Skip to content

release: v0.4.26 — blue-green deploy correctness, Antigravity quota buckets, Command Code - #911

Merged
kittors merged 30 commits into
mainfrom
dev
Aug 19, 2026
Merged

release: v0.4.26 — blue-green deploy correctness, Antigravity quota buckets, Command Code#911
kittors merged 30 commits into
mainfrom
dev

Conversation

@kittors

@kittors kittors commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Promotes the verified dev branch to main for v0.4.26.

What matters most in this release

The blue-green cutover had never actually worked on this host. switch_nginx_port rewrote the nginx config with perl, which is not installed there. Because the call sits inside if ! switch_nginx_port ..., set -e is suspended, so the missing binary stopped nothing — the following nginx -t passed (the config was unchanged and therefore still valid), reload succeeded, and the function returned 0.

Every deploy reported a successful cutover that had edited nothing, advancing .active-port while 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

Compatibility and upgrade notes

Action required for self-hosted deployments.

  • Deploy scripts must be synced to the host by hand. SCRIPT_VERSION moves 2026.07.162026.08.19.3 and 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 until scripts/deploy-blue-green.sh and scripts/reconcile-active-slot.sh are copied over.
  • DRAIN_SECONDS 35 → 180 and TimeoutStopSec 90 → 300. Two slots now overlap for about three minutes per deploy; verify the host has headroom for a second process.
  • CLIRELAY_SHUTDOWN_GRACE is new, defaults to 5 minutes, and is exported into the unit by the deploy script.
  • Antigravity quota keys change from provider:* to antigravity:*. The panel maps previously cached rows on read, so no data migration is needed.
  • Pairs with codeProxy v0.4.26.

Verification

  • All 12 merged dev PRs passed build / vulncheck / ensure-no-translator-changes.
  • Deploy pipeline exercised four times end to end after the fixes: nginx port switched correctly each time, and a continuous probe across the full 180s drain window returned 200 on every request.
  • Production confirmed on the released code: three state sources agree, readyz 204, and Command Code requests went from failing with zero tokens to succeeding with normal token counts.

🤖 Generated with Claude Code

kittors and others added 30 commits August 14, 2026 22:24
…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
@kittors
kittors merged commit 4e85cd4 into main Aug 19, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant