diff --git a/src/agents/model-forward-compat.ts b/src/agents/model-forward-compat.ts index 393f5e42857..f92523e0296 100644 --- a/src/agents/model-forward-compat.ts +++ b/src/agents/model-forward-compat.ts @@ -24,6 +24,20 @@ const ANTIGRAVITY_OPUS_THINKING_TEMPLATE_MODEL_IDS = [ "claude-opus-4.5-thinking", ] as const; +export const ANTIGRAVITY_OPUS_46_FORWARD_COMPAT_CANDIDATES = [ + { + id: ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID, + templatePrefixes: [ + "google-antigravity/claude-opus-4-5-thinking", + "google-antigravity/claude-opus-4.5-thinking", + ], + }, + { + id: ANTIGRAVITY_OPUS_46_MODEL_ID, + templatePrefixes: ["google-antigravity/claude-opus-4-5", "google-antigravity/claude-opus-4.5"], + }, +] as const; + function resolveOpenAICodexGpt53FallbackModel( provider: string, modelId: string, diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index 5555c17266d..5d17f677a56 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -1,5 +1,3 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { @@ -8,7 +6,6 @@ import { resolveAgentWorkspaceDir, } from "../agents/agent-scope.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js"; -import { normalizeProviderId } from "../agents/model-selection.js"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { resolvePluginProviders } from "../plugins/providers.js"; @@ -16,6 +13,12 @@ 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 { + applyDefaultModel, + mergeConfigPatch, + pickAuthMethod, + resolveProviderMatch, +} from "./provider-auth-helpers.js"; export type PluginProviderAuthChoiceOptions = { authChoice: string; @@ -25,78 +28,6 @@ export type PluginProviderAuthChoiceOptions = { label: string; }; -function resolveProviderMatch( - providers: ProviderPlugin[], - rawProvider: string, -): ProviderPlugin | null { - const normalized = normalizeProviderId(rawProvider); - return ( - providers.find((provider) => normalizeProviderId(provider.id) === normalized) ?? - providers.find( - (provider) => - provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false, - ) ?? - null - ); -} - -function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null { - const raw = rawMethod?.trim(); - if (!raw) { - return null; - } - const normalized = raw.toLowerCase(); - return ( - provider.auth.find((method) => method.id.toLowerCase() === normalized) ?? - provider.auth.find((method) => method.label.toLowerCase() === normalized) ?? - null - ); -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function mergeConfigPatch(base: T, patch: unknown): T { - if (!isPlainRecord(base) || !isPlainRecord(patch)) { - return patch as T; - } - - const next: Record = { ...base }; - for (const [key, value] of Object.entries(patch)) { - const existing = next[key]; - if (isPlainRecord(existing) && isPlainRecord(value)) { - next[key] = mergeConfigPatch(existing, value); - } else { - next[key] = value; - } - } - return next as T; -} - -function applyDefaultModel(cfg: OpenClawConfig, model: string): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[model] = models[model] ?? {}; - - const existingModel = cfg.agents?.defaults?.model; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - model: { - ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel - ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } - : undefined), - primary: model, - }, - }, - }, - }; -} - export async function applyAuthChoicePluginProvider( params: ApplyAuthChoiceParams, options: PluginProviderAuthChoiceOptions, diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 71db1ac5e3a..69dd84dde05 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -1,10 +1,6 @@ import { confirm as clackConfirm, select as clackSelect, text as clackText } from "@clack/prompts"; import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; -import type { - ProviderAuthMethod, - ProviderAuthResult, - ProviderPlugin, -} from "../../plugins/types.js"; +import type { ProviderAuthResult, ProviderPlugin } from "../../plugins/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import { resolveAgentDir, @@ -16,7 +12,7 @@ import { normalizeProviderId } from "../../agents/model-selection.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; -import { readConfigFileSnapshot, type OpenClawConfig } from "../../config/config.js"; +import { readConfigFileSnapshot } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; import { resolvePluginProviders } from "../../plugins/providers.js"; import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js"; @@ -26,6 +22,12 @@ 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 { + applyDefaultModel, + mergeConfigPatch, + pickAuthMethod, + resolveProviderMatch, +} from "../provider-auth-helpers.js"; import { updateConfig } from "./shared.js"; const confirm = (params: Parameters[0]) => @@ -239,25 +241,6 @@ type LoginOptions = { setDefault?: boolean; }; -function resolveProviderMatch( - providers: ProviderPlugin[], - rawProvider?: string, -): ProviderPlugin | null { - const raw = rawProvider?.trim(); - if (!raw) { - return null; - } - const normalized = normalizeProviderId(raw); - return ( - providers.find((provider) => normalizeProviderId(provider.id) === normalized) ?? - providers.find( - (provider) => - provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false, - ) ?? - null - ); -} - export function resolveRequestedLoginProviderOrThrow( providers: ProviderPlugin[], rawProvider?: string, @@ -280,63 +263,6 @@ export function resolveRequestedLoginProviderOrThrow( ); } -function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null { - const raw = rawMethod?.trim(); - if (!raw) { - return null; - } - const normalized = raw.toLowerCase(); - return ( - provider.auth.find((method) => method.id.toLowerCase() === normalized) ?? - provider.auth.find((method) => method.label.toLowerCase() === normalized) ?? - null - ); -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function mergeConfigPatch(base: T, patch: unknown): T { - if (!isPlainRecord(base) || !isPlainRecord(patch)) { - return patch as T; - } - - const next: Record = { ...base }; - for (const [key, value] of Object.entries(patch)) { - const existing = next[key]; - if (isPlainRecord(existing) && isPlainRecord(value)) { - next[key] = mergeConfigPatch(existing, value); - } else { - next[key] = value; - } - } - return next as T; -} - -function applyDefaultModel(cfg: OpenClawConfig, model: string): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[model] = models[model] ?? {}; - - const existingModel = cfg.agents?.defaults?.model; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - model: { - ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel - ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } - : undefined), - primary: model, - }, - }, - }, - }; -} - function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" | "token" { if (credential.type === "api_key") { return "api_key"; diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 2f7f6ec2719..811797eb5e7 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -8,7 +8,9 @@ const mocks = vi.hoisted(() => { models: { providers: {} }, }), ensureAuthProfileStore: vi.fn().mockReturnValue({ version: 1, profiles: {}, order: {} }), - loadModelRegistry: vi.fn().mockResolvedValue({ models: [], availableKeys: new Set() }), + loadModelRegistry: vi + .fn() + .mockResolvedValue({ models: [], availableKeys: new Set(), registry: {} }), resolveConfiguredEntries: vi.fn().mockReturnValue({ entries: [ { @@ -20,21 +22,16 @@ const mocks = vi.hoisted(() => { ], }), printModelTable, - resolveModel: vi.fn().mockReturnValue({ - model: { - provider: "openai-codex", - id: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - api: "openai-codex-responses", - baseUrl: "https://chatgpt.com/backend-api", - input: ["text"], - contextWindow: 272000, - maxTokens: 128000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }, - error: undefined, - authStorage: {} as never, - modelRegistry: {} as never, + resolveForwardCompatModel: vi.fn().mockReturnValue({ + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, }), }; }); @@ -68,14 +65,18 @@ vi.mock("./list.table.js", () => ({ printModelTable: mocks.printModelTable, })); -vi.mock("../../agents/pi-embedded-runner/model.js", () => ({ - resolveModel: mocks.resolveModel, -})); +vi.mock("../../agents/model-forward-compat.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveForwardCompatModel: mocks.resolveForwardCompatModel, + }; +}); import { modelsListCommand } from "./list.list-command.js"; describe("modelsListCommand forward-compat", () => { - it("does not mark configured codex spark as missing when resolveModel can build a fallback", async () => { + it("does not mark configured codex spark as missing when forward-compat can build a fallback", async () => { const runtime = { log: vi.fn(), error: vi.fn() }; await modelsListCommand({ json: true }, runtime as never); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 675e04adb87..7f77bb311f3 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -1,14 +1,16 @@ import type { Api, Model } from "@mariozechner/pi-ai"; +import type { ModelRegistry } from "../../agents/pi-model-discovery.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { ModelRow } from "./list.types.js"; import { ensureAuthProfileStore } from "../../agents/auth-profiles.js"; +import { resolveForwardCompatModel } from "../../agents/model-forward-compat.js"; import { parseModelRef } from "../../agents/model-selection.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"; +import { DEFAULT_PROVIDER, ensureFlagCompatibility, isLocalBaseUrl, modelKey } from "./shared.js"; export async function modelsListCommand( opts: { @@ -33,10 +35,12 @@ export async function modelsListCommand( })(); let models: Model[] = []; + let modelRegistry: ModelRegistry | undefined; let availableKeys: Set | undefined; let availabilityErrorMessage: string | undefined; try { const loaded = await loadModelRegistry(cfg); + modelRegistry = loaded.registry; models = loaded.models; availableKeys = loaded.availableKeys; availabilityErrorMessage = loaded.availabilityErrorMessage; @@ -58,22 +62,6 @@ export async function modelsListCommand( const rows: ModelRow[] = []; - const isLocalBaseUrl = (baseUrl: string) => { - try { - const url = new URL(baseUrl); - const host = url.hostname.toLowerCase(); - return ( - host === "localhost" || - host === "127.0.0.1" || - host === "0.0.0.0" || - host === "::1" || - host.endsWith(".local") - ); - } catch { - return false; - } - }; - if (opts.all) { const sorted = [...models].toSorted((a, b) => { const p = a.provider.localeCompare(b.provider); @@ -109,7 +97,18 @@ export async function modelsListCommand( if (providerFilter && entry.ref.provider.toLowerCase() !== providerFilter) { continue; } - const model = modelByKey.get(entry.key); + let model = modelByKey.get(entry.key); + if (!model && modelRegistry) { + const forwardCompat = resolveForwardCompatModel( + entry.ref.provider, + entry.ref.model, + modelRegistry, + ); + if (forwardCompat) { + model = forwardCompat; + modelByKey.set(entry.key, forwardCompat); + } + } 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 9f690021f3f..1edeb81980a 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -10,7 +10,10 @@ import { resolveAwsSdkEnvVarName, resolveEnvApiKey, } from "../../agents/model-auth.js"; -import { resolveForwardCompatModel } from "../../agents/model-forward-compat.js"; +import { + ANTIGRAVITY_OPUS_46_FORWARD_COMPAT_CANDIDATES, + resolveForwardCompatModel, +} from "../../agents/model-forward-compat.js"; import { ensureOpenClawModelsJson } from "../../agents/models-config.js"; import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import { @@ -18,23 +21,7 @@ import { MODEL_AVAILABILITY_UNAVAILABLE_CODE, shouldFallbackToAuthHeuristics, } from "./list.errors.js"; -import { modelKey } from "./shared.js"; - -const isLocalBaseUrl = (baseUrl: string) => { - try { - const url = new URL(baseUrl); - const host = url.hostname.toLowerCase(); - return ( - host === "localhost" || - host === "127.0.0.1" || - host === "0.0.0.0" || - host === "::1" || - host.endsWith(".local") - ); - } catch { - return false; - } -}; +import { isLocalBaseUrl, modelKey } from "./shared.js"; const hasAuthForProvider = ( provider: string, @@ -147,34 +134,17 @@ export async function loadModelRegistry(cfg: OpenClawConfig) { type SynthesizedForwardCompat = { key: string; - templatePrefixes: string[]; + templatePrefixes: readonly 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) { + for (const candidate of ANTIGRAVITY_OPUS_46_FORWARD_COMPAT_CANDIDATES) { const key = modelKey("google-antigravity", candidate.id); const hasForwardCompat = nextModels.some((model) => modelKey(model.provider, model.id) === key); if (hasForwardCompat) { @@ -196,7 +166,10 @@ function appendAntigravityForwardCompatModels( return { models: nextModels, synthesizedForwardCompat }; } -function hasAvailableTemplate(availableKeys: Set, templatePrefixes: string[]): boolean { +function hasAvailableTemplate( + availableKeys: Set, + templatePrefixes: readonly string[], +): boolean { for (const key of availableKeys) { if (templatePrefixes.some((prefix) => key.startsWith(prefix))) { return true; diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 99c64dff78f..b25be3a8926 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -43,6 +43,22 @@ export const formatMs = (value?: number | null) => { return `${Math.round(value / 100) / 10}s`; }; +export const isLocalBaseUrl = (baseUrl: string) => { + try { + const url = new URL(baseUrl); + const host = url.hostname.toLowerCase(); + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "0.0.0.0" || + host === "::1" || + host.endsWith(".local") + ); + } catch { + return false; + } +}; + export async function updateConfig( mutator: (cfg: OpenClawConfig) => OpenClawConfig, ): Promise { diff --git a/src/commands/provider-auth-helpers.ts b/src/commands/provider-auth-helpers.ts new file mode 100644 index 00000000000..1204a3ad395 --- /dev/null +++ b/src/commands/provider-auth-helpers.ts @@ -0,0 +1,82 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js"; +import { normalizeProviderId } from "../agents/model-selection.js"; + +export function resolveProviderMatch( + providers: ProviderPlugin[], + rawProvider?: string, +): ProviderPlugin | null { + const raw = rawProvider?.trim(); + if (!raw) { + return null; + } + const normalized = normalizeProviderId(raw); + return ( + providers.find((provider) => normalizeProviderId(provider.id) === normalized) ?? + providers.find( + (provider) => + provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false, + ) ?? + null + ); +} + +export function pickAuthMethod( + provider: ProviderPlugin, + rawMethod?: string, +): ProviderAuthMethod | null { + const raw = rawMethod?.trim(); + if (!raw) { + return null; + } + const normalized = raw.toLowerCase(); + return ( + provider.auth.find((method) => method.id.toLowerCase() === normalized) ?? + provider.auth.find((method) => method.label.toLowerCase() === normalized) ?? + null + ); +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +export function mergeConfigPatch(base: T, patch: unknown): T { + if (!isPlainRecord(base) || !isPlainRecord(patch)) { + return patch as T; + } + + const next: Record = { ...base }; + for (const [key, value] of Object.entries(patch)) { + const existing = next[key]; + if (isPlainRecord(existing) && isPlainRecord(value)) { + next[key] = mergeConfigPatch(existing, value); + } else { + next[key] = value; + } + } + return next as T; +} + +export function applyDefaultModel(cfg: OpenClawConfig, model: string): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[model] = models[model] ?? {}; + + const existingModel = cfg.agents?.defaults?.model; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + model: { + ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel + ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } + : undefined), + primary: model, + }, + }, + }, + }; +}