diff --git a/src/agents/model-forward-compat.ts b/src/agents/model-forward-compat.ts new file mode 100644 index 00000000000..393f5e42857 --- /dev/null +++ b/src/agents/model-forward-compat.ts @@ -0,0 +1,226 @@ +import type { Api, Model } from "@mariozechner/pi-ai"; +import type { ModelRegistry } from "./pi-model-discovery.js"; +import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js"; +import { normalizeModelCompat } from "./model-compat.js"; +import { normalizeProviderId } from "./model-selection.js"; + +const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex"; +const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; + +const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6"; +const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6"; +const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const; + +const ZAI_GLM5_MODEL_ID = "glm-5"; +const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const; + +const ANTIGRAVITY_OPUS_46_MODEL_ID = "claude-opus-4-6"; +const ANTIGRAVITY_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6"; +const ANTIGRAVITY_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const; +const ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID = "claude-opus-4-6-thinking"; +const ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID = "claude-opus-4.6-thinking"; +const ANTIGRAVITY_OPUS_THINKING_TEMPLATE_MODEL_IDS = [ + "claude-opus-4-5-thinking", + "claude-opus-4.5-thinking", +] as const; + +function resolveOpenAICodexGpt53FallbackModel( + provider: string, + modelId: string, + modelRegistry: ModelRegistry, +): Model | undefined { + const normalizedProvider = normalizeProviderId(provider); + const trimmedModelId = modelId.trim(); + if (normalizedProvider !== "openai-codex") { + return undefined; + } + if (trimmedModelId.toLowerCase() !== OPENAI_CODEX_GPT_53_MODEL_ID) { + return undefined; + } + + for (const templateId of OPENAI_CODEX_TEMPLATE_MODEL_IDS) { + const template = modelRegistry.find(normalizedProvider, templateId) as Model | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + } as Model); + } + + return normalizeModelCompat({ + id: trimmedModelId, + name: trimmedModelId, + api: "openai-codex-responses", + provider: normalizedProvider, + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_TOKENS, + maxTokens: DEFAULT_CONTEXT_TOKENS, + } as Model); +} + +function resolveAnthropicOpus46ForwardCompatModel( + provider: string, + modelId: string, + modelRegistry: ModelRegistry, +): Model | undefined { + const normalizedProvider = normalizeProviderId(provider); + if (normalizedProvider !== "anthropic") { + return undefined; + } + + const trimmedModelId = modelId.trim(); + const lower = trimmedModelId.toLowerCase(); + const isOpus46 = + lower === ANTHROPIC_OPUS_46_MODEL_ID || + lower === ANTHROPIC_OPUS_46_DOT_MODEL_ID || + lower.startsWith(`${ANTHROPIC_OPUS_46_MODEL_ID}-`) || + lower.startsWith(`${ANTHROPIC_OPUS_46_DOT_MODEL_ID}-`); + if (!isOpus46) { + return undefined; + } + + const templateIds: string[] = []; + if (lower.startsWith(ANTHROPIC_OPUS_46_MODEL_ID)) { + templateIds.push(lower.replace(ANTHROPIC_OPUS_46_MODEL_ID, "claude-opus-4-5")); + } + if (lower.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID)) { + templateIds.push(lower.replace(ANTHROPIC_OPUS_46_DOT_MODEL_ID, "claude-opus-4.5")); + } + templateIds.push(...ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS); + + for (const templateId of [...new Set(templateIds)].filter(Boolean)) { + const template = modelRegistry.find(normalizedProvider, templateId) as Model | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + } as Model); + } + + return undefined; +} + +// Z.ai's GLM-5 may not be present in pi-ai's built-in model catalog yet. +// When a user configures zai/glm-5 without a models.json entry, clone glm-4.7 as a forward-compat fallback. +function resolveZaiGlm5ForwardCompatModel( + provider: string, + modelId: string, + modelRegistry: ModelRegistry, +): Model | undefined { + if (normalizeProviderId(provider) !== "zai") { + return undefined; + } + const trimmed = modelId.trim(); + const lower = trimmed.toLowerCase(); + if (lower !== ZAI_GLM5_MODEL_ID && !lower.startsWith(`${ZAI_GLM5_MODEL_ID}-`)) { + return undefined; + } + + for (const templateId of ZAI_GLM5_TEMPLATE_MODEL_IDS) { + const template = modelRegistry.find("zai", templateId) as Model | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmed, + name: trimmed, + reasoning: true, + } as Model); + } + + return normalizeModelCompat({ + id: trimmed, + name: trimmed, + api: "openai-completions", + provider: "zai", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_TOKENS, + maxTokens: DEFAULT_CONTEXT_TOKENS, + } as Model); +} + +function resolveAntigravityOpus46ForwardCompatModel( + provider: string, + modelId: string, + modelRegistry: ModelRegistry, +): Model | undefined { + const normalizedProvider = normalizeProviderId(provider); + if (normalizedProvider !== "google-antigravity") { + return undefined; + } + + const trimmedModelId = modelId.trim(); + const lower = trimmedModelId.toLowerCase(); + const isOpus46 = + lower === ANTIGRAVITY_OPUS_46_MODEL_ID || + lower === ANTIGRAVITY_OPUS_46_DOT_MODEL_ID || + lower.startsWith(`${ANTIGRAVITY_OPUS_46_MODEL_ID}-`) || + lower.startsWith(`${ANTIGRAVITY_OPUS_46_DOT_MODEL_ID}-`); + const isOpus46Thinking = + lower === ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID || + lower === ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID || + lower.startsWith(`${ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID}-`) || + lower.startsWith(`${ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID}-`); + if (!isOpus46 && !isOpus46Thinking) { + return undefined; + } + + const templateIds: string[] = []; + if (lower.startsWith(ANTIGRAVITY_OPUS_46_MODEL_ID)) { + templateIds.push(lower.replace(ANTIGRAVITY_OPUS_46_MODEL_ID, "claude-opus-4-5")); + } + if (lower.startsWith(ANTIGRAVITY_OPUS_46_DOT_MODEL_ID)) { + templateIds.push(lower.replace(ANTIGRAVITY_OPUS_46_DOT_MODEL_ID, "claude-opus-4.5")); + } + if (lower.startsWith(ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID)) { + templateIds.push( + lower.replace(ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID, "claude-opus-4-5-thinking"), + ); + } + if (lower.startsWith(ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID)) { + templateIds.push( + lower.replace(ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID, "claude-opus-4.5-thinking"), + ); + } + templateIds.push(...ANTIGRAVITY_OPUS_TEMPLATE_MODEL_IDS); + templateIds.push(...ANTIGRAVITY_OPUS_THINKING_TEMPLATE_MODEL_IDS); + + for (const templateId of [...new Set(templateIds)].filter(Boolean)) { + const template = modelRegistry.find(normalizedProvider, templateId) as Model | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + } as Model); + } + + return undefined; +} + +export function resolveForwardCompatModel( + provider: string, + modelId: string, + modelRegistry: ModelRegistry, +): Model | undefined { + return ( + resolveOpenAICodexGpt53FallbackModel(provider, modelId, modelRegistry) ?? + resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ?? + resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ?? + resolveAntigravityOpus46ForwardCompatModel(provider, modelId, modelRegistry) + ); +} diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 69c93ca8cfd..794b4c3d985 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -172,43 +172,6 @@ describe("resolveModel", () => { }); }); - it("builds an openai-codex fallback for gpt-5.3-codex-spark", () => { - const templateModel = { - id: "gpt-5.2-codex", - name: "GPT-5.2 Codex", - provider: "openai-codex", - api: "openai-codex-responses", - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - input: ["text", "image"] as const, - cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: 272000, - maxTokens: 128000, - }; - - vi.mocked(discoverModels).mockReturnValue({ - find: vi.fn((provider: string, modelId: string) => { - if (provider === "openai-codex" && modelId === "gpt-5.2-codex") { - return templateModel; - } - return null; - }), - } as unknown as ReturnType); - - const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent"); - - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ - provider: "openai-codex", - id: "gpt-5.3-codex-spark", - api: "openai-codex-responses", - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - contextWindow: 272000, - maxTokens: 128000, - }); - }); - it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => { const templateModel = { id: "claude-opus-4-5", @@ -244,7 +207,7 @@ describe("resolveModel", () => { }); }); - it("builds a google-antigravity forward-compat fallback for claude-opus-4-6-thinking", () => { + it("builds an antigravity forward-compat fallback for claude-opus-4-6-thinking", () => { const templateModel = { id: "claude-opus-4-5-thinking", name: "Claude Opus 4.5 Thinking", @@ -253,8 +216,8 @@ describe("resolveModel", () => { baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", reasoning: true, input: ["text", "image"] as const, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000000, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 200000, maxTokens: 64000, }; @@ -276,6 +239,45 @@ describe("resolveModel", () => { api: "google-gemini-cli", baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", reasoning: true, + contextWindow: 200000, + maxTokens: 64000, + }); + }); + + it("builds an antigravity forward-compat fallback for claude-opus-4-6", () => { + const templateModel = { + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + provider: "google-antigravity", + api: "google-gemini-cli", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"] as const, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 200000, + maxTokens: 64000, + }; + + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn((provider: string, modelId: string) => { + if (provider === "google-antigravity" && modelId === "claude-opus-4-5") { + return templateModel; + } + return null; + }), + } as unknown as ReturnType); + + const result = resolveModel("google-antigravity", "claude-opus-4-6", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "google-antigravity", + id: "claude-opus-4-6", + api: "google-gemini-cli", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + contextWindow: 200000, + maxTokens: 64000, }); }); @@ -314,18 +316,34 @@ describe("resolveModel", () => { }); }); + it("keeps unknown-model errors when no antigravity thinking template exists", () => { + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn(() => null), + } as unknown as ReturnType); + + const result = resolveModel("google-antigravity", "claude-opus-4-6-thinking", "/tmp/agent"); + + expect(result.model).toBeUndefined(); + expect(result.error).toBe("Unknown model: google-antigravity/claude-opus-4-6-thinking"); + }); + + it("keeps unknown-model errors when no antigravity non-thinking template exists", () => { + vi.mocked(discoverModels).mockReturnValue({ + find: vi.fn(() => null), + } as unknown as ReturnType); + + const result = resolveModel("google-antigravity", "claude-opus-4-6", "/tmp/agent"); + + expect(result.model).toBeUndefined(); + expect(result.error).toBe("Unknown model: google-antigravity/claude-opus-4-6"); + }); + it("keeps unknown-model errors for non-gpt-5 openai-codex ids", () => { const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent"); expect(result.model).toBeUndefined(); expect(result.error).toBe("Unknown model: openai-codex/gpt-4.1-mini"); }); - it("errors for unknown gpt-5.3-codex-* variants", () => { - const result = resolveModel("openai-codex", "gpt-5.3-codex-unknown", "/tmp/agent"); - expect(result.model).toBeUndefined(); - expect(result.error).toBe("Unknown model: openai-codex/gpt-5.3-codex-unknown"); - }); - it("uses codex fallback even when openai-codex provider is configured", () => { // This test verifies the ordering: codex fallback must fire BEFORE the generic providerCfg fallback. // If ordering is wrong, the generic fallback would use api: "openai-responses" (the default) diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 41e1f8baf10..cbc21fe2d4f 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -4,6 +4,7 @@ import type { ModelDefinitionConfig } from "../../config/types.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { normalizeModelCompat } from "../model-compat.js"; +import { resolveForwardCompatModel } from "../model-forward-compat.js"; import { normalizeProviderId } from "../model-selection.js"; import { discoverAuthStorage, @@ -19,188 +20,6 @@ type InlineProviderConfig = { models?: ModelDefinitionConfig[]; }; -const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex"; -const OPENAI_CODEX_GPT_53_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; - -const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; - -// pi-ai's built-in Anthropic catalog can lag behind OpenClaw's defaults/docs. -// Add forward-compat fallbacks for known-new IDs by cloning an older template model. -const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6"; -const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6"; -const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const; - -function resolveOpenAICodexGpt53FallbackModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - const normalizedProvider = normalizeProviderId(provider); - const trimmedModelId = modelId.trim(); - if (normalizedProvider !== "openai-codex") { - return undefined; - } - - const lower = trimmedModelId.toLowerCase(); - const isGpt53 = lower === OPENAI_CODEX_GPT_53_MODEL_ID; - const isSpark = lower === OPENAI_CODEX_GPT_53_SPARK_MODEL_ID; - if (!isGpt53 && !isSpark) { - return undefined; - } - - for (const templateId of OPENAI_CODEX_TEMPLATE_MODEL_IDS) { - const template = modelRegistry.find(normalizedProvider, templateId) as Model | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - // Spark is a low-latency variant; keep api/baseUrl from template. - ...(isSpark ? { reasoning: true } : {}), - } as Model); - } - - return normalizeModelCompat({ - id: trimmedModelId, - name: trimmedModelId, - api: "openai-codex-responses", - provider: normalizedProvider, - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_TOKENS, - maxTokens: DEFAULT_CONTEXT_TOKENS, - } as Model); -} - -function resolveAnthropicOpus46ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - const normalizedProvider = normalizeProviderId(provider); - if (normalizedProvider !== "anthropic") { - return undefined; - } - - const trimmedModelId = modelId.trim(); - const lower = trimmedModelId.toLowerCase(); - const isOpus46 = - lower === ANTHROPIC_OPUS_46_MODEL_ID || - lower === ANTHROPIC_OPUS_46_DOT_MODEL_ID || - lower.startsWith(`${ANTHROPIC_OPUS_46_MODEL_ID}-`) || - lower.startsWith(`${ANTHROPIC_OPUS_46_DOT_MODEL_ID}-`); - if (!isOpus46) { - return undefined; - } - - const templateIds: string[] = []; - if (lower.startsWith(ANTHROPIC_OPUS_46_MODEL_ID)) { - templateIds.push(lower.replace(ANTHROPIC_OPUS_46_MODEL_ID, "claude-opus-4-5")); - } - if (lower.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID)) { - templateIds.push(lower.replace(ANTHROPIC_OPUS_46_DOT_MODEL_ID, "claude-opus-4.5")); - } - templateIds.push(...ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS); - - for (const templateId of [...new Set(templateIds)].filter(Boolean)) { - const template = modelRegistry.find(normalizedProvider, templateId) as Model | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - } as Model); - } - - return undefined; -} - -// Z.ai's GLM-5 may not be present in pi-ai's built-in model catalog yet. -// When a user configures zai/glm-5 without a models.json entry, clone glm-4.7 as a forward-compat fallback. -const ZAI_GLM5_MODEL_ID = "glm-5"; -const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const; - -function resolveZaiGlm5ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - if (normalizeProviderId(provider) !== "zai") { - return undefined; - } - const trimmed = modelId.trim(); - const lower = trimmed.toLowerCase(); - if (lower !== ZAI_GLM5_MODEL_ID && !lower.startsWith(`${ZAI_GLM5_MODEL_ID}-`)) { - return undefined; - } - - for (const templateId of ZAI_GLM5_TEMPLATE_MODEL_IDS) { - const template = modelRegistry.find("zai", templateId) as Model | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmed, - name: trimmed, - reasoning: true, - } as Model); - } - - return normalizeModelCompat({ - id: trimmed, - name: trimmed, - api: "openai-completions", - provider: "zai", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_TOKENS, - maxTokens: DEFAULT_CONTEXT_TOKENS, - } as Model); -} - -// google-antigravity's model catalog in pi-ai can lag behind the actual platform. -// When a google-antigravity model ID contains "opus-4-6" (or "opus-4.6") but isn't -// in the registry yet, clone the opus-4-5 template so the correct api -// ("google-gemini-cli") and baseUrl are preserved. -const ANTIGRAVITY_OPUS_46_STEMS = ["claude-opus-4-6", "claude-opus-4.6"] as const; -const ANTIGRAVITY_OPUS_45_TEMPLATES = ["claude-opus-4-5-thinking", "claude-opus-4-5"] as const; - -function resolveAntigravityOpus46ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - if (normalizeProviderId(provider) !== "google-antigravity") { - return undefined; - } - const lower = modelId.trim().toLowerCase(); - const isOpus46 = ANTIGRAVITY_OPUS_46_STEMS.some( - (stem) => lower === stem || lower.startsWith(`${stem}-`), - ); - if (!isOpus46) { - return undefined; - } - for (const templateId of ANTIGRAVITY_OPUS_45_TEMPLATES) { - const template = modelRegistry.find("google-antigravity", templateId) as Model | null; - if (template) { - return normalizeModelCompat({ - ...template, - id: modelId.trim(), - name: modelId.trim(), - } as Model); - } - } - return undefined; -} - export function buildInlineProviderModels( providers: Record, ): InlineModelEntry[] { @@ -267,36 +86,11 @@ export function resolveModel( modelRegistry, }; } - // Codex gpt-5.3 forward-compat fallback must be checked BEFORE the generic providerCfg fallback. - // Otherwise, if cfg.models.providers["openai-codex"] is configured, the generic fallback fires - // with api: "openai-responses" instead of the correct "openai-codex-responses". - const codexForwardCompat = resolveOpenAICodexGpt53FallbackModel( - provider, - modelId, - modelRegistry, - ); - if (codexForwardCompat) { - return { model: codexForwardCompat, authStorage, modelRegistry }; - } - const anthropicForwardCompat = resolveAnthropicOpus46ForwardCompatModel( - provider, - modelId, - modelRegistry, - ); - if (anthropicForwardCompat) { - return { model: anthropicForwardCompat, authStorage, modelRegistry }; - } - const antigravityForwardCompat = resolveAntigravityOpus46ForwardCompatModel( - provider, - modelId, - modelRegistry, - ); - if (antigravityForwardCompat) { - return { model: antigravityForwardCompat, authStorage, modelRegistry }; - } - const zaiForwardCompat = resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry); - if (zaiForwardCompat) { - return { model: zaiForwardCompat, authStorage, modelRegistry }; + // Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback. + // Otherwise, configured providers can default to a generic API and break specific transports. + const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry); + if (forwardCompat) { + return { model: forwardCompat, authStorage, modelRegistry }; } const providerCfg = providers[provider]; if (providerCfg || modelId.startsWith("mock-")) { diff --git a/src/commands/models.auth.provider-resolution.test.ts b/src/commands/models.auth.provider-resolution.test.ts new file mode 100644 index 00000000000..11cc6d934ac --- /dev/null +++ b/src/commands/models.auth.provider-resolution.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderPlugin } from "../plugins/types.js"; +import { resolveRequestedLoginProviderOrThrow } from "./models/auth.js"; + +function makeProvider(params: { id: string; label?: string; aliases?: string[] }): ProviderPlugin { + return { + id: params.id, + label: params.label ?? params.id, + aliases: params.aliases, + auth: [], + }; +} + +describe("resolveRequestedLoginProviderOrThrow", () => { + it("returns null when no provider was requested", () => { + const providers = [makeProvider({ id: "google-antigravity" })]; + const result = resolveRequestedLoginProviderOrThrow(providers, undefined); + expect(result).toBeNull(); + }); + + it("resolves requested provider by id", () => { + const providers = [ + makeProvider({ id: "google-antigravity" }), + makeProvider({ id: "google-gemini-cli" }), + ]; + const result = resolveRequestedLoginProviderOrThrow(providers, "google-antigravity"); + expect(result?.id).toBe("google-antigravity"); + }); + + it("resolves requested provider by alias", () => { + const providers = [makeProvider({ id: "google-antigravity", aliases: ["antigravity"] })]; + const result = resolveRequestedLoginProviderOrThrow(providers, "antigravity"); + expect(result?.id).toBe("google-antigravity"); + }); + + it("throws when requested provider is not loaded", () => { + const providers = [ + makeProvider({ id: "google-gemini-cli" }), + makeProvider({ id: "qwen-portal" }), + ]; + + expect(() => + resolveRequestedLoginProviderOrThrow(providers, "google-antigravity"), + ).toThrowError( + 'Unknown provider "google-antigravity". Loaded providers: google-gemini-cli, qwen-portal. Verify plugins via `openclaw plugins list --json`.', + ); + }); +}); diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 199ef6402de..182657e466f 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const loadConfig = vi.fn(); const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined); @@ -16,7 +16,10 @@ const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined); const modelRegistryState = { models: [] as Array>, available: [] as Array>, + getAllError: undefined as unknown, + getAvailableError: undefined as unknown, }; +let previousExitCode: number | undefined; vi.mock("../config/config.js", () => ({ CONFIG_PATH: "/tmp/openclaw.json", @@ -46,17 +49,44 @@ vi.mock("../agents/model-auth.js", () => ({ getCustomProviderApiKey, })); -vi.mock("@mariozechner/pi-coding-agent", () => ({ - AuthStorage: class {}, - ModelRegistry: class { - getAll() { - return modelRegistryState.models; +vi.mock("@mariozechner/pi-coding-agent", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-coding-agent", + ); + + class MockAuthStorage {} + + class MockModelRegistry { + find(provider: string, id: string): ReturnType { + const found = + modelRegistryState.models.find((model) => model.provider === provider && model.id === id) ?? + null; + return found as ReturnType; } - getAvailable() { - return modelRegistryState.available; + + getAll(): ReturnType { + if (modelRegistryState.getAllError !== undefined) { + throw modelRegistryState.getAllError; + } + return modelRegistryState.models as ReturnType; } - }, -})); + + getAvailable(): ReturnType { + if (modelRegistryState.getAvailableError !== undefined) { + throw modelRegistryState.getAvailableError; + } + return modelRegistryState.available as ReturnType< + typeof actual.ModelRegistry.prototype.getAvailable + >; + } + } + + return { + ...actual, + AuthStorage: MockAuthStorage as unknown as typeof actual.AuthStorage, + ModelRegistry: MockModelRegistry as unknown as typeof actual.ModelRegistry, + }; +}); function makeRuntime() { return { @@ -65,6 +95,18 @@ function makeRuntime() { }; } +beforeEach(() => { + previousExitCode = process.exitCode; + process.exitCode = undefined; + modelRegistryState.getAllError = undefined; + modelRegistryState.getAvailableError = undefined; + listProfilesForProvider.mockReturnValue([]); +}); + +afterEach(() => { + process.exitCode = previousExitCode; +}); + describe("models list/status", () => { it("models status resolves z.ai alias to canonical zai", async () => { loadConfig.mockReturnValue({ @@ -280,4 +322,463 @@ describe("models list/status", () => { const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); expect(payload.models[0]?.available).toBe(false); }); + + it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + const runtime = makeRuntime(); + + modelRegistryState.models = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.tags).toContain("default"); + expect(payload.models[0]?.tags).toContain("configured"); + }); + + it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6", + models: { + "google-antigravity/claude-opus-4-6": {}, + }, + }, + }, + }); + const runtime = makeRuntime(); + + modelRegistryState.models = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.tags).toContain("default"); + expect(payload.models[0]?.tags).toContain("configured"); + }); + + it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + const runtime = makeRuntime(); + + const template = { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }; + modelRegistryState.models = [template]; + modelRegistryState.available = [template]; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + }); + + it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6", + models: { + "google-antigravity/claude-opus-4-6": {}, + }, + }, + }, + }); + const runtime = makeRuntime(); + + const template = { + provider: "google-antigravity", + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }; + modelRegistryState.models = [template]; + modelRegistryState.available = [template]; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + }); + + it("models list prefers registry availability over provider auth heuristics", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "google-antigravity" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + const runtime = makeRuntime(); + + const template = { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }; + modelRegistryState.models = [template]; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(false); + listProfilesForProvider.mockReturnValue([]); + }); + + it("models list falls back to auth heuristics when registry availability is unavailable", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "google-antigravity" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + modelRegistryState.getAvailableError = Object.assign( + new Error("availability unsupported: getAvailable failed"), + { code: "MODEL_AVAILABILITY_UNAVAILABLE" }, + ); + const runtime = makeRuntime(); + + modelRegistryState.models = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics"); + expect(runtime.error.mock.calls[0]?.[0]).toContain("getAvailable failed"); + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + }); + + it("models list falls back to auth heuristics when getAvailable returns invalid shape", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "google-antigravity" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + modelRegistryState.available = { bad: true } as unknown as Array>; + const runtime = makeRuntime(); + + modelRegistryState.models = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics"); + expect(runtime.error.mock.calls[0]?.[0]).toContain("non-array value"); + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + }); + + it("models list falls back to auth heuristics when getAvailable throws", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "google-antigravity" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + modelRegistryState.getAvailableError = new Error( + "availability unsupported: getAvailable failed", + ); + const runtime = makeRuntime(); + + modelRegistryState.models = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics"); + expect(runtime.error.mock.calls[0]?.[0]).toContain( + "availability unsupported: getAvailable failed", + ); + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + }); + + it("models list does not treat availability-unavailable code as discovery fallback", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + modelRegistryState.getAllError = Object.assign(new Error("model discovery failed"), { + code: "MODEL_AVAILABILITY_UNAVAILABLE", + }); + const runtime = makeRuntime(); + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:"); + expect(runtime.error.mock.calls[0]?.[0]).toContain("model discovery failed"); + expect(runtime.error.mock.calls[0]?.[0]).not.toContain("configured models may appear missing"); + expect(runtime.log).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("models list fails fast when registry model discovery is unavailable", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "google-antigravity" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), { + code: "MODEL_DISCOVERY_UNAVAILABLE", + }); + const runtime = makeRuntime(); + + modelRegistryState.models = []; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:"); + expect(runtime.error.mock.calls[0]?.[0]).toContain("model discovery unavailable"); + expect(runtime.log).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("loadModelRegistry throws when model discovery is unavailable", async () => { + modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), { + code: "MODEL_DISCOVERY_UNAVAILABLE", + }); + modelRegistryState.available = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + + const { loadModelRegistry } = await import("./models/list.registry.js"); + await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable"); + }); + + it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => { + const { toModelRow } = await import("./models/list.registry.js"); + + const row = toModelRow({ + model: { + provider: "google-antigravity", + id: "claude-opus-4-6-thinking", + name: "Claude Opus 4.6 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + key: "google-antigravity/claude-opus-4-6-thinking", + tags: [], + availableKeys: undefined, + }); + + expect(row.missing).toBe(false); + expect(row.available).toBe(false); + }); }); diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts new file mode 100644 index 00000000000..182657e466f --- /dev/null +++ b/src/commands/models.list.test.ts @@ -0,0 +1,784 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const loadConfig = vi.fn(); +const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined); +const resolveOpenClawAgentDir = vi.fn().mockReturnValue("/tmp/openclaw-agent"); +const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} }); +const listProfilesForProvider = vi.fn().mockReturnValue([]); +const resolveAuthProfileDisplayLabel = vi.fn(({ profileId }: { profileId: string }) => profileId); +const resolveAuthStorePathForDisplay = vi + .fn() + .mockReturnValue("/tmp/openclaw-agent/auth-profiles.json"); +const resolveProfileUnusableUntilForDisplay = vi.fn().mockReturnValue(null); +const resolveEnvApiKey = vi.fn().mockReturnValue(undefined); +const resolveAwsSdkEnvVarName = vi.fn().mockReturnValue(undefined); +const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined); +const modelRegistryState = { + models: [] as Array>, + available: [] as Array>, + getAllError: undefined as unknown, + getAvailableError: undefined as unknown, +}; +let previousExitCode: number | undefined; + +vi.mock("../config/config.js", () => ({ + CONFIG_PATH: "/tmp/openclaw.json", + STATE_DIR: "/tmp/openclaw-state", + loadConfig, +})); + +vi.mock("../agents/models-config.js", () => ({ + ensureOpenClawModelsJson, +})); + +vi.mock("../agents/agent-paths.js", () => ({ + resolveOpenClawAgentDir, +})); + +vi.mock("../agents/auth-profiles.js", () => ({ + ensureAuthProfileStore, + listProfilesForProvider, + resolveAuthProfileDisplayLabel, + resolveAuthStorePathForDisplay, + resolveProfileUnusableUntilForDisplay, +})); + +vi.mock("../agents/model-auth.js", () => ({ + resolveEnvApiKey, + resolveAwsSdkEnvVarName, + getCustomProviderApiKey, +})); + +vi.mock("@mariozechner/pi-coding-agent", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-coding-agent", + ); + + class MockAuthStorage {} + + class MockModelRegistry { + find(provider: string, id: string): ReturnType { + const found = + modelRegistryState.models.find((model) => model.provider === provider && model.id === id) ?? + null; + return found as ReturnType; + } + + getAll(): ReturnType { + if (modelRegistryState.getAllError !== undefined) { + throw modelRegistryState.getAllError; + } + return modelRegistryState.models as ReturnType; + } + + getAvailable(): ReturnType { + if (modelRegistryState.getAvailableError !== undefined) { + throw modelRegistryState.getAvailableError; + } + return modelRegistryState.available as ReturnType< + typeof actual.ModelRegistry.prototype.getAvailable + >; + } + } + + return { + ...actual, + AuthStorage: MockAuthStorage as unknown as typeof actual.AuthStorage, + ModelRegistry: MockModelRegistry as unknown as typeof actual.ModelRegistry, + }; +}); + +function makeRuntime() { + return { + log: vi.fn(), + error: vi.fn(), + }; +} + +beforeEach(() => { + previousExitCode = process.exitCode; + process.exitCode = undefined; + modelRegistryState.getAllError = undefined; + modelRegistryState.getAvailableError = undefined; + listProfilesForProvider.mockReturnValue([]); +}); + +afterEach(() => { + process.exitCode = previousExitCode; +}); + +describe("models list/status", () => { + it("models status resolves z.ai alias to canonical zai", async () => { + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); + const runtime = makeRuntime(); + + const { modelsStatusCommand } = await import("./models/list.js"); + await modelsStatusCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.resolvedDefault).toBe("zai/glm-4.7"); + }); + + it("models status plain outputs canonical zai model", async () => { + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); + const runtime = makeRuntime(); + + const { modelsStatusCommand } = await import("./models/list.js"); + await modelsStatusCommand({ plain: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7"); + }); + + it("models list outputs canonical zai key for configured z.ai model", async () => { + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); + const runtime = makeRuntime(); + + const model = { + provider: "zai", + id: "glm-4.7", + name: "GLM-4.7", + input: ["text"], + baseUrl: "https://api.z.ai/v1", + contextWindow: 128000, + }; + + modelRegistryState.models = [model]; + modelRegistryState.available = [model]; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("zai/glm-4.7"); + }); + + it("models list plain outputs canonical zai key", async () => { + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); + const runtime = makeRuntime(); + + const model = { + provider: "zai", + id: "glm-4.7", + name: "GLM-4.7", + input: ["text"], + baseUrl: "https://api.z.ai/v1", + contextWindow: 128000, + }; + + modelRegistryState.models = [model]; + modelRegistryState.available = [model]; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ plain: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7"); + }); + + it("models list provider filter normalizes z.ai alias", async () => { + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); + const runtime = makeRuntime(); + + const models = [ + { + provider: "zai", + id: "glm-4.7", + name: "GLM-4.7", + input: ["text"], + baseUrl: "https://api.z.ai/v1", + contextWindow: 128000, + }, + { + provider: "openai", + id: "gpt-4.1-mini", + name: "GPT-4.1 mini", + input: ["text"], + baseUrl: "https://api.openai.com/v1", + contextWindow: 128000, + }, + ]; + + modelRegistryState.models = models; + modelRegistryState.available = models; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ all: true, provider: "z.ai", json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.count).toBe(1); + expect(payload.models[0]?.key).toBe("zai/glm-4.7"); + }); + + it("models list provider filter normalizes Z.AI alias casing", async () => { + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); + const runtime = makeRuntime(); + + const models = [ + { + provider: "zai", + id: "glm-4.7", + name: "GLM-4.7", + input: ["text"], + baseUrl: "https://api.z.ai/v1", + contextWindow: 128000, + }, + { + provider: "openai", + id: "gpt-4.1-mini", + name: "GPT-4.1 mini", + input: ["text"], + baseUrl: "https://api.openai.com/v1", + contextWindow: 128000, + }, + ]; + + modelRegistryState.models = models; + modelRegistryState.available = models; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ all: true, provider: "Z.AI", json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.count).toBe(1); + expect(payload.models[0]?.key).toBe("zai/glm-4.7"); + }); + + it("models list provider filter normalizes z-ai alias", async () => { + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); + const runtime = makeRuntime(); + + const models = [ + { + provider: "zai", + id: "glm-4.7", + name: "GLM-4.7", + input: ["text"], + baseUrl: "https://api.z.ai/v1", + contextWindow: 128000, + }, + { + provider: "openai", + id: "gpt-4.1-mini", + name: "GPT-4.1 mini", + input: ["text"], + baseUrl: "https://api.openai.com/v1", + contextWindow: 128000, + }, + ]; + + modelRegistryState.models = models; + modelRegistryState.available = models; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ all: true, provider: "z-ai", json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.count).toBe(1); + expect(payload.models[0]?.key).toBe("zai/glm-4.7"); + }); + + it("models list marks auth as unavailable when ZAI key is missing", async () => { + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); + const runtime = makeRuntime(); + + const model = { + provider: "zai", + id: "glm-4.7", + name: "GLM-4.7", + input: ["text"], + baseUrl: "https://api.z.ai/v1", + contextWindow: 128000, + }; + + modelRegistryState.models = [model]; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ all: true, json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.available).toBe(false); + }); + + it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + const runtime = makeRuntime(); + + modelRegistryState.models = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.tags).toContain("default"); + expect(payload.models[0]?.tags).toContain("configured"); + }); + + it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6", + models: { + "google-antigravity/claude-opus-4-6": {}, + }, + }, + }, + }); + const runtime = makeRuntime(); + + modelRegistryState.models = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.tags).toContain("default"); + expect(payload.models[0]?.tags).toContain("configured"); + }); + + it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + const runtime = makeRuntime(); + + const template = { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }; + modelRegistryState.models = [template]; + modelRegistryState.available = [template]; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + }); + + it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6", + models: { + "google-antigravity/claude-opus-4-6": {}, + }, + }, + }, + }); + const runtime = makeRuntime(); + + const template = { + provider: "google-antigravity", + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }; + modelRegistryState.models = [template]; + modelRegistryState.available = [template]; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + }); + + it("models list prefers registry availability over provider auth heuristics", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "google-antigravity" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + const runtime = makeRuntime(); + + const template = { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }; + modelRegistryState.models = [template]; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(false); + listProfilesForProvider.mockReturnValue([]); + }); + + it("models list falls back to auth heuristics when registry availability is unavailable", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "google-antigravity" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + modelRegistryState.getAvailableError = Object.assign( + new Error("availability unsupported: getAvailable failed"), + { code: "MODEL_AVAILABILITY_UNAVAILABLE" }, + ); + const runtime = makeRuntime(); + + modelRegistryState.models = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics"); + expect(runtime.error.mock.calls[0]?.[0]).toContain("getAvailable failed"); + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + }); + + it("models list falls back to auth heuristics when getAvailable returns invalid shape", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "google-antigravity" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + modelRegistryState.available = { bad: true } as unknown as Array>; + const runtime = makeRuntime(); + + modelRegistryState.models = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics"); + expect(runtime.error.mock.calls[0]?.[0]).toContain("non-array value"); + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + }); + + it("models list falls back to auth heuristics when getAvailable throws", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "google-antigravity" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + modelRegistryState.getAvailableError = new Error( + "availability unsupported: getAvailable failed", + ); + const runtime = makeRuntime(); + + modelRegistryState.models = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics"); + expect(runtime.error.mock.calls[0]?.[0]).toContain( + "availability unsupported: getAvailable failed", + ); + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking"); + expect(payload.models[0]?.missing).toBe(false); + expect(payload.models[0]?.available).toBe(true); + }); + + it("models list does not treat availability-unavailable code as discovery fallback", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + modelRegistryState.getAllError = Object.assign(new Error("model discovery failed"), { + code: "MODEL_AVAILABILITY_UNAVAILABLE", + }); + const runtime = makeRuntime(); + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:"); + expect(runtime.error.mock.calls[0]?.[0]).toContain("model discovery failed"); + expect(runtime.error.mock.calls[0]?.[0]).not.toContain("configured models may appear missing"); + expect(runtime.log).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("models list fails fast when registry model discovery is unavailable", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "google-antigravity/claude-opus-4-6-thinking", + models: { + "google-antigravity/claude-opus-4-6-thinking": {}, + }, + }, + }, + }); + listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "google-antigravity" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), { + code: "MODEL_DISCOVERY_UNAVAILABLE", + }); + const runtime = makeRuntime(); + + modelRegistryState.models = []; + modelRegistryState.available = []; + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ json: true }, runtime); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:"); + expect(runtime.error.mock.calls[0]?.[0]).toContain("model discovery unavailable"); + expect(runtime.log).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("loadModelRegistry throws when model discovery is unavailable", async () => { + modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), { + code: "MODEL_DISCOVERY_UNAVAILABLE", + }); + modelRegistryState.available = [ + { + provider: "google-antigravity", + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + ]; + + const { loadModelRegistry } = await import("./models/list.registry.js"); + await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable"); + }); + + it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => { + const { toModelRow } = await import("./models/list.registry.js"); + + const row = toModelRow({ + model: { + provider: "google-antigravity", + id: "claude-opus-4-6-thinking", + name: "Claude Opus 4.6 Thinking", + api: "google-gemini-cli", + input: ["text", "image"], + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 200000, + maxTokens: 64000, + reasoning: true, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + key: "google-antigravity/claude-opus-4-6-thinking", + tags: [], + availableKeys: undefined, + }); + + expect(row.missing).toBe(false); + expect(row.available).toBe(false); + }); +}); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 146c1d2693f..71db1ac5e3a 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -26,8 +26,6 @@ import { isRemoteEnvironment } from "../oauth-env.js"; import { createVpsAwareOAuthHandlers } from "../oauth-flow.js"; import { applyAuthProfileConfig } from "../onboard-auth.js"; import { openUrl } from "../onboard-helpers.js"; -import { OPENAI_CODEX_DEFAULT_MODEL } from "../openai-codex-model-default.js"; -import { loginOpenAICodexOAuth } from "../openai-codex-oauth.js"; import { updateConfig } from "./shared.js"; const confirm = (params: Parameters[0]) => @@ -260,6 +258,28 @@ function resolveProviderMatch( ); } +export function resolveRequestedLoginProviderOrThrow( + providers: ProviderPlugin[], + rawProvider?: string, +): ProviderPlugin | null { + const requested = rawProvider?.trim(); + if (!requested) { + return null; + } + const matched = resolveProviderMatch(providers, requested); + if (matched) { + return matched; + } + const available = providers + .map((provider) => provider.id) + .filter(Boolean) + .toSorted((a, b) => a.localeCompare(b)); + const availableText = available.length > 0 ? available.join(", ") : "(none)"; + throw new Error( + `Unknown provider "${requested}". Loaded providers: ${availableText}. Verify plugins via \`${formatCliCommand("openclaw plugins list --json")}\`.`, + ); +} + function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null { const raw = rawMethod?.trim(); if (!raw) { @@ -344,59 +364,6 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim const workspaceDir = resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir(); - const prompter = createClackPrompter(); - const requestedProvider = opts.provider ? normalizeProviderId(opts.provider) : null; - if (requestedProvider === "openai-codex") { - const method = opts.method?.trim().toLowerCase(); - if (method && method !== "oauth") { - throw new Error('OpenAI Codex auth only supports --method "oauth".'); - } - - const creds = await loginOpenAICodexOAuth({ - prompter, - runtime, - isRemote: isRemoteEnvironment(), - openUrl: async (url) => { - await openUrl(url); - }, - }); - if (!creds) { - return; - } - - const profileId = "openai-codex:default"; - upsertAuthProfile({ - profileId, - credential: { - type: "oauth", - provider: "openai-codex", - ...creds, - }, - agentDir, - }); - - await updateConfig((cfg) => { - let next = applyAuthProfileConfig(cfg, { - profileId, - provider: "openai-codex", - mode: "oauth", - }); - if (opts.setDefault) { - next = applyDefaultModel(next, OPENAI_CODEX_DEFAULT_MODEL); - } - return next; - }); - - logConfigUpdated(runtime); - runtime.log(`Auth profile: ${profileId} (openai-codex/oauth)`); - runtime.log( - opts.setDefault - ? `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}` - : `Default model available: ${OPENAI_CODEX_DEFAULT_MODEL} (use --set-default to apply)`, - ); - return; - } - const providers = resolvePluginProviders({ config, workspaceDir }); if (providers.length === 0) { throw new Error( @@ -404,8 +371,10 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim ); } + const prompter = createClackPrompter(); + const requestedProvider = resolveRequestedLoginProviderOrThrow(providers, opts.provider); const selectedProvider = - resolveProviderMatch(providers, opts.provider) ?? + requestedProvider ?? (await prompter .select({ message: "Select a provider", diff --git a/src/commands/models/list.errors.ts b/src/commands/models/list.errors.ts new file mode 100644 index 00000000000..3c501e095db --- /dev/null +++ b/src/commands/models/list.errors.ts @@ -0,0 +1,16 @@ +export const MODEL_AVAILABILITY_UNAVAILABLE_CODE = "MODEL_AVAILABILITY_UNAVAILABLE"; + +export function formatErrorWithStack(err: unknown): string { + if (err instanceof Error) { + return err.stack ?? `${err.name}: ${err.message}`; + } + return String(err); +} + +export function shouldFallbackToAuthHeuristics(err: unknown): boolean { + if (!(err instanceof Error)) { + return false; + } + const code = (err as { code?: unknown }).code; + return code === MODEL_AVAILABILITY_UNAVAILABLE_CODE; +} diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index dcc8bf089ff..675e04adb87 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -3,9 +3,9 @@ import type { RuntimeEnv } from "../../runtime.js"; import type { ModelRow } from "./list.types.js"; import { ensureAuthProfileStore } from "../../agents/auth-profiles.js"; import { parseModelRef } from "../../agents/model-selection.js"; -import { resolveModel } from "../../agents/pi-embedded-runner/model.js"; import { loadConfig } from "../../config/config.js"; import { resolveConfiguredEntries } from "./list.configured.js"; +import { formatErrorWithStack } from "./list.errors.js"; import { loadModelRegistry, toModelRow } from "./list.registry.js"; import { printModelTable } from "./list.table.js"; import { DEFAULT_PROVIDER, ensureFlagCompatibility, modelKey } from "./shared.js"; @@ -34,12 +34,21 @@ export async function modelsListCommand( let models: Model[] = []; let availableKeys: Set | undefined; + let availabilityErrorMessage: string | undefined; try { const loaded = await loadModelRegistry(cfg); models = loaded.models; availableKeys = loaded.availableKeys; + availabilityErrorMessage = loaded.availabilityErrorMessage; } catch (err) { - runtime.error(`Model registry unavailable: ${String(err)}`); + runtime.error(`Model registry unavailable:\n${formatErrorWithStack(err)}`); + process.exitCode = 1; + return; + } + if (availabilityErrorMessage !== undefined) { + runtime.error( + `Model availability lookup failed; falling back to auth heuristics for discovered models: ${availabilityErrorMessage}`, + ); } const modelByKey = new Map(models.map((model) => [modelKey(model.provider, model.id), model])); @@ -100,13 +109,7 @@ export async function modelsListCommand( if (providerFilter && entry.ref.provider.toLowerCase() !== providerFilter) { continue; } - let model = modelByKey.get(entry.key); - if (!model) { - const resolved = resolveModel(entry.ref.provider, entry.ref.model, undefined, cfg); - if (resolved.model && !resolved.error) { - model = resolved.model; - } - } + const model = modelByKey.get(entry.key); if (opts.local && model && !isLocalBaseUrl(model.baseUrl)) { continue; } diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index a8ff5ded52a..9f690021f3f 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -1,5 +1,6 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import type { ModelRegistry } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { ModelRow } from "./list.types.js"; import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; @@ -9,9 +10,14 @@ import { resolveAwsSdkEnvVarName, resolveEnvApiKey, } from "../../agents/model-auth.js"; +import { resolveForwardCompatModel } from "../../agents/model-forward-compat.js"; import { ensureOpenClawModelsJson } from "../../agents/models-config.js"; -import { ensurePiAuthJsonFromAuthProfiles } from "../../agents/pi-auth-json.js"; import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; +import { + formatErrorWithStack, + MODEL_AVAILABILITY_UNAVAILABLE_CODE, + shouldFallbackToAuthHeuristics, +} from "./list.errors.js"; import { modelKey } from "./shared.js"; const isLocalBaseUrl = (baseUrl: string) => { @@ -30,7 +36,14 @@ const isLocalBaseUrl = (baseUrl: string) => { } }; -const hasAuthForProvider = (provider: string, cfg: OpenClawConfig, authStore: AuthProfileStore) => { +const hasAuthForProvider = ( + provider: string, + cfg?: OpenClawConfig, + authStore?: AuthProfileStore, +) => { + if (!cfg || !authStore) { + return false; + } if (listProfilesForProvider(authStore, provider).length > 0) { return true; } @@ -46,16 +59,150 @@ const hasAuthForProvider = (provider: string, cfg: OpenClawConfig, authStore: Au return false; }; +function createAvailabilityUnavailableError(message: string): Error { + const err = new Error(message); + (err as { code?: string }).code = MODEL_AVAILABILITY_UNAVAILABLE_CODE; + return err; +} + +function normalizeAvailabilityError(err: unknown): Error { + if (shouldFallbackToAuthHeuristics(err) && err instanceof Error) { + return err; + } + return createAvailabilityUnavailableError( + `Model availability unavailable: getAvailable() failed.\n${formatErrorWithStack(err)}`, + ); +} + +function validateAvailableModels(availableModels: unknown): Model[] { + if (!Array.isArray(availableModels)) { + throw createAvailabilityUnavailableError( + "Model availability unavailable: getAvailable() returned a non-array value.", + ); + } + + for (const model of availableModels) { + if ( + !model || + typeof model !== "object" || + typeof (model as { provider?: unknown }).provider !== "string" || + typeof (model as { id?: unknown }).id !== "string" + ) { + throw createAvailabilityUnavailableError( + "Model availability unavailable: getAvailable() returned invalid model entries.", + ); + } + } + + return availableModels as Model[]; +} + +function loadAvailableModels(registry: ModelRegistry): Model[] { + let availableModels: unknown; + try { + availableModels = registry.getAvailable(); + } catch (err) { + throw normalizeAvailabilityError(err); + } + try { + return validateAvailableModels(availableModels); + } catch (err) { + throw normalizeAvailabilityError(err); + } +} + export async function loadModelRegistry(cfg: OpenClawConfig) { await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); - await ensurePiAuthJsonFromAuthProfiles(agentDir); const authStorage = discoverAuthStorage(agentDir); const registry = discoverModels(authStorage, agentDir); - const models = registry.getAll(); - const availableModels = registry.getAvailable(); - const availableKeys = new Set(availableModels.map((model) => modelKey(model.provider, model.id))); - return { registry, models, availableKeys }; + const appended = appendAntigravityForwardCompatModels(registry.getAll(), registry); + const models = appended.models; + const synthesizedForwardCompat = appended.synthesizedForwardCompat; + let availableKeys: Set | undefined; + let availabilityErrorMessage: string | undefined; + + try { + const availableModels = loadAvailableModels(registry); + availableKeys = new Set(availableModels.map((model) => modelKey(model.provider, model.id))); + for (const synthesized of synthesizedForwardCompat) { + if (hasAvailableTemplate(availableKeys, synthesized.templatePrefixes)) { + availableKeys.add(synthesized.key); + } + } + } catch (err) { + if (!shouldFallbackToAuthHeuristics(err)) { + throw err; + } + + // Some providers can report model-level availability as unavailable. + // Fall back to provider-level auth heuristics when availability is undefined. + availableKeys = undefined; + if (!availabilityErrorMessage) { + availabilityErrorMessage = formatErrorWithStack(err); + } + } + return { registry, models, availableKeys, availabilityErrorMessage }; +} + +type SynthesizedForwardCompat = { + key: string; + templatePrefixes: string[]; +}; + +function appendAntigravityForwardCompatModels( + models: Model[], + modelRegistry: ModelRegistry, +): { models: Model[]; synthesizedForwardCompat: SynthesizedForwardCompat[] } { + const candidates = [ + { + id: "claude-opus-4-6-thinking", + templatePrefixes: [ + "google-antigravity/claude-opus-4-5-thinking", + "google-antigravity/claude-opus-4.5-thinking", + ], + }, + { + id: "claude-opus-4-6", + templatePrefixes: [ + "google-antigravity/claude-opus-4-5", + "google-antigravity/claude-opus-4.5", + ], + }, + ]; + + const nextModels = [...models]; + const synthesizedForwardCompat: SynthesizedForwardCompat[] = []; + + for (const candidate of candidates) { + const key = modelKey("google-antigravity", candidate.id); + const hasForwardCompat = nextModels.some((model) => modelKey(model.provider, model.id) === key); + if (hasForwardCompat) { + continue; + } + + const fallback = resolveForwardCompatModel("google-antigravity", candidate.id, modelRegistry); + if (!fallback) { + continue; + } + + nextModels.push(fallback); + synthesizedForwardCompat.push({ + key, + templatePrefixes: candidate.templatePrefixes, + }); + } + + return { models: nextModels, synthesizedForwardCompat }; +} + +function hasAvailableTemplate(availableKeys: Set, templatePrefixes: string[]): boolean { + for (const key of availableKeys) { + if (templatePrefixes.some((prefix) => key.startsWith(prefix))) { + return true; + } + } + return false; } export function toModelRow(params: { @@ -83,10 +230,14 @@ export function toModelRow(params: { const input = model.input.join("+") || "text"; const local = isLocalBaseUrl(model.baseUrl); + // Prefer model-level registry availability when present. + // Fall back to provider-level auth heuristics only if registry availability isn't available. const available = - cfg && authStore - ? hasAuthForProvider(model.provider, cfg, authStore) - : (availableKeys?.has(modelKey(model.provider, model.id)) ?? false); + availableKeys !== undefined + ? availableKeys.has(modelKey(model.provider, model.id)) + : cfg && authStore + ? hasAuthForProvider(model.provider, cfg, authStore) + : false; const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : []; const mergedTags = new Set(tags); if (aliasTags.length > 0) {