fix(models): antigravity opus 4.6 availability follow-up (#12845)

* fix(models): antigravity opus 4.6 availability follow-up

* chore(format): apply updated oxfmt config to models files

* fix(models): retain zai glm-5 forward-compat fallback after extraction

* chore(format): apply updated oxfmt config

* fix(models): fail fast on unknown auth login provider

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Vincent Koc
2026-02-13 15:54:46 -08:00
committed by GitHub
parent 23e8f3a20a
commit a0cbf9002d
10 changed files with 1853 additions and 343 deletions

View File

@@ -0,0 +1,226 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "./pi-model-discovery.js";
import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
import { normalizeModelCompat } from "./model-compat.js";
import { normalizeProviderId } from "./model-selection.js";
const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
const ZAI_GLM5_MODEL_ID = "glm-5";
const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const;
const ANTIGRAVITY_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTIGRAVITY_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTIGRAVITY_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
const ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID = "claude-opus-4-6-thinking";
const ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID = "claude-opus-4.6-thinking";
const ANTIGRAVITY_OPUS_THINKING_TEMPLATE_MODEL_IDS = [
"claude-opus-4-5-thinking",
"claude-opus-4.5-thinking",
] as const;
function resolveOpenAICodexGpt53FallbackModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider);
const trimmedModelId = modelId.trim();
if (normalizedProvider !== "openai-codex") {
return undefined;
}
if (trimmedModelId.toLowerCase() !== OPENAI_CODEX_GPT_53_MODEL_ID) {
return undefined;
}
for (const templateId of OPENAI_CODEX_TEMPLATE_MODEL_IDS) {
const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
} as Model<Api>);
}
return normalizeModelCompat({
id: trimmedModelId,
name: trimmedModelId,
api: "openai-codex-responses",
provider: normalizedProvider,
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
maxTokens: DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
}
function resolveAnthropicOpus46ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider);
if (normalizedProvider !== "anthropic") {
return undefined;
}
const trimmedModelId = modelId.trim();
const lower = trimmedModelId.toLowerCase();
const isOpus46 =
lower === ANTHROPIC_OPUS_46_MODEL_ID ||
lower === ANTHROPIC_OPUS_46_DOT_MODEL_ID ||
lower.startsWith(`${ANTHROPIC_OPUS_46_MODEL_ID}-`) ||
lower.startsWith(`${ANTHROPIC_OPUS_46_DOT_MODEL_ID}-`);
if (!isOpus46) {
return undefined;
}
const templateIds: string[] = [];
if (lower.startsWith(ANTHROPIC_OPUS_46_MODEL_ID)) {
templateIds.push(lower.replace(ANTHROPIC_OPUS_46_MODEL_ID, "claude-opus-4-5"));
}
if (lower.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID)) {
templateIds.push(lower.replace(ANTHROPIC_OPUS_46_DOT_MODEL_ID, "claude-opus-4.5"));
}
templateIds.push(...ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS);
for (const templateId of [...new Set(templateIds)].filter(Boolean)) {
const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
} as Model<Api>);
}
return undefined;
}
// Z.ai's GLM-5 may not be present in pi-ai's built-in model catalog yet.
// When a user configures zai/glm-5 without a models.json entry, clone glm-4.7 as a forward-compat fallback.
function resolveZaiGlm5ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
if (normalizeProviderId(provider) !== "zai") {
return undefined;
}
const trimmed = modelId.trim();
const lower = trimmed.toLowerCase();
if (lower !== ZAI_GLM5_MODEL_ID && !lower.startsWith(`${ZAI_GLM5_MODEL_ID}-`)) {
return undefined;
}
for (const templateId of ZAI_GLM5_TEMPLATE_MODEL_IDS) {
const template = modelRegistry.find("zai", templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmed,
name: trimmed,
reasoning: true,
} as Model<Api>);
}
return normalizeModelCompat({
id: trimmed,
name: trimmed,
api: "openai-completions",
provider: "zai",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
maxTokens: DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
}
function resolveAntigravityOpus46ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider);
if (normalizedProvider !== "google-antigravity") {
return undefined;
}
const trimmedModelId = modelId.trim();
const lower = trimmedModelId.toLowerCase();
const isOpus46 =
lower === ANTIGRAVITY_OPUS_46_MODEL_ID ||
lower === ANTIGRAVITY_OPUS_46_DOT_MODEL_ID ||
lower.startsWith(`${ANTIGRAVITY_OPUS_46_MODEL_ID}-`) ||
lower.startsWith(`${ANTIGRAVITY_OPUS_46_DOT_MODEL_ID}-`);
const isOpus46Thinking =
lower === ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID ||
lower === ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID ||
lower.startsWith(`${ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID}-`) ||
lower.startsWith(`${ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID}-`);
if (!isOpus46 && !isOpus46Thinking) {
return undefined;
}
const templateIds: string[] = [];
if (lower.startsWith(ANTIGRAVITY_OPUS_46_MODEL_ID)) {
templateIds.push(lower.replace(ANTIGRAVITY_OPUS_46_MODEL_ID, "claude-opus-4-5"));
}
if (lower.startsWith(ANTIGRAVITY_OPUS_46_DOT_MODEL_ID)) {
templateIds.push(lower.replace(ANTIGRAVITY_OPUS_46_DOT_MODEL_ID, "claude-opus-4.5"));
}
if (lower.startsWith(ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID)) {
templateIds.push(
lower.replace(ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID, "claude-opus-4-5-thinking"),
);
}
if (lower.startsWith(ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID)) {
templateIds.push(
lower.replace(ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID, "claude-opus-4.5-thinking"),
);
}
templateIds.push(...ANTIGRAVITY_OPUS_TEMPLATE_MODEL_IDS);
templateIds.push(...ANTIGRAVITY_OPUS_THINKING_TEMPLATE_MODEL_IDS);
for (const templateId of [...new Set(templateIds)].filter(Boolean)) {
const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
} as Model<Api>);
}
return undefined;
}
export function resolveForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
return (
resolveOpenAICodexGpt53FallbackModel(provider, modelId, modelRegistry) ??
resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ??
resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ??
resolveAntigravityOpus46ForwardCompatModel(provider, modelId, modelRegistry)
);
}

