mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 15:01:43 +00:00
* feat(models): curate default picker visibility * chore(models): curate provider catalogs * test(openai): generalize static-catalog transport guard after row curation (#91710) * fix(models): propagate catalog lifecycle metadata * test(models): keep picker suite within line budget
1483 lines
42 KiB
TypeScript
1483 lines
42 KiB
TypeScript
// Models method tests cover slow catalog timeouts, configured/all views,
|
|
// validation errors, and protocol response shapes.
|
|
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js";
|
|
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
|
import {
|
|
clearRuntimeAuthProfileStoreSnapshots,
|
|
replaceRuntimeAuthProfileStoreSnapshots,
|
|
} from "../../agents/auth-profiles.js";
|
|
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
|
|
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
|
import { createDeferred } from "../../test-utils/deferred.js";
|
|
import { withEnvAsync } from "../../test-utils/env.js";
|
|
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
|
|
import { expectGatewayErrorResponse } from "./gateway-response.test-helpers.js";
|
|
import { modelsHandlers } from "./models.js";
|
|
import type { RespondFn } from "./types.js";
|
|
|
|
const withoutOpenAIEnvAuth = async <T>(run: () => Promise<T>): Promise<T> =>
|
|
await withEnvAsync(
|
|
{
|
|
CODEX_API_KEY: undefined,
|
|
CODEX_HOME: "/__openclaw_models_list_test__/codex",
|
|
OPENAI_API_KEY: undefined,
|
|
OPENAI_BASE_URL: undefined,
|
|
OPENAI_OAUTH_TOKEN: undefined,
|
|
CHATGPT_OAUTH_TOKEN: undefined,
|
|
},
|
|
run,
|
|
);
|
|
|
|
function createDemoOAuthStore(params: { access: string; expires: number }) {
|
|
return {
|
|
version: 1 as const,
|
|
profiles: {
|
|
"demo-provider:oauth": {
|
|
type: "oauth" as const,
|
|
provider: "demo-provider",
|
|
access: params.access,
|
|
refresh: "refresh-token",
|
|
expires: params.expires,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function requestModelsList(params: {
|
|
view: "default" | "configured" | "provider-config" | "all";
|
|
respond?: ReturnType<typeof vi.fn>;
|
|
runtimeConfig?: OpenClawConfig;
|
|
getRuntimeConfig?: () => OpenClawConfig;
|
|
loadGatewayModelCatalog: (params?: {
|
|
agentId?: string;
|
|
agentDir?: string;
|
|
readOnly?: boolean;
|
|
workspaceDir?: string;
|
|
}) => Promise<Array<Record<string, unknown>>>;
|
|
reqId?: string;
|
|
includeProviderCapabilities?: boolean;
|
|
}) {
|
|
const respond = params.respond ?? vi.fn();
|
|
const runtimeConfig = params.runtimeConfig ?? ({} as OpenClawConfig);
|
|
const getRuntimeConfig = params.getRuntimeConfig ?? (() => runtimeConfig);
|
|
const request = expectDefined(
|
|
modelsHandlers["models.list"],
|
|
'modelsHandlers["models.list"] test invariant',
|
|
)({
|
|
req: {
|
|
type: "req",
|
|
id: params.reqId ?? `req-models-list-${params.view}`,
|
|
method: "models.list",
|
|
params: {
|
|
view: params.view,
|
|
...(params.includeProviderCapabilities ? { includeProviderCapabilities: true } : {}),
|
|
},
|
|
},
|
|
params: {
|
|
view: params.view,
|
|
...(params.includeProviderCapabilities ? { includeProviderCapabilities: true } : {}),
|
|
},
|
|
respond: respond as RespondFn,
|
|
client: null,
|
|
isWebchatConnect: () => false,
|
|
context: {
|
|
getRuntimeConfig,
|
|
loadGatewayModelCatalog: params.loadGatewayModelCatalog,
|
|
loadGatewayModelCatalogSnapshot: async (
|
|
loadParams: Parameters<typeof params.loadGatewayModelCatalog>[0],
|
|
) => {
|
|
const entries = await params.loadGatewayModelCatalog(loadParams);
|
|
const config = getRuntimeConfig();
|
|
return {
|
|
agentId: loadParams?.agentId ?? resolveDefaultAgentId(config),
|
|
agentDir: "/tmp/models-list-agent",
|
|
config,
|
|
entries,
|
|
routeVariants: entries,
|
|
};
|
|
},
|
|
logGateway: {
|
|
debug: vi.fn(),
|
|
},
|
|
} as never,
|
|
});
|
|
return { request, respond };
|
|
}
|
|
|
|
describe("models.list", () => {
|
|
it("uses the replacement owner config for the whole catalog projection", async () => {
|
|
const initialConfig = {
|
|
agents: { defaults: { models: { "test/old": {} } } },
|
|
} as OpenClawConfig;
|
|
const latestConfig = {
|
|
agents: { defaults: { models: { "test/demo": {} } } },
|
|
} as OpenClawConfig;
|
|
let currentConfig = initialConfig;
|
|
const loadGatewayModelCatalog = vi.fn(async () => {
|
|
if (currentConfig === initialConfig) {
|
|
currentConfig = latestConfig;
|
|
}
|
|
return [{ id: "demo", name: "Demo", provider: "test" }];
|
|
});
|
|
|
|
const { request, respond } = requestModelsList({
|
|
view: "configured",
|
|
getRuntimeConfig: () => currentConfig,
|
|
loadGatewayModelCatalog,
|
|
});
|
|
await request;
|
|
|
|
expect(loadGatewayModelCatalog).toHaveBeenCalledOnce();
|
|
expect(respond).toHaveBeenCalledOnce();
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{ models: [expect.objectContaining({ id: "demo", provider: "test" })] },
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("escalates to the full owner when replacement config adds a provider wildcard", async () => {
|
|
const initialConfig = {
|
|
agents: { defaults: { models: { "test/demo": {} } } },
|
|
} as OpenClawConfig;
|
|
const latestConfig = {
|
|
agents: { defaults: { models: { "test/*": {} } } },
|
|
} as OpenClawConfig;
|
|
let currentConfig = initialConfig;
|
|
let firstLoad = true;
|
|
const loadGatewayModelCatalog = vi.fn(async (_params?: { readOnly?: boolean }) => {
|
|
if (firstLoad) {
|
|
firstLoad = false;
|
|
currentConfig = latestConfig;
|
|
}
|
|
return [{ id: "demo", name: "Demo", provider: "test" }];
|
|
});
|
|
|
|
const { request, respond } = requestModelsList({
|
|
view: "configured",
|
|
getRuntimeConfig: () => currentConfig,
|
|
loadGatewayModelCatalog,
|
|
});
|
|
await request;
|
|
|
|
expect(loadGatewayModelCatalog.mock.calls.map(([params]) => params?.readOnly)).toEqual([
|
|
true,
|
|
false,
|
|
]);
|
|
expect(respond).toHaveBeenCalledWith(true, { models: [] }, undefined);
|
|
});
|
|
|
|
it("reports API-key capability from provider auth contracts when requested", async () => {
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
includeProviderCapabilities: true,
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([
|
|
{ id: "claude-test", name: "Claude Test", provider: "anthropic" },
|
|
{ id: "copilot-test", name: "Copilot Test", provider: "github-copilot" },
|
|
{ id: "byteplus-test", name: "BytePlus Plan Test", provider: "byteplus-plan" },
|
|
{ id: "custom-test", name: "Custom Test", provider: "custom-cloud" },
|
|
]),
|
|
),
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: expect.arrayContaining([
|
|
expect.objectContaining({ provider: "anthropic", apiKeySupported: true }),
|
|
expect.objectContaining({ provider: "github-copilot", apiKeySupported: false }),
|
|
expect.objectContaining({ provider: "byteplus-plan", apiKeySupported: true }),
|
|
]),
|
|
},
|
|
undefined,
|
|
);
|
|
const payload = respond.mock.calls[0]?.[1] as
|
|
| { models: Array<{ provider: string; apiKeySupported?: boolean }> }
|
|
| undefined;
|
|
const custom = payload?.models.find((model) => model.provider === "custom-cloud");
|
|
expect(custom).toBeDefined();
|
|
expect(custom).not.toHaveProperty("apiKeySupported");
|
|
});
|
|
|
|
it("keeps source-authored provider inventory when the canonical catalog is missing", async () => {
|
|
const sourceProvider = {
|
|
baseUrl: "https://vllm.example/v1",
|
|
apiKey: {
|
|
source: "file",
|
|
provider: "mounted-json",
|
|
id: "/providers/vllm/apiKey",
|
|
},
|
|
models: [
|
|
{
|
|
id: "source-model",
|
|
name: "Source Model",
|
|
contextWindow: 128_000,
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
params: { temperature: 0.2 },
|
|
compat: { supportsDeveloperRole: false },
|
|
},
|
|
],
|
|
};
|
|
const sourceConfig = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"vllm/allowlisted": {},
|
|
},
|
|
},
|
|
},
|
|
secrets: {
|
|
providers: {
|
|
"mounted-json": {
|
|
source: "file",
|
|
path: "/tmp/openclaw-test-secrets.json",
|
|
mode: "json",
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
vllm: sourceProvider,
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
const runtimeConfig = {
|
|
...sourceConfig,
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
...sourceProvider,
|
|
apiKey: "test-key",
|
|
models: [{ id: "runtime-only", name: "Runtime Only" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
const loadGatewayModelCatalog = vi.fn(() =>
|
|
Promise.resolve([
|
|
{
|
|
id: "source-model",
|
|
name: "Source Model",
|
|
provider: "vllm",
|
|
status: "disabled",
|
|
contextWindow: 128_000,
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
},
|
|
]),
|
|
);
|
|
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
|
try {
|
|
const { request, respond } = requestModelsList({
|
|
view: "provider-config",
|
|
runtimeConfig,
|
|
loadGatewayModelCatalog,
|
|
reqId: "req-models-list-provider-config-source",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "source-model",
|
|
name: "Source Model",
|
|
provider: "vllm",
|
|
contextWindow: 128_000,
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
available: true,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
expect(loadGatewayModelCatalog).toHaveBeenCalledOnce();
|
|
expect(loadGatewayModelCatalog).toHaveBeenCalledWith(
|
|
expect.objectContaining({ readOnly: true }),
|
|
);
|
|
} finally {
|
|
clearRuntimeConfigSnapshot();
|
|
}
|
|
});
|
|
|
|
it("omits unknown provider-config availability", async () => {
|
|
const config = {
|
|
secrets: {
|
|
providers: {
|
|
"mounted-json": {
|
|
source: "file",
|
|
path: "/tmp/openclaw-test-secrets.json",
|
|
mode: "json",
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
baseUrl: "https://vllm.example/v1",
|
|
apiKey: {
|
|
source: "file",
|
|
provider: "mounted-json",
|
|
id: "/providers/vllm/apiKey",
|
|
},
|
|
models: [
|
|
{
|
|
id: "llama-secure",
|
|
name: "Llama Secure",
|
|
input: ["text", "image", "document"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
setRuntimeConfigSnapshot(config, config);
|
|
try {
|
|
const { request, respond } = requestModelsList({
|
|
view: "provider-config",
|
|
runtimeConfig: config,
|
|
loadGatewayModelCatalog: vi.fn(() => Promise.resolve([])),
|
|
reqId: "req-models-list-provider-config-unknown",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "llama-secure",
|
|
name: "Llama Secure",
|
|
provider: "vllm",
|
|
input: ["text", "image", "document"],
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
} finally {
|
|
clearRuntimeConfigSnapshot();
|
|
}
|
|
});
|
|
|
|
it("does not block the configured view on slow model catalog discovery", async () => {
|
|
await withoutOpenAIEnvAuth(async () => {
|
|
const catalog = createDeferred<never>();
|
|
const loadGatewayModelCatalog = vi.fn(() => catalog.promise);
|
|
const runtimeConfig = {
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "https://openai.example.com",
|
|
models: [{ id: "gpt-test", name: "GPT Test" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
try {
|
|
const { request, respond } = requestModelsList({
|
|
view: "configured",
|
|
runtimeConfig,
|
|
loadGatewayModelCatalog,
|
|
reqId: "req-models-list-slow-catalog",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "gpt-test",
|
|
name: "GPT Test",
|
|
provider: "openai",
|
|
agentRuntime: { id: "openclaw", source: "implicit" },
|
|
available: false,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
expect(loadGatewayModelCatalog).toHaveBeenCalledWith(
|
|
expect.objectContaining({ readOnly: true }),
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("keeps SecretRef configured fallback rows unknown when catalog discovery times out", async () => {
|
|
const catalog = createDeferred<never>();
|
|
const loadGatewayModelCatalog = vi.fn(() => catalog.promise);
|
|
const runtimeConfig = {
|
|
secrets: {
|
|
providers: {
|
|
"mounted-json": {
|
|
source: "file",
|
|
path: "/tmp/openclaw-test-secrets.json",
|
|
mode: "json",
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
baseUrl: "https://vllm.example/v1",
|
|
apiKey: {
|
|
source: "file",
|
|
provider: "mounted-json",
|
|
id: "/providers/vllm/apiKey",
|
|
},
|
|
models: [{ id: "llama-secure", name: "Llama Secure" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
try {
|
|
const { request, respond } = requestModelsList({
|
|
view: "configured",
|
|
runtimeConfig,
|
|
loadGatewayModelCatalog,
|
|
reqId: "req-models-list-secretref-timeout",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "llama-secure",
|
|
name: "Llama Secure",
|
|
provider: "vllm",
|
|
available: false,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("keeps the all view exact instead of timing out to a partial catalog", async () => {
|
|
await withoutOpenAIEnvAuth(async () => {
|
|
const catalog = createDeferred<[{ id: string; name: string; provider: string }]>();
|
|
const loadGatewayModelCatalog = vi.fn(() => catalog.promise);
|
|
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
try {
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
loadGatewayModelCatalog,
|
|
reqId: "req-models-list-all-slow-catalog",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(respond).not.toHaveBeenCalled();
|
|
|
|
catalog.resolve([{ id: "gpt-test", name: "GPT Test", provider: "openai" }]);
|
|
await vi.runAllTimersAsync();
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "gpt-test",
|
|
name: "GPT Test",
|
|
provider: "openai",
|
|
agentRuntime: { id: "codex", source: "implicit" },
|
|
available: false,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
expect(loadGatewayModelCatalog).toHaveBeenCalledWith(
|
|
expect.objectContaining({ readOnly: false }),
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("does not expose runtime params from catalog rows", async () => {
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([
|
|
{
|
|
id: "qwen-local",
|
|
name: "Qwen Local",
|
|
provider: "vllm",
|
|
params: { qwenThinkingFormat: "chat-template" },
|
|
},
|
|
]),
|
|
),
|
|
reqId: "req-models-list-redact-params",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [{ id: "qwen-local", name: "Qwen Local", provider: "vllm", available: false }],
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("loads the full catalog for provider-scoped configured view and filters only providers", async () => {
|
|
await withoutOpenAIEnvAuth(async () => {
|
|
const catalog = [
|
|
{ id: "claude-test", name: "Claude Test", provider: "anthropic" },
|
|
{ id: "gpt-5.4-codex", name: "GPT-5.4 Codex", provider: "openai" },
|
|
{ id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai" },
|
|
{ id: "llama-local", name: "Llama Local", provider: "vllm" },
|
|
{ id: "qwen-local", name: "Qwen Local", provider: "vllm" },
|
|
];
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai/*": {},
|
|
"vllm/*": {},
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
api: "openai-responses",
|
|
apiKey: "test-key",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
},
|
|
vllm: { apiKey: "test-key" },
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const loadConfiguredCatalog = vi.fn(() => Promise.resolve(catalog));
|
|
const { request: configuredRequest, respond: configuredRespond } = requestModelsList({
|
|
view: "configured",
|
|
runtimeConfig: cfg,
|
|
loadGatewayModelCatalog: loadConfiguredCatalog,
|
|
reqId: "req-models-list-provider-allowlist",
|
|
});
|
|
await configuredRequest;
|
|
|
|
expect(configuredRespond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "gpt-5.4",
|
|
name: "GPT-5.4 Codex",
|
|
provider: "openai",
|
|
agentRuntime: { id: "codex", source: "implicit" },
|
|
available: true,
|
|
},
|
|
{
|
|
id: "gpt-codex-test",
|
|
name: "GPT Codex Test",
|
|
provider: "openai",
|
|
agentRuntime: { id: "codex", source: "implicit" },
|
|
available: true,
|
|
},
|
|
{ id: "llama-local", name: "Llama Local", provider: "vllm", available: true },
|
|
{ id: "qwen-local", name: "Qwen Local", provider: "vllm", available: true },
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
expect(loadConfiguredCatalog).toHaveBeenCalledWith(
|
|
expect.objectContaining({ readOnly: false }),
|
|
);
|
|
|
|
const { request: allRequest, respond: allRespond } = requestModelsList({
|
|
view: "all",
|
|
runtimeConfig: cfg,
|
|
loadGatewayModelCatalog: vi.fn(() => Promise.resolve(catalog)),
|
|
reqId: "req-models-list-provider-allowlist-all",
|
|
});
|
|
await allRequest;
|
|
|
|
expect(allRespond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "claude-test",
|
|
name: "Claude Test",
|
|
provider: "anthropic",
|
|
available: false,
|
|
},
|
|
{
|
|
id: "gpt-5.4",
|
|
name: "GPT-5.4 Codex",
|
|
provider: "openai",
|
|
agentRuntime: { id: "codex", source: "implicit" },
|
|
available: true,
|
|
},
|
|
{
|
|
id: "gpt-codex-test",
|
|
name: "GPT Codex Test",
|
|
provider: "openai",
|
|
agentRuntime: { id: "codex", source: "implicit" },
|
|
available: true,
|
|
},
|
|
{ id: "llama-local", name: "Llama Local", provider: "vllm", available: true },
|
|
{ id: "qwen-local", name: "Qwen Local", provider: "vllm", available: true },
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("keeps keyless local provider wildcard discoveries visible with unknown availability", async () => {
|
|
await withoutOpenAIEnvAuth(async () => {
|
|
await withOpenClawTestState(
|
|
{
|
|
layout: "state-only",
|
|
prefix: "openclaw-models-list-local-wildcard-",
|
|
agentEnv: "main",
|
|
env: { VLLM_API_KEY: undefined },
|
|
},
|
|
async () => {
|
|
const catalog = [
|
|
{
|
|
id: "llama-configured",
|
|
name: "Llama Configured",
|
|
provider: "vllm",
|
|
api: "openai-completions",
|
|
baseUrl: "http://127.0.0.1:8000/v1",
|
|
},
|
|
{
|
|
id: "llama-discovered",
|
|
name: "Llama Discovered",
|
|
provider: "vllm",
|
|
api: "openai-completions",
|
|
baseUrl: "http://127.0.0.1:8000/v1",
|
|
},
|
|
];
|
|
const cfg = {
|
|
agents: { defaults: { models: { "vllm/*": {} } } },
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
api: "openai-completions",
|
|
baseUrl: "http://127.0.0.1:8000/v1",
|
|
models: [{ id: "llama-configured", name: "Llama Configured" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
const expected = {
|
|
models: [
|
|
{
|
|
id: "llama-configured",
|
|
name: "Llama Configured",
|
|
provider: "vllm",
|
|
available: true,
|
|
},
|
|
{
|
|
id: "llama-discovered",
|
|
name: "Llama Discovered",
|
|
provider: "vllm",
|
|
available: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
for (const view of ["default", "configured"] as const) {
|
|
const { request, respond } = requestModelsList({
|
|
view,
|
|
runtimeConfig: cfg,
|
|
loadGatewayModelCatalog: vi.fn(() => Promise.resolve(catalog)),
|
|
reqId: `req-models-list-local-wildcard-${view}`,
|
|
});
|
|
await request;
|
|
expect(respond).toHaveBeenCalledWith(true, expected, undefined);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
it("marks legacy OpenAI Codex aliases available through ChatGPT OAuth", async () => {
|
|
await withoutOpenAIEnvAuth(async () => {
|
|
await withOpenClawTestState(
|
|
{
|
|
layout: "state-only",
|
|
prefix: "openclaw-models-list-codex-alias-",
|
|
agentEnv: "main",
|
|
},
|
|
async (state) => {
|
|
await state.writeAuthProfiles({
|
|
version: 1,
|
|
profiles: {
|
|
"openai:chatgpt": {
|
|
type: "oauth",
|
|
provider: "openai",
|
|
access: "chatgpt-access",
|
|
refresh: "chatgpt-refresh",
|
|
expires: Date.now() + 30 * 60_000,
|
|
},
|
|
},
|
|
});
|
|
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([
|
|
{
|
|
id: "gpt-5.4-codex",
|
|
name: "GPT-5.4 Codex",
|
|
provider: "openai",
|
|
api: "openai-responses",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
},
|
|
]),
|
|
),
|
|
reqId: "req-models-list-codex-alias",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "gpt-5.4",
|
|
name: "GPT-5.4 Codex",
|
|
provider: "openai",
|
|
agentRuntime: { id: "codex", source: "implicit" },
|
|
available: true,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
it("marks catalog models available through their configured CLI runtime", async () => {
|
|
await withEnvAsync({ ANTHROPIC_API_KEY: undefined }, async () => {
|
|
await withOpenClawTestState(
|
|
{
|
|
layout: "state-only",
|
|
prefix: "openclaw-models-list-cli-runtime-",
|
|
agentEnv: "main",
|
|
},
|
|
async (state) => {
|
|
await state.writeAuthProfiles({
|
|
version: 1,
|
|
profiles: {
|
|
"anthropic:claude-cli": {
|
|
type: "oauth",
|
|
provider: "claude-cli",
|
|
access: "claude-cli-access",
|
|
refresh: "claude-cli-refresh",
|
|
expires: Date.now() + 30 * 60_000,
|
|
},
|
|
},
|
|
});
|
|
|
|
const runtimeConfig = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"anthropic/claude-opus-4-8": {
|
|
agentRuntime: { id: "claude-cli" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
runtimeConfig,
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([
|
|
{
|
|
id: "claude-opus-4-8",
|
|
name: "Claude Opus 4.8",
|
|
provider: "anthropic",
|
|
},
|
|
]),
|
|
),
|
|
reqId: "req-models-list-cli-runtime",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "claude-opus-4-8",
|
|
name: "Claude Opus 4.8",
|
|
provider: "anthropic",
|
|
agentRuntime: { id: "claude-cli", source: "model" },
|
|
available: true,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
it("keeps file SecretRef provider availability unknown when read-only auth cannot resolve it", async () => {
|
|
const catalog = [{ id: "llama-secure", name: "Llama Secure", provider: "vllm" }];
|
|
const cfg = {
|
|
secrets: {
|
|
providers: {
|
|
"mounted-json": {
|
|
source: "file",
|
|
path: "/tmp/openclaw-test-secrets.json",
|
|
mode: "json",
|
|
},
|
|
},
|
|
},
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"vllm/*": {},
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
apiKey: {
|
|
source: "file",
|
|
provider: "mounted-json",
|
|
id: "/providers/vllm/apiKey",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
runtimeConfig: cfg,
|
|
loadGatewayModelCatalog: vi.fn(() => Promise.resolve(catalog)),
|
|
reqId: "req-models-list-secretref-file",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [{ id: "llama-secure", name: "Llama Secure", provider: "vllm", available: false }],
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("keeps managed SecretRef provider availability unknown without runtime proof", async () => {
|
|
const catalog = [{ id: "llama-managed", name: "Llama Managed", provider: "vllm" }];
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"vllm/*": {},
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
apiKey: "secretref-managed",
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
runtimeConfig: cfg,
|
|
loadGatewayModelCatalog: vi.fn(() => Promise.resolve(catalog)),
|
|
reqId: "req-models-list-secretref-managed",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{ id: "llama-managed", name: "Llama Managed", provider: "vllm", available: false },
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("uses an exact hydrated runtime snapshot as managed SecretRef proof", async () => {
|
|
const sourceConfig: OpenClawConfig = {
|
|
secrets: {
|
|
providers: {
|
|
"mounted-json": {
|
|
source: "file",
|
|
path: "/tmp/openclaw-test-secrets.json",
|
|
mode: "json",
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
baseUrl: "https://vllm.example/v1",
|
|
apiKey: {
|
|
source: "file",
|
|
provider: "mounted-json",
|
|
id: "/providers/vllm/apiKey",
|
|
},
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const sourceProvider = expectDefined(
|
|
sourceConfig.models?.providers?.vllm,
|
|
"source vLLM provider",
|
|
);
|
|
const runtimeConfig: OpenClawConfig = {
|
|
...sourceConfig,
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
...sourceProvider,
|
|
apiKey: "resolved-runtime-key",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
|
try {
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
runtimeConfig: sourceConfig,
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([{ id: "llama-secure", name: "Llama Secure", provider: "vllm" }]),
|
|
),
|
|
reqId: "req-models-list-secretref-runtime-proof",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [{ id: "llama-secure", name: "Llama Secure", provider: "vllm", available: true }],
|
|
},
|
|
undefined,
|
|
);
|
|
} finally {
|
|
clearRuntimeConfigSnapshot();
|
|
}
|
|
});
|
|
|
|
it("does not mark catalog rows available from expired OAuth profiles", async () => {
|
|
await withOpenClawTestState(
|
|
{
|
|
layout: "state-only",
|
|
prefix: "openclaw-models-list-expired-profile-",
|
|
agentEnv: "main",
|
|
},
|
|
async (state) => {
|
|
await state.writeAuthProfiles(
|
|
createDemoOAuthStore({
|
|
access: "expired-access",
|
|
expires: Date.now() - 60_000,
|
|
}),
|
|
);
|
|
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([{ id: "demo-model", name: "Demo Model", provider: "demo-provider" }]),
|
|
),
|
|
reqId: "req-models-list-expired-profile",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "demo-model",
|
|
name: "Demo Model",
|
|
provider: "demo-provider",
|
|
available: false,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("uses refreshed persisted OAuth when the runtime auth snapshot is stale", async () => {
|
|
await withOpenClawTestState(
|
|
{
|
|
layout: "state-only",
|
|
prefix: "openclaw-models-list-stale-runtime-profile-",
|
|
agentEnv: "main",
|
|
},
|
|
async (state) => {
|
|
const agentDir = state.agentDir();
|
|
await state.writeAuthProfiles(
|
|
createDemoOAuthStore({
|
|
access: "refreshed-access",
|
|
expires: Date.now() + 60 * 60_000,
|
|
}),
|
|
);
|
|
replaceRuntimeAuthProfileStoreSnapshots([
|
|
{
|
|
agentDir,
|
|
store: createDemoOAuthStore({
|
|
access: "expired-access",
|
|
expires: Date.now() - 60_000,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
try {
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([
|
|
{ id: "demo-model", name: "Demo Model", provider: "demo-provider" },
|
|
]),
|
|
),
|
|
reqId: "req-models-list-stale-runtime-profile",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "demo-model",
|
|
name: "Demo Model",
|
|
provider: "demo-provider",
|
|
available: true,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
} finally {
|
|
clearRuntimeAuthProfileStoreSnapshots();
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
it("marks env SecretRef-backed auth profiles available", async () => {
|
|
await withOpenClawTestState(
|
|
{
|
|
layout: "state-only",
|
|
prefix: "openclaw-models-list-env-profile-",
|
|
agentEnv: "main",
|
|
env: {
|
|
DEMO_PROVIDER_TOKEN: "test-token",
|
|
},
|
|
},
|
|
async (state) => {
|
|
await state.writeAuthProfiles({
|
|
version: 1,
|
|
profiles: {
|
|
"demo-provider:env": {
|
|
type: "token",
|
|
provider: "demo-provider",
|
|
tokenRef: {
|
|
source: "env",
|
|
provider: "default",
|
|
id: "DEMO_PROVIDER_TOKEN",
|
|
},
|
|
expires: Date.now() + 60_000,
|
|
},
|
|
},
|
|
});
|
|
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([{ id: "demo-model", name: "Demo Model", provider: "demo-provider" }]),
|
|
),
|
|
reqId: "req-models-list-env-profile",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "demo-model",
|
|
name: "Demo Model",
|
|
provider: "demo-provider",
|
|
available: true,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("keeps non-env SecretRef-backed auth profile availability unknown", async () => {
|
|
await withOpenClawTestState(
|
|
{
|
|
layout: "state-only",
|
|
prefix: "openclaw-models-list-file-profile-",
|
|
agentEnv: "main",
|
|
},
|
|
async (state) => {
|
|
await state.writeAuthProfiles({
|
|
version: 1,
|
|
profiles: {
|
|
"demo-provider:file": {
|
|
type: "token",
|
|
provider: "demo-provider",
|
|
tokenRef: {
|
|
source: "file",
|
|
provider: "mounted-json",
|
|
id: "/providers/demo/token",
|
|
},
|
|
expires: Date.now() + 60_000,
|
|
},
|
|
},
|
|
});
|
|
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
runtimeConfig: {
|
|
secrets: {
|
|
providers: {
|
|
"mounted-json": {
|
|
source: "file",
|
|
path: "/tmp/openclaw-test-secrets.json",
|
|
mode: "json",
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([{ id: "demo-model", name: "Demo Model", provider: "demo-provider" }]),
|
|
),
|
|
reqId: "req-models-list-file-profile",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "demo-model",
|
|
name: "Demo Model",
|
|
provider: "demo-provider",
|
|
available: false,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("uses an exact hydrated runtime profile SecretRef as read-only proof", async () => {
|
|
await withOpenClawTestState(
|
|
{
|
|
layout: "state-only",
|
|
prefix: "openclaw-models-list-hydrated-file-profile-",
|
|
agentEnv: "main",
|
|
},
|
|
async (state) => {
|
|
const tokenRef = {
|
|
source: "file" as const,
|
|
provider: "mounted-json",
|
|
id: "/providers/demo/token",
|
|
};
|
|
const persisted = {
|
|
version: 1 as const,
|
|
profiles: {
|
|
"demo-provider:file": {
|
|
type: "token" as const,
|
|
provider: "demo-provider",
|
|
tokenRef,
|
|
expires: Date.now() + 10 * 60_000,
|
|
},
|
|
},
|
|
};
|
|
await state.writeAuthProfiles(persisted);
|
|
replaceRuntimeAuthProfileStoreSnapshots([
|
|
{
|
|
agentDir: state.agentDir(),
|
|
store: {
|
|
...persisted,
|
|
profiles: {
|
|
"demo-provider:file": {
|
|
...persisted.profiles["demo-provider:file"],
|
|
token: "resolved-runtime-token",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
try {
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
runtimeConfig: {
|
|
secrets: {
|
|
providers: {
|
|
"mounted-json": {
|
|
source: "file",
|
|
path: "/tmp/openclaw-test-secrets.json",
|
|
mode: "json",
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([
|
|
{ id: "demo-model", name: "Demo Model", provider: "demo-provider" },
|
|
]),
|
|
),
|
|
reqId: "req-models-list-hydrated-file-profile",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "demo-model",
|
|
name: "Demo Model",
|
|
provider: "demo-provider",
|
|
available: true,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
} finally {
|
|
clearRuntimeAuthProfileStoreSnapshots();
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
it("marks auth profiles available even when provider config uses non-env SecretRef markers", async () => {
|
|
for (const fixture of [
|
|
{
|
|
name: "file",
|
|
apiKey: {
|
|
source: "file",
|
|
provider: "mounted-json",
|
|
id: "/providers/vllm/apiKey",
|
|
},
|
|
},
|
|
{ name: "managed-marker", apiKey: "secretref-managed" },
|
|
] as const) {
|
|
await withOpenClawTestState(
|
|
{
|
|
layout: "state-only",
|
|
prefix: `openclaw-models-list-provider-${fixture.name}-profile-`,
|
|
agentEnv: "main",
|
|
env: {
|
|
OPENCLAW_TEST_PROFILE_API_KEY: "test-token",
|
|
VLLM_API_KEY: undefined,
|
|
},
|
|
},
|
|
async (state) => {
|
|
await state.writeAuthProfiles({
|
|
version: 1,
|
|
profiles: {
|
|
"vllm:env": {
|
|
type: "api_key",
|
|
provider: "vllm",
|
|
keyRef: {
|
|
source: "env",
|
|
provider: "default",
|
|
id: "OPENCLAW_TEST_PROFILE_API_KEY",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"vllm/*": {},
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
apiKey: fixture.apiKey,
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
runtimeConfig: cfg,
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([{ id: "llama-secure", name: "Llama Secure", provider: "vllm" }]),
|
|
),
|
|
reqId: `req-models-list-provider-${fixture.name}-profile`,
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "llama-secure",
|
|
name: "Llama Secure",
|
|
provider: "vllm",
|
|
available: true,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
});
|
|
|
|
it("projects only public model fields", async () => {
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([
|
|
{
|
|
id: "demo-model",
|
|
name: "Demo Model",
|
|
provider: "demo-provider",
|
|
contextWindow: 0,
|
|
reasoning: "yes",
|
|
api: "openai-responses",
|
|
baseUrl: "https://private.example.test/v1",
|
|
authRequirement: "api-key",
|
|
agentRuntime: { id: "private-runtime" },
|
|
params: { private: true },
|
|
},
|
|
]),
|
|
),
|
|
reqId: "req-models-list-safe-public-projection",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "demo-model",
|
|
name: "Demo Model",
|
|
provider: "demo-provider",
|
|
available: false,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("does not reinterpret context tokens or expose model input metadata", async () => {
|
|
const { request, respond } = requestModelsList({
|
|
view: "all",
|
|
loadGatewayModelCatalog: vi.fn(() =>
|
|
Promise.resolve([
|
|
{
|
|
id: "vision-model",
|
|
name: "Vision Model",
|
|
provider: "demo-provider",
|
|
contextWindow: 128_000,
|
|
contextTokens: 96_000,
|
|
input: ["text", "image", "private-runtime-capability", "image"],
|
|
},
|
|
]),
|
|
),
|
|
reqId: "req-models-list-public-capabilities",
|
|
});
|
|
await request;
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
true,
|
|
{
|
|
models: [
|
|
{
|
|
id: "vision-model",
|
|
name: "Vision Model",
|
|
provider: "demo-provider",
|
|
available: false,
|
|
contextWindow: 128_000,
|
|
},
|
|
],
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("preserves catalog load errors before the timeout fallback wins", async () => {
|
|
const { request, respond } = requestModelsList({
|
|
view: "configured",
|
|
loadGatewayModelCatalog: vi.fn(() => Promise.reject(new Error("catalog failed"))),
|
|
reqId: "req-models-list-catalog-error",
|
|
});
|
|
await request;
|
|
|
|
expectGatewayErrorResponse(respond, {
|
|
code: ErrorCodes.UNAVAILABLE,
|
|
message: "Error: catalog failed",
|
|
});
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|