mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
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:
226
src/agents/model-forward-compat.ts
Normal file
226
src/agents/model-forward-compat.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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-")) {
|
||||
|
||||
48
src/commands/models.auth.provider-resolution.test.ts
Normal file
48
src/commands/models.auth.provider-resolution.test.ts
Normal 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`.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
784
src/commands/models.list.test.ts
Normal file
784
src/commands/models.list.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
16
src/commands/models/list.errors.ts
Normal file
16
src/commands/models/list.errors.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user