View File

@@ -172,43 +172,6 @@ describe("resolveModel", () => {
});
});
it("builds an openai-codex fallback for gpt-5.3-codex-spark", () => {
const templateModel = {
id: "gpt-5.2-codex",
name: "GPT-5.2 Codex",
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
contextWindow: 272000,
maxTokens: 128000,
};
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider === "openai-codex" && modelId === "gpt-5.2-codex") {
return templateModel;
}
return null;
}),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openai-codex",
id: "gpt-5.3-codex-spark",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
contextWindow: 272000,
maxTokens: 128000,
});
});
it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => {
const templateModel = {
id: "claude-opus-4-5",
@@ -244,7 +207,7 @@ describe("resolveModel", () => {
});
});
it("builds a google-antigravity forward-compat fallback for claude-opus-4-6-thinking", () => {
it("builds an antigravity forward-compat fallback for claude-opus-4-6-thinking", () => {
const templateModel = {
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
@@ -253,8 +216,8 @@ describe("resolveModel", () => {
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 200000,
maxTokens: 64000,
};
@@ -276,6 +239,45 @@ describe("resolveModel", () => {
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
contextWindow: 200000,
maxTokens: 64000,
});
});
it("builds an antigravity forward-compat fallback for claude-opus-4-6", () => {
const templateModel = {
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
provider: "google-antigravity",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 200000,
maxTokens: 64000,
};
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider === "google-antigravity" && modelId === "claude-opus-4-5") {
return templateModel;
}
return null;
}),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("google-antigravity", "claude-opus-4-6", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "google-antigravity",
id: "claude-opus-4-6",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
contextWindow: 200000,
maxTokens: 64000,
});
});
@@ -314,18 +316,34 @@ describe("resolveModel", () => {
});
});
it("keeps unknown-model errors when no antigravity thinking template exists", () => {
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn(() => null),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("google-antigravity", "claude-opus-4-6-thinking", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: google-antigravity/claude-opus-4-6-thinking");
});
it("keeps unknown-model errors when no antigravity non-thinking template exists", () => {
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn(() => null),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("google-antigravity", "claude-opus-4-6", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: google-antigravity/claude-opus-4-6");
});
it("keeps unknown-model errors for non-gpt-5 openai-codex ids", () => {
const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: openai-codex/gpt-4.1-mini");
});
it("errors for unknown gpt-5.3-codex-* variants", () => {
const result = resolveModel("openai-codex", "gpt-5.3-codex-unknown", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: openai-codex/gpt-5.3-codex-unknown");
});
it("uses codex fallback even when openai-codex provider is configured", () => {
// This test verifies the ordering: codex fallback must fire BEFORE the generic providerCfg fallback.
// If ordering is wrong, the generic fallback would use api: "openai-responses" (the default)

View File

@@ -4,6 +4,7 @@ import type { ModelDefinitionConfig } from "../../config/types.js";
import { resolveOpenClawAgentDir } from "../agent-paths.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { normalizeModelCompat } from "../model-compat.js";
import { resolveForwardCompatModel } from "../model-forward-compat.js";
import { normalizeProviderId } from "../model-selection.js";
import {
discoverAuthStorage,
@@ -19,188 +20,6 @@ type InlineProviderConfig = {
models?: ModelDefinitionConfig[];
};
const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_GPT_53_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
// pi-ai's built-in Anthropic catalog can lag behind OpenClaw's defaults/docs.
// Add forward-compat fallbacks for known-new IDs by cloning an older template model.
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
function resolveOpenAICodexGpt53FallbackModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider);
const trimmedModelId = modelId.trim();
if (normalizedProvider !== "openai-codex") {
return undefined;
}
const lower = trimmedModelId.toLowerCase();
const isGpt53 = lower === OPENAI_CODEX_GPT_53_MODEL_ID;
const isSpark = lower === OPENAI_CODEX_GPT_53_SPARK_MODEL_ID;
if (!isGpt53 && !isSpark) {
return undefined;
}
for (const templateId of OPENAI_CODEX_TEMPLATE_MODEL_IDS) {
const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
// Spark is a low-latency variant; keep api/baseUrl from template.
...(isSpark ? { reasoning: true } : {}),
} as Model<Api>);
}
return normalizeModelCompat({
id: trimmedModelId,
name: trimmedModelId,
api: "openai-codex-responses",
provider: normalizedProvider,
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
maxTokens: DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
}
function resolveAnthropicOpus46ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider);
if (normalizedProvider !== "anthropic") {
return undefined;
}
const trimmedModelId = modelId.trim();
const lower = trimmedModelId.toLowerCase();
const isOpus46 =
lower === ANTHROPIC_OPUS_46_MODEL_ID ||
lower === ANTHROPIC_OPUS_46_DOT_MODEL_ID ||
lower.startsWith(`${ANTHROPIC_OPUS_46_MODEL_ID}-`) ||
lower.startsWith(`${ANTHROPIC_OPUS_46_DOT_MODEL_ID}-`);
if (!isOpus46) {
return undefined;
}
const templateIds: string[] = [];
if (lower.startsWith(ANTHROPIC_OPUS_46_MODEL_ID)) {
templateIds.push(lower.replace(ANTHROPIC_OPUS_46_MODEL_ID, "claude-opus-4-5"));
}
if (lower.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID)) {
templateIds.push(lower.replace(ANTHROPIC_OPUS_46_DOT_MODEL_ID, "claude-opus-4.5"));
}
templateIds.push(...ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS);
for (const templateId of [...new Set(templateIds)].filter(Boolean)) {
const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
} as Model<Api>);
}
return undefined;
}
// Z.ai's GLM-5 may not be present in pi-ai's built-in model catalog yet.
// When a user configures zai/glm-5 without a models.json entry, clone glm-4.7 as a forward-compat fallback.
const ZAI_GLM5_MODEL_ID = "glm-5";
const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const;
function resolveZaiGlm5ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
if (normalizeProviderId(provider) !== "zai") {
return undefined;
}
const trimmed = modelId.trim();
const lower = trimmed.toLowerCase();
if (lower !== ZAI_GLM5_MODEL_ID && !lower.startsWith(`${ZAI_GLM5_MODEL_ID}-`)) {
return undefined;
}
for (const templateId of ZAI_GLM5_TEMPLATE_MODEL_IDS) {
const template = modelRegistry.find("zai", templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmed,
name: trimmed,
reasoning: true,
} as Model<Api>);
}
return normalizeModelCompat({
id: trimmed,
name: trimmed,
api: "openai-completions",
provider: "zai",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
maxTokens: DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
}
// google-antigravity's model catalog in pi-ai can lag behind the actual platform.
// When a google-antigravity model ID contains "opus-4-6" (or "opus-4.6") but isn't
// in the registry yet, clone the opus-4-5 template so the correct api
// ("google-gemini-cli") and baseUrl are preserved.
const ANTIGRAVITY_OPUS_46_STEMS = ["claude-opus-4-6", "claude-opus-4.6"] as const;
const ANTIGRAVITY_OPUS_45_TEMPLATES = ["claude-opus-4-5-thinking", "claude-opus-4-5"] as const;
function resolveAntigravityOpus46ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
if (normalizeProviderId(provider) !== "google-antigravity") {
return undefined;
}
const lower = modelId.trim().toLowerCase();
const isOpus46 = ANTIGRAVITY_OPUS_46_STEMS.some(
(stem) => lower === stem || lower.startsWith(`${stem}-`),
);
if (!isOpus46) {
return undefined;
}
for (const templateId of ANTIGRAVITY_OPUS_45_TEMPLATES) {
const template = modelRegistry.find("google-antigravity", templateId) as Model<Api> | null;
if (template) {
return normalizeModelCompat({
...template,
id: modelId.trim(),
name: modelId.trim(),
} as Model<Api>);
}
}
return undefined;
}
export function buildInlineProviderModels(
providers: Record<string, InlineProviderConfig>,
): InlineModelEntry[] {
@@ -267,36 +86,11 @@ export function resolveModel(
modelRegistry,
};
}
// Codex gpt-5.3 forward-compat fallback must be checked BEFORE the generic providerCfg fallback.
// Otherwise, if cfg.models.providers["openai-codex"] is configured, the generic fallback fires
// with api: "openai-responses" instead of the correct "openai-codex-responses".
const codexForwardCompat = resolveOpenAICodexGpt53FallbackModel(
provider,
modelId,
modelRegistry,
);
if (codexForwardCompat) {
return { model: codexForwardCompat, authStorage, modelRegistry };
}
const anthropicForwardCompat = resolveAnthropicOpus46ForwardCompatModel(
provider,
modelId,
modelRegistry,
);
if (anthropicForwardCompat) {
return { model: anthropicForwardCompat, authStorage, modelRegistry };
}
const antigravityForwardCompat = resolveAntigravityOpus46ForwardCompatModel(
provider,
modelId,
modelRegistry,
);
if (antigravityForwardCompat) {
return { model: antigravityForwardCompat, authStorage, modelRegistry };
}
const zaiForwardCompat = resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry);
if (zaiForwardCompat) {
return { model: zaiForwardCompat, authStorage, modelRegistry };
// Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback.
// Otherwise, configured providers can default to a generic API and break specific transports.
const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry);
if (forwardCompat) {
return { model: forwardCompat, authStorage, modelRegistry };
}
const providerCfg = providers[provider];
if (providerCfg || modelId.startsWith("mock-")) {

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import type { ProviderPlugin } from "../plugins/types.js";
import { resolveRequestedLoginProviderOrThrow } from "./models/auth.js";
function makeProvider(params: { id: string; label?: string; aliases?: string[] }): ProviderPlugin {
return {
id: params.id,
label: params.label ?? params.id,
aliases: params.aliases,
auth: [],
};
}
describe("resolveRequestedLoginProviderOrThrow", () => {
it("returns null when no provider was requested", () => {
const providers = [makeProvider({ id: "google-antigravity" })];
const result = resolveRequestedLoginProviderOrThrow(providers, undefined);
expect(result).toBeNull();
});
it("resolves requested provider by id", () => {
const providers = [
makeProvider({ id: "google-antigravity" }),
makeProvider({ id: "google-gemini-cli" }),
];
const result = resolveRequestedLoginProviderOrThrow(providers, "google-antigravity");
expect(result?.id).toBe("google-antigravity");
});
it("resolves requested provider by alias", () => {
const providers = [makeProvider({ id: "google-antigravity", aliases: ["antigravity"] })];
const result = resolveRequestedLoginProviderOrThrow(providers, "antigravity");
expect(result?.id).toBe("google-antigravity");
});
it("throws when requested provider is not loaded", () => {
const providers = [
makeProvider({ id: "google-gemini-cli" }),
makeProvider({ id: "qwen-portal" }),
];
expect(() =>
resolveRequestedLoginProviderOrThrow(providers, "google-antigravity"),
).toThrowError(
'Unknown provider "google-antigravity". Loaded providers: google-gemini-cli, qwen-portal. Verify plugins via `openclaw plugins list --json`.',
);
});
});

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const loadConfig = vi.fn();
const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined);
@@ -16,7 +16,10 @@ const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined);
const modelRegistryState = {
models: [] as Array<Record<string, unknown>>,
available: [] as Array<Record<string, unknown>>,
getAllError: undefined as unknown,
getAvailableError: undefined as unknown,
};
let previousExitCode: number | undefined;
vi.mock("../config/config.js", () => ({
CONFIG_PATH: "/tmp/openclaw.json",
@@ -46,17 +49,44 @@ vi.mock("../agents/model-auth.js", () => ({
getCustomProviderApiKey,
}));
vi.mock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: class {},
ModelRegistry: class {
getAll() {
return modelRegistryState.models;
vi.mock("@mariozechner/pi-coding-agent", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-coding-agent")>(
"@mariozechner/pi-coding-agent",
);
class MockAuthStorage {}
class MockModelRegistry {
find(provider: string, id: string): ReturnType<typeof actual.ModelRegistry.prototype.find> {
const found =
modelRegistryState.models.find((model) => model.provider === provider && model.id === id) ??
null;
return found as ReturnType<typeof actual.ModelRegistry.prototype.find>;
}
getAvailable() {
return modelRegistryState.available;
getAll(): ReturnType<typeof actual.ModelRegistry.prototype.getAll> {
if (modelRegistryState.getAllError !== undefined) {
throw modelRegistryState.getAllError;
}
return modelRegistryState.models as ReturnType<typeof actual.ModelRegistry.prototype.getAll>;
}
},
}));
getAvailable(): ReturnType<typeof actual.ModelRegistry.prototype.getAvailable> {
if (modelRegistryState.getAvailableError !== undefined) {
throw modelRegistryState.getAvailableError;
}
return modelRegistryState.available as ReturnType<
typeof actual.ModelRegistry.prototype.getAvailable
>;
}
}
return {
...actual,
AuthStorage: MockAuthStorage as unknown as typeof actual.AuthStorage,
ModelRegistry: MockModelRegistry as unknown as typeof actual.ModelRegistry,
};
});
function makeRuntime() {
return {
@@ -65,6 +95,18 @@ function makeRuntime() {
};
}
beforeEach(() => {
previousExitCode = process.exitCode;
process.exitCode = undefined;
modelRegistryState.getAllError = undefined;
modelRegistryState.getAvailableError = undefined;
listProfilesForProvider.mockReturnValue([]);
});
afterEach(() => {
process.exitCode = previousExitCode;
});
describe("models list/status", () => {
it("models status resolves z.ai alias to canonical zai", async () => {
loadConfig.mockReturnValue({
@@ -280,4 +322,463 @@ describe("models list/status", () => {
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.available).toBe(false);
});
it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.tags).toContain("default");
expect(payload.models[0]?.tags).toContain("configured");
});
it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6",
models: {
"google-antigravity/claude-opus-4-6": {},
},
},
},
});
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.tags).toContain("default");
expect(payload.models[0]?.tags).toContain("configured");
});
it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
const runtime = makeRuntime();
const template = {
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
};
modelRegistryState.models = [template];
modelRegistryState.available = [template];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6",
models: {
"google-antigravity/claude-opus-4-6": {},
},
},
},
});
const runtime = makeRuntime();
const template = {
provider: "google-antigravity",
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
};
modelRegistryState.models = [template];
modelRegistryState.available = [template];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list prefers registry availability over provider auth heuristics", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
const runtime = makeRuntime();
const template = {
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
};
modelRegistryState.models = [template];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(false);
listProfilesForProvider.mockReturnValue([]);
});
it("models list falls back to auth heuristics when registry availability is unavailable", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
modelRegistryState.getAvailableError = Object.assign(
new Error("availability unsupported: getAvailable failed"),
{ code: "MODEL_AVAILABILITY_UNAVAILABLE" },
);
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics");
expect(runtime.error.mock.calls[0]?.[0]).toContain("getAvailable failed");
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list falls back to auth heuristics when getAvailable returns invalid shape", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
modelRegistryState.available = { bad: true } as unknown as Array<Record<string, unknown>>;
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics");
expect(runtime.error.mock.calls[0]?.[0]).toContain("non-array value");
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list falls back to auth heuristics when getAvailable throws", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
modelRegistryState.getAvailableError = new Error(
"availability unsupported: getAvailable failed",
);
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics");
expect(runtime.error.mock.calls[0]?.[0]).toContain(
"availability unsupported: getAvailable failed",
);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list does not treat availability-unavailable code as discovery fallback", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
modelRegistryState.getAllError = Object.assign(new Error("model discovery failed"), {
code: "MODEL_AVAILABILITY_UNAVAILABLE",
});
const runtime = makeRuntime();
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:");
expect(runtime.error.mock.calls[0]?.[0]).toContain("model discovery failed");
expect(runtime.error.mock.calls[0]?.[0]).not.toContain("configured models may appear missing");
expect(runtime.log).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it("models list fails fast when registry model discovery is unavailable", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), {
code: "MODEL_DISCOVERY_UNAVAILABLE",
});
const runtime = makeRuntime();
modelRegistryState.models = [];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:");
expect(runtime.error.mock.calls[0]?.[0]).toContain("model discovery unavailable");
expect(runtime.log).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it("loadModelRegistry throws when model discovery is unavailable", async () => {
modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), {
code: "MODEL_DISCOVERY_UNAVAILABLE",
});
modelRegistryState.available = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
const { loadModelRegistry } = await import("./models/list.registry.js");
await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable");
});
it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => {
const { toModelRow } = await import("./models/list.registry.js");
const row = toModelRow({
model: {
provider: "google-antigravity",
id: "claude-opus-4-6-thinking",
name: "Claude Opus 4.6 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
key: "google-antigravity/claude-opus-4-6-thinking",
tags: [],
availableKeys: undefined,
});
expect(row.missing).toBe(false);
expect(row.available).toBe(false);
});
});

