mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
fix: expose codex provider catalog
This commit is contained in:
@@ -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"]
|
||||
|
||||
83
extensions/codex/provider-catalog.ts
Normal file
83
extensions/codex/provider-catalog.ts
Normal file
@@ -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")
|
||||
);
|
||||
}
|
||||
45
extensions/codex/provider-discovery.ts
Normal file
45
extensions/codex/provider-discovery.ts
Normal file
@@ -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<string, { config?: unknown } | undefined>)?.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;
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<CodexAppServerModelListResult> {
|
||||
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
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>", "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>", "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>", "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>", "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>", "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>", "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>", "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>", "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<string>(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<string>(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<string>(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,
|
||||
|
||||
@@ -17,7 +17,7 @@ type RouteArgParser<TArgs> = (argv: string[]) => TArgs | null;
|
||||
|
||||
type ParsedRouteArgs<TParse extends RouteArgParser<unknown>> = Exclude<ReturnType<TParse>, 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<TParse extends RouteArgParser<unknown>> = {
|
||||
parseArgs: TParse;
|
||||
@@ -36,16 +36,16 @@ function defineRoutedCommand<TParse extends RouteArgParser<unknown>>(
|
||||
}
|
||||
|
||||
let configCliPromise: Promise<ConfigCliModule> | undefined;
|
||||
let modelsCommandsPromise: Promise<ModelsCommandsModule> | undefined;
|
||||
let modelsListPromise: Promise<ModelsListModule> | undefined;
|
||||
|
||||
function loadConfigCli(): Promise<ConfigCliModule> {
|
||||
configCliPromise ??= import("../config-cli.js");
|
||||
return configCliPromise;
|
||||
}
|
||||
|
||||
function loadModelsCommands(): Promise<ModelsCommandsModule> {
|
||||
modelsCommandsPromise ??= import("../../commands/models.js");
|
||||
return modelsCommandsPromise;
|
||||
function loadModelsList(): Promise<ModelsListModule> {
|
||||
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);
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
31
src/commands/models/list.configured.test.ts
Normal file
31
src/commands/models/list.configured.test.ts
Normal file
@@ -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"]));
|
||||
});
|
||||
});
|
||||
@@ -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<string, Set<string>>();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string>();
|
||||
let availableKeys: Set<string> | 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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
81
src/commands/models/list.rows.test.ts
Normal file
81
src/commands/models/list.rows.test.ts
Normal file
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ type RowBuilderContext = {
|
||||
configuredByKey: ConfiguredByKey;
|
||||
discoveredKeys: Set<string>;
|
||||
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<string>;
|
||||
}): Promise<void> {
|
||||
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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<ProgressRuntime> | undefined;
|
||||
let terminalTableRuntimePromise: Promise<TerminalTableRuntime> | undefined;
|
||||
let listProbeRuntimePromise: Promise<ListProbeRuntime> | undefined;
|
||||
|
||||
const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const;
|
||||
|
||||
function loadProviderUsageRuntime(): Promise<ProviderUsageRuntime> {
|
||||
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<string>();
|
||||
const providersInUse = new Set<string>();
|
||||
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)}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user