@@ -93,6 +93,21 @@ Stateful note:
9393 managed read-only, env-over-YAML, startup validation) needs gateways launched
9494 with custom config, so it is covered by a standalone script rather than this
9595 running-stack matrix
96+ - ` S173 ` -` S182 ` exercise the Anthropic Messages drop-in compatibility fixes
97+ (` x-api-key ` auth fallback, ` stop_sequence ` , seeded stream usage,
98+ dialect-aware ` /v1/models ` and 404s); ` S173 ` -` S175 ` need the auth-enabled
99+ gateway (` $AUTH_BASE_URL ` ), the rest are read-mostly on ` $BASE_URL ` and
100+ rerunnable in any order
101+ - ` S183 ` -` S191 ` exercise the Anthropic Message Batches API
102+ (` /v1/messages/batches* ` ); each creates its own ` msgbatch_ ` -scoped batch and
103+ is rerunnable in any order, but (like ` S47 ` -` S48 ` ) they leave
104+ ` in_progress ` /` canceling ` batches behind since the scenarios do not wait for
105+ a real provider batch to end. Bedrock Mantle (` internal/providers/bedrockmantle ` )
106+ has no running-stack coverage here: no ` BEDROCK_MANTLE_API_KEY ` /AWS
107+ credentials are available in ` .env ` , so it is covered by its unit test suite
108+ (` config_test.go ` , ` bedrock_mantle_test.go ` ) plus a one-off manual check that
109+ a ` BEDROCK_MANTLE_* ` -prefixed provider registers distinctly from ` BEDROCK_* `
110+ at startup without colliding or crashing
96111- For stateful partial reruns, prefer a contiguous range that includes the
97112 prerequisite setup scenarios, or rerun with the same ` --qa-suffix ` and
98113 ` --keep-artifacts `
@@ -4236,3 +4251,323 @@ mcp_post "$BASE_URL/mcp" "$SID" \
42364251 ' {"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"mock://alpha/info"}}' \
42374252 | jq -e ' any(.result.contents[]?; .text == "MOCKMCP_ALPHA_RESOURCE_OK")' > /dev/null
42384253```
4254+
4255+ ## 24. Anthropic Messages drop-in compatibility
4256+
4257+ These scenarios cover the gaps closed by the Anthropic-SDK drop-in fix pass:
4258+ ` x-api-key ` auth fallback, ` stop_sequence ` surfaced as a typed field, seeded
4259+ ` message_start ` usage on streams, dialect-aware ` /v1/models ` , and a canonical
4260+ 404 envelope that no longer swallows 405s. ` S173 ` -` S175 ` run on the
4261+ auth-enabled gateway (` $AUTH_BASE_URL ` ) since the auth fallback needs a
4262+ gateway with a master key configured; the rest run on ` $BASE_URL ` , which runs
4263+ in unsafe mode. All are read-mostly and rerunnable in any order.
4264+
4265+ ### S173 ` x-api-key ` header authenticates like ` Authorization: Bearer `
4266+
4267+ Checks the Anthropic-native credential header works unchanged, matching
4268+ ` Anthropic(api_key=...) ` SDK defaults.
4269+
4270+ ``` bash
4271+ RESP_FILE=" $QA_RUN_DIR /s173.chat.json"
4272+ curl -fsS " $AUTH_BASE_URL /v1/chat/completions" \
4273+ -H ' Content-Type: application/json' \
4274+ -H " x-api-key: $GOMODEL_MASTER_KEY " \
4275+ -d ' {"model":"gpt-4.1-nano","messages":[{"role":"user","content":"Reply with exactly QA_XAPIKEY_OK"}],"max_tokens":20}' \
4276+ > " $RESP_FILE "
4277+ assert_chat_response_contains " $RESP_FILE " " openai" " QA_XAPIKEY_OK"
4278+ ```
4279+
4280+ ### S174 Missing credentials names both accepted schemes (negative)
4281+
4282+ Checks the combined error message added when neither header is present.
4283+
4284+ ``` bash
4285+ HEADERS_FILE=" $QA_RUN_DIR /s174.headers"
4286+ BODY_FILE=" $QA_RUN_DIR /s174.body"
4287+ curl -sS -D " $HEADERS_FILE " -o " $BODY_FILE " " $AUTH_BASE_URL /v1/chat/completions" \
4288+ -H ' Content-Type: application/json' \
4289+ -d ' {"model":"gpt-4.1-nano","messages":[{"role":"user","content":"hi"}]}'
4290+ grep -Eiq ' ^HTTP/.* 401 ' " $HEADERS_FILE "
4291+ jq -e ' .error.type == "authentication_error" and (.error.message | test("Authorization: Bearer") and test("x-api-key"))' " $BODY_FILE " > /dev/null
4292+ ```
4293+
4294+ ### S175 ` Authorization ` takes precedence over ` x-api-key ` when both are sent
4295+
4296+ A wrong bearer token is still rejected even when a valid ` x-api-key ` is also
4297+ present, confirming the fallback only applies when ` Authorization ` is absent.
4298+
4299+ ``` bash
4300+ HEADERS_FILE=" $QA_RUN_DIR /s175.headers"
4301+ BODY_FILE=" $QA_RUN_DIR /s175.body"
4302+ curl -sS -D " $HEADERS_FILE " -o " $BODY_FILE " " $AUTH_BASE_URL /v1/chat/completions" \
4303+ -H ' Content-Type: application/json' \
4304+ -H ' Authorization: Bearer totally-wrong-key' \
4305+ -H " x-api-key: $GOMODEL_MASTER_KEY " \
4306+ -d ' {"model":"gpt-4.1-nano","messages":[{"role":"user","content":"hi"}]}'
4307+ grep -Eiq ' ^HTTP/.* 401 ' " $HEADERS_FILE "
4308+ ```
4309+
4310+ ### S176 ` stop_sequence ` surfaces as a typed field (non-streaming, Anthropic backend)
4311+
4312+ Checks the natively reported matched sequence round-trips through
4313+ ` /v1/messages ` as ` stop_reason: "stop_sequence" ` plus ` stop_sequence ` .
4314+
4315+ ``` bash
4316+ RESP_FILE=" $QA_RUN_DIR /s176.messages.json"
4317+ curl -fsS " $BASE_URL /v1/messages" \
4318+ -H ' Content-Type: application/json' \
4319+ -d ' {"model":"claude-sonnet-4-6","max_tokens":64,"stop_sequences":["QA_STOP_HERE"],"messages":[{"role":"user","content":"Count from 1 to 10, one number per line. After the number 3 write the exact text QA_STOP_HERE then continue."}]}' \
4320+ > " $RESP_FILE "
4321+ jq ' {stop_reason,stop_sequence}' " $RESP_FILE "
4322+ jq -e ' .stop_reason == "stop_sequence" and .stop_sequence == "QA_STOP_HERE"' " $RESP_FILE " > /dev/null
4323+ ```
4324+
4325+ ### S177 ` stop_sequence ` in streaming ` message_delta ` plus seeded ` message_start ` usage
4326+
4327+ Checks the streaming counterpart of ` S176 ` and that ` message_start ` no longer
4328+ reports a hardcoded zero for ` usage.input_tokens ` .
4329+
4330+ ``` bash
4331+ SSE_FILE=" $QA_RUN_DIR /s177.messages.sse"
4332+ curl -fsS --no-buffer " $BASE_URL /v1/messages" \
4333+ -H ' Content-Type: application/json' \
4334+ -d ' {"model":"claude-sonnet-4-6","max_tokens":64,"stream":true,"stop_sequences":["QA_STOP_HERE"],"messages":[{"role":"user","content":"Count from 1 to 10, one number per line. After the number 3 write the exact text QA_STOP_HERE then continue."}]}' \
4335+ > " $SSE_FILE "
4336+ grep -A1 ' ^event: message_start' " $SSE_FILE " | sed -n ' 2p' | sed ' s/^data: //' \
4337+ | jq -e ' .message.usage.input_tokens > 0' > /dev/null
4338+ grep -A1 ' ^event: message_delta' " $SSE_FILE " | sed -n ' 2p' | sed ' s/^data: //' \
4339+ | jq -e ' .delta.stop_reason == "stop_sequence" and .delta.stop_sequence == "QA_STOP_HERE"' > /dev/null
4340+ ```
4341+
4342+ ### S178 OpenAI-family backend keeps ` end_turn ` through ` /v1/messages ` (documented limitation)
4343+
4344+ ` finish_reason: "stop" ` conflates a natural stop with a stop-sequence hit, so
4345+ OpenAI-family providers structurally cannot report ` stop_sequence ` ; checks the
4346+ gateway keeps ` end_turn ` rather than fabricating a value.
4347+
4348+ ``` bash
4349+ RESP_FILE=" $QA_RUN_DIR /s178.messages.json"
4350+ curl -fsS " $BASE_URL /v1/messages" \
4351+ -H ' Content-Type: application/json' \
4352+ -d ' {"model":"gpt-4.1-nano","max_tokens":64,"stop_sequences":["QA_STOP_HERE"],"messages":[{"role":"user","content":"Count from 1 to 10, one number per line. After the number 3 write the exact text QA_STOP_HERE then continue."}]}' \
4353+ > " $RESP_FILE "
4354+ jq ' {stop_reason,stop_sequence}' " $RESP_FILE "
4355+ jq -e ' .stop_reason == "end_turn" and .stop_sequence == null' " $RESP_FILE " > /dev/null
4356+ ```
4357+
4358+ ### S179 ` GET /v1/models ` renders the Anthropic shape for Anthropic SDK clients
4359+
4360+ The Anthropic SDK always sends ` anthropic-version ` ; checks the response takes
4361+ the Anthropic list shape (` type ` , ` display_name ` , ` created_at ` ,
4362+ ` has_more ` /` first_id ` /` last_id ` ) instead of the OpenAI shape.
4363+
4364+ ``` bash
4365+ RESP_FILE=" $QA_RUN_DIR /s179.models.json"
4366+ curl -fsS " $BASE_URL /v1/models" -H ' anthropic-version: 2023-06-01' > " $RESP_FILE "
4367+ jq ' {sample: .data[0], has_more, first_id, last_id}' " $RESP_FILE "
4368+ jq -e '
4369+ (.data | length) > 0
4370+ and .data[0].type == "model"
4371+ and (.data[0].display_name | type == "string")
4372+ and (.data[0] | has("object") | not)
4373+ and .has_more == false
4374+ and (.first_id != null)
4375+ and (.last_id != null)
4376+ ' " $RESP_FILE " > /dev/null
4377+ ```
4378+
4379+ ### S180 ` GET /v1/models ` stays OpenAI-shaped without the header (regression)
4380+
4381+ Checks the default OpenAI-compatible listing shape is unchanged for callers
4382+ that do not send ` anthropic-version ` .
4383+
4384+ ``` bash
4385+ RESP_FILE=" $QA_RUN_DIR /s180.models.json"
4386+ curl -fsS " $BASE_URL /v1/models" > " $RESP_FILE "
4387+ jq -e ' .object == "list" and (.data[0].object == "model")' " $RESP_FILE " > /dev/null
4388+ ```
4389+
4390+ ### S181 Unknown route 404 renders in the caller's wire dialect
4391+
4392+ Checks the canonical 404 envelope added for unclassified routes: Anthropic
4393+ shape when ` anthropic-version ` is present, gateway/OpenAI shape otherwise.
4394+
4395+ ``` bash
4396+ ANTHROPIC_BODY=" $QA_RUN_DIR /s181.anthropic.json"
4397+ DEFAULT_BODY=" $QA_RUN_DIR /s181.default.json"
4398+ curl -sS -o " $ANTHROPIC_BODY " -w ' %{http_code}\n' " $BASE_URL /v1/does-not-exist" -H ' anthropic-version: 2023-06-01'
4399+ curl -sS -o " $DEFAULT_BODY " -w ' %{http_code}\n' " $BASE_URL /v1/does-not-exist"
4400+ jq ' .' " $ANTHROPIC_BODY "
4401+ jq ' .' " $DEFAULT_BODY "
4402+ jq -e ' .type == "error" and .error.type == "not_found_error"' " $ANTHROPIC_BODY " > /dev/null
4403+ jq -e ' (.type != "error") and .error.type == "not_found_error"' " $DEFAULT_BODY " > /dev/null
4404+ ```
4405+
4406+ ### S182 Known route with the wrong method still returns 405 (regression)
4407+
4408+ The dialect-aware 404 handler is registered as the router-level
4409+ ` NotFoundHandler ` , not a wildcard route, specifically so it does not shadow
4410+ echo's 405 method-not-allowed handling for routes that do exist.
4411+
4412+ ``` bash
4413+ HEADERS_FILE=" $QA_RUN_DIR /s182.headers"
4414+ curl -sS -D " $HEADERS_FILE " -o /dev/null -X GET " $BASE_URL /v1/chat/completions"
4415+ grep -Eiq ' ^HTTP/.* 405 ' " $HEADERS_FILE "
4416+ ```
4417+
4418+ ## 25. Anthropic Message Batches API
4419+
4420+ These scenarios exercise ` /v1/messages/batches* ` , the Anthropic-dialect ingress
4421+ over the same native-batch pipeline that serves ` /v1/batches ` . A batch's
4422+ requests are translated per-item to canonical chat requests, so a Message
4423+ Batch can route to any provider with native batch support, not only
4424+ Anthropic. Batch IDs are pure prefix aliases of one underlying resource:
4425+ ` msgbatch_<uuid> ` on this dialect, ` batch_<uuid> ` on ` /v1/batches ` . Real
4426+ provider batches can take a long time to complete, so these scenarios check
4427+ create/get/list/cancel/delete-guard/validation behavior rather than waiting
4428+ for a batch to end; ` S183 ` -` S191 ` are self-contained and rerunnable in any
4429+ order but leave ` in_progress ` /` canceling ` batches behind, like ` S47 ` -` S48 ` .
4430+
4431+ ### S183 Create a native Anthropic Message Batch
4432+
4433+ ``` bash
4434+ RESP_FILE=" $QA_RUN_DIR /s183.batch.json"
4435+ curl -fsS " $BASE_URL /v1/messages/batches" \
4436+ -H ' Content-Type: application/json' \
4437+ -d ' {"requests":[{"custom_id":"qa-msgbatch-anthropic-1","params":{"model":"claude-sonnet-4-6","max_tokens":32,"messages":[{"role":"user","content":"Reply with exactly QA_MSGBATCH_ANTHROPIC_OK"}]}}]}' \
4438+ > " $RESP_FILE "
4439+ jq ' {id,type,processing_status,request_counts}' " $RESP_FILE "
4440+ jq -e ' .type == "message_batch" and (.id | startswith("msgbatch_")) and (.processing_status | type == "string")' " $RESP_FILE " > /dev/null
4441+ echo " $( jq -r .id " $RESP_FILE " ) " > " $QA_RUN_DIR /s183.batch-id"
4442+ ```
4443+
4444+ ### S184 Create a Message Batch routed to an OpenAI model (cross-provider)
4445+
4446+ Checks the Anthropic Message Batches dialect is provider-agnostic like
4447+ ` /v1/messages ` : an OpenAI model batch is created and materialized into an
4448+ uploaded JSONL input file under the hood.
4449+
4450+ ``` bash
4451+ RESP_FILE=" $QA_RUN_DIR /s184.batch.json"
4452+ curl -fsS " $BASE_URL /v1/messages/batches" \
4453+ -H ' Content-Type: application/json' \
4454+ -d ' {"requests":[{"custom_id":"qa-msgbatch-openai-1","params":{"model":"gpt-4.1-nano","max_tokens":32,"messages":[{"role":"user","content":"Reply with exactly QA_MSGBATCH_OPENAI_OK"}]}}]}' \
4455+ > " $RESP_FILE "
4456+ jq ' {id,type,processing_status}' " $RESP_FILE "
4457+ jq -e ' .type == "message_batch" and (.id | startswith("msgbatch_"))' " $RESP_FILE " > /dev/null
4458+ echo " $( jq -r .id " $RESP_FILE " ) " > " $QA_RUN_DIR /s184.batch-id"
4459+ ```
4460+
4461+ ### S185 Get and list Message Batches
4462+
4463+ ``` bash
4464+ BATCH_ID=$( cat " $QA_RUN_DIR /s183.batch-id" )
4465+ GET_FILE=" $QA_RUN_DIR /s185.get.json"
4466+ LIST_FILE=" $QA_RUN_DIR /s185.list.json"
4467+ curl -fsS " $BASE_URL /v1/messages/batches/$BATCH_ID " > " $GET_FILE "
4468+ jq -e --arg id " $BATCH_ID " ' .id == $id and .type == "message_batch" and (.expires_at | type == "string")' " $GET_FILE " > /dev/null
4469+
4470+ curl -fsS " $BASE_URL /v1/messages/batches?limit=20" > " $LIST_FILE "
4471+ jq ' {has_more,first_id,last_id,count:(.data|length)}' " $LIST_FILE "
4472+ jq -e --arg id " $BATCH_ID " ' [.data[].id] | index($id) != null' " $LIST_FILE " > /dev/null
4473+ ```
4474+
4475+ ### S186 Message Batch IDs are dialect aliases of ` /v1/batches ` resources
4476+
4477+ Checks the ` msgbatch_ ` /` batch_ ` prefix aliasing holds in both directions: a
4478+ batch created on one dialect is retrievable on the other under the mapped ID.
4479+
4480+ ``` bash
4481+ BATCH_ID=$( cat " $QA_RUN_DIR /s183.batch-id" )
4482+ ALIASED_FILE=" $QA_RUN_DIR /s186.aliased.json"
4483+ curl -fsS " $BASE_URL /v1/batches/batch_${BATCH_ID# msgbatch_} " > " $ALIASED_FILE "
4484+ jq -e --arg id " batch_${BATCH_ID# msgbatch_} " ' .id == $id and .object == "batch" and .provider == "anthropic"' " $ALIASED_FILE " > /dev/null
4485+
4486+ CREATE_FILE=" $QA_RUN_DIR /s186.create.json"
4487+ curl -fsS " $BASE_URL /v1/batches" \
4488+ -H ' Content-Type: application/json' \
4489+ -d ' {"endpoint":"/v1/chat/completions","requests":[{"custom_id":"qa-reverse-alias-1","method":"POST","url":"/v1/chat/completions","body":{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"Reply with exactly QA_REVERSE_BATCH_OK"}],"max_tokens":32}}]}' \
4490+ > " $CREATE_FILE "
4491+ NATIVE_ID=$( jq -er ' .id' " $CREATE_FILE " )
4492+ REVERSE_FILE=" $QA_RUN_DIR /s186.reverse.json"
4493+ curl -fsS " $BASE_URL /v1/messages/batches/msgbatch_${NATIVE_ID# batch_} " > " $REVERSE_FILE "
4494+ jq -e --arg id " msgbatch_${NATIVE_ID# batch_} " ' .id == $id and .type == "message_batch"' " $REVERSE_FILE " > /dev/null
4495+ ```
4496+
4497+ ### S187 Cancel a Message Batch
4498+
4499+ ``` bash
4500+ BATCH_ID=$( cat " $QA_RUN_DIR /s184.batch-id" )
4501+ RESP_FILE=" $QA_RUN_DIR /s187.cancel.json"
4502+ curl -fsS -X POST " $BASE_URL /v1/messages/batches/$BATCH_ID /cancel" > " $RESP_FILE "
4503+ jq ' {id,processing_status,cancel_initiated_at}' " $RESP_FILE "
4504+ jq -e --arg id " $BATCH_ID " ' .id == $id and (.processing_status == "canceling" or .processing_status == "ended")' " $RESP_FILE " > /dev/null
4505+ ```
4506+
4507+ ### S188 Delete guard rejects a still-processing Message Batch (negative)
4508+
4509+ Batches still processing must be canceled first, matching the Anthropic
4510+ Message Batches contract.
4511+
4512+ ``` bash
4513+ BATCH_ID=$( cat " $QA_RUN_DIR /s183.batch-id" )
4514+ HEADERS_FILE=" $QA_RUN_DIR /s188.headers"
4515+ BODY_FILE=" $QA_RUN_DIR /s188.body"
4516+ curl -sS -D " $HEADERS_FILE " -o " $BODY_FILE " -X DELETE " $BASE_URL /v1/messages/batches/$BATCH_ID "
4517+ cat " $BODY_FILE "
4518+ grep -Eiq ' ^HTTP/.* 400 ' " $HEADERS_FILE "
4519+ jq -e ' .type == "error" and .error.type == "invalid_request_error" and (.error.message | test("still processing"))' " $BODY_FILE " > /dev/null
4520+ ```
4521+
4522+ ### S189 Message Batch create validation negatives
4523+
4524+ ``` bash
4525+ HEADERS_FILE=" $QA_RUN_DIR /s189.headers"
4526+ BODY_FILE=" $QA_RUN_DIR /s189.body"
4527+
4528+ curl -sS -D " $HEADERS_FILE " -o " $BODY_FILE " " $BASE_URL /v1/messages/batches" \
4529+ -H ' Content-Type: application/json' -d ' {"requests":[]}'
4530+ grep -Eiq ' ^HTTP/.* 400 ' " $HEADERS_FILE "
4531+
4532+ curl -sS -D " $HEADERS_FILE " -o " $BODY_FILE " " $BASE_URL /v1/messages/batches" \
4533+ -H ' Content-Type: application/json' \
4534+ -d ' {"requests":[{"custom_id":"dup","params":{"model":"gpt-4.1-nano","max_tokens":10,"messages":[{"role":"user","content":"a"}]}},{"custom_id":"dup","params":{"model":"gpt-4.1-nano","max_tokens":10,"messages":[{"role":"user","content":"b"}]}}]}'
4535+ grep -Eiq ' ^HTTP/.* 400 ' " $HEADERS_FILE "
4536+ jq -e ' .error.message | test("not unique")' " $BODY_FILE " > /dev/null
4537+
4538+ curl -sS -D " $HEADERS_FILE " -o " $BODY_FILE " " $BASE_URL /v1/messages/batches" \
4539+ -H ' Content-Type: application/json' \
4540+ -d ' {"requests":[{"custom_id":" ","params":{"model":"gpt-4.1-nano","max_tokens":10,"messages":[{"role":"user","content":"a"}]}}]}'
4541+ grep -Eiq ' ^HTTP/.* 400 ' " $HEADERS_FILE "
4542+ jq -e ' .error.message | test("required")' " $BODY_FILE " > /dev/null
4543+ ```
4544+
4545+ ### S190 Message Batch results before ready returns the not-ready envelope (negative)
4546+
4547+ ``` bash
4548+ BATCH_ID=$( cat " $QA_RUN_DIR /s183.batch-id" )
4549+ HEADERS_FILE=" $QA_RUN_DIR /s190.headers"
4550+ BODY_FILE=" $QA_RUN_DIR /s190.body"
4551+ curl -sS -D " $HEADERS_FILE " -o " $BODY_FILE " " $BASE_URL /v1/messages/batches/$BATCH_ID /results"
4552+ cat " $BODY_FILE "
4553+ grep -Eiq ' ^HTTP/.* 409 ' " $HEADERS_FILE "
4554+ jq -e ' .error.type == "invalid_request_error" and (.error.message | test("not ready"))' " $BODY_FILE " > /dev/null
4555+ ```
4556+
4557+ ### S191 Mixed-provider requests in one Message Batch are rejected (negative)
4558+
4559+ A single native batch is submitted to one upstream provider; checks a batch
4560+ mixing an Anthropic and an OpenAI model item is rejected before submission,
4561+ the same discipline as the file-based ` /v1/batches ` mixed-provider check
4562+ (` S48 ` ).
4563+
4564+ ``` bash
4565+ HEADERS_FILE=" $QA_RUN_DIR /s191.headers"
4566+ BODY_FILE=" $QA_RUN_DIR /s191.body"
4567+ curl -sS -D " $HEADERS_FILE " -o " $BODY_FILE " " $BASE_URL /v1/messages/batches" \
4568+ -H ' Content-Type: application/json' \
4569+ -d ' {"requests":[{"custom_id":"qa-mixed-a","params":{"model":"claude-sonnet-4-6","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}},{"custom_id":"qa-mixed-b","params":{"model":"gpt-4.1-nano","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}}]}'
4570+ cat " $BODY_FILE "
4571+ grep -Eiq ' ^HTTP/.* 400 ' " $HEADERS_FILE "
4572+ jq -e ' .error.message | test("single provider per batch")' " $BODY_FILE " > /dev/null
4573+ ```
0 commit comments