Repository navigation
fix: make the Anthropic surface conform to the real SDK wire contract (#1274, #1260, #1261, #1259) - #1296
Conversation
Four defects found by running the published anthropic 1.2.0 Python SDK against a live edge-api, in two root-cause families. Family one, Go zero-value JSON encoding dropping values Anthropic requires to be present. Issue #1274, the one that broke every streaming call. Anthropic emits content_block_start for a text block as {"type":"text","text":""}, verified on 2026-08-28 against the live streaming specification. The struct carried `omitempty` on a plain Go string, which drops the key for the empty string exactly as readily as for "unset", so the key never shipped. The SDK's own stream accumulator seeds its snapshot from that field and then runs content.text += delta.text on the first delta, which made a None += str TypeError on every text response consumed through the documented messages.stream() helper. Text is now a pointer so the empty value serializes. The same specification shows a tool_use block start carrying "input":{}, which was dropped by the same mechanism, so that is fixed in the same struct; the Python accumulator survives that one because it assigns input from its own partial-JSON buffer rather than reading it at block start, but the wire shape was wrong regardless. Issue #1260. FromOAIResponse left Content as a nil slice for a turn that produced neither text nor a tool call, and a nil slice marshals to JSON null rather than []. Anthropic's contract is that content is always an array, so a typed client iterating it got a TypeError instead of an empty turn. The empty slice is now seeded at construction, which covers both exits from that function rather than only the one the report named. Family two, an auth model that only ever covered POST /v1/messages. Issue #1261. count_tokens is the one route on this surface that does not delegate to the chat chain, so it recognised a JWT session principal and nothing else. An hk_ request is routed past the JWT middleware by auth.Selector and therefore carries no session user at all, which made every API-key caller a 401 on that route while the sibling /v1/messages accepted the identical key on the identical connection. The handler now takes an optional API-key authority and accepts either principal. Nil leaves it session-only and fail-closed, so no deployment silently loses the guard. The refusal is the authorizer's own verdict reshaped into the Anthropic envelope, reusing the shared status mapping rather than duplicating it. Issue #1259. GET /v1/models is now registered through a named modelsHandler that applies APIKeyNormalizer at the leaf, as /v1/messages already did, so an x-api-key credential is rewritten even on a deployment where JWT auth is unwired and authSelectorMiddleware never mounts. It also applies a compat wrapper that answers an Anthropic-shaped caller with the Anthropic list envelope and the Anthropic error envelope, verified against the live models-list specification. Detection is on anthropic-version or x-api-key, so an OpenAI-shaped caller, Open WebUI included, keeps the byte-identical body it always had. The translation carries id, display name and created_at only, dropping owned_by, so it strictly reduces the provider-blind leak surface rather than widening it. Every added test was mutation-checked: each fix was reverted in turn, the corresponding test observed failing for the right reason, then restored and observed passing.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Review limit reachedNext included review available in 40 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (12)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
sakibsadmanshajib
left a comment
There was a problem hiding this comment.
Reviewed this as the mandatory security pass, since it changes authentication on two routes. I verified the four fixes against the live specifications and the SDK source myself rather than re-deriving your work, and I traced the auth question end to end. Summary of where I landed, with detail in the inline comments.
Authority is not widened. For count_tokens I followed an hk_ request all the way through: auth.Selector sends it past the JWT middleware with no session user, the leaf APIKeyNormalizer on /v1/messages/ has already rewritten x-api-key into Authorization, and the new closure hands that header to the same authorizer.Authorize the rest of edge-api uses. CheckAccess still enforces key status, expiry and budget on that call; only the model allowlist is skipped, and only because aliasID is empty, which is exactly the precedent handleModels set for /v1/models. The limiter still runs. An invalid or revoked hk_ key gets a 401 carrying the authorizer's own message, which I confirmed is the authorizer's verdict and not a blanket string. What the route grants an API key is a len/4 estimate computed locally from the caller's own request body, with no catalog lookup, no tenant read and no dispatch, so there is no data an API key can reach here that it could not already reach. Nil AuthorizeAPIKey stays session only. For /v1/models the leaf normalizer changes the spelling of the credential, nothing else: same authorizer, same tenant filtered snapshot, and on a deployment with no JWT wiring the route is still fully authenticated because handleModels fails closed on an unresolvable tenant.
The client discriminator cannot be spoofed into revealing anything different. Both branches serve the same authorized snapshot.Models for the same principal, and the Anthropic branch carries a strict subset of the fields. Setting anthropic-version on a session request flips the shape but reveals strictly less. The OpenAI path is genuinely untouched: I grepped every in-repo consumer and nothing outside this PR's own tests sends anthropic-version or x-api-key at /v1/models, so Open WebUI, the console and the OpenAI SDK conformance suite all keep the byte-identical body. One caveat about intermediary caching in an inline comment.
Provider blindness holds, and I confirmed it independently. ModelsCompat reads only id, created and name. owned_by is dropped, and so is description, which is where the live leak in #1284 actually sits (hive-stt and hive-tts name Groq verbatim in model_aliases.summary). So the Anthropic branch is immune to that leak by construction. Please do not let this merge read as closing #1284 though: the OpenAI branch still ships those descriptions to every Open WebUI and OpenAI SDK caller, so that issue stays open. One forward looking risk on display_name inline.
The non-guard labelling is honest but the count is short by two. Both tests you named really are non-defect guards, and both really do go red under the inverse mutation, so they earn their place. Two more added tests are in the same category and are not named. Detail inline.
The declined scope is the right call. I checked it against the live specification rather than plausibility. The streaming page describes "a series of content blocks" with no stated minimum of one, emitMessageStart really does set Content: []string{} so message_start carries "content":[] on the wire, and the SDK accumulator appends blocks into that list and never indexes it eagerly, so a turn with zero blocks folds back into an empty array with no error. I did find one genuinely unhandled sequence next door to it, which is pre-existing rather than something you introduced. Inline, with a suggestion to file it rather than widen this PR.
On the review streams themselves, so the absences are not read as passes:
- CodeRabbit: SKIPPED. Rate limited repo wide. The bot comment on this PR says "Review limit reached" and the check reports zero duration, and the CLI is limited on the same account. Not a clean pass.
- Codex adversarial review: SKIPPED. Over quota. The connector posted "You have reached your Codex usage limits for code reviews" on this PR.
- Everything else ran: the security pass, a Go read, the plain adversarial pass, and a downstream trace through
authz,catalog, the alias seed migrations and the Anthropic SDK accumulator source.
Nothing here blocks the fixes. The one I would like addressed before merge is the dropped retry headers, since it is a small change and it quietly undoes a contract another PR added on purpose.
…e finishing Review findings on PR #1296, all three in the Anthropic surface. Recording a refusal in headerlessRecorder and reshaping only its body threw away the header half of that refusal. WriteAuthFailure is the shared source of truth precisely so a retryable 429 is never collapsed into a non-retryable refusal, and it delivers the retryable part through headers: retry-after plus the x-ratelimit-* family on a real 429, and retry-after on both the degraded limiter and the upstream_unavailable branches. count_tokens dropped all of them, and GET /v1/models did the same one layer out, which also left the two client shapes disagreeing about retry metadata for the same refusal on the same route. headerlessRecorder.reshapeInto now carries the headers over and reshapes in one call, so a call site cannot take one half without the other. Content-Type and Content-Length are deliberately not carried: the reshaped body is a different envelope of a different length, and a stale Content-Length would truncate it on the wire. Nothing forwarded carries provider identity. GET /v1/models now serves two representations for one URL, chosen by request headers, so it declares Vary on both branches before either writes. Nothing on the route sets Cache-Control and the route needs a credential, so no correct cache stores it today; the declaration is what keeps a later edge cache, or an intermediary keying on URL alone, from handing an Anthropic-shaped body to Open WebUI and emptying its model picker. SSETranslator.Finish emitted message_delta and message_stop without checking whether message_start had ever fired, so an upstream stream that yielded no parseable chunk produced a sequence the SDK accumulator rejects for unexpected event order. Finish now calls the same ensureStarted the first chunk does, which costs two events on an empty stream and emits message_start exactly once on every other one.
… the models wrapper Both findings from the adversarial review of the previous commit. ModelsCompat set Vary rather than adding it. Nothing in edge-api declares a Vary on this route today, so overwriting was harmless in the current tree and wrong the moment an outer middleware declares one of its own, a CORS layer setting Vary: Origin being the obvious case. Add keeps the wrapper additive. The 200 branch of the same wrapper dropped every header the delegated handler set. That path re-encodes the body rather than reshaping it, which is why the carry-over the refusal path had just gained was easy to leave out of it, but a header set alongside a success is as much part of that response as the body is, and this route is exactly where a 2xx rate-limit budget would surface. The header copy is now its own method on the recorder and both branches call it.
## Summary This is the batched buglog follow-up for the 21 pull requests merged during the 2026-08-28 session. Its diff is `.wolf/buglog.jsonl` and nothing else. Per `.claude/rules/openwolf.md`, every fixed bug, error, failed test or failed build must be logged, but the line may never be appended on a fix branch: `merge=union` in `.gitattributes` resolves concurrent appends locally and is ignored by GitHub's server side merge, so two branches that both appended land in hard conflict there. An unmergeable pull request gets no `refs/pull/N/merge`, no `pull_request` run and therefore zero checks, and the required status gate then blocks the merge for a reason the page never states (issue #873). Each fix accordingly carried its entry in its own pull request body, and this pull request copies them onto `main` in one batch, which the protocol explicitly prefers over one pull request per entry. ## Source pull requests 1240, 1251, 1253, 1257, 1268, 1276, 1277, 1278, 1281, 1287, 1292, 1293, 1294, 1296, 1300, 1301, 1303, 1305, 1313, 1335, 1337. All merged. Every entry came from a "Buglog entry" heading in one of those bodies. Nothing was invented for a pull request that carried none. ## What landed 36 entries appended, one JSON object per line, append only. The 196 pre-existing lines are byte identical to `origin/main`. | Source | Entries | |---|---| | #1240 | 1 | | #1251 | 1 | | #1253 | 1 | | #1257 | 4 | | #1268 | 5 | | #1276 | 2 | | #1277 | 1 (of 2 in the body) | | #1278 | 0 (merged into #1296) | | #1281 | 2 | | #1287 | 2 | | #1292 | 3 | | #1293 | 1 | | #1294 | 3 | | #1296 | 1 | | #1300 | 1 | | #1301 | 1 | | #1303 | 1 | | #1305 | 3 | | #1313 | 1 | | #1335 | 1 | | #1337 | 1 | Note on #1268: its first "Buglog entry" heading says "None yet" in prose and carries no JSON. Its two later headings, from the CI live lane and from the intermittent tool call failure, carry the five entries taken here. ## Deduplication - **#1278 dropped, folded into #1296.** Both describe the same defect: `omitempty` on `StreamContentBlock.Text` dropped the required `"text":""` from every text `content_block_start`, crashing the real Anthropic SDK's stream accumulator (issue #1274). #1278 is the conformance suite that found it and shipped it marked xfail; #1296 is the fix, and its entry carries the fuller root cause and the actual remedy. One bug, one entry. #1296's entry gains a `discovered_by` field naming #1278 so the discovery is not lost. - **#1277's first entry dropped.** The same body carries a later "Buglog entry (revised)" heading written after the review round found the page's claims did not match what the code enforces. The revised entry is the one taken. - Checked and kept as distinct: #1313 and #1337 are two different hooks (`decision-citation-check.js` and `secrets-scanner.js`) blind to the same MultiEdit payload shape, fixed in two different pull requests, so two entries. #1240 and #1335 are two different `account_not_provisioned` defects, one an observability gap at the edge boundary and one a console mint that should have refused, so two entries. #1305's three entries are three separate rounds of defects in the same money path change, each with its own root cause. ## Corrections against what actually merged Each entry was checked against the merged tree at `origin/main`, not against its own claim. - **#1240.** The entry said the log line went in at `AuthSnapshot.TenantUUID`. On `main` the check is the exported `authz.ParseTenantID(TenantLookup)` that `TenantUUID` delegates to, which the images and audio routing adapters (two further silent call sites found in the same review) also call, and `key_id` is deliberately not logged because CodeQL's clear text logging check flags any field named `*Key*` (alert #31). The `fix` field now says so. - **#1276, first entry.** The entry named `app/console/analytics/page.tsx` as the home of the five fetch helpers and their `Promise.all`. On `main` they live in `apps/web-console/lib/analytics/overview-fetch.ts`, extracted during review. Path corrected. - **#1277.** Its `error_message` was the placeholder `n/a`. Reconstructed from the pull request's own correction narrative: the page as first written published a blanket no content stored claim false for `/v1/batches`, `/v1/files` and `/v1/rag`, a product wide provider blindness claim disproved by catalogue summaries that name vendors (#1284), a metering claim anchored to the console side `UsageEventRow` projection rather than the `usage_events` table, and a 1:1 alias to route claim that is a property of seed data rather than of `SelectRoute`. **#1303 needed no correction.** Its original root cause asserted a live mid stream provider leak on the session chat relay that measurement disproved, and the author had already corrected the body before merge. The corrected version is what was taken, including the sentence recording that the session chat relay did not leak an error frame but silently truncated instead. Every other entry's central claim was verified present in the merged tree, among them `metering.SupportsIncludeUsage`, `sanitize.VariablePriceFrame` in the batch dispatcher, the revoke and regrant in `20260828_01_service_role_public_schema_grant.sql` with the `anon` assertions in `ci-throwaway-db.sh`, `normalizeReasoningUsage` now called from `normalizeChatCompletion`, `signup.SyncTenantMembershipRole`, `TestKeyViewHidesALimitThatIsNotEnforced`, `TestListEventsLatencyCrossesTheWire`, `mask-api-keys.mjs` and `md-table.mjs`, `StreamContentBlock.Text` as `*string`, `redactSnapshot`, `httpx.ReadBody`, `sanitize.ReplaceErrorFrame` with the default deny tail in `provider_blind.go`, `pinCompletionCeiling` and `captureInputTokens` with `applyReasoningHeadroom` gone, `requireBillingTenant`, and `hooks.selfcheck.js` wired into the Repo policy lints check. ## Verification - `node .wolf/hooks/bugstore.selfcheck.js` reports `bugstore selfcheck OK`. - All 232 lines parse as a single JSON object each. - Every appended entry carries `error_message`, `root_cause`, `fix` and `tags`. - Scanned for credentials: no API key, bearer token, JWT, password, AWS key or Postgres DSN with a password appears in any entry. The `hk_` occurrences are prefix descriptions in prose, not keys. - `git diff origin/main...HEAD --name-only` prints `.wolf/buglog.jsonl` and nothing else. No `.wolf/` telemetry was staged. ## Review No adversarial review streams were run, deliberately. This change is records only: it adds no code, no test, no configuration and no behavior, and `.wolf/buglog.jsonl` is on the inert path allowlist in `.github/workflows/ci.yml`, so the six required checks report green without running their heavy steps. If a check does fail here, that is a real signal about the file rather than about the pipeline. https://claude.ai/code/session_01WyEwUxZCArdn1ZUDkTvuQ1 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Same defect family as the three markers cleared in #1383: a marker that outlived the bug it tracked and now renders as a red XPASS(strict) reading "Expect test to fail". Issue #1274 was content_block_start omitting an empty `text` field, because `json:"text,omitempty"` drops a Go string's empty value as readily for the required-but-empty case as for unset. The Anthropic SDK seeds its accumulator snapshot from that field and then does `content.text += delta.text`, so a missing key made that a `None += str` TypeError on every streamed text response. PR #1296 (commit 0b4e5ff) fixed it by making Text a pointer so the value reaches the wire, and the issue is closed. Not cleared on a single green sample. The test reported XPASS(strict) on five independent CI runs across five commits (33244935750, 33246016113, 33247050365, 33247571957, 33248088001), two of them after rebasing onto the classifier fix in #1410, with no run in which it failed legitimately.
) (#1390) Fixes #1324. Fixes #1380. Diagnoses #1370 and leaves it open with the cause recorded. ## Was restoring this coverage worth it? It caught a real wire-format bug on its first sighted run. That is the short answer to the only question that matters here, and it is evidence rather than principle. A suite that had been blind for four months was given a real object store to talk to. The first thing it found was that **every signed S3 request this product makes was going out in absolute form** (`PUT http://host/s3/bucket/key HTTP/1.1`), which RFC 9112 reserves for requests **to a proxy**. An origin server expects `PUT /s3/bucket/key`. The cause was `URL.Opaque`, set for the SigV4 signer and never cleared, which `net/url.RequestURI` returns verbatim with the scheme glued back on. It was invisible on the deployed box for one reason only: every S3 request there passes through Caddy, which normalizes the target before proxying it on, so Supabase Storage never saw the malformed form. Reached directly, Storage refuses it with a 500 that names neither the path nor the credential. Nothing in four months of green CI could have found this, because CI was pointed at a hostname that stopped resolving in August. Details, the wire capture, and the fix are in the pinned comment on this pull request. ## What was actually wrong, and it is one thing rather than three The `S3_ENDPOINT` repository secret was last written on `2026-04-21T23:15:49Z`, four months before the August self-hosted Supabase cutover, and still names the Supabase Cloud project this repository left. That project is deleted, so the hostname does not resolve. Confirmed with `gh secret list`, along with the rest of that April-vintage family: `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`, `S3_BUCKET_FILES`, `S3_BUCKET_IMAGES`, `SUPABASE_URL`, `SUPABASE_DB_HOST`. **#1324.** `live-integration` reads those six at job level (`ci.yml:1221`), so every `POST /v1/files` died at the socket. The Files and Batches conformance tests were carried as `it.fails` markers, which means the suite reported green for four months while covering nothing. **#1380 names the wrong job, and this is worth stating plainly rather than quietly fixing.** `ci.yml:2026` and `ci.yml:2565` are `web-e2e` and `interaction-coverage`, both Playwright. Neither runs `sdk-tests-js`; those run in `live-integration` (`ci.yml:1612`), which was reading the dead secret. So #1380's conclusion, that CI cannot fail on the storage path, is correct, but its cause is #1324's secret and not the discard port. I read the comment at `ci.yml:2016` before touching either line, as asked, and its author's three reasons all survive scrutiny, so the stub stays and the comment now names the job that does exercise storage. **#1370 is a third victim of the same secret family.** The nightly does not report `skipped` at run level: every scheduled run since 2026-08-09 reports `failure` (runs 33197858823, 33097409194, 32939092305, 32817812570, 32698750004, 32623212748, 32557183055, 32455134986, 32340347866). What is skipped is the work. Job log 98939498320 shows the cause: ``` socket.gaierror: [Errno -2] Name or service not known urllib.error.URLError: <urlopen error [Errno -2] Name or service not known> File "scripts/seed-owui-e2e-user.py", line 709, in main ``` The seeder resolves `secrets.SUPABASE_URL`. It dies at step 5, so steps 6 through 15 including the entire Playwright suite are skipped, and the only red left is step 16 failing on `browserType.launch: Executable doesn't exist`, because the step that installs browsers was one of the skipped ones. A reader who opens the run sees a Playwright error and no mention of a dead hostname. That is what made a week of failures read as flake. ## The decision, and why it is not a new secret **Can a GitHub-hosted runner reach the box's storage endpoint? No, and it must not.** `deploy/docker/Caddyfile.supabase` puts `/storage/v1` on the internal listener only. The public listener is a separate port carrying `/auth/v1` minus its admin and self-service routes, and the file states the constraint directly: > Consequence for whoever does the cutover: publish or tunnel port 8080 ONLY. Ports 80 and 443 are the in-network surface and must never be exposed. So repointing the secret is blocked by a deliberate security posture, not by an unfinished chore. Even with that posture relaxed, CI would then be reading and writing the live demo box's buckets, which is the arrangement the `web-e2e` comment already records as a defect that was fixed. **MinIO was considered and rejected, and not on the grounds you might expect.** The standing no-MinIO line lives in `docker-compose.enterprise.yml:312` and in CLAUDE.md as a statement about what a deployment persists objects into. On a literal reading it does not reach a container that lives for six minutes and holds nothing, and I do not think it does. MinIO is rejected on a different ground: the defect that actually took production down, #1282, was a `supabase-storage` server-side credential defect. MinIO has no `S3_PROTOCOL_*` concept at all, so a MinIO fixture is structurally incapable of catching that or its next relative, and picking it would rebuild a weaker version of the blindness this PR exists to remove. **What this does instead:** `live-integration` stands up the real `supabase/storage-api`, at the same immutable digest `docker-compose.enterprise.yml` pins, on the throwaway Postgres the job already boots. That database already has everything Storage needs: `deploy/supabase/init/00-extensions.sql` creates the `storage` schema, the Supabase roles, and the default privileges in `storage`, and its own comments already name this consumer. Nothing external is dialed, no secret is involved, and a Dependabot-triggered run behaves identically to every other run. Verified before writing a line of workflow YAML: the image boots against a bare `pgvector/pgvector:pg17` with only `00-extensions.sql` applied, runs its own migrations into `storage`, and serves a full signed round trip on `/s3/hive-files/<key>` with no PostgREST and no Caddy. **Deliberate gap, stated rather than buried.** Production reaches Storage through the gateway, which strips `/storage/v1`, so Storage is given `S3_PROTOCOL_PREFIX=/storage/v1` to rebuild the canonical URI. The fixture has no gateway, so it runs with an empty prefix and the endpoint ends in `/s3`. That prefix agreement is therefore not exercised live here. It is not unchecked: `scripts/test_selfhost_supabase_seam.py` asserts the prefix, the endpoint and the Caddy route all agree, and CI runs it through `make test-scripts`. Adding Caddy to the fixture would add a component and its failure modes for a seam that already has a guard. ## No secret needs creating, rotating or repointing Stated explicitly because the obvious reading of #1324 is "fix the secret". It is not. The three workflows that read `secrets.S3_*` now read none of them: - `live-integration` takes its values from the fixture. - `agent-visual-proof` takes the same secret-free stub the Playwright jobs use, because it touches no object either. Its `github.actor != 'dependabot[bot]'` guard is byte identical after the change; `git diff` shows no line touching it. - `owui-nightly` keeps its six, deliberately, and that needs the explanation below. Nothing else in this repository reads the six `S3_*` secrets after this change. ### Why `owui-nightly` keeps them, and what the lint taught me My first attempt at that file added a preflight step resolving `secrets.SUPABASE_URL`, failing with a message naming the dead project. `tools/lint-no-dead-supabase-secrets.mjs` rejected it, correctly, and it is worth restating why rather than just complying. That guard pins the exact per-secret reference counts for the file rather than exempting the file, so a freshly copy-pasted `secrets.SUPABASE_*` line cannot ride in on an exemption. My step took `SUPABASE_URL` from 3 to 4. Improving an error message is not a reason to widen an exemption documented as one that must only ever shrink. The diagnostic therefore moved into the existing `Reconstruct SUPABASE_DB_URL` step, which already reads `SUPABASE_DB_HOST`, and resolves that instead. Same deleted project, same answer, zero new references. **The `PENDING` map is unchanged and the lint passes on the audited counts exactly as they stand:** ``` pending owui-nightly.yml: 13 audited reference(s) to the retired project's secrets, tracked in #1055 ok: 16 workflow file(s) scanned, 1 pending, no unexempted reference to the retired project's secrets ``` I checked whether this PR could take that file to zero and retire the entry, which is what #1055 actually wants. It can in principle, by the route the linter itself names: `scripts/ci-throwaway-db.sh` plus `scripts/ci-supabase-stack.sh` plus this PR's new `scripts/ci-object-store.sh`, the arrangement `web-e2e` already has. I am not doing it here, for reasons I would rather state than have discovered: - It replaces every credential path in a job whose suite has not passed since 2026-08-18, so a configuration fix is unlikely to be the last thing wrong with it. That is #1370's own fix, not a storage PR's. - It is unverifiable from this branch in any way I would trust. The job runs 45 minutes and builds Open WebUI from source, and I would be asserting it works on one dispatch of a suite with unknown other breakage. This repository has a standing lesson about exactly that shape of sincere false green. - Its S3 half specifically cannot take the stub the other jobs take. `web-e2e`, `interaction-coverage` and `agent-visual-proof` can be stubbed at the discard port because they touch no object. This one boots Open WebUI, whose knowledge and document surfaces do write objects, so a stub would convert a blocked suite into a suite that runs and fails on storage. It needs the real fixture, which needs the throwaway Postgres, which is the #1370 change. The block now carries that reasoning inline, so the next person to open it does not rediscover any of it. ## Proving the signal can actually fail Every restored or new assertion was verified red before being kept. **The fixture's own round-trip check.** Booted with `S3_PROTOCOL_ACCESS_KEY_ID` and `S3_PROTOCOL_ACCESS_KEY_SECRET` removed, it exits non-zero with, verbatim, the error PR #1368 read off the production box: ``` ::error::the throwaway object store answered its health check but could not complete a signed PUT and GET. That is the shape of issue #1282: Storage reports healthy and refuses every signed request. aws: [ERROR]: An error occurred (AccessDenied) when calling the PutObject operation: Missing S3 Protocol Access Key ID or Secret Key Environment variables ``` With the credentials present, same database, same command: `round trip OK` and seven `KEY=value` lines. The ordering guard was also verified red, by running it against a Postgres with no `storage` schema. **The workflow rot guard**, `apps/web-console/tests/unit/ci-live-integration-storage.test.ts`, run against the pre-change `ci.yml`: ``` × reads no S3 repository secret × stands up a real object store fixture × keeps the S3 values out of the job-level env block Tests 3 failed | 1 passed (4) ``` and green against the post-change file. All four of its assertions matter: a returning secret means the job dials a dead host again, a missing fixture step means it dials nothing, and a job-level `S3_*` entry silently overrides what the step writes to `$GITHUB_ENV`, which is the exact trap that got `SUPABASE_*` and `HIVE_API_KEY` removed from this job rather than reassigned. **The digest drift guard**, added to `scripts/test_selfhost_supabase_seam.py`, verified red by bumping the fixture pin one patch version: ``` AssertionError: the CI object-store fixture and the deployed stack pin different Storage images. compose: supabase/storage-api:v1.11.13@sha256:1e85dad... fixture: supabase/storage-api:v1.11.12@sha256:1e85dad... ``` Without it, the claim that CI tests what the box runs is a comment rather than a property. **End to end in CI.** The `it.fails` markers came off, which is itself a two-way check: `it.fails` reports an unexpected pass as a failure, so a premature removal and a premature marker both go red. The deliberate break and its revert are recorded below once both runs land. The break was made in the code path rather than in the fixture, so what is proven is that a real storage defect turns the check red: `apps/edge-api/cmd/server/main.go` handed the files handler a bucket that does not exist, which is the shape of the family #1282 belongs to, where the request reaches a live server and is refused. **Run 33243735217, commit `1e160189b`, the deliberate break. `Live integration (SDK tests + smoke)` (job 99077797492): failure.** The fixture itself came up clean on the hosted runner, which was the one thing that could not be proven locally: ``` bucket hive-files: created bucket hive-images: created round trip OK ``` and then the restored coverage did exactly what it exists to do: ``` ❯ tests/files/files.test.ts (2 tests | 1 failed) × Files > uploads, lists, retrieves metadata, downloads content, then deletes a file → 500 Failed to store file ✓ Files > rejects an invalid purpose with a structured 4xx error ❯ tests/batches/batches.test.ts (2 tests | 1 failed) × Batches > rejects an unsupported batch endpoint value with a structured 4xx error → 500 Failed to store file ``` Both failures name the storage path. Before this PR the same break would have changed nothing, because the endpoint was already dead and both assertions were carried as expected failures. That run was later cancelled by the `ci-${{ github.ref }}` concurrency group when the revert was pushed. Its `Live integration` job had already concluded, so the evidence above is from a completed job and not from a cancelled one. It also failed `Repo policy lints (tenant + audit)` on the dead-secret linter, which is the finding described in the section above and is fixed in `4411d7af9`. **Run 33244166031, commit `b8b8ae75e`, break reverted plus the lint fix.** Result recorded here once it lands. **Run 33246016113, commit `9994dba07`.** The storage half is green: ``` == sdk-tests-js (exit 0) ✓ tests/files/files.test.ts (2 tests) ✓ tests/batches/batches.test.ts (2 tests) Test Files 22 passed (22) Tests 58 passed | 1 skipped (59) ``` | Run | Commit | Expectation | Result | | --- | --- | --- | --- | | 33243735217 | `1e160189b` | `Live integration` red, naming the storage path | failure on `500 Failed to store file` | | 33246016113 | `9994dba07` | Files and Batches green | both suites pass, 58/58 in JS | Between those two the restored coverage did its job and caught a real defect, which is why the middle runs are not a clean green: see the pinned comment on this pull request. `packages/storage` was emitting an absolute-form request target on every S3 request, which Caddy normalizes on the box and Supabase Storage refuses outright. That is fixed here, verified red then green, then end to end against a real Storage container. `Live integration` is still red overall, on a strict `xfail` for issue **#1274** in the Python suite that now passes. It is pre-existing, present in run 33243735217 before any of this branch's storage work, unrelated to storage, and deliberately left alone: clearing a strict `xfail` on one green sample is how an intermittent defect gets recorded as fixed. ## Required contexts: ran, or satisfied by a skip Filled in from the PR's own checks once they resolve, per the standing hazard that a `skipped` required check satisfies branch protection and a non-required check's absence is invisible on the page. ## Also in this PR, at the coordinator's request `.claude/skills/worktree-compose-stack.md` advertised "the current dead-Supabase-host blocker" in its frontmatter description, which is the line the skill router matches on, and named a specific Supabase Cloud hostname in `.env` as the thing stopping a local stack, citing #1254. Both halves are now false, and the way they are false is expensive: #1254 is closed, and `.env` no longer contains that hostname. Verified directly, values masked: `SUPABASE_URL`, `SUPABASE_DB_URL`, `S3_ENDPOINT` and `NEXT_PUBLIC_SUPABASE_URL` are all present with a zero-length value. An agent reading the old text greps for the hostname, finds nothing, and concludes either that the skill is wrong or that a local stack should now work. Neither is true. The blocker changed shape rather than going away, and the two shapes produce different errors: - Then: `control-plane` came up and warned `database unreachable after 6 attempt(s)`, naming no cause. - Now: `control-plane` never reaches the database check. `loadStorageConfigFromEnv` requires the S3 names to be non-empty and `log.Fatalf`s, so it exits at boot with `storage unavailable: missing S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION`. The section now leads with "do not grep `.env` for a Supabase hostname to confirm this", states both shapes, and points at `scripts/ci-supabase-stack.sh` plus the new `scripts/ci-object-store.sh` as the sandbox-workable alternative. It belongs in this PR because it is the same root cause at a different site: one deleted Supabase Cloud project, still referenced by stale configuration in more than one place. The CI-side instance is the secret this PR removes. ## Not in scope, deliberately - **Fully fixing #1370.** See the section above on why, and what it would take. What this PR adds there is a resolution check inside a step that already reads the host, failing in seconds with a message naming the stale secret family and the issue, instead of spending 45 minutes producing a misleading missing-browser cascade. The issue stays open with the cause recorded. - **The accumulating `owui-e2e-shim` API keys** noted in #1370. Separate hygiene finding. - **`/v1/batches` success path.** Still not exercisable with the current provider mix, CLAUDE.md Known Issues item 4, unchanged. The submit, retrieve and cancel path is what the marker was hiding and is what comes back. ## Effect on the demo None. `deploy-demo-box.yml` is untouched, and no file this PR changes is read by it. ## Plan `ObsidianVault/hive/plan-2026-08-29-ci-object-storage-blindness.md`, with the requirements and acceptance criteria folded in. ## Issue #1274's stale marker, cleared with two-sample evidence `Live integration` was red on one thing that was not storage: a `strict=True` `xfail` in `packages/sdk-tests/python/tests/test_anthropic_messages.py` that now passes, rendering as `XPASS(strict)`. That is the same defect family as the three `it.fails` markers #1383 cleared this morning, and a marker states what is true today. **What it tracked and what fixed it,** so the closure is traceable rather than "it passes now": #1274 was `content_block_start` omitting an empty `text` field, because `json:"text,omitempty"` drops a Go string's empty value as readily for the required-but-empty case as for unset. The Anthropic SDK seeds its accumulator snapshot from that field and then does `content.text += delta.text`, so a missing key made that a `None += str` TypeError on every streamed text response. **PR #1296, commit `0b4e5ffed`,** fixed it by making `Text` a pointer so the required-but-empty value reaches the wire. The comment on `StreamContentBlock` in `apps/edge-api/internal/anthropic/types.go` records that, and issue #1274 is closed. **Not cleared on one sample.** It reported `XPASS(strict)` on five independent runs across five commits, two of them after rebasing onto #1410's classifier fix, with no run in which it failed legitimately: | Run | Commit | | --- | --- | | 33244935750 | `f8dddd47e` | | 33246016113 | `9994dba07` | | 33247050365 | `3060119a5` | | 33247571957 | `11d7e2eae` (post-rebase) | | 33248088001 | `eac8729b4` (post-rebase) | `gh run rerun --failed` refuses on this branch ("its workflow file may be broken", the known behaviour when a run's workflow file has changed), so the second sample comes from separate runs on separate commits rather than a re-run of one. That is the stronger form of the evidence, not a substitute for it. The `run-live-integration` label stays on. Removing it would make a required check skip, and a skipped required check satisfies branch protection: that would trade a loud failure for a quiet absence on the exact lane this pull request exists to restore. ## Correction posted to #1380 That issue attributes the blindness to the `http://127.0.0.1:9` stub. The conclusion is right and the cause is not: `ci.yml:2026` and `ci.yml:2565` are `web-e2e` and `interaction-coverage`, both Playwright, and neither runs `sdk-tests-js`. The SDK suites run in `live-integration`, which was reading the dead secret. Corrected on the issue itself rather than only here, so the next reader does not inherit the wrong model: #1380 (comment) ## `ci.yml` regions this branch touches Three, all narrow, listed so a rebase order against #1410 and #1417 can be worked out: 1. The `live-integration` job env block, where six `secrets.S3_*` entries are removed and replaced by a comment. 2. One new step in `live-integration` after the throwaway-database step, one new teardown step, one new failure-dump step, and one sentence added to the existing `Pre-verify secrets reached runner env` comment. 3. Comment-only additions inside the `web-e2e` and `interaction-coverage` S3 stub blocks. No value changes in either. It does not touch the `Repo policy lints` region that #1417 edits, and it does not touch the upstream-refusal classifier that #1410 changed. Rebased onto `0d089744a` cleanly with no conflicts. ## Buglog entry ```json {"id":"bug-2026-08-29-ci-object-storage-dead-secret","date":"2026-08-29","title":"CI object storage endpoint pointed at a deleted Supabase Cloud project, so the file upload path could not fail","error_message":"file upload to object storage failed bucket=hive-files error=\"Put \\\"https://<project>.supabase.co/storage/v1/s3/hive-files/...\\\": dial tcp: lookup <project>.supabase.co: no such host\"","root_cause":"The S3_ENDPOINT repository secret was last written 2026-04-21, four months before the August self-hosted Supabase cutover, and still named the deleted Supabase Cloud project. The live-integration job read it at job level, so every POST /v1/files in CI died at DNS and the Files and Batches conformance assertions were carried as it.fails markers rather than as coverage. The same stale secret family also killed the OWUI nightly at its seeding step via SUPABASE_URL (#1370), which then skipped the entire Playwright suite and left a misleading missing-browser cascade as the only visible red. Issue #1380 attributed the blindness to the http://127.0.0.1:9 stub in ci.yml, but that stub is in two Playwright jobs that run no SDK suite.","fix":"Removed every secrets.S3_* reference from live-integration and agent-visual-proof. live-integration now boots its own throwaway supabase/storage-api at the digest docker-compose.enterprise.yml pins, on the throwaway Postgres it already stands up, whose 00-extensions.sql already creates the storage schema and roles. The fixture asserts a signed PUT and GET before printing its values. The it.fails markers came off. Guards added: a unit test asserting the job keeps the fixture and no S3 secret, and a seam-test assertion that the fixture and compose pin the same image.","tags":["ci","storage","s3","supabase","dark-detector","stale-secret","sigv4"],"issues":[1324,1380,1370,1282]} ``` ```json {"id":"bug-2026-08-29-s3-absolute-form-request-target","date":"2026-08-29","title":"S3 client sent absolute-form request targets, which Supabase Storage refuses with an unnamed 500","error_message":"s3 PUT /s3/hive-files/<key> failed with status 500: <Code>InternalError</Code><Message>Internal Server Error</Message>; storage side: {\"code\":\"ERR_INVALID_URL\",\"input\":\"http://localhost:8080http://172.17.0.1:5000/s3/hive-files/<key>\"} TypeError: Invalid URL at SignatureV4.constructCanonicalRequest","root_cause":"packages/storage/signing.go SignHTTP sets req.URL.Opaque to \"//host/path\" so the AWS SigV4 signer receives an un-normalized path, and never cleared it. net/url.RequestURI returns Opaque verbatim and re-attaches the scheme when it starts with \"//\", so every signed S3 request went on the wire in absolute form (PUT http://host/s3/bucket/key HTTP/1.1). RFC 9112 reserves absolute form for a request to a proxy. The deployed box never saw it because every S3 request passes through Caddy, which normalizes the target before proxying. Reached directly, Supabase Storage builds its canonical URI as new URL(\"http://localhost:8080\" + prefix + request.url) and throws on the doubled origin.","fix":"Clear req.URL.Opaque after signer.SignHTTP returns. The signature is unaffected: the signer derives its canonical URI from Opaque while set, stripping the leading //host, and that string is byte identical to the EscapedPath the wire form falls back to once Opaque is empty. presignHTTP still keeps Opaque, because url.URL.String renders a bare-path Opaque as http:/s3/... and the //host form is what makes a presigned URL absolute. Four tests added, each verified red first.","tags":["storage","s3","sigv4","http","rfc9112","proxy-masked","dark-detector"],"issues":[1324,1380,1282]} ```
## Summary This is the batched buglog follow-up for the pull requests merged to `main` on 2026-08-29. Its diff is `.wolf/buglog.jsonl` and nothing else. Per `.claude/rules/openwolf.md`, every fixed bug, error, failed test or failed build must be logged, but the line may never be appended on a fix branch. `merge=union` in `.gitattributes` resolves concurrent appends locally and is ignored by GitHub's server side merge, so two branches that both appended land in hard conflict there. An unmergeable pull request gets no `refs/pull/N/merge`, no `pull_request` run and therefore zero checks, and the required status gate then blocks the merge for a reason the page never states (issue #873). Each fix accordingly carried its entry in its own pull request body, and this pull request copies them onto `main` in one batch, which the protocol explicitly prefers over one pull request per entry. ## Scope examined Fifty nine pull requests merged to `main` on 2026-08-29. Forty eight of them carried at least one entry, for eighty two entries in total. Thirty two of those were already on `main` and are skipped, leaving fifty appended here from thirty four pull requests. The largest block of skips comes from #1342, the equivalent batch for the 2026-08-28 merges, which merged earlier the same day and already landed thirty six entries covering #1257, #1268, #1276, #1277, #1287, #1292, #1293, #1294, #1296, #1301, #1303, #1305, #1313, #1335 and #1337. ## What landed Fifty entries appended, one JSON object per line, append only. The 232 pre-existing lines are byte identical to `origin/main` (verified by hashing the first 232 lines of the result against the base file). Every line in the resulting file parses as JSON and carries `error_message`, `root_cause`, `fix` and `tags`. | Source | Entries | |---|---| | #1083 | 2 | | #1277 | 1 | | #1278 | 1 | | #1298 | 1 | | #1334 | 1 | | #1336 | 3 | | #1343 | 1 | | #1346 | 1 | | #1351 | 1 | | #1365 | 2 | | #1368 | 1 | | #1369 | 1 | | #1371 | 3 | | #1375 | 3 | | #1376 | 1 | | #1378 | 1 | | #1379 | 2 | | #1388 | 5 | | #1389 | 3 | | #1390 | 2 | | #1393 | 1 | | #1394 | 1 | | #1410 | 1 | | #1417 | 1 | | #1421 | 1 | | #1423 | 1 | | #1424 | 1 | | #1426 | 1 | | #1429 | 1 | | #1431 | 1 | | #1433 | 1 | | #1434 | 1 | | #1436 | 1 | | #1439 | 1 | Entries are copied verbatim from their source pull request bodies. Nothing was rewritten, no field was invented, and no field was added. No JSON needed repair: all eighty two extracted entries parsed on the first attempt and all four required fields were present on every one. ## Merged pull requests that carried no entry Eleven of the fifty nine. Recorded here because the gap is itself the useful signal. | Pull request | Title | Assessment | |---|---|---| | #1013 | chore(deps): bump the go-minor-patch group across 1 directory with 4 updates | Dependabot bump, no defect fixed, no entry expected | | #1015 | chore(deps): bump the go-minor-patch group across 1 directory with 6 updates | Dependabot bump, no entry expected | | #1016 | chore(deps): bump golang from 1.26-alpine to 1.27-alpine in /deploy/docker | Dependabot bump, no entry expected | | #1218 | chore(deps): bump postcss from 8.5.19 to 8.5.26 in /apps/desktop | Dependabot bump, no entry expected | | #1219 | chore(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in /apps/control-plane | Dependabot bump, no entry expected | | #1342 | chore: batch buglog entries for the 2026-08-28 merges | The previous batch pull request itself, correctly carries no entry of its own | | #1364 | chore: remove four dead skills and record the patterns that cost time | Protocol gap. The body records patterns that cost time, which is the shape of a buglog entry, but none was written as one | | #1383 | test: retire stale expected-failure markers, restore the ones that are true (#1381, #1382, #1324) | Protocol gap. Stale `it.fails` markers reading as red is a real defect that was fixed here and should have carried an entry | | #1384 | docs: correct D-047, hive-auto reverted to variable pricing (D-059) | Decision ledger correction, arguably a documentation defect, no entry written | | #1387 | chore(deps): bump next from 15.5.23 to 16.3.3 in /apps/agent-console | Dependabot bump, no entry expected | | #1398 | docs: rescue the 2026-08-25 parity captures and add the 2026-08-29 QA matrix evidence | Documentation and evidence rescue, no entry written | Six of the eleven are Dependabot bumps and one is the previous batch, so the genuine protocol gaps are #1364, #1383, #1384 and #1398. Of those, #1383 is the one worth a follow-up: it fixed a real defect class (a stale expected-failure marker reads as a red "Expect test to fail" and gets dismissed as pre-existing) and left no record. ## Entries skipped as already present Thirty two. Thirty of them matched an entry already on `main` on `error_message`, `id` or `fix`. Two more from #1278 are semantic duplicates that an exact match would have missed, and were skipped after reading the landed entries they duplicate: - #1278's `streaming content_block_start omits text field` entry is covered by the consolidated `bug-2026-08-28-anthropic-sdk-wire-conformance` entry landed from #1296, whose root cause names the same `omitempty` on `StreamContentBlock.Text`. - #1278's `GET /v1/models leaked an upstream provider name` entry is covered by `BUG-1284`, landed from #1300, which names the same `public.model_aliases.summary` publication path. #1278's third entry, on `top_k` forwarding producing a 400, is not covered anywhere on `main` and is appended here. #1342 recorded #1278 as fully "merged into #1296", which was accurate for two of its three entries. ## Note on entry quality One appended entry is thin: #1277's parity re-score record carries `error_message` of `n/a` and a root cause of "console had no privacy/data-policy surface at all". It is a parity gap record rather than a defect record. It is included exactly as written rather than embellished, per the protocol's preference for the author's own words. ## Test plan - [x] Branch cut fresh from `origin/main`, diff is `.wolf/buglog.jsonl` and nothing else - [x] First 232 lines byte identical to the base file (md5 match) - [x] All 282 resulting lines parse as JSON and carry `error_message`, `root_cause`, `fix` and `tags` - [x] No `.wolf/` telemetry (`anatomy.md`, `memory.md`, `token-ledger.json`, `hooks/_session.json`, `buglog.json`) in the commit - [ ] The six required checks report green via the inert path allowlist in `.github/workflows/ci.yml` --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #1274. Closes #1260. Closes #1261. Closes #1259.
Are these one root cause or four?
Two families, not one, and not four independent fixes.
Family A, Go zero-value JSON encoding dropping values the Anthropic contract requires to be present (#1274 and #1260). Both are the gateway emitting a valid Go zero value where Anthropic requires an explicit empty one, and in both cases the Go-side assertion a normal test would write cannot see the defect at all:
len(content) == 0is true for a nil slice and an empty one alike, and aTextfield set to""looks correct right up untilencoding/jsonruns. The mechanism differs (omitemptyon a string in one, a nil slice with no tag in the other), so it is one class with two instances rather than a single line to change.Family B, an auth model that only ever covered POST /v1/messages (#1261 and #1259). Every other route on the Anthropic surface was left to inherit an auth path built for a different dialect.
count_tokensis the only route here that does not delegate to the chat chain, so it never gained the API-key authority that chain provides, andGET /v1/modelswas never given the leafAPIKeyNormalizerthat/v1/messageshas carried since #954. The two share a cause but not a line of code.#1259 additionally carries a response-shape defect that belongs to neither family: the route answered an Anthropic client with the OpenAI list envelope and the OpenAI error envelope. Fixed here in the same pass, since fixing only the auth half would have moved the failure one step later.
Ground truth
API shapes were verified against the live specification on 2026-08-28, not from model memory, per the repository rule.
https://docs.claude.com/en/docs/build-with-claude/streamingshowscontent_block_startas{"type":"text","text":""}for a text block and{"type":"tool_use","id":...,"name":...,"input":{}}for a tool_use block, in every one of its five worked examples.https://docs.claude.com/en/api/models-listshows the list envelope asdata(entries oftype/id/display_name/created_at) plushas_more,first_idandlast_id, the last two typed "string or null".anthropic/lib/streaming/_messages.py,accumulate_event) was read directly to confirm the failure mechanism: it doescontent.text += event.delta.textfor atext_delta, which is theNone += strcrash, and for aninput_json_deltait assignscontent.inputfrom its ownjiterbuffer rather than reading the block-start value. That is why the missingtextwas fatal and the missinginputwas not, and it is why this PR treats the two differently in its claims.openwolf bug search anthropicandopenwolf bug search omitemptyboth returned no prior entries.What changed
StreamContentBlock.Textis now*stringso the empty string serializes, following the precedentStreamEvent.Indexalready set in this same struct for the identical reason.Input json.RawMessageadded and set to{}on a tool_use block start.FromOAIResponseseedsContentwith an empty non-nil slice at construction and appends into it, which covers the earlylen(resp.Choices) == 0return as well as the normal path.anthropic.Deps.AuthorizeAPIKeyadded;count_tokensaccepts a JWT session principal or an API-key principal. Nil leaves it session-only and fail-closed. The refusal is the authorizer's own verdict run through the sharedWriteAuthFailurestatus mapping and then reshaped into the Anthropic envelope, so no status logic is duplicated.GET /v1/modelsis registered through a namedmodelsHandlerapplyinganthropic.APIKeyNormalizerat the leaf andanthropic.ModelsCompatfor shape.Why the leaf normalizer on /v1/models is not redundant
authSelectorMiddlewarealready wraps every/v1/*route inAPIKeyNormalizer(#954), but only when JWT auth is wired:handler = authSelectorMiddleware(jwtMW, handler)sits behindif jwtMW != nil. On a deployment where Supabase JWT config is absent, edge-api logs "JWT auth wiring skipped" and mounts no selector at all, so nothing normalizesx-api-keyandhandleModelsreads an emptyAuthorizationheader./v1/messageskept working there purely because it carries its own leaf wrapper. That asymmetry is exactly the reported symptom in #1259 (same key, same connection,/v1/messagesfine and/v1/models401) and it is why the fix is a leaf wrapper rather than a change to the selector.Provider-blind invariant
The
ModelsCompattranslation readsid,createdandnameand writestype,id,display_nameandcreated_at.owned_byand every other OpenAI field are dropped rather than passed through, so this strictly reduces what reaches an Anthropic client.TestModelsCompat_AnthropicClientGetsTheAnthropicListShapeassertsowned_byis absent from the body. Nothing on any path touched here emits a provider name, an upstream id or asystem_fingerprint; the existingTestHandleModelsDoesNotLeakProviderNamesandTestSSETranslator_MessageID_NeverLeaksUpstreamIDguards are unchanged and still pass.Mutation check
Every added test was proved load-bearing by reverting the fix it guards and observing it fail for the right reason, then restoring and observing it pass. Full transcript, six mutations, all red, then green on restore:
StreamContentBlock.Textback to a plainstring, call site back toText: ""TestSSETranslator_TextBlockStartCarriesExplicitEmptyTexttext content_block_start omits the "text" key entirely ... map[type:text]Input: json.RawMessage("{}")from the tool_use block startTestSSETranslator_ToolUseBlockStartCarriesExplicitEmptyInputtool_use content_block_start omits the "input" key: map[id:call_1 name:get_weather type:tool_use]Content: []ResponseBlock{}seedTestFromOAIResponse_EmptyCompletionSerializesContentAsArraygot {... "content":null ...}if h.deps.AuthorizeAPIKey == nilforced toif true, i.e. count_tokens back to session-onlyTestHandler_CountTokens_AcceptsAPIKeyPrincipal,..._RejectedAPIKeyKeepsTheAuthorizersOwnRefusalwant 200 got 401 body={"type":"error","error":{"type":"authentication_error","message":"missing user"}}, anderror.message: want the authorizer's own refusal got missing userif !IsAnthropicClient(r)forced toif true, i.e. never re-shapeTestModelsCompat_*(3 tests)first_id/last_idnil,envelope: want top-level type=error got <nil>modelsHandlerunwrapped toreturn handleModels(client, authorizer)TestModelsHandlerServesAnAnthropicSDKClientx-api-key caller: want 200 got 401: {"error":{"message":"You didn't provide an API key...After restoring all six,
go test ./apps/edge-api/internal/anthropic/... ./apps/edge-api/cmd/server/...isokfor both packages, and the widergo test ./apps/edge-api/... -count=1 -shortpasses with no failures.gofmt -lreports none of the touched files.Four of the added tests are deliberately non-regression guards rather than defect guards, and do not go red under any mutation above. The count said two on first posting and was corrected in review; the table is the record anyone reads later, so it should say what is actually true.
TestModelsCompat_OpenAIClientIsUntouchedandTestModelsHandlerKeepsTheOpenAIShapeForOpenAIClientsexist to fail if a later change starts re-shaping unconditionally and empties Open WebUI's model picker. Both compare byte-identical bodies, so the inverse mutation of always reshaping does turn them red.TestHandler_CountTokens_WithoutAnAPIKeyAuthorityFailsClosedstays green under mutation 4, since a handler wired without an API-key authority writes 401 either way. It pins the fail-closed default and goes red if someone makes a nil authority permissive.TestHandler_CountTokens_SessionPrincipalDoesNotConsultTheAPIKeyAuthorityalso stays green under mutation 4, since the session branch returns before the nil check is reached. It goes red if the two principal checks are ever reordered.TestIsAnthropicClientis a unit test of a new pure function that none of the six mutations touch, so it is neither a defect guard nor a non-regression guard for anything in the table.Review round two
Three findings from review, all fixed in
25b2820, all inapps/edge-api/internal/anthropic.Refusal retry headers were being dropped.
apierrors.WriteAuthFailureis the shared source of truth precisely so a retryable 429 is never collapsed into a non-retryable refusal, and it delivers the retryable part through headers rather than the body:retry-afterplus the fourx-ratelimit-*values on a real 429, andretry-afteron both the degraded-limiter branch and theupstream_unavailablebranch. Recording the refusal in aheaderlessRecorderand reshaping only its body threw all of that away.count_tokenslost it latently, andGET /v1/modelslost it live throughauthorizeAliasRequest, which also left the two client shapes disagreeing about retry metadata for the same refusal on the same route.headerlessRecorder.reshapeIntonow carries the headers over and reshapes in one call, so a call site cannot take one half of a refusal without the other.Content-TypeandContent-Lengthare deliberately not carried: the reshaped body is a different envelope of a different length, and a staleContent-Lengthwould truncate it on the wire. Everything that is forwarded is retry metadata and carries no provider identity, so the provider-blind invariant is unchanged.GET /v1/modelsserved two representations for one URL without declaring it. Nothing on the route setsCache-Controland the route needs a credential, so no correct cache stores it today and no live exploit exists. The declaration is what keeps a later edge cache in front of Caddy, or an intermediary keying on URL alone, from handing an Anthropic-shaped body to Open WebUI and emptying its model picker, which is the regressionTestModelsCompat_OpenAIClientIsUntouchedexists to prevent.Vary: anthropic-version, x-api-keyis now set on both branches, before either writes.Finishcould emitmessage_deltabefore anymessage_start.TranslatecallsFinishunconditionally andFinishdid not checkt.started, so an upstream stream that yielded no parseable chunk at all produced a terminal pair with nothing before it, and the SDK accumulator raises an unexpected-event-order error when amessage_deltaarrives while its snapshot is still nil. Review suggested a follow-up issue for this rather than widening the PR; the fix turned out to be three lines in a file this PR already changes, which is smaller than the issue describing it, so it is taken here.FeedLineandFinishnow share oneensureStarted, which emitsmessage_startexactly once per stream whichever of them reaches it first.Four more mutations, each applied alone, each restored after:
reshapeInto's header loop forced to skip every keyTestHandler_CountTokens_RefusalCarriesTheAuthorizersRetryHeaders,TestHandler_CountTokens_UpstreamUnavailableCarriesRetryAfter,TestModelsCompat_RefusalCarriesTheRetryHeadersretry-after: want 30 got "",retry-after: want 5 got "", and the threex-ratelimit-*assertions on both routesContent-Lengthremoved from the skip list, so the delegated body's length is forwardedTestModelsCompat_RefusalCarriesTheRetryHeaderscontent-length: the delegated body length must not describe the reshaped one, got "4096"Varyline deletedTestModelsCompat_DeclaresVaryvary: want both request headers that select the representation, got ""ensureStartedremoved fromFinishTestSSETranslator_EmptyStreamStillOpensTheMessagefirst event: want message_start got message_deltaTestSSETranslator_FinishAfterAStartedStreamDoesNotRepeatMessageStartis a fifth non-regression guard: it stays green under all ten mutations and exists so that opening the message fromFinishcan never add a secondmessage_startto a stream that already carried one.Adversarial review of the round-two fixes
The round-two commit went back through the review pipeline before this was called done. Two findings, both fixed in
f0e4c56.The
Varydeclaration usedSetwhere it should useAdd. Nothing in edge-api declares aVaryon this route today (grepfinds exactly one writer, the new line itself), so overwriting was harmless in the current tree and wrong the moment an outer middleware declares one of its own, a CORS layer settingVary: Originbeing the obvious case. The wrapper is now additive.The 2xx branch of
ModelsCompatdropped every header the delegated handler set. That path re-encodes the body rather than reshaping it, which is exactly why the carry-over the refusal path had just gained was easy to leave out of it. A header set alongside a success is as much part of that response as the body is, and this route is where a 2xx rate-limit budget would surface. The header copy is now its own method on the recorder,copyHeadersTo, and both branches call it.Varyback toSetTestModelsCompat_VaryDoesNotClobberAnExistingDeclarationvary: want "Origin" preserved, got "anthropic-version, x-api-key"rec.copyHeadersTo(w)deleted from the success branchTestModelsCompat_SuccessCarriesTheDelegatedHeadersx-ratelimit-remaining-requests: want 42 got ""On
display_nameand the provider-blind edgeReview confirmed independently that the
ModelsCompattranslation is provider-blind (it readsid,createdandnameonly, soowned_byanddescriptioncannot ride along) and flagged the remaining edge:display_nameismodel_aliases.display_name, free text, and one seeded row already readsOpenrouter Auto (Task Aware). That row isvisibility='internal'today, so thevisibility IN ('public', 'preview')predicate keeps it off both list paths, but the migration that added it describes a later flip to public as a one-line follow-up.Nothing here closes #1284, and this PR never claimed to: the OpenAI branch is unchanged and still ships the
hive-sttandhive-ttsdescriptions that #1284 reports. Thedisplay_nameedge is recorded as a comment on #1284 rather than a new issue, since it is the same route and the same family, and the guard it asks for (assert no listed alias'sdisplay_nameorsummarymatches a known provider name) belongs where the catalog is listed, not in this translation.Effect on #1278
This should flip all four
xfailmarkers inpackages/sdk-tests/python/tests/test_anthropic_messages.pyto real assertions. Not touched here, since that branch is not mine to edit.test_streaming_event_sequence_integrity(Streaming content_block_start omits text field, crashing the real Anthropic SDK's stream accumulator on every text response #1274)test_tool_choice_none_forbids_tool_use(Anthropic /v1/messages returns content:null instead of content:[] on empty completions, breaking real SDK/typed clients #1260)test_count_tokens(POST /v1/messages/count_tokens 401s a valid Hive API key: session-only auth, no API-key path #1261)test_models_list(Anthropic /v1/models incompatible with real Anthropic SDK: wrong auth header accepted, wrong error envelope, OpenAI-shaped body #1259)One caveat on the last one.
client.models.list()will now authenticate and return the Anthropic list envelope, but the SDK's typed page model may expect fields this gateway has no source for. If that assertion still fails after this merges, it is a narrower follow-up about specific fields, not the auth and envelope defects #1259 reported.Scope deliberately not taken
#1260 also observes that a zero-content streaming turn emits
message_start,message_delta,message_stopwith nocontent_block_start/stoppair. That is left alone: the streaming path already emits"content":[]inmessage_start, so an SDK folding the stream back into a message gets an empty array rather than null, and Anthropic's own specification does not require a content block on a turn that produced no content. The reported crash is in the non-streaming builder, which is what this changes.The neighbouring case where
message_startnever fires at all is a different defect, was not what #1260 reported, and is now fixed here rather than deferred. See Review round two above.Test plan
go test ./apps/edge-api/... -count=1 -shortthrough the toolchain container, green, re-run after the review-round-two fixesgofmt -l apps/edge-apiclean for every touched filepackages/sdk-testsAnthropic conformance suite re-run against a live stack once test: add Anthropic Messages API conformance suite using the real SDK #1278 merges, to confirm the four xfails flipBuglog entry
{"id":"bug-2026-08-28-anthropic-sdk-wire-conformance","date":"2026-08-28","title":"Anthropic surface broke the real SDK four ways: content_block_start dropped text, empty completions serialized content null, count_tokens 401d API keys, /v1/models rejected x-api-key and answered OpenAI-shaped","error_message":"TypeError: unsupported operand type(s) for +=: 'NoneType' and 'str' in anthropic/lib/streaming/_messages.py accumulate_event; TypeError: 'NoneType' object is not iterable on msg.content; anthropic.AuthenticationError 401 {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"missing user\"}} on count_tokens; anthropic.AuthenticationError 401 invalid_api_key on models.list()","root_cause":"Two families. (1) Go zero-value JSON encoding dropped values the Anthropic wire contract requires to be explicitly present: omitempty on StreamContentBlock.Text dropped the required \"text\":\"\" from every text content_block_start, and a nil []ResponseBlock in FromOAIResponse marshaled to null instead of []. Neither is visible to a Go-side assertion, since len() cannot distinguish nil from empty and a \"\" field looks correct until encoding/json runs. (2) The Anthropic surface's auth model only ever covered POST /v1/messages: count_tokens is the only route that does not delegate to the chat chain so it never gained an API-key authority and recognised session principals only, and GET /v1/models never got the leaf APIKeyNormalizer /v1/messages carries, which matters because the global one in authSelectorMiddleware only exists when jwtMW is non-nil.","fix":"StreamContentBlock.Text became *string and gained Input json.RawMessage set to {} on tool_use starts, matching the live streaming spec. FromOAIResponse seeds Content with an empty non-nil slice at construction so both exits are covered. anthropic.Deps.AuthorizeAPIKey added and count_tokens accepts either principal, fail-closed when nil, refusals reshaped from the shared WriteAuthFailure mapping. GET /v1/models registered through modelsHandler applying APIKeyNormalizer at the leaf plus a ModelsCompat wrapper that answers Anthropic-shaped callers with the Anthropic list and error envelopes while leaving OpenAI callers byte-identical.","tags":["anthropic","sdk-conformance","edge-api","json-encoding","omitempty","auth","streaming","provider-blind"],"issues":[1274,1260,1261,1259],"pr":"fix/anthropic-stream-content-block-start"}