From e4b32ed3cfdd3b6011102db8a0f0c93378783f36 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 04:36:51 +0900 Subject: [PATCH] feat(zai): default to the Responses protocol and keep Chat as a per-model opt-in Adds chatCompletionsPath as the openai-chat mirror of responsesPath, applies modelSuffixBracketStrip on the Responses passthrough body, and keeps the old endpoint resolvable through destinationAliases. Closes #4297. --- .../030_wp4_zai_responses_default.md | 73 +++++++++++++------ .../src/content/docs/guides/providers.md | 2 +- .../docs/reference/configuration/providers.md | 1 + scripts/test-layout/layout.json | 1 + src/adapters/openai-chat.ts | 9 ++- src/adapters/openai-responses.ts | 17 ++++- src/config.ts | 35 +++++---- src/lab/subject/behavior-fingerprint.ts | 2 +- src/providers/derive.ts | 8 ++ src/providers/registry.ts | 58 +++++++++++++-- src/router.ts | 3 + src/routing/compatibility/behavior.ts | 1 + src/server/auth-cors.ts | 1 + src/types/provider.ts | 11 +++ structure/data-planes/inbound-compat.md | 7 +- .../openai/openai-chat-model-suffix.test.ts | 23 +++++- .../openai/openai-chat-path-override.test.ts | 58 +++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + .../provider-registry-parity.test.ts | 27 ++++++- tests/server/config.test.ts | 48 ++++++++++++ 20 files changed, 335 insertions(+), 51 deletions(-) create mode 100644 tests/adapters/openai/openai-chat-path-override.test.ts diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md b/devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md index 817993dc98..2b830e42e4 100644 --- a/devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md @@ -83,14 +83,18 @@ responsesPath 가 실제로 흐르는 경로 전체를 대칭으로 따라가야 src/router.ts:377-378 fill-if-absent 시딩 src/config.ts zod 스키마 + 경로 검증(responsesPath 규칙 재사용) src/server/auth-cors.ts:801 필드 권한 맵에 "editor" - gui/src/provider-payload.ts:5, :74, :89-90 - gui/src/components/provider-catalog/provider-presets.ts:18 - gui/src/components/AddProviderModal.tsx:151 + (대시보드 배선은 이 유닛에서 제외 — 아래 참고) tests/server/config.test.ts:1476 허용/거절 검증 3건의 대칭 openai-chat 쪽 URL 조립은 openAIChatTransport 한 곳(src/adapters/openai-chat.ts:99)이면 된다. 116행과 1460행은 그 함수를 탄다. +대시보드 payload/preset/모달은 이 변경에서 건드리지 않는다. router 의 fill-if-absent 가 라우팅 시점에 +필드를 채우므로 대시보드로 추가한 프로바이더도 올바른 경로로 나간다. 남는 차이는 손으로 만든 커스텀 +프로바이더의 디스크 설정에 필드가 남지 않는다는 것뿐이다. gui/ 아래 파일을 건드리면 PR 게이트가 +UI 스크린샷을 요구하는데 이 변경에는 보여 줄 시각적 변화가 없다. 에디터에 실제 입력 컨트롤을 붙이는 +후속 작업에서 스크린샷과 함께 가져간다. + ### NEW modelSuffixBracketStrip 을 Responses 어댑터에도 적용 감사와 사전 조사가 일치한다. 이 플래그는 openai-chat.ts:119, :743, :1466 과 ollama-native.ts:214 에만 @@ -98,12 +102,24 @@ openai-chat 쪽 URL 조립은 openAIChatTransport 한 곳(src/adapters/openai-ch 상류 실측에서 괄호 id 는 400 model_not_found 였다. 지금 상태로 Responses 를 기본으로 돌리면 두 별칭이 기본 경로에서 죽는다. -해결책은 둘이다. Responses buildRequest 의 wire model 에 스트립을 넣거나, 로스터에서 별칭을 뺀다. +해결책은 둘이다. Responses buildRequest 에 스트립을 넣거나, 로스터에서 별칭을 뺀다. 후자는 zai/glm-5.3[1m] 을 고른 기존 사용자 선택을 깨고 parity 테스트가 고정한 별칭 메타데이터 (provider-registry-parity.test.ts:462, :490-504)까지 무너뜨린다. 결함 크기에 비해 파괴가 크다. -전자를 택한다: provider.modelSuffixBracketStrip 이 true 일 때만 wire model 을 정규화하고 -카탈로그 slug 는 그대로 둔다. openai-chat 이 이미 하는 것과 같은 동작이다. -tests/adapters/openai/openai-chat-model-suffix.test.ts 의 Responses 대칭 테스트를 추가한다. +전자를 택한다. + +삽입 지점은 감사가 정정했다. openai-responses 는 번역 어댑터가 없는 passthrough 라 +(adapters/registry.ts:90-94) body 를 parsed.modelId 로 다시 만들지 않고 parsed._rawBody 를 흘린다 +(openai-responses.ts:2373-2376). 라우터가 네이티브 id 를 _rawBody.model 에 써넣으므로 +(server/responses/core.ts:2492-2497) 괄호 별칭은 그 필드에 남는다. openai-chat 처럼 wire model 만 +건드리면 스트립이 조용히 무효가 된다. + +따라서 createResponsesPassthroughAdapter.buildRequest 안, JSON.stringify(finalBody)(:2544) 직전에 +provider.modelSuffixBracketStrip === true 이고 finalBody 가 plain object 이며 model 이 문자열일 때 +stripBracketedModelSuffix 를 적용한다. 이 한 지점이 HTTP 와 WebSocket outbound 를 모두 덮는다 — +WS 는 어댑터가 URL/body 를 다시 만들지 않고 수송만 바꾼다(server/responses/fetch-helpers.ts:97-104). + +테스트는 기존 openai-chat-model-suffix.test.ts 헬퍼를 그대로 쓸 수 없다. 그 헬퍼(:7-14)는 _rawBody 가 +없어서 Responses 대칭 테스트에 넣으면 빈 body 가 된다. passthrough 테스트처럼 _rawBody 를 채운다. ### MODIFY zai 행 @@ -123,10 +139,11 @@ Chat 전용이던 preserveReasoningContentModels 는 유지한다(opt-in 한 사 opt-in 은 기존 수단을 그대로 쓴다: 사용자가 modelAdapters 에 "openai-chat" 을 적으면 resolveWireProtocolOverride 가 어댑터를 바꾸고, 새 chatCompletionsPath 가 올바른 경로로 보낸다. -Chat 이 받지 않는 모델은 레지스트리가 Responses 로 고정한다. B 단계에서 coding/paas/v4 chat 경로에 -로스터 전체를 실제로 던져 어떤 모델이 400/403 을 내는지 확인하고, 해당 모델만 modelWireDefaults 에 -wire "openai-responses" 와 inbound ["responses", "chat", "anthropic"] 로 선언한다. -grok-4.20-multi-agent 행의 주석이 같은 상황을 같은 방식으로 처리한 선례다. +Chat 이 받지 않는 모델은 레지스트리가 Responses 로 고정한다 — 다만 실측 결과 그런 모델이 없다. +2026-09-12 에 coding/paas/v4 chat 경로로 로스터 전체를 던졌더니 glm-5.3 / glm-5.3-flash / glm-5.2 / +glm-5.1 / glm-5 / glm-4.6 / glm-5-turbo 가 모두 200 이었다. 그래서 이번 변경에 modelWireDefaults 는 +넣지 않는다. 나중에 어느 모델이 Chat 에서 거절되면 그때 wire "openai-responses" 와 +inbound ["responses", "chat", "anthropic"] 로 선언하면 된다. grok-4.20-multi-agent 행 주석이 그 선례다. ### 감사에서 정리된 사항 @@ -137,21 +154,35 @@ grok-4.20-multi-agent 행의 주석이 같은 상황을 같은 방식으로 처 OpenAI /models(data[] + id) 계약을 기대한다. 확인되지 않은 라이브 주장은 빈 피커를 만든다. - free-directory 의 glm id 는 별개다(src/providers/free-directory.ts:112). 계속 https://api.z.ai/api/coding/paas/v4 + openai-chat 에 남고 zai 전환을 따라가지 않는다. -- structure 문서 의무: src/adapters/ 와 src/config.ts 와 src/providers/ 가 소유 문서를 갖는다. - structure/transports/responses.md:253 의 responsesPath 서술에 chatCompletionsPath 대칭 문장을 넣고 - bun run structure:check 를 wp4 검증에 포함한다. +- Lab behavior fingerprint: behavior.ts 에 wire 키를 넣으려면 src/lab/subject/behavior-fingerprint.ts 의 + CLOSED_KEYS(:5-6)에 같은 키를 등록해야 한다. 미분류 키는 normalizeBehaviorValues 가 런타임에 던지고 + (:60) LabBehaviorValues 가 Record 라 typecheck 로는 안 잡힌다. wire.chatCompletionsPath 를 + wire.responsesPath 와 나란히 등록한다. +- #1100 destination 매칭: registryEntryForProviderDestination(registry.ts:3513-3524)은 adapter 와 + 정규화된 baseUrl 로 행을 찾는다. zai 를 openai-responses + https://api.z.ai 로 옮기면 구 Chat URL 을 + 가리키는 커스텀 프로바이더(예: 이름 "GLM")가 zai 메타데이터를 잃는다 + (tests/codex-integration/codex-catalog.test.ts:6068-6075 가 그 계약을 고정한다). + 구 Chat 엔드포인트를 destination alias 로 남겨서 기존 동작을 보존한다. +- structure 문서 의무: 감사가 정정한 대로 structure/transports/responses.md:253 은 OpenCode Go URL 매처 + 문단이라 넣을 자리가 아니다. 갱신 대상은 같은 문서의 responsesPath 일반 계약 문단, + structure/data-planes/inbound-compat.md:8-10(현재 openaiChatCompletionsUrl 만 적혀 있다), + structure/config.md, structure/runtime.md:143-144. docs-site 는 guides/providers.md:457 의 zai baseUrl 과 + reference/configuration/providers.md:144, reference/adapters.md:181 의 responsesPath 서술이 대상이다. - 이 변경은 020 이 넣는 ZAI_GLM_5X_INPUT_MODALITIES 상수를 쓴다. wp3 가 dev 에 들어간 뒤 올린다. ## 테스트 -갱신이 필요한 기존 고정 테스트. 감사가 열거한 목록이다. +갱신이 필요한 기존 고정 테스트. 두 번째 감사가 좁혀 준 목록이다 — 초안은 과했다. + + tests/providers/provider-registry-parity.test.ts:462 modelContextWindows 1_000_000 -> 1_048_576 + tests/codex-integration/codex-catalog.test.ts:6068-6075 #1100 destination 계약 + tests/server/config.test.ts:1476-1505 responsesPath 검증의 대칭 3건 추가 + tests/adapters/openai/openai-chat-model-suffix.test.ts:116-118 routed zai 스트립을 Responses 로 + tests/gui/provider-payload.test.ts:195 GUI 라운드트립 toEqual - tests/providers/provider-registry-parity.test.ts:281, :313, :462, :512 - (:462 는 modelContextWindows 를 1_000_000 으로 고정한다 -> 1_048_576) - tests/providers/zhipu-bigmodel-provider.test.ts:86 - (glm free-directory 는 coding/paas/v4 에 남는다. zai 와 섞지 말 것) - tests/adapters/openai/openai-chat-model-suffix.test.ts:31 - quota / catalog / reasoning 테스트 중 zai Chat URL 을 fixture 로 쓰는 것들 +건드리지 않는다: parity :281 / :313 / :527 은 구 Chat URL 을 '낡은 저장 설정' 픽스처로 쓸 뿐 adapter/baseUrl 을 +단언하지 않는다. :512 는 131_072 라 무관하다. quota 테스트의 구 URL 은 매핑이 남아 있어 유효하다. +zhipu-bigmodel-provider.test.ts:86/:91 은 glm 이 coding/paas/v4 에 남는다는 단언이라 그대로 둔다. MODIFY tests/providers/provider-registry-parity.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index ec169b2505..6e2f4ffcce 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -454,7 +454,7 @@ routing or defaults changes. | Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | -| Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | +| Z.AI (GLM Coding) | `https://api.z.ai` — Responses at `/api/v1/responses` by default; Chat Completions at `/api/coding/paas/v4/chat/completions` per model through `modelAdapters` | | Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | | BigModel Coding Plan (Responses, static roster) | `https://open.bigmodel.cn/api/v1` | | Qwen Cloud | Token plan (default): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · Pay as you go: `https://dashscope.aliyuncs.com/compatible-mode/v1` · or Custom | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 65e57f19ab..33605f80d4 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -142,6 +142,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | +| `chatCompletionsPath?` | `string` | Relative resource path for `openai-chat` requests, the mirror of `responsesPath` and subject to the same shape rules. Needed when one upstream serves Chat Completions and Responses under different prefixes: a per-model wire override changes the adapter and leaves `baseUrl` alone, so without this an opted-in Chat request would be sent to the Responses base. Z.AI is the shipped example. | | `allowEncryptedV2AgentTasks?` | `boolean` | Disabled by default. Trust a direct key-auth `openai-responses` provider to consume or relay opaque encrypted V2 sub-agent tasks unchanged. Eligible routes skip `agentTaskRecovery`; all other routes keep the existing recovery or fail-closed behavior. OpenCodex does not decrypt, translate, or recover tasks sent through this opt-in. | | `upstreamWebsocket?` | `boolean` | Opt-in upstream Responses WebSocket transport for `openai-responses` requests (default false). When the upstream supports the Responses WebSocket protocol, streaming POST requests to the configured Responses path (default `/v1/responses`) are dialed as WSS over an HTTPS base URL and re-encoded to SSE for the usual pipeline. Forward providers use `{baseUrl}/responses`; key-auth providers use `responsesPath`, or the legacy `/v1/responses` fallback. This mirrors the canonical ChatGPT backend optimization for OpenAI-compatible gateways (for example sub2api) whose WebSocket ingress is measurably faster than its SSE queue. Plain HTTP remains on SSE; non-Responses paths and `openai-chat` requests stay on HTTP. | | `supportsServiceTier?` | `boolean` | Tri-state canonical Fast capability fallback. `true` publishes Fast in the catalog, satisfies service-tier routing requirements, contributes a supported fingerprint, and lets fast mode inject the provider's canonical wire value on a compatible final adapter. `false` strips the field and never injects, and exact model declarations cannot reopen it. Absent leaves the provider unclassified: fast mode does not inject or normalize a canonical caller value, and caller values obey the final wire's forwarding permission (`chatServiceTier` on Chat; passthrough on Responses). The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 23ed0c910c..41c6be90a6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -939,6 +939,7 @@ "openai-chat-hardening.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", + "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index cd3481bc7e..e338d845ad 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -96,7 +96,14 @@ function openAIChatTransport(provider: OcxProviderConfig): { }; if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`; if (provider.headers) Object.assign(headers, provider.headers); - return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential }; + // A configured relative path wins, mirroring how the Responses adapter honours + // `responsesPath`. An upstream can serve both wires under different prefixes, and a + // per-model wire override only swaps the adapter, so without this the opted-in Chat + // request would be sent to the Responses base with `/chat/completions` appended. + const url = provider.chatCompletionsPath === undefined + ? openaiChatCompletionsUrl(provider.baseUrl) + : `${provider.baseUrl.replace(/\/$/, "")}${provider.chatCompletionsPath}`; + return { url, headers, hasCredential }; } /** diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c4aa523ee6..30e1c14c85 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,4 +1,5 @@ import { normalizeRoutedAgentMessages } from "./routed-agent-messages"; +import { stripBracketedModelSuffix } from "./openai-chat"; import { normalizeOpenCodeGoAdditionalTools } from "./opencode-go-additional-tools"; import { isXaiResponsesDestination } from "../providers/xai-transport"; import { createHash } from "node:crypto"; @@ -2541,7 +2542,21 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): actualServiceTier === null ? null : "service-tier", actualServiceTier, ); - const body = JSON.stringify(finalBody); + // The Responses adapter is passthrough: it forwards `parsed._rawBody` rather than + // rebuilding the body from `parsed.modelId`, and the router writes the routed id into + // that raw body. So a provider whose upstream rejects bracketed ids has to be honoured + // here, on the serialized body, not on the parsed selector. One place covers both the + // HTTP and the WebSocket outbound, because the WS path transports this same request + // instead of rebuilding it. + const body = JSON.stringify( + provider.modelSuffixBracketStrip + && finalBody !== null + && typeof finalBody === "object" + && !Array.isArray(finalBody) + && typeof (finalBody as { model?: unknown }).model === "string" + ? { ...(finalBody as Record), model: stripBracketedModelSuffix((finalBody as { model: string }).model) } + : finalBody, + ); const releaseBodyObservation = translatorBudget.observeExternallyCapped( "passthrough_serialization", new TextEncoder().encode(body).byteLength, diff --git a/src/config.ts b/src/config.ts index 106dadd2b6..a0282d921c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -602,6 +602,7 @@ const providerConfigSchema = z.object({ mcpMaxResultBytes: z.number().int().positive().optional(), apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(), responsesPath: z.string().min(1).optional(), + chatCompletionsPath: z.string().min(1).optional(), statelessResponses: z.boolean().optional(), requiresAdjacentResponsesToolResults: z.boolean().optional(), annotateEmptyToolOutputs: z.boolean().optional(), @@ -674,14 +675,18 @@ export { upstreamHttpVersionConfigError, } from "./config/provider-validation"; -function providerResponsesPathConfigError(responsesPath: string | undefined): string | null { - if (responsesPath === undefined) return null; - if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(responsesPath) || responsesPath.includes("://")) { - return "responsesPath must be a relative path without a URL scheme"; +/** + * Shared shape check for the two relative send-path overrides. `field` names the + * offending key so the message stays specific to what the user actually wrote. + */ +function providerRelativeSendPathConfigError(field: string, value: string | undefined): string | null { + if (value === undefined) return null; + if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || value.includes("://")) { + return `${field} must be a relative path without a URL scheme`; } - if (!responsesPath.startsWith("/")) return "responsesPath must start with /"; - if (responsesPath.includes("?") || responsesPath.includes("#")) { - return "responsesPath must not include query strings or fragments"; + if (!value.startsWith("/")) return `${field} must start with /`; + if (value.includes("?") || value.includes("#")) { + return `${field} must not include query strings or fragments`; } return null; } @@ -1450,13 +1455,15 @@ const configSchema = z.object({ }); } } - const responsesPathError = providerResponsesPathConfigError(provider.responsesPath); - if (responsesPathError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "responsesPath"], - message: responsesPathError, - }); + for (const field of ["responsesPath", "chatCompletionsPath"] as const) { + const sendPathError = providerRelativeSendPathConfigError(field, provider[field]); + if (sendPathError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), field], + message: sendPathError, + }); + } } const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers); if (headersError) { diff --git a/src/lab/subject/behavior-fingerprint.ts b/src/lab/subject/behavior-fingerprint.ts index 51dd9d9efb..831577c8dc 100644 --- a/src/lab/subject/behavior-fingerprint.ts +++ b/src/lab/subject/behavior-fingerprint.ts @@ -3,7 +3,7 @@ import { jcsStringify } from "../digest"; import type { LabBehaviorSource, LabBehaviorValues } from "../live/types"; const CLOSED_KEYS = new Set([ - "wire.adapter", "wire.upstreamProtocol", "wire.responsesPath", "wire.commandCodeVersion", "wire.modelSuffixMode", + "wire.adapter", "wire.upstreamProtocol", "wire.responsesPath", "wire.chatCompletionsPath", "wire.commandCodeVersion", "wire.modelSuffixMode", "auth.mode", "auth.transport", "responses.stateful", "responses.upstreamStreaming", "responses.serviceTier", "responses.fastWireKind", "responses.fastWireValue", "responses.snapshotRepair", "responses.itemIdRepair", "limits.contextWindow", "limits.maxInputTokens", "limits.maxOutputTokens", diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 67e6c0522e..72a662aee4 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -16,6 +16,7 @@ export interface DerivedKeyLoginProvider { label: string; baseUrl: string; responsesPath?: string; + chatCompletionsPath?: string; adapter: string; apiKeyValidation?: "unknown"; apiKeyTransport?: OcxProviderConfig["apiKeyTransport"]; @@ -71,6 +72,7 @@ export interface DerivedProviderPreset { adapter: string; baseUrl: string; responsesPath?: string; + chatCompletionsPath?: string; defaultModel?: string; auth: "oauth" | "forward" | "key" | "local"; codexAccountMode?: CodexAccountMode; @@ -222,6 +224,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon baseUrl: entry.baseUrl, ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}), ...(entry.responsesPath ? { responsesPath: entry.responsesPath } : {}), + ...(entry.chatCompletionsPath ? { chatCompletionsPath: entry.chatCompletionsPath } : {}), ...(entry.alias ? { alias: entry.alias } : {}), // Preserve the registry auth kind verbatim (including "local") so fail-closed gates that // distinguish local runtimes from API-key providers keep working after the seed round-trip. @@ -257,6 +260,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.chatServiceTier !== undefined ? { chatServiceTier: entry.chatServiceTier } : {}), ...(entry.openaiChatEofTolerance !== undefined ? { openaiChatEofTolerance: entry.openaiChatEofTolerance } : {}), ...(entry.responsesPath !== undefined ? { responsesPath: entry.responsesPath } : {}), + ...(entry.chatCompletionsPath !== undefined ? { chatCompletionsPath: entry.chatCompletionsPath } : {}), ...(entry.statelessResponses !== undefined ? { statelessResponses: entry.statelessResponses } : {}), ...(entry.requiresAdjacentResponsesToolResults !== undefined ? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults } @@ -288,6 +292,7 @@ export function deriveKeyLoginMap(): Record { label: entry.label, baseUrl: entry.baseUrl, ...(entry.responsesPath ? { responsesPath: entry.responsesPath } : {}), + ...(entry.chatCompletionsPath ? { chatCompletionsPath: entry.chatCompletionsPath } : {}), adapter: entry.adapter, ...(entry.apiKeyValidation !== undefined ? { apiKeyValidation: entry.apiKeyValidation } : {}), ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}), @@ -478,6 +483,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport; if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel; if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath; + if (prov.chatCompletionsPath === undefined && seed.chatCompletionsPath !== undefined) prov.chatCompletionsPath = seed.chatCompletionsPath; // Fill mode only when absent: an explicit persisted `direct` must never be overwritten. if (prov.codexAccountMode === undefined && seed.codexAccountMode !== undefined) prov.codexAccountMode = seed.codexAccountMode; if (!prov.models && seed.models) prov.models = [...seed.models]; @@ -521,6 +527,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // Fill-only: a hand-edited path must survive, and a config saved before the registry // learned this route still gets backfilled. if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath; + if (prov.chatCompletionsPath === undefined && seed.chatCompletionsPath !== undefined) prov.chatCompletionsPath = seed.chatCompletionsPath; if (prov.statelessResponses === undefined && seed.statelessResponses !== undefined) prov.statelessResponses = seed.statelessResponses; if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) { prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults; @@ -602,6 +609,7 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset { adapter: entry.adapter, baseUrl: entry.baseUrl, ...(entry.responsesPath ? { responsesPath: entry.responsesPath } : {}), + ...(entry.chatCompletionsPath ? { chatCompletionsPath: entry.chatCompletionsPath } : {}), auth: entry.authKind === "forward" ? "forward" : entry.authKind === "oauth" ? "oauth" : entry.authKind === "local" ? "local" : "key", ...(entry.codexAccountMode ? { codexAccountMode: entry.codexAccountMode } : {}), ...(entry.codexAccountMode ? { provider: providerConfigSeed(entry) } : {}), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index b85aa3e95c..27bc1738ba 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -231,6 +231,19 @@ export interface ProviderRegistryEntry { * per model. DeepSeek documents `POST /responses` with no `/v1` segment. */ responsesPath?: string; + /** + * Relative send path for the `openai-chat` wire, seeded into saved config exactly like + * `responsesPath`. Needed when one upstream serves both wires under different prefixes, + * because a per-model wire override changes the adapter and not the base URL. + */ + chatCompletionsPath?: string; + /** + * Endpoints this entry used to live at, kept so a saved custom provider that still points + * at one keeps receiving this row's metadata through `registryEntryForProviderDestination`. + * Destination matching is by adapter plus normalized base URL, so moving a row's wire or + * prefix would otherwise orphan every config a user wrote against the old address. + */ + destinationAliases?: readonly { readonly baseUrl: string; readonly adapter: string }[]; /** * Responses upstream that stores nothing server-side. Stateful request parameters * are dropped and `store` is pinned false, and orphaned tool results left by a @@ -362,7 +375,7 @@ export interface ProviderRegistryEntry { export type ProviderConfigSeed = Pick< OcxProviderConfig, - "adapter" | "baseUrl" | "apiKeyTransport" | "responsesPath" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models" + "adapter" | "baseUrl" | "apiKeyTransport" | "responsesPath" | "chatCompletionsPath" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models" | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities" | "modelDisplayNames" | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" @@ -2635,12 +2648,32 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // exact 131_072 every other source in this repo uses for that model. Coding Plan pricing stays // unpublished, so no cost entry is asserted. { - id: "zai", label: "Z.AI — GLM Coding Plan", baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat", authKind: "key", + id: "zai", label: "Z.AI — GLM Coding Plan", baseUrl: "https://api.z.ai", adapter: "openai-responses", authKind: "key", + // One subscription and one key, three protocols. docs.z.ai/guides/llm/glm-5.3 lists them: + // Chat Completions at /api/coding/paas/v4, Responses at /api/v1, Anthropic Messages at + // /api/anthropic. docs.z.ai/devpack/latest-model points Codex-family clients at /api/v1, + // and the Chat path is the one that misbehaves in practice. + // + // Responses is the default and Chat stays reachable per model through `modelAdapters`. + // The two wires sit under different prefixes, and a wire override swaps the adapter + // without touching baseUrl, so each wire carries its own relative send path. + // + // Measured 2026-09-12 against a live key: every roster id answers 200 on + // /api/v1/responses, and every one also answers 200 on the Chat prefix, so no model + // needs a `modelWireDefaults` pin. /api/v1/chat/completions returns 403 + // model_access_denied, which is why the Chat path cannot simply hang off the new base. + responsesPath: "/api/v1/responses", + chatCompletionsPath: "/api/coding/paas/v4/chat/completions", + // The address this row occupied before the move. A saved custom provider still pointing + // at the Chat endpoint keeps receiving this row's metadata (#1100). + destinationAliases: [{ baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat" }], dashboardUrl: "https://z.ai/manage-apikey/apikey-list", defaultModel: "glm-5.3", note: "GLM-5.3 coding subscription", models: ["glm-5.3", "glm-5.3[1m]", "glm-5.3-flash", "glm-5.2", "glm-5.2[1m]", "glm-5.1", "glm-5", "glm-4.6"], - modelContextWindows: { "glm-5.3": 1_000_000, "glm-5.3[1m]": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }, - // Z.AI's OpenAI path returns 400 code 1211 for bracketed model ids. + // The upstream catalog reports 1_048_576 for the 5.3 family, which is what the domestic + // Responses row already carries. Both are documented as "1M"; this is that number. + modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3[1m]": 1_048_576, "glm-5.3-flash": 1_048_576, "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }, + // Z.AI returns 400 for bracketed model ids on both wires; the aliases are local. modelSuffixBracketStrip: true, noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, modelInputModalities: ZAI_GLM_5X_INPUT_MODALITIES, @@ -2649,6 +2682,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelMaxOutputTokens: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, 131_072])), modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_5X_MODELS.map(id => [id, true])), preserveReasoningContentModels: ZAI_GLM_5X_MODELS, + // Responses replay uses this provider-level flag; the model list above still covers a + // caller who opts back into Chat. + preserveResponsesReasoningContent: true, }, // Zhipu's domestic BigModel platform: OpenAI-compatible pay-as-you-go on open.bigmodel.cn — a // different host and billing product from the `zai` coding-plan subscription above. @@ -3516,12 +3552,22 @@ export function registryEntryForProviderDestination( if (typeof provider.baseUrl !== "string" || !provider.baseUrl) return undefined; if (provider.authMode !== undefined && provider.authMode !== "key") return undefined; const endpoint = normalizedProviderEndpoint(provider.baseUrl); - return PROVIDER_REGISTRY.find(entry => + const eligible = (entry: ProviderRegistryEntry): boolean => entry.authKind === "key" && !entry.allowBaseUrlOverride - && !/\{[^}]*\}/.test(entry.baseUrl) + && !/\{[^}]*\}/.test(entry.baseUrl); + const direct = PROVIDER_REGISTRY.find(entry => + eligible(entry) && entry.adapter === provider.adapter && normalizedProviderEndpoint(entry.baseUrl) === endpoint); + if (direct) return direct; + // A row that moved keeps answering for the address it used to occupy, so an existing + // custom provider written against the old endpoint does not silently lose its metadata. + return PROVIDER_REGISTRY.find(entry => + eligible(entry) + && (entry.destinationAliases ?? []).some(alias => + alias.adapter === provider.adapter + && normalizedProviderEndpoint(alias.baseUrl) === endpoint)); } /** diff --git a/src/router.ts b/src/router.ts index 55a0326fce..bd8e9dc690 100644 --- a/src/router.ts +++ b/src/router.ts @@ -377,6 +377,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined ? { responsesPath: registryEntry.responsesPath } : {}), + ...(provider.chatCompletionsPath === undefined && registryEntry.chatCompletionsPath !== undefined + ? { chatCompletionsPath: registryEntry.chatCompletionsPath } + : {}), ...(provider.requiresAdjacentResponsesToolResults === undefined && registryEntry.requiresAdjacentResponsesToolResults !== undefined ? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults } diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index a853fa4813..700eb333bc 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -149,6 +149,7 @@ export function resolveProductionBehaviorValues( "wire.adapter": behaviorRow("provider_config", adapter), "wire.upstreamProtocol": behaviorRow("provider_config", upstreamProtocol), "wire.responsesPath": behaviorRow("provider_config", effective.responsesPath ?? null), + "wire.chatCompletionsPath": behaviorRow("provider_config", effective.chatCompletionsPath ?? null), "wire.commandCodeVersion": behaviorRow("provider_config", effective.commandCodeVersion ?? null), "wire.modelSuffixMode": behaviorRow( "provider_config", diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 3a93246cd0..7ae0c0209a 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -799,6 +799,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { fastWire: "editor", baseUrl: "editor", responsesPath: "editor", + chatCompletionsPath: "editor", commandCodeVersion: "editor", statelessResponses: "editor", requiresAdjacentResponsesToolResults: "editor", diff --git a/src/types/provider.ts b/src/types/provider.ts index e65130a4fa..f850773109 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -272,6 +272,17 @@ export interface OcxProviderConfig { * the legacy `/v1/responses` construction. */ responsesPath?: string; + /** + * Optional relative send path for the `openai-chat` wire, mirroring `responsesPath`. + * Same shape rules: must start with `/`, no URL scheme, query string, or fragment. + * When omitted the adapter keeps `openaiChatCompletionsUrl(baseUrl)`. + * + * This exists because a per-model wire override swaps `adapter` and leaves `baseUrl` + * alone, so an upstream that serves Chat Completions and Responses under different + * path prefixes cannot be reached by the adapter swap by itself. Z.AI is that case: + * `/api/v1/responses` and `/api/coding/paas/v4/chat/completions` on one host and one key. + */ + chatCompletionsPath?: string; /** * Command Code protocol version sent as `x-command-code-version` on /alpha/generate requests. * The internal endpoint's schema drifts with the CLI version; operators can pin a known-good diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 927a26aa0e..e7ba7a6769 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -6,8 +6,11 @@ Completions endpoint. Route selection reads the raw Chat body and the native request keeps that body as its wire source; a Responses projection is constructed only after the native route is declined and is never converted back into Chat. Request construction remains owned by `src/adapters/openai-chat.ts`, including model -normalization, credential and provider headers, capability-specific fields, and the canonical -`openaiChatCompletionsUrl()` path. The passthrough builder uses an explicit Chat-field whitelist so +normalization, credential and provider headers, capability-specific fields, and the send URL: the +canonical `openaiChatCompletionsUrl()` path, or `chatCompletionsPath` when the provider declares one. +That field is the `openai-chat` mirror of `responsesPath` and exists because a per-model wire +override swaps the adapter without touching `baseUrl`, so an upstream serving the two wires under +different prefixes cannot be reached by the swap alone. The passthrough builder uses an explicit Chat-field whitelist so messages (including `name` and separate `system`/`developer` entries), Chat token controls, sampling/logprob fields, caller identity/metadata, and caller stream options retain their wire shape. For streams, caller `stream_options` are merged with mandatory `include_usage: true`. On diff --git a/tests/adapters/openai/openai-chat-model-suffix.test.ts b/tests/adapters/openai/openai-chat-model-suffix.test.ts index 69dcee5b59..96ba37e1fc 100644 --- a/tests/adapters/openai/openai-chat-model-suffix.test.ts +++ b/tests/adapters/openai/openai-chat-model-suffix.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter, stripBracketedModelSuffix } from "../../../src/adapters/openai-chat"; +import { createResponsesPassthroughAdapter } from "../../../src/adapters/openai-responses"; +import { parseRequest } from "../../../src/responses/parser"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; import { routeModel } from "../../../src/router"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; @@ -113,10 +116,26 @@ describe("openai-chat adapter wire model normalization", () => { expect(wireModel(req)).toBe("glm-5.2[1m]"); }); - test("a routed zai config strips glm-5.2[1m]", async () => { - const req = createOpenAIChatAdapter(routedZaiProvider()).buildRequest(parsed("glm-5.2[1m]")); + test("a routed zai config opted back into Chat strips glm-5.2[1m]", async () => { + // zai defaults to Responses now, so this is the modelAdapters opt-in path: the override + // swaps the adapter and the row's chatCompletionsPath carries the request to the Chat + // prefix. The bracket strip has to survive that swap. + const req = createOpenAIChatAdapter({ ...routedZaiProvider(), adapter: "openai-chat" }) + .buildRequest(parsed("glm-5.2[1m]")); expect(wireModel(req)).toBe("glm-5.2"); }); + + test("the Responses wire strips the alias too, on the passthrough body", async () => { + // The Responses adapter forwards the raw body instead of rebuilding it from the parsed + // selector, and the router writes the routed id into that raw body. Stripping only the + // parsed selector would send `glm-5.2[1m]` upstream, which Z.AI answers with a 400. + const provider = routedZaiProvider(); + expect(provider.adapter).toBe("openai-responses"); + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)); + const req = await adapter.buildRequest(parseRequest({ model: "glm-5.2[1m]", input: "hi" })); + expect((JSON.parse(req.body as string) as { model?: unknown }).model).toBe("glm-5.2"); + expect(req.url).toBe("https://api.z.ai/api/v1/responses"); + }); }); describe("anthropic adapter leaves the bracketed suffix intact", () => { diff --git a/tests/adapters/openai/openai-chat-path-override.test.ts b/tests/adapters/openai/openai-chat-path-override.test.ts new file mode 100644 index 0000000000..f7857dd3f9 --- /dev/null +++ b/tests/adapters/openai/openai-chat-path-override.test.ts @@ -0,0 +1,58 @@ +/** + * `chatCompletionsPath` is the mirror of `responsesPath`: a relative send path for the + * openai-chat wire. + * + * It exists because a per-model wire override swaps `provider.adapter` and leaves + * `provider.baseUrl` alone (src/server/adapter-resolve.ts). An upstream that serves Chat + * Completions and Responses under different prefixes therefore cannot be reached by the + * adapter swap by itself. Z.AI is exactly that shape: `/api/v1/responses` and + * `/api/coding/paas/v4/chat/completions` on one host and one key, with + * `/api/v1/chat/completions` answering 403. + */ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { routeModel } from "../../../src/router"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +function parsed(modelId: string): OcxParsedRequest { + return { + modelId, + context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + stream: false, + options: {}, + }; +} + +describe("openai-chat send path override", () => { + test("without the field the adapter keeps appending /chat/completions to baseUrl", () => { + const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://example.test/v1" }; + const req = createOpenAIChatAdapter(provider).buildRequest(parsed("some-model")); + expect(req.url).toBe("https://example.test/v1/chat/completions"); + }); + + test("a configured path replaces the whole suffix, trailing slash and all", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://example.test/", + chatCompletionsPath: "/api/other/v4/chat/completions", + }; + const req = createOpenAIChatAdapter(provider).buildRequest(parsed("some-model")); + expect(req.url).toBe("https://example.test/api/other/v4/chat/completions"); + }); + + test("the zai row carries both wires, so a Chat opt-in reaches the Chat prefix", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "zai", + providers: { zai: { adapter: "openai-responses", baseUrl: "https://api.z.ai" } }, + }; + const routed = routeModel(config, "zai/glm-5.3").provider; + // Both paths are seeded from the registry onto the resolved provider. + expect(routed.responsesPath).toBe("/api/v1/responses"); + expect(routed.chatCompletionsPath).toBe("/api/coding/paas/v4/chat/completions"); + // Opting a model back into Chat only swaps the adapter; the path field is what keeps + // the request off `https://api.z.ai/chat/completions`, which is not an endpoint. + const req = createOpenAIChatAdapter({ ...routed, adapter: "openai-chat" }).buildRequest(parsed("glm-5.3")); + expect(req.url).toBe("https://api.z.ai/api/coding/paas/v4/chat/completions"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 8b3d85d403..268f1d2141 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -773,6 +773,7 @@ "openai-chat-hardening.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", + "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 96be6cc6ab..bfe4532edb 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -14,7 +14,7 @@ import { enrichProviderFromRegistry, providerConfigSeed, } from "../../src/providers/derive"; -import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { PROVIDER_REGISTRY, registryEntryForProviderDestination } from "../../src/providers/registry"; import { FREE_PROVIDER_DIRECTORY } from "../../src/providers/free-directory"; import { applyProviderConfigHints } from "../../src/codex/catalog"; import { routeModel } from "../../src/router"; @@ -459,7 +459,9 @@ describe("provider registry parity", () => { const optedInProviders = PROVIDER_REGISTRY .filter(entry => entry.modelSuffixBracketStrip) .map(entry => entry.id); - expect(zai?.modelContextWindows).toEqual({ "glm-5.3": 1_000_000, "glm-5.3[1m]": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }); + // The 5.3 family carries the number its own catalog reports, 1_048_576, which is what the + // domestic Responses row already used. 5.2 keeps the round figure it was seeded with. + expect(zai?.modelContextWindows).toEqual({ "glm-5.3": 1_048_576, "glm-5.3[1m]": 1_048_576, "glm-5.3-flash": 1_048_576, "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }); // BUG-R5: glm-5.3-flash is a native VLM (docs.z.ai/guides/vlm/glm-5.3-flash), so it // must never sit in noVisionModels - that list routes a model's images through the // proxy's vision sidecar, which hands the model a text description of a picture it @@ -512,6 +514,27 @@ describe("provider registry parity", () => { expect(zai?.modelMaxOutputTokens?.[id]).toBe(131_072); } expect(providerConfigSeed(zai!).modelSuffixBracketStrip).toBe(true); + // Responses is the default wire and Chat stays reachable per model. The two live under + // different prefixes on one host, so each carries its own relative send path; a wire + // override swaps the adapter and leaves baseUrl alone. + expect(zai?.adapter).toBe("openai-responses"); + expect(zai?.baseUrl).toBe("https://api.z.ai"); + expect(zai?.responsesPath).toBe("/api/v1/responses"); + expect(zai?.chatCompletionsPath).toBe("/api/coding/paas/v4/chat/completions"); + expect(providerConfigSeed(zai!).responsesPath).toBe("/api/v1/responses"); + expect(providerConfigSeed(zai!).chatCompletionsPath).toBe("/api/coding/paas/v4/chat/completions"); + // A config saved against the address this row used to occupy still resolves to it, + // so an existing custom provider does not quietly lose its metadata (#1100). + expect(registryEntryForProviderDestination({ + adapter: "openai-chat", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + authMode: "key", + })?.id).toBe("zai"); + expect(registryEntryForProviderDestination({ + adapter: "openai-responses", + baseUrl: "https://api.z.ai", + authMode: "key", + })?.id).toBe("zai"); expect(providerConfigSeed(zai!).modelDefaultReasoningEfforts?.["glm-5.3"]).toBe("max"); expect(deriveKeyLoginMap().zai.modelMaxOutputTokens?.["glm-5.3[1m]"]).toBe(131_072); // `zhipu-bigmodel-coding` opts in for the same reason `zai` does: it serves the same diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 33643fb495..2b8cdaa63e 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -325,6 +325,20 @@ function writeResponsesPathConfig(responsesPath: string): void { }); } +function writeChatCompletionsPathConfig(chatCompletionsPath: string): void { + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-chat", + baseUrl: "https://example.test", + chatCompletionsPath, + }, + }, + defaultProvider: "custom", + }); +} + function writeAccountNamespaceConfig( codexAccountNamespaces: unknown, overrides: Record = {}, @@ -1505,6 +1519,40 @@ describe("opencodex config defaults", () => { } }); + // chatCompletionsPath is the openai-chat mirror of responsesPath and shares its shape + // rules, so the same three cases have to hold on that side too. + test("accepts a relative chatCompletionsPath", () => { + writeChatCompletionsPathConfig("/api/coding/paas/v4/chat/completions"); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.providers.custom.chatCompletionsPath).toBe("/api/coding/paas/v4/chat/completions"); + }); + + test("rejects chatCompletionsPath without a leading slash", () => { + writeChatCompletionsPathConfig("chat/completions"); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("chatCompletionsPath must start with /"); + }); + + test("rejects chatCompletionsPath containing a URL scheme, query, or fragment", () => { + for (const [chatCompletionsPath, expectedError] of [ + ["https://other-origin.example/chat/completions", "chatCompletionsPath must be a relative path without a URL scheme"], + ["/https://other-origin.example/chat/completions", "chatCompletionsPath must be a relative path without a URL scheme"], + ["/chat/completions?api-version=v1", "chatCompletionsPath must not include query strings or fragments"], + ["/chat/completions#section", "chatCompletionsPath must not include query strings or fragments"], + ] as const) { + writeChatCompletionsPathConfig(chatCompletionsPath); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain(expectedError); + } + }); + test("reads valid config diagnostics without mutation", () => { writeConfig({ port: 12345,