mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 15:18:58 +00:00
fix(models): hide unauthenticated catalog entries
This commit is contained in:
@@ -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 <provider> 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
<ParamField path="--all" type="boolean">
|
||||
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.
|
||||
|
||||
@@ -141,7 +141,7 @@ Current source-of-truth:
|
||||
- `/elevated [on|off|ask|full]` toggles elevated mode. Alias: `/elev`.
|
||||
- `/exec host=<auto|sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` shows or sets exec defaults.
|
||||
- `/model [name|#|status]` shows or sets the model.
|
||||
- `/models [provider] [page] [limit=<n>|size=<n>|all]` lists providers or models for a provider.
|
||||
- `/models [provider] [page] [limit=<n>|size=<n>|all]` lists configured/auth-available providers or models for a provider; add `all` to browse that provider's full catalog.
|
||||
- `/queue <mode>` manages queue behavior (`steer`, `interrupt`, `followup`, `collect`, `steer-backlog`) plus options like `debounce:2s cap:25 drop:summarize`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -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.
|
||||
|
||||
</Accordion>
|
||||
|
||||
65
src/agents/model-catalog-visibility.ts
Normal file
65
src/agents/model-catalog-visibility.ts
Normal file
@@ -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<string>();
|
||||
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]),
|
||||
);
|
||||
}
|
||||
60
src/agents/model-provider-auth.ts
Normal file
60
src/agents/model-provider-auth.ts
Normal file
@@ -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<string, boolean>();
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<ModelsProviderData> {
|
||||
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<string, string>();
|
||||
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 });
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<typeof ensureAuthProfileStore>,
|
||||
) {
|
||||
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<string, boolean>();
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown>): 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)));
|
||||
|
||||
@@ -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 <T>(config: unknown, run: () => Promise<T>): Promise<T> => {
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user