diff --git a/extensions/xai/stream.test.ts b/extensions/xai/stream.test.ts index 5bf6a5f4b38..552111d18eb 100644 --- a/extensions/xai/stream.test.ts +++ b/extensions/xai/stream.test.ts @@ -39,6 +39,13 @@ describe("xai stream wrappers", () => { }), ).toBe("grok-3-fast"); expect(captureWrappedModelId({ modelId: "grok-4", fastMode: true })).toBe("grok-4-fast"); + expect( + captureWrappedModelId({ + modelId: "grok-3", + fastMode: true, + api: "openai-responses", + }), + ).toBe("grok-3-fast"); }); it("leaves unsupported or disabled models unchanged", () => { @@ -83,4 +90,31 @@ describe("xai stream wrappers", () => { expect(payload).not.toHaveProperty("reasoning_effort"); expect(payload.tools[0]?.function).not.toHaveProperty("strict"); }); + + it("strips unsupported reasoning controls from xai payloads", () => { + const payload: Record = { + reasoning: { effort: "high" }, + reasoningEffort: "high", + reasoning_effort: "high", + }; + const baseStreamFn: StreamFn = (_model, _context, options) => { + options?.onPayload?.(payload, {} as Model<"openai-responses">); + return {} as ReturnType; + }; + const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn); + + void wrapped( + { + api: "openai-responses", + provider: "xai", + id: "grok-4-fast", + } as Model<"openai-responses">, + { messages: [] } as Context, + {}, + ); + + expect(payload).not.toHaveProperty("reasoning"); + expect(payload).not.toHaveProperty("reasoningEffort"); + expect(payload).not.toHaveProperty("reasoning_effort"); + }); }); diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index df8c99ad8b0..bbfb0c31e29 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -111,6 +111,20 @@ afterEach(() => { clearRuntimeConfigSnapshot(); }); +async function withoutEnv(key: string, fn: () => Promise): Promise { + const previous = process.env[key]; + delete process.env[key]; + try { + return await fn(); + } finally { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } +} + function createCustomProviderConfig( baseUrl: string, modelId = "llama3", @@ -386,23 +400,25 @@ describe("resolveUsableCustomProviderApiKey", () => { describe("resolveApiKeyForProvider", () => { it("reuses the xai plugin web search key without models.providers.xai", async () => { - const resolved = await resolveApiKeyForProvider({ - provider: "xai", - cfg: { - plugins: { - entries: { - xai: { - config: { - webSearch: { - apiKey: "xai-plugin-fallback-key", // pragma: allowlist secret + const resolved = await withoutEnv("XAI_API_KEY", () => + resolveApiKeyForProvider({ + provider: "xai", + cfg: { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "xai-plugin-fallback-key", // pragma: allowlist secret + }, }, }, }, }, }, - }, - store: { version: 1, profiles: {} }, - }); + store: { version: 1, profiles: {} }, + }), + ); expect(resolved).toMatchObject({ apiKey: "xai-plugin-fallback-key", @@ -440,11 +456,13 @@ describe("resolveApiKeyForProvider", () => { }; setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); - const resolved = await resolveApiKeyForProvider({ - provider: "xai", - cfg: sourceConfig, - store: { version: 1, profiles: {} }, - }); + const resolved = await withoutEnv("XAI_API_KEY", () => + resolveApiKeyForProvider({ + provider: "xai", + cfg: sourceConfig, + store: { version: 1, profiles: {} }, + }), + ); expect(resolved).toMatchObject({ apiKey: "xai-runtime-key", @@ -455,24 +473,26 @@ describe("resolveApiKeyForProvider", () => { it("does not reuse xai fallback auth when the xai plugin is disabled", async () => { await expect( - resolveApiKeyForProvider({ - provider: "xai", - cfg: { - plugins: { - entries: { - xai: { - enabled: false, - config: { - webSearch: { - apiKey: "xai-plugin-fallback-key", // pragma: allowlist secret + withoutEnv("XAI_API_KEY", () => + resolveApiKeyForProvider({ + provider: "xai", + cfg: { + plugins: { + entries: { + xai: { + enabled: false, + config: { + webSearch: { + apiKey: "xai-plugin-fallback-key", // pragma: allowlist secret + }, }, }, }, }, }, - }, - store: { version: 1, profiles: {} }, - }), + store: { version: 1, profiles: {} }, + }), + ), ).rejects.toThrow('No API key found for provider "xai"'); }); }); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 3e9e8d9c075..ac59a08456a 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -6,7 +6,6 @@ import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types import { coerceSecretRef } from "../config/types.secrets.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { formatApiKeyPreview } from "../plugins/provider-auth-input.js"; import { buildProviderMissingAuthMessageWithPlugin, resolveProviderSyntheticAuthWithPlugin, @@ -34,6 +33,7 @@ import { type ResolvedProviderAuth, } from "./model-auth-runtime-shared.js"; import { normalizeProviderId } from "./model-selection.js"; +import { shouldTraceProviderAuth, summarizeProviderAuthKey } from "./xai-auth-trace.js"; export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js"; export { requireApiKey, resolveAwsSdkEnvVarName } from "./model-auth-runtime-shared.js"; @@ -41,21 +41,6 @@ export type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js"; const log = createSubsystemLogger("model-auth"); -function shouldTraceProviderAuth(provider: string): boolean { - return normalizeProviderId(provider) === "xai"; -} - -function summarizeProviderAuthKey(apiKey: string | undefined): string { - const trimmed = apiKey?.trim() ?? ""; - if (!trimmed) { - return "missing"; - } - if (isNonSecretApiKeyMarker(trimmed)) { - return `marker:${trimmed}`; - } - return formatApiKeyPreview(trimmed); -} - function logProviderAuthDecision(params: { provider: string; stage: string; diff --git a/src/agents/models-config.providers.secrets.ts b/src/agents/models-config.providers.secrets.ts index e84742f1210..47df7d79677 100644 --- a/src/agents/models-config.providers.secrets.ts +++ b/src/agents/models-config.providers.secrets.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { formatApiKeyPreview } from "../plugins/provider-auth-input.js"; +import { resolveProviderWebSearchPluginConfig } from "../plugin-sdk/provider-web-search.js"; import { resolveProviderSyntheticAuthWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { listProfilesForProvider } from "./auth-profiles/profiles.js"; @@ -14,6 +14,7 @@ import { resolveNonEnvSecretRefHeaderValueMarker, } from "./model-auth-markers.js"; import { resolveAwsSdkEnvVarName } from "./model-auth-runtime-shared.js"; +import { shouldTraceProviderAuth, summarizeProviderAuthKey } from "./xai-auth-trace.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; @@ -49,21 +50,6 @@ export type ProviderAuthResolver = ( const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; const log = createSubsystemLogger("agents/model-providers"); -function shouldTraceProviderAuth(provider: string): boolean { - return provider.trim().toLowerCase() === "xai"; -} - -function summarizeProviderAuthKey(apiKey: string | undefined): string { - const trimmed = apiKey?.trim() ?? ""; - if (!trimmed) { - return "missing"; - } - if (isNonSecretApiKeyMarker(trimmed)) { - return `marker:${trimmed}`; - } - return formatApiKeyPreview(trimmed); -} - export function normalizeApiKeyConfig(value: string): string { const trimmed = value.trim(); const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed); @@ -438,15 +424,19 @@ function resolveConfigBackedProviderAuth(params: { provider: string; config?: Op source: "config"; } | undefined { - const synthetic = resolveProviderSyntheticAuthWithPlugin({ - provider: params.provider, - config: params.config, - context: { - config: params.config, + // Providers own any provider-specific fallback auth logic via + // resolveSyntheticAuth(...). Discovery/bootstrap callers may consume + // non-secret markers from source config, but must never persist plaintext. + const synthetic = + resolveProviderSyntheticAuthWithPlugin({ provider: params.provider, - providerConfig: params.config?.models?.providers?.[params.provider], - }, - }); + config: params.config, + context: { + config: params.config, + provider: params.provider, + providerConfig: params.config?.models?.providers?.[params.provider], + }, + }) ?? resolveXaiConfigFallbackAuth(params); const apiKey = synthetic?.apiKey?.trim(); if (!apiKey) { if (shouldTraceProviderAuth(params.provider)) { @@ -473,3 +463,68 @@ function resolveConfigBackedProviderAuth(params: { provider: string; config?: Op source: "config", }; } + +function resolveXaiConfigFallbackAuth(params: { provider: string; config?: OpenClawConfig }): + | { + apiKey: string; + source: string; + mode: "api-key"; + } + | undefined { + if (params.provider.trim().toLowerCase() !== "xai") { + return undefined; + } + const xaiPluginEntry = params.config?.plugins?.entries?.xai; + if (xaiPluginEntry?.enabled === false) { + return undefined; + } + const pluginApiKey = normalizeOptionalSecretInput( + resolveProviderWebSearchPluginConfig( + params.config as Record | undefined, + "xai", + )?.apiKey, + ); + if (pluginApiKey) { + return { + apiKey: pluginApiKey, + source: "plugins.entries.xai.config.webSearch.apiKey", + mode: "api-key", + }; + } + const pluginApiKeyRef = coerceSecretRef( + resolveProviderWebSearchPluginConfig( + params.config as Record | undefined, + "xai", + )?.apiKey, + ); + if (pluginApiKeyRef) { + return { + apiKey: + pluginApiKeyRef.source === "env" + ? pluginApiKeyRef.id.trim() + : resolveNonEnvSecretRefApiKeyMarker(pluginApiKeyRef.source), + source: "plugins.entries.xai.config.webSearch.apiKey", + mode: "api-key", + }; + } + const grokApiKey = normalizeOptionalSecretInput(params.config?.tools?.web?.search?.grok?.apiKey); + if (grokApiKey) { + return { + apiKey: grokApiKey, + source: "tools.web.search.grok.apiKey", + mode: "api-key", + }; + } + const grokApiKeyRef = coerceSecretRef(params.config?.tools?.web?.search?.grok?.apiKey); + if (!grokApiKeyRef) { + return undefined; + } + return { + apiKey: + grokApiKeyRef.source === "env" + ? grokApiKeyRef.id.trim() + : resolveNonEnvSecretRefApiKeyMarker(grokApiKeyRef.source), + source: "tools.web.search.grok.apiKey", + mode: "api-key", + }; +} diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ac2ae8fa0e9..c3859aaa1c8 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -97,6 +97,7 @@ import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js"; import { resolveTranscriptPolicy } from "../../transcript-policy.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; +import { shouldTraceProviderAuth, summarizeProviderAuthKey } from "../../xai-auth-trace.js"; import { isRunnerAbortError } from "../abort.js"; import { isCacheTtlEligibleProvider } from "../cache-ttl.js"; import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js"; @@ -214,18 +215,6 @@ export { const MAX_BTW_SNAPSHOT_MESSAGES = 100; -function shouldTraceProviderAuth(provider: string): boolean { - return provider.trim().toLowerCase() === "xai"; -} - -function summarizeProviderAuthKey(apiKey: string | undefined): string { - const trimmed = apiKey?.trim() ?? ""; - if (!trimmed) { - return "missing"; - } - return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}`; -} - export function resolveEmbeddedAgentStreamFn(params: { currentStreamFn: StreamFn | undefined; providerStreamFn?: StreamFn; diff --git a/src/agents/pi-embedded-runner/run/auth-controller.ts b/src/agents/pi-embedded-runner/run/auth-controller.ts index c283873f0fa..598c35595e1 100644 --- a/src/agents/pi-embedded-runner/run/auth-controller.ts +++ b/src/agents/pi-embedded-runner/run/auth-controller.ts @@ -1,6 +1,5 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ThinkLevel } from "../../../auto-reply/thinking.js"; -import { formatApiKeyPreview } from "../../../plugins/provider-auth-input.js"; import { prepareProviderRuntimeAuth } from "../../../plugins/provider-runtime.js"; import { type AuthProfileStore, @@ -16,6 +15,7 @@ import { type FailoverReason, } from "../../pi-embedded-helpers.js"; import { clampRuntimeAuthRefreshDelayMs } from "../../runtime-auth-refresh.js"; +import { shouldTraceProviderAuth, summarizeProviderAuthKey } from "../../xai-auth-trace.js"; import { describeUnknownError } from "../utils.js"; import { RUNTIME_AUTH_REFRESH_MARGIN_MS, @@ -37,15 +37,6 @@ type LogLike = { warn(message: string): void; }; -function shouldTraceProviderAuth(provider: string): boolean { - return provider.trim().toLowerCase() === "xai"; -} - -function summarizeProviderAuthKey(apiKey: string | undefined): string { - const trimmed = apiKey?.trim() ?? ""; - return trimmed ? formatApiKeyPreview(trimmed) : "missing"; -} - export function createEmbeddedRunAuthController(params: { config: RunEmbeddedPiAgentParams["config"]; agentDir: string; diff --git a/src/agents/xai-auth-trace.ts b/src/agents/xai-auth-trace.ts new file mode 100644 index 00000000000..b9877a7bd3e --- /dev/null +++ b/src/agents/xai-auth-trace.ts @@ -0,0 +1,35 @@ +import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; + +const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 }; + +function formatApiKeyPreview(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return "…"; + } + const { head, tail } = DEFAULT_KEY_PREVIEW; + if (trimmed.length <= head + tail) { + const shortHead = Math.min(2, trimmed.length); + const shortTail = Math.min(2, trimmed.length - shortHead); + if (shortTail <= 0) { + return `${trimmed.slice(0, shortHead)}…`; + } + return `${trimmed.slice(0, shortHead)}…${trimmed.slice(-shortTail)}`; + } + return `${trimmed.slice(0, head)}…${trimmed.slice(-tail)}`; +} + +export function shouldTraceProviderAuth(provider: string): boolean { + return provider.trim().toLowerCase() === "xai"; +} + +export function summarizeProviderAuthKey(apiKey: string | undefined): string { + const trimmed = apiKey?.trim() ?? ""; + if (!trimmed) { + return "missing"; + } + if (isNonSecretApiKeyMarker(trimmed)) { + return `marker:${trimmed}`; + } + return formatApiKeyPreview(trimmed); +} diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 6635b377dba..2ca39ec8312 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -3,6 +3,57 @@ import os from "node:os"; import path from "node:path"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; +import { applyLitellmProviderConfig } from "../../extensions/litellm/onboard.js"; +import { + applyMinimaxApiConfig, + applyMinimaxApiProviderConfig, +} from "../../extensions/minimax/onboard.js"; +import { buildMistralModelDefinition as buildBundledMistralModelDefinition } from "../../extensions/mistral/model-definitions.js"; +import { + applyMistralConfig, + applyMistralProviderConfig, + MISTRAL_DEFAULT_MODEL_REF, +} from "../../extensions/mistral/onboard.js"; +import { + applyOpencodeGoConfig, + applyOpencodeGoProviderConfig, +} from "../../extensions/opencode-go/onboard.js"; +import { + applyOpencodeZenConfig, + applyOpencodeZenProviderConfig, +} from "../../extensions/opencode/onboard.js"; +import { + applyOpenrouterConfig, + applyOpenrouterProviderConfig, + OPENROUTER_DEFAULT_MODEL_REF, +} from "../../extensions/openrouter/onboard.js"; +import { + applySyntheticConfig, + applySyntheticProviderConfig, + SYNTHETIC_DEFAULT_MODEL_REF, +} from "../../extensions/synthetic/onboard.js"; +import { + applyXaiConfig, + applyXaiProviderConfig, + XAI_DEFAULT_MODEL_REF, +} from "../../extensions/xai/onboard.js"; +import { applyXiaomiConfig, applyXiaomiProviderConfig } from "../../extensions/xiaomi/onboard.js"; +import { + ZAI_CODING_CN_BASE_URL, + ZAI_GLOBAL_BASE_URL, +} from "../../extensions/zai/model-definitions.js"; +import { applyZaiConfig, applyZaiProviderConfig } from "../../extensions/zai/onboard.js"; +import { + createConfigWithFallbacks, + createLegacyProviderConfig, + EXPECTED_FALLBACKS, +} from "../../test/helpers/extensions/onboard-config.js"; +import { SYNTHETIC_DEFAULT_MODEL_ID } from "../agents/synthetic-models.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../config/model-input.js"; import { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; import { setMinimaxApiKey, writeOAuthCredentials } from "../plugins/provider-auth-storage.js"; import { @@ -11,6 +62,26 @@ import { setupAuthTestEnv, } from "./test-wizard-helpers.js"; +function expectPrimaryModelPreserved(cfg: OpenClawConfig): void { + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe( + "anthropic/claude-opus-4-5", + ); +} + +function expectFallbacksPreserved(cfg: OpenClawConfig): void { + expect(resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)).toEqual([ + ...EXPECTED_FALLBACKS, + ]); +} + +function expectAllowlistContains(cfg: OpenClawConfig, modelRef: string): void { + expect(Object.keys(cfg.agents?.defaults?.models ?? {})).toContain(modelRef); +} + +function expectAliasPreserved(cfg: OpenClawConfig, modelRef: string, alias: string): void { + expect(cfg.agents?.defaults?.models?.[modelRef]?.alias).toBe(alias); +} + describe("writeOAuthCredentials", () => { const lifecycle = createAuthTestLifecycle([ "OPENCLAW_STATE_DIR", @@ -320,3 +391,426 @@ describe("applyAuthProfileConfig", () => { }); }); }); + +describe("applyMinimaxApiConfig", () => { + it("adds minimax provider with correct settings", () => { + const cfg = applyMinimaxApiConfig({}); + expect(cfg.models?.providers?.minimax).toMatchObject({ + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + authHeader: true, + }); + }); + + it("keeps reasoning enabled for MiniMax-M2.7", () => { + const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.7"); + expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(true); + }); + + it("preserves existing model params when adding alias", () => { + const cfg = applyMinimaxApiConfig( + { + agents: { + defaults: { + models: { + "minimax/MiniMax-M2.7": { + alias: "MiniMax", + params: { custom: "value" }, + }, + }, + }, + }, + }, + "MiniMax-M2.7", + ); + expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.7"]).toMatchObject({ + alias: "Minimax", + params: { custom: "value" }, + }); + }); + + it("merges existing minimax provider models", () => { + const cfg = applyMinimaxApiConfig( + createLegacyProviderConfig({ + providerId: "minimax", + api: "openai-completions", + }), + ); + expect(cfg.models?.providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); + expect(cfg.models?.providers?.minimax?.api).toBe("anthropic-messages"); + expect(cfg.models?.providers?.minimax?.authHeader).toBe(true); + expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key"); + expect(cfg.models?.providers?.minimax?.models.map((m) => m.id)).toEqual([ + "old-model", + "MiniMax-M2.7", + ]); + }); + + it("preserves other providers when adding minimax", () => { + const cfg = applyMinimaxApiConfig({ + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com", + apiKey: "anthropic-key", // pragma: allowlist secret + api: "anthropic-messages", + models: [ + { + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + reasoning: false, + input: ["text"], + cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + }, + ], + }, + }, + }, + }); + expect(cfg.models?.providers?.anthropic).toBeDefined(); + expect(cfg.models?.providers?.minimax).toBeDefined(); + }); + + it("preserves existing models mode", () => { + const cfg = applyMinimaxApiConfig({ + models: { mode: "replace", providers: {} }, + }); + expect(cfg.models?.mode).toBe("replace"); + }); +}); + +describe("provider config helpers", () => { + it("does not overwrite existing primary model", () => { + const providerConfigAppliers = [applyMinimaxApiProviderConfig, applyZaiProviderConfig]; + for (const applyConfig of providerConfigAppliers) { + const cfg = applyConfig({ + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, + }); + expectPrimaryModelPreserved(cfg); + } + }); +}); + +describe("applyZaiConfig", () => { + it("adds zai provider with correct settings", () => { + const cfg = applyZaiConfig({}); + expect(cfg.models?.providers?.zai).toMatchObject({ + // Default: general (non-coding) endpoint. Coding Plan endpoint is detected during setup. + baseUrl: ZAI_GLOBAL_BASE_URL, + api: "openai-completions", + }); + const ids = cfg.models?.providers?.zai?.models?.map((m) => m.id); + expect(ids).toContain("glm-5"); + expect(ids).toContain("glm-5-turbo"); + expect(ids).toContain("glm-4.7"); + expect(ids).toContain("glm-4.7-flash"); + expect(ids).toContain("glm-4.7-flashx"); + }); + + it("supports CN endpoint for supported coding models", () => { + for (const modelId of ["glm-4.7-flash", "glm-4.7-flashx"] as const) { + const cfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId }); + expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(`zai/${modelId}`); + } + }); +}); + +describe("applySyntheticConfig", () => { + it("adds synthetic provider with correct settings", () => { + const cfg = applySyntheticConfig({}); + expect(cfg.models?.providers?.synthetic).toMatchObject({ + baseUrl: "https://api.synthetic.new/anthropic", + api: "anthropic-messages", + }); + }); + + it("merges existing synthetic provider models", () => { + const cfg = applySyntheticProviderConfig( + createLegacyProviderConfig({ + providerId: "synthetic", + api: "openai-completions", + }), + ); + expect(cfg.models?.providers?.synthetic?.baseUrl).toBe("https://api.synthetic.new/anthropic"); + expect(cfg.models?.providers?.synthetic?.api).toBe("anthropic-messages"); + expect(cfg.models?.providers?.synthetic?.apiKey).toBe("old-key"); + const ids = cfg.models?.providers?.synthetic?.models.map((m) => m.id); + expect(ids).toContain("old-model"); + expect(ids).toContain(SYNTHETIC_DEFAULT_MODEL_ID); + }); +}); + +describe("primary model defaults", () => { + it("sets correct primary model", () => { + const configCases = [ + { + getConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.7-highspeed"), + primaryModel: "minimax/MiniMax-M2.7-highspeed", + }, + { + getConfig: () => applyZaiConfig({}, { modelId: "glm-5" }), + primaryModel: "zai/glm-5", + }, + { + getConfig: () => applySyntheticConfig({}), + primaryModel: SYNTHETIC_DEFAULT_MODEL_REF, + }, + ] as const; + for (const { getConfig, primaryModel } of configCases) { + const cfg = getConfig(); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(primaryModel); + } + }); +}); + +describe("applyXiaomiConfig", () => { + it("adds Xiaomi provider with correct settings", () => { + const cfg = applyXiaomiConfig({}); + expect(cfg.models?.providers?.xiaomi).toMatchObject({ + baseUrl: "https://api.xiaomimimo.com/v1", + api: "openai-completions", + }); + expect(cfg.models?.providers?.xiaomi?.models.map((m) => m.id)).toEqual([ + "mimo-v2-flash", + "mimo-v2-pro", + "mimo-v2-omni", + ]); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe("xiaomi/mimo-v2-flash"); + }); + + it("merges Xiaomi models and keeps existing provider overrides", () => { + const cfg = applyXiaomiProviderConfig( + createLegacyProviderConfig({ + providerId: "xiaomi", + api: "openai-completions", + modelId: "custom-model", + modelName: "Custom", + }), + ); + + expect(cfg.models?.providers?.xiaomi?.baseUrl).toBe("https://api.xiaomimimo.com/v1"); + expect(cfg.models?.providers?.xiaomi?.api).toBe("openai-completions"); + expect(cfg.models?.providers?.xiaomi?.apiKey).toBe("old-key"); + expect(cfg.models?.providers?.xiaomi?.models.map((m) => m.id)).toEqual([ + "custom-model", + "mimo-v2-flash", + "mimo-v2-pro", + "mimo-v2-omni", + ]); + }); +}); + +describe("applyXaiConfig", () => { + it("adds xAI provider with correct settings", () => { + const cfg = applyXaiConfig({}); + expect(cfg.models?.providers?.xai).toMatchObject({ + baseUrl: "https://api.x.ai/v1", + api: "openai-responses", + }); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(XAI_DEFAULT_MODEL_REF); + }); +}); + +describe("applyXaiProviderConfig", () => { + it("merges xAI models and keeps existing provider overrides", () => { + const cfg = applyXaiProviderConfig( + createLegacyProviderConfig({ + providerId: "xai", + api: "anthropic-messages", + modelId: "custom-model", + modelName: "Custom", + }), + ); + + expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1"); + expect(cfg.models?.providers?.xai?.api).toBe("openai-responses"); + expect(cfg.models?.providers?.xai?.apiKey).toBe("old-key"); + expect(cfg.models?.providers?.xai?.models.map((m) => m.id)).toEqual( + expect.arrayContaining([ + "custom-model", + "grok-4", + "grok-4-1-fast", + "grok-4.20-beta-latest-reasoning", + "grok-code-fast-1", + ]), + ); + }); +}); + +describe("applyMistralConfig", () => { + it("adds Mistral provider with correct settings", () => { + const cfg = applyMistralConfig({}); + expect(cfg.models?.providers?.mistral).toMatchObject({ + baseUrl: "https://api.mistral.ai/v1", + api: "openai-completions", + }); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe( + MISTRAL_DEFAULT_MODEL_REF, + ); + }); +}); + +describe("applyMistralProviderConfig", () => { + it("merges Mistral models and keeps existing provider overrides", () => { + const cfg = applyMistralProviderConfig( + createLegacyProviderConfig({ + providerId: "mistral", + api: "anthropic-messages", + modelId: "custom-model", + modelName: "Custom", + }), + ); + + expect(cfg.models?.providers?.mistral?.baseUrl).toBe("https://api.mistral.ai/v1"); + expect(cfg.models?.providers?.mistral?.api).toBe("openai-completions"); + expect(cfg.models?.providers?.mistral?.apiKey).toBe("old-key"); + expect(cfg.models?.providers?.mistral?.models.map((m) => m.id)).toEqual([ + "custom-model", + "mistral-large-latest", + ]); + const mistralDefault = cfg.models?.providers?.mistral?.models.find( + (model) => model.id === "mistral-large-latest", + ); + expect(mistralDefault?.contextWindow).toBe(262144); + expect(mistralDefault?.maxTokens).toBe(16384); + }); + + it("uses the bundled mistral default model definition", () => { + const bundled = buildBundledMistralModelDefinition(); + const cfg = applyMistralProviderConfig({}); + const defaultModel = cfg.models?.providers?.mistral?.models.find( + (model) => model.id === bundled.id, + ); + + expect(defaultModel).toMatchObject({ + id: bundled.id, + contextWindow: bundled.contextWindow, + maxTokens: bundled.maxTokens, + }); + }); +}); + +describe("fallback preservation helpers", () => { + it("preserves existing model fallbacks", () => { + const fallbackCases = [applyMinimaxApiConfig, applyXaiConfig, applyMistralConfig] as const; + for (const applyConfig of fallbackCases) { + const cfg = applyConfig(createConfigWithFallbacks()); + expectFallbacksPreserved(cfg); + } + }); +}); + +describe("provider alias defaults", () => { + it("adds expected alias for provider defaults", () => { + const aliasCases = [ + { + applyConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.7"), + modelRef: "minimax/MiniMax-M2.7", + alias: "Minimax", + }, + { + applyConfig: () => applyXaiProviderConfig({}), + modelRef: XAI_DEFAULT_MODEL_REF, + alias: "Grok", + }, + { + applyConfig: () => applyMistralProviderConfig({}), + modelRef: MISTRAL_DEFAULT_MODEL_REF, + alias: "Mistral", + }, + ] as const; + for (const testCase of aliasCases) { + const cfg = testCase.applyConfig(); + expect(cfg.agents?.defaults?.models?.[testCase.modelRef]?.alias).toBe(testCase.alias); + } + }); +}); + +describe("allowlist provider helpers", () => { + it("adds allowlist entry and preserves alias", () => { + const providerCases = [ + { + applyConfig: applyOpencodeZenProviderConfig, + modelRef: "opencode/claude-opus-4-6", + alias: "My Opus", + }, + { + applyConfig: applyOpencodeGoProviderConfig, + modelRef: "opencode-go/kimi-k2.5", + alias: "Kimi", + }, + { + applyConfig: applyOpenrouterProviderConfig, + modelRef: OPENROUTER_DEFAULT_MODEL_REF, + alias: "Router", + }, + ] as const; + for (const { applyConfig, modelRef, alias } of providerCases) { + const withDefault = applyConfig({}); + expectAllowlistContains(withDefault, modelRef); + + const withAlias = applyConfig({ + agents: { + defaults: { + models: { + [modelRef]: { alias }, + }, + }, + }, + }); + expectAliasPreserved(withAlias, modelRef, alias); + } + }); +}); + +describe("applyLitellmProviderConfig", () => { + it("preserves existing baseUrl and api key while adding the default model", () => { + const cfg = applyLitellmProviderConfig( + createLegacyProviderConfig({ + providerId: "litellm", + api: "anthropic-messages", + modelId: "custom-model", + modelName: "Custom", + baseUrl: "https://litellm.example/v1", + apiKey: " old-key ", + }), + ); + + expect(cfg.models?.providers?.litellm?.baseUrl).toBe("https://litellm.example/v1"); + expect(cfg.models?.providers?.litellm?.api).toBe("openai-completions"); + expect(cfg.models?.providers?.litellm?.apiKey).toBe("old-key"); + expect(cfg.models?.providers?.litellm?.models.map((m) => m.id)).toEqual([ + "custom-model", + "claude-opus-4-6", + ]); + }); +}); + +describe("default-model config helpers", () => { + it("sets primary model and preserves existing model fallbacks", () => { + const configCases = [ + { + applyConfig: applyOpencodeZenConfig, + primaryModel: "opencode/claude-opus-4-6", + }, + { + applyConfig: applyOpencodeGoConfig, + primaryModel: "opencode-go/kimi-k2.5", + }, + { + applyConfig: applyOpenrouterConfig, + primaryModel: OPENROUTER_DEFAULT_MODEL_REF, + }, + ] as const; + for (const { applyConfig, primaryModel } of configCases) { + const cfg = applyConfig({}); + expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(primaryModel); + + const cfgWithFallbacks = applyConfig(createConfigWithFallbacks()); + expectFallbacksPreserved(cfgWithFallbacks); + } + }); +});