diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index dddb4c817b16..1dfbc2b2c397 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -102,7 +102,7 @@ ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persist f806b7326c4462fbbfc7407ff5d0eea831dbda4f87f73d0cdb193c1baf9e195d module/plugin-config-runtime beb6923354b3046a7c552a3476eb9c0c33f3c994de69e4b10a97a3eeaf4ce3fe module/plugin-entry d54879d527a9de84af4820bae79e66ad207896c595119f12330eca641151a2ce module/plugin-runtime -413b203696ff75c52f12008dfa3af5025e060bee6a5e32bba4af2d3087a67740 module/provider-auth +d16722b00152d27c415ee5efd7a901a9f42f7b68c969cd8ff7bd53cea1456bb1 module/provider-auth 6798bbe969215d0600d13b429098ca37133fa455d0589890ae79a9fd40a6d37d module/provider-catalog-runtime 8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture d077971d6208c5459ee2234283cae14ac4cfd74a8362de461800ed64ab5b3bc3 module/question-gateway-runtime diff --git a/extensions/opencode-go/api.ts b/extensions/opencode-go/api.ts deleted file mode 100644 index 24d3e4cd6bd3..000000000000 --- a/extensions/opencode-go/api.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Opencode Go API module exposes the plugin public contract. -export { - applyOpencodeGoConfig, - applyOpencodeGoProviderConfig, - OPENCODE_GO_DEFAULT_MODEL_REF, -} from "./onboard.js"; diff --git a/extensions/opencode-go/index.test.ts b/extensions/opencode-go/index.test.ts index 4232bc1b52f7..a5374b5eccb3 100644 --- a/extensions/opencode-go/index.test.ts +++ b/extensions/opencode-go/index.test.ts @@ -15,6 +15,7 @@ import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildOpencodeGoLiveProviderConfig, buildStaticOpencodeGoProviderConfig, + resolveOpencodeGoStarterModel, } from "./provider-catalog.js"; import opencodeGoProviderDiscovery from "./provider-discovery.js"; @@ -487,6 +488,27 @@ describe("opencode-go provider plugin", () => { expect(live.models.map((model) => model.id)).toEqual(activeModelIds); }); + it.each([ + [["deepseek-v4-pro"], "opencode-go/deepseek-v4-pro"], + [["glm-5.1"], undefined], + ])("selects only the advertised preferred onboarding model %#", async (modelIds, expected) => { + const fetchGuard = vi.fn(async () => ({ + response: new Response( + JSON.stringify({ data: modelIds.map((id) => ({ id, object: "model" })) }), + ), + finalUrl: "https://opencode.ai/zen/go/v1/models", + release: vi.fn(async () => undefined), + })); + + await expect( + resolveOpencodeGoStarterModel({ + apiKey: "resolved-opencode-key", + preferredModelRef: "opencode-go/deepseek-v4-pro", + fetchGuard, + }), + ).resolves.toBe(expected); + }); + it("does not mix provider-specific runtime auth with shared discovery auth", async () => { const provider = await registerSingleProviderPlugin(plugin); const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("blocked fetch")); diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index 2f69995b9cb4..2d1585e842d9 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -1,8 +1,8 @@ // Opencode Go plugin entrypoint registers its OpenClaw integration. import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; -import { applyOpencodeGoConfig, OPENCODE_GO_DEFAULT_MODEL_REF } from "./api.js"; import { opencodeGoMediaUnderstandingProvider } from "./media-understanding-provider.js"; +import { OPENCODE_GO_DEFAULT_MODEL_REF } from "./onboard.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildOpencodeGoLiveProviderConfig, @@ -11,31 +11,23 @@ import { normalizeOpencodeGoBaseUrl, normalizeOpencodeGoResolvedModel, resolveOpencodeGoModel, + resolveOpencodeGoStarterModel, } from "./provider-catalog.js"; import { resolveThinkingProfile } from "./provider-policy-api.js"; import { createOpencodeGoWrapper } from "./stream.js"; const PROVIDER_ID = "opencode-go"; -const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const; -const OPENCODE_SHARED_HINT = "Shared API key infrastructure for Zen + Go"; -type OpencodeGoCatalogAuth = { - apiKey?: string; - discoveryApiKey?: string; -}; - -function hasCatalogAuth(auth: OpencodeGoCatalogAuth): boolean { - return Boolean(auth.apiKey || auth.discoveryApiKey); -} +type OpencodeGoCatalogAuth = { apiKey?: string; discoveryApiKey?: string }; function resolveOpencodeGoCatalogAuth( resolveProviderApiKey: (providerId: string) => OpencodeGoCatalogAuth, ): OpencodeGoCatalogAuth | undefined { - const opencodeGoAuth = resolveProviderApiKey(PROVIDER_ID); - if (hasCatalogAuth(opencodeGoAuth)) { - return opencodeGoAuth; + const own = resolveProviderApiKey(PROVIDER_ID); + if (own.apiKey || own.discoveryApiKey) { + return own; } - const sharedOpencodeAuth = resolveProviderApiKey("opencode"); - return hasCatalogAuth(sharedOpencodeAuth) ? sharedOpencodeAuth : undefined; + const shared = resolveProviderApiKey("opencode"); + return shared.apiKey || shared.discoveryApiKey ? shared : undefined; } export default defineSingleProviderPluginEntry({ @@ -48,11 +40,16 @@ export default defineSingleProviderPluginEntry({ docsPath: "/providers/models", envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], manifestAuth: { - hint: OPENCODE_SHARED_HINT, + hint: "Shared API key infrastructure for Zen + Go", promptMessage: "Enter OpenCode API key", - profileIds: [...OPENCODE_SHARED_PROFILE_IDS], + profileIds: ["opencode:default", "opencode-go:default"], defaultModel: OPENCODE_GO_DEFAULT_MODEL_REF, - applyConfig: applyOpencodeGoConfig, + resolveDefaultModel: async ({ apiKey, signal }) => + await resolveOpencodeGoStarterModel({ + apiKey, + preferredModelRef: OPENCODE_GO_DEFAULT_MODEL_REF, + ...(signal ? { signal } : {}), + }), expectedProviders: ["opencode", "opencode-go"], noteMessage: [ "OpenCode Go is a separate paid subscription that uses the shared OpenCode API key.", diff --git a/extensions/opencode-go/onboard.test.ts b/extensions/opencode-go/onboard.test.ts deleted file mode 100644 index 9d6468828d1e..000000000000 --- a/extensions/opencode-go/onboard.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Opencode Go tests cover onboard plugin behavior. -import { expectProviderOnboardPrimaryAndFallbacks } from "openclaw/plugin-sdk/provider-test-contracts"; -import { describe, expect, it } from "vitest"; -import { applyOpencodeGoConfig, applyOpencodeGoProviderConfig } from "./onboard.js"; - -const MODEL_REF = "opencode-go/kimi-k2.6"; - -describe("opencode-go onboard", () => { - it("leaves model aliases to the OpenClaw catalog", () => { - const cfg = { - agents: { - defaults: { - models: { - [MODEL_REF]: { alias: "Kimi" }, - }, - }, - }, - }; - - expect(applyOpencodeGoProviderConfig(cfg)).toBe(cfg); - }); - - it("sets primary model and preserves existing model fallbacks", () => { - expectProviderOnboardPrimaryAndFallbacks({ - applyConfig: applyOpencodeGoConfig, - modelRef: MODEL_REF, - }); - }); -}); diff --git a/extensions/opencode-go/onboard.ts b/extensions/opencode-go/onboard.ts index a639dc4ff0be..1cc8407dd147 100644 --- a/extensions/opencode-go/onboard.ts +++ b/extensions/opencode-go/onboard.ts @@ -1,18 +1 @@ -// Opencode Go setup module handles plugin onboarding behavior. -import { - applyAgentDefaultModelPrimary, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; - -export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/kimi-k2.6"; - -export function applyOpencodeGoProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - return cfg; -} - -export function applyOpencodeGoConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyAgentDefaultModelPrimary( - applyOpencodeGoProviderConfig(cfg), - OPENCODE_GO_DEFAULT_MODEL_REF, - ); -} +export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/deepseek-v4-pro"; diff --git a/extensions/opencode-go/provider-catalog.ts b/extensions/opencode-go/provider-catalog.ts index cecb2f4604c9..050788c1c7bd 100644 --- a/extensions/opencode-go/provider-catalog.ts +++ b/extensions/opencode-go/provider-catalog.ts @@ -3,6 +3,7 @@ import type { ModelCatalogEntry } from "openclaw/plugin-sdk/agent-runtime"; import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { buildLiveModelProviderConfig, + fetchLiveProviderModelIds, type LiveModelCatalogFetchGuard, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared"; @@ -15,21 +16,6 @@ const PROVIDER_ID = "opencode-go"; const OPENCODE_GO_OPENAI_BASE_URL = "https://opencode.ai/zen/go/v1"; const OPENCODE_GO_ANTHROPIC_BASE_URL = "https://opencode.ai/zen/go"; -const OPENAI_COMPLETIONS_MODEL = { - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, -} as const; -const ANTHROPIC_MESSAGES_MODEL = { - api: "anthropic-messages", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, -} as const; -const OPENAI_RESPONSES_MODEL = { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, -} as const; const OPENCODE_GO_KIMI_NO_REASONING_MODEL_IDS = new Set([ "kimi-k2.5", "kimi-k2.6", @@ -45,433 +31,63 @@ type OpencodeGoModelDefinition = ModelDefinitionConfig & { input: Array<"text" | "image">; }; -const OPENCODE_GO_RESOLVABLE_MODELS = ( +const T = ["text"] as const; +const TI = ["text", "image"] as const; +const E_HM = ["high", "max"] as const; +const E_LHM = ["low", "high", "max"] as const; +const E_LMH = ["low", "medium", "high"] as const; +const E_NONE_LH = ["none", "low", "high"] as const; +const E_NONE_LMHXM = ["none", "low", "medium", "high", "xhigh", "max"] as const; +const E_MAX = ["max"] as const; + +type OpencodeGoCostRow = + | readonly [number, number, number, number] + | readonly [number, number, number, number, number, number, number, number, number]; +type OpencodeGoModelRow = readonly [ + id: string, + contextWindow: number, + maxTokens: number, + input: ReadonlyArray<"text" | "image">, + cost: OpencodeGoCostRow, + reasoningEfforts?: readonly string[], + contextTokens?: number, +]; + +const OPENCODE_GO_MODEL_ROWS = [ + ["deepseek-v4-pro", 1_000_000, 384_000, T, [0.435, 0.87, 0.003625, 0], E_HM], + ["deepseek-v4-flash", 1_000_000, 384_000, T, [0.14, 0.28, 0.0028, 0], E_LHM], + ["glm-5", 202_752, 32_768, T, [1, 3.2, 0.2, 0]], + ["glm-5.1", 202_752, 32_768, T, [1.4, 4.4, 0.26, 0]], + ["glm-5.2", 1_000_000, 131_072, T, [1.4, 4.4, 0.26, 0], E_HM], [ - { - id: "deepseek-v4-pro", - name: "DeepSeek V4 Pro", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text"], - cost: { - input: 0.435, - output: 0.87, - cacheRead: 0.003625, - cacheWrite: 0, - }, - contextWindow: 1_000_000, - maxTokens: 384_000, - compat: { - supportsUsageInStreaming: true, - supportsReasoningEffort: true, - supportedReasoningEfforts: ["high", "max"], - maxTokensField: "max_tokens", - }, - }, - { - id: "deepseek-v4-flash", - name: "DeepSeek V4 Flash", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text"], - cost: { - input: 0.14, - output: 0.28, - cacheRead: 0.0028, - cacheWrite: 0, - }, - contextWindow: 1_000_000, - maxTokens: 384_000, - compat: { - supportsUsageInStreaming: true, - supportsReasoningEffort: true, - supportedReasoningEfforts: ["low", "high", "max"], - maxTokensField: "max_tokens", - }, - }, - { - id: "glm-5", - name: "GLM-5", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text"], - cost: { - input: 1, - output: 3.2, - cacheRead: 0.2, - cacheWrite: 0, - }, - contextWindow: 202_752, - maxTokens: 32_768, - }, - { - id: "glm-5.1", - name: "GLM-5.1", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text"], - cost: { - input: 1.4, - output: 4.4, - cacheRead: 0.26, - cacheWrite: 0, - }, - contextWindow: 202_752, - maxTokens: 32_768, - }, - { - id: "glm-5.2", - name: "GLM-5.2", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text"], - cost: { - input: 1.4, - output: 4.4, - cacheRead: 0.26, - cacheWrite: 0, - }, - contextWindow: 1_000_000, - maxTokens: 131_072, - compat: { - supportsUsageInStreaming: true, - supportsReasoningEffort: true, - supportedReasoningEfforts: ["high", "max"], - maxTokensField: "max_tokens", - }, - }, - { - id: "gpt-5.6-luna", - name: "GPT-5.6 Luna", - ...OPENAI_RESPONSES_MODEL, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.2, - output: 1.2, - cacheRead: 0.02, - cacheWrite: 0.25, - tieredPricing: [ - { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25, range: [0, 272_000] }, - { input: 0.4, output: 1.8, cacheRead: 0.04, cacheWrite: 0.5, range: [272_000] }, - ], - }, - contextWindow: 1_050_000, - contextTokens: 922_000, - maxTokens: 128_000, - compat: { - supportsUsageInStreaming: true, - supportsReasoningEffort: true, - supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], - maxTokensField: "max_tokens", - }, - }, - { - id: "grok-4.5", - name: "Grok 4.5", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text", "image"], - cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, - contextWindow: 500_000, - maxTokens: 500_000, - compat: { - supportsUsageInStreaming: true, - supportsReasoningEffort: true, - supportedReasoningEfforts: ["low", "medium", "high"], - maxTokensField: "max_tokens", - }, - }, - { - id: "hy3", - name: "Hy3", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text"], - cost: { input: 0.14, output: 0.58, cacheRead: 0.035, cacheWrite: 0 }, - contextWindow: 256_000, - maxTokens: 64_000, - compat: { - supportsUsageInStreaming: true, - supportsReasoningEffort: true, - supportedReasoningEfforts: ["none", "low", "high"], - maxTokensField: "max_tokens", - }, - }, - { - id: "hy3-preview", - name: "HY3 Preview", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262_144, - maxTokens: 32_768, - }, - { - id: "kimi-k2.5", - name: "Kimi K2.5", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.6, - output: 3, - cacheRead: 0.1, - cacheWrite: 0, - }, - contextWindow: 262_144, - maxTokens: 65_536, - }, - { - id: "kimi-k2.6", - name: "Kimi K2.6", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.95, - output: 4, - cacheRead: 0.16, - cacheWrite: 0, - }, - contextWindow: 262_144, - maxTokens: 65_536, - }, - { - id: "kimi-k2.7-code", - name: "Kimi K2.7 Code", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.95, - output: 4, - cacheRead: 0.19, - cacheWrite: 0, - }, - contextWindow: 262_144, - maxTokens: 262_144, - }, - { - id: "kimi-k3", - name: "Kimi K3", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text", "image"], - cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - contextWindow: 1_048_576, - maxTokens: 131_072, - compat: { - supportsUsageInStreaming: true, - supportsReasoningEffort: true, - supportedReasoningEfforts: ["max"], - maxTokensField: "max_tokens", - }, - }, - { - id: "mimo-v2-omni", - name: "MiMo V2 Omni", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.4, output: 2, cacheRead: 0.08, cacheWrite: 0 }, - contextWindow: 262_144, - maxTokens: 128_000, - }, - { - id: "mimo-v2-pro", - name: "MiMo V2 Pro", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0.2, - cacheWrite: 0, - tieredPricing: [ - { input: 1, output: 3, cacheRead: 0.2, cacheWrite: 0, range: [0, 256_000] }, - { input: 2, output: 6, cacheRead: 0.4, cacheWrite: 0, range: [256_000] }, - ], - }, - contextWindow: 1_048_576, - maxTokens: 128_000, - }, - { - id: "mimo-v2.5", - name: "MiMo V2.5", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.14, - output: 0.28, - cacheRead: 0.0028, - cacheWrite: 0, - }, - contextWindow: 1_000_000, - maxTokens: 128_000, - }, - { - id: "mimo-v2.5-pro", - name: "MiMo V2.5 Pro", - ...OPENAI_COMPLETIONS_MODEL, - reasoning: true, - input: ["text"], - cost: { - input: 0.435, - output: 0.87, - cacheRead: 0.003625, - cacheWrite: 0, - }, - contextWindow: 1_048_576, - maxTokens: 128_000, - }, - { - id: "minimax-m2.5", - name: "MiniMax M2.5", - ...ANTHROPIC_MESSAGES_MODEL, - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.06, - cacheWrite: 0.375, - }, - contextWindow: 204_800, - maxTokens: 65_536, - }, - { - id: "minimax-m2.7", - name: "MiniMax M2.7", - ...ANTHROPIC_MESSAGES_MODEL, - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.06, - cacheWrite: 0.375, - }, - contextWindow: 204_800, - maxTokens: 131_072, - }, - { - id: "minimax-m3", - name: "MiniMax M3", - ...ANTHROPIC_MESSAGES_MODEL, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.06, - cacheWrite: 0, - tieredPricing: [ - { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0, range: [0, 512_000] }, - { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0, range: [512_000] }, - ], - }, - contextWindow: 1_000_000, - maxTokens: 131_072, - }, - { - id: "qwen3.5-plus", - name: "Qwen3.5 Plus", - ...ANTHROPIC_MESSAGES_MODEL, - compat: { thinkingFormat: "qwen" }, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.2, - output: 1.2, - cacheRead: 0.02, - cacheWrite: 0.25, - }, - contextWindow: 262_144, - maxTokens: 65_536, - }, - { - id: "qwen3.7-max", - name: "Qwen3.7 Max", - ...ANTHROPIC_MESSAGES_MODEL, - compat: { thinkingFormat: "qwen" }, - reasoning: true, - input: ["text"], - cost: { - input: 2.5, - output: 7.5, - cacheRead: 0.5, - cacheWrite: 3.125, - }, - contextWindow: 1_000_000, - maxTokens: 65_536, - }, - { - id: "qwen3.7-plus", - name: "Qwen3.7 Plus", - ...ANTHROPIC_MESSAGES_MODEL, - compat: { thinkingFormat: "qwen" }, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.4, - output: 1.6, - cacheRead: 0.04, - cacheWrite: 0.5, - tieredPricing: [ - { input: 0.4, output: 1.6, cacheRead: 0.04, cacheWrite: 0.5, range: [0, 256_000] }, - { input: 1.2, output: 4.8, cacheRead: 0.12, cacheWrite: 1.5, range: [256_000] }, - ], - }, - contextWindow: 1_000_000, - maxTokens: 65_536, - }, - { - id: "qwen3.8-max", - name: "Qwen3.8 Max", - ...ANTHROPIC_MESSAGES_MODEL, - compat: { thinkingFormat: "qwen" }, - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 6, - cacheRead: 0.25, - cacheWrite: 2.5, - }, - contextWindow: 1_000_000, - maxTokens: 131_072, - }, - { - id: "qwen3.6-plus", - name: "Qwen3.6 Plus", - ...ANTHROPIC_MESSAGES_MODEL, - compat: { thinkingFormat: "qwen" }, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.5, - output: 3, - cacheRead: 0.05, - cacheWrite: 0.625, - tieredPricing: [ - { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625, range: [0, 256_000] }, - { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 2.5, range: [256_000] }, - ], - }, - contextWindow: 1_000_000, - maxTokens: 65_536, - }, - ] satisfies OpencodeGoModelDefinition[] -).map((model) => normalizeModelCompat(model) as OpencodeGoModelDefinition); + "gpt-5.6-luna", + 1_050_000, + 128_000, + TI, + [0.2, 1.2, 0.02, 0.25, 272_000, 0.4, 1.8, 0.04, 0.5], + E_NONE_LMHXM, + 922_000, + ], + ["grok-4.5", 500_000, 500_000, TI, [2, 6, 0.3, 0], E_LMH], + ["hy3", 256_000, 64_000, T, [0.14, 0.58, 0.035, 0], E_NONE_LH], + ["hy3-preview", 262_144, 32_768, T, [0, 0, 0, 0]], + ["kimi-k2.5", 262_144, 65_536, TI, [0.6, 3, 0.1, 0]], + ["kimi-k2.6", 262_144, 65_536, TI, [0.95, 4, 0.16, 0]], + ["kimi-k2.7-code", 262_144, 262_144, TI, [0.95, 4, 0.19, 0]], + ["kimi-k3", 1_048_576, 131_072, TI, [3, 15, 0.3, 0], E_MAX], + ["mimo-v2-omni", 262_144, 128_000, TI, [0.4, 2, 0.08, 0]], + ["mimo-v2-pro", 1_048_576, 128_000, T, [1, 3, 0.2, 0, 256_000, 2, 6, 0.4, 0]], + ["mimo-v2.5", 1_000_000, 128_000, TI, [0.14, 0.28, 0.0028, 0]], + ["mimo-v2.5-pro", 1_048_576, 128_000, T, [0.435, 0.87, 0.003625, 0]], + ["minimax-m2.5", 204_800, 65_536, T, [0.3, 1.2, 0.06, 0.375]], + ["minimax-m2.7", 204_800, 131_072, T, [0.3, 1.2, 0.06, 0.375]], + ["minimax-m3", 1_000_000, 131_072, TI, [0.3, 1.2, 0.06, 0, 512_000, 0.6, 2.4, 0.12, 0]], + ["qwen3.5-plus", 262_144, 65_536, TI, [0.2, 1.2, 0.02, 0.25]], + ["qwen3.7-max", 1_000_000, 65_536, T, [2.5, 7.5, 0.5, 3.125]], + ["qwen3.7-plus", 1_000_000, 65_536, TI, [0.4, 1.6, 0.04, 0.5, 256_000, 1.2, 4.8, 0.12, 1.5]], + ["qwen3.8-max", 1_000_000, 131_072, TI, [2, 6, 0.25, 2.5]], + ["qwen3.6-plus", 1_000_000, 65_536, TI, [0.5, 3, 0.05, 0.625, 256_000, 2, 6, 0.2, 2.5]], +] as const satisfies readonly OpencodeGoModelRow[]; const OPENCODE_GO_MODEL_STATUS = new Map([ ["glm-5", "deprecated"], @@ -483,6 +99,96 @@ const OPENCODE_GO_MODEL_STATUS = new Map([ ["hy3-preview", "preview"], ]); +function titleCaseModelPart(value: string): string { + return value ? `${value[0]?.toUpperCase()}${value.slice(1)}` : value; +} + +function formatOpencodeGoModelName(id: string): string { + if (id === "hy3" || id === "hy3-preview") { + return id === "hy3" ? "Hy3" : "HY3 Preview"; + } + if (id.startsWith("qwen")) { + const [version, ...parts] = id.slice(4).split("-"); + return `Qwen${version}${parts.length ? ` ${parts.map(titleCaseModelPart).join(" ")}` : ""}`; + } + const [family = "", ...parts] = id.split("-"); + const prefix: Record = { + deepseek: "DeepSeek", + glm: "GLM", + gpt: "GPT", + grok: "Grok", + kimi: "Kimi", + mimo: "MiMo", + minimax: "MiniMax", + }; + const separator = family === "glm" || family === "gpt" ? "-" : " "; + return `${prefix[family] ?? titleCaseModelPart(family)}${separator}${parts.map(titleCaseModelPart).join(" ")}`; +} + +function buildOpencodeGoCost(row: OpencodeGoCostRow): ModelDefinitionConfig["cost"] { + const [input, output, cacheRead, cacheWrite] = row; + const cost = { input, output, cacheRead, cacheWrite }; + if (row.length === 4) { + return cost; + } + const threshold = row[4]; + const tierInput = row[5]; + const tierOutput = row[6]; + const tierCacheRead = row[7]; + const tierCacheWrite = row[8]; + return { + ...cost, + tieredPricing: [ + { ...cost, range: [0, threshold] }, + { + input: tierInput, + output: tierOutput, + cacheRead: tierCacheRead, + cacheWrite: tierCacheWrite, + range: [threshold], + }, + ], + }; +} + +function buildOpencodeGoModel(row: OpencodeGoModelRow): OpencodeGoModelDefinition { + const [id, contextWindow, maxTokens, input, cost, reasoningEfforts, contextTokens] = row; + const anthropic = id.startsWith("minimax-") || id.startsWith("qwen"); + const api = id.startsWith("gpt-") + ? "openai-responses" + : anthropic + ? "anthropic-messages" + : "openai-completions"; + const model: OpencodeGoModelDefinition = { + id, + name: formatOpencodeGoModelName(id), + api, + provider: PROVIDER_ID, + baseUrl: anthropic ? OPENCODE_GO_ANTHROPIC_BASE_URL : OPENCODE_GO_OPENAI_BASE_URL, + reasoning: true, + input: [...input], + cost: buildOpencodeGoCost(cost), + contextWindow, + ...(contextTokens ? { contextTokens } : {}), + maxTokens, + ...(reasoningEfforts + ? { + compat: { + supportsUsageInStreaming: true, + supportsReasoningEffort: true, + supportedReasoningEfforts: [...reasoningEfforts], + maxTokensField: "max_tokens", + }, + } + : id.startsWith("qwen") + ? { compat: { thinkingFormat: "qwen" as const } } + : {}), + }; + return normalizeModelCompat(model) as OpencodeGoModelDefinition; +} + +const OPENCODE_GO_RESOLVABLE_MODELS = OPENCODE_GO_MODEL_ROWS.map(buildOpencodeGoModel); + const OPENCODE_GO_MODEL_BY_ID = new Map( OPENCODE_GO_RESOLVABLE_MODELS.map((model) => [model.id, model]), ); @@ -506,6 +212,25 @@ export function buildStaticOpencodeGoProviderConfig(apiKey?: string): ModelProvi }; } +export async function resolveOpencodeGoStarterModel(params: { + apiKey: string; + preferredModelRef: string; + fetchGuard?: LiveModelCatalogFetchGuard; + signal?: AbortSignal; +}): Promise { + const liveModelIds = await fetchLiveProviderModelIds({ + providerId: PROVIDER_ID, + endpoint: OPENCODE_GO_MODELS_ENDPOINT, + discoveryApiKey: params.apiKey, + fetchGuard: params.fetchGuard, + signal: params.signal, + timeoutMs: OPENCODE_GO_MODELS_TIMEOUT_MS, + auditContext: "opencode-go-onboarding-model-discovery", + }); + const preferredModelId = params.preferredModelRef.replace(`${PROVIDER_ID}/`, ""); + return liveModelIds.includes(preferredModelId) ? params.preferredModelRef : undefined; +} + export async function buildOpencodeGoLiveProviderConfig( params: FetchOpencodeGoLiveModelIdsParams = {}, ): Promise { diff --git a/extensions/opencode/api.ts b/extensions/opencode/api.ts deleted file mode 100644 index 03230204d107..000000000000 --- a/extensions/opencode/api.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Opencode API module exposes the plugin public contract. -export { - applyOpencodeZenModelDefault, - OPENCODE_ZEN_DEFAULT_MODEL, -} from "openclaw/plugin-sdk/provider-onboard"; -export { - applyOpencodeZenConfig, - applyOpencodeZenProviderConfig, - OPENCODE_ZEN_DEFAULT_MODEL_REF, -} from "./onboard.js"; diff --git a/extensions/opencode/index.test.ts b/extensions/opencode/index.test.ts index 3d8695e8282e..ec916f71c80c 100644 --- a/extensions/opencode/index.test.ts +++ b/extensions/opencode/index.test.ts @@ -13,7 +13,10 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; import plugin from "./index.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; -import { buildOpencodeZenLiveProviderConfig } from "./provider-catalog.js"; +import { + buildOpencodeZenLiveProviderConfig, + resolveOpencodeZenStarterModel, +} from "./provider-catalog.js"; const requireRecord = createRequireRecord("record", "expected-label-record"); @@ -795,6 +798,27 @@ describe("opencode provider plugin", () => { expect(secondCached.models.map((model) => model.id)).toEqual(["gpt-5.6-luna"]); }); + it.each([ + [["claude-opus-5"], "opencode/claude-opus-5"], + [["gpt-5.6-sol"], undefined], + ])("selects only the advertised preferred onboarding model %#", async (modelIds, expected) => { + const fetchGuard = vi.fn(async () => ({ + response: new Response( + JSON.stringify({ data: modelIds.map((id) => ({ id, object: "model" })) }), + ), + finalUrl: "https://opencode.ai/zen/v1/models", + release: vi.fn(async () => undefined), + })); + + await expect( + resolveOpencodeZenStarterModel({ + apiKey: "resolved-opencode-key", + preferredModelRef: "opencode/claude-opus-5", + fetchGuard, + }), + ).resolves.toBe(expected); + }); + it.each([ ["off", undefined], ["max", "max"], diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 3df16c0deca2..f32d42911a31 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -6,8 +6,8 @@ import { } from "openclaw/plugin-sdk/provider-model-shared"; import { createOpenAICompatibleCompletionsThinkingOffWrapper } from "openclaw/plugin-sdk/provider-stream-shared"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { applyOpencodeZenConfig, OPENCODE_ZEN_DEFAULT_MODEL } from "./api.js"; import { opencodeMediaUnderstandingProvider } from "./media-understanding-provider.js"; +import { applyOpencodeZenProviderConfig, OPENCODE_ZEN_DEFAULT_MODEL_REF } from "./onboard.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildOpencodeZenLiveProviderConfig, @@ -15,32 +15,25 @@ import { listOpencodeZenModelCatalogEntries, normalizeOpencodeZenBaseUrl, resolveOpencodeZenModel, + resolveOpencodeZenStarterModel, } from "./provider-catalog.js"; import { resolveThinkingProfile as resolveOpencodeThinkingProfile } from "./provider-policy-api.js"; import { registerOpenCodeSessionCatalog } from "./session-catalog-plugin.js"; +import { wrapOpencodeProviderStream } from "./stream.js"; const PROVIDER_ID = "opencode"; const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m2.7"] as const; -const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const; -const OPENCODE_SHARED_HINT = "Shared API key infrastructure for Zen + Go"; -type OpencodeZenCatalogAuth = { - apiKey?: string; - discoveryApiKey?: string; -}; - -function hasCatalogAuth(auth: OpencodeZenCatalogAuth): boolean { - return Boolean(auth.apiKey || auth.discoveryApiKey); -} +type OpencodeZenCatalogAuth = { apiKey?: string; discoveryApiKey?: string }; function resolveOpencodeZenCatalogAuth( resolveProviderApiKey: (providerId: string) => OpencodeZenCatalogAuth, ): OpencodeZenCatalogAuth | undefined { - const opencodeAuth = resolveProviderApiKey(PROVIDER_ID); - if (hasCatalogAuth(opencodeAuth)) { - return opencodeAuth; + const own = resolveProviderApiKey(PROVIDER_ID); + if (own.apiKey || own.discoveryApiKey) { + return own; } - const sharedOpencodeGoAuth = resolveProviderApiKey("opencode-go"); - return hasCatalogAuth(sharedOpencodeGoAuth) ? sharedOpencodeGoAuth : undefined; + const shared = resolveProviderApiKey("opencode-go"); + return shared.apiKey || shared.discoveryApiKey ? shared : undefined; } function isModernOpencodeModel(modelId: string): boolean { @@ -61,12 +54,18 @@ export default defineSingleProviderPluginEntry({ docsPath: "/providers/models", envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], manifestAuth: { - hint: OPENCODE_SHARED_HINT, + hint: "Shared API key infrastructure for Zen + Go", promptMessage: "Enter OpenCode API key", - profileIds: [...OPENCODE_SHARED_PROFILE_IDS], - defaultModel: OPENCODE_ZEN_DEFAULT_MODEL, - applyConfig: applyOpencodeZenConfig, + profileIds: ["opencode:default", "opencode-go:default"], + defaultModel: OPENCODE_ZEN_DEFAULT_MODEL_REF, + resolveDefaultModel: async ({ apiKey, signal }) => + await resolveOpencodeZenStarterModel({ + apiKey, + preferredModelRef: OPENCODE_ZEN_DEFAULT_MODEL_REF, + ...(signal ? { signal } : {}), + }), expectedProviders: ["opencode", "opencode-go"], + applyConfig: applyOpencodeZenProviderConfig, noteMessage: [ "One OpenCode API key can authenticate Zen and a separately subscribed Go catalog.", "Zen provides access to Claude, GPT, Gemini, and more models.", @@ -137,10 +136,11 @@ export default defineSingleProviderPluginEntry({ baseStreamFn, ctx.thinkingLevel, ); - return (model, context, options) => + const thinkingStreamFn: typeof baseStreamFn = (model, context, options) => model.provider === PROVIDER_ID && model.id === "kimi-k3" ? thinkingOff(model, context, options) : baseStreamFn(model, context, options); + return wrapOpencodeProviderStream({ ...ctx, streamFn: thinkingStreamFn }); }, }, register(api) { diff --git a/extensions/opencode/onboard.test.ts b/extensions/opencode/onboard.test.ts index 70e557847990..6536bc05dabf 100644 --- a/extensions/opencode/onboard.test.ts +++ b/extensions/opencode/onboard.test.ts @@ -1,12 +1,9 @@ // Opencode tests cover onboard plugin behavior. -import { - expectProviderOnboardAllowlistAlias, - expectProviderOnboardPrimaryAndFallbacks, -} from "openclaw/plugin-sdk/provider-test-contracts"; +import { expectProviderOnboardAllowlistAlias } from "openclaw/plugin-sdk/provider-test-contracts"; import { describe, it } from "vitest"; -import { applyOpencodeZenConfig, applyOpencodeZenProviderConfig } from "./onboard.js"; +import { applyOpencodeZenProviderConfig } from "./onboard.js"; -const MODEL_REF = "opencode/claude-opus-4-6"; +const MODEL_REF = "opencode/claude-opus-5"; describe("opencode onboard", () => { it("adds allowlist entry and preserves alias", () => { @@ -16,11 +13,4 @@ describe("opencode onboard", () => { alias: "My Opus", }); }); - - it("sets primary model and preserves existing model fallbacks", () => { - expectProviderOnboardPrimaryAndFallbacks({ - applyConfig: applyOpencodeZenConfig, - modelRef: MODEL_REF, - }); - }); }); diff --git a/extensions/opencode/onboard.ts b/extensions/opencode/onboard.ts index 28ff844f3093..87e88b622f60 100644 --- a/extensions/opencode/onboard.ts +++ b/extensions/opencode/onboard.ts @@ -1,11 +1,7 @@ // Opencode setup module handles plugin onboarding behavior. -import { - applyAgentDefaultModelPrimary, - withAgentModelAliases, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; +import { withAgentModelAliases, type OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard"; -export const OPENCODE_ZEN_DEFAULT_MODEL_REF = "opencode/claude-opus-4-6"; +export const OPENCODE_ZEN_DEFAULT_MODEL_REF = "opencode/claude-opus-5"; export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawConfig { return { @@ -21,10 +17,3 @@ export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawCon }, }; } - -export function applyOpencodeZenConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyAgentDefaultModelPrimary( - applyOpencodeZenProviderConfig(cfg), - OPENCODE_ZEN_DEFAULT_MODEL_REF, - ); -} diff --git a/extensions/opencode/provider-catalog.ts b/extensions/opencode/provider-catalog.ts index b43cc3c47183..22a5c8632159 100644 --- a/extensions/opencode/provider-catalog.ts +++ b/extensions/opencode/provider-catalog.ts @@ -3,6 +3,7 @@ import type { ModelCatalogEntry } from "openclaw/plugin-sdk/agent-runtime"; import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { buildLiveModelProviderConfig, + fetchLiveProviderModelIds, type LiveModelCatalogFetchGuard, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared"; @@ -466,6 +467,25 @@ export function buildStaticOpencodeZenProviderConfig(apiKey?: string): ModelProv }; } +export async function resolveOpencodeZenStarterModel(params: { + apiKey: string; + preferredModelRef: string; + fetchGuard?: LiveModelCatalogFetchGuard; + signal?: AbortSignal; +}): Promise { + const liveModelIds = await fetchLiveProviderModelIds({ + providerId: PROVIDER_ID, + endpoint: OPENCODE_ZEN_MODELS_ENDPOINT, + discoveryApiKey: params.apiKey, + fetchGuard: params.fetchGuard, + signal: params.signal, + timeoutMs: OPENCODE_ZEN_MODELS_TIMEOUT_MS, + auditContext: "opencode-zen-onboarding-model-discovery", + }); + const preferredModelId = params.preferredModelRef.replace(`${PROVIDER_ID}/`, ""); + return liveModelIds.includes(preferredModelId) ? params.preferredModelRef : undefined; +} + function readLiveModelId(row: unknown): string | undefined { if (!row || typeof row !== "object" || Array.isArray(row)) { return undefined; diff --git a/extensions/opencode/stream.test.ts b/extensions/opencode/stream.test.ts new file mode 100644 index 000000000000..0d0dba94691d --- /dev/null +++ b/extensions/opencode/stream.test.ts @@ -0,0 +1,525 @@ +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { + createAssistantMessageEventStream, + type AssistantMessage, + type AssistantMessageEvent, +} from "openclaw/plugin-sdk/llm"; +import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime"; +import { describe, expect, it } from "vitest"; +import plugin from "./index.js"; + +function toolCallMessage( + name: string, + argumentsValue: Record = { query: "OpenClaw" }, +): AssistantMessage { + return { + role: "assistant", + api: "openai-responses", + provider: "opencode", + model: "gpt-5.6-sol", + content: [{ type: "toolCall", id: "call_1", name, arguments: argumentsValue }], + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: 1, + }; +} + +describe("OpenCode stream adapter", () => { + it("aliases the reserved web_search function across OpenCode Responses requests", async () => { + const provider = await registerSingleProviderPlugin(plugin); + let capturedPayload: Record | undefined; + const existingAlias = "openclaw_web_search"; + const wireAlias = "openclaw_web_search_2"; + let producerPartial: AssistantMessage | undefined; + let producerTerminal: AssistantMessage | undefined; + let releaseTerminal = () => {}; + const allowTerminal = new Promise((resolve) => { + releaseTerminal = resolve; + }); + const baseStreamFn: StreamFn = async (model, _context, options) => { + const initialPayload = { model: model.id }; + const replacement = await options?.onPayload?.(initialPayload, model); + capturedPayload = (replacement ?? initialPayload) as Record; + const stream = createAssistantMessageEventStream(); + const wireArguments = { options: [{ key: "region", value: "us" }] }; + producerPartial = toolCallMessage(wireAlias, wireArguments); + producerTerminal = toolCallMessage(wireAlias, wireArguments); + queueMicrotask(() => { + stream.push({ + type: "toolcall_start", + contentIndex: 0, + partial: producerPartial as AssistantMessage, + }); + void allowTerminal.then(() => { + stream.push({ + type: "toolcall_end", + contentIndex: 0, + toolCall: producerPartial?.content[0] as never, + partial: producerPartial as AssistantMessage, + }); + stream.push({ + type: "done", + reason: "toolUse", + message: producerTerminal as AssistantMessage, + }); + }); + }); + return stream; + }; + const streamFn = provider.wrapStreamFn?.({ + streamFn: baseStreamFn, + providerId: "opencode", + modelId: "gpt-5.6-sol", + } as never); + if (!streamFn) { + throw new Error("expected OpenCode stream wrapper"); + } + + const stream = await streamFn( + { provider: "opencode", id: "gpt-5.6-sol", api: "openai-responses" } as never, + { messages: [] } as never, + { + onPayload: () => ({ + tools: [ + { + type: "function", + name: "web_search", + parameters: { + type: "object", + properties: { + options: { + type: "object", + patternProperties: { "^.*$": { type: "string" } }, + }, + }, + }, + }, + { type: "function", name: existingAlias }, + { type: "function", name: "read" }, + ], + input: [{ type: "function_call", name: "web_search", call_id: "call_0" }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", name: "web_search" }, + { type: "function", name: existingAlias }, + ], + }, + }), + }, + ); + const iterator = stream[Symbol.asyncIterator](); + const first = await iterator.next(); + if (first.done) { + throw new Error("expected staged tool-call event"); + } + const events = [first.value]; + expect(first.value).toMatchObject({ + type: "toolcall_start", + partial: { content: [{ name: "web_search", arguments: { options: { region: "us" } } }] }, + }); + expect([producerPartial, producerTerminal]).toMatchObject([ + { content: [{ name: wireAlias, arguments: { options: [{ key: "region", value: "us" }] } }] }, + { content: [{ name: wireAlias, arguments: { options: [{ key: "region", value: "us" }] } }] }, + ]); + releaseTerminal(); + for (let next = await iterator.next(); !next.done; next = await iterator.next()) { + events.push(next.value); + } + + expect(capturedPayload).toMatchObject({ + tools: [ + { type: "function", name: wireAlias }, + { type: "function", name: existingAlias }, + { type: "function", name: "read" }, + ], + input: [{ type: "function_call", name: wireAlias, call_id: "call_0" }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", name: wireAlias }, + { type: "function", name: existingAlias }, + ], + }, + }); + expect(events[1]).toMatchObject({ + type: "toolcall_end", + toolCall: { name: "web_search", arguments: { options: { region: "us" } } }, + partial: { + content: [{ name: "web_search", arguments: { options: { region: "us" } } }], + }, + }); + expect(events[2]).toMatchObject({ + type: "done", + message: { content: [{ name: "web_search" }] }, + }); + await expect(stream.result()).resolves.toMatchObject({ + content: [{ name: "web_search", arguments: { options: { region: "us" } } }], + }); + expect([producerPartial, producerTerminal]).toMatchObject([ + { content: [{ name: wireAlias, arguments: { options: [{ key: "region", value: "us" }] } }] }, + { content: [{ name: wireAlias, arguments: { options: [{ key: "region", value: "us" }] } }] }, + ]); + }); + + it("does not restore an unaliased OpenCode Responses function name", async () => { + const provider = await registerSingleProviderPlugin(plugin); + const existingAlias = "openclaw_web_search"; + const source = createAssistantMessageEventStream(); + const payload = { tools: [{ type: "function", name: existingAlias }] }; + const baseStreamFn: StreamFn = (model, _context, options) => { + void options?.onPayload?.(payload, model); + queueMicrotask(() => source.end(toolCallMessage(existingAlias))); + return source; + }; + const streamFn = provider.wrapStreamFn?.({ + streamFn: baseStreamFn, + providerId: "opencode", + modelId: "gpt-5.6-sol", + } as never); + + const stream = await streamFn?.( + { provider: "opencode", id: "gpt-5.6-sol", api: "openai-responses" } as never, + { messages: [] } as never, + {}, + ); + + expect(payload.tools[0]?.name).toBe(existingAlias); + await expect(stream?.result()).resolves.toMatchObject({ content: [{ name: existingAlias }] }); + }); + + it("round-trips dynamic record tool arguments through OpenCode-compatible schemas", async () => { + const provider = await registerSingleProviderPlugin(plugin); + let capturedPayload: Record | undefined; + let producerDelta: Extract | undefined; + const baseStreamFn: StreamFn = async (model, _context, options) => { + const initialPayload = { model: model.id }; + const replacement = await options?.onPayload?.(initialPayload, model); + capturedPayload = (replacement ?? initialPayload) as Record; + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + const execMessage = toolCallMessage("exec", { + command: "node app.js", + env: [{ key: "NODE_ENV", value: "test" }], + }); + producerDelta = { + type: "toolcall_delta", + contentIndex: 0, + delta: JSON.stringify({ + command: "node app.js", + env: [{ key: "NODE_ENV", value: "test" }], + }), + partial: execMessage, + }; + stream.push(producerDelta); + stream.push({ + type: "toolcall_end", + contentIndex: 0, + toolCall: execMessage.content[0] as never, + partial: execMessage, + }); + const duplicateMessage = toolCallMessage("dashboard", { + props: [ + { key: "title", value: '"first"' }, + { key: "title", value: '"second"' }, + ], + }); + stream.push({ + type: "toolcall_end", + contentIndex: 0, + toolCall: duplicateMessage.content[0] as never, + partial: duplicateMessage, + }); + const malformedMessage = toolCallMessage("video_generate", { + providerOptions: [{ key: "broken", value: "not-json" }], + }); + stream.push({ + type: "toolcall_end", + contentIndex: 0, + toolCall: malformedMessage.content[0] as never, + partial: malformedMessage, + }); + stream.push({ + type: "done", + reason: "toolUse", + message: toolCallMessage("video_generate", { + providerOptions: [ + { key: "label", value: '"42"' }, + { key: "seed", value: "42" }, + { key: "enabled", value: "true" }, + { key: "empty", value: "null" }, + ], + }), + }); + }); + return stream; + }; + const streamFn = provider.wrapStreamFn?.({ + streamFn: baseStreamFn, + providerId: "opencode", + modelId: "gpt-5.6-sol", + } as never); + if (!streamFn) { + throw new Error("expected OpenCode stream wrapper"); + } + + const stream = await streamFn( + { provider: "opencode", id: "gpt-5.6-sol", api: "openai-responses" } as never, + { messages: [] } as never, + { + onPayload: () => ({ + tools: [ + { + type: "function", + name: "exec", + parameters: { + type: "object", + properties: { + env: { + type: "object", + patternProperties: { "^.*$": { type: "string" } }, + }, + }, + }, + }, + { + type: "function", + name: "video_generate", + parameters: { + type: "object", + properties: { + providerOptions: { + type: "object", + patternProperties: { "^.*$": {} }, + }, + }, + }, + }, + { + type: "function", + name: "dashboard", + parameters: { + type: "object", + properties: { + props: { + type: "object", + patternProperties: { "^.*$": {} }, + }, + }, + }, + }, + ], + input: [ + { + type: "function_call", + name: "exec", + arguments: JSON.stringify({ command: "node app.js", env: { NODE_ENV: "test" } }), + }, + { + type: "function_call", + name: "video_generate", + arguments: { + providerOptions: { label: "42", seed: 42, enabled: true, empty: null }, + }, + }, + ], + }), + }, + ); + const events = []; + for await (const event of stream) { + events.push(event); + } + + const tools = capturedPayload?.tools as Array>; + const execParameters = tools[0]?.parameters as Record; + const execProperties = execParameters.properties as Record; + expect(execProperties.env).toMatchObject({ + type: "array", + items: { + properties: { key: { type: "string" }, value: { type: "string" } }, + required: ["key", "value"], + additionalProperties: false, + }, + }); + expect(JSON.stringify(execProperties.env)).not.toContain("patternProperties"); + const input = capturedPayload?.input as Array>; + expect(JSON.parse(input[0]?.arguments as string)).toEqual({ + command: "node app.js", + env: [{ key: "NODE_ENV", value: "test" }], + }); + expect(input[1]?.arguments).toEqual({ + providerOptions: [ + { key: "label", value: '"42"' }, + { key: "seed", value: "42" }, + { key: "enabled", value: "true" }, + { key: "empty", value: "null" }, + ], + }); + expect(events[0]).toMatchObject({ + type: "toolcall_delta", + delta: "", + partial: { + content: [{ arguments: { command: "node app.js", env: { NODE_ENV: "test" } } }], + }, + }); + expect(producerDelta).toMatchObject({ + delta: '{"command":"node app.js","env":[{"key":"NODE_ENV","value":"test"}]}', + partial: { + content: [ + { + arguments: { + command: "node app.js", + env: [{ key: "NODE_ENV", value: "test" }], + }, + }, + ], + }, + }); + expect(events[1]).toMatchObject({ + type: "toolcall_end", + toolCall: { arguments: { command: "node app.js", env: { NODE_ENV: "test" } } }, + }); + expect(events[2]).toMatchObject({ + type: "toolcall_end", + toolCall: { + arguments: { + props: [ + { key: "title", value: '"first"' }, + { key: "title", value: '"second"' }, + ], + }, + }, + }); + expect(events[3]).toMatchObject({ + type: "toolcall_end", + toolCall: { + arguments: { providerOptions: [{ key: "broken", value: "not-json" }] }, + }, + }); + expect(events[4]).toMatchObject({ + type: "done", + message: { + content: [ + { + arguments: { + providerOptions: { label: "42", seed: 42, enabled: true, empty: null }, + }, + }, + ], + }, + }); + await expect(stream.result()).resolves.toMatchObject({ + content: [ + { + arguments: { + providerOptions: { label: "42", seed: 42, enabled: true, empty: null }, + }, + }, + ], + }); + }); + + it("rebuilds dynamic record metadata when a Responses request payload is rebuilt", async () => { + const provider = await registerSingleProviderPlugin(plugin); + const firstPayload = { + tools: [ + { + type: "function", + name: "exec", + parameters: { + type: "object", + properties: { + env: { + type: "object", + patternProperties: { "^.*$": { type: "string" } }, + }, + }, + }, + }, + ], + }; + const secondPayload = { + tools: [ + { + type: "function", + name: "exec", + parameters: { + type: "object", + properties: { env: { type: "array", items: { type: "string" } } }, + }, + }, + ], + }; + const baseStreamFn: StreamFn = async (model, _context, options) => { + await options?.onPayload?.(firstPayload, model); + await options?.onPayload?.(secondPayload, model); + const source = createAssistantMessageEventStream(); + queueMicrotask(() => + source.end( + toolCallMessage("exec", { + env: [{ key: "literal", value: "array" }], + }), + ), + ); + return source; + }; + const streamFn = provider.wrapStreamFn?.({ + streamFn: baseStreamFn, + providerId: "opencode", + modelId: "gpt-5.6-sol", + } as never); + + const stream = await streamFn?.( + { provider: "opencode", id: "gpt-5.6-sol", api: "openai-responses" } as never, + { messages: [] } as never, + {}, + ); + + expect(firstPayload.tools[0]?.parameters.properties.env.type).toBe("array"); + expect(secondPayload.tools[0]?.parameters.properties.env).toEqual({ + type: "array", + items: { type: "string" }, + }); + await expect(stream?.result()).resolves.toMatchObject({ + content: [ + { + arguments: { env: [{ key: "literal", value: "array" }] }, + }, + ], + }); + }); + + it("leaves web_search unchanged for non-Responses OpenCode models", async () => { + const provider = await registerSingleProviderPlugin(plugin); + const source = createAssistantMessageEventStream(); + const payload = { tools: [{ type: "function", name: "web_search" }] }; + const baseStreamFn: StreamFn = (model, _context, options) => { + void options?.onPayload?.(payload, model); + queueMicrotask(() => source.end(toolCallMessage("web_search"))); + return source; + }; + const streamFn = provider.wrapStreamFn?.({ + streamFn: baseStreamFn, + providerId: "opencode", + modelId: "kimi-k2.6", + } as never); + + const stream = await streamFn?.( + { provider: "opencode", id: "kimi-k2.6", api: "openai-completions" } as never, + { messages: [] } as never, + {}, + ); + expect(stream).toBe(source); + expect(payload.tools[0]?.name).toBe("web_search"); + }); +}); diff --git a/extensions/opencode/stream.ts b/extensions/opencode/stream.ts new file mode 100644 index 000000000000..5f1efc599d5c --- /dev/null +++ b/extensions/opencode/stream.ts @@ -0,0 +1,265 @@ +// OpenCode Zen stream adapter handles provider-specific Responses wire compatibility. +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { + streamSimple, + type AssistantMessage, + type AssistantMessageEvent, +} from "openclaw/plugin-sdk/llm"; +import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; + +const WEB_SEARCH = "web_search"; +const WEB_SEARCH_ALIAS = "openclaw_web_search"; + +type ProviderStream = Awaited>; +type DynamicFields = Map>; +type TransformState = { fields: DynamicFields; alias?: string }; + +function payloadFunctions(payload: Record): Record[] { + const choice = isRecord(payload.tool_choice) ? payload.tool_choice : undefined; + const candidates = [ + ...(Array.isArray(payload.tools) ? payload.tools : []), + ...(Array.isArray(payload.input) ? payload.input : []), + ...(Array.isArray(choice?.tools) ? choice.tools : []), + choice, + ]; + return candidates.filter( + (item): item is Record => + isRecord(item) && + (item.type === "function" || item.type === "function_call") && + typeof item.name === "string", + ); +} + +function rewriteDynamicRecordSchemas(payload: Record): DynamicFields { + const fieldsByTool: DynamicFields = new Map(); + for (const tool of payloadFunctions(payload)) { + if (tool.type !== "function" || !isRecord(tool.parameters)) { + continue; + } + const properties = tool.parameters.properties; + if (!isRecord(properties)) { + continue; + } + const fields: Array = []; + for (const [name, schema] of Object.entries(properties)) { + if (!isRecord(schema)) { + continue; + } + const patterns = schema.patternProperties; + if ( + (isRecord(schema.properties) && Object.keys(schema.properties).length > 0) || + !isRecord(patterns) || + Object.keys(patterns).length !== 1 || + !Object.hasOwn(patterns, "^.*$") + ) { + continue; + } + const valueSchema = patterns["^.*$"]; + const jsonValues = !isRecord(valueSchema) || valueSchema.type !== "string"; + const description = typeof schema.description === "string" ? `${schema.description} ` : ""; + properties[name] = { + ...schema, + type: "array", + description: `${description}Provide as key/value entries.${jsonValues ? " JSON-encode every value, including strings." : ""}`, + items: { + type: "object", + properties: { + key: { type: "string" }, + value: jsonValues + ? { + type: "string", + description: "JSON-encoded value, including JSON encoding for string values.", + } + : valueSchema, + }, + required: ["key", "value"], + additionalProperties: false, + }, + properties: undefined, + patternProperties: undefined, + additionalProperties: undefined, + required: undefined, + }; + fields.push([name, jsonValues]); + } + fieldsByTool.set(tool.name as string, fields); + } + return fieldsByTool; +} + +function transformArguments( + toolName: string, + args: Record, + fields: DynamicFields, + toWire: boolean, +): void { + for (const [name, jsonValues] of fields.get(toolName) ?? []) { + const value = args[name]; + if (toWire) { + if (isRecord(value)) { + args[name] = Object.entries(value).map(([key, item]) => ({ + key, + value: jsonValues || typeof item !== "string" ? (JSON.stringify(item) ?? "null") : item, + })); + } + continue; + } + if (!Array.isArray(value)) { + continue; + } + const entries: Array<[string, unknown]> = []; + const keys = new Set(); + let valid = true; + for (const entry of value) { + if ( + !isRecord(entry) || + typeof entry.key !== "string" || + typeof entry.value !== "string" || + keys.has(entry.key) + ) { + valid = false; + break; + } + keys.add(entry.key); + let item: unknown = entry.value; + if (jsonValues) { + try { + item = JSON.parse(entry.value) as unknown; + } catch { + valid = false; + break; + } + } + entries.push([entry.key, item]); + } + if (valid) { + args[name] = Object.fromEntries(entries); + } + } +} + +function transformCall( + call: Record, + state: TransformState, + toWire: boolean, +): void { + if (typeof call.name !== "string") { + return; + } + let toolName = call.name; + if (!toWire && state.alias && toolName === state.alias) { + call.name = toolName = WEB_SEARCH; + } + const serialized = typeof call.arguments === "string"; + try { + const args = serialized + ? (JSON.parse(call.arguments as string) as unknown) + : !toWire && isRecord(call.arguments) + ? { ...call.arguments } + : call.arguments; + if (isRecord(args)) { + transformArguments(toolName, args, state.fields, toWire); + call.arguments = serialized ? JSON.stringify(args) : args; + } + } catch { + // Leave partial or malformed arguments unchanged for normal validation. + } +} + +function aliasWebSearch(payload: Record): string | undefined { + const functions = payloadFunctions(payload); + const names = new Set(functions.map((item) => item.name as string)); + if (!names.has(WEB_SEARCH)) { + return undefined; + } + let alias = WEB_SEARCH_ALIAS; + for (let suffix = 2; names.has(alias); suffix += 1) { + alias = `${WEB_SEARCH_ALIAS}_${suffix}`; + } + for (const item of functions) { + if (item.name === WEB_SEARCH) { + item.name = alias; + } + } + return alias; +} + +function restoreMessage(message: AssistantMessage, state: TransformState): AssistantMessage { + const restored = { ...message, content: message.content.map((block) => ({ ...block })) }; + for (const block of restored.content) { + if (block.type === "toolCall") { + transformCall(block as unknown as Record, state, false); + } + } + return restored; +} + +function restoreEvent(event: AssistantMessageEvent, state: TransformState): AssistantMessageEvent { + const restored = { ...event }; + if ("partial" in restored && restored.partial) { + restored.partial = restoreMessage(restored.partial, state); + } + if (restored.type === "toolcall_delta") { + const call = restored.partial.content[restored.contentIndex]; + if (call?.type === "toolCall" && (state.fields.get(call.name)?.length ?? 0) > 0) { + // Dynamic-record wire JSON is not prefix-compatible with restored object JSON. + // Defer argument bytes so consumers emit one canonical payload at toolcall_end. + restored.delta = ""; + } + } else if (restored.type === "toolcall_end") { + restored.toolCall = { ...restored.toolCall }; + transformCall(restored.toolCall as unknown as Record, state, false); + } else if (restored.type === "done") { + restored.message = restoreMessage(restored.message, state); + } else if (restored.type === "error") { + restored.error = restoreMessage(restored.error, state); + } + return restored; +} + +function wrapResponseStream(stream: ProviderStream, state: TransformState): ProviderStream { + return { + async *[Symbol.asyncIterator]() { + for await (const event of stream) { + yield restoreEvent(event, state); + } + }, + async result() { + return restoreMessage(await stream.result(), state); + }, + }; +} + +export function wrapOpencodeProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn { + const underlying = ctx.streamFn ?? streamSimple; + return (model, context, options) => { + if (model.api !== "openai-responses") { + return underlying(model, context, options); + } + const originalOnPayload = options?.onPayload; + const state: TransformState = { fields: new Map() }; + const maybeStream = underlying(model, context, { + ...options, + async onPayload(payload, payloadModel) { + const finalPayload = (await originalOnPayload?.(payload, payloadModel)) ?? payload; + state.fields = new Map(); + state.alias = undefined; + if (isRecord(finalPayload)) { + state.fields = rewriteDynamicRecordSchemas(finalPayload); + for (const call of payloadFunctions(finalPayload)) { + if (call.type === "function_call") { + transformCall(call, state, true); + } + } + state.alias = aliasWebSearch(finalPayload); + } + return finalPayload; + }, + }); + const wrap = (stream: ProviderStream) => wrapResponseStream(stream, state); + return maybeStream && typeof maybeStream === "object" && "then" in maybeStream + ? Promise.resolve(maybeStream).then(wrap) + : wrap(maybeStream); + }; +} diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 68445f107ad0..6c094d6aec61 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -545,7 +545,7 @@ async function createDefaultProviderPlugins(): Promise { envVar: "OPENCODE_API_KEY", promptMessage: "Enter OpenCode API key", profileIds: ["opencode:default", "opencode-go:default"], - defaultModel: "opencode/claude-opus-4-6", + defaultModel: "opencode/claude-opus-5", expectedProviders: ["opencode", "opencode-go"], noteMessage: "OpenCode uses one API key across the Zen and Go catalogs.", noteTitle: "OpenCode", @@ -559,7 +559,7 @@ async function createDefaultProviderPlugins(): Promise { envVar: "OPENCODE_API_KEY", promptMessage: "Enter OpenCode API key", profileIds: ["opencode-go:default", "opencode:default"], - defaultModel: "opencode-go/kimi-k2.6", + defaultModel: "opencode-go/deepseek-v4-pro", expectedProviders: ["opencode", "opencode-go"], noteMessage: "OpenCode uses one API key across the Zen and Go catalogs.", noteTitle: "OpenCode", @@ -1183,7 +1183,7 @@ describe("applyAuthChoice", () => { token: "sk-opencode-zen-test", promptMessage: "Enter OpenCode API key", existingPrimary: "anthropic/claude-opus-4-5", - expectedOverride: "opencode/claude-opus-4-6", + expectedOverride: "opencode/claude-opus-5", profileId: "opencode:default", profileProvider: "opencode", extraProfileId: "opencode-go:default", diff --git a/src/gateway/worker-environments/inference-runtime.test.ts b/src/gateway/worker-environments/inference-runtime.test.ts index ebf29a6a9f3d..1583963a3d99 100644 --- a/src/gateway/worker-environments/inference-runtime.test.ts +++ b/src/gateway/worker-environments/inference-runtime.test.ts @@ -774,6 +774,30 @@ describe("worker inference provider runtime", () => { expect(emitted).toBe(64 * 1024); }); + it("synthesizes canonical arguments after deferred provider deltas", () => { + const complete = { ...TOOL_CALL, arguments: { env: { NODE_ENV: "test" } } }; + const message = finalMessage(); + message.content = [...message.content.slice(0, -1), complete]; + const emitted: Parameters[0][] = []; + const toolCalls = createWorkerToolCallStream({ + emit: (event) => emitted.push(event), + isCurrent: () => true, + }); + + expect(toolCalls.start(1, message)).toBe("ok"); + expect(toolCalls.delta(1, "", message)).toBe("ok"); + expect(toolCalls.end(1, message, complete)).toBe("ok"); + expect(emitted).toEqual([ + { type: "toolcall_start", contentIndex: 1, id: "call-1", toolName: "lookup" }, + { + type: "toolcall_delta", + contentIndex: 1, + delta: '{"env":{"NODE_ENV":"test"}}', + }, + { type: "toolcall_end", contentIndex: 1 }, + ]); + }); + it("fences terminal tool-call synthesis after owner rotation", async () => { const runtime = setup(); runtime.stream.mockImplementation(() => providerStream(finalMessage(), { omitToolEnd: true })); diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index 0396d2595304..a747aaa0007c 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -40,7 +40,7 @@ const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([ ]); /** Current OpenCode Zen default model ref used by onboarding and repair flows. */ -export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-4-6"; +export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-5"; /** Pair of preset appliers exposed by provider setup modules. */ export type ProviderOnboardPresetAppliers = { diff --git a/src/plugins/provider-api-key-auth.test.ts b/src/plugins/provider-api-key-auth.test.ts index e4aa3a4d23ff..8b2d425fd7c5 100644 --- a/src/plugins/provider-api-key-auth.test.ts +++ b/src/plugins/provider-api-key-auth.test.ts @@ -32,4 +32,98 @@ describe("createProviderApiKeyAuthMethod", () => { envVar: "EXAMPLE_API_KEY", }); }); + + it("applies a key-scoped default model during non-interactive auth", async () => { + const resolveDefaultModel = vi.fn(async () => "example/enabled-model"); + const method = createProviderApiKeyAuthMethod({ + providerId: "example", + methodId: "api-key", + label: "Example", + optionKey: "exampleApiKey", + flagName: "--example-api-key", + envVar: "EXAMPLE_API_KEY", + promptMessage: "Example API key", + defaultModel: "example/static-model", + resolveDefaultModel, + }); + + const config = await method.runNonInteractive?.({ + authChoice: "example-api-key", + config: {}, + baseConfig: {}, + opts: { exampleApiKey: "test-token" }, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + resolveApiKey: vi.fn(async () => ({ key: "test-token", source: "profile" as const })), + toApiKeyCredential: vi.fn(() => null), + }); + + expect(resolveDefaultModel).toHaveBeenCalledWith({ apiKey: "test-token", config: {} }); + expect(config?.agents?.defaults?.model).toEqual({ primary: "example/enabled-model" }); + }); + + it.each([ + { + name: "falls back to the static model when discovery fails", + resolveDefaultModel: async () => { + throw new Error("catalog unavailable"); + }, + expected: { primary: "example/static-model" }, + }, + { + name: "leaves the model unset when discovery finds no safe default", + resolveDefaultModel: async () => undefined, + expected: undefined, + }, + ])("$name", async ({ resolveDefaultModel, expected }) => { + const method = createProviderApiKeyAuthMethod({ + providerId: "example", + methodId: "api-key", + label: "Example", + optionKey: "exampleApiKey", + flagName: "--example-api-key", + envVar: "EXAMPLE_API_KEY", + promptMessage: "Example API key", + defaultModel: "example/static-model", + resolveDefaultModel, + }); + + const config = await method.runNonInteractive?.({ + authChoice: "example-api-key", + config: {}, + baseConfig: {}, + opts: { exampleApiKey: "test-token" }, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + resolveApiKey: vi.fn(async () => ({ key: "test-token", source: "profile" as const })), + toApiKeyCredential: vi.fn(() => null), + }); + + expect(config?.agents?.defaults?.model).toEqual(expected); + }); + + it("returns a key-scoped default model during interactive auth", async () => { + const resolveDefaultModel = vi.fn(async () => "example/enabled-model"); + const method = createProviderApiKeyAuthMethod({ + providerId: "example", + methodId: "api-key", + label: "Example", + optionKey: "exampleApiKey", + flagName: "--example-api-key", + envVar: "EXAMPLE_API_KEY", + promptMessage: "Example API key", + defaultModel: "example/static-model", + resolveDefaultModel, + }); + + const result = await method.run({ + config: {}, + env: {}, + opts: { exampleApiKey: "test-token" }, + prompter: { note: vi.fn() }, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + secretInputMode: "plaintext", + } as never); + + expect(resolveDefaultModel).toHaveBeenCalledWith({ apiKey: "test-token", config: {} }); + expect(result.defaultModel).toBe("example/enabled-model"); + }); }); diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts index 6c9701c4d6bd..dcf9742b7aec 100644 --- a/src/plugins/provider-api-key-auth.ts +++ b/src/plugins/provider-api-key-auth.ts @@ -37,6 +37,11 @@ type ProviderApiKeyAuthMethodOptions = { noteMessage?: string; noteTitle?: string; applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; + resolveDefaultModel?: (params: { + apiKey: string; + config: OpenClawConfig; + signal?: AbortSignal; + }) => Promise; }; const loadProviderApiKeyAuthRuntime = createLazyRuntimeSurface( @@ -64,6 +69,23 @@ function resolveProfileIds(params: { return [resolveProfileId(params)]; } +async function resolveDefaultModel( + params: ProviderApiKeyAuthMethodOptions, + context: { apiKey: string; config: OpenClawConfig; signal?: AbortSignal }, +): Promise { + if (!params.resolveDefaultModel) { + return params.defaultModel; + } + try { + return await params.resolveDefaultModel(context); + } catch { + // Key-scoped discovery improves the first-run default, but an advisory + // catalog outage must not discard credentials or block onboarding. + context.signal?.throwIfAborted(); + return params.defaultModel; + } +} + async function applyApiKeyConfig(params: { ctx: ProviderAuthMethodNonInteractiveContext; providerId: string; @@ -132,7 +154,7 @@ export function createProviderApiKeyAuthMethod( validateApiKeyInput, } = await loadProviderApiKeyAuthRuntime(); - await ensureApiKeyFromOptionEnvOrPrompt({ + const apiKey = await ensureApiKeyFromOptionEnvOrPrompt({ token: flagValue ?? normalizeOptionalSecretInput(ctx.opts?.token), tokenProvider: flagValue ? params.providerId @@ -152,8 +174,8 @@ export function createProviderApiKeyAuthMethod( prompter: ctx.prompter, noteMessage: params.noteMessage, noteTitle: params.noteTitle, - setCredential: async (apiKey, mode) => { - capturedSecretInput = apiKey; + setCredential: async (credential, mode) => { + capturedSecretInput = credential; capturedCredential = true; capturedMode = mode; }, @@ -164,6 +186,11 @@ export function createProviderApiKeyAuthMethod( } const credentialInput = capturedSecretInput ?? ""; const profileIds = resolveProfileIds(params); + const defaultModel = await resolveDefaultModel(params, { + apiKey, + config: ctx.config, + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); return { profiles: profileIds.map((profileId) => ({ @@ -181,7 +208,7 @@ export function createProviderApiKeyAuthMethod( ), })), ...(params.applyConfig ? { configPatch: params.applyConfig(ctx.config) } : {}), - ...(params.defaultModel ? { defaultModel: params.defaultModel } : {}), + ...(defaultModel ? { defaultModel } : {}), }; }, validateNonInteractive: async (ctx) => Boolean(await resolveNonInteractiveCredential(ctx)), @@ -214,7 +241,10 @@ export function createProviderApiKeyAuthMethod( ctx, providerId: params.providerId, profileIds, - defaultModel: params.defaultModel, + defaultModel: await resolveDefaultModel(params, { + apiKey: resolved.key, + config: ctx.config, + }), preserveExistingPrimary: params.preserveExistingPrimary, applyConfig: params.applyConfig, });