diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index 201474a6a3f..951cc02fd7c 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -3,6 +3,8 @@ "name": "Codex", "description": "Codex app-server harness and Codex-managed GPT model catalog.", "providers": ["codex"], + "providerDiscoveryEntry": "./provider-discovery.ts", + "syntheticAuthRefs": ["codex"], "nonSecretAuthMarkers": ["codex-app-server"], "activation": { "onAgentHarnesses": ["codex"] diff --git a/extensions/codex/provider-catalog.ts b/extensions/codex/provider-catalog.ts new file mode 100644 index 00000000000..33b6e0442b9 --- /dev/null +++ b/extensions/codex/provider-catalog.ts @@ -0,0 +1,83 @@ +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-shared"; +import type { CodexAppServerModel } from "./src/app-server/models.js"; + +export const CODEX_PROVIDER_ID = "codex"; +export const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; +export const CODEX_APP_SERVER_AUTH_MARKER = "codex-app-server"; + +const DEFAULT_CONTEXT_WINDOW = 272_000; +const DEFAULT_MAX_TOKENS = 128_000; + +export const FALLBACK_CODEX_MODELS = [ + { + id: "gpt-5.4", + model: "gpt-5.4", + displayName: "gpt-5.4", + description: "Latest frontier agentic coding model.", + isDefault: true, + inputModalities: ["text", "image"], + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + }, + { + id: "gpt-5.4-mini", + model: "gpt-5.4-mini", + displayName: "GPT-5.4-Mini", + description: "Smaller frontier agentic coding model.", + inputModalities: ["text", "image"], + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + }, + { + id: "gpt-5.2", + model: "gpt-5.2", + displayName: "gpt-5.2", + inputModalities: ["text", "image"], + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + }, +] satisfies CodexAppServerModel[]; + +export function buildCodexModelDefinition(model: { + id: string; + model: string; + displayName?: string; + inputModalities: string[]; + supportedReasoningEfforts: string[]; +}): ModelDefinitionConfig { + const id = model.id.trim() || model.model.trim(); + return { + id, + name: model.displayName?.trim() || id, + api: "openai-codex-responses", + reasoning: model.supportedReasoningEfforts.length > 0 || shouldDefaultToReasoningModel(id), + input: model.inputModalities.includes("image") ? ["text", "image"] : ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + compat: { + supportsReasoningEffort: model.supportedReasoningEfforts.length > 0, + supportsUsageInStreaming: true, + }, + }; +} + +export function buildCodexProviderConfig(models: CodexAppServerModel[]): ModelProviderConfig { + return { + baseUrl: CODEX_BASE_URL, + apiKey: CODEX_APP_SERVER_AUTH_MARKER, + auth: "token", + api: "openai-codex-responses", + models: models.map(buildCodexModelDefinition), + }; +} + +function shouldDefaultToReasoningModel(modelId: string): boolean { + const lower = modelId.toLowerCase(); + return ( + lower.startsWith("gpt-5") || + lower.startsWith("o1") || + lower.startsWith("o3") || + lower.startsWith("o4") + ); +} diff --git a/extensions/codex/provider-discovery.ts b/extensions/codex/provider-discovery.ts new file mode 100644 index 00000000000..8666fe73562 --- /dev/null +++ b/extensions/codex/provider-discovery.ts @@ -0,0 +1,45 @@ +import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { + buildCodexProviderConfig, + CODEX_APP_SERVER_AUTH_MARKER, + CODEX_PROVIDER_ID, + FALLBACK_CODEX_MODELS, +} from "./provider-catalog.js"; + +function resolveCodexPluginConfig(ctx: ProviderCatalogContext): unknown { + return (ctx.config.plugins?.entries as Record)?.codex + ?.config; +} + +async function runCodexCatalog(ctx: ProviderCatalogContext) { + const { buildCodexProviderCatalog } = await import("./provider.js"); + return await buildCodexProviderCatalog({ + env: ctx.env, + pluginConfig: resolveCodexPluginConfig(ctx), + }); +} + +export const codexProviderDiscovery: ProviderPlugin = { + id: CODEX_PROVIDER_ID, + label: "Codex", + docsPath: "/providers/models", + auth: [], + catalog: { + order: "late", + run: runCodexCatalog, + }, + staticCatalog: { + order: "late", + run: async () => ({ + provider: buildCodexProviderConfig(FALLBACK_CODEX_MODELS), + }), + }, + resolveSyntheticAuth: () => ({ + apiKey: CODEX_APP_SERVER_AUTH_MARKER, + source: "codex-app-server", + mode: "token", + }), +}; + +export default codexProviderDiscovery; diff --git a/extensions/codex/provider.test.ts b/extensions/codex/provider.test.ts index 044648dc5b3..44c75b11dc5 100644 --- a/extensions/codex/provider.test.ts +++ b/extensions/codex/provider.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "./prompt-overlay.js"; +import { codexProviderDiscovery } from "./provider-discovery.js"; import { buildCodexProvider, buildCodexProviderCatalog } from "./provider.js"; import { CodexAppServerClient } from "./src/app-server/client.js"; import { @@ -178,6 +179,25 @@ describe("codex provider", () => { }); }); + it("exposes a lightweight provider-discovery entry for model list/status", async () => { + expect(codexProviderDiscovery.id).toBe("codex"); + expect(codexProviderDiscovery.resolveSyntheticAuth?.({ provider: "codex" })).toEqual({ + apiKey: "codex-app-server", + source: "codex-app-server", + mode: "token", + }); + + const result = await codexProviderDiscovery.staticCatalog?.run({ + config: {}, + env: {}, + agentDir: "/tmp/openclaw-agent", + } as never); + + expect( + result && "provider" in result ? result.provider.models.map((model) => model.id) : [], + ).toEqual(["gpt-5.4", "gpt-5.4-mini", "gpt-5.2"]); + }); + it("adds the GPT-5 prompt overlay to Codex provider runs", () => { const provider = buildCodexProvider(); diff --git a/extensions/codex/provider.ts b/extensions/codex/provider.ts index 850641c08fc..59e3cb45356 100644 --- a/extensions/codex/provider.ts +++ b/extensions/codex/provider.ts @@ -1,26 +1,28 @@ import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeModelCompat, - type ModelDefinitionConfig, type ModelProviderConfig, type ProviderPlugin, } from "openclaw/plugin-sdk/provider-model-shared"; -import { - listCodexAppServerModels, - type CodexAppServerModel, - type CodexAppServerModelListResult, -} from "./harness.js"; import { resolveCodexSystemPromptContribution } from "./prompt-overlay.js"; +import { + buildCodexModelDefinition, + buildCodexProviderConfig, + CODEX_APP_SERVER_AUTH_MARKER, + CODEX_BASE_URL, + CODEX_PROVIDER_ID, + FALLBACK_CODEX_MODELS, +} from "./provider-catalog.js"; import { type CodexAppServerStartOptions, readCodexPluginConfig, resolveCodexAppServerRuntimeOptions, } from "./src/app-server/config.js"; +import type { + CodexAppServerModel, + CodexAppServerModelListResult, +} from "./src/app-server/models.js"; -const PROVIDER_ID = "codex"; -const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; -const DEFAULT_CONTEXT_WINDOW = 272_000; -const DEFAULT_MAX_TOKENS = 128_000; const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500; const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE"; @@ -42,36 +44,9 @@ type BuildCatalogOptions = { listModels?: CodexModelLister; }; -const FALLBACK_CODEX_MODELS = [ - { - id: "gpt-5.4", - model: "gpt-5.4", - displayName: "gpt-5.4", - description: "Latest frontier agentic coding model.", - isDefault: true, - inputModalities: ["text", "image"], - supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], - }, - { - id: "gpt-5.4-mini", - model: "gpt-5.4-mini", - displayName: "GPT-5.4-Mini", - description: "Smaller frontier agentic coding model.", - inputModalities: ["text", "image"], - supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], - }, - { - id: "gpt-5.2", - model: "gpt-5.2", - displayName: "gpt-5.2", - inputModalities: ["text", "image"], - supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], - }, -] satisfies CodexAppServerModel[]; - export function buildCodexProvider(options: BuildCodexProviderOptions = {}): ProviderPlugin { return { - id: PROVIDER_ID, + id: CODEX_PROVIDER_ID, label: "Codex", docsPath: "/providers/models", auth: [], @@ -84,9 +59,15 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro listModels: options.listModels, }), }, + staticCatalog: { + order: "late", + run: async () => ({ + provider: buildCodexProviderConfig(FALLBACK_CODEX_MODELS), + }), + }, resolveDynamicModel: (ctx) => resolveCodexDynamicModel(ctx.modelId), resolveSyntheticAuth: () => ({ - apiKey: "codex-app-server", + apiKey: CODEX_APP_SERVER_AUTH_MARKER, source: "codex-app-server", mode: "token", }), @@ -115,22 +96,13 @@ export async function buildCodexProviderCatalog( let discovered: CodexAppServerModel[] = []; if (config.discovery?.enabled !== false && !shouldSkipLiveDiscovery(options.env)) { discovered = await listModelsBestEffort({ - listModels: options.listModels ?? listCodexAppServerModels, + listModels: options.listModels ?? listCodexAppServerModelsLazy, timeoutMs, startOptions: appServer.start, }); } - const models = (discovered.length > 0 ? discovered : FALLBACK_CODEX_MODELS).map( - codexModelToDefinition, - ); return { - provider: { - baseUrl: CODEX_BASE_URL, - apiKey: "codex-app-server", - auth: "token", - api: "openai-codex-responses", - models, - }, + provider: buildCodexProviderConfig(discovered.length > 0 ? discovered : FALLBACK_CODEX_MODELS), }; } @@ -140,45 +112,17 @@ function resolveCodexDynamicModel(modelId: string): ProviderRuntimeModel | undef return undefined; } return normalizeModelCompat({ - ...buildModelDefinition({ + ...buildCodexModelDefinition({ id, model: id, inputModalities: ["text", "image"], supportedReasoningEfforts: shouldDefaultToReasoningModel(id) ? ["medium"] : [], }), - provider: PROVIDER_ID, + provider: CODEX_PROVIDER_ID, baseUrl: CODEX_BASE_URL, } as ProviderRuntimeModel); } -function codexModelToDefinition(model: CodexAppServerModel): ModelDefinitionConfig { - return buildModelDefinition(model); -} - -function buildModelDefinition(model: { - id: string; - model: string; - displayName?: string; - inputModalities: string[]; - supportedReasoningEfforts: string[]; -}): ModelDefinitionConfig { - const id = model.id.trim() || model.model.trim(); - return { - id, - name: model.displayName?.trim() || id, - api: "openai-codex-responses", - reasoning: model.supportedReasoningEfforts.length > 0 || shouldDefaultToReasoningModel(id), - input: model.inputModalities.includes("image") ? ["text", "image"] : ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, - compat: { - supportsReasoningEffort: model.supportedReasoningEfforts.length > 0, - supportsUsageInStreaming: true, - }, - }; -} - async function listModelsBestEffort(params: { listModels: CodexModelLister; timeoutMs: number; @@ -197,6 +141,16 @@ async function listModelsBestEffort(params: { } } +async function listCodexAppServerModelsLazy(options: { + timeoutMs: number; + limit?: number; + startOptions?: CodexAppServerStartOptions; + sharedClient?: boolean; +}): Promise { + const { listCodexAppServerModels } = await import("./src/app-server/models.js"); + return listCodexAppServerModels(options); +} + function normalizeTimeoutMs(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 21820438302..56870c1f630 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -83,13 +83,13 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ { commandPath: ["models", "list"], exact: true, - policy: { ensureCliPath: false }, + policy: { ensureCliPath: false, routeConfigGuard: "always" }, route: { id: "models-list" }, }, { commandPath: ["models", "status"], exact: true, - policy: { ensureCliPath: false }, + policy: { ensureCliPath: false, routeConfigGuard: "always" }, route: { id: "models-status" }, }, { commandPath: ["backup"], policy: { bypassConfigGuard: true } }, diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 3d3b5d4bf95..cae77e98557 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -36,6 +36,47 @@ vi.mock("../commands/models.js", () => ({ modelsSetCommand: mocks.noopAsync, modelsSetImageCommand: mocks.noopAsync, })); +vi.mock("../commands/models/list.js", () => ({ + modelsListCommand: mocks.noopAsync, + modelsStatusCommand: mocks.modelsStatusCommand, +})); +vi.mock("../commands/models/auth.js", () => ({ + modelsAuthAddCommand: mocks.noopAsync, + modelsAuthLoginCommand: mocks.modelsAuthLoginCommand, + modelsAuthPasteTokenCommand: mocks.noopAsync, + modelsAuthSetupTokenCommand: mocks.noopAsync, +})); +vi.mock("../commands/models/auth-order.js", () => ({ + modelsAuthOrderClearCommand: mocks.noopAsync, + modelsAuthOrderGetCommand: mocks.noopAsync, + modelsAuthOrderSetCommand: mocks.noopAsync, +})); +vi.mock("../commands/models/aliases.js", () => ({ + modelsAliasesAddCommand: mocks.noopAsync, + modelsAliasesListCommand: mocks.noopAsync, + modelsAliasesRemoveCommand: mocks.noopAsync, +})); +vi.mock("../commands/models/fallbacks.js", () => ({ + modelsFallbacksAddCommand: mocks.noopAsync, + modelsFallbacksClearCommand: mocks.noopAsync, + modelsFallbacksListCommand: mocks.noopAsync, + modelsFallbacksRemoveCommand: mocks.noopAsync, +})); +vi.mock("../commands/models/image-fallbacks.js", () => ({ + modelsImageFallbacksAddCommand: mocks.noopAsync, + modelsImageFallbacksClearCommand: mocks.noopAsync, + modelsImageFallbacksListCommand: mocks.noopAsync, + modelsImageFallbacksRemoveCommand: mocks.noopAsync, +})); +vi.mock("../commands/models/scan.js", () => ({ + modelsScanCommand: mocks.noopAsync, +})); +vi.mock("../commands/models/set.js", () => ({ + modelsSetCommand: mocks.noopAsync, +})); +vi.mock("../commands/models/set-image.js", () => ({ + modelsSetImageCommand: mocks.noopAsync, +})); describe("models cli", () => { beforeEach(() => { diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index c8fd0b0676a..814e1d613ce 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -1,29 +1,5 @@ import type { Command } from "commander"; -import { - modelsAliasesAddCommand, - modelsAliasesListCommand, - modelsAliasesRemoveCommand, - modelsAuthAddCommand, - modelsAuthLoginCommand, - modelsAuthOrderClearCommand, - modelsAuthOrderGetCommand, - modelsAuthOrderSetCommand, - modelsAuthPasteTokenCommand, - modelsAuthSetupTokenCommand, - modelsFallbacksAddCommand, - modelsFallbacksClearCommand, - modelsFallbacksListCommand, - modelsFallbacksRemoveCommand, - modelsImageFallbacksAddCommand, - modelsImageFallbacksClearCommand, - modelsImageFallbacksListCommand, - modelsImageFallbacksRemoveCommand, - modelsListCommand, - modelsScanCommand, - modelsSetCommand, - modelsSetImageCommand, - modelsStatusCommand, -} from "../commands/models.js"; +import { modelsListCommand, modelsStatusCommand } from "../commands/models/list.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; @@ -119,6 +95,7 @@ export function registerModelsCli(program: Command) { .argument("", "Model id or alias") .action(async (model: string) => { await runModelsCommand(async () => { + const { modelsSetCommand } = await import("../commands/models/set.js"); await modelsSetCommand(model, defaultRuntime); }); }); @@ -129,6 +106,7 @@ export function registerModelsCli(program: Command) { .argument("", "Model id or alias") .action(async (model: string) => { await runModelsCommand(async () => { + const { modelsSetImageCommand } = await import("../commands/models/set-image.js"); await modelsSetImageCommand(model, defaultRuntime); }); }); @@ -142,6 +120,7 @@ export function registerModelsCli(program: Command) { .option("--plain", "Plain output", false) .action(async (opts) => { await runModelsCommand(async () => { + const { modelsAliasesListCommand } = await import("../commands/models/aliases.js"); await modelsAliasesListCommand(opts, defaultRuntime); }); }); @@ -153,6 +132,7 @@ export function registerModelsCli(program: Command) { .argument("", "Model id or alias") .action(async (alias: string, model: string) => { await runModelsCommand(async () => { + const { modelsAliasesAddCommand } = await import("../commands/models/aliases.js"); await modelsAliasesAddCommand(alias, model, defaultRuntime); }); }); @@ -163,6 +143,7 @@ export function registerModelsCli(program: Command) { .argument("", "Alias name") .action(async (alias: string) => { await runModelsCommand(async () => { + const { modelsAliasesRemoveCommand } = await import("../commands/models/aliases.js"); await modelsAliasesRemoveCommand(alias, defaultRuntime); }); }); @@ -176,6 +157,7 @@ export function registerModelsCli(program: Command) { .option("--plain", "Plain output", false) .action(async (opts) => { await runModelsCommand(async () => { + const { modelsFallbacksListCommand } = await import("../commands/models/fallbacks.js"); await modelsFallbacksListCommand(opts, defaultRuntime); }); }); @@ -186,6 +168,7 @@ export function registerModelsCli(program: Command) { .argument("", "Model id or alias") .action(async (model: string) => { await runModelsCommand(async () => { + const { modelsFallbacksAddCommand } = await import("../commands/models/fallbacks.js"); await modelsFallbacksAddCommand(model, defaultRuntime); }); }); @@ -196,6 +179,7 @@ export function registerModelsCli(program: Command) { .argument("", "Model id or alias") .action(async (model: string) => { await runModelsCommand(async () => { + const { modelsFallbacksRemoveCommand } = await import("../commands/models/fallbacks.js"); await modelsFallbacksRemoveCommand(model, defaultRuntime); }); }); @@ -205,6 +189,7 @@ export function registerModelsCli(program: Command) { .description("Clear all fallback models") .action(async () => { await runModelsCommand(async () => { + const { modelsFallbacksClearCommand } = await import("../commands/models/fallbacks.js"); await modelsFallbacksClearCommand(defaultRuntime); }); }); @@ -220,6 +205,8 @@ export function registerModelsCli(program: Command) { .option("--plain", "Plain output", false) .action(async (opts) => { await runModelsCommand(async () => { + const { modelsImageFallbacksListCommand } = + await import("../commands/models/image-fallbacks.js"); await modelsImageFallbacksListCommand(opts, defaultRuntime); }); }); @@ -230,6 +217,8 @@ export function registerModelsCli(program: Command) { .argument("", "Model id or alias") .action(async (model: string) => { await runModelsCommand(async () => { + const { modelsImageFallbacksAddCommand } = + await import("../commands/models/image-fallbacks.js"); await modelsImageFallbacksAddCommand(model, defaultRuntime); }); }); @@ -240,6 +229,8 @@ export function registerModelsCli(program: Command) { .argument("", "Model id or alias") .action(async (model: string) => { await runModelsCommand(async () => { + const { modelsImageFallbacksRemoveCommand } = + await import("../commands/models/image-fallbacks.js"); await modelsImageFallbacksRemoveCommand(model, defaultRuntime); }); }); @@ -249,6 +240,8 @@ export function registerModelsCli(program: Command) { .description("Clear all image fallback models") .action(async () => { await runModelsCommand(async () => { + const { modelsImageFallbacksClearCommand } = + await import("../commands/models/image-fallbacks.js"); await modelsImageFallbacksClearCommand(defaultRuntime); }); }); @@ -270,6 +263,7 @@ export function registerModelsCli(program: Command) { .option("--json", "Output JSON", false) .action(async (opts) => { await runModelsCommand(async () => { + const { modelsScanCommand } = await import("../commands/models/scan.js"); await modelsScanCommand(opts, defaultRuntime); }); }); @@ -298,6 +292,7 @@ export function registerModelsCli(program: Command) { .description("Interactive auth helper (provider auth or paste token)") .action(async () => { await runModelsCommand(async () => { + const { modelsAuthAddCommand } = await import("../commands/models/auth.js"); await modelsAuthAddCommand({}, defaultRuntime); }); }); @@ -310,6 +305,7 @@ export function registerModelsCli(program: Command) { .option("--set-default", "Apply the provider's default model recommendation", false) .action(async (opts) => { await runModelsCommand(async () => { + const { modelsAuthLoginCommand } = await import("../commands/models/auth.js"); await modelsAuthLoginCommand( { provider: opts.provider as string | undefined, @@ -328,6 +324,7 @@ export function registerModelsCli(program: Command) { .option("--yes", "Skip confirmation", false) .action(async (opts) => { await runModelsCommand(async () => { + const { modelsAuthSetupTokenCommand } = await import("../commands/models/auth.js"); await modelsAuthSetupTokenCommand( { provider: opts.provider as string | undefined, @@ -349,6 +346,7 @@ export function registerModelsCli(program: Command) { ) .action(async (opts) => { await runModelsCommand(async () => { + const { modelsAuthPasteTokenCommand } = await import("../commands/models/auth.js"); await modelsAuthPasteTokenCommand( { provider: opts.provider as string | undefined, @@ -366,6 +364,7 @@ export function registerModelsCli(program: Command) { .option("--yes", "Overwrite existing profile without prompting", false) .action(async (opts) => { await runModelsCommand(async () => { + const { modelsAuthLoginCommand } = await import("../commands/models/auth.js"); await modelsAuthLoginCommand( { provider: "github-copilot", @@ -389,6 +388,7 @@ export function registerModelsCli(program: Command) { const agent = resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); await runModelsCommand(async () => { + const { modelsAuthOrderGetCommand } = await import("../commands/models/auth-order.js"); await modelsAuthOrderGetCommand( { provider: opts.provider as string, @@ -410,6 +410,7 @@ export function registerModelsCli(program: Command) { const agent = resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); await runModelsCommand(async () => { + const { modelsAuthOrderSetCommand } = await import("../commands/models/auth-order.js"); await modelsAuthOrderSetCommand( { provider: opts.provider as string, @@ -430,6 +431,7 @@ export function registerModelsCli(program: Command) { const agent = resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); await runModelsCommand(async () => { + const { modelsAuthOrderClearCommand } = await import("../commands/models/auth-order.js"); await modelsAuthOrderClearCommand( { provider: opts.provider as string, diff --git a/src/cli/program/routed-command-definitions.ts b/src/cli/program/routed-command-definitions.ts index ee356251ae3..8557039ac19 100644 --- a/src/cli/program/routed-command-definitions.ts +++ b/src/cli/program/routed-command-definitions.ts @@ -17,7 +17,7 @@ type RouteArgParser = (argv: string[]) => TArgs | null; type ParsedRouteArgs> = Exclude, null>; type ConfigCliModule = typeof import("../config-cli.js"); -type ModelsCommandsModule = typeof import("../../commands/models.js"); +type ModelsListModule = typeof import("../../commands/models/list.js"); export type RoutedCommandDefinition> = { parseArgs: TParse; @@ -36,16 +36,16 @@ function defineRoutedCommand>( } let configCliPromise: Promise | undefined; -let modelsCommandsPromise: Promise | undefined; +let modelsListPromise: Promise | undefined; function loadConfigCli(): Promise { configCliPromise ??= import("../config-cli.js"); return configCliPromise; } -function loadModelsCommands(): Promise { - modelsCommandsPromise ??= import("../../commands/models.js"); - return modelsCommandsPromise; +function loadModelsList(): Promise { + modelsListPromise ??= import("../../commands/models/list.js"); + return modelsListPromise; } export const routedCommandDefinitions = { @@ -114,14 +114,14 @@ export const routedCommandDefinitions = { "models-list": defineRoutedCommand({ parseArgs: parseModelsListRouteArgs, runParsedArgs: async (args) => { - const { modelsListCommand } = await loadModelsCommands(); + const { modelsListCommand } = await loadModelsList(); await modelsListCommand(args, defaultRuntime); }, }), "models-status": defineRoutedCommand({ parseArgs: parseModelsStatusRouteArgs, runParsedArgs: async (args) => { - const { modelsStatusCommand } = await loadModelsCommands(); + const { modelsStatusCommand } = await loadModelsList(); await modelsStatusCommand(args, defaultRuntime); }, }), diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index faa1ae02827..631d564f865 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -19,6 +19,10 @@ vi.mock("../../commands/models.js", () => ({ modelsListCommand: modelsListCommandMock, modelsStatusCommand: modelsStatusCommandMock, })); +vi.mock("../../commands/models/list.js", () => ({ + modelsListCommand: modelsListCommandMock, + modelsStatusCommand: modelsStatusCommandMock, +})); vi.mock("../daemon-cli/status.js", () => ({ runDaemonStatus: runDaemonStatusMock, diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts index 50afd510213..ddf1067de15 100644 --- a/src/commands/models/list.auth-overview.ts +++ b/src/commands/models/list.auth-overview.ts @@ -47,6 +47,7 @@ export function resolveProviderAuthOverview(params: { cfg: OpenClawConfig; store: AuthProfileStore; modelsPath: string; + syntheticAuth?: { value: string; source: string }; }): ProviderAuthOverview { const { provider, cfg, store } = params; const now = Date.now(); @@ -126,6 +127,9 @@ export function resolveProviderAuthOverview(params: { if (usableCustomKey) { return { kind: "models.json", detail: formatMarkerOrSecret(usableCustomKey.apiKey) }; } + if (params.syntheticAuth) { + return { kind: "synthetic", detail: params.syntheticAuth.source }; + } return { kind: "missing", detail: "missing" }; })(); @@ -160,5 +164,6 @@ export function resolveProviderAuthOverview(params: { }, } : {}), + ...(params.syntheticAuth ? { syntheticAuth: params.syntheticAuth } : {}), }; } diff --git a/src/commands/models/list.configured.test.ts b/src/commands/models/list.configured.test.ts new file mode 100644 index 00000000000..04fb661c040 --- /dev/null +++ b/src/commands/models/list.configured.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../agents/provider-model-normalization.runtime.js", () => ({ + normalizeProviderModelIdWithRuntime: vi.fn(() => { + throw new Error("runtime model normalization should not load for models list entries"); + }), +})); + +import { resolveConfiguredEntries } from "./list.configured.js"; + +describe("resolveConfiguredEntries", () => { + it("parses configured models without loading provider-runtime normalization", () => { + const { entries } = resolveConfiguredEntries({ + agents: { + defaults: { + model: { primary: "codex/gpt-5.4", fallbacks: ["codex/gpt-5.4-mini"] }, + models: { + "codex/gpt-5.4": { alias: "Codex" }, + "codex/gpt-5.4-mini": {}, + }, + }, + }, + models: { providers: {} }, + }); + + expect(entries.map((entry) => entry.key)).toEqual(["codex/gpt-5.4", "codex/gpt-5.4-mini"]); + expect(entries[0]?.tags).toEqual(new Set(["default", "configured"])); + expect(entries[0]?.aliases).toEqual(["Codex"]); + expect(entries[1]?.tags).toEqual(new Set(["fallback#1", "configured"])); + }); +}); diff --git a/src/commands/models/list.configured.ts b/src/commands/models/list.configured.ts index b220d506eee..7e07d110528 100644 --- a/src/commands/models/list.configured.ts +++ b/src/commands/models/list.configured.ts @@ -12,15 +12,19 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ConfiguredEntry } from "./list.types.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER, modelKey } from "./shared.js"; +const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; + export function resolveConfiguredEntries(cfg: OpenClawConfig) { const resolvedDefault = resolveConfiguredModelRef({ cfg, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, + ...DISPLAY_MODEL_PARSE_OPTIONS, }); const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER, + ...DISPLAY_MODEL_PARSE_OPTIONS, }); const order: string[] = []; const tagsByKey = new Map>(); @@ -44,6 +48,7 @@ export function resolveConfiguredEntries(cfg: OpenClawConfig) { raw, defaultProvider: DEFAULT_PROVIDER, aliasIndex, + ...DISPLAY_MODEL_PARSE_OPTIONS, }); if (resolved) { addEntry(resolved.ref, tag); @@ -69,7 +74,7 @@ export function resolveConfiguredEntries(cfg: OpenClawConfig) { }); for (const key of Object.keys(cfg.agents?.defaults?.models ?? {})) { - const parsed = parseModelRef(key, DEFAULT_PROVIDER); + const parsed = parseModelRef(key, DEFAULT_PROVIDER, DISPLAY_MODEL_PARSE_OPTIONS); if (!parsed) { continue; } 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 19f9034a00b..e8cb02333d0 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -66,6 +66,7 @@ const mocks = vi.hoisted(() => { printModelTable: vi.fn(), listProfilesForProvider: vi.fn(), resolveModelWithRegistry: vi.fn(), + resolveRuntimeSyntheticAuthProviderRefs: vi.fn(), }; }); @@ -100,6 +101,7 @@ function resetMocks() { mocks.printModelTable.mockReset(); mocks.listProfilesForProvider.mockReturnValue([]); mocks.resolveModelWithRegistry.mockReturnValue({ ...OPENAI_CODEX_MODEL }); + mocks.resolveRuntimeSyntheticAuthProviderRefs.mockReturnValue([]); } function createRuntime() { @@ -151,6 +153,10 @@ function installModelsListCommandForwardCompatMocks() { resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined), hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false), })); + + vi.doMock("../../plugins/synthetic-auth.runtime.js", () => ({ + resolveRuntimeSyntheticAuthProviderRefs: mocks.resolveRuntimeSyntheticAuthProviderRefs, + })); } beforeAll(async () => { @@ -366,6 +372,41 @@ describe("modelsListCommand forward-compat", () => { }); describe("--all catalog supplementation", () => { + it("uses the provider catalog fast path for Codex provider lists", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadProviderCatalogModelsForList.mockResolvedValueOnce([ + { + provider: "codex", + id: "gpt-5.4", + name: "gpt-5.4", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text", "image"], + contextWindow: 272_000, + maxTokens: 128_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ]); + mocks.resolveRuntimeSyntheticAuthProviderRefs.mockReturnValueOnce(["codex"]); + const runtime = createRuntime(); + + await modelsListCommand({ all: true, provider: "codex", json: true }, runtime as never); + + expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(mocks.loadProviderCatalogModelsForList).toHaveBeenCalledWith({ + cfg: mocks.resolvedConfig, + agentDir: "/tmp/openclaw-agent", + providerFilter: "codex", + }); + expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([ + expect.objectContaining({ + key: "codex/gpt-5.4", + available: true, + }), + ]); + }); + it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); mocks.loadModelRegistry.mockResolvedValueOnce({ diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 3c00dee3bbc..ab5768c8d15 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -8,6 +8,7 @@ import { appendCatalogSupplementRows, appendConfiguredRows, appendDiscoveredRows, + appendProviderCatalogRows, loadListModelRegistry, } from "./list.rows.js"; import { printModelTable } from "./list.table.js"; @@ -15,6 +16,8 @@ import type { ModelRow } from "./list.types.js"; import { loadModelsConfigWithSource } from "./load-config.js"; import { DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; +const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; + export async function modelsListCommand( opts: { all?: boolean; @@ -39,7 +42,7 @@ export async function modelsListCommand( if (!raw) { return undefined; } - const parsed = parseModelRef(`${raw}/_`, DEFAULT_PROVIDER); + const parsed = parseModelRef(`${raw}/_`, DEFAULT_PROVIDER, DISPLAY_MODEL_PARSE_OPTIONS); return parsed?.provider ?? normalizeLowercaseStringOrEmpty(raw); })(); @@ -47,15 +50,18 @@ export async function modelsListCommand( let discoveredKeys = new Set(); let availableKeys: Set | undefined; let availabilityErrorMessage: string | undefined; + const useProviderCatalogFastPath = Boolean(opts.all && providerFilter === "codex"); try { // Keep command behavior explicit: sync models.json from the source config // before building the read-only model registry view. - await ensureOpenClawModelsJson(sourceConfig ?? cfg); - const loaded = await loadListModelRegistry(cfg, { sourceConfig }); - modelRegistry = loaded.registry; - discoveredKeys = loaded.discoveredKeys; - availableKeys = loaded.availableKeys; - availabilityErrorMessage = loaded.availabilityErrorMessage; + if (!useProviderCatalogFastPath) { + await ensureOpenClawModelsJson(sourceConfig ?? cfg); + const loaded = await loadListModelRegistry(cfg, { sourceConfig }); + modelRegistry = loaded.registry; + discoveredKeys = loaded.discoveredKeys; + availableKeys = loaded.availableKeys; + availabilityErrorMessage = loaded.availabilityErrorMessage; + } } catch (err) { runtime.error(`Model registry unavailable:\n${formatErrorWithStack(err)}`); process.exitCode = 1; @@ -81,6 +87,7 @@ export async function modelsListCommand( provider: providerFilter, local: opts.local, }, + skipRuntimeModelSuppression: useProviderCatalogFastPath, }; if (opts.all) { @@ -97,6 +104,12 @@ export async function modelsListCommand( context: rowContext, seenKeys, }); + } else if (useProviderCatalogFastPath) { + await appendProviderCatalogRows({ + rows, + context: rowContext, + seenKeys, + }); } } else { const registry = modelRegistry; diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 3cb364bff4e..3ae30979ab4 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -3,6 +3,7 @@ import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveRuntimeSyntheticAuthProviderRefs } from "../../plugins/synthetic-auth.runtime.js"; import { formatErrorWithStack, MODEL_AVAILABILITY_UNAVAILABLE_CODE, @@ -41,6 +42,9 @@ const hasAuthForProvider = ( if (hasUsableCustomProviderApiKey(cfg, provider)) { return true; } + if (resolveRuntimeSyntheticAuthProviderRefs().includes(provider)) { + return true; + } return false; }; diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts new file mode 100644 index 00000000000..df8af8b264a --- /dev/null +++ b/src/commands/models/list.rows.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; +import type { ModelRow } from "./list.types.js"; + +const mocks = vi.hoisted(() => ({ + shouldSuppressBuiltInModel: vi.fn(() => { + throw new Error("runtime model suppression should be skipped"); + }), + loadProviderCatalogModelsForList: vi.fn().mockResolvedValue([ + { + id: "gpt-5.4", + name: "gpt-5.4", + provider: "codex", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text"], + }, + ]), + listProfilesForProvider: vi.fn().mockReturnValue(["codex:synthetic"]), +})); + +vi.mock("../../agents/model-suppression.js", () => ({ + shouldSuppressBuiltInModel: mocks.shouldSuppressBuiltInModel, +})); + +vi.mock("./list.runtime.js", () => ({ + loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList, + listProfilesForProvider: mocks.listProfilesForProvider, + resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined), + resolveEnvApiKey: vi.fn().mockReturnValue(null), + hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false), +})); + +vi.mock("../../plugins/synthetic-auth.runtime.js", () => ({ + resolveRuntimeSyntheticAuthProviderRefs: vi.fn().mockReturnValue([]), +})); + +import { appendProviderCatalogRows } from "./list.rows.js"; + +describe("appendProviderCatalogRows", () => { + it("can skip runtime model-suppression hooks for provider-catalog fast paths", async () => { + const rows: ModelRow[] = []; + const authStore: AuthProfileStore = { + version: 1, + profiles: { + "codex:synthetic": { + type: "token", + provider: "codex", + token: "codex-app-server", + }, + }, + order: {}, + }; + + await appendProviderCatalogRows({ + rows, + seenKeys: new Set(), + context: { + cfg: { + agents: { defaults: { model: { primary: "codex/gpt-5.4" } } }, + models: { providers: {} }, + }, + agentDir: "/tmp/openclaw-agent", + authStore, + configuredByKey: new Map(), + discoveredKeys: new Set(), + filter: { provider: "codex", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(mocks.shouldSuppressBuiltInModel).not.toHaveBeenCalled(); + expect(rows).toMatchObject([ + { + key: "codex/gpt-5.4", + available: true, + missing: false, + }, + ]); + }); +}); diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index e471d0030a6..f98b7db891e 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -28,6 +28,7 @@ type RowBuilderContext = { configuredByKey: ConfiguredByKey; discoveredKeys: Set; filter: RowFilter; + skipRuntimeModelSuppression?: boolean; }; function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl?: string }) { @@ -59,6 +60,21 @@ function buildRow(params: { }); } +function shouldSuppressListModel(params: { + model: { provider: string; id: string; baseUrl?: string }; + context: RowBuilderContext; +}): boolean { + if (params.context.skipRuntimeModelSuppression) { + return false; + } + return shouldSuppressBuiltInModel({ + provider: params.model.provider, + id: params.model.id, + baseUrl: params.model.baseUrl, + config: params.context.cfg, + }); +} + export async function loadListModelRegistry( cfg: OpenClawConfig, opts?: { sourceConfig?: OpenClawConfig }, @@ -85,14 +101,7 @@ export function appendDiscoveredRows(params: { }); for (const model of sorted) { - if ( - shouldSuppressBuiltInModel({ - provider: model.provider, - id: model.id, - baseUrl: model.baseUrl, - config: params.context.cfg, - }) - ) { + if (shouldSuppressListModel({ model, context: params.context })) { continue; } if (!matchesRowFilter(params.context.filter, model)) { @@ -139,14 +148,7 @@ export async function appendCatalogSupplementRows(params: { if (!model || !matchesRowFilter(params.context.filter, model)) { continue; } - if ( - shouldSuppressBuiltInModel({ - provider: model.provider, - id: model.id, - baseUrl: model.baseUrl, - config: params.context.cfg, - }) - ) { + if (shouldSuppressListModel({ model, context: params.context })) { continue; } params.rows.push( @@ -164,6 +166,18 @@ export async function appendCatalogSupplementRows(params: { return; } + await appendProviderCatalogRows({ + rows: params.rows, + context: params.context, + seenKeys: params.seenKeys, + }); +} + +export async function appendProviderCatalogRows(params: { + rows: ModelRow[]; + context: RowBuilderContext; + seenKeys: Set; +}): Promise { for (const model of await loadProviderCatalogModelsForList({ cfg: params.context.cfg, agentDir: params.context.agentDir, @@ -172,14 +186,7 @@ export async function appendCatalogSupplementRows(params: { if (!matchesRowFilter(params.context.filter, model)) { continue; } - if ( - shouldSuppressBuiltInModel({ - provider: model.provider, - id: model.id, - baseUrl: model.baseUrl, - config: params.context.cfg, - }) - ) { + if (shouldSuppressListModel({ model, context: params.context })) { continue; } const key = modelKey(model.provider, model.id); @@ -223,15 +230,7 @@ export function appendConfiguredRows(params: { if (params.context.filter.local && !model) { continue; } - if ( - model && - shouldSuppressBuiltInModel({ - provider: model.provider, - id: model.id, - baseUrl: model.baseUrl, - config: params.context.cfg, - }) - ) { + if (model && shouldSuppressListModel({ model, context: params.context })) { continue; } params.rows.push( diff --git a/src/commands/models/list.runtime.ts b/src/commands/models/list.runtime.ts index 8548faec3ef..ad446fa4b78 100644 --- a/src/commands/models/list.runtime.ts +++ b/src/commands/models/list.runtime.ts @@ -1,4 +1,4 @@ -export { ensureAuthProfileStore } from "../../agents/auth-profiles.runtime.js"; +export { ensureAuthProfileStoreWithoutExternalProfiles as ensureAuthProfileStore } from "../../agents/auth-profiles/store.js"; export { ensureOpenClawModelsJson } from "../../agents/models-config.js"; export { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; export { listProfilesForProvider } from "../../agents/auth-profiles.js"; diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index a665ffed09f..ed5ac8b1ad9 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -11,7 +11,7 @@ import { formatRemainingShort, } from "../../agents/auth-health.js"; import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles/paths.js"; -import { ensureAuthProfileStore } from "../../agents/auth-profiles/store.js"; +import { ensureAuthProfileStoreWithoutExternalProfiles as ensureAuthProfileStore } from "../../agents/auth-profiles/store.js"; import { resolveProfileUnusableUntilForDisplay } from "../../agents/auth-profiles/usage.js"; import { resolveProviderEnvApiKeyCandidates } from "../../agents/model-auth-env-vars.js"; import { resolveEnvApiKey } from "../../agents/model-auth.js"; @@ -21,7 +21,6 @@ import { normalizeProviderId, parseModelRef, resolveConfiguredModelRef, - resolveDefaultModelForAgent, resolveModelRefFromString, } from "../../agents/model-selection.js"; import { createConfigIO } from "../../config/config.js"; @@ -30,6 +29,7 @@ import { resolveAgentModelPrimaryValue, } from "../../config/model-input.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; +import { resolveRuntimeSyntheticAuthProviderRefs } from "../../plugins/synthetic-auth.runtime.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { colorize, theme } from "../../terminal/theme.js"; @@ -55,6 +55,8 @@ let progressRuntimePromise: Promise | undefined; let terminalTableRuntimePromise: Promise | undefined; let listProbeRuntimePromise: Promise | undefined; +const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; + function loadProviderUsageRuntime(): Promise { providerUsageRuntimePromise ??= import("../../infra/provider-usage.js"); return providerUsageRuntimePromise; @@ -102,13 +104,30 @@ export async function modelsStatusCommand( const agentFallbacksOverride = agentId ? resolveAgentModelFallbacksOverride(cfg, agentId) : undefined; - const resolved = agentId - ? resolveDefaultModelForAgent({ cfg, agentId }) - : resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); + const resolvedConfig = + agentModelPrimary && agentModelPrimary.length > 0 + ? { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { + ...(typeof cfg.agents?.defaults?.model === "object" + ? cfg.agents.defaults.model + : {}), + primary: agentModelPrimary, + }, + }, + }, + } + : cfg; + const resolved = resolveConfiguredModelRef({ + cfg: resolvedConfig, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + ...DISPLAY_MODEL_PARSE_OPTIONS, + }); const rawDefaultsModel = resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model) ?? ""; const rawModel = agentModelPrimary ?? rawDefaultsModel; @@ -146,13 +165,13 @@ export async function modelsStatusCommand( const providersFromModels = new Set(); const providersInUse = new Set(); for (const raw of [defaultLabel, ...fallbacks, imageModel, ...imageFallbacks, ...allowed]) { - const parsed = parseModelRef(raw ?? "", DEFAULT_PROVIDER); + const parsed = parseModelRef(raw ?? "", DEFAULT_PROVIDER, DISPLAY_MODEL_PARSE_OPTIONS); if (parsed?.provider) { providersFromModels.add(normalizeProviderId(parsed.provider)); } } for (const raw of [defaultLabel, ...fallbacks, imageModel, ...imageFallbacks]) { - const parsed = parseModelRef(raw ?? "", DEFAULT_PROVIDER); + const parsed = parseModelRef(raw ?? "", DEFAULT_PROVIDER, DISPLAY_MODEL_PARSE_OPTIONS); if (parsed?.provider) { providersInUse.add(normalizeProviderId(parsed.provider)); } @@ -166,6 +185,15 @@ export async function modelsStatusCommand( providersFromEnv.add(provider); } } + const syntheticAuthByProvider = new Map( + resolveRuntimeSyntheticAuthProviderRefs().map((provider) => [ + normalizeProviderId(provider), + { + value: "plugin-owned", + source: "plugin synthetic auth", + }, + ]), + ); const providers = Array.from( new Set([ @@ -184,14 +212,27 @@ export async function modelsStatusCommand( shouldEnableShellEnvFallback(process.env) || cfg.env?.shellEnv?.enabled === true; const providerAuth = providers - .map((provider) => resolveProviderAuthOverview({ provider, cfg, store, modelsPath })) + .map((provider) => + resolveProviderAuthOverview({ + provider, + cfg, + store, + modelsPath, + syntheticAuth: syntheticAuthByProvider.get(provider), + }), + ) .filter((entry) => { - const hasAny = entry.profiles.count > 0 || Boolean(entry.env) || Boolean(entry.modelsJson); + const hasAny = + entry.profiles.count > 0 || + Boolean(entry.env) || + Boolean(entry.modelsJson) || + Boolean(entry.syntheticAuth); return hasAny; }); const providerAuthMap = new Map(providerAuth.map((entry) => [entry.provider, entry])); const missingProvidersInUse = Array.from(providersInUse) .filter((provider) => !providerAuthMap.has(provider)) + .filter((provider) => !syntheticAuthByProvider.has(provider)) .filter((provider) => !isCliProvider(provider, cfg)) .toSorted((a, b) => a.localeCompare(b)); @@ -218,7 +259,11 @@ export async function modelsStatusCommand( throw new Error("--probe-max-tokens must be > 0."); } - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER }); + const aliasIndex = buildModelAliasIndex({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + ...DISPLAY_MODEL_PARSE_OPTIONS, + }); const rawCandidates = [ rawModel || resolvedLabel, ...fallbacks, @@ -233,6 +278,7 @@ export async function modelsStatusCommand( raw: raw ?? "", defaultProvider: DEFAULT_PROVIDER, aliasIndex, + ...DISPLAY_MODEL_PARSE_OPTIONS, })?.ref, ) .filter((ref): ref is { provider: string; model: string } => Boolean(ref)); @@ -530,6 +576,14 @@ export async function modelsStatusCommand( ), ); } + if (entry.syntheticAuth) { + bits.push( + formatKeyValue( + "synthetic", + `${entry.syntheticAuth.value}${separator}${formatKeyValue("source", entry.syntheticAuth.source)}`, + ), + ); + } runtime.log(`- ${theme.heading(entry.provider)} ${bits.join(separator)}`); } diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 6b00bb47abd..357d5d991ba 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -120,6 +120,7 @@ const mocks = vi.hoisted(() => { env: { shellEnv: { enabled: true } }, }), loadProviderUsageSummary: vi.fn().mockResolvedValue(undefined), + resolveRuntimeSyntheticAuthProviderRefs: vi.fn().mockReturnValue([]), }; }); @@ -145,6 +146,7 @@ vi.mock("../../agents/auth-profiles/profiles.js", () => ({ })); vi.mock("../../agents/auth-profiles/store.js", () => ({ ensureAuthProfileStore: mocks.ensureAuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles: mocks.ensureAuthProfileStore, })); vi.mock("../../agents/auth-profiles/usage.js", () => ({ resolveProfileUnusableUntilForDisplay: mocks.resolveProfileUnusableUntilForDisplay, @@ -206,6 +208,9 @@ vi.mock("../../infra/provider-usage.js", () => ({ loadProviderUsageSummary: mocks.loadProviderUsageSummary, resolveUsageProviderId: vi.fn((providerId: string) => providerId), })); +vi.mock("../../plugins/synthetic-auth.runtime.js", () => ({ + resolveRuntimeSyntheticAuthProviderRefs: mocks.resolveRuntimeSyntheticAuthProviderRefs, +})); import { modelsStatusCommand } from "./list.status-command.js"; @@ -415,6 +420,69 @@ describe("modelsStatusCommand auth overview", () => { } }); + it("treats plugin-owned synthetic auth as usable for models in use", async () => { + const localRuntime = createRuntime(); + const originalLoadConfig = mocks.loadConfig.getMockImplementation(); + const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); + const originalSyntheticImpl = + mocks.resolveRuntimeSyntheticAuthProviderRefs.getMockImplementation(); + mocks.loadConfig.mockReturnValue({ + agents: { + defaults: { + model: { primary: "codex/gpt-5.4", fallbacks: [] }, + models: { "codex/gpt-5.4": {} }, + }, + }, + models: { providers: {} }, + env: { shellEnv: { enabled: false } }, + }); + mocks.resolveEnvApiKey.mockImplementation(() => null); + mocks.resolveRuntimeSyntheticAuthProviderRefs.mockReturnValue(["codex", "unused-synthetic"]); + + try { + await modelsStatusCommand({ json: true }, localRuntime as never); + const payload = JSON.parse(String((localRuntime.log as Mock).mock.calls[0]?.[0])); + const providers = payload.auth.providers as Array<{ + provider: string; + syntheticAuth?: { value: string; source: string }; + effective?: { kind: string; detail?: string }; + }>; + expect(payload.auth.missingProvidersInUse).toEqual([]); + expect(providers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider: "codex", + syntheticAuth: { + value: "plugin-owned", + source: "plugin synthetic auth", + }, + effective: { + kind: "synthetic", + detail: "plugin synthetic auth", + }, + }), + ]), + ); + expect(providers.some((entry) => entry.provider === "unused-synthetic")).toBe(false); + } finally { + if (originalLoadConfig) { + mocks.loadConfig.mockImplementation(originalLoadConfig); + } + if (originalEnvImpl) { + mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); + } else if (defaultResolveEnvApiKeyImpl) { + mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); + } else { + mocks.resolveEnvApiKey.mockImplementation(() => null); + } + if (originalSyntheticImpl) { + mocks.resolveRuntimeSyntheticAuthProviderRefs.mockImplementation(originalSyntheticImpl); + } else { + mocks.resolveRuntimeSyntheticAuthProviderRefs.mockReturnValue([]); + } + } + }); + it("reports defaults source when --agent has no overrides", async () => { await withAgentScopeOverrides( { diff --git a/src/commands/models/list.types.ts b/src/commands/models/list.types.ts index 2f4157aaa3d..ba5c45893ef 100644 --- a/src/commands/models/list.types.ts +++ b/src/commands/models/list.types.ts @@ -19,7 +19,7 @@ export type ModelRow = { export type ProviderAuthOverview = { provider: string; effective: { - kind: "profiles" | "env" | "models.json" | "missing"; + kind: "profiles" | "env" | "models.json" | "synthetic" | "missing"; detail: string; }; profiles: { @@ -31,4 +31,5 @@ export type ProviderAuthOverview = { }; env?: { value: string; source: string }; modelsJson?: { value: string; source: string }; + syntheticAuth?: { value: string; source: string }; };