From e743660fca5d7d1bebb1f146bb4a1b372b2bda20 Mon Sep 17 00:00:00 2001 From: zhouxun Date: Fri, 31 Jul 2026 15:08:15 +0800 Subject: [PATCH] fix(providers): default DeepSeek V4 Flash to Responses --- .../content/docs/reference/configuration.md | 2 +- .../docs/zh-cn/reference/configuration.md | 1 + src/providers/registry.ts | 28 +++++++++++++ src/server/adapter-resolve.ts | 11 +++++- tests/adapter-resolve.test.ts | 39 +++++++++++++++++++ 5 files changed, 78 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 7c2de76b3..c38b94320 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -325,7 +325,7 @@ or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10 | `modelReasoningEfforts?` | `Record` | Model-specific reasoning labels. An empty list hides the effort control for that model. | | `modelSupportsReasoningSummaries?` | `Record` | Model-specific reasoning-summary capability. Set a model to `false` to stop advertising summaries and strip summary-delivery fields before an `openai-responses` request. | | `modelReasoningSummaryDelivery?` | `Record` | Model-specific Responses delivery enum. A configured model stays summary-capable and only an existing `stream_options.reasoning_summary_delivery` is rewritten; do not combine it with a `false` summary capability for the same model. | -| `modelAdapters?` | `Record` | Per-model wire override for a gateway that fronts models speaking different wires. Keys are upstream native model ids; values must be `openai-chat` or `openai-responses`. Useful when one model needs the Responses API for hosted tools such as `web_search` while its siblings are fine on chat completions. Models the upstream pins to a single wire, and the canonical ChatGPT forward provider, reject overrides. | +| `modelAdapters?` | `Record` | Per-model wire override for a gateway that fronts models speaking different wires. Keys are upstream native model ids; values must be `openai-chat` or `openai-responses`. Built-in registry defaults may select a verified mixed-wire route automatically (the DeepSeek preset sends `deepseek-v4-flash` over native Responses); an explicit entry wins and can opt a model back out. Models the upstream pins to a single wire, and the canonical ChatGPT forward provider, reject overrides. | | `reasoningEffortMap?` | `Record` | Provider-wide wire aliases for reasoning labels. Use only when the upstream expects a different value. | | `modelReasoningEffortMap?` | `Record>` | Model-specific wire aliases for reasoning labels. | | `noReasoningModels?` | `string[]` | Models that reject a reasoning/thinking param — the adapter drops `reasoning_effort` for them. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 44c1b06f3..36934d417 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -172,6 +172,7 @@ x-opencodex-api-key: your-secret-token | `modelReasoningEfforts?` | `Record` | 模型级 reasoning label。空数组会隐藏该模型的 effort 控件。 | | `modelSupportsReasoningSummaries?` | `Record` | 模型级 reasoning summary 能力。设为 `false` 时不再声明 summary 支持,并在 `openai-responses` 请求前移除 summary-delivery 字段。 | | `modelReasoningSummaryDelivery?` | `Record` | 模型级 Responses delivery enum。已配置模型保持 summary 能力,适配器只改写现有的 `stream_options.reasoning_summary_delivery`;同一模型不能同时将 summary 能力设为 `false`。 | +| `modelAdapters?` | `Record` | 面向同一 gateway 混合使用不同 wire 的模型级覆盖。键是上游原生模型 id,值只能是 `openai-chat` 或 `openai-responses`。已验证的混合 wire 路由会由 registry 自动提供默认值(DeepSeek preset 会让 `deepseek-v4-flash` 使用原生 Responses);显式配置优先,也可以把模型切回 Chat。上游固定单一 wire 的模型和 canonical ChatGPT forward provider 不接受覆盖。 | | `reasoningEffortMap?` | `Record` | provider 级 reasoning label wire alias。只在上游需要不同值时使用。 | | `modelReasoningEffortMap?` | `Record>` | 模型级 reasoning label wire alias。 | | `noReasoningModels?` | `string[]` | 拒绝 reasoning/thinking 参数的模型;adapter 会为它们移除 `reasoning_effort`。 | diff --git a/src/providers/registry.ts b/src/providers/registry.ts index da8256366..ff4de74bb 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -123,6 +123,12 @@ export interface ProviderRegistryEntry { defaultModel?: string; models?: string[]; liveModels?: boolean; + /** + * Registry-only per-model wire defaults for mixed OpenAI-compatible gateways. + * These are intentionally not seeded into saved config: an explicit `modelAdapters` + * entry must remain distinguishable and must always win over a default. + */ + modelWireDefaults?: Record; modelDiscovery?: ProviderModelDiscoverySpec; contextWindow?: number; modelContextWindows?: Record; @@ -813,6 +819,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ models: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_THINKING_MODELS], defaultModel: "deepseek-v4-flash", modelContextWindows: { "deepseek-v4-flash": 1_000_000, "deepseek-v4-pro": 1_000_000 }, + // DeepSeek documents V4-Flash as a native Responses API model adapted for Codex. The + // API id is `deepseek-v4-flash`; `DeepSeek-V4-Flash-0731` is a release/version label. + modelWireDefaults: { "deepseek-v4-flash": "openai-responses" }, /* [Decision Log] - 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content. - 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata. @@ -1235,6 +1244,25 @@ export function providerMatchesRegistryTransport( return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); } +/** + * Resolve a registry-only default for a mixed-wire provider. Defaults only move a provider + * between the two OpenAI-shaped adapters and never override a provider configured on another + * wire. The resolver receives the allow-list so this helper cannot accidentally widen the + * adapter-selection boundary when a new registry entry is added. + */ +export function providerModelWireDefault( + id: string, + provider: Pick & Partial>, + modelId: string, + allowedWires: ReadonlySet, +): string | undefined { + if (!allowedWires.has(provider.adapter)) return undefined; + const entry = getProviderRegistryEntry(id); + if (!entry?.modelWireDefaults || !providerMatchesRegistryTransport(id, provider)) return undefined; + const wire = entry.modelWireDefaults[modelId.trim().toLowerCase()]; + return wire !== undefined && allowedWires.has(wire) ? wire : undefined; +} + /** * Effective Codex account mode for a provider. For canonical `openai`, a valid persisted * `codexAccountMode` on the provider config wins and a missing/invalid value defaults to diff --git a/src/server/adapter-resolve.ts b/src/server/adapter-resolve.ts index 3f74d87b2..b510a2455 100644 --- a/src/server/adapter-resolve.ts +++ b/src/server/adapter-resolve.ts @@ -9,10 +9,12 @@ import { createResponsesPassthroughAdapter } from "../adapters/openai-responses" import type { OcxProviderConfig } from "../types"; import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { providerModelWireDefault } from "../providers/registry"; /** * Resolve the wire a single model should use: a hard pin first, then a configured - * per-model override, then the provider's own adapter. + * per-model override, then a registry default for a mixed-wire provider, then the provider's + * own adapter. * * Safe to call more than once on its own output — the pin check does not look at the * current adapter, so a second pass cannot let an override displace a pin. @@ -24,7 +26,12 @@ export function resolveWireProtocolOverride(providerName: string, modelId: strin } // Re-check the allow-list here, not just in the config validator: the file may have // been hand-edited, or written by a build that allowed more values. - const requested = providerConfig.modelAdapters?.[modelId]; + const configured = providerConfig.modelAdapters?.[modelId]; + // An explicit allowed override wins, including one naming the provider-wide adapter (the + // opt-out from a registry default). Invalid hand-edited values fall through to the default. + const requested = configured && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured) + ? configured + : providerModelWireDefault(providerName, providerConfig, modelId, MODEL_ADAPTER_OVERRIDE_ALLOWED); if (requested && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requested) && requested !== providerConfig.adapter diff --git a/tests/adapter-resolve.test.ts b/tests/adapter-resolve.test.ts index aa1d8bd1e..c5ccdc663 100644 --- a/tests/adapter-resolve.test.ts +++ b/tests/adapter-resolve.test.ts @@ -90,3 +90,42 @@ describe("per-model wire override (#404)", () => { .toBe("openai-responses"); }); }); + +describe("registry per-model wire defaults", () => { + function deepseek(overrides: Partial = {}): OcxProviderConfig { + return gateway({ + baseUrl: "https://api.deepseek.com", + authMode: "key", + ...overrides, + }); + } + + test("routes only the official Flash API id through Responses", () => { + expect(resolveWireProtocolOverride("deepseek", "deepseek-v4-flash", deepseek()).adapter) + .toBe("openai-responses"); + expect(resolveWireProtocolOverride("deepseek", "deepseek-v4-pro", deepseek()).adapter) + .toBe("openai-chat"); + // The dated release label is not the API model id and must not be silently rewritten. + expect(resolveWireProtocolOverride("deepseek", "deepseek-v4-flash-0731", deepseek()).adapter) + .toBe("openai-chat"); + }); + + test("an explicit Chat override opts Flash back out of the default", () => { + const provider = deepseek({ modelAdapters: { "deepseek-v4-flash": "openai-chat" } }); + expect(resolveWireProtocolOverride("deepseek", "deepseek-v4-flash", provider).adapter) + .toBe("openai-chat"); + }); + + test("defaults do not apply when the provider is already on another wire", () => { + expect(resolveWireProtocolOverride("deepseek", "deepseek-v4-flash", deepseek({ adapter: "anthropic" })).adapter) + .toBe("anthropic"); + }); + + test("keeps provider credentials and destination untouched", () => { + const provider = deepseek({ apiKey: "test-key" }); + const resolved = resolveWireProtocolOverride("deepseek", "deepseek-v4-flash", provider); + expect(provider.adapter).toBe("openai-chat"); + expect(resolved.apiKey).toBe("test-key"); + expect(resolved.baseUrl).toBe("https://api.deepseek.com"); + }); +});