Conversation
chore: sync main to dev after v0.4.25
…ma cache reads Two console complaints with one root in how a request's model identity is recorded. Aliased providers logged every request as "requested X, actually used Y". An API-key account alias only adds a routing segment — Ollama Cloud exposes `deepseek-v4-flash:0731` as `ollama/deepseek-v4-flash:0731` — so the request log carried both names and the panel raised a "real model ID" hint on every single row. Add sameModelIdentity and keep the invariant that upstream/vision names on a usage record are genuinely different models; renaming aliases (`fast` -> `claude-sonnet-4`) still carry information and are still recorded. Ollama Cloud also reported 0 cached tokens on every request (985 of 985 in the last week on the live deployment, while every other channel reports cache hits). Ollama never publishes cache counters: native /api/chat returns only prompt_eval_count, and the OpenAI- and Anthropic-compatible endpoints mirror it. The existing session-prefix estimate only ran on the native path, so Claude-format clients — which reach /v1/messages — never got one, and it needed a client session id to key on. Move the estimate to one per-request estimator installed at the executor entry points, shared by all three paths and applied in the usage reporter only when the upstream reported nothing. Without a session id, key on a fingerprint of the prompt opening, which is stable across turns and distinct between conversations. A retried request replays its original estimate instead of scoring against its own stored prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(usage): stop logging alias-only upstream models and estimate Ollama cache reads
The catalog carried no video model at all, so neither the API nor the console could reach Grok Imagine video — an xAI account listed four models, none of them video. Adds the model to the static catalog and classifies it in its own registry rather than folding it into the image one: the two share the "grok-imagine-" prefix but video is asynchronous, takes a different argument set, and lands on a different upstream path. Reusing the image classifier would have made /images/* start accepting a model it cannot serve. Public API keeps the upstream's two-step shape: POST /v1/videos/generations answers with a request id and GET /v1/videos/:request_id polls it. Collapsing that into one blocking call is not viable — a clip renders for minutes, past any client timeout. Because a request id only resolves for the credential that created it, submissions are pinned to their provider in an in-memory registry; an id the process no longer knows gets an explicit "not known to this server" rather than a poll against the wrong account. The console has the opposite need — one thing to watch — so the management test task absorbs the upstream polling and completes when the clip is ready, reusing the async task primitive the image test already has. Also fixes a gap found while reading the image path: successful Grok Imagine image requests were never recorded in the request log, because xAI returns no token counts and a token-shaped record is dropped as empty. On the live deployment that left 151 gpt-image-2 rows and zero Grok ones. Both media paths now record the call itself, with cost coming from per-call pricing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e smoke The smoke walks every management route with placeholder ids against a handler whose auth manager is unavailable, so both media test services answer 503. The image one was already allowed; video needs the same entry or the route fails a check that is only asserting the route is registered and reachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(video): serve Grok Imagine video generation end to end
…gated Three failures with three different causes, all visible from the console. "auth_not_found: no auth available" — an xAI credential's routable models come from the upstream /models listing, which never reports the Imagine models: they live on the media host and are not discoverable. The image models were already merged into every credential's model set for exactly this reason, with a comment saying so; video was added to the static catalog only, so the router had no credential that served it. Video now goes through the same merge. "model 'grok-imagine-video-1.5' is not in the allowed models of channel group" — the model library seed walked a hardcoded channel list that omitted xai entirely, so no Grok model has ever reached the library. The channel-group editor and the pricing table read the library, which means the operator could not add the model to the group the error told them to fix. xai joins the list, and media rows are seeded with per-call pricing and their real modalities. Rows that predate the catalog entry are imported as 'legacy-pricing', which the library scope filters out and INSERT OR IGNORE will not update. They are promoted to 'seed' across every tenant; 'user' rows keep operator pricing and 'openrouter' rows already surface. The console also stopped guessing: /video-generation/models now reports, per effective tenant, which channels can serve each model, so the page can disable generation and say what is missing instead of letting the request fail in the router. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed helpers Keeps store.go under its frozen line budget while the xai entry is added; the list now sits next to the code that interprets it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(video): make Grok video models routable, selectable and honestly gated
…n usage Command Code sells a 55-model catalog behind an OpenAI-compatible Provider API, so routing needs no new transport: the auth carries compat metadata and the shared OpenAICompatExecutor serves it, the same way Cline is served. Two things are worth knowing about this upstream. The plan tier is an entitlement, not a credential concern. An account on the $1 Go plan mints a working key and still gets 403 upgrade_required from /provider/v1, because that plan excludes API access; GOAT and above work. The refusal is surfaced as-is rather than retried or masked, so an operator sees what the gateway actually said. Plan usage needs no dashboard cookie. Unlike the OpenCode Go, Cline and Ollama Cloud checks — which scrape a console page or call a dashboard API with a browser cookie that expires — Command Code reports its 5-hour and weekly windows from an endpoint that authenticates with the same API key used for inference. That endpoint is not in the published reference, so every failure degrades to "usage unavailable" and never to a credential error: a key that cannot read credits still serves requests. resetAt arrives in seconds or milliseconds depending on the field, and is normalized on read. Model discovery follows the Cline precedent. The catalog endpoint needs no credential at all, so the console fetches the live list and the static snapshot only covers the offline case; an operator running ahead of that snapshot can name a newer model explicitly and it routes without a code change. Three of the touched files are frozen by the structure ratchet, so the provider settings, the management handlers and the model snapshot each live in their own new file rather than growing the file they belong beside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six tests over the two hops a new channel actually fails at. Synthesis: the auth must carry the compat metadata, or nothing selects an executor for it; a blank credential must not produce an auth; a disabled one must not produce an active auth; and a mirrored base URL must survive, since that attribute is the endpoint the executor reads. Routing: the auth must land on the shared OpenAI-compatible executor rather than falling through; with no configured models the static catalog serves; with them, they win — which is how an operator routes a model Command Code shipped after this build — and cline-pass ids stay out, since they belong to another channel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(commandcode): add Command Code as a first-class channel with plan usage
Two checks fail on every PR opened against dev right now, neither caused by the branch under review. TestUsageRollupSurvivesDetailDelete seeded its rollup at a hard-coded 2026-07-20 and then queried the last 30 days. It passed until the calendar walked past that window; from 2026-08-19 it fails on every run and would never have recovered. Seed relative to now, the way the test directly below it already does. govulncheck reports GO-2026-6213 and one other advisory against go-git v6.0.0-alpha.4, reachable from internal/store/gitstore.go. These were published after the last green run, which is why a check that passed on #901 now fails without any code having changed. Upgrade to alpha.5, which carries the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(ci): unblock dev by defusing a dated test and patching go-git
The quota probe announced client version 1.11.5 while the request executor announced 1.104.0. The upstream gates its answer on that header, so the probe was served a coarser quota view than the account actually has — three model families came back sharing one percentage and one reset time because they were, upstream, a single bucket. Three further sources of wrong numbers sat on top of it: - The probe queried under a hard-coded shared project id. Quota is reported per project, so an account with no project of its own read back that project's remaining fraction, and an exhausted account could report 100% available. - Models were grouped by a hand-written id list. A model missing from it was not shown as unknown, it was dropped — new families rendered as nothing at all. - The weekly figure fell through to "whichever window came first", which for this provider is a 5h window, so the card reported a number for a cycle it never measured. Read retrieveUserQuotaSummary for the upstream's own weekly and 5h buckets, keeping its grouping and its wording, and fall back to fetchAvailableModels when it is unavailable. Resolve the account's own project through loadCodeAssist and take the subscription tier from the same response, which this provider never reported before. Retry once without the project field on a 403, since a project the account cannot read is rejected outright. Classify models by the shape of their id and keep the unclassified ones under their own name. weeklyUsedFromQuotas now matches by window width when a provider declares no primary key. Providers that declare one — claude, codex, kimi, xai — keep exact-match behaviour, covered by a regression test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(antigravity): read the account's real quota buckets
A failed deploy took production down today. The deploy did not break the new binary — it passed its readiness check — it broke the site by stopping the slot nginx was serving from. Three state sources describe which slot is live: .active-port, systemd, and nginx. Only nginx decides where traffic actually goes, and it was the one source the deploy never consulted. reconcile-active-slot.sh compares the first two and stops there. They had drifted: .active-port said 8319 while nginx routed to 8318. From there the outage was mechanical. switch_nginx_port greps the config for the recorded port, 8319 was not in it, so cutover "failed" — and the EXIT trap then stopped the candidate slot, which under drift is 8318, the one serving every request. Two deploys produced byte-identical logs because the state guaranteeing the failure never changed. Two changes: - Resolve the routed slot before any slot arithmetic and reconcile against it. When the recorded and routed slots disagree and the routed slot is running, nginx wins and .active-port is corrected. Replaying today's state through this turns the outage into a normal deploy: active becomes 8318, cutover goes 8318 -> 8319, and the grep matches. - Refuse to stop the candidate slot when nginx is routing to it. A stray slot left running is cheap; taking production down to tidy it up is not. SCRIPT_VERSION and the workflow's EXPECTED_SCRIPT_VERSION both move to 2026.08.19. The version gate means a server still carrying the old script fails the deploy immediately, before touching any service, so the script must be synced to the host before this merges — GHA deliberately does not upload it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(deploy): let nginx arbitrate the active slot, and never stop it
The cutover never worked on this host. The log line that explains every incident today: /opt/clirelay2/scripts/deploy-blue-green.sh: line 317: perl: command not found switch_nginx_port rewrote the config with perl, which is not installed here. Because the call sits inside `if ! switch_nginx_port ...`, set -e is suspended, so the missing binary did not stop anything. The following `nginx -t` passed (the config was unchanged and therefore still valid) and `nginx -s reload` succeeded, so the function returned 0 and the deploy reported a successful cutover that had edited nothing. Every deploy then advanced .active-port while nginx stayed on the old slot. That is the drift, and it was manufactured fresh on each deploy rather than inherited from the server migration as previously assumed. The drift is what let a later deploy stop the slot that was serving production, and it is why the site 502'd twice today. - Rewrite with sed, then prove the edit: the old port must be gone from the file and the new one present. An absent source port is a failure, not a no-op success. - End switch_nginx_port by re-reading the routed port and requiring it to equal the requested one, so a cutover can only be reported when nginx is demonstrably serving the new slot. - Give every step in the function an explicit `|| return 1` instead of relying on set -e, which the call site disables. - Fail at startup when sed/grep/systemctl/nginx are missing. A missing tool must not be indistinguishable from a working deploy. - Repair nginx when it points at a slot that is not running while a healthy slot exists. That state is an outage in progress; the deploy now fixes it before proceeding instead of stepping around it. Verified on the deploy host against a copy of the live config: 8318->8319 rewrites and verifies, and a rewrite from a port that is not present correctly fails instead of reporting success. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(deploy): rewrite the nginx port with sed, and verify it happened
Every Claude Code request that disabled thinking failed against the
Command Code channel with
400 {"error":{"message":"Invalid option: expected one of
\"low\"|\"medium\"|\"high\"|\"xhigh\"|\"max\"",
"param":"reasoning_effort"}}
`none` and `auto` are markers this codebase uses internally to mean "do
not think" and "let the model decide". Neither is part of the OpenAI wire
format. Upstreams disagree about them — opencode go tolerates `none`
while Command Code rejects it outright — so they must not be sent to a
model whose accepted levels are unknown.
The model-aware path already knew this. Its ModeNone branch refuses to
emit `none` unless the model declares it, and its comment says so: "fall
back to lowest level so upstream does not 400 on 'none'". Two paths
around it did not:
- applyCompatibleOpenAI, taken for user-defined models, set `none` and
`auto` unconditionally. Command Code's models are registered from
config and carry no thinking declaration, so they land here.
- Apply returned the body untouched when a model declares no thinking
support, forwarding whatever was already there. The Claude→OpenAI
translator writes `reasoning_effort: "none"` for
`thinking.type: "disabled"` before this applier runs, so that value
reached the wire unexamined.
Both now drop the field instead. Omitting reasoning_effort is how the
OpenAI wire format says "use the model default", and it is accepted
everywhere. Real levels — including a budget that maps onto one — are
forwarded unchanged, and a model that explicitly declares `none` among
its levels still receives it.
Behaviour note: channels that currently succeed while sending `none`
stop sending it, so those requests fall back to the upstream's default
thinking behaviour rather than an explicit off. Declaring the accepted
levels on such a model restores exact control through the model-aware
path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two matrix cases pinned the behaviour this fixes: a user-defined model with budget 0 was expected to receive reasoning_effort "none", and one with budget -1 to receive "auto". Those are the exact values Command Code rejects with 400. Only the wire value changes. The intent still travels: budget 0 is still read as ModeNone and budget -1 as ModeAuto, and models that declare their accepted levels still get an exact level. What no longer happens is putting an internal marker on the wire for a model that never said it would accept one. Dropping the marker rather than the whole signal matters — Case 71 shows that a request arriving with no thinking information at all gets a default of "medium" injected, so silently discarding the signal would turn "do not think" into "think moderately". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestCommandCodeResetInAcceptsSecondsAndMilliseconds failed on roughly half of all runs and has blocked three unrelated PRs today. It is not load-sensitive; it is arithmetic. The test built its input from time.Now(). Unix() truncates the sub-second part while UnixMilli() keeps it, so the two inputs described instants up to a second apart. With the target two hours out, that straddled a rounding step: one side rendered "2 hours", the other "1 hour 59 minutes". Whether it failed depended on the fractional part of the clock when the test started. commandCodeResetIn now delegates to commandCodeResetInAt, which takes the reference instant as an argument. Reading the clock inside a formatter makes its output depend on when it is called, which cannot be pinned down at the boundaries. The test's premise is also corrected. Seconds and milliseconds only describe the same instant when resetAt is second-aligned; when it is not, the seconds form has already discarded information and the two genuinely differ. The original assertion demanded that a lossy conversion be lossless. The replacement fixes resetAt on a second and sweeps "now" across sub-second offsets instead, which is the property that actually has to hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(thinking): stop sending internal effort markers to compat upstreams
Cutover no longer 502s, but it still cut requests off. A retiring slot had 35s of drain plus 30s of graceful shutdown — 65 seconds total. This proxy fronts LLMs, where a single streamed answer routinely runs longer than that, so every deploy severed whatever was still streaming. The deploy reported success; the user saw a dropped connection. The 30s was written twice, and the second one was the binding constraint. Run created a 30s shutdown context, and Shutdown then wrapped the context it was handed in *another* fixed 30s before calling server.Stop. Widening only the first would have changed nothing. - Shutdown honours the caller's deadline instead of clamping it, and only imposes its own bound when handed a context without one. - The window is CLIRELAY_SHUTDOWN_GRACE, defaulting to 5 minutes. An unparseable or non-positive value falls back to the default rather than to zero, which would sever every request instead of none. - DRAIN_SECONDS 35 -> 180 and TimeoutStopSec 90 -> 300, with the unit now exporting CLIRELAY_SHUTDOWN_GRACE so systemd cannot kill the process while it is still finishing work. Draining is scheduled asynchronously via systemd-run, so a longer window costs one idle process for a few minutes, not deploy time. The grace period is an upper bound, not a delay: shutdown returns as soon as the last in-flight request completes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TimeoutStopSec is no longer a literal 90, so the existing guard had to move to the variable. While editing it, the guard list gained the properties today's incidents showed were worth pinning: - rewrite_slot_port and nginx_slot_port must exist. The cutover has to verify the rewrite landed and re-read the routed port, rather than inferring success from an exit status. - The missing-tool check must stay. A rewrite tool that is absent from the host must fail the deploy, not silently skip the rewrite. - "refusing to stop" must stay. The failure path must never stop the slot nginx is routing to. - perl -0pi is now forbidden outright. It is not installed on the deploy host; using it is what let a cutover report success while editing nothing, which manufactured the slot drift behind two outages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(deploy): stop severing in-flight streams during blue-green cutover
Both halves of this provider call CloudCode, and each carried its own User-Agent literal: the request executor advertised antigravity/1.104.0 darwin/arm64 while the account status probe advertised antigravity/1.11.5 windows/amd64. One account therefore looked like two different clients depending on which half was asking. CloudCode gates on that header. The probe, on the older of the two, was served a reduced model set and a coarser quota view than the account actually had — three model families reporting one identical percentage and reset time, because upstream they were a single bucket the older client could not see broken down. Both now derive from antigravity.ClientUserAgent, built from a single ClientVersion. The OAuth identity stays separate on purpose: token and userinfo calls go to Google's OAuth endpoints rather than CloudCode, and google-api-nodejs-client is the correct client for those. Tests pin the properties rather than the strings: the version may not slide below the known floor, the User-Agent must carry it, the OAuth and CloudCode identities must stay distinct, and the executor must resolve to the shared constant so a local literal cannot be reintroduced. reconcile-active-slot.sh gains a note that nginx is deliberately outside its scope, so a green result from it is not read as "the deployment is consistent" — deploy-blue-green.sh is what reconciles against nginx. Not done here: folding this into internal/identityfingerprint. That package is a learning system driven by observed client headers, and it is currently codex-specific. A server-initiated probe has no client request to learn from, so the fit is poor, and #884 is actively reworking those files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(antigravity): give the executor and the probe one client identity
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes the verified
devbranch tomainfor v0.4.26.What matters most in this release
The blue-green cutover had never actually worked on this host.
switch_nginx_portrewrote the nginx config withperl, which is not installed there. Because the call sits insideif ! switch_nginx_port ...,set -eis suspended, so the missing binary stopped nothing — the followingnginx -tpassed (the config was unchanged and therefore still valid),reloadsucceeded, and the function returned 0.Every deploy reported a successful cutover that had edited nothing, advancing
.active-portwhile nginx stayed on the old slot. That manufactured drift took production down twice on 2026-08-19: a later deploy's failure path stopped the slot that was actually serving traffic.Three PRs address it end to end (#907, #908, #909), and the invariants are now pinned by tests.
Key fixes and improvements
sed, then verify the old port is gone and the new one present; re-read the routed port before reporting success; fail at startup when a required tool is missing.retrieveUserQuotaSummaryfor the real weekly/5h buckets instead of inventing a model-family split, resolves the account's own project vialoadCodeAssist, and reportsplan_typefor the first time.reasoning_effortno longer leaks to compat upstreams (fix(thinking): stop sending internal effort markers to compat upstreams #906): internal effort markers are omitted for capability-unknown models, which was returning 400 on Command Code.go-gitadvisories published after the last green run.Compatibility and upgrade notes
SCRIPT_VERSIONmoves2026.07.16→2026.08.19.3and the workflow gate expects the new value. GitHub Actions deliberately does not upload these scripts, so a host still carrying the old copy fails the deploy at the version check — a safe failure that touches no service, but the deploy will not proceed untilscripts/deploy-blue-green.shandscripts/reconcile-active-slot.share copied over.DRAIN_SECONDS35 → 180 andTimeoutStopSec90 → 300. Two slots now overlap for about three minutes per deploy; verify the host has headroom for a second process.CLIRELAY_SHUTDOWN_GRACEis new, defaults to 5 minutes, and is exported into the unit by the deploy script.provider:*toantigravity:*. The panel maps previously cached rows on read, so no data migration is needed.Verification
devPRs passedbuild/vulncheck/ensure-no-translator-changes.readyz204, and Command Code requests went from failing with zero tokens to succeeding with normal token counts.🤖 Generated with Claude Code