diff --git a/CHANGELOG.md b/CHANGELOG.md index 905cd1f2087..9aeb532c301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Models/UI: hide unauthenticated providers from the default Web chat, `/models`, and model setup pickers while keeping explicit full-catalog browse paths through `view: "all"`, `/models all`, and `models list --all`. Fixes #74423. Thanks @guarismo and @SymbolStar. - Exec: reject invalid per-call `host` values instead of silently falling back to the default target, so hostname-like values fail before commands run. Fixes #74426. Thanks @scr00ge-00 and @vyctorbrzezowski. - Google/Gemini: send non-empty placeholder content when a Gemini run is triggered with empty or filtered user content, avoiding `contents is not specified` API errors. Thanks @CaoYuhaoCarl. - Heartbeat: preserve non-task `HEARTBEAT.md` context around `tasks:` blocks and apply `agents.defaults.heartbeat` to all agents unless per-agent heartbeat entries restrict scope. Thanks @Sekhar03. diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 0a3d7b41b31..b4d30ecec95 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -63,7 +63,7 @@ The same `provider/model` can mean different things depending on where it came f - User session selections are exact. `/model`, the model picker, `session_status(model=...)`, and `sessions.patch` store `modelOverrideSource: "user"`; if that selected provider/model is unreachable, OpenClaw fails visibly instead of falling through to another configured model. - Cron `--model` / payload `model` is a per-job primary. It still uses configured fallbacks unless the job supplies explicit payload `fallbacks` (use `fallbacks: []` for a strict cron run). - CLI default-model and allowlist pickers respect `models.mode: "replace"` by listing explicit `models.providers.*.models` instead of loading the full built-in catalog. -- The Control UI model picker asks the Gateway for its configured model view: `agents.defaults.models` when present, otherwise explicit `models.providers.*.models`, otherwise the full catalog so fresh installs are not blank. +- The Control UI model picker asks the Gateway for its configured model view: `agents.defaults.models` when present, otherwise explicit `models.providers.*.models` plus providers with usable auth. The full built-in catalog is reserved for explicit browse views such as `models.list` with `view: "all"` or `openclaw models list --all`. ## Quick model policy @@ -219,7 +219,7 @@ openclaw models image-fallbacks clear ### `models list` -Shows configured models by default. Useful flags: +Shows configured/auth-available models by default. Useful flags: Full catalog. Includes bundled provider-owned static catalog rows before auth is configured, so discovery-only views can show models that are unavailable until you add matching provider credentials. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 8e387a9803c..f340d9dc2d2 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -141,7 +141,7 @@ Current source-of-truth: - `/elevated [on|off|ask|full]` toggles elevated mode. Alias: `/elev`. - `/exec host= security= ask= node=` shows or sets exec defaults. - `/model [name|#|status]` shows or sets the model. - - `/models [provider] [page] [limit=|size=|all]` lists providers or models for a provider. + - `/models [provider] [page] [limit=|size=|all]` lists configured/auth-available providers or models for a provider; add `all` to browse that provider's full catalog. - `/queue ` manages queue behavior (`steer`, `interrupt`, `followup`, `collect`, `steer-backlog`) plus options like `debounce:2s cap:25 drop:summarize`. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index e5bba9bf971..4c41a0d7263 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -155,7 +155,7 @@ Imported themes are stored only in the current browser profile. They are not wri - During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up. - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). - The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. - - The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker. Otherwise the picker shows explicit `models.providers.*.models` entries before falling back to the full catalog for fresh installs. + - The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker. Otherwise the picker shows explicit `models.providers.*.models` entries plus providers with usable auth. The full catalog stays available through the debug `models.list` RPC with `view: "all"`. - When fresh Gateway session usage reports show high context pressure, the chat composer area shows a context notice and, at recommended compaction levels, a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again. diff --git a/src/agents/model-catalog-visibility.ts b/src/agents/model-catalog-visibility.ts new file mode 100644 index 00000000000..a0fe795847a --- /dev/null +++ b/src/agents/model-catalog-visibility.ts @@ -0,0 +1,65 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ModelCatalogEntry } from "./model-catalog.js"; +import { createProviderAuthChecker } from "./model-provider-auth.js"; +import { buildAllowedModelSet, buildConfiguredModelCatalog, modelKey } from "./model-selection.js"; + +export type ModelCatalogVisibilityView = "default" | "configured" | "all"; + +function sortModelCatalogEntries(entries: ModelCatalogEntry[]): ModelCatalogEntry[] { + return entries.toSorted( + (a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id), + ); +} + +function dedupeModelCatalogEntries(entries: ModelCatalogEntry[]): ModelCatalogEntry[] { + const seen = new Set(); + const next: ModelCatalogEntry[] = []; + for (const entry of entries) { + const key = modelKey(entry.provider, entry.id); + if (seen.has(key)) { + continue; + } + seen.add(key); + next.push(entry); + } + return next; +} + +export function resolveVisibleModelCatalog(params: { + cfg: OpenClawConfig; + catalog: ModelCatalogEntry[]; + defaultProvider: string; + defaultModel?: string; + agentId?: string; + agentDir?: string; + env?: NodeJS.ProcessEnv; + view?: ModelCatalogVisibilityView; +}): ModelCatalogEntry[] { + if (params.view === "all") { + return params.catalog; + } + + const allowed = buildAllowedModelSet({ + cfg: params.cfg, + catalog: params.catalog, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + agentId: params.agentId, + }); + if (!allowed.allowAny && allowed.allowedCatalog.length > 0) { + return sortModelCatalogEntries(allowed.allowedCatalog); + } + + const configuredCatalog = sortModelCatalogEntries( + buildConfiguredModelCatalog({ cfg: params.cfg }), + ); + const hasAuth = createProviderAuthChecker({ + cfg: params.cfg, + agentDir: params.agentDir, + env: params.env, + }); + const authBackedCatalog = params.catalog.filter((entry) => hasAuth(entry.provider)); + return sortModelCatalogEntries( + dedupeModelCatalogEntries([...configuredCatalog, ...authBackedCatalog]), + ); +} diff --git a/src/agents/model-provider-auth.ts b/src/agents/model-provider-auth.ts new file mode 100644 index 00000000000..4b45924a121 --- /dev/null +++ b/src/agents/model-provider-auth.ts @@ -0,0 +1,60 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, + type AuthProfileStore, +} from "./auth-profiles.js"; +import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "./model-auth.js"; +import { normalizeProviderId } from "./model-selection.js"; + +export function hasAuthForModelProvider(params: { + provider: string; + cfg?: OpenClawConfig; + agentDir?: string; + env?: NodeJS.ProcessEnv; + store?: AuthProfileStore; +}): boolean { + const provider = normalizeProviderId(params.provider); + const store = + params.store ?? + ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + if (listProfilesForProvider(store, provider).length > 0) { + return true; + } + if (resolveEnvApiKey(provider, params.env)?.apiKey) { + return true; + } + if (hasUsableCustomProviderApiKey(params.cfg, provider, params.env)) { + return true; + } + return false; +} + +export function createProviderAuthChecker(params: { + cfg?: OpenClawConfig; + agentDir?: string; + env?: NodeJS.ProcessEnv; +}): (provider: string) => boolean { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const authCache = new Map(); + return (provider: string) => { + const key = normalizeProviderId(provider); + const cached = authCache.get(key); + if (cached !== undefined) { + return cached; + } + const value = hasAuthForModelProvider({ + provider: key, + cfg: params.cfg, + agentDir: params.agentDir, + env: params.env, + store, + }); + authCache.set(key, value); + return value; + }; +} diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index 020a095abf7..f70052c7707 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -16,6 +16,9 @@ const modelCatalogMocks = vi.hoisted(() => ({ const modelAuthLabelMocks = vi.hoisted(() => ({ resolveModelAuthLabel: vi.fn<(params: unknown) => string | undefined>(() => undefined), })); +const modelProviderAuthMocks = vi.hoisted(() => ({ + authenticatedProviders: new Set(["anthropic", "google", "openai"]), +})); const MODELS_ADD_DEPRECATED_TEXT = "⚠️ /models add is deprecated. Use /models to browse providers and /model to switch models."; @@ -28,6 +31,13 @@ vi.mock("../../agents/model-auth-label.js", () => ({ resolveModelAuthLabel: modelAuthLabelMocks.resolveModelAuthLabel, })); +vi.mock("../../agents/model-provider-auth.js", () => ({ + createProviderAuthChecker: () => (provider: string) => + modelProviderAuthMocks.authenticatedProviders.has(provider), + hasAuthForModelProvider: ({ provider }: { provider: string }) => + modelProviderAuthMocks.authenticatedProviders.has(provider), +})); + const telegramModelsTestPlugin: ChannelPlugin = { ...createChannelTestPluginBase({ id: "telegram", @@ -93,6 +103,7 @@ beforeEach(() => { ]); modelAuthLabelMocks.resolveModelAuthLabel.mockReset(); modelAuthLabelMocks.resolveModelAuthLabel.mockReturnValue(undefined); + modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "google", "openai"]); setActivePluginRegistry( createTestRegistry([ ...textSurfaceModelsTestPlugins, @@ -170,6 +181,23 @@ describe("handleModelsCommand", () => { expect(result?.reply?.text).not.toContain("Add: /models add"); }); + it("hides unauthenticated providers by default and keeps all as explicit browse", async () => { + modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic"]); + + const providersResult = await handleModelsCommand(buildParams("/models"), true); + expect(providersResult?.reply?.text).toContain("- anthropic (2)"); + expect(providersResult?.reply?.text).not.toContain("- google"); + expect(providersResult?.reply?.text).not.toContain("- openai"); + + const defaultListResult = await handleModelsCommand(buildParams("/models openai"), true); + expect(defaultListResult?.reply?.text).toContain("Unknown provider: openai"); + + const allListResult = await handleModelsCommand(buildParams("/models openai all"), true); + expect(allListResult?.reply?.text).toContain("Models (openai) — showing 1-2 of 2 (page 1/1)"); + expect(allListResult?.reply?.text).toContain("- openai/gpt-4.1"); + expect(allListResult?.reply?.text).toContain("- openai/gpt-4.1-mini"); + }); + it("hides legacy runtime providers from /models provider lists", async () => { modelCatalogMocks.loadModelCatalog.mockResolvedValueOnce([ { provider: "codex", id: "gpt-5.5", name: "GPT-5.5" }, diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index a63b25bdcec..a496d23aa35 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -1,10 +1,10 @@ import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; +import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { isModelPickerVisibleProvider } from "../../agents/model-picker-visibility.js"; import { listLegacyRuntimeModelProviderAliases } from "../../agents/model-runtime-aliases.js"; import { - buildAllowedModelSet, buildModelAliasIndex, normalizeProviderId, resolveBareModelDefaultProvider, @@ -63,6 +63,7 @@ type ParsedModelsCommand = export async function buildModelsProviderData( cfg: OpenClawConfig, agentId?: string, + options: { view?: "default" | "all" } = {}, ): Promise { const resolvedDefault = resolveDefaultModelForAgent({ cfg, @@ -70,12 +71,13 @@ export async function buildModelsProviderData( }); const catalog = await loadModelCatalog({ config: cfg }); - const allowed = buildAllowedModelSet({ + const visibleCatalog = resolveVisibleModelCatalog({ cfg, catalog, defaultProvider: resolvedDefault.provider, defaultModel: resolvedDefault.model, agentId, + view: options.view, }); const aliasIndex = buildModelAliasIndex({ @@ -140,7 +142,7 @@ export async function buildModelsProviderData( } }; - for (const entry of allowed.allowedCatalog) { + for (const entry of visibleCatalog) { add(entry.provider, entry.id); } @@ -154,7 +156,7 @@ export async function buildModelsProviderData( const providers = [...byProvider.keys()].toSorted(); const modelNames = new Map(); - for (const entry of catalog) { + for (const entry of [...catalog, ...visibleCatalog]) { if (entry.name && entry.name !== entry.id) { modelNames.set(`${normalizeProviderId(entry.provider)}/${entry.id}`, entry.name); } @@ -340,6 +342,7 @@ export async function resolveModelsCommandReply(params: { const { byProvider, providers, modelNames } = await buildModelsProviderData( params.cfg, params.agentId, + parsed.action === "list" && parsed.all ? { view: "all" } : undefined, ); const commandPlugin = params.surface ? getChannelPlugin(params.surface) : null; const providerInfos = buildProviderInfos({ providers, byProvider }); diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 80c9b54e522..c288c9c8622 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -35,7 +35,9 @@ vi.mock("../agents/auth-profiles.js", () => ({ upsertAuthProfile, })); -const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined)); +const resolveEnvApiKey = vi.hoisted(() => + vi.fn((_provider: string) => ({ apiKey: "test-key", source: "test" })), +); const hasUsableCustomProviderApiKey = vi.hoisted(() => vi.fn(() => false)); vi.mock("../agents/model-auth.js", () => ({ resolveEnvApiKey, @@ -120,6 +122,12 @@ function configuredTextModel(id: string, name: string) { beforeEach(() => { vi.clearAllMocks(); loadStaticManifestCatalogRowsForList.mockReturnValue([]); + listProfilesForProvider.mockReturnValue([]); + resolveEnvApiKey.mockImplementation((_provider: string) => ({ + apiKey: "test-key", + source: "test", + })); + hasUsableCustomProviderApiKey.mockReturnValue(false); providerModelPickerContributionRuntime.enabled = false; resolveOwningPluginIdsForProvider.mockImplementation(({ provider }: { provider: string }) => { if (provider === "byteplus" || provider === "byteplus-plan") { @@ -173,6 +181,30 @@ describe("promptDefaultModel", () => { ); }); + it("hides unauthenticated catalog entries from default model choices", async () => { + resolveEnvApiKey.mockReturnValue(undefined); + loadModelCatalog.mockResolvedValue([ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" }, + { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }, + ]); + + const select = vi.fn(async (params) => params.initialValue as never); + const prompter = makePrompter({ select }); + + await promptDefaultModel({ + config: { agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } } }, + prompter, + allowKeep: false, + includeManual: false, + ignoreAllowlist: true, + }); + + const values = (select.mock.calls[0]?.[0]?.options ?? []).map( + (option: { value: string }) => option.value, + ); + expect(values).toEqual(["anthropic/claude-sonnet-4-6"]); + }); + it("hides legacy runtime providers from default model choices", async () => { loadModelCatalog.mockResolvedValue([ { provider: "codex", id: "gpt-5.5", name: "GPT-5.5" }, diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 653da80f032..f9c12e8fac5 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -1,15 +1,14 @@ -import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; +import { resolveVisibleModelCatalog } from "../agents/model-catalog-visibility.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { isModelPickerVisibleModelRef, isModelPickerVisibleProvider, } from "../agents/model-picker-visibility.js"; +import { createProviderAuthChecker } from "../agents/model-provider-auth.js"; import { formatLiteralProviderPrefixedModelRef } from "../agents/model-ref-shared.js"; import { - buildAllowedModelSet, buildConfiguredModelCatalog, buildModelAliasIndex, type ModelAliasIndex, @@ -73,42 +72,6 @@ const loadResolvedModelPickerRuntime = createLazyRuntimeSurface( ({ modelPickerRuntime }) => modelPickerRuntime, ); -function hasAuthForProvider( - provider: string, - cfg: OpenClawConfig, - store: ReturnType, -) { - if (listProfilesForProvider(store, provider).length > 0) { - return true; - } - if (resolveEnvApiKey(provider)) { - return true; - } - if (hasUsableCustomProviderApiKey(cfg, provider)) { - return true; - } - return false; -} - -function createProviderAuthChecker(params: { - cfg: OpenClawConfig; - agentDir?: string; -}): (provider: string) => boolean { - const authStore = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const authCache = new Map(); - return (provider: string) => { - const cached = authCache.get(provider); - if (cached !== undefined) { - return cached; - } - const value = hasAuthForProvider(provider, params.cfg, authStore); - authCache.set(provider, value); - return value; - }; -} - function resolveConfiguredModelRaw(cfg: OpenClawConfig): string { return resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model) ?? ""; } @@ -744,14 +707,14 @@ export async function promptDefaultModel( }); const models = ignoreAllowlist ? catalog - : (() => { - const { allowedCatalog } = buildAllowedModelSet({ - cfg, - catalog, - defaultProvider: DEFAULT_PROVIDER, - }); - return allowedCatalog.length > 0 ? allowedCatalog : catalog; - })(); + : resolveVisibleModelCatalog({ + cfg, + catalog, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: resolved.model, + agentDir: params.agentDir, + env: params.env, + }); if (models.length === 0) { return promptManualModel({ prompter: params.prompter, @@ -786,7 +749,7 @@ export async function promptDefaultModel( const hasPreferredProvider = preferredProvider ? filteredModels.some((entry) => matchesPreferredProvider?.(entry.provider)) : false; - const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); + const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir, env: params.env }); const literalPrefixProviders = await resolveCachedLiteralPrefixProviders(); // Show the literal form (e.g. nvidia/nvidia/...) in the "Keep current" label @@ -949,7 +912,7 @@ export async function promptModelAllowlist(params: { fallbackKeys.length > 0 || (params.initialSelections?.length ?? 0) > 0 || configuredRaw.length > 0; - const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); + const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir, env: params.env }); const matchesPreferredProvider = preferredProvider ? createPreferredProviderMatcher({ preferredProvider, diff --git a/src/gateway/server-methods/models.ts b/src/gateway/server-methods/models.ts index 874992b8120..95a6c39d098 100644 --- a/src/gateway/server-methods/models.ts +++ b/src/gateway/server-methods/models.ts @@ -1,6 +1,5 @@ import { DEFAULT_PROVIDER } from "../../agents/defaults.js"; -import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; -import { buildAllowedModelSet, buildConfiguredModelCatalog } from "../../agents/model-selection.js"; +import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js"; import { ErrorCodes, errorShape, @@ -11,12 +10,6 @@ import type { GatewayRequestHandlers } from "./types.js"; type ModelsListView = "default" | "configured" | "all"; -function sortModelCatalogEntries(entries: ModelCatalogEntry[]): ModelCatalogEntry[] { - return entries.toSorted( - (a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id), - ); -} - function resolveModelsListView(params: Record): ModelsListView { return typeof params.view === "string" ? (params.view as ModelsListView) : "default"; } @@ -42,21 +35,12 @@ export const modelsHandlers: GatewayRequestHandlers = { respond(true, { models: catalog }, undefined); return; } - const allowed = buildAllowedModelSet({ + const models = resolveVisibleModelCatalog({ cfg, catalog, defaultProvider: DEFAULT_PROVIDER, + view, }); - const configuredCatalog = - view === "configured" ? sortModelCatalogEntries(buildConfiguredModelCatalog({ cfg })) : []; - const models = - view === "configured" && allowed.allowAny && configuredCatalog.length > 0 - ? configuredCatalog - : allowed.allowedCatalog.length > 0 - ? allowed.allowedCatalog - : configuredCatalog.length > 0 - ? configuredCatalog - : catalog; respond(true, { models }, undefined); } catch (err) { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err))); diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index 0e8e63634a1..47ae9aa575c 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -3,6 +3,7 @@ import { createServer } from "node:net"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { WebSocket } from "ws"; +import { resetModelCatalogCacheForTest } from "../agents/model-catalog.js"; import type { ChannelOutboundAdapter } from "../channels/plugins/types.js"; import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js"; @@ -153,9 +154,14 @@ describe("gateway server models + voicewake", () => { : await rpcReq<{ models: ModelCatalogRpcEntry[] }>(ws, "models.list"), ); - const seedPiCatalog = () => { + const setPiCatalog = (entries: PiCatalogFixtureEntry[]) => { piSdkMock.enabled = true; - piSdkMock.models = buildPiCatalogFixture(); + piSdkMock.models = entries; + resetModelCatalogCacheForTest(); + }; + + const seedPiCatalog = () => { + setPiCatalog(buildPiCatalogFixture()); }; const withModelsConfig = async (config: unknown, run: () => Promise): Promise => { @@ -465,11 +471,11 @@ describe("gateway server models + voicewake", () => { }); }); - test("models.list returns model catalog", async () => { + test("models.list all view returns model catalog", async () => { seedPiCatalog(); - const res1 = await listModels(); - const res2 = await listModels(); + const res1 = await listModels({ view: "all" }); + const res2 = await listModels({ view: "all" }); expect(res1.ok).toBe(true); expect(res2.ok).toBe(true); @@ -480,7 +486,7 @@ describe("gateway server models + voicewake", () => { expect(piSdkMock.discoverCalls).toBe(1); }); - test("models.list keeps default view on the full catalog when no allowlist is configured", async () => { + test("models.list default view uses configured providers instead of the full catalog", async () => { await withModelsConfig( { models: { @@ -493,10 +499,49 @@ describe("gateway server models + voicewake", () => { }, }, async () => { - seedPiCatalog(); + setPiCatalog([ + { id: "remote-a", provider: "unauth-a", name: "Remote A" }, + { id: "remote-b", provider: "unauth-b", name: "Remote B" }, + ]); const res = await listModels(); expect(res.ok).toBe(true); - expect(res.payload?.models).toEqual(expectedSortedCatalog()); + expect(res.payload?.models).toEqual([ + { + id: "MiniMax-M2.7-highspeed", + name: "MiniMax M2.7 Highspeed", + provider: "minimax", + }, + ]); + }, + ); + }); + + test("models.list configured view includes auth-backed provider catalog entries", async () => { + await withEnvAsync( + { + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_OAUTH_TOKEN: undefined, + OPENAI_API_KEY: "test-openai-key", + }, + async () => { + await withModelsConfig({}, async () => { + seedPiCatalog(); + const res = await listModels({ view: "configured" }); + expect(res.ok).toBe(true); + expect(res.payload?.models).toEqual([ + { + id: "gpt-test-a", + name: "A-Model", + provider: "openai", + contextWindow: 8000, + }, + { + id: "gpt-test-z", + name: "gpt-test-z", + provider: "openai", + }, + ]); + }); }, ); }); @@ -518,7 +563,10 @@ describe("gateway server models + voicewake", () => { }, }, async () => { - seedPiCatalog(); + setPiCatalog([ + { id: "remote-a", provider: "unauth-a", name: "Remote A" }, + { id: "remote-b", provider: "unauth-b", name: "Remote B" }, + ]); const res = await listModels({ view: "configured" }); expect(res.ok).toBe(true); expect(res.payload?.models).toEqual([