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(),