From 28ff3f93eda85c170619b5bfb02903c95e7c4525 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 10:34:38 +0900 Subject: [PATCH] fix(zai): persist the Responses destination the router already applies A config written before the Z.AI coding plan moved to Responses still stores adapter openai-chat and baseUrl https://api.z.ai/api/coding/paas/v4. routedProviderConfig() rewrites both on every request because the registry entry owns a fixed destination, so the row already talks Responses while the dashboard, ocx doctor and direct config readers show the retired Chat endpoint, and every boot warns that the configured base URL is ignored. Startup now writes the canonical pair once and records zaiResponsesDefaultVersion. Only rows the router canonicalizes anyway are touched, so the wire does not change; a custom-named provider at the retired endpoint keeps its configured wire and its destinationAliases metadata. --- src/config.ts | 1 + src/providers/zai-responses-migration.ts | 45 ++++++++++++++++++ src/server/auth-cors.ts | 1 + src/server/index.ts | 3 +- src/server/management/provider-routes.ts | 9 ++++ src/server/zai-responses-startup.ts | 21 +++++++++ src/types/provider.ts | 6 +++ structure/transports/responses.md | 10 ++++ tests/server/config.test.ts | 60 ++++++++++++++++++++++++ 9 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 src/providers/zai-responses-migration.ts create mode 100644 src/server/zai-responses-startup.ts diff --git a/src/config.ts b/src/config.ts index 9f4968136a..5e81a7e5f1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -657,6 +657,7 @@ const providerConfigSchema = z.object({ webSearchBridge: providerWebSearchBridgeSchema.optional().catch(undefined), xaiResponsesXSearch: z.boolean().optional(), xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), + zaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), }).passthrough(); export { isValidProviderName, hasOwnProvider } from "./config/provider-name"; diff --git a/src/providers/zai-responses-migration.ts b/src/providers/zai-responses-migration.ts new file mode 100644 index 0000000000..c31b26e398 --- /dev/null +++ b/src/providers/zai-responses-migration.ts @@ -0,0 +1,45 @@ +import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry"; +import type { OcxConfig } from "../types"; + +export const ZAI_PROVIDER_ID = "zai"; +export const ZAI_RESPONSES_DEFAULT_VERSION = 1; + +/** + * Persist the Responses destination the router already applies to the `zai` row. + * + * The Z.AI coding plan moved from Chat Completions at /api/coding/paas/v4 to Responses at + * /api/v1 (#4297). A config written before that move still stores the Chat adapter and the old + * base URL, and `routedProviderConfig()` rewrites both on every request because the registry + * entry owns a fixed destination. The row therefore already talks Responses while the dashboard, + * `ocx doctor` and any direct config reader show the retired Chat endpoint, and each boot logs a + * "configured baseUrl is ignored" warning about a value the user never chose. + * + * This migration writes the canonical pair once so the stored row matches the live wire. It is + * behavior-preserving by construction: it only rewrites rows the router canonicalizes anyway. + * Chat remains reachable per model through `modelAdapters`, and the persisted marker keeps a + * later explicit Chat choice from being migrated again. + * + * A custom-named provider pointing at the retired endpoint is deliberately left alone. The router + * does not canonicalize it, so rewriting it would change a wire the operator actually configured; + * `destinationAliases` already gives it this row's metadata. + */ +export function migrateZaiResponsesDefault(config: OcxConfig): boolean { + const provider = config.providers[ZAI_PROVIDER_ID]; + if (!provider || (provider.zaiResponsesDefaultVersion ?? 0) >= ZAI_RESPONSES_DEFAULT_VERSION) return false; + const entry = getProviderRegistryEntry(ZAI_PROVIDER_ID); + if (!entry) return false; + // Fail closed if a later registry edit makes this destination operator-owned: only a fixed, + // non-templated endpoint is canonicalized at request time, so only that one may be persisted. + if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) return false; + if (!providerMatchesRegistryTransport(ZAI_PROVIDER_ID, provider)) return false; + config.providers = { + ...config.providers, + [ZAI_PROVIDER_ID]: { + ...provider, + adapter: entry.adapter, + baseUrl: entry.baseUrl, + zaiResponsesDefaultVersion: ZAI_RESPONSES_DEFAULT_VERSION, + }, + }; + return true; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 7ae0c0209a..0910698a0c 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -863,6 +863,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { supportsOpenAiWebSearchToolFields: "editor", xaiResponsesXSearch: "editor", xaiResponsesDefaultVersion: "runtime", + zaiResponsesDefaultVersion: "runtime", supportsResponsesCustomTools: "editor", responsesSnapshotRepair: "editor", webSearchBridge: "editor", diff --git a/src/server/index.ts b/src/server/index.ts index e1aa2be2bd..e97c869aee 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -24,6 +24,7 @@ import { grokDefaultReasoningEffort } from "../grok/effort"; import { flushConfigDirHardening } from "../config/paths"; import { migrateStartupSubagentModels } from "./subagent-models-startup"; import { migrateStartupXaiResponses } from "./xai-responses-startup"; +import { migrateStartupZaiResponses } from "./zai-responses-startup"; import { reconcileOAuthProviders } from "../oauth"; import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization"; import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync"; @@ -666,7 +667,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server ({ + changed: migrateZaiResponsesDefault(fresh), + value: fresh, + })); + if (outcome.status !== "unavailable") return outcome.value; + console.warn(`[zai-responses-migration] Persistence unavailable (${outcome.reason}); using Responses in memory only.`); + } catch { + // Filesystem errors can carry private paths. Startup must still remain available. + console.warn("[zai-responses-migration] Persistence failed; using Responses in memory only."); + } + return projection; +} diff --git a/src/types/provider.ts b/src/types/provider.ts index 1b43cb8d9c..a701bdb564 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -620,6 +620,12 @@ export interface OcxProviderConfig { xaiResponsesXSearch?: boolean; /** One-time Grok subscription wire upgrade; later explicit Chat choices remain authoritative. */ xaiResponsesDefaultVersion?: number; + /** + * One-time Z.AI coding-plan wire upgrade. The router already canonicalizes the `zai` row onto the + * Responses destination at request time; the marker records that the saved row was rewritten to + * match, so a later explicit Chat choice is not re-migrated on the next boot. + */ + zaiResponsesDefaultVersion?: number; /** * Whether the Responses upstream accepts native custom tools and custom_tool_call items. * Set false only for a provider whose native contract rejects them; absence preserves diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 90504f7ac5..2d7bd85db6 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -213,6 +213,16 @@ Startup removes legacy Grok 4.5/4.6 Chat overrides once and persists the provide rebases under the config mutation lock; unavailable persistence warns and uses an isolated in-memory projection without overwriting invalid disk state. Read-only config loading does not migrate. +The Z.AI coding plan gets the same shape for a different reason. Its registry row owns a fixed +destination, so `routedProviderConfig()` already rewrites a config written against the retired +Chat endpoint (`/api/coding/paas/v4`, `openai-chat`) onto Responses at `https://api.z.ai` on every +request. Startup persists that same canonical pair to the `zai` row once and records +`zaiResponsesDefaultVersion`, so the dashboard, `ocx doctor` and direct config readers stop showing +an endpoint the runtime never uses and the per-boot discarded-base-URL warning stops. The rewrite is +behavior-preserving because it only touches a row the router canonicalizes anyway; Chat stays +reachable per model through `modelAdapters`. A custom-named provider at the retired endpoint is not +migrated — the router leaves its wire alone, and `destinationAliases` already supplies its metadata. + The dashboard's Chat Completions switch and `ocx provider edit xai --xai-chat on|off` share the existing `modelAdapters` lane. On writes Chat for both models; off writes Responses. Unrelated overrides remain intact. The legacy PATCH field `xaiResponsesOptIn` retains its direction: diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 2b8cdaa63e..11931aefef 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -41,6 +41,8 @@ import { DEFAULT_SUBAGENT_MODELS, migrateSubagentModels } from "../../src/config import { migrateStartupSubagentModels } from "../../src/server/subagent-models-startup"; import { migrateXaiResponsesDefault } from "../../src/providers/xai-responses-opt-in"; import { migrateStartupXaiResponses } from "../../src/server/xai-responses-startup"; +import { migrateZaiResponsesDefault } from "../../src/providers/zai-responses-migration"; +import { migrateStartupZaiResponses } from "../../src/server/zai-responses-startup"; import * as configStore from "../../src/config"; import { runClaudeAuthModeMigration } from "../../src/claude/auth-mode-migration"; import { providerManagementConfigError } from "../../src/server/auth-cors"; @@ -303,6 +305,64 @@ describe("one-time Grok Responses upgrade", () => { }); }); +describe("one-time Z.AI Responses upgrade", () => { + const CANONICAL = { adapter: "openai-responses", baseUrl: "https://api.z.ai" }; + const RETIRED = { adapter: "openai-chat", baseUrl: "https://api.z.ai/api/coding/paas/v4" }; + + function legacy() { + return { + ...getDefaultConfig(), + providers: { + zai: { ...RETIRED, authMode: "key" as const, defaultModel: "glm-5.3" }, + }, + defaultProvider: "zai", + }; + } + + test("read-only load keeps the retired endpoint; startup persists the canonical wire once", () => { + saveConfig(legacy()); + const before = readFileSync(getConfigPath(), "utf8"); + const config = loadConfig(); + expect(config.providers.zai).toMatchObject(RETIRED); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + + const upgraded = migrateStartupZaiResponses(config); + expect(upgraded.providers.zai).toMatchObject({ ...CANONICAL, zaiResponsesDefaultVersion: 1 }); + expect(upgraded.providers.zai!.defaultModel).toBe("glm-5.3"); + expect(loadConfig().providers.zai).toEqual(upgraded.providers.zai); + // The caller's snapshot is not mutated in place, and a second boot is a no-op. + expect(config.providers.zai).toMatchObject(RETIRED); + expect(migrateZaiResponsesDefault(upgraded)).toBe(false); + }); + + test.each([1, 2])("an existing marker of version %i blocks a second rewrite", version => { + const config = legacy(); + config.providers.zai.zaiResponsesDefaultVersion = version; + saveConfig(config); + expect(migrateStartupZaiResponses(loadConfig()).providers.zai).toEqual(config.providers.zai); + expect(loadConfig().providers.zai!.zaiResponsesDefaultVersion).toBe(version); + }); + + test("a custom-named row at the retired endpoint keeps its configured wire", () => { + const source = legacy(); + const custom = { ...source, defaultProvider: "my-zai", providers: { "my-zai": source.providers.zai } }; + const before = structuredClone(custom); + expect(migrateZaiResponsesDefault(custom)).toBe(false); + expect(custom).toEqual(before); + }); + + test("unavailable persistence preserves disk and returns an isolated projection", () => { + const config = legacy(); + writeConfig("{ invalid"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(migrateStartupZaiResponses(config).providers.zai).toMatchObject(CANONICAL); + expect(readFileSync(getConfigPath(), "utf8")).toBe("{ invalid"); + expect(config.providers.zai).toMatchObject(RETIRED); + } finally { warn.mockRestore(); } + }); +}); + function writeConfig(content: unknown): void { writeFileSync( getConfigPath(),