View File

@@ -0,0 +1,784 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const loadConfig = vi.fn();
const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined);
const resolveOpenClawAgentDir = vi.fn().mockReturnValue("/tmp/openclaw-agent");
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
const listProfilesForProvider = vi.fn().mockReturnValue([]);
const resolveAuthProfileDisplayLabel = vi.fn(({ profileId }: { profileId: string }) => profileId);
const resolveAuthStorePathForDisplay = vi
.fn()
.mockReturnValue("/tmp/openclaw-agent/auth-profiles.json");
const resolveProfileUnusableUntilForDisplay = vi.fn().mockReturnValue(null);
const resolveEnvApiKey = vi.fn().mockReturnValue(undefined);
const resolveAwsSdkEnvVarName = vi.fn().mockReturnValue(undefined);
const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined);
const modelRegistryState = {
models: [] as Array<Record<string, unknown>>,
available: [] as Array<Record<string, unknown>>,
getAllError: undefined as unknown,
getAvailableError: undefined as unknown,
};
let previousExitCode: number | undefined;
vi.mock("../config/config.js", () => ({
CONFIG_PATH: "/tmp/openclaw.json",
STATE_DIR: "/tmp/openclaw-state",
loadConfig,
}));
vi.mock("../agents/models-config.js", () => ({
ensureOpenClawModelsJson,
}));
vi.mock("../agents/agent-paths.js", () => ({
resolveOpenClawAgentDir,
}));
vi.mock("../agents/auth-profiles.js", () => ({
ensureAuthProfileStore,
listProfilesForProvider,
resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay,
resolveProfileUnusableUntilForDisplay,
}));
vi.mock("../agents/model-auth.js", () => ({
resolveEnvApiKey,
resolveAwsSdkEnvVarName,
getCustomProviderApiKey,
}));
vi.mock("@mariozechner/pi-coding-agent", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-coding-agent")>(
"@mariozechner/pi-coding-agent",
);
class MockAuthStorage {}
class MockModelRegistry {
find(provider: string, id: string): ReturnType<typeof actual.ModelRegistry.prototype.find> {
const found =
modelRegistryState.models.find((model) => model.provider === provider && model.id === id) ??
null;
return found as ReturnType<typeof actual.ModelRegistry.prototype.find>;
}
getAll(): ReturnType<typeof actual.ModelRegistry.prototype.getAll> {
if (modelRegistryState.getAllError !== undefined) {
throw modelRegistryState.getAllError;
}
return modelRegistryState.models as ReturnType<typeof actual.ModelRegistry.prototype.getAll>;
}
getAvailable(): ReturnType<typeof actual.ModelRegistry.prototype.getAvailable> {
if (modelRegistryState.getAvailableError !== undefined) {
throw modelRegistryState.getAvailableError;
}
return modelRegistryState.available as ReturnType<
typeof actual.ModelRegistry.prototype.getAvailable
>;
}
}
return {
...actual,
AuthStorage: MockAuthStorage as unknown as typeof actual.AuthStorage,
ModelRegistry: MockModelRegistry as unknown as typeof actual.ModelRegistry,
};
});
function makeRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
};
}
beforeEach(() => {
previousExitCode = process.exitCode;
process.exitCode = undefined;
modelRegistryState.getAllError = undefined;
modelRegistryState.getAvailableError = undefined;
listProfilesForProvider.mockReturnValue([]);
});
afterEach(() => {
process.exitCode = previousExitCode;
});
describe("models list/status", () => {
it("models status resolves z.ai alias to canonical zai", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const { modelsStatusCommand } = await import("./models/list.js");
await modelsStatusCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.resolvedDefault).toBe("zai/glm-4.7");
});
it("models status plain outputs canonical zai model", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const { modelsStatusCommand } = await import("./models/list.js");
await modelsStatusCommand({ plain: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7");
});
it("models list outputs canonical zai key for configured z.ai model", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const model = {
provider: "zai",
id: "glm-4.7",
name: "GLM-4.7",
input: ["text"],
baseUrl: "https://api.z.ai/v1",
contextWindow: 128000,
};
modelRegistryState.models = [model];
modelRegistryState.available = [model];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("zai/glm-4.7");
});
it("models list plain outputs canonical zai key", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const model = {
provider: "zai",
id: "glm-4.7",
name: "GLM-4.7",
input: ["text"],
baseUrl: "https://api.z.ai/v1",
contextWindow: 128000,
};
modelRegistryState.models = [model];
modelRegistryState.available = [model];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ plain: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7");
});
it("models list provider filter normalizes z.ai alias", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const models = [
{
provider: "zai",
id: "glm-4.7",
name: "GLM-4.7",
input: ["text"],
baseUrl: "https://api.z.ai/v1",
contextWindow: 128000,
},
{
provider: "openai",
id: "gpt-4.1-mini",
name: "GPT-4.1 mini",
input: ["text"],
baseUrl: "https://api.openai.com/v1",
contextWindow: 128000,
},
];
modelRegistryState.models = models;
modelRegistryState.available = models;
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ all: true, provider: "z.ai", json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.count).toBe(1);
expect(payload.models[0]?.key).toBe("zai/glm-4.7");
});
it("models list provider filter normalizes Z.AI alias casing", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const models = [
{
provider: "zai",
id: "glm-4.7",
name: "GLM-4.7",
input: ["text"],
baseUrl: "https://api.z.ai/v1",
contextWindow: 128000,
},
{
provider: "openai",
id: "gpt-4.1-mini",
name: "GPT-4.1 mini",
input: ["text"],
baseUrl: "https://api.openai.com/v1",
contextWindow: 128000,
},
];
modelRegistryState.models = models;
modelRegistryState.available = models;
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ all: true, provider: "Z.AI", json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.count).toBe(1);
expect(payload.models[0]?.key).toBe("zai/glm-4.7");
});
it("models list provider filter normalizes z-ai alias", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const models = [
{
provider: "zai",
id: "glm-4.7",
name: "GLM-4.7",
input: ["text"],
baseUrl: "https://api.z.ai/v1",
contextWindow: 128000,
},
{
provider: "openai",
id: "gpt-4.1-mini",
name: "GPT-4.1 mini",
input: ["text"],
baseUrl: "https://api.openai.com/v1",
contextWindow: 128000,
},
];
modelRegistryState.models = models;
modelRegistryState.available = models;
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ all: true, provider: "z-ai", json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.count).toBe(1);
expect(payload.models[0]?.key).toBe("zai/glm-4.7");
});
it("models list marks auth as unavailable when ZAI key is missing", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const model = {
provider: "zai",
id: "glm-4.7",
name: "GLM-4.7",
input: ["text"],
baseUrl: "https://api.z.ai/v1",
contextWindow: 128000,
};
modelRegistryState.models = [model];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ all: true, json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.available).toBe(false);
});
it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.tags).toContain("default");
expect(payload.models[0]?.tags).toContain("configured");
});
it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6",
models: {
"google-antigravity/claude-opus-4-6": {},
},
},
},
});
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.tags).toContain("default");
expect(payload.models[0]?.tags).toContain("configured");
});
it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
const runtime = makeRuntime();
const template = {
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
};
modelRegistryState.models = [template];
modelRegistryState.available = [template];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6",
models: {
"google-antigravity/claude-opus-4-6": {},
},
},
},
});
const runtime = makeRuntime();
const template = {
provider: "google-antigravity",
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
};
modelRegistryState.models = [template];
modelRegistryState.available = [template];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list prefers registry availability over provider auth heuristics", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
const runtime = makeRuntime();
const template = {
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
};
modelRegistryState.models = [template];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(false);
listProfilesForProvider.mockReturnValue([]);
});
it("models list falls back to auth heuristics when registry availability is unavailable", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
modelRegistryState.getAvailableError = Object.assign(
new Error("availability unsupported: getAvailable failed"),
{ code: "MODEL_AVAILABILITY_UNAVAILABLE" },
);
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics");
expect(runtime.error.mock.calls[0]?.[0]).toContain("getAvailable failed");
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list falls back to auth heuristics when getAvailable returns invalid shape", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
modelRegistryState.available = { bad: true } as unknown as Array<Record<string, unknown>>;
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics");
expect(runtime.error.mock.calls[0]?.[0]).toContain("non-array value");
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list falls back to auth heuristics when getAvailable throws", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
modelRegistryState.getAvailableError = new Error(
"availability unsupported: getAvailable failed",
);
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics");
expect(runtime.error.mock.calls[0]?.[0]).toContain(
"availability unsupported: getAvailable failed",
);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list does not treat availability-unavailable code as discovery fallback", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
modelRegistryState.getAllError = Object.assign(new Error("model discovery failed"), {
code: "MODEL_AVAILABILITY_UNAVAILABLE",
});
const runtime = makeRuntime();
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:");
expect(runtime.error.mock.calls[0]?.[0]).toContain("model discovery failed");
expect(runtime.error.mock.calls[0]?.[0]).not.toContain("configured models may appear missing");
expect(runtime.log).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it("models list fails fast when registry model discovery is unavailable", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), {
code: "MODEL_DISCOVERY_UNAVAILABLE",
});
const runtime = makeRuntime();
modelRegistryState.models = [];
modelRegistryState.available = [];
const { modelsListCommand } = await import("./models/list.js");
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:");
expect(runtime.error.mock.calls[0]?.[0]).toContain("model discovery unavailable");
expect(runtime.log).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it("loadModelRegistry throws when model discovery is unavailable", async () => {
modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), {
code: "MODEL_DISCOVERY_UNAVAILABLE",
});
modelRegistryState.available = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
const { loadModelRegistry } = await import("./models/list.registry.js");
await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable");
});
it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => {
const { toModelRow } = await import("./models/list.registry.js");
const row = toModelRow({
model: {
provider: "google-antigravity",
id: "claude-opus-4-6-thinking",
name: "Claude Opus 4.6 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
key: "google-antigravity/claude-opus-4-6-thinking",
tags: [],
availableKeys: undefined,
});
expect(row.missing).toBe(false);
expect(row.available).toBe(false);
});
});

View File

@@ -26,8 +26,6 @@ import { isRemoteEnvironment } from "../oauth-env.js";
import { createVpsAwareOAuthHandlers } from "../oauth-flow.js";
import { applyAuthProfileConfig } from "../onboard-auth.js";
import { openUrl } from "../onboard-helpers.js";
import { OPENAI_CODEX_DEFAULT_MODEL } from "../openai-codex-model-default.js";
import { loginOpenAICodexOAuth } from "../openai-codex-oauth.js";
import { updateConfig } from "./shared.js";
const confirm = (params: Parameters<typeof clackConfirm>[0]) =>
@@ -260,6 +258,28 @@ function resolveProviderMatch(
);
}
export function resolveRequestedLoginProviderOrThrow(
providers: ProviderPlugin[],
rawProvider?: string,
): ProviderPlugin | null {
const requested = rawProvider?.trim();
if (!requested) {
return null;
}
const matched = resolveProviderMatch(providers, requested);
if (matched) {
return matched;
}
const available = providers
.map((provider) => provider.id)
.filter(Boolean)
.toSorted((a, b) => a.localeCompare(b));
const availableText = available.length > 0 ? available.join(", ") : "(none)";
throw new Error(
`Unknown provider "${requested}". Loaded providers: ${availableText}. Verify plugins via \`${formatCliCommand("openclaw plugins list --json")}\`.`,
);
}
function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null {
const raw = rawMethod?.trim();
if (!raw) {
@@ -344,59 +364,6 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim
const workspaceDir =
resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir();
const prompter = createClackPrompter();
const requestedProvider = opts.provider ? normalizeProviderId(opts.provider) : null;
if (requestedProvider === "openai-codex") {
const method = opts.method?.trim().toLowerCase();
if (method && method !== "oauth") {
throw new Error('OpenAI Codex auth only supports --method "oauth".');
}
const creds = await loginOpenAICodexOAuth({
prompter,
runtime,
isRemote: isRemoteEnvironment(),
openUrl: async (url) => {
await openUrl(url);
},
});
if (!creds) {
return;
}
const profileId = "openai-codex:default";
upsertAuthProfile({
profileId,
credential: {
type: "oauth",
provider: "openai-codex",
...creds,
},
agentDir,
});
await updateConfig((cfg) => {
let next = applyAuthProfileConfig(cfg, {
profileId,
provider: "openai-codex",
mode: "oauth",
});
if (opts.setDefault) {
next = applyDefaultModel(next, OPENAI_CODEX_DEFAULT_MODEL);
}
return next;
});
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (openai-codex/oauth)`);
runtime.log(
opts.setDefault
? `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`
: `Default model available: ${OPENAI_CODEX_DEFAULT_MODEL} (use --set-default to apply)`,
);
return;
}
const providers = resolvePluginProviders({ config, workspaceDir });
if (providers.length === 0) {
throw new Error(
@@ -404,8 +371,10 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim
);
}
const prompter = createClackPrompter();
const requestedProvider = resolveRequestedLoginProviderOrThrow(providers, opts.provider);
const selectedProvider =
resolveProviderMatch(providers, opts.provider) ??
requestedProvider ??
(await prompter
.select({
message: "Select a provider",

View File

@@ -0,0 +1,16 @@
export const MODEL_AVAILABILITY_UNAVAILABLE_CODE = "MODEL_AVAILABILITY_UNAVAILABLE";
export function formatErrorWithStack(err: unknown): string {
if (err instanceof Error) {
return err.stack ?? `${err.name}: ${err.message}`;
}
return String(err);
}
export function shouldFallbackToAuthHeuristics(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
}
const code = (err as { code?: unknown }).code;
return code === MODEL_AVAILABILITY_UNAVAILABLE_CODE;
}

View File

@@ -3,9 +3,9 @@ import type { RuntimeEnv } from "../../runtime.js";
import type { ModelRow } from "./list.types.js";
import { ensureAuthProfileStore } from "../../agents/auth-profiles.js";
import { parseModelRef } from "../../agents/model-selection.js";
import { resolveModel } from "../../agents/pi-embedded-runner/model.js";
import { loadConfig } from "../../config/config.js";
import { resolveConfiguredEntries } from "./list.configured.js";
import { formatErrorWithStack } from "./list.errors.js";
import { loadModelRegistry, toModelRow } from "./list.registry.js";
import { printModelTable } from "./list.table.js";
import { DEFAULT_PROVIDER, ensureFlagCompatibility, modelKey } from "./shared.js";
@@ -34,12 +34,21 @@ export async function modelsListCommand(
let models: Model<Api>[] = [];
let availableKeys: Set<string> | undefined;
let availabilityErrorMessage: string | undefined;
try {
const loaded = await loadModelRegistry(cfg);
models = loaded.models;
availableKeys = loaded.availableKeys;
availabilityErrorMessage = loaded.availabilityErrorMessage;
} catch (err) {
runtime.error(`Model registry unavailable: ${String(err)}`);
runtime.error(`Model registry unavailable:\n${formatErrorWithStack(err)}`);
process.exitCode = 1;
return;
}
if (availabilityErrorMessage !== undefined) {
runtime.error(
`Model availability lookup failed; falling back to auth heuristics for discovered models: ${availabilityErrorMessage}`,
);
}
const modelByKey = new Map(models.map((model) => [modelKey(model.provider, model.id), model]));
@@ -100,13 +109,7 @@ export async function modelsListCommand(
if (providerFilter && entry.ref.provider.toLowerCase() !== providerFilter) {
continue;
}
let model = modelByKey.get(entry.key);
if (!model) {
const resolved = resolveModel(entry.ref.provider, entry.ref.model, undefined, cfg);
if (resolved.model && !resolved.error) {
model = resolved.model;
}
}
const model = modelByKey.get(entry.key);
if (opts.local && model && !isLocalBaseUrl(model.baseUrl)) {
continue;
}

View File

@@ -1,5 +1,6 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
import type { ModelRegistry } from "../../agents/pi-model-discovery.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { ModelRow } from "./list.types.js";
import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js";
@@ -9,9 +10,14 @@ import {
resolveAwsSdkEnvVarName,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { resolveForwardCompatModel } from "../../agents/model-forward-compat.js";
import { ensureOpenClawModelsJson } from "../../agents/models-config.js";
import { ensurePiAuthJsonFromAuthProfiles } from "../../agents/pi-auth-json.js";
import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js";
import {
formatErrorWithStack,
MODEL_AVAILABILITY_UNAVAILABLE_CODE,
shouldFallbackToAuthHeuristics,
} from "./list.errors.js";
import { modelKey } from "./shared.js";
const isLocalBaseUrl = (baseUrl: string) => {
@@ -30,7 +36,14 @@ const isLocalBaseUrl = (baseUrl: string) => {
}
};
const hasAuthForProvider = (provider: string, cfg: OpenClawConfig, authStore: AuthProfileStore) => {
const hasAuthForProvider = (
provider: string,
cfg?: OpenClawConfig,
authStore?: AuthProfileStore,
) => {
if (!cfg || !authStore) {
return false;
}
if (listProfilesForProvider(authStore, provider).length > 0) {
return true;
}
@@ -46,16 +59,150 @@ const hasAuthForProvider = (provider: string, cfg: OpenClawConfig, authStore: Au
return false;
};
function createAvailabilityUnavailableError(message: string): Error {
const err = new Error(message);
(err as { code?: string }).code = MODEL_AVAILABILITY_UNAVAILABLE_CODE;
return err;
}
function normalizeAvailabilityError(err: unknown): Error {
if (shouldFallbackToAuthHeuristics(err) && err instanceof Error) {
return err;
}
return createAvailabilityUnavailableError(
`Model availability unavailable: getAvailable() failed.\n${formatErrorWithStack(err)}`,
);
}
function validateAvailableModels(availableModels: unknown): Model<Api>[] {
if (!Array.isArray(availableModels)) {
throw createAvailabilityUnavailableError(
"Model availability unavailable: getAvailable() returned a non-array value.",
);
}
for (const model of availableModels) {
if (
!model ||
typeof model !== "object" ||
typeof (model as { provider?: unknown }).provider !== "string" ||
typeof (model as { id?: unknown }).id !== "string"
) {
throw createAvailabilityUnavailableError(
"Model availability unavailable: getAvailable() returned invalid model entries.",
);
}
}
return availableModels as Model<Api>[];
}
function loadAvailableModels(registry: ModelRegistry): Model<Api>[] {
let availableModels: unknown;
try {
availableModels = registry.getAvailable();
} catch (err) {
throw normalizeAvailabilityError(err);
}
try {
return validateAvailableModels(availableModels);
} catch (err) {
throw normalizeAvailabilityError(err);
}
}
export async function loadModelRegistry(cfg: OpenClawConfig) {
await ensureOpenClawModelsJson(cfg);
const agentDir = resolveOpenClawAgentDir();
await ensurePiAuthJsonFromAuthProfiles(agentDir);
const authStorage = discoverAuthStorage(agentDir);
const registry = discoverModels(authStorage, agentDir);
const models = registry.getAll();
const availableModels = registry.getAvailable();
const availableKeys = new Set(availableModels.map((model) => modelKey(model.provider, model.id)));
return { registry, models, availableKeys };
const appended = appendAntigravityForwardCompatModels(registry.getAll(), registry);
const models = appended.models;
const synthesizedForwardCompat = appended.synthesizedForwardCompat;
let availableKeys: Set<string> | undefined;
let availabilityErrorMessage: string | undefined;
try {
const availableModels = loadAvailableModels(registry);
availableKeys = new Set(availableModels.map((model) => modelKey(model.provider, model.id)));
for (const synthesized of synthesizedForwardCompat) {
if (hasAvailableTemplate(availableKeys, synthesized.templatePrefixes)) {
availableKeys.add(synthesized.key);
}
}
} catch (err) {
if (!shouldFallbackToAuthHeuristics(err)) {
throw err;
}
// Some providers can report model-level availability as unavailable.
// Fall back to provider-level auth heuristics when availability is undefined.
availableKeys = undefined;
if (!availabilityErrorMessage) {
availabilityErrorMessage = formatErrorWithStack(err);
}
}
return { registry, models, availableKeys, availabilityErrorMessage };
}
type SynthesizedForwardCompat = {
key: string;
templatePrefixes: string[];
};
function appendAntigravityForwardCompatModels(
models: Model<Api>[],
modelRegistry: ModelRegistry,
): { models: Model<Api>[]; synthesizedForwardCompat: SynthesizedForwardCompat[] } {
const candidates = [
{
id: "claude-opus-4-6-thinking",
templatePrefixes: [
"google-antigravity/claude-opus-4-5-thinking",
"google-antigravity/claude-opus-4.5-thinking",
],
},
{
id: "claude-opus-4-6",
templatePrefixes: [
"google-antigravity/claude-opus-4-5",
"google-antigravity/claude-opus-4.5",
],
},
];
const nextModels = [...models];
const synthesizedForwardCompat: SynthesizedForwardCompat[] = [];
for (const candidate of candidates) {
const key = modelKey("google-antigravity", candidate.id);
const hasForwardCompat = nextModels.some((model) => modelKey(model.provider, model.id) === key);
if (hasForwardCompat) {
continue;
}
const fallback = resolveForwardCompatModel("google-antigravity", candidate.id, modelRegistry);
if (!fallback) {
continue;
}
nextModels.push(fallback);
synthesizedForwardCompat.push({
key,
templatePrefixes: candidate.templatePrefixes,
});
}
return { models: nextModels, synthesizedForwardCompat };
}
function hasAvailableTemplate(availableKeys: Set<string>, templatePrefixes: string[]): boolean {
for (const key of availableKeys) {
if (templatePrefixes.some((prefix) => key.startsWith(prefix))) {
return true;
}
}
return false;
}
export function toModelRow(params: {
@@ -83,10 +230,14 @@ export function toModelRow(params: {
const input = model.input.join("+") || "text";
const local = isLocalBaseUrl(model.baseUrl);
// Prefer model-level registry availability when present.
// Fall back to provider-level auth heuristics only if registry availability isn't available.
const available =
cfg && authStore
? hasAuthForProvider(model.provider, cfg, authStore)
: (availableKeys?.has(modelKey(model.provider, model.id)) ?? false);
availableKeys !== undefined
? availableKeys.has(modelKey(model.provider, model.id))
: cfg && authStore
? hasAuthForProvider(model.provider, cfg, authStore)
: false;
const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : [];
const mergedTags = new Set(tags);
if (aliasTags.length > 0) {