test(e2e): C7 /v1/responses dispatch + provider mismatch (#151) - #163

Merged
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses
May 9, 2026
Merged

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151)#163
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses

Conversation

@moonming

@moonmingmoonming commented May 9, 2026

Copy link
Copy Markdown
Member

Summary

Second endpoint covered from #151's C7 row. `/v1/responses` is OpenAI's newer endpoint (introduced 2024) and the recommended path for new integrations — rapidly displacing `/v1/chat/completions`. Prior to this PR the gateway had zero e2e coverage on `/v1/responses`.

All 4 cases derived directly from the gateway's published contract in `docs/api-proxy.md` §4.6 + §2 status→type table.

What's pinned

CaseUser journeyAsserts
OpenAI provider happy pathPOST `/v1/responses` with OpenAI-provider ModelCaller gets OpenAI-Responses-shape body byte-for-byte (`object: "response"`, `id` round-trips exactly, `output[0].type: "message"`, `content[0].type: "output_text"`, reply text exact, `usage` using Responses-vocabulary `input_tokens` / `output_tokens` / `total_tokens`); gateway hits `/v1/responses` exactly once with translated `model_name` and `Bearer sk-mock`; caller's input verbatim
Non-OpenAI: anthropicPOST `/v1/responses` with anthropic Model400 + `error.type: "invalid_request_error"`; upstream never hit
Non-OpenAI: geminiPOST `/v1/responses` with gemini Modelsame
Non-OpenAI: deepseekPOST `/v1/responses` with deepseek Modelsame

The non-OpenAI matrix is parametrized across all three non-OpenAI providers per docs §6 (anthropic, gemini, deepseek). Critical for gemini and deepseek specifically: their bridges do speak OpenAI wire shape upstream, so a regression that "just dispatched anyway" instead of refusing per §4.6 would silently 200 from the upstream-compat layer, billing the caller and breaking the published contract.

Why these matter

Three regression modes that were unverified before:

  • Mis-route through `/v1/chat/completions` — the body shapes are different (`input` vs `messages`). Without an explicit path assertion this would slip past against a permissive mock.
  • Wrong usage field names — a regression that ran the response through chat-completions translation (`prompt_tokens` instead of `input_tokens`) would silently break every Responses-API caller's billing logic.
  • Non-OpenAI dispatched silently — gemini and deepseek bridges speak OpenAI wire upstream; a regression bypassing the §4.6 refusal would 200 with billing impact while violating the documented contract.

Source-blind discipline

Every assertion derives from external contracts:

No internal Rust paths or struct field names referenced.

Note on SDK

The OpenAI Node SDK 4.65 (this project's pinned version) doesn't yet expose `client.responses.create()` — that method was added in a later SDK release. The test uses raw `fetch` to hit `/v1/responses` directly. This still exercises the real gateway end-to-end; the SDK call layer is a thin convenience wrapper for the same wire shape.

Independent audit

Per CLAUDE.md §8, an independent audit agent reviewed the initial commit (6255e19). Resolution log:

FindingSeverityResolution
`error.type` left loose despite docs §2 publishing 400 → `invalid_request_error`HIGHTightened to `expect(body.error?.type).toBe("invalid_request_error")` (cb9b451)
Probe comment misleadingly claimed "model not found 400" — but `model_not_found` is documented as 404, not 400MEDIUMRewrote comment to reflect actual disambiguation (cb9b451)
Provider matrix gap: only anthropic tested for mismatch; gemini/deepseek bridges do speak OpenAI wire upstream and a bypass would silently 200MEDIUMParametrized to all 3 non-OpenAI providers (cb9b451)
`body.id` round-trip not pinnedLOWPinned `expect(body.id).toBe("resp_e2e_01")` (cb9b451)
`created_at` round-trip / streaming / tools / multi-turn coverageLOWNot addressed; deferred to follow-up rows

Test plan

  • `npm test` (full e2e suite) — 32/32 passing locally (was 28)
  • No mock-data-only paths: each case still exercises the real `aisix` binary, real etcd config propagation, real fetch reverse-call against `/v1/responses`
  • CI green

Refs #151.

Second endpoint covered from #151's C7 row. /v1/responses is
OpenAI's newer endpoint (introduced 2024) and is the recommended
path for new integrations — rapidly displacing /v1/chat/completions
in new code. Prior to this file the gateway had **zero** e2e
coverage on /v1/responses.
Two user journeys pinned, both derived from the gateway's own
published contract in `docs/api-proxy.md` §4.6:
> Native OpenAI Responses API. OpenAI Models only — non-OpenAI
> providers return 400.
Case 1 — happy path on OpenAI provider:
Caller POSTs /v1/responses with the OpenAI Responses-shape body
(`{model, input}`). Gateway dispatches to upstream's
/v1/responses (NOT /v1/chat/completions — a regression that
mis-routed via chat would surface as `object: "chat.completion"`
on the response). Caller receives the upstream's
Responses-shape body byte-for-byte:
- `object === "response"` (distinct envelope from chat)
- `output[0].type === "message"`, `output[0].role === "assistant"`
- `output[0].content[0].type === "output_text"` per spec
- Reply text byte-for-byte
- Usage uses `input_tokens` / `output_tokens` / `total_tokens`
(different field names from chat's `prompt_tokens` /
`completion_tokens` — a regression that translated through
chat-completions field names would mismatch)
Upstream-side: gateway hit `/v1/responses` exactly once with
`Bearer sk-mock`, body has display name → upstream model_name
translation, caller's input reaches upstream verbatim.
Case 2 — provider mismatch (anthropic Model on /v1/responses):
Per docs §4.6, non-OpenAI providers must return 400. Caller
sees 400 with OpenAI-shape error envelope (`error.type` /
`error.message` non-empty); upstream MUST NOT be hit (the
whole point of the restriction is that OpenAI-Responses-shape
doesn't translate to Anthropic Messages today).
References:
- OpenAI Responses API spec
<https://platform.openai.com/docs/api-reference/responses>
- Gateway's /v1/responses contract: `docs/api-proxy.md` §4.6
- OpenAI error envelope spec
<https://platform.openai.com/docs/guides/error-codes/api-errors>
Note on SDK: the OpenAI Node SDK 4.65 (the version pinned by
this project) doesn't yet expose `client.responses.create()` —
`client.responses` was added in a later SDK release. The test
uses raw `fetch` to hit `/v1/responses` directly. This still
exercises the real gateway end-to-end; the SDK-call layer is
just a thin convenience wrapper for the same wire shape.
Refs #151
CopilotAI review requested due to automatic review settings May 9, 2026 13:38
@coderabbitai

coderabbitaiBot commented May 9, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 48 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: e0a76037-443c-4403-97aa-e4739051c3de

📥 Commits

Reviewing files that changed from the base of the PR and between 2d0f188 and cb9b451.

📒 Files selected for processing (1)
  • tests/e2e/src/cases/responses-endpoint-e2e.test.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first end-to-end coverage for the gateway’s POST /v1/responses proxy surface, validating both correct dispatch for OpenAI-backed models and correct early rejection for non-OpenAI providers per docs/api-proxy.md §4.6.

Changes:

  • Introduces an e2e test that pins /v1/responses dispatch to the upstream /v1/responses path and validates key OpenAI Responses envelope/usage fields.
  • Introduces an e2e test that pins the “OpenAI-only” restriction by asserting a 400 OpenAI-shaped error envelope and that the upstream is not contacted for an Anthropic model.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +248 to +252
// Readiness gate: poll until the gateway returns the
// documented 400, not a model-not-found 400 from snapshot lag.
// Disambiguate by checking the error envelope is fully formed
// (a snapshot-lag 400 might have a different message style).
await waitConfigPropagation(async () => {
Comment on lines +76 to +80
test("OpenAI provider: caller receives upstream Responses body byte-for-byte", async (ctx) => {
if (!etcdReachable || !app || !admin) {
ctx.skip();
return;
}
Audit (per CLAUDE.md §8) found one HIGH, two MEDIUM, three LOW.
Resolutions:
HIGH H1 (error.type left loose despite a documented value in the
gateway's own contract):
Pinned `error.type === "invalid_request_error"` per docs §2
status→type table (400 → invalid_request_error). The published
table makes this unambiguous; loose "non-empty string" let
regressions to "service_unavailable", "provider_error", or any
new vocabulary slip through. Same pinning convention
body-edges-e2e and error-envelope-normalization-e2e use.
MEDIUM M1 (misleading probe comment):
Probe comment claimed "model not found 400" — but per docs §2,
model_not_found is mapped to 404, NOT 400. Rewrote comment to
reflect the actual disambiguation (404 = snapshot lag, 400 +
invalid_request_error = §4.6 OpenAI-only refusal).
MEDIUM M2 (provider matrix coverage gap):
Promoted the non-OpenAI provider case from a single anthropic
test to a parametrized matrix covering all three non-OpenAI
providers per docs §6 (anthropic, gemini, deepseek). Critical
for gemini and deepseek specifically: their bridges DO speak
OpenAI wire shape upstream, so a regression that "just
dispatched anyway" instead of refusing per §4.6 would silently
200 from the upstream-compat layer, billing the caller and
breaking the published contract.
LOW L1 (id round-trip not pinned):
Pinned `body.id === "resp_e2e_01"` byte-for-byte. A regression
that re-issued ids during gateway-side normalization would
break SDK paginators and webhook callbacks that key off
response id.
LOW L2-L3 (created_at, streaming/tools/multi-turn coverage):
Not addressed (created_at is a minor add; streaming/tools/
multi-turn deferred to follow-up rows).
All 32 e2e tests still pass locally (was 30; this PR now ships
4 cases — 1 happy path + 3 non-OpenAI mismatch).
@moonming
moonming merged commit b691bf7 into mainMay 9, 2026
6 checks passed
@moonming
moonming deleted the test/e2e-c7-responses branch May 9, 2026 13:51
moonming added a commit that referenced this pull request May 9, 2026
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
moonming added a commit that referenced this pull request May 9, 2026
… (#169)
* test(harness): raise waitConfigPropagation budget 5s → 10s (#157)
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
* test(harness): also reduce maxForks 4→2 (#157 fallback escalation)
After the timeout bump landed, #169's own CI run still flaked with
the same `condition not met within 10s` error — meaning etcd watch
dispatch latency genuinely exceeds 10s under maxForks=4 with the
current suite size (20+ files). The audit on #169 had flagged this
as the "product-side hypothesis still open" — confirmed.
Apply the documented fallback from #157: cut maxForks from 4 to 2.
This halves the concurrent-watcher count against the shared etcd
and brings dispatch latency back inside the budget.
Trade-off: wall time grows ~1.5-2× per CI run (locally measured
~14s @ maxForks=4 vs ~30s @ maxForks=2, so faster than expected
because the extra parallelism wasn't fully utilized anyway —
contention dominated). Net for CI is "predictable green" vs
"fast but constantly-rerunning".
The 10s `waitConfigPropagation` budget from the prior commit stays
in place as belt-and-suspenders — even with maxForks=2 the original
5s would still be tight on slow runners.
Combined this PR now does:
1. waitConfigPropagation deadline 5s → 10s (`harness/admin.ts`)
2. maxForks 4 → 2 (`vitest.config.ts`)
The product-side hypothesis ("etcd watch dispatch degrades
non-linearly with concurrent watchers") is now strongly supported
and worth a separate product-side investigation tracked in #157
follow-up.
Sign up for freeto 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.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151) - #163

Merged
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses
May 9, 2026
Merged

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151)#163
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses

Conversation

@moonming

@moonmingmoonming commented May 9, 2026

Copy link
Copy Markdown
Member

Summary

Second endpoint covered from #151's C7 row. `/v1/responses` is OpenAI's newer endpoint (introduced 2024) and the recommended path for new integrations — rapidly displacing `/v1/chat/completions`. Prior to this PR the gateway had zero e2e coverage on `/v1/responses`.

All 4 cases derived directly from the gateway's published contract in `docs/api-proxy.md` §4.6 + §2 status→type table.

What's pinned

CaseUser journeyAsserts
OpenAI provider happy pathPOST `/v1/responses` with OpenAI-provider ModelCaller gets OpenAI-Responses-shape body byte-for-byte (`object: "response"`, `id` round-trips exactly, `output[0].type: "message"`, `content[0].type: "output_text"`, reply text exact, `usage` using Responses-vocabulary `input_tokens` / `output_tokens` / `total_tokens`); gateway hits `/v1/responses` exactly once with translated `model_name` and `Bearer sk-mock`; caller's input verbatim
Non-OpenAI: anthropicPOST `/v1/responses` with anthropic Model400 + `error.type: "invalid_request_error"`; upstream never hit
Non-OpenAI: geminiPOST `/v1/responses` with gemini Modelsame
Non-OpenAI: deepseekPOST `/v1/responses` with deepseek Modelsame

The non-OpenAI matrix is parametrized across all three non-OpenAI providers per docs §6 (anthropic, gemini, deepseek). Critical for gemini and deepseek specifically: their bridges do speak OpenAI wire shape upstream, so a regression that "just dispatched anyway" instead of refusing per §4.6 would silently 200 from the upstream-compat layer, billing the caller and breaking the published contract.

Why these matter

Three regression modes that were unverified before:

  • Mis-route through `/v1/chat/completions` — the body shapes are different (`input` vs `messages`). Without an explicit path assertion this would slip past against a permissive mock.
  • Wrong usage field names — a regression that ran the response through chat-completions translation (`prompt_tokens` instead of `input_tokens`) would silently break every Responses-API caller's billing logic.
  • Non-OpenAI dispatched silently — gemini and deepseek bridges speak OpenAI wire upstream; a regression bypassing the §4.6 refusal would 200 with billing impact while violating the documented contract.

Source-blind discipline

Every assertion derives from external contracts:

No internal Rust paths or struct field names referenced.

Note on SDK

The OpenAI Node SDK 4.65 (this project's pinned version) doesn't yet expose `client.responses.create()` — that method was added in a later SDK release. The test uses raw `fetch` to hit `/v1/responses` directly. This still exercises the real gateway end-to-end; the SDK call layer is a thin convenience wrapper for the same wire shape.

Independent audit

Per CLAUDE.md §8, an independent audit agent reviewed the initial commit (6255e19). Resolution log:

FindingSeverityResolution
`error.type` left loose despite docs §2 publishing 400 → `invalid_request_error`HIGHTightened to `expect(body.error?.type).toBe("invalid_request_error")` (cb9b451)
Probe comment misleadingly claimed "model not found 400" — but `model_not_found` is documented as 404, not 400MEDIUMRewrote comment to reflect actual disambiguation (cb9b451)
Provider matrix gap: only anthropic tested for mismatch; gemini/deepseek bridges do speak OpenAI wire upstream and a bypass would silently 200MEDIUMParametrized to all 3 non-OpenAI providers (cb9b451)
`body.id` round-trip not pinnedLOWPinned `expect(body.id).toBe("resp_e2e_01")` (cb9b451)
`created_at` round-trip / streaming / tools / multi-turn coverageLOWNot addressed; deferred to follow-up rows

Test plan

  • `npm test` (full e2e suite) — 32/32 passing locally (was 28)
  • No mock-data-only paths: each case still exercises the real `aisix` binary, real etcd config propagation, real fetch reverse-call against `/v1/responses`
  • CI green

Refs #151.

Second endpoint covered from #151's C7 row. /v1/responses is
OpenAI's newer endpoint (introduced 2024) and is the recommended
path for new integrations — rapidly displacing /v1/chat/completions
in new code. Prior to this file the gateway had **zero** e2e
coverage on /v1/responses.
Two user journeys pinned, both derived from the gateway's own
published contract in `docs/api-proxy.md` §4.6:
> Native OpenAI Responses API. OpenAI Models only — non-OpenAI
> providers return 400.
Case 1 — happy path on OpenAI provider:
Caller POSTs /v1/responses with the OpenAI Responses-shape body
(`{model, input}`). Gateway dispatches to upstream's
/v1/responses (NOT /v1/chat/completions — a regression that
mis-routed via chat would surface as `object: "chat.completion"`
on the response). Caller receives the upstream's
Responses-shape body byte-for-byte:
- `object === "response"` (distinct envelope from chat)
- `output[0].type === "message"`, `output[0].role === "assistant"`
- `output[0].content[0].type === "output_text"` per spec
- Reply text byte-for-byte
- Usage uses `input_tokens` / `output_tokens` / `total_tokens`
(different field names from chat's `prompt_tokens` /
`completion_tokens` — a regression that translated through
chat-completions field names would mismatch)
Upstream-side: gateway hit `/v1/responses` exactly once with
`Bearer sk-mock`, body has display name → upstream model_name
translation, caller's input reaches upstream verbatim.
Case 2 — provider mismatch (anthropic Model on /v1/responses):
Per docs §4.6, non-OpenAI providers must return 400. Caller
sees 400 with OpenAI-shape error envelope (`error.type` /
`error.message` non-empty); upstream MUST NOT be hit (the
whole point of the restriction is that OpenAI-Responses-shape
doesn't translate to Anthropic Messages today).
References:
- OpenAI Responses API spec
<https://platform.openai.com/docs/api-reference/responses>
- Gateway's /v1/responses contract: `docs/api-proxy.md` §4.6
- OpenAI error envelope spec
<https://platform.openai.com/docs/guides/error-codes/api-errors>
Note on SDK: the OpenAI Node SDK 4.65 (the version pinned by
this project) doesn't yet expose `client.responses.create()` —
`client.responses` was added in a later SDK release. The test
uses raw `fetch` to hit `/v1/responses` directly. This still
exercises the real gateway end-to-end; the SDK-call layer is
just a thin convenience wrapper for the same wire shape.
Refs #151
CopilotAI review requested due to automatic review settings May 9, 2026 13:38
@coderabbitai

coderabbitaiBot commented May 9, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 48 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: e0a76037-443c-4403-97aa-e4739051c3de

📥 Commits

Reviewing files that changed from the base of the PR and between 2d0f188 and cb9b451.

📒 Files selected for processing (1)
  • tests/e2e/src/cases/responses-endpoint-e2e.test.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first end-to-end coverage for the gateway’s POST /v1/responses proxy surface, validating both correct dispatch for OpenAI-backed models and correct early rejection for non-OpenAI providers per docs/api-proxy.md §4.6.

Changes:

  • Introduces an e2e test that pins /v1/responses dispatch to the upstream /v1/responses path and validates key OpenAI Responses envelope/usage fields.
  • Introduces an e2e test that pins the “OpenAI-only” restriction by asserting a 400 OpenAI-shaped error envelope and that the upstream is not contacted for an Anthropic model.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +248 to +252
// Readiness gate: poll until the gateway returns the
// documented 400, not a model-not-found 400 from snapshot lag.
// Disambiguate by checking the error envelope is fully formed
// (a snapshot-lag 400 might have a different message style).
await waitConfigPropagation(async () => {
Comment on lines +76 to +80
test("OpenAI provider: caller receives upstream Responses body byte-for-byte", async (ctx) => {
if (!etcdReachable || !app || !admin) {
ctx.skip();
return;
}
Audit (per CLAUDE.md §8) found one HIGH, two MEDIUM, three LOW.
Resolutions:
HIGH H1 (error.type left loose despite a documented value in the
gateway's own contract):
Pinned `error.type === "invalid_request_error"` per docs §2
status→type table (400 → invalid_request_error). The published
table makes this unambiguous; loose "non-empty string" let
regressions to "service_unavailable", "provider_error", or any
new vocabulary slip through. Same pinning convention
body-edges-e2e and error-envelope-normalization-e2e use.
MEDIUM M1 (misleading probe comment):
Probe comment claimed "model not found 400" — but per docs §2,
model_not_found is mapped to 404, NOT 400. Rewrote comment to
reflect the actual disambiguation (404 = snapshot lag, 400 +
invalid_request_error = §4.6 OpenAI-only refusal).
MEDIUM M2 (provider matrix coverage gap):
Promoted the non-OpenAI provider case from a single anthropic
test to a parametrized matrix covering all three non-OpenAI
providers per docs §6 (anthropic, gemini, deepseek). Critical
for gemini and deepseek specifically: their bridges DO speak
OpenAI wire shape upstream, so a regression that "just
dispatched anyway" instead of refusing per §4.6 would silently
200 from the upstream-compat layer, billing the caller and
breaking the published contract.
LOW L1 (id round-trip not pinned):
Pinned `body.id === "resp_e2e_01"` byte-for-byte. A regression
that re-issued ids during gateway-side normalization would
break SDK paginators and webhook callbacks that key off
response id.
LOW L2-L3 (created_at, streaming/tools/multi-turn coverage):
Not addressed (created_at is a minor add; streaming/tools/
multi-turn deferred to follow-up rows).
All 32 e2e tests still pass locally (was 30; this PR now ships
4 cases — 1 happy path + 3 non-OpenAI mismatch).
@moonming
moonming merged commit b691bf7 into mainMay 9, 2026
6 checks passed
@moonming
moonming deleted the test/e2e-c7-responses branch May 9, 2026 13:51
moonming added a commit that referenced this pull request May 9, 2026
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
moonming added a commit that referenced this pull request May 9, 2026
… (#169)
* test(harness): raise waitConfigPropagation budget 5s → 10s (#157)
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
* test(harness): also reduce maxForks 4→2 (#157 fallback escalation)
After the timeout bump landed, #169's own CI run still flaked with
the same `condition not met within 10s` error — meaning etcd watch
dispatch latency genuinely exceeds 10s under maxForks=4 with the
current suite size (20+ files). The audit on #169 had flagged this
as the "product-side hypothesis still open" — confirmed.
Apply the documented fallback from #157: cut maxForks from 4 to 2.
This halves the concurrent-watcher count against the shared etcd
and brings dispatch latency back inside the budget.
Trade-off: wall time grows ~1.5-2× per CI run (locally measured
~14s @ maxForks=4 vs ~30s @ maxForks=2, so faster than expected
because the extra parallelism wasn't fully utilized anyway —
contention dominated). Net for CI is "predictable green" vs
"fast but constantly-rerunning".
The 10s `waitConfigPropagation` budget from the prior commit stays
in place as belt-and-suspenders — even with maxForks=2 the original
5s would still be tight on slow runners.
Combined this PR now does:
1. waitConfigPropagation deadline 5s → 10s (`harness/admin.ts`)
2. maxForks 4 → 2 (`vitest.config.ts`)
The product-side hypothesis ("etcd watch dispatch degrades
non-linearly with concurrent watchers") is now strongly supported
and worth a separate product-side investigation tracked in #157
follow-up.
Sign up for freeto 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.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151) - #163

Merged
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses
May 9, 2026
Merged

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151)#163
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses

Conversation

@moonming

@moonmingmoonming commented May 9, 2026

Copy link
Copy Markdown
Member

Summary

Second endpoint covered from #151's C7 row. `/v1/responses` is OpenAI's newer endpoint (introduced 2024) and the recommended path for new integrations — rapidly displacing `/v1/chat/completions`. Prior to this PR the gateway had zero e2e coverage on `/v1/responses`.

All 4 cases derived directly from the gateway's published contract in `docs/api-proxy.md` §4.6 + §2 status→type table.

What's pinned

CaseUser journeyAsserts
OpenAI provider happy pathPOST `/v1/responses` with OpenAI-provider ModelCaller gets OpenAI-Responses-shape body byte-for-byte (`object: "response"`, `id` round-trips exactly, `output[0].type: "message"`, `content[0].type: "output_text"`, reply text exact, `usage` using Responses-vocabulary `input_tokens` / `output_tokens` / `total_tokens`); gateway hits `/v1/responses` exactly once with translated `model_name` and `Bearer sk-mock`; caller's input verbatim
Non-OpenAI: anthropicPOST `/v1/responses` with anthropic Model400 + `error.type: "invalid_request_error"`; upstream never hit
Non-OpenAI: geminiPOST `/v1/responses` with gemini Modelsame
Non-OpenAI: deepseekPOST `/v1/responses` with deepseek Modelsame

The non-OpenAI matrix is parametrized across all three non-OpenAI providers per docs §6 (anthropic, gemini, deepseek). Critical for gemini and deepseek specifically: their bridges do speak OpenAI wire shape upstream, so a regression that "just dispatched anyway" instead of refusing per §4.6 would silently 200 from the upstream-compat layer, billing the caller and breaking the published contract.

Why these matter

Three regression modes that were unverified before:

  • Mis-route through `/v1/chat/completions` — the body shapes are different (`input` vs `messages`). Without an explicit path assertion this would slip past against a permissive mock.
  • Wrong usage field names — a regression that ran the response through chat-completions translation (`prompt_tokens` instead of `input_tokens`) would silently break every Responses-API caller's billing logic.
  • Non-OpenAI dispatched silently — gemini and deepseek bridges speak OpenAI wire upstream; a regression bypassing the §4.6 refusal would 200 with billing impact while violating the documented contract.

Source-blind discipline

Every assertion derives from external contracts:

No internal Rust paths or struct field names referenced.

Note on SDK

The OpenAI Node SDK 4.65 (this project's pinned version) doesn't yet expose `client.responses.create()` — that method was added in a later SDK release. The test uses raw `fetch` to hit `/v1/responses` directly. This still exercises the real gateway end-to-end; the SDK call layer is a thin convenience wrapper for the same wire shape.

Independent audit

Per CLAUDE.md §8, an independent audit agent reviewed the initial commit (6255e19). Resolution log:

FindingSeverityResolution
`error.type` left loose despite docs §2 publishing 400 → `invalid_request_error`HIGHTightened to `expect(body.error?.type).toBe("invalid_request_error")` (cb9b451)
Probe comment misleadingly claimed "model not found 400" — but `model_not_found` is documented as 404, not 400MEDIUMRewrote comment to reflect actual disambiguation (cb9b451)
Provider matrix gap: only anthropic tested for mismatch; gemini/deepseek bridges do speak OpenAI wire upstream and a bypass would silently 200MEDIUMParametrized to all 3 non-OpenAI providers (cb9b451)
`body.id` round-trip not pinnedLOWPinned `expect(body.id).toBe("resp_e2e_01")` (cb9b451)
`created_at` round-trip / streaming / tools / multi-turn coverageLOWNot addressed; deferred to follow-up rows

Test plan

  • `npm test` (full e2e suite) — 32/32 passing locally (was 28)
  • No mock-data-only paths: each case still exercises the real `aisix` binary, real etcd config propagation, real fetch reverse-call against `/v1/responses`
  • CI green

Refs #151.

Second endpoint covered from #151's C7 row. /v1/responses is
OpenAI's newer endpoint (introduced 2024) and is the recommended
path for new integrations — rapidly displacing /v1/chat/completions
in new code. Prior to this file the gateway had **zero** e2e
coverage on /v1/responses.
Two user journeys pinned, both derived from the gateway's own
published contract in `docs/api-proxy.md` §4.6:
> Native OpenAI Responses API. OpenAI Models only — non-OpenAI
> providers return 400.
Case 1 — happy path on OpenAI provider:
Caller POSTs /v1/responses with the OpenAI Responses-shape body
(`{model, input}`). Gateway dispatches to upstream's
/v1/responses (NOT /v1/chat/completions — a regression that
mis-routed via chat would surface as `object: "chat.completion"`
on the response). Caller receives the upstream's
Responses-shape body byte-for-byte:
- `object === "response"` (distinct envelope from chat)
- `output[0].type === "message"`, `output[0].role === "assistant"`
- `output[0].content[0].type === "output_text"` per spec
- Reply text byte-for-byte
- Usage uses `input_tokens` / `output_tokens` / `total_tokens`
(different field names from chat's `prompt_tokens` /
`completion_tokens` — a regression that translated through
chat-completions field names would mismatch)
Upstream-side: gateway hit `/v1/responses` exactly once with
`Bearer sk-mock`, body has display name → upstream model_name
translation, caller's input reaches upstream verbatim.
Case 2 — provider mismatch (anthropic Model on /v1/responses):
Per docs §4.6, non-OpenAI providers must return 400. Caller
sees 400 with OpenAI-shape error envelope (`error.type` /
`error.message` non-empty); upstream MUST NOT be hit (the
whole point of the restriction is that OpenAI-Responses-shape
doesn't translate to Anthropic Messages today).
References:
- OpenAI Responses API spec
<https://platform.openai.com/docs/api-reference/responses>
- Gateway's /v1/responses contract: `docs/api-proxy.md` §4.6
- OpenAI error envelope spec
<https://platform.openai.com/docs/guides/error-codes/api-errors>
Note on SDK: the OpenAI Node SDK 4.65 (the version pinned by
this project) doesn't yet expose `client.responses.create()` —
`client.responses` was added in a later SDK release. The test
uses raw `fetch` to hit `/v1/responses` directly. This still
exercises the real gateway end-to-end; the SDK-call layer is
just a thin convenience wrapper for the same wire shape.
Refs #151
CopilotAI review requested due to automatic review settings May 9, 2026 13:38
@coderabbitai

coderabbitaiBot commented May 9, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 48 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: e0a76037-443c-4403-97aa-e4739051c3de

📥 Commits

Reviewing files that changed from the base of the PR and between 2d0f188 and cb9b451.

📒 Files selected for processing (1)
  • tests/e2e/src/cases/responses-endpoint-e2e.test.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first end-to-end coverage for the gateway’s POST /v1/responses proxy surface, validating both correct dispatch for OpenAI-backed models and correct early rejection for non-OpenAI providers per docs/api-proxy.md §4.6.

Changes:

  • Introduces an e2e test that pins /v1/responses dispatch to the upstream /v1/responses path and validates key OpenAI Responses envelope/usage fields.
  • Introduces an e2e test that pins the “OpenAI-only” restriction by asserting a 400 OpenAI-shaped error envelope and that the upstream is not contacted for an Anthropic model.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +248 to +252
// Readiness gate: poll until the gateway returns the
// documented 400, not a model-not-found 400 from snapshot lag.
// Disambiguate by checking the error envelope is fully formed
// (a snapshot-lag 400 might have a different message style).
await waitConfigPropagation(async () => {
Comment on lines +76 to +80
test("OpenAI provider: caller receives upstream Responses body byte-for-byte", async (ctx) => {
if (!etcdReachable || !app || !admin) {
ctx.skip();
return;
}
Audit (per CLAUDE.md §8) found one HIGH, two MEDIUM, three LOW.
Resolutions:
HIGH H1 (error.type left loose despite a documented value in the
gateway's own contract):
Pinned `error.type === "invalid_request_error"` per docs §2
status→type table (400 → invalid_request_error). The published
table makes this unambiguous; loose "non-empty string" let
regressions to "service_unavailable", "provider_error", or any
new vocabulary slip through. Same pinning convention
body-edges-e2e and error-envelope-normalization-e2e use.
MEDIUM M1 (misleading probe comment):
Probe comment claimed "model not found 400" — but per docs §2,
model_not_found is mapped to 404, NOT 400. Rewrote comment to
reflect the actual disambiguation (404 = snapshot lag, 400 +
invalid_request_error = §4.6 OpenAI-only refusal).
MEDIUM M2 (provider matrix coverage gap):
Promoted the non-OpenAI provider case from a single anthropic
test to a parametrized matrix covering all three non-OpenAI
providers per docs §6 (anthropic, gemini, deepseek). Critical
for gemini and deepseek specifically: their bridges DO speak
OpenAI wire shape upstream, so a regression that "just
dispatched anyway" instead of refusing per §4.6 would silently
200 from the upstream-compat layer, billing the caller and
breaking the published contract.
LOW L1 (id round-trip not pinned):
Pinned `body.id === "resp_e2e_01"` byte-for-byte. A regression
that re-issued ids during gateway-side normalization would
break SDK paginators and webhook callbacks that key off
response id.
LOW L2-L3 (created_at, streaming/tools/multi-turn coverage):
Not addressed (created_at is a minor add; streaming/tools/
multi-turn deferred to follow-up rows).
All 32 e2e tests still pass locally (was 30; this PR now ships
4 cases — 1 happy path + 3 non-OpenAI mismatch).
@moonming
moonming merged commit b691bf7 into mainMay 9, 2026
6 checks passed
@moonming
moonming deleted the test/e2e-c7-responses branch May 9, 2026 13:51
moonming added a commit that referenced this pull request May 9, 2026
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
moonming added a commit that referenced this pull request May 9, 2026
… (#169)
* test(harness): raise waitConfigPropagation budget 5s → 10s (#157)
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
* test(harness): also reduce maxForks 4→2 (#157 fallback escalation)
After the timeout bump landed, #169's own CI run still flaked with
the same `condition not met within 10s` error — meaning etcd watch
dispatch latency genuinely exceeds 10s under maxForks=4 with the
current suite size (20+ files). The audit on #169 had flagged this
as the "product-side hypothesis still open" — confirmed.
Apply the documented fallback from #157: cut maxForks from 4 to 2.
This halves the concurrent-watcher count against the shared etcd
and brings dispatch latency back inside the budget.
Trade-off: wall time grows ~1.5-2× per CI run (locally measured
~14s @ maxForks=4 vs ~30s @ maxForks=2, so faster than expected
because the extra parallelism wasn't fully utilized anyway —
contention dominated). Net for CI is "predictable green" vs
"fast but constantly-rerunning".
The 10s `waitConfigPropagation` budget from the prior commit stays
in place as belt-and-suspenders — even with maxForks=2 the original
5s would still be tight on slow runners.
Combined this PR now does:
1. waitConfigPropagation deadline 5s → 10s (`harness/admin.ts`)
2. maxForks 4 → 2 (`vitest.config.ts`)
The product-side hypothesis ("etcd watch dispatch degrades
non-linearly with concurrent watchers") is now strongly supported
and worth a separate product-side investigation tracked in #157
follow-up.
Sign up for freeto 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.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151) - #163

Merged
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses
May 9, 2026
Merged

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151)#163
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses

Conversation

@moonming

@moonmingmoonming commented May 9, 2026

Copy link
Copy Markdown
Member

Summary

Second endpoint covered from #151's C7 row. `/v1/responses` is OpenAI's newer endpoint (introduced 2024) and the recommended path for new integrations — rapidly displacing `/v1/chat/completions`. Prior to this PR the gateway had zero e2e coverage on `/v1/responses`.

All 4 cases derived directly from the gateway's published contract in `docs/api-proxy.md` §4.6 + §2 status→type table.

What's pinned

CaseUser journeyAsserts
OpenAI provider happy pathPOST `/v1/responses` with OpenAI-provider ModelCaller gets OpenAI-Responses-shape body byte-for-byte (`object: "response"`, `id` round-trips exactly, `output[0].type: "message"`, `content[0].type: "output_text"`, reply text exact, `usage` using Responses-vocabulary `input_tokens` / `output_tokens` / `total_tokens`); gateway hits `/v1/responses` exactly once with translated `model_name` and `Bearer sk-mock`; caller's input verbatim
Non-OpenAI: anthropicPOST `/v1/responses` with anthropic Model400 + `error.type: "invalid_request_error"`; upstream never hit
Non-OpenAI: geminiPOST `/v1/responses` with gemini Modelsame
Non-OpenAI: deepseekPOST `/v1/responses` with deepseek Modelsame

The non-OpenAI matrix is parametrized across all three non-OpenAI providers per docs §6 (anthropic, gemini, deepseek). Critical for gemini and deepseek specifically: their bridges do speak OpenAI wire shape upstream, so a regression that "just dispatched anyway" instead of refusing per §4.6 would silently 200 from the upstream-compat layer, billing the caller and breaking the published contract.

Why these matter

Three regression modes that were unverified before:

  • Mis-route through `/v1/chat/completions` — the body shapes are different (`input` vs `messages`). Without an explicit path assertion this would slip past against a permissive mock.
  • Wrong usage field names — a regression that ran the response through chat-completions translation (`prompt_tokens` instead of `input_tokens`) would silently break every Responses-API caller's billing logic.
  • Non-OpenAI dispatched silently — gemini and deepseek bridges speak OpenAI wire upstream; a regression bypassing the §4.6 refusal would 200 with billing impact while violating the documented contract.

Source-blind discipline

Every assertion derives from external contracts:

No internal Rust paths or struct field names referenced.

Note on SDK

The OpenAI Node SDK 4.65 (this project's pinned version) doesn't yet expose `client.responses.create()` — that method was added in a later SDK release. The test uses raw `fetch` to hit `/v1/responses` directly. This still exercises the real gateway end-to-end; the SDK call layer is a thin convenience wrapper for the same wire shape.

Independent audit

Per CLAUDE.md §8, an independent audit agent reviewed the initial commit (6255e19). Resolution log:

FindingSeverityResolution
`error.type` left loose despite docs §2 publishing 400 → `invalid_request_error`HIGHTightened to `expect(body.error?.type).toBe("invalid_request_error")` (cb9b451)
Probe comment misleadingly claimed "model not found 400" — but `model_not_found` is documented as 404, not 400MEDIUMRewrote comment to reflect actual disambiguation (cb9b451)
Provider matrix gap: only anthropic tested for mismatch; gemini/deepseek bridges do speak OpenAI wire upstream and a bypass would silently 200MEDIUMParametrized to all 3 non-OpenAI providers (cb9b451)
`body.id` round-trip not pinnedLOWPinned `expect(body.id).toBe("resp_e2e_01")` (cb9b451)
`created_at` round-trip / streaming / tools / multi-turn coverageLOWNot addressed; deferred to follow-up rows

Test plan

  • `npm test` (full e2e suite) — 32/32 passing locally (was 28)
  • No mock-data-only paths: each case still exercises the real `aisix` binary, real etcd config propagation, real fetch reverse-call against `/v1/responses`
  • CI green

Refs #151.

Second endpoint covered from #151's C7 row. /v1/responses is
OpenAI's newer endpoint (introduced 2024) and is the recommended
path for new integrations — rapidly displacing /v1/chat/completions
in new code. Prior to this file the gateway had **zero** e2e
coverage on /v1/responses.
Two user journeys pinned, both derived from the gateway's own
published contract in `docs/api-proxy.md` §4.6:
> Native OpenAI Responses API. OpenAI Models only — non-OpenAI
> providers return 400.
Case 1 — happy path on OpenAI provider:
Caller POSTs /v1/responses with the OpenAI Responses-shape body
(`{model, input}`). Gateway dispatches to upstream's
/v1/responses (NOT /v1/chat/completions — a regression that
mis-routed via chat would surface as `object: "chat.completion"`
on the response). Caller receives the upstream's
Responses-shape body byte-for-byte:
- `object === "response"` (distinct envelope from chat)
- `output[0].type === "message"`, `output[0].role === "assistant"`
- `output[0].content[0].type === "output_text"` per spec
- Reply text byte-for-byte
- Usage uses `input_tokens` / `output_tokens` / `total_tokens`
(different field names from chat's `prompt_tokens` /
`completion_tokens` — a regression that translated through
chat-completions field names would mismatch)
Upstream-side: gateway hit `/v1/responses` exactly once with
`Bearer sk-mock`, body has display name → upstream model_name
translation, caller's input reaches upstream verbatim.
Case 2 — provider mismatch (anthropic Model on /v1/responses):
Per docs §4.6, non-OpenAI providers must return 400. Caller
sees 400 with OpenAI-shape error envelope (`error.type` /
`error.message` non-empty); upstream MUST NOT be hit (the
whole point of the restriction is that OpenAI-Responses-shape
doesn't translate to Anthropic Messages today).
References:
- OpenAI Responses API spec
<https://platform.openai.com/docs/api-reference/responses>
- Gateway's /v1/responses contract: `docs/api-proxy.md` §4.6
- OpenAI error envelope spec
<https://platform.openai.com/docs/guides/error-codes/api-errors>
Note on SDK: the OpenAI Node SDK 4.65 (the version pinned by
this project) doesn't yet expose `client.responses.create()` —
`client.responses` was added in a later SDK release. The test
uses raw `fetch` to hit `/v1/responses` directly. This still
exercises the real gateway end-to-end; the SDK-call layer is
just a thin convenience wrapper for the same wire shape.
Refs #151
CopilotAI review requested due to automatic review settings May 9, 2026 13:38
@coderabbitai

coderabbitaiBot commented May 9, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 48 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: e0a76037-443c-4403-97aa-e4739051c3de

📥 Commits

Reviewing files that changed from the base of the PR and between 2d0f188 and cb9b451.

📒 Files selected for processing (1)
  • tests/e2e/src/cases/responses-endpoint-e2e.test.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first end-to-end coverage for the gateway’s POST /v1/responses proxy surface, validating both correct dispatch for OpenAI-backed models and correct early rejection for non-OpenAI providers per docs/api-proxy.md §4.6.

Changes:

  • Introduces an e2e test that pins /v1/responses dispatch to the upstream /v1/responses path and validates key OpenAI Responses envelope/usage fields.
  • Introduces an e2e test that pins the “OpenAI-only” restriction by asserting a 400 OpenAI-shaped error envelope and that the upstream is not contacted for an Anthropic model.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +248 to +252
// Readiness gate: poll until the gateway returns the
// documented 400, not a model-not-found 400 from snapshot lag.
// Disambiguate by checking the error envelope is fully formed
// (a snapshot-lag 400 might have a different message style).
await waitConfigPropagation(async () => {
Comment on lines +76 to +80
test("OpenAI provider: caller receives upstream Responses body byte-for-byte", async (ctx) => {
if (!etcdReachable || !app || !admin) {
ctx.skip();
return;
}
Audit (per CLAUDE.md §8) found one HIGH, two MEDIUM, three LOW.
Resolutions:
HIGH H1 (error.type left loose despite a documented value in the
gateway's own contract):
Pinned `error.type === "invalid_request_error"` per docs §2
status→type table (400 → invalid_request_error). The published
table makes this unambiguous; loose "non-empty string" let
regressions to "service_unavailable", "provider_error", or any
new vocabulary slip through. Same pinning convention
body-edges-e2e and error-envelope-normalization-e2e use.
MEDIUM M1 (misleading probe comment):
Probe comment claimed "model not found 400" — but per docs §2,
model_not_found is mapped to 404, NOT 400. Rewrote comment to
reflect the actual disambiguation (404 = snapshot lag, 400 +
invalid_request_error = §4.6 OpenAI-only refusal).
MEDIUM M2 (provider matrix coverage gap):
Promoted the non-OpenAI provider case from a single anthropic
test to a parametrized matrix covering all three non-OpenAI
providers per docs §6 (anthropic, gemini, deepseek). Critical
for gemini and deepseek specifically: their bridges DO speak
OpenAI wire shape upstream, so a regression that "just
dispatched anyway" instead of refusing per §4.6 would silently
200 from the upstream-compat layer, billing the caller and
breaking the published contract.
LOW L1 (id round-trip not pinned):
Pinned `body.id === "resp_e2e_01"` byte-for-byte. A regression
that re-issued ids during gateway-side normalization would
break SDK paginators and webhook callbacks that key off
response id.
LOW L2-L3 (created_at, streaming/tools/multi-turn coverage):
Not addressed (created_at is a minor add; streaming/tools/
multi-turn deferred to follow-up rows).
All 32 e2e tests still pass locally (was 30; this PR now ships
4 cases — 1 happy path + 3 non-OpenAI mismatch).
@moonming
moonming merged commit b691bf7 into mainMay 9, 2026
6 checks passed
@moonming
moonming deleted the test/e2e-c7-responses branch May 9, 2026 13:51
moonming added a commit that referenced this pull request May 9, 2026
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
moonming added a commit that referenced this pull request May 9, 2026
… (#169)
* test(harness): raise waitConfigPropagation budget 5s → 10s (#157)
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
* test(harness): also reduce maxForks 4→2 (#157 fallback escalation)
After the timeout bump landed, #169's own CI run still flaked with
the same `condition not met within 10s` error — meaning etcd watch
dispatch latency genuinely exceeds 10s under maxForks=4 with the
current suite size (20+ files). The audit on #169 had flagged this
as the "product-side hypothesis still open" — confirmed.
Apply the documented fallback from #157: cut maxForks from 4 to 2.
This halves the concurrent-watcher count against the shared etcd
and brings dispatch latency back inside the budget.
Trade-off: wall time grows ~1.5-2× per CI run (locally measured
~14s @ maxForks=4 vs ~30s @ maxForks=2, so faster than expected
because the extra parallelism wasn't fully utilized anyway —
contention dominated). Net for CI is "predictable green" vs
"fast but constantly-rerunning".
The 10s `waitConfigPropagation` budget from the prior commit stays
in place as belt-and-suspenders — even with maxForks=2 the original
5s would still be tight on slow runners.
Combined this PR now does:
1. waitConfigPropagation deadline 5s → 10s (`harness/admin.ts`)
2. maxForks 4 → 2 (`vitest.config.ts`)
The product-side hypothesis ("etcd watch dispatch degrades
non-linearly with concurrent watchers") is now strongly supported
and worth a separate product-side investigation tracked in #157
follow-up.
Sign up for freeto 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.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151) - #163

Merged
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses
May 9, 2026
Merged

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151)#163
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses

Conversation

@moonming

@moonmingmoonming commented May 9, 2026

Copy link
Copy Markdown
Member

Summary

Second endpoint covered from #151's C7 row. `/v1/responses` is OpenAI's newer endpoint (introduced 2024) and the recommended path for new integrations — rapidly displacing `/v1/chat/completions`. Prior to this PR the gateway had zero e2e coverage on `/v1/responses`.

All 4 cases derived directly from the gateway's published contract in `docs/api-proxy.md` §4.6 + §2 status→type table.

What's pinned

CaseUser journeyAsserts
OpenAI provider happy pathPOST `/v1/responses` with OpenAI-provider ModelCaller gets OpenAI-Responses-shape body byte-for-byte (`object: "response"`, `id` round-trips exactly, `output[0].type: "message"`, `content[0].type: "output_text"`, reply text exact, `usage` using Responses-vocabulary `input_tokens` / `output_tokens` / `total_tokens`); gateway hits `/v1/responses` exactly once with translated `model_name` and `Bearer sk-mock`; caller's input verbatim
Non-OpenAI: anthropicPOST `/v1/responses` with anthropic Model400 + `error.type: "invalid_request_error"`; upstream never hit
Non-OpenAI: geminiPOST `/v1/responses` with gemini Modelsame
Non-OpenAI: deepseekPOST `/v1/responses` with deepseek Modelsame

The non-OpenAI matrix is parametrized across all three non-OpenAI providers per docs §6 (anthropic, gemini, deepseek). Critical for gemini and deepseek specifically: their bridges do speak OpenAI wire shape upstream, so a regression that "just dispatched anyway" instead of refusing per §4.6 would silently 200 from the upstream-compat layer, billing the caller and breaking the published contract.

Why these matter

Three regression modes that were unverified before:

  • Mis-route through `/v1/chat/completions` — the body shapes are different (`input` vs `messages`). Without an explicit path assertion this would slip past against a permissive mock.
  • Wrong usage field names — a regression that ran the response through chat-completions translation (`prompt_tokens` instead of `input_tokens`) would silently break every Responses-API caller's billing logic.
  • Non-OpenAI dispatched silently — gemini and deepseek bridges speak OpenAI wire upstream; a regression bypassing the §4.6 refusal would 200 with billing impact while violating the documented contract.

Source-blind discipline

Every assertion derives from external contracts:

No internal Rust paths or struct field names referenced.

Note on SDK

The OpenAI Node SDK 4.65 (this project's pinned version) doesn't yet expose `client.responses.create()` — that method was added in a later SDK release. The test uses raw `fetch` to hit `/v1/responses` directly. This still exercises the real gateway end-to-end; the SDK call layer is a thin convenience wrapper for the same wire shape.

Independent audit

Per CLAUDE.md §8, an independent audit agent reviewed the initial commit (6255e19). Resolution log:

FindingSeverityResolution
`error.type` left loose despite docs §2 publishing 400 → `invalid_request_error`HIGHTightened to `expect(body.error?.type).toBe("invalid_request_error")` (cb9b451)
Probe comment misleadingly claimed "model not found 400" — but `model_not_found` is documented as 404, not 400MEDIUMRewrote comment to reflect actual disambiguation (cb9b451)
Provider matrix gap: only anthropic tested for mismatch; gemini/deepseek bridges do speak OpenAI wire upstream and a bypass would silently 200MEDIUMParametrized to all 3 non-OpenAI providers (cb9b451)
`body.id` round-trip not pinnedLOWPinned `expect(body.id).toBe("resp_e2e_01")` (cb9b451)
`created_at` round-trip / streaming / tools / multi-turn coverageLOWNot addressed; deferred to follow-up rows

Test plan

  • `npm test` (full e2e suite) — 32/32 passing locally (was 28)
  • No mock-data-only paths: each case still exercises the real `aisix` binary, real etcd config propagation, real fetch reverse-call against `/v1/responses`
  • CI green

Refs #151.

Second endpoint covered from #151's C7 row. /v1/responses is
OpenAI's newer endpoint (introduced 2024) and is the recommended
path for new integrations — rapidly displacing /v1/chat/completions
in new code. Prior to this file the gateway had **zero** e2e
coverage on /v1/responses.
Two user journeys pinned, both derived from the gateway's own
published contract in `docs/api-proxy.md` §4.6:
> Native OpenAI Responses API. OpenAI Models only — non-OpenAI
> providers return 400.
Case 1 — happy path on OpenAI provider:
Caller POSTs /v1/responses with the OpenAI Responses-shape body
(`{model, input}`). Gateway dispatches to upstream's
/v1/responses (NOT /v1/chat/completions — a regression that
mis-routed via chat would surface as `object: "chat.completion"`
on the response). Caller receives the upstream's
Responses-shape body byte-for-byte:
- `object === "response"` (distinct envelope from chat)
- `output[0].type === "message"`, `output[0].role === "assistant"`
- `output[0].content[0].type === "output_text"` per spec
- Reply text byte-for-byte
- Usage uses `input_tokens` / `output_tokens` / `total_tokens`
(different field names from chat's `prompt_tokens` /
`completion_tokens` — a regression that translated through
chat-completions field names would mismatch)
Upstream-side: gateway hit `/v1/responses` exactly once with
`Bearer sk-mock`, body has display name → upstream model_name
translation, caller's input reaches upstream verbatim.
Case 2 — provider mismatch (anthropic Model on /v1/responses):
Per docs §4.6, non-OpenAI providers must return 400. Caller
sees 400 with OpenAI-shape error envelope (`error.type` /
`error.message` non-empty); upstream MUST NOT be hit (the
whole point of the restriction is that OpenAI-Responses-shape
doesn't translate to Anthropic Messages today).
References:
- OpenAI Responses API spec
<https://platform.openai.com/docs/api-reference/responses>
- Gateway's /v1/responses contract: `docs/api-proxy.md` §4.6
- OpenAI error envelope spec
<https://platform.openai.com/docs/guides/error-codes/api-errors>
Note on SDK: the OpenAI Node SDK 4.65 (the version pinned by
this project) doesn't yet expose `client.responses.create()` —
`client.responses` was added in a later SDK release. The test
uses raw `fetch` to hit `/v1/responses` directly. This still
exercises the real gateway end-to-end; the SDK-call layer is
just a thin convenience wrapper for the same wire shape.
Refs #151
CopilotAI review requested due to automatic review settings May 9, 2026 13:38
@coderabbitai

coderabbitaiBot commented May 9, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 48 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: e0a76037-443c-4403-97aa-e4739051c3de

📥 Commits

Reviewing files that changed from the base of the PR and between 2d0f188 and cb9b451.

📒 Files selected for processing (1)
  • tests/e2e/src/cases/responses-endpoint-e2e.test.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first end-to-end coverage for the gateway’s POST /v1/responses proxy surface, validating both correct dispatch for OpenAI-backed models and correct early rejection for non-OpenAI providers per docs/api-proxy.md §4.6.

Changes:

  • Introduces an e2e test that pins /v1/responses dispatch to the upstream /v1/responses path and validates key OpenAI Responses envelope/usage fields.
  • Introduces an e2e test that pins the “OpenAI-only” restriction by asserting a 400 OpenAI-shaped error envelope and that the upstream is not contacted for an Anthropic model.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +248 to +252
// Readiness gate: poll until the gateway returns the
// documented 400, not a model-not-found 400 from snapshot lag.
// Disambiguate by checking the error envelope is fully formed
// (a snapshot-lag 400 might have a different message style).
await waitConfigPropagation(async () => {
Comment on lines +76 to +80
test("OpenAI provider: caller receives upstream Responses body byte-for-byte", async (ctx) => {
if (!etcdReachable || !app || !admin) {
ctx.skip();
return;
}
Audit (per CLAUDE.md §8) found one HIGH, two MEDIUM, three LOW.
Resolutions:
HIGH H1 (error.type left loose despite a documented value in the
gateway's own contract):
Pinned `error.type === "invalid_request_error"` per docs §2
status→type table (400 → invalid_request_error). The published
table makes this unambiguous; loose "non-empty string" let
regressions to "service_unavailable", "provider_error", or any
new vocabulary slip through. Same pinning convention
body-edges-e2e and error-envelope-normalization-e2e use.
MEDIUM M1 (misleading probe comment):
Probe comment claimed "model not found 400" — but per docs §2,
model_not_found is mapped to 404, NOT 400. Rewrote comment to
reflect the actual disambiguation (404 = snapshot lag, 400 +
invalid_request_error = §4.6 OpenAI-only refusal).
MEDIUM M2 (provider matrix coverage gap):
Promoted the non-OpenAI provider case from a single anthropic
test to a parametrized matrix covering all three non-OpenAI
providers per docs §6 (anthropic, gemini, deepseek). Critical
for gemini and deepseek specifically: their bridges DO speak
OpenAI wire shape upstream, so a regression that "just
dispatched anyway" instead of refusing per §4.6 would silently
200 from the upstream-compat layer, billing the caller and
breaking the published contract.
LOW L1 (id round-trip not pinned):
Pinned `body.id === "resp_e2e_01"` byte-for-byte. A regression
that re-issued ids during gateway-side normalization would
break SDK paginators and webhook callbacks that key off
response id.
LOW L2-L3 (created_at, streaming/tools/multi-turn coverage):
Not addressed (created_at is a minor add; streaming/tools/
multi-turn deferred to follow-up rows).
All 32 e2e tests still pass locally (was 30; this PR now ships
4 cases — 1 happy path + 3 non-OpenAI mismatch).
@moonming
moonming merged commit b691bf7 into mainMay 9, 2026
6 checks passed
@moonming
moonming deleted the test/e2e-c7-responses branch May 9, 2026 13:51
moonming added a commit that referenced this pull request May 9, 2026
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
moonming added a commit that referenced this pull request May 9, 2026
… (#169)
* test(harness): raise waitConfigPropagation budget 5s → 10s (#157)
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
* test(harness): also reduce maxForks 4→2 (#157 fallback escalation)
After the timeout bump landed, #169's own CI run still flaked with
the same `condition not met within 10s` error — meaning etcd watch
dispatch latency genuinely exceeds 10s under maxForks=4 with the
current suite size (20+ files). The audit on #169 had flagged this
as the "product-side hypothesis still open" — confirmed.
Apply the documented fallback from #157: cut maxForks from 4 to 2.
This halves the concurrent-watcher count against the shared etcd
and brings dispatch latency back inside the budget.
Trade-off: wall time grows ~1.5-2× per CI run (locally measured
~14s @ maxForks=4 vs ~30s @ maxForks=2, so faster than expected
because the extra parallelism wasn't fully utilized anyway —
contention dominated). Net for CI is "predictable green" vs
"fast but constantly-rerunning".
The 10s `waitConfigPropagation` budget from the prior commit stays
in place as belt-and-suspenders — even with maxForks=2 the original
5s would still be tight on slow runners.
Combined this PR now does:
1. waitConfigPropagation deadline 5s → 10s (`harness/admin.ts`)
2. maxForks 4 → 2 (`vitest.config.ts`)
The product-side hypothesis ("etcd watch dispatch degrades
non-linearly with concurrent watchers") is now strongly supported
and worth a separate product-side investigation tracked in #157
follow-up.
Sign up for freeto 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.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151) - #163

Merged
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses
May 9, 2026
Merged

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151)#163
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses

Conversation

@moonming

@moonmingmoonming commented May 9, 2026

Copy link
Copy Markdown
Member

Summary

Second endpoint covered from #151's C7 row. `/v1/responses` is OpenAI's newer endpoint (introduced 2024) and the recommended path for new integrations — rapidly displacing `/v1/chat/completions`. Prior to this PR the gateway had zero e2e coverage on `/v1/responses`.

All 4 cases derived directly from the gateway's published contract in `docs/api-proxy.md` §4.6 + §2 status→type table.

What's pinned

CaseUser journeyAsserts
OpenAI provider happy pathPOST `/v1/responses` with OpenAI-provider ModelCaller gets OpenAI-Responses-shape body byte-for-byte (`object: "response"`, `id` round-trips exactly, `output[0].type: "message"`, `content[0].type: "output_text"`, reply text exact, `usage` using Responses-vocabulary `input_tokens` / `output_tokens` / `total_tokens`); gateway hits `/v1/responses` exactly once with translated `model_name` and `Bearer sk-mock`; caller's input verbatim
Non-OpenAI: anthropicPOST `/v1/responses` with anthropic Model400 + `error.type: "invalid_request_error"`; upstream never hit
Non-OpenAI: geminiPOST `/v1/responses` with gemini Modelsame
Non-OpenAI: deepseekPOST `/v1/responses` with deepseek Modelsame

The non-OpenAI matrix is parametrized across all three non-OpenAI providers per docs §6 (anthropic, gemini, deepseek). Critical for gemini and deepseek specifically: their bridges do speak OpenAI wire shape upstream, so a regression that "just dispatched anyway" instead of refusing per §4.6 would silently 200 from the upstream-compat layer, billing the caller and breaking the published contract.

Why these matter

Three regression modes that were unverified before:

  • Mis-route through `/v1/chat/completions` — the body shapes are different (`input` vs `messages`). Without an explicit path assertion this would slip past against a permissive mock.
  • Wrong usage field names — a regression that ran the response through chat-completions translation (`prompt_tokens` instead of `input_tokens`) would silently break every Responses-API caller's billing logic.
  • Non-OpenAI dispatched silently — gemini and deepseek bridges speak OpenAI wire upstream; a regression bypassing the §4.6 refusal would 200 with billing impact while violating the documented contract.

Source-blind discipline

Every assertion derives from external contracts:

No internal Rust paths or struct field names referenced.

Note on SDK

The OpenAI Node SDK 4.65 (this project's pinned version) doesn't yet expose `client.responses.create()` — that method was added in a later SDK release. The test uses raw `fetch` to hit `/v1/responses` directly. This still exercises the real gateway end-to-end; the SDK call layer is a thin convenience wrapper for the same wire shape.

Independent audit

Per CLAUDE.md §8, an independent audit agent reviewed the initial commit (6255e19). Resolution log:

FindingSeverityResolution
`error.type` left loose despite docs §2 publishing 400 → `invalid_request_error`HIGHTightened to `expect(body.error?.type).toBe("invalid_request_error")` (cb9b451)
Probe comment misleadingly claimed "model not found 400" — but `model_not_found` is documented as 404, not 400MEDIUMRewrote comment to reflect actual disambiguation (cb9b451)
Provider matrix gap: only anthropic tested for mismatch; gemini/deepseek bridges do speak OpenAI wire upstream and a bypass would silently 200MEDIUMParametrized to all 3 non-OpenAI providers (cb9b451)
`body.id` round-trip not pinnedLOWPinned `expect(body.id).toBe("resp_e2e_01")` (cb9b451)
`created_at` round-trip / streaming / tools / multi-turn coverageLOWNot addressed; deferred to follow-up rows

Test plan

  • `npm test` (full e2e suite) — 32/32 passing locally (was 28)
  • No mock-data-only paths: each case still exercises the real `aisix` binary, real etcd config propagation, real fetch reverse-call against `/v1/responses`
  • CI green

Refs #151.

Second endpoint covered from #151's C7 row. /v1/responses is
OpenAI's newer endpoint (introduced 2024) and is the recommended
path for new integrations — rapidly displacing /v1/chat/completions
in new code. Prior to this file the gateway had **zero** e2e
coverage on /v1/responses.
Two user journeys pinned, both derived from the gateway's own
published contract in `docs/api-proxy.md` §4.6:
> Native OpenAI Responses API. OpenAI Models only — non-OpenAI
> providers return 400.
Case 1 — happy path on OpenAI provider:
Caller POSTs /v1/responses with the OpenAI Responses-shape body
(`{model, input}`). Gateway dispatches to upstream's
/v1/responses (NOT /v1/chat/completions — a regression that
mis-routed via chat would surface as `object: "chat.completion"`
on the response). Caller receives the upstream's
Responses-shape body byte-for-byte:
- `object === "response"` (distinct envelope from chat)
- `output[0].type === "message"`, `output[0].role === "assistant"`
- `output[0].content[0].type === "output_text"` per spec
- Reply text byte-for-byte
- Usage uses `input_tokens` / `output_tokens` / `total_tokens`
(different field names from chat's `prompt_tokens` /
`completion_tokens` — a regression that translated through
chat-completions field names would mismatch)
Upstream-side: gateway hit `/v1/responses` exactly once with
`Bearer sk-mock`, body has display name → upstream model_name
translation, caller's input reaches upstream verbatim.
Case 2 — provider mismatch (anthropic Model on /v1/responses):
Per docs §4.6, non-OpenAI providers must return 400. Caller
sees 400 with OpenAI-shape error envelope (`error.type` /
`error.message` non-empty); upstream MUST NOT be hit (the
whole point of the restriction is that OpenAI-Responses-shape
doesn't translate to Anthropic Messages today).
References:
- OpenAI Responses API spec
<https://platform.openai.com/docs/api-reference/responses>
- Gateway's /v1/responses contract: `docs/api-proxy.md` §4.6
- OpenAI error envelope spec
<https://platform.openai.com/docs/guides/error-codes/api-errors>
Note on SDK: the OpenAI Node SDK 4.65 (the version pinned by
this project) doesn't yet expose `client.responses.create()` —
`client.responses` was added in a later SDK release. The test
uses raw `fetch` to hit `/v1/responses` directly. This still
exercises the real gateway end-to-end; the SDK-call layer is
just a thin convenience wrapper for the same wire shape.
Refs #151
CopilotAI review requested due to automatic review settings May 9, 2026 13:38
@coderabbitai

coderabbitaiBot commented May 9, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 48 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: e0a76037-443c-4403-97aa-e4739051c3de

📥 Commits

Reviewing files that changed from the base of the PR and between 2d0f188 and cb9b451.

📒 Files selected for processing (1)
  • tests/e2e/src/cases/responses-endpoint-e2e.test.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first end-to-end coverage for the gateway’s POST /v1/responses proxy surface, validating both correct dispatch for OpenAI-backed models and correct early rejection for non-OpenAI providers per docs/api-proxy.md §4.6.

Changes:

  • Introduces an e2e test that pins /v1/responses dispatch to the upstream /v1/responses path and validates key OpenAI Responses envelope/usage fields.
  • Introduces an e2e test that pins the “OpenAI-only” restriction by asserting a 400 OpenAI-shaped error envelope and that the upstream is not contacted for an Anthropic model.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +248 to +252
// Readiness gate: poll until the gateway returns the
// documented 400, not a model-not-found 400 from snapshot lag.
// Disambiguate by checking the error envelope is fully formed
// (a snapshot-lag 400 might have a different message style).
await waitConfigPropagation(async () => {
Comment on lines +76 to +80
test("OpenAI provider: caller receives upstream Responses body byte-for-byte", async (ctx) => {
if (!etcdReachable || !app || !admin) {
ctx.skip();
return;
}
Audit (per CLAUDE.md §8) found one HIGH, two MEDIUM, three LOW.
Resolutions:
HIGH H1 (error.type left loose despite a documented value in the
gateway's own contract):
Pinned `error.type === "invalid_request_error"` per docs §2
status→type table (400 → invalid_request_error). The published
table makes this unambiguous; loose "non-empty string" let
regressions to "service_unavailable", "provider_error", or any
new vocabulary slip through. Same pinning convention
body-edges-e2e and error-envelope-normalization-e2e use.
MEDIUM M1 (misleading probe comment):
Probe comment claimed "model not found 400" — but per docs §2,
model_not_found is mapped to 404, NOT 400. Rewrote comment to
reflect the actual disambiguation (404 = snapshot lag, 400 +
invalid_request_error = §4.6 OpenAI-only refusal).
MEDIUM M2 (provider matrix coverage gap):
Promoted the non-OpenAI provider case from a single anthropic
test to a parametrized matrix covering all three non-OpenAI
providers per docs §6 (anthropic, gemini, deepseek). Critical
for gemini and deepseek specifically: their bridges DO speak
OpenAI wire shape upstream, so a regression that "just
dispatched anyway" instead of refusing per §4.6 would silently
200 from the upstream-compat layer, billing the caller and
breaking the published contract.
LOW L1 (id round-trip not pinned):
Pinned `body.id === "resp_e2e_01"` byte-for-byte. A regression
that re-issued ids during gateway-side normalization would
break SDK paginators and webhook callbacks that key off
response id.
LOW L2-L3 (created_at, streaming/tools/multi-turn coverage):
Not addressed (created_at is a minor add; streaming/tools/
multi-turn deferred to follow-up rows).
All 32 e2e tests still pass locally (was 30; this PR now ships
4 cases — 1 happy path + 3 non-OpenAI mismatch).
@moonming
moonming merged commit b691bf7 into mainMay 9, 2026
6 checks passed
@moonming
moonming deleted the test/e2e-c7-responses branch May 9, 2026 13:51
moonming added a commit that referenced this pull request May 9, 2026
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
moonming added a commit that referenced this pull request May 9, 2026
… (#169)
* test(harness): raise waitConfigPropagation budget 5s → 10s (#157)
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
* test(harness): also reduce maxForks 4→2 (#157 fallback escalation)
After the timeout bump landed, #169's own CI run still flaked with
the same `condition not met within 10s` error — meaning etcd watch
dispatch latency genuinely exceeds 10s under maxForks=4 with the
current suite size (20+ files). The audit on #169 had flagged this
as the "product-side hypothesis still open" — confirmed.
Apply the documented fallback from #157: cut maxForks from 4 to 2.
This halves the concurrent-watcher count against the shared etcd
and brings dispatch latency back inside the budget.
Trade-off: wall time grows ~1.5-2× per CI run (locally measured
~14s @ maxForks=4 vs ~30s @ maxForks=2, so faster than expected
because the extra parallelism wasn't fully utilized anyway —
contention dominated). Net for CI is "predictable green" vs
"fast but constantly-rerunning".
The 10s `waitConfigPropagation` budget from the prior commit stays
in place as belt-and-suspenders — even with maxForks=2 the original
5s would still be tight on slow runners.
Combined this PR now does:
1. waitConfigPropagation deadline 5s → 10s (`harness/admin.ts`)
2. maxForks 4 → 2 (`vitest.config.ts`)
The product-side hypothesis ("etcd watch dispatch degrades
non-linearly with concurrent watchers") is now strongly supported
and worth a separate product-side investigation tracked in #157
follow-up.
Sign up for freeto 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.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151) - #163

Merged
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses
May 9, 2026
Merged

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151)#163
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses

Conversation

@moonming

@moonmingmoonming commented May 9, 2026

Copy link
Copy Markdown
Member

Summary

Second endpoint covered from #151's C7 row. `/v1/responses` is OpenAI's newer endpoint (introduced 2024) and the recommended path for new integrations — rapidly displacing `/v1/chat/completions`. Prior to this PR the gateway had zero e2e coverage on `/v1/responses`.

All 4 cases derived directly from the gateway's published contract in `docs/api-proxy.md` §4.6 + §2 status→type table.

What's pinned

CaseUser journeyAsserts
OpenAI provider happy pathPOST `/v1/responses` with OpenAI-provider ModelCaller gets OpenAI-Responses-shape body byte-for-byte (`object: "response"`, `id` round-trips exactly, `output[0].type: "message"`, `content[0].type: "output_text"`, reply text exact, `usage` using Responses-vocabulary `input_tokens` / `output_tokens` / `total_tokens`); gateway hits `/v1/responses` exactly once with translated `model_name` and `Bearer sk-mock`; caller's input verbatim
Non-OpenAI: anthropicPOST `/v1/responses` with anthropic Model400 + `error.type: "invalid_request_error"`; upstream never hit
Non-OpenAI: geminiPOST `/v1/responses` with gemini Modelsame
Non-OpenAI: deepseekPOST `/v1/responses` with deepseek Modelsame

The non-OpenAI matrix is parametrized across all three non-OpenAI providers per docs §6 (anthropic, gemini, deepseek). Critical for gemini and deepseek specifically: their bridges do speak OpenAI wire shape upstream, so a regression that "just dispatched anyway" instead of refusing per §4.6 would silently 200 from the upstream-compat layer, billing the caller and breaking the published contract.

Why these matter

Three regression modes that were unverified before:

  • Mis-route through `/v1/chat/completions` — the body shapes are different (`input` vs `messages`). Without an explicit path assertion this would slip past against a permissive mock.
  • Wrong usage field names — a regression that ran the response through chat-completions translation (`prompt_tokens` instead of `input_tokens`) would silently break every Responses-API caller's billing logic.
  • Non-OpenAI dispatched silently — gemini and deepseek bridges speak OpenAI wire upstream; a regression bypassing the §4.6 refusal would 200 with billing impact while violating the documented contract.

Source-blind discipline

Every assertion derives from external contracts:

No internal Rust paths or struct field names referenced.

Note on SDK

The OpenAI Node SDK 4.65 (this project's pinned version) doesn't yet expose `client.responses.create()` — that method was added in a later SDK release. The test uses raw `fetch` to hit `/v1/responses` directly. This still exercises the real gateway end-to-end; the SDK call layer is a thin convenience wrapper for the same wire shape.

Independent audit

Per CLAUDE.md §8, an independent audit agent reviewed the initial commit (6255e19). Resolution log:

FindingSeverityResolution
`error.type` left loose despite docs §2 publishing 400 → `invalid_request_error`HIGHTightened to `expect(body.error?.type).toBe("invalid_request_error")` (cb9b451)
Probe comment misleadingly claimed "model not found 400" — but `model_not_found` is documented as 404, not 400MEDIUMRewrote comment to reflect actual disambiguation (cb9b451)
Provider matrix gap: only anthropic tested for mismatch; gemini/deepseek bridges do speak OpenAI wire upstream and a bypass would silently 200MEDIUMParametrized to all 3 non-OpenAI providers (cb9b451)
`body.id` round-trip not pinnedLOWPinned `expect(body.id).toBe("resp_e2e_01")` (cb9b451)
`created_at` round-trip / streaming / tools / multi-turn coverageLOWNot addressed; deferred to follow-up rows

Test plan

  • `npm test` (full e2e suite) — 32/32 passing locally (was 28)
  • No mock-data-only paths: each case still exercises the real `aisix` binary, real etcd config propagation, real fetch reverse-call against `/v1/responses`
  • CI green

Refs #151.

Second endpoint covered from #151's C7 row. /v1/responses is
OpenAI's newer endpoint (introduced 2024) and is the recommended
path for new integrations — rapidly displacing /v1/chat/completions
in new code. Prior to this file the gateway had **zero** e2e
coverage on /v1/responses.
Two user journeys pinned, both derived from the gateway's own
published contract in `docs/api-proxy.md` §4.6:
> Native OpenAI Responses API. OpenAI Models only — non-OpenAI
> providers return 400.
Case 1 — happy path on OpenAI provider:
Caller POSTs /v1/responses with the OpenAI Responses-shape body
(`{model, input}`). Gateway dispatches to upstream's
/v1/responses (NOT /v1/chat/completions — a regression that
mis-routed via chat would surface as `object: "chat.completion"`
on the response). Caller receives the upstream's
Responses-shape body byte-for-byte:
- `object === "response"` (distinct envelope from chat)
- `output[0].type === "message"`, `output[0].role === "assistant"`
- `output[0].content[0].type === "output_text"` per spec
- Reply text byte-for-byte
- Usage uses `input_tokens` / `output_tokens` / `total_tokens`
(different field names from chat's `prompt_tokens` /
`completion_tokens` — a regression that translated through
chat-completions field names would mismatch)
Upstream-side: gateway hit `/v1/responses` exactly once with
`Bearer sk-mock`, body has display name → upstream model_name
translation, caller's input reaches upstream verbatim.
Case 2 — provider mismatch (anthropic Model on /v1/responses):
Per docs §4.6, non-OpenAI providers must return 400. Caller
sees 400 with OpenAI-shape error envelope (`error.type` /
`error.message` non-empty); upstream MUST NOT be hit (the
whole point of the restriction is that OpenAI-Responses-shape
doesn't translate to Anthropic Messages today).
References:
- OpenAI Responses API spec
<https://platform.openai.com/docs/api-reference/responses>
- Gateway's /v1/responses contract: `docs/api-proxy.md` §4.6
- OpenAI error envelope spec
<https://platform.openai.com/docs/guides/error-codes/api-errors>
Note on SDK: the OpenAI Node SDK 4.65 (the version pinned by
this project) doesn't yet expose `client.responses.create()` —
`client.responses` was added in a later SDK release. The test
uses raw `fetch` to hit `/v1/responses` directly. This still
exercises the real gateway end-to-end; the SDK-call layer is
just a thin convenience wrapper for the same wire shape.
Refs #151
CopilotAI review requested due to automatic review settings May 9, 2026 13:38
@coderabbitai

coderabbitaiBot commented May 9, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 48 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: e0a76037-443c-4403-97aa-e4739051c3de

📥 Commits

Reviewing files that changed from the base of the PR and between 2d0f188 and cb9b451.

📒 Files selected for processing (1)
  • tests/e2e/src/cases/responses-endpoint-e2e.test.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first end-to-end coverage for the gateway’s POST /v1/responses proxy surface, validating both correct dispatch for OpenAI-backed models and correct early rejection for non-OpenAI providers per docs/api-proxy.md §4.6.

Changes:

  • Introduces an e2e test that pins /v1/responses dispatch to the upstream /v1/responses path and validates key OpenAI Responses envelope/usage fields.
  • Introduces an e2e test that pins the “OpenAI-only” restriction by asserting a 400 OpenAI-shaped error envelope and that the upstream is not contacted for an Anthropic model.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +248 to +252
// Readiness gate: poll until the gateway returns the
// documented 400, not a model-not-found 400 from snapshot lag.
// Disambiguate by checking the error envelope is fully formed
// (a snapshot-lag 400 might have a different message style).
await waitConfigPropagation(async () => {
Comment on lines +76 to +80
test("OpenAI provider: caller receives upstream Responses body byte-for-byte", async (ctx) => {
if (!etcdReachable || !app || !admin) {
ctx.skip();
return;
}
Audit (per CLAUDE.md §8) found one HIGH, two MEDIUM, three LOW.
Resolutions:
HIGH H1 (error.type left loose despite a documented value in the
gateway's own contract):
Pinned `error.type === "invalid_request_error"` per docs §2
status→type table (400 → invalid_request_error). The published
table makes this unambiguous; loose "non-empty string" let
regressions to "service_unavailable", "provider_error", or any
new vocabulary slip through. Same pinning convention
body-edges-e2e and error-envelope-normalization-e2e use.
MEDIUM M1 (misleading probe comment):
Probe comment claimed "model not found 400" — but per docs §2,
model_not_found is mapped to 404, NOT 400. Rewrote comment to
reflect the actual disambiguation (404 = snapshot lag, 400 +
invalid_request_error = §4.6 OpenAI-only refusal).
MEDIUM M2 (provider matrix coverage gap):
Promoted the non-OpenAI provider case from a single anthropic
test to a parametrized matrix covering all three non-OpenAI
providers per docs §6 (anthropic, gemini, deepseek). Critical
for gemini and deepseek specifically: their bridges DO speak
OpenAI wire shape upstream, so a regression that "just
dispatched anyway" instead of refusing per §4.6 would silently
200 from the upstream-compat layer, billing the caller and
breaking the published contract.
LOW L1 (id round-trip not pinned):
Pinned `body.id === "resp_e2e_01"` byte-for-byte. A regression
that re-issued ids during gateway-side normalization would
break SDK paginators and webhook callbacks that key off
response id.
LOW L2-L3 (created_at, streaming/tools/multi-turn coverage):
Not addressed (created_at is a minor add; streaming/tools/
multi-turn deferred to follow-up rows).
All 32 e2e tests still pass locally (was 30; this PR now ships
4 cases — 1 happy path + 3 non-OpenAI mismatch).
@moonming
moonming merged commit b691bf7 into mainMay 9, 2026
6 checks passed
@moonming
moonming deleted the test/e2e-c7-responses branch May 9, 2026 13:51
moonming added a commit that referenced this pull request May 9, 2026
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
moonming added a commit that referenced this pull request May 9, 2026
… (#169)
* test(harness): raise waitConfigPropagation budget 5s → 10s (#157)
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
* test(harness): also reduce maxForks 4→2 (#157 fallback escalation)
After the timeout bump landed, #169's own CI run still flaked with
the same `condition not met within 10s` error — meaning etcd watch
dispatch latency genuinely exceeds 10s under maxForks=4 with the
current suite size (20+ files). The audit on #169 had flagged this
as the "product-side hypothesis still open" — confirmed.
Apply the documented fallback from #157: cut maxForks from 4 to 2.
This halves the concurrent-watcher count against the shared etcd
and brings dispatch latency back inside the budget.
Trade-off: wall time grows ~1.5-2× per CI run (locally measured
~14s @ maxForks=4 vs ~30s @ maxForks=2, so faster than expected
because the extra parallelism wasn't fully utilized anyway —
contention dominated). Net for CI is "predictable green" vs
"fast but constantly-rerunning".
The 10s `waitConfigPropagation` budget from the prior commit stays
in place as belt-and-suspenders — even with maxForks=2 the original
5s would still be tight on slow runners.
Combined this PR now does:
1. waitConfigPropagation deadline 5s → 10s (`harness/admin.ts`)
2. maxForks 4 → 2 (`vitest.config.ts`)
The product-side hypothesis ("etcd watch dispatch degrades
non-linearly with concurrent watchers") is now strongly supported
and worth a separate product-side investigation tracked in #157
follow-up.
Sign up for freeto 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.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151) - #163

Merged
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses
May 9, 2026
Merged

test(e2e): C7 /v1/responses dispatch + provider mismatch (#151)#163
moonming merged 2 commits into
mainfrom
test/e2e-c7-responses

Conversation

@moonming

@moonmingmoonming commented May 9, 2026

Copy link
Copy Markdown
Member

Summary

Second endpoint covered from #151's C7 row. `/v1/responses` is OpenAI's newer endpoint (introduced 2024) and the recommended path for new integrations — rapidly displacing `/v1/chat/completions`. Prior to this PR the gateway had zero e2e coverage on `/v1/responses`.

All 4 cases derived directly from the gateway's published contract in `docs/api-proxy.md` §4.6 + §2 status→type table.

What's pinned

CaseUser journeyAsserts
OpenAI provider happy pathPOST `/v1/responses` with OpenAI-provider ModelCaller gets OpenAI-Responses-shape body byte-for-byte (`object: "response"`, `id` round-trips exactly, `output[0].type: "message"`, `content[0].type: "output_text"`, reply text exact, `usage` using Responses-vocabulary `input_tokens` / `output_tokens` / `total_tokens`); gateway hits `/v1/responses` exactly once with translated `model_name` and `Bearer sk-mock`; caller's input verbatim
Non-OpenAI: anthropicPOST `/v1/responses` with anthropic Model400 + `error.type: "invalid_request_error"`; upstream never hit
Non-OpenAI: geminiPOST `/v1/responses` with gemini Modelsame
Non-OpenAI: deepseekPOST `/v1/responses` with deepseek Modelsame

The non-OpenAI matrix is parametrized across all three non-OpenAI providers per docs §6 (anthropic, gemini, deepseek). Critical for gemini and deepseek specifically: their bridges do speak OpenAI wire shape upstream, so a regression that "just dispatched anyway" instead of refusing per §4.6 would silently 200 from the upstream-compat layer, billing the caller and breaking the published contract.

Why these matter

Three regression modes that were unverified before:

  • Mis-route through `/v1/chat/completions` — the body shapes are different (`input` vs `messages`). Without an explicit path assertion this would slip past against a permissive mock.
  • Wrong usage field names — a regression that ran the response through chat-completions translation (`prompt_tokens` instead of `input_tokens`) would silently break every Responses-API caller's billing logic.
  • Non-OpenAI dispatched silently — gemini and deepseek bridges speak OpenAI wire upstream; a regression bypassing the §4.6 refusal would 200 with billing impact while violating the documented contract.

Source-blind discipline

Every assertion derives from external contracts:

No internal Rust paths or struct field names referenced.

Note on SDK

The OpenAI Node SDK 4.65 (this project's pinned version) doesn't yet expose `client.responses.create()` — that method was added in a later SDK release. The test uses raw `fetch` to hit `/v1/responses` directly. This still exercises the real gateway end-to-end; the SDK call layer is a thin convenience wrapper for the same wire shape.

Independent audit

Per CLAUDE.md §8, an independent audit agent reviewed the initial commit (6255e19). Resolution log:

FindingSeverityResolution
`error.type` left loose despite docs §2 publishing 400 → `invalid_request_error`HIGHTightened to `expect(body.error?.type).toBe("invalid_request_error")` (cb9b451)
Probe comment misleadingly claimed "model not found 400" — but `model_not_found` is documented as 404, not 400MEDIUMRewrote comment to reflect actual disambiguation (cb9b451)
Provider matrix gap: only anthropic tested for mismatch; gemini/deepseek bridges do speak OpenAI wire upstream and a bypass would silently 200MEDIUMParametrized to all 3 non-OpenAI providers (cb9b451)
`body.id` round-trip not pinnedLOWPinned `expect(body.id).toBe("resp_e2e_01")` (cb9b451)
`created_at` round-trip / streaming / tools / multi-turn coverageLOWNot addressed; deferred to follow-up rows

Test plan

  • `npm test` (full e2e suite) — 32/32 passing locally (was 28)
  • No mock-data-only paths: each case still exercises the real `aisix` binary, real etcd config propagation, real fetch reverse-call against `/v1/responses`
  • CI green

Refs #151.

Second endpoint covered from #151's C7 row. /v1/responses is
OpenAI's newer endpoint (introduced 2024) and is the recommended
path for new integrations — rapidly displacing /v1/chat/completions
in new code. Prior to this file the gateway had **zero** e2e
coverage on /v1/responses.
Two user journeys pinned, both derived from the gateway's own
published contract in `docs/api-proxy.md` §4.6:
> Native OpenAI Responses API. OpenAI Models only — non-OpenAI
> providers return 400.
Case 1 — happy path on OpenAI provider:
Caller POSTs /v1/responses with the OpenAI Responses-shape body
(`{model, input}`). Gateway dispatches to upstream's
/v1/responses (NOT /v1/chat/completions — a regression that
mis-routed via chat would surface as `object: "chat.completion"`
on the response). Caller receives the upstream's
Responses-shape body byte-for-byte:
- `object === "response"` (distinct envelope from chat)
- `output[0].type === "message"`, `output[0].role === "assistant"`
- `output[0].content[0].type === "output_text"` per spec
- Reply text byte-for-byte
- Usage uses `input_tokens` / `output_tokens` / `total_tokens`
(different field names from chat's `prompt_tokens` /
`completion_tokens` — a regression that translated through
chat-completions field names would mismatch)
Upstream-side: gateway hit `/v1/responses` exactly once with
`Bearer sk-mock`, body has display name → upstream model_name
translation, caller's input reaches upstream verbatim.
Case 2 — provider mismatch (anthropic Model on /v1/responses):
Per docs §4.6, non-OpenAI providers must return 400. Caller
sees 400 with OpenAI-shape error envelope (`error.type` /
`error.message` non-empty); upstream MUST NOT be hit (the
whole point of the restriction is that OpenAI-Responses-shape
doesn't translate to Anthropic Messages today).
References:
- OpenAI Responses API spec
<https://platform.openai.com/docs/api-reference/responses>
- Gateway's /v1/responses contract: `docs/api-proxy.md` §4.6
- OpenAI error envelope spec
<https://platform.openai.com/docs/guides/error-codes/api-errors>
Note on SDK: the OpenAI Node SDK 4.65 (the version pinned by
this project) doesn't yet expose `client.responses.create()` —
`client.responses` was added in a later SDK release. The test
uses raw `fetch` to hit `/v1/responses` directly. This still
exercises the real gateway end-to-end; the SDK-call layer is
just a thin convenience wrapper for the same wire shape.
Refs #151
CopilotAI review requested due to automatic review settings May 9, 2026 13:38
@coderabbitai

coderabbitaiBot commented May 9, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@moonming has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 48 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: e0a76037-443c-4403-97aa-e4739051c3de

📥 Commits

Reviewing files that changed from the base of the PR and between 2d0f188 and cb9b451.

📒 Files selected for processing (1)
  • tests/e2e/src/cases/responses-endpoint-e2e.test.ts

Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first end-to-end coverage for the gateway’s POST /v1/responses proxy surface, validating both correct dispatch for OpenAI-backed models and correct early rejection for non-OpenAI providers per docs/api-proxy.md §4.6.

Changes:

  • Introduces an e2e test that pins /v1/responses dispatch to the upstream /v1/responses path and validates key OpenAI Responses envelope/usage fields.
  • Introduces an e2e test that pins the “OpenAI-only” restriction by asserting a 400 OpenAI-shaped error envelope and that the upstream is not contacted for an Anthropic model.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +248 to +252
// Readiness gate: poll until the gateway returns the
// documented 400, not a model-not-found 400 from snapshot lag.
// Disambiguate by checking the error envelope is fully formed
// (a snapshot-lag 400 might have a different message style).
await waitConfigPropagation(async () => {
Comment on lines +76 to +80
test("OpenAI provider: caller receives upstream Responses body byte-for-byte", async (ctx) => {
if (!etcdReachable || !app || !admin) {
ctx.skip();
return;
}
Audit (per CLAUDE.md §8) found one HIGH, two MEDIUM, three LOW.
Resolutions:
HIGH H1 (error.type left loose despite a documented value in the
gateway's own contract):
Pinned `error.type === "invalid_request_error"` per docs §2
status→type table (400 → invalid_request_error). The published
table makes this unambiguous; loose "non-empty string" let
regressions to "service_unavailable", "provider_error", or any
new vocabulary slip through. Same pinning convention
body-edges-e2e and error-envelope-normalization-e2e use.
MEDIUM M1 (misleading probe comment):
Probe comment claimed "model not found 400" — but per docs §2,
model_not_found is mapped to 404, NOT 400. Rewrote comment to
reflect the actual disambiguation (404 = snapshot lag, 400 +
invalid_request_error = §4.6 OpenAI-only refusal).
MEDIUM M2 (provider matrix coverage gap):
Promoted the non-OpenAI provider case from a single anthropic
test to a parametrized matrix covering all three non-OpenAI
providers per docs §6 (anthropic, gemini, deepseek). Critical
for gemini and deepseek specifically: their bridges DO speak
OpenAI wire shape upstream, so a regression that "just
dispatched anyway" instead of refusing per §4.6 would silently
200 from the upstream-compat layer, billing the caller and
breaking the published contract.
LOW L1 (id round-trip not pinned):
Pinned `body.id === "resp_e2e_01"` byte-for-byte. A regression
that re-issued ids during gateway-side normalization would
break SDK paginators and webhook callbacks that key off
response id.
LOW L2-L3 (created_at, streaming/tools/multi-turn coverage):
Not addressed (created_at is a minor add; streaming/tools/
multi-turn deferred to follow-up rows).
All 32 e2e tests still pass locally (was 30; this PR now ships
4 cases — 1 happy path + 3 non-OpenAI mismatch).
@moonming
moonming merged commit b691bf7 into mainMay 9, 2026
6 checks passed
@moonming
moonming deleted the test/e2e-c7-responses branch May 9, 2026 13:51
moonming added a commit that referenced this pull request May 9, 2026
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
moonming added a commit that referenced this pull request May 9, 2026
… (#169)
* test(harness): raise waitConfigPropagation budget 5s → 10s (#157)
Closes#157 (test-infra concern, not a product bug).
Issue: the e2e suite's `guardrail-keyword-e2e.test.ts` has flaked
three times on CI in the past 24 hours (#157 first occurrence,
plus reruns required on PR #165 and #167). Failure mode is the
same: `waitConfigPropagation: condition not met within 5s`.
Root cause: vitest is configured with `maxForks: 4` (per
`vitest.config.ts`), so up to 4 test files run in parallel, each
spawning its own `aisix` binary against a SHARED etcd. Each
binary opens watches and writes resources via the admin API
concurrently with the others. Under that load, etcd watch
dispatch latency for the LAST resource in a multi-resource
batch (e.g. a Guardrail rule following Model + ApiKey +
ProviderKey writes) can exceed the 5s budget.
The 5s budget was sized when the suite had ~9 files. The suite
is now 20+ files (#161, #163, #165, #167 added embeddings,
responses, passthrough, rerank, images). The growth in
parallelism load wasn't matched by a budget bump.
Fix: raise the deadline to 10s. This:
- Eliminates the recurring rerun churn on feature PRs
- Preserves the "fail loudly on a genuinely stuck snapshot"
property — 10s is still a generous floor; a real bug where
propagation hangs indefinitely would still fail clearly
- Doesn't change the happy-path latency at all (the helper
polls every 50ms and returns as soon as the condition is
met — bumping the deadline only affects the sad path)
This is a test-infra-only change; no product behavior is affected.
The ≤500ms spec target for in-process propagation is unchanged;
this is purely the CI test harness's wait budget for slow runners
under concurrent load.
If 10s proves insufficient as the suite grows further, the next
escalation would be to reduce `maxForks` from 4 to 2 (slower wall
time, less etcd pressure) — tracked in #157 as a fallback.
* test(harness): also reduce maxForks 4→2 (#157 fallback escalation)
After the timeout bump landed, #169's own CI run still flaked with
the same `condition not met within 10s` error — meaning etcd watch
dispatch latency genuinely exceeds 10s under maxForks=4 with the
current suite size (20+ files). The audit on #169 had flagged this
as the "product-side hypothesis still open" — confirmed.
Apply the documented fallback from #157: cut maxForks from 4 to 2.
This halves the concurrent-watcher count against the shared etcd
and brings dispatch latency back inside the budget.
Trade-off: wall time grows ~1.5-2× per CI run (locally measured
~14s @ maxForks=4 vs ~30s @ maxForks=2, so faster than expected
because the extra parallelism wasn't fully utilized anyway —
contention dominated). Net for CI is "predictable green" vs
"fast but constantly-rerunning".
The 10s `waitConfigPropagation` budget from the prior commit stays
in place as belt-and-suspenders — even with maxForks=2 the original
5s would still be tight on slow runners.
Combined this PR now does:
1. waitConfigPropagation deadline 5s → 10s (`harness/admin.ts`)
2. maxForks 4 → 2 (`vitest.config.ts`)
The product-side hypothesis ("etcd watch dispatch degrades
non-linearly with concurrent watchers") is now strongly supported
and worth a separate product-side investigation tracked in #157
follow-up.
Sign up for freeto 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.

2 participants

@moonming