From 5ae401d9084f76ef4908b584bfff44962fa271f4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 00:01:37 +0800 Subject: [PATCH] fix(models): reduce default model-list memory use (#117323) * fix(models): scope default catalog discovery * fix(models): split scoped catalog preparation * perf(models): narrow model-list imports * perf(auth): narrow provider id imports * perf(plugins): narrow provider discovery imports * perf(models): bypass runtime catalog facade for scoped lists * perf(models): avoid runtime imports in default list * fix(models): preserve scoped catalog coverage * chore(models): remove obsolete auth exports * perf(models): skip redundant configured-provider discovery * perf(models): skip canonical row runtime loading * fix(models): normalize configured fallback rows * test(models): update registry list fixtures * fix(models): separate catalog and runtime discovery scopes * fix(models): break row projection import cycle * fix(models): satisfy type and export checks * fix(models): keep row projection bundled-only * fix(models): preserve partial catalog visibility * fix(models): avoid live discovery in default lists * test(models): isolate auth-backed catalog coverage * fix(models): retain OpenAI runtime normalization * fix(models): skip canonical OpenAI row runtime loading * test(models): keep provider policy fixtures typed * fix(models): preserve configured OpenAI routes --- extensions/openai/openai-provider.ts | 23 +- extensions/openai/provider-policy-api.ts | 61 ++- .../openai/provider-policy-projection.test.ts | 107 +++++ src/agents/model-auth-availability.ts | 84 +++- src/agents/model-auth-label.ts | 2 +- src/agents/model-auth-model.ts | 2 +- src/agents/model-auth-openai.ts | 2 +- src/agents/model-auth-provider-config.ts | 44 +- src/agents/model-auth-provider.ts | 6 +- src/agents/model-auth-runtime-config.ts | 89 ++++ src/agents/model-auth-runtime.ts | 122 +---- src/agents/model-auth.ts | 2 +- src/agents/prepared-model-catalog.test.ts | 54 +++ src/agents/prepared-model-catalog.ts | 33 ++ src/agents/prepared-model-runtime.facts.ts | 66 ++- .../prepared-model-runtime.scoped-catalog.ts | 53 +++ ...pared-model-runtime.startup-static.test.ts | 71 +++ src/cli/command-secret-gateway.ts | 2 +- src/commands/models.list.e2e.test.ts | 45 +- src/commands/models/list.auth-index.test.ts | 212 +++++++-- src/commands/models/list.auth-index.ts | 37 +- src/commands/models/list.configured.ts | 5 +- src/commands/models/list.format.test.ts | 23 +- src/commands/models/list.format.ts | 12 + .../list.list-command.forward-compat.test.ts | 37 ++ src/commands/models/list.list-command.ts | 33 +- .../models/list.manifest-catalog.test.ts | 133 +++++- src/commands/models/list.manifest-catalog.ts | 127 ++++- src/commands/models/list.model-projection.ts | 72 +++ src/commands/models/list.options.ts | 6 + .../models/list.persisted-catalog.test.ts | 98 ++++ src/commands/models/list.persisted-catalog.ts | 113 +++++ src/commands/models/list.registry-load.ts | 2 +- src/commands/models/list.registry.ts | 2 +- src/commands/models/list.row-context.ts | 28 ++ src/commands/models/list.rows.test.ts | 91 +++- src/commands/models/list.rows.ts | 106 ++--- .../models/list.scoped-catalog.test.ts | 435 ++++++++++++++++++ src/commands/models/list.scoped-catalog.ts | 244 ++++++++++ src/commands/models/list.table.ts | 3 +- src/commands/models/provider-aliases.ts | 2 +- src/commands/models/shared.test.ts | 25 +- src/commands/models/shared.ts | 20 +- .../server-methods/models-list-result.ts | 2 +- src/plugins/plugin-metadata-snapshot.test.ts | 4 +- src/plugins/plugin-metadata-snapshot.ts | 2 +- src/plugins/provider-discovery.ts | 2 +- src/plugins/provider-policy-surface.ts | 91 +++- src/plugins/provider-public-artifacts.test.ts | 3 + src/plugins/provider-public-artifacts.ts | 3 +- 50 files changed, 2386 insertions(+), 455 deletions(-) create mode 100644 extensions/openai/provider-policy-projection.test.ts create mode 100644 src/agents/model-auth-runtime-config.ts create mode 100644 src/agents/prepared-model-runtime.scoped-catalog.ts create mode 100644 src/commands/models/list.model-projection.ts create mode 100644 src/commands/models/list.options.ts create mode 100644 src/commands/models/list.persisted-catalog.test.ts create mode 100644 src/commands/models/list.persisted-catalog.ts create mode 100644 src/commands/models/list.row-context.ts create mode 100644 src/commands/models/list.scoped-catalog.test.ts create mode 100644 src/commands/models/list.scoped-catalog.ts diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 986e64a89a45..53f295d898aa 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -58,6 +58,7 @@ import { buildOpenAICodexProviderHooks, } from "./openai-chatgpt-provider.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; +import { resolveAuthoredOpenAIProviderConfig } from "./provider-policy-api.js"; import { buildOpenAIResponsesProviderHooks, buildOpenAISyntheticCatalogEntry, @@ -664,27 +665,7 @@ function resolveAuthoredOpenAIConfigRoute(params: { }): | { configuredModel?: ModelDefinitionConfig; configuredProvider: ModelProviderConfig } | undefined { - if (normalizeProviderId(params.provider) !== PROVIDER_ID) { - return undefined; - } - const providers = Object.entries(params.config?.models?.providers ?? {}); - const requestedProvider = params.provider.trim(); - const providerKey = - providers.find(([providerId]) => providerId.trim() === requestedProvider)?.[0].trim() ?? - providers.find(([providerId]) => normalizeProviderId(providerId) === PROVIDER_ID)?.[0].trim(); - let providerConfig: ModelProviderConfig | undefined; - for (const [providerId, candidate] of providers) { - if (providerId.trim() !== providerKey || !candidate) { - continue; - } - providerConfig = providerConfig - ? { - ...providerConfig, - ...candidate, - models: candidate.models ?? providerConfig.models, - } - : candidate; - } + const providerConfig = resolveAuthoredOpenAIProviderConfig(params); if (!providerConfig) { return undefined; } diff --git a/extensions/openai/provider-policy-api.ts b/extensions/openai/provider-policy-api.ts index 95a3fa32c39c..1e394f600674 100644 --- a/extensions/openai/provider-policy-api.ts +++ b/extensions/openai/provider-policy-api.ts @@ -1,5 +1,6 @@ // Openai API module exposes the plugin public contract. import type { ProviderDefaultThinkingPolicyContext } from "openclaw/plugin-sdk/core"; +import type { ProviderNormalizeResolvedModelContext } from "openclaw/plugin-sdk/plugin-entry"; import type { ModelApi, ModelProviderConfig, @@ -11,6 +12,7 @@ import type { } from "openclaw/plugin-sdk/provider-model-types"; import { classifyOpenAIBaseUrl, + isOpenAICodexBaseUrl, OPENAI_API_BASE_URL, OPENAI_CODEX_RESPONSES_BASE_URL, } from "./base-url.js"; @@ -25,6 +27,7 @@ import { resolveUnifiedOpenAIThinkingProfile } from "./thinking-policy.js"; const OPENAI_RESPONSES_API = "openai-responses"; const OPENAI_COMPLETIONS_API = "openai-completions"; const OPENAI_CHATGPT_RESPONSES_API = "openai-chatgpt-responses"; +const OPENAI_PROVIDER_ID = "openai"; const OPENAI_AGENT_RUNTIME_ID = "openclaw"; const CODEX_AGENT_RUNTIME_ID = "codex"; const OPENCLAW_RUNTIME_COMPATIBLE_IDS = [OPENAI_AGENT_RUNTIME_ID] as const; @@ -47,11 +50,67 @@ function normalizeOptionalRouteBaseUrl(value: unknown): string | undefined { /** Canonical logical id for OpenAI catalog projection. */ export function normalizeModelCatalogId(params: ProviderNormalizeModelCatalogIdContext) { - return params.provider.trim().toLowerCase() === "openai" + return params.provider.trim().toLowerCase() === OPENAI_PROVIDER_ID ? normalizeOpenAIModelRouteId(params.modelId) : null; } +/** Resolves authored OpenAI provider config without activating the runtime plugin. */ +export function resolveAuthoredOpenAIProviderConfig(params: { + provider: string; + config?: { models?: { providers?: Record } }; +}): ModelProviderConfig | undefined { + if (params.provider.trim().toLowerCase() !== OPENAI_PROVIDER_ID) { + return undefined; + } + const providers = Object.entries(params.config?.models?.providers ?? {}); + const requestedProvider = params.provider.trim(); + const providerKey = + providers.find(([providerId]) => providerId.trim() === requestedProvider)?.[0].trim() ?? + providers + .find(([providerId]) => providerId.trim().toLowerCase() === OPENAI_PROVIDER_ID)?.[0] + .trim(); + let providerConfig: ModelProviderConfig | undefined; + for (const [providerId, candidate] of providers) { + if (providerId.trim() !== providerKey || !candidate) { + continue; + } + providerConfig = providerConfig + ? { + ...providerConfig, + ...candidate, + models: candidate.models ?? providerConfig.models, + } + : candidate; + } + return providerConfig; +} + +/** + * Skips full runtime loading only when OpenAI normalization is provably a no-op. + * Transport-sensitive routes and legacy model aliases still use the runtime hook. + */ +export function projectConfiguredModelRow(ctx: ProviderNormalizeResolvedModelContext) { + if (ctx.provider.trim().toLowerCase() !== OPENAI_PROVIDER_ID) { + return undefined; + } + const configuredProvider = resolveAuthoredOpenAIProviderConfig(ctx); + const configuredApi = normalizeOptionalRouteApi(configuredProvider?.api); + const modelId = ctx.model.id; + const canonicalModelId = normalizeOpenAIModelRouteId(modelId); + const canonicalRouteId = normalizeOpenAIModelRouteId(ctx.modelId); + if ( + (configuredApi !== undefined && configuredApi !== OPENAI_RESPONSES_API) || + ctx.model.api !== OPENAI_RESPONSES_API || + isOpenAICodexBaseUrl(ctx.model.baseUrl) || + canonicalModelId !== modelId || + canonicalRouteId !== ctx.modelId + ) { + return undefined; + } + return null; +} + function firstRouteBaseUrl(...values: unknown[]): unknown { for (const value of values) { if (typeof value === "string") { diff --git a/extensions/openai/provider-policy-projection.test.ts b/extensions/openai/provider-policy-projection.test.ts new file mode 100644 index 000000000000..067b2d0bcbb0 --- /dev/null +++ b/extensions/openai/provider-policy-projection.test.ts @@ -0,0 +1,107 @@ +import type { ProviderNormalizeResolvedModelContext } from "openclaw/plugin-sdk/plugin-entry"; +import type { ModelApi } from "openclaw/plugin-sdk/provider-model-types"; +import { describe, expect, it } from "vitest"; +import { projectConfiguredModelRow } from "./provider-policy-api.js"; + +function createProjectionContext(params?: { + modelId?: string; + rowApi?: ModelApi; + rowBaseUrl?: string; + providerApi?: ModelApi; + configuredModelApi?: ModelApi; +}): ProviderNormalizeResolvedModelContext { + const modelId = params?.modelId ?? "gpt-5.5"; + return { + provider: "openai", + modelId, + ...(params?.providerApi + ? { + config: { + models: { + providers: { + openai: { + api: params.providerApi, + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: modelId, + name: "Configured OpenAI model", + reasoning: true, + input: ["text" as const], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 96_000, + maxTokens: 128_000, + ...(params.configuredModelApi ? { api: params.configuredModelApi } : {}), + }, + ], + }, + }, + }, + }, + } + : {}), + model: { + provider: "openai", + id: modelId, + api: params?.rowApi ?? ("openai-responses" as const), + baseUrl: params?.rowBaseUrl ?? "https://api.openai.com/v1", + input: ["text" as const], + name: "OpenAI model", + reasoning: true, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 96_000, + maxTokens: 128_000, + }, + }; +} + +describe("OpenAI configured-row projection", () => { + it("skips runtime normalization for canonical Responses rows", () => { + expect(projectConfiguredModelRow(createProjectionContext())).toBeNull(); + }); + + it.each([ + { + source: "provider", + providerApi: "openai-completions" as const, + configuredModelApi: undefined, + rowApi: undefined, + }, + { + source: "model", + providerApi: "openai-responses" as const, + configuredModelApi: "openai-completions" as const, + rowApi: "openai-completions" as const, + }, + { + source: "provider ChatGPT", + providerApi: "openai-chatgpt-responses" as const, + configuredModelApi: undefined, + rowApi: undefined, + }, + ])( + "keeps runtime normalization for a $source configured route", + ({ providerApi, configuredModelApi, rowApi }) => { + expect( + projectConfiguredModelRow( + createProjectionContext({ providerApi, configuredModelApi, rowApi }), + ), + ).toBeUndefined(); + }, + ); + + it.each([ + ["openai-completions", "https://api.openai.com/v1", "gpt-5.5"], + ["openai-chatgpt-responses", "https://chatgpt.com/backend-api/codex", "gpt-5.5"], + ["openai-responses", "https://chatgpt.com/backend-api/codex", "gpt-5.5"], + ["anthropic-messages", "https://api.openai.com/v1", "gpt-5.5"], + ["openai-responses", "https://api.openai.com/v1", "gpt-5.4-codex"], + ] as const)( + "keeps runtime normalization for api=%s baseUrl=%s model=%s", + (rowApi, rowBaseUrl, modelId) => { + expect( + projectConfiguredModelRow(createProjectionContext({ modelId, rowApi, rowBaseUrl })), + ).toBeUndefined(); + }, + ); +}); diff --git a/src/agents/model-auth-availability.ts b/src/agents/model-auth-availability.ts index 62c83b2962f3..f37de0accd30 100644 --- a/src/agents/model-auth-availability.ts +++ b/src/agents/model-auth-availability.ts @@ -15,15 +15,12 @@ import type { } from "../plugin-sdk/provider-model-types.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import { isValidSecretRef } from "../secrets/ref-contract.js"; -import { - isConfiguredAwsSdkAuthProfileForProvider, - getRuntimeAuthProfileStoreSnapshot, - resolveAuthProfileEligibility, -} from "./auth-profiles.js"; import { hasUsableOAuthCredential } from "./auth-profiles/credential-state.js"; import { resolveExternalCliAuthProfiles } from "./auth-profiles/external-cli-sync.js"; import { type AuthProfileOrderResolution, + isConfiguredAwsSdkAuthProfileForProvider, + resolveAuthProfileEligibility, resolveAuthProfileOrderWithMetadata, } from "./auth-profiles/order.js"; import { @@ -31,18 +28,22 @@ import { resolveSecretRefReadOnlyAvailability, resolveStoredCredentialReadOnlyAvailability, } from "./auth-profiles/read-only-availability.js"; +import { getRuntimeAuthProfileStoreSnapshot } from "./auth-profiles/runtime-snapshots.js"; import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js"; import { isProfileInCooldown } from "./auth-profiles/usage-state.js"; -import { resolveProviderEnvAuthLookupMaps } from "./model-auth-env-vars.js"; +import { + listProviderEnvAuthLookupKeys, + resolveProviderEnvAuthLookupMaps, +} from "./model-auth-env-vars.js"; import { resolveProviderEnvAuthEvidence } from "./model-auth-env.js"; import { isKnownEnvApiKeyMarker, isSecretRefHeaderValueMarker } from "./model-auth-markers.js"; import { - hasUsableCustomProviderApiKey, - hasRuntimeAvailableProviderAuth, hasSyntheticLocalProviderAuthConfig, + hasUsableCustomProviderApiKey, resolveProviderEntryApiKeyProfileReference, shouldPreferExplicitConfigApiKeyAuth, -} from "./model-auth.js"; +} from "./model-auth-provider-config.js"; +import { resolveManagedSecretRefRuntimeProviderAuth } from "./model-auth-runtime-config.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; import { createOpenAIModelRoutesResolver, @@ -88,6 +89,7 @@ export type ModelAuthAvailabilityEvaluation = { evidence?: ModelAuthAvailabilityEvidence; }; export type ModelAuthAvailabilityResolver = { + providerDiscoveryProviderIds: readonly string[]; evaluateModelAuth( provider: string, ref?: ModelAuthAvailabilityRef, @@ -534,14 +536,8 @@ export function createModelAuthAvailabilityResolver( const managed = typeof apiKey === "string" && isSecretRefHeaderValueMarker(apiKey); return { availability: managed - ? hasRuntimeAvailableProviderAuth({ - provider, - modelApi: target.api ?? undefined, - cfg: params.cfg, - workspaceDir: params.workspaceDir, - env, - allowPluginSyntheticAuth: false, - }) || undefined + ? Boolean(resolveManagedSecretRefRuntimeProviderAuth({ provider, cfg: params.cfg })) || + undefined : undefined, selectedAuthMode: configuredBearerMode, evidence: managed ? "runtime" : "synthetic", @@ -556,14 +552,9 @@ export function createModelAuthAvailabilityResolver( }; } const available = resolveSecretRefReadOnlyAvailability(apiKeyRef, params.cfg, env); - const runtimeAvailable = hasRuntimeAvailableProviderAuth({ - provider, - modelApi: target.api ?? undefined, - cfg: params.cfg, - workspaceDir: params.workspaceDir, - env, - allowPluginSyntheticAuth: false, - }); + const runtimeAvailable = Boolean( + resolveManagedSecretRefRuntimeProviderAuth({ provider, cfg: params.cfg }), + ); return { availability: runtimeAvailable ? true : available, selectedAuthMode: configuredBearerMode, @@ -1005,7 +996,50 @@ export function createModelAuthAvailabilityResolver( selectedRoute, }; }; + const providerDiscoveryProviderIds = new Set(); + const addProviderDiscoveryProviderId = (provider: string | undefined) => { + if (!provider) { + return; + } + const normalized = normalizeProvider(provider); + if (normalized) { + providerDiscoveryProviderIds.add(normalized); + } + }; + for (const credential of Object.values(store.profiles)) { + addProviderDiscoveryProviderId(credential.provider); + } + for (const profile of Object.values(params.cfg.auth?.profiles ?? {})) { + addProviderDiscoveryProviderId(profile.provider); + } + for (const provider of listProviderEnvAuthLookupKeys({ envCandidateMap, authEvidenceMap })) { + if (envAuth(provider)) { + addProviderDiscoveryProviderId(provider); + } + } + for (const plugin of params.metadataSnapshot?.index?.plugins ?? []) { + if ( + !plugin.enabled || + !(plugin.syntheticAuthRefs ?? []).some((ref) => + synthetic.has(normalizeProviderIdForAuth(ref)), + ) + ) { + continue; + } + for (const provider of [ + ...(plugin.contributions?.providers ?? []), + ...(plugin.contributions?.modelCatalogProviders ?? []), + ]) { + addProviderDiscoveryProviderId(provider); + } + } + if (synthetic.has("codex")) { + addProviderDiscoveryProviderId(OPENAI_PROVIDER_ID); + } return { + providerDiscoveryProviderIds: [...providerDiscoveryProviderIds].toSorted((left, right) => + left.localeCompare(right), + ), evaluateModelAuth, resolveProviderAuthAvailability, hasSyntheticAuth: (provider) => diff --git a/src/agents/model-auth-label.ts b/src/agents/model-auth-label.ts index 3db81028870c..c127fee541bf 100644 --- a/src/agents/model-auth-label.ts +++ b/src/agents/model-auth-label.ts @@ -1,6 +1,7 @@ /** * Formats user-facing auth labels for resolved provider/model credentials. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -21,7 +22,6 @@ import { resolveProviderEntryApiKeyProfileReference, resolveUsableCustomProviderApiKey, } from "./model-auth.js"; -import { normalizeProviderId } from "./model-selection.js"; // Builds concise auth labels for UI/status surfaces without exposing credential // values. Resolution follows profile override, provider profiles, env, CLI, then diff --git a/src/agents/model-auth-model.ts b/src/agents/model-auth-model.ts index e61ef13b469c..0ec4b4737043 100644 --- a/src/agents/model-auth-model.ts +++ b/src/agents/model-auth-model.ts @@ -1,6 +1,7 @@ /** * Model-level auth diagnostics and request-header preparation. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { getRuntimeConfigSnapshot, @@ -34,7 +35,6 @@ import { } from "./model-auth-provider.js"; import type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js"; import { resolveSyntheticLocalProviderAuth } from "./model-auth-runtime.js"; -import { normalizeProviderId } from "./model-selection.js"; import { attachModelProviderRequestTransport, getModelProviderRequestTransport, diff --git a/src/agents/model-auth-openai.ts b/src/agents/model-auth-openai.ts index 4b63ddea69f8..9d707c21fe4a 100644 --- a/src/agents/model-auth-openai.ts +++ b/src/agents/model-auth-openai.ts @@ -1,6 +1,6 @@ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js"; -import { normalizeProviderId } from "./model-selection.js"; const OPENAI_PROVIDER_ID = "openai"; const OPENAI_CODEX_RESPONSES_API = "openai-chatgpt-responses"; diff --git a/src/agents/model-auth-provider-config.ts b/src/agents/model-auth-provider-config.ts index c929f5e06483..bfc495999da8 100644 --- a/src/agents/model-auth-provider-config.ts +++ b/src/agents/model-auth-provider-config.ts @@ -1,6 +1,7 @@ /** * Provider-entry configuration and stored-profile binding for model auth. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, @@ -16,12 +17,10 @@ import { SecretSurfaceUnavailableError } from "../secrets/runtime-degraded-state import { mintSecretSentinel } from "../secrets/sentinel.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { - type AuthProfileCredential, - type AuthProfileStore, isConfiguredAwsSdkAuthProfileForProvider, isStoredCredentialCompatibleWithAuthProvider, - resolveApiKeyForProfile, -} from "./auth-profiles.js"; +} from "./auth-profiles/order.js"; +import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js"; import { resolveEnvApiKey, type EnvApiKeyResult } from "./model-auth-env.js"; import { CUSTOM_LOCAL_AUTH_MARKER, @@ -32,7 +31,6 @@ import { } from "./model-auth-markers.js"; import type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js"; import { isLocalProviderBaseUrl } from "./model-provider-local.js"; -import { normalizeProviderId } from "./model-selection.js"; const MODEL_AUTH_LOCAL_HOST_ALIASES = new Set([ "docker.orb.internal", @@ -223,6 +221,35 @@ export function shouldPreferExplicitConfigApiKeyAuth( ); } +/** True when a custom local provider can use a synthetic no-auth placeholder. */ +export function hasSyntheticLocalProviderAuthConfig(params: { + cfg: OpenClawConfig | undefined; + provider: string; +}): boolean { + const providerConfig = resolveProviderConfig(params.cfg, params.provider); + if (!providerConfig) { + return false; + } + const hasApiConfig = + Boolean(providerConfig.api?.trim()) || + Boolean(providerConfig.baseUrl?.trim()) || + (Array.isArray(providerConfig.models) && providerConfig.models.length > 0); + if (!hasApiConfig) { + return false; + } + const authOverride = resolveProviderAuthOverride(params.cfg, params.provider); + if (authOverride && authOverride !== "api-key") { + return false; + } + if ( + !isCustomLocalProviderConfig(providerConfig) || + hasExplicitProviderApiKeyConfig(providerConfig) + ) { + return false; + } + return Boolean(providerConfig.baseUrl && isLocalAuthProviderBaseUrl(providerConfig.baseUrl)); +} + export function resolveProviderAuthOverride( cfg: OpenClawConfig | undefined, provider: string, @@ -435,6 +462,7 @@ export async function resolveProviderEntryApiKeyBinding(params: { return reference; } try { + const { resolveApiKeyForProfile } = await import("./auth-profiles/oauth.js"); const resolved = await resolveApiKeyForProfile({ cfg: params.cfg, store: params.store, @@ -483,18 +511,18 @@ export function resolveConfiguredAwsSdkProfileAuth(params: { }; } -export function isLocalAuthProviderBaseUrl(baseUrl: string): boolean { +function isLocalAuthProviderBaseUrl(baseUrl: string): boolean { return isLocalProviderBaseUrl(baseUrl, MODEL_AUTH_LOCAL_HOST_ALIASES); } -export function hasExplicitProviderApiKeyConfig(providerConfig: ModelProviderConfig): boolean { +function hasExplicitProviderApiKeyConfig(providerConfig: ModelProviderConfig): boolean { return ( normalizeOptionalSecretInput(providerConfig.apiKey) !== undefined || coerceSecretRef(providerConfig.apiKey) !== null ); } -export function isCustomLocalProviderConfig(providerConfig: ModelProviderConfig): boolean { +function isCustomLocalProviderConfig(providerConfig: ModelProviderConfig): boolean { return ( typeof providerConfig.baseUrl === "string" && providerConfig.baseUrl.trim().length > 0 && diff --git a/src/agents/model-auth-provider.ts b/src/agents/model-auth-provider.ts index a77ddd599f31..bbb1519f8a44 100644 --- a/src/agents/model-auth-provider.ts +++ b/src/agents/model-auth-provider.ts @@ -27,12 +27,12 @@ import { OAuthRefreshFailureError } from "./auth-profiles/oauth-refresh-failure. import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; import { assertAuthModeAllowedForModel, isAuthModeAllowedForModel } from "./model-auth-openai.js"; import * as authConfig from "./model-auth-provider-config.js"; -import { ProviderAuthError, type ResolvedProviderAuth } from "./model-auth-runtime-shared.js"; import { assertRuntimeProviderSecretOwnerAvailable, resolveManagedSecretRefRuntimeProviderAuth, - resolveSyntheticLocalProviderAuth, -} from "./model-auth-runtime.js"; +} from "./model-auth-runtime-config.js"; +import { ProviderAuthError, type ResolvedProviderAuth } from "./model-auth-runtime-shared.js"; +import { resolveSyntheticLocalProviderAuth } from "./model-auth-runtime.js"; export type ProviderCredentialPrecedence = "profile-first" | "env-first"; diff --git a/src/agents/model-auth-runtime-config.ts b/src/agents/model-auth-runtime-config.ts new file mode 100644 index 000000000000..f6f1daa55c87 --- /dev/null +++ b/src/agents/model-auth-runtime-config.ts @@ -0,0 +1,89 @@ +/** + * Runtime-config-backed provider auth that does not require plugin activation. + */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { + getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, + selectApplicableRuntimeConfig, +} from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + findActiveDegradedSecretOwner, + SecretSurfaceUnavailableError, +} from "../secrets/runtime-degraded-state.js"; +import { mintSecretSentinel } from "../secrets/sentinel.js"; +import * as authConfig from "./model-auth-provider-config.js"; +import type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js"; + +/** Reads a runtime-resolved credential for a SecretRef-backed provider entry. */ +export function resolveManagedSecretRefRuntimeProviderAuth(params: { + cfg: OpenClawConfig | undefined; + provider: string; + secretSentinels?: boolean; +}): ResolvedProviderAuth | undefined { + const runtimeConfig = getRuntimeConfigSnapshot(); + const runtimeSourceConfig = getRuntimeConfigSourceSnapshot(); + if (params.cfg && params.cfg !== runtimeConfig && !runtimeSourceConfig) { + return undefined; + } + const applicableConfig = selectApplicableRuntimeConfig({ + inputConfig: params.cfg, + runtimeConfig, + runtimeSourceConfig, + }); + const usesRuntimeProvider = + applicableConfig === runtimeConfig || + authConfig.providerConfigMatchesRuntimeSnapshot({ + inputConfig: params.cfg, + runtimeConfig, + provider: params.provider, + }); + const sourceConfig = usesRuntimeProvider ? (runtimeSourceConfig ?? undefined) : params.cfg; + if (!authConfig.hasSecretRefProviderApiKey(sourceConfig, params.provider)) { + return undefined; + } + if (!runtimeConfig || !usesRuntimeProvider) { + return undefined; + } + const resolved = authConfig.resolveLiteralProviderConfigApiKeyAuth({ + cfg: runtimeConfig, + provider: params.provider, + }); + if (!resolved?.apiKey) { + return undefined; + } + return { + ...resolved, + apiKey: params.secretSentinels + ? mintSecretSentinel(resolved.apiKey, { + label: `model-auth:${params.provider}`, + }) + : resolved.apiKey, + }; +} + +export function assertRuntimeProviderSecretOwnerAvailable(params: { + cfg: OpenClawConfig | undefined; + provider: string; +}): void { + const provider = normalizeProviderId(params.provider); + const degraded = findActiveDegradedSecretOwner("provider", provider); + if (!degraded) { + return; + } + const runtimeConfig = getRuntimeConfigSnapshot(); + const runtimeSourceConfig = getRuntimeConfigSourceSnapshot(); + const usesRuntimeProvider = + !params.cfg || + params.cfg === runtimeConfig || + params.cfg === runtimeSourceConfig || + authConfig.providerConfigMatchesRuntimeSnapshot({ + inputConfig: params.cfg, + runtimeConfig, + provider, + }); + if (usesRuntimeProvider) { + throw new SecretSurfaceUnavailableError(degraded); + } +} diff --git a/src/agents/model-auth-runtime.ts b/src/agents/model-auth-runtime.ts index d6c4ce59a617..775035af1636 100644 --- a/src/agents/model-auth-runtime.ts +++ b/src/agents/model-auth-runtime.ts @@ -1,27 +1,20 @@ /** * Snapshot-aware and synthetic provider-auth availability. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; -import { - getRuntimeConfigSnapshot, - getRuntimeConfigSourceSnapshot, - selectApplicableRuntimeConfig, -} from "../config/config.js"; +import { getRuntimeConfigSnapshot } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveProviderSyntheticAuthWithPlugin } from "../plugins/provider-runtime.js"; import { resolveRuntimeSyntheticAuthProviderRefState } from "../plugins/synthetic-auth.runtime.js"; -import { - findActiveDegradedSecretOwner, - SecretSurfaceUnavailableError, -} from "../secrets/runtime-degraded-state.js"; import { mintSecretSentinel } from "../secrets/sentinel.js"; import { resolveProviderEnvAuthLookupMaps } from "./model-auth-env-vars.js"; import { resolveEnvApiKey, type EnvApiKeyLookupOptions } from "./model-auth-env.js"; import { CUSTOM_LOCAL_AUTH_MARKER, isNonSecretApiKeyMarker } from "./model-auth-markers.js"; import { isAuthModeAllowedForModel } from "./model-auth-openai.js"; import * as authConfig from "./model-auth-provider-config.js"; +import { resolveManagedSecretRefRuntimeProviderAuth } from "./model-auth-runtime-config.js"; import type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js"; -import { normalizeProviderId } from "./model-selection.js"; /** Precomputed provider-auth lookup tables reused during one runtime turn. */ export type RuntimeProviderAuthLookup = { @@ -103,111 +96,6 @@ function resolveRuntimeEnvApiKeyLookupOptions(params: { }; } -/** Reads a literal or env-secret marker for a custom provider entry. */ -export function resolveManagedSecretRefRuntimeProviderAuth(params: { - cfg: OpenClawConfig | undefined; - provider: string; - secretSentinels?: boolean; -}): ResolvedProviderAuth | undefined { - const runtimeConfig = getRuntimeConfigSnapshot(); - const runtimeSourceConfig = getRuntimeConfigSourceSnapshot(); - if (params.cfg && params.cfg !== runtimeConfig && !runtimeSourceConfig) { - return undefined; - } - const applicableConfig = selectApplicableRuntimeConfig({ - inputConfig: params.cfg, - runtimeConfig, - runtimeSourceConfig, - }); - const usesRuntimeProvider = - applicableConfig === runtimeConfig || - authConfig.providerConfigMatchesRuntimeSnapshot({ - inputConfig: params.cfg, - runtimeConfig, - provider: params.provider, - }); - const sourceConfig = usesRuntimeProvider ? (runtimeSourceConfig ?? undefined) : params.cfg; - if (!authConfig.hasSecretRefProviderApiKey(sourceConfig, params.provider)) { - return undefined; - } - if (!runtimeConfig || !usesRuntimeProvider) { - return undefined; - } - const resolved = authConfig.resolveLiteralProviderConfigApiKeyAuth({ - cfg: runtimeConfig, - provider: params.provider, - }); - if (!resolved?.apiKey) { - return undefined; - } - return { - ...resolved, - apiKey: params.secretSentinels - ? mintSecretSentinel(resolved.apiKey, { - label: `model-auth:${params.provider}`, - }) - : resolved.apiKey, - }; -} - -export function assertRuntimeProviderSecretOwnerAvailable(params: { - cfg: OpenClawConfig | undefined; - provider: string; -}): void { - const provider = normalizeProviderId(params.provider); - const degraded = findActiveDegradedSecretOwner("provider", provider); - if (!degraded) { - return; - } - const runtimeConfig = getRuntimeConfigSnapshot(); - const runtimeSourceConfig = getRuntimeConfigSourceSnapshot(); - const usesRuntimeProvider = - !params.cfg || - params.cfg === runtimeConfig || - params.cfg === runtimeSourceConfig || - authConfig.providerConfigMatchesRuntimeSnapshot({ - inputConfig: params.cfg, - runtimeConfig, - provider, - }); - if (usesRuntimeProvider) { - throw new SecretSurfaceUnavailableError(degraded); - } -} - -/** True when a custom local provider can use a synthetic no-auth placeholder. */ -export function hasSyntheticLocalProviderAuthConfig(params: { - cfg: OpenClawConfig | undefined; - provider: string; -}): boolean { - const providerConfig = authConfig.resolveProviderConfig(params.cfg, params.provider); - if (!providerConfig) { - return false; - } - - const hasApiConfig = - Boolean(providerConfig.api?.trim()) || - Boolean(providerConfig.baseUrl?.trim()) || - (Array.isArray(providerConfig.models) && providerConfig.models.length > 0); - if (!hasApiConfig) { - return false; - } - - const authOverride = authConfig.resolveProviderAuthOverride(params.cfg, params.provider); - if (authOverride && authOverride !== "api-key") { - return false; - } - if (!authConfig.isCustomLocalProviderConfig(providerConfig)) { - return false; - } - if (authConfig.hasExplicitProviderApiKeyConfig(providerConfig)) { - return false; - } - return Boolean( - providerConfig.baseUrl && authConfig.isLocalAuthProviderBaseUrl(providerConfig.baseUrl), - ); -} - function listProviderSyntheticAuthRefs(params: { cfg: OpenClawConfig | undefined; provider: string; @@ -288,7 +176,7 @@ export function hasRuntimeAvailableProviderAuth(params: { if (resolveManagedSecretRefRuntimeProviderAuth({ cfg: params.cfg, provider })) { return true; } - if (hasSyntheticLocalProviderAuthConfig({ cfg: params.cfg, provider })) { + if (authConfig.hasSyntheticLocalProviderAuthConfig({ cfg: params.cfg, provider })) { return true; } if ( @@ -398,7 +286,7 @@ export function resolveSyntheticLocalProviderAuth(params: { // Custom providers pointing at a local server (e.g. llama.cpp, vLLM, LocalAI) // typically don't require auth. Synthesize a local key so the auth resolver // doesn't reject them when the user left the API key blank during setup. - if (hasSyntheticLocalProviderAuthConfig(params)) { + if (authConfig.hasSyntheticLocalProviderAuthConfig(params)) { return { apiKey: CUSTOM_LOCAL_AUTH_MARKER, source: `models.providers.${params.provider} (synthetic local key)`, diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index eda5d0c96a67..7f5248c6b32d 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -20,6 +20,7 @@ export type { ModelAuthMode } from "./model-auth-model.js"; export { canUseProfileAsProviderEntryApiKey, getCustomProviderApiKey, + hasSyntheticLocalProviderAuthConfig, hasUsableCustomProviderApiKey, resolveProviderEntryApiKeyBinding, resolveProviderEntryApiKeyProfileReference, @@ -32,7 +33,6 @@ export type { ProviderCredentialPrecedence } from "./model-auth-provider.js"; export { createRuntimeProviderAuthLookup, hasRuntimeAvailableProviderAuth, - hasSyntheticLocalProviderAuthConfig, } from "./model-auth-runtime.js"; export type { RuntimeProviderAuthLookup } from "./model-auth-runtime.js"; export { diff --git a/src/agents/prepared-model-catalog.test.ts b/src/agents/prepared-model-catalog.test.ts index c406a7e94727..b2293b71412e 100644 --- a/src/agents/prepared-model-catalog.test.ts +++ b/src/agents/prepared-model-catalog.test.ts @@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({ getSnapshot: vi.fn(), loadSnapshot: vi.fn(), prepareSnapshot: vi.fn(), + prepareScopedCatalog: vi.fn(), + isFullCatalog: vi.fn(), releaseSnapshot: vi.fn(), })); @@ -46,6 +48,14 @@ vi.mock("./prepared-model-runtime.js", () => { }; }); +vi.mock("./prepared-model-runtime.facts.js", () => ({ + isPreparedModelCatalogFull: (...args: unknown[]) => mocks.isFullCatalog(...args), +})); + +vi.mock("./prepared-model-runtime.scoped-catalog.js", () => ({ + prepareScopedReadOnlyModelCatalog: (...args: unknown[]) => mocks.prepareScopedCatalog(...args), +})); + import { PreparedModelCatalogConfigReplacedError } from "./prepared-model-catalog.errors.js"; import { getPreparedModelCatalogSnapshot, @@ -77,6 +87,8 @@ describe("prepared model catalog access", () => { mocks.getSnapshot.mockReset(); mocks.loadSnapshot.mockReset(); mocks.prepareSnapshot.mockReset(); + mocks.prepareScopedCatalog.mockReset(); + mocks.isFullCatalog.mockReset(); mocks.releaseSnapshot.mockReset(); }); @@ -105,6 +117,48 @@ describe("prepared model catalog access", () => { expect(mocks.releaseSnapshot).not.toHaveBeenCalled(); }); + it("reuses a published full generation for a provider-scoped read-only load", async () => { + mocks.prepareSnapshot.mockResolvedValue(fullSnapshot); + mocks.isFullCatalog.mockReturnValue(true); + + await expect( + loadPreparedModelCatalogSnapshot({ + readOnly: true, + providerDiscoveryProviderIds: ["anthropic"], + }), + ).resolves.toBe(fullSnapshot.modelCatalog); + + expect(mocks.isFullCatalog).toHaveBeenCalledWith(fullSnapshot.modelCatalog); + expect(mocks.prepareScopedCatalog).not.toHaveBeenCalled(); + }); + + it("builds a scoped catalog when the published generation is configured-only", async () => { + const scopedCatalog = { + entries: [{ provider: "anthropic", id: "claude", name: "Claude" }], + routeVariants: [], + }; + mocks.prepareSnapshot.mockResolvedValue(readOnlySnapshot); + mocks.isFullCatalog.mockReturnValue(false); + mocks.prepareScopedCatalog.mockResolvedValue(scopedCatalog); + + await expect( + loadPreparedModelCatalogSnapshot({ + readOnly: true, + providerDiscoveryProviderIds: ["anthropic"], + }), + ).resolves.toBe(scopedCatalog); + + expect(mocks.prepareSnapshot).toHaveBeenCalledTimes(2); + expect(mocks.prepareScopedCatalog).toHaveBeenCalledOnce(); + expect(mocks.prepareScopedCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + readOnly: true, + workspaceDir: "/tmp/prepared-model-catalog-workspace", + }), + ["anthropic"], + ); + }); + it("keeps read-only catalog reads on configured facts and materializes full reads once", async () => { const configuredCatalog = { entries: [{ provider: "test", id: "configured", name: "Configured" }], diff --git a/src/agents/prepared-model-catalog.ts b/src/agents/prepared-model-catalog.ts index 8a6b9a1ac6b6..6449032b0955 100644 --- a/src/agents/prepared-model-catalog.ts +++ b/src/agents/prepared-model-catalog.ts @@ -12,6 +12,7 @@ import type { ModelCatalogEntry, ModelCatalogSnapshot } from "./model-catalog.ty import { resolvePublishedModelCatalogOwner } from "./prepared-model-catalog-owner.js"; import { PreparedModelCatalogConfigReplacedError } from "./prepared-model-catalog.errors.js"; import type { ResolvedPublishedModelCatalogOwner } from "./prepared-model-catalog.types.js"; +import { isPreparedModelCatalogFull } from "./prepared-model-runtime.facts.js"; import { acquireAgentRunPreparedModelRuntime, acquireReadOnlyPreparedModelRuntime, @@ -23,6 +24,7 @@ import { type PreparedModelRuntimeInput, type PreparedModelRuntimeSnapshot, } from "./prepared-model-runtime.js"; +import { prepareScopedReadOnlyModelCatalog } from "./prepared-model-runtime.scoped-catalog.js"; export type LoadPreparedModelCatalogParams = { agentId?: string; @@ -31,6 +33,7 @@ export type LoadPreparedModelCatalogParams = { readOnly?: boolean; workspaceDir?: string; env?: NodeJS.ProcessEnv; + providerDiscoveryProviderIds?: readonly string[]; }; type PreparedModelCatalogConfigPolicy = "exact" | "published"; @@ -231,6 +234,33 @@ async function loadPreparedModelCatalogOwnerSnapshotWithPolicy( ); } +async function loadScopedReadOnlyModelCatalog( + params: LoadPreparedModelCatalogParams, +): Promise { + const { activationExact, activationFull, full } = resolveInputs(params); + const fullCandidates = + activationFull.workspaceDir === full.workspaceDir ? [full] : [full, activationFull]; + for (const candidate of fullCandidates) { + try { + const prepared = await prepareModelRuntimeSnapshot(candidate); + if (!preparedModelRuntimeConfigsMatch(prepared.config, candidate.config)) { + throw new PreparedModelCatalogConfigReplacedError(candidate.agentDir); + } + if (isPreparedModelCatalogFull(prepared.modelCatalog)) { + return prepared.modelCatalog; + } + } catch (error) { + if (!(error instanceof PreparedModelRuntimeOwnerNotPublishedError)) { + throw error; + } + } + } + return prepareScopedReadOnlyModelCatalog( + activationExact, + params.providerDiscoveryProviderIds ?? [], + ); +} + /** Resolves the lifecycle owner for an exact caller-supplied config. */ export async function loadPreparedModelCatalogOwnerSnapshot( params: LoadPreparedModelCatalogParams = {}, @@ -258,6 +288,9 @@ export async function loadResolvedPublishedModelCatalogOwner( export async function loadPreparedModelCatalogSnapshot( params: LoadPreparedModelCatalogParams = {}, ): Promise { + if (params.readOnly && params.providerDiscoveryProviderIds) { + return loadScopedReadOnlyModelCatalog(params); + } return (await loadPreparedModelCatalogOwnerSnapshot(params)).modelCatalog; } diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index 4c5038f7051a..749e9c438b78 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -62,6 +62,7 @@ import type { ModelRegistry } from "./sessions/model-registry.js"; import { stableStringify } from "./stable-stringify.js"; const MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000; +const fullModelCatalogSnapshots = new WeakSet(); type PreparedModelRuntimeAgentBaseFacts = { input: PreparedModelRuntimeInput; @@ -111,6 +112,7 @@ function prepareAgentFacts( input: PreparedModelRuntimeInput, catalogMode: PreparedModelRuntimeCatalogMode, ambientCredentials: Readonly, + additionalProviderIds: readonly string[] = [], ): PreparedModelRuntimeAgentBaseFacts { const env = input.env ?? process.env; const templateAuthStorage = discoverAuthStorage(input.agentDir, { @@ -137,12 +139,17 @@ function prepareAgentFacts( configuredModelRefs, // Gateway startup prepares only providers named by config/model selection. An unrelated // stored credential must not pull that provider's complete catalog into the admission path. - providerIds: collectPreparedModelRuntimeProviderIds( - input.config, - credentials, - catalogMode === "live", - configuredModelRefs, - ), + providerIds: [ + ...new Set([ + ...collectPreparedModelRuntimeProviderIds( + input.config, + credentials, + catalogMode === "live", + configuredModelRefs, + ), + ...additionalProviderIds.map(normalizeProviderId).filter(Boolean), + ]), + ].toSorted((left, right) => left.localeCompare(right)), }; } @@ -194,6 +201,7 @@ export function preparedModelRuntimeWorkspaceFactsKey(input: PreparedModelRuntim export async function prepareWorkspaceBuildGroup( inputs: readonly PreparedModelRuntimeInput[], catalogMode: PreparedModelRuntimeCatalogMode, + options: { providerDiscoveryProviderIds?: readonly string[] } = {}, ): Promise<{ agentFacts: PreparedModelRuntimeAgentFacts[]; workspaceFacts: PreparedModelRuntimeWorkspaceFacts; @@ -261,12 +269,22 @@ export async function prepareWorkspaceBuildGroup( configuredManifestModels.set(key, model); return model; }; - const configuredProviderIds = collectPreparedModelRuntimeProviderIds(input.config, {}, false); - const staticCatalogProviderIds = collectConfiguredProviderIdsNeedingStaticCatalog({ - config: input.config, - matchesStaticModelId, - resolveStaticCatalogModel: resolveConfiguredManifestModel, - }); + const configuredProviderIds = [ + ...new Set([ + ...collectPreparedModelRuntimeProviderIds(input.config, {}, false), + ...(options.providerDiscoveryProviderIds ?? []).map(normalizeProviderId).filter(Boolean), + ]), + ].toSorted((left, right) => left.localeCompare(right)); + const staticCatalogProviderIds = [ + ...new Set([ + ...collectConfiguredProviderIdsNeedingStaticCatalog({ + config: input.config, + matchesStaticModelId, + resolveStaticCatalogModel: resolveConfiguredManifestModel, + }), + ...(options.providerDiscoveryProviderIds ?? []).map(normalizeProviderId).filter(Boolean), + ]), + ].toSorted((left, right) => left.localeCompare(right)); const staticProviderCatalogStartedAt = performance.now(); const preparedStaticProviderCatalog = catalogMode === "static" @@ -312,7 +330,12 @@ export async function prepareWorkspaceBuildGroup( const ambientCredentialsMs = performance.now() - ambientCredentialsStartedAt; const agentFactsStartedAt = performance.now(); const agentBaseFacts = inputs.map((candidate) => - prepareAgentFacts(candidate, catalogMode, ambientCredentials), + prepareAgentFacts( + candidate, + catalogMode, + ambientCredentials, + options.providerDiscoveryProviderIds, + ), ); const agentFactsMs = performance.now() - agentFactsStartedAt; const configuredProjectionStartedAt = performance.now(); @@ -460,14 +483,23 @@ export async function prepareFullCatalogFacts( } } const staticEntries = [...staticModels.values()].map(toStaticCatalogEntry); + const completeModelCatalog = { ...modelCatalog, staticEntries }; + if (catalogMode === "live") { + fullModelCatalogSnapshots.add(completeModelCatalog); + } return { templateModelRegistry, - modelCatalog: { ...modelCatalog, staticEntries }, + modelCatalog: completeModelCatalog, configuredRuntimeModels, inlineProviderModels: workspaceFacts.inlineProviderModels, }; } +/** Reports whether a catalog came from the complete prepared-catalog build path. */ +export function isPreparedModelCatalogFull(snapshot: ModelCatalogSnapshot): boolean { + return fullModelCatalogSnapshots.has(snapshot); +} + function modelCatalogEntryKey(entry: Pick): string { return `${normalizeProviderId(entry.provider)}\0${entry.id.trim().toLowerCase()}`; } @@ -659,6 +691,7 @@ export async function prepareAgentCatalogSource( workspaceFacts: PreparedModelRuntimeWorkspaceFacts, catalogMode: PreparedModelRuntimeCatalogMode, persist = true, + sourceOptions: { providerDiscoveryProviderIds?: readonly string[] } = {}, ): Promise { const { env, input, providerIds } = agentFacts; const options = { @@ -671,10 +704,13 @@ export async function prepareAgentCatalogSource( ...(catalogMode === "static" ? { providerDiscoveryEntriesOnly: true as const, - providerDiscoveryProviderIds: providerIds, + providerDiscoveryProviderIds: sourceOptions.providerDiscoveryProviderIds ?? providerIds, } : { providerDiscoveryTimeoutMs: MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS, + ...(sourceOptions.providerDiscoveryProviderIds + ? { providerDiscoveryProviderIds: sourceOptions.providerDiscoveryProviderIds } + : {}), }), }; if (!persist) { diff --git a/src/agents/prepared-model-runtime.scoped-catalog.ts b/src/agents/prepared-model-runtime.scoped-catalog.ts new file mode 100644 index 000000000000..ea6569d65a92 --- /dev/null +++ b/src/agents/prepared-model-runtime.scoped-catalog.ts @@ -0,0 +1,53 @@ +import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; +import { + prepareAgentCatalogSource, + prepareFullCatalogFacts, + prepareWorkspaceBuildGroup, +} from "./prepared-model-runtime.facts.js"; +import type { + PreparedModelRuntimeCatalogMode, + PreparedModelRuntimeInput, +} from "./prepared-model-runtime.types.js"; + +async function prepareScopedReadOnlyModelCatalogWithMode( + input: PreparedModelRuntimeInput, + providerDiscoveryProviderIds: readonly string[], + catalogMode: PreparedModelRuntimeCatalogMode, +): Promise { + const scopedInput = input.readOnly ? input : { ...input, readOnly: true }; + const { agentFacts, workspaceFacts } = await prepareWorkspaceBuildGroup( + [scopedInput], + catalogMode, + { providerDiscoveryProviderIds }, + ); + const agentFactsForInput = agentFacts[0]; + if (!agentFactsForInput) { + throw new Error("scoped prepared model catalog facts are missing"); + } + const catalogSource = await prepareAgentCatalogSource( + agentFactsForInput, + workspaceFacts, + catalogMode, + false, + catalogMode === "live" ? { providerDiscoveryProviderIds } : {}, + ); + return ( + await prepareFullCatalogFacts(agentFactsForInput, workspaceFacts, catalogMode, catalogSource) + ).modelCatalog; +} + +/** Builds a request-scoped read-only catalog without executing live provider discovery. */ +export function prepareScopedReadOnlyModelCatalog( + input: PreparedModelRuntimeInput, + providerDiscoveryProviderIds: readonly string[], +): Promise { + return prepareScopedReadOnlyModelCatalogWithMode(input, providerDiscoveryProviderIds, "static"); +} + +/** Builds a request-scoped read-only catalog with live discovery for selected providers. */ +export function prepareScopedReadOnlyLiveModelCatalog( + input: PreparedModelRuntimeInput, + providerDiscoveryProviderIds: readonly string[], +): Promise { + return prepareScopedReadOnlyModelCatalogWithMode(input, providerDiscoveryProviderIds, "live"); +} diff --git a/src/agents/prepared-model-runtime.startup-static.test.ts b/src/agents/prepared-model-runtime.startup-static.test.ts index 5151c6b5d923..9b4ef483b90f 100644 --- a/src/agents/prepared-model-runtime.startup-static.test.ts +++ b/src/agents/prepared-model-runtime.startup-static.test.ts @@ -163,6 +163,8 @@ vi.mock("../logging/subsystem.js", () => ({ const { getPreparedModelRuntimeSnapshot, refreshPreparedModelRuntimeSnapshots } = await import("./prepared-model-runtime.js"); +const { prepareScopedReadOnlyLiveModelCatalog, prepareScopedReadOnlyModelCatalog } = + await import("./prepared-model-runtime.scoped-catalog.js"); const { resetPreparedModelRuntimeSnapshotsForTest } = await import("./prepared-model-runtime.test-support.js"); @@ -173,6 +175,75 @@ beforeEach(() => { }); describe("prepared model runtime Gateway catalog mode", () => { + it("imports and materializes only configured and auth-candidate providers", async () => { + const config = { + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + }; + + await prepareScopedReadOnlyModelCatalog( + { + agentId: "default", + agentDir: "/tmp/prepared-static-agent", + config, + inheritedAuthDir: "/tmp/prepared-static-agent", + workspaceDir: "/tmp/prepared-static-workspace", + env: {}, + readOnly: true, + }, + ["anthropic", "local-runtime"], + ); + + expect(mocks.prepareStaticCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + providerDiscoveryProviderIds: ["anthropic", "local-runtime", "openai"], + staticCatalogProviderIds: ["anthropic", "local-runtime", "openai"], + }), + ); + expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledWith( + config, + "/tmp/prepared-static-agent", + expect.objectContaining({ + providerDiscoveryEntriesOnly: true, + providerDiscoveryProviderIds: ["anthropic", "local-runtime", "openai"], + }), + ); + expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); + }); + + it("uses live provider catalogs for an explicit read-only list scope", async () => { + const config = { + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + }; + + await prepareScopedReadOnlyLiveModelCatalog( + { + agentId: "default", + agentDir: "/tmp/prepared-live-agent", + config, + inheritedAuthDir: "/tmp/prepared-live-agent", + workspaceDir: "/tmp/prepared-live-workspace", + env: {}, + readOnly: true, + }, + ["anthropic"], + ); + + expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledWith( + config, + "/tmp/prepared-live-agent", + expect.objectContaining({ + providerDiscoveryProviderIds: ["anthropic"], + providerDiscoveryTimeoutMs: expect.any(Number), + }), + ); + expect(mocks.planOpenClawModelsJsonSource).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ providerDiscoveryEntriesOnly: true }), + ); + expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); + }); + it("does not publish a static catalog generation superseded while its hook is running", async () => { const staleConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; const latestConfig = { agents: { defaults: { model: "openai/gpt-5.6" } } }; diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 7cb1db4ec993..b71f0da97f17 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -15,7 +15,7 @@ import { type SupportedGatewaySecretInputPath, } from "../gateway/secret-input-paths.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { resolveManifestContractOwnerPluginId } from "../plugins/plugin-registry.js"; +import { resolveManifestContractOwnerPluginId } from "../plugins/plugin-registry-contributions.js"; import { analyzeCommandSecretAssignmentsFromSnapshot, type UnresolvedCommandSecretAssignment, diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index e891d7c5cf21..63c62a03c3e4 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -55,6 +55,7 @@ const modelRegistryState = { findError: undefined as unknown, }; let previousExitCode: typeof process.exitCode; +let previousOpenAiApiKey: string | undefined; vi.mock("./models/load-config.js", () => ({ loadModelsConfigWithSource: vi.fn(async () => { @@ -74,6 +75,7 @@ vi.mock("../agents/auth-profiles/profile-list.js", () => ({ })); vi.mock("../agents/auth-profiles/store.js", () => ({ + ensureAuthProfileStore, getRuntimeAuthProfileStoreSnapshot: vi.fn(() => undefined), loadAuthProfileStoreWithoutExternalProfiles: ensureAuthProfileStore, updateAuthProfileStoreWithLock: vi.fn(async () => ensureAuthProfileStore()), @@ -155,6 +157,10 @@ vi.mock("../agents/agent-model-discovery.js", () => { return modelRegistryState.available; } + getProviderMetadataOwners() { + return undefined; + } + hasConfiguredAuth(model: { provider: string; id: string }) { return modelRegistryState.available.some( (available) => available.provider === model.provider && available.id === model.id, @@ -246,6 +252,8 @@ async function loadSourceConfigSnapshotForTest(fallback: unknown): Promise { previousExitCode = process.exitCode; process.exitCode = undefined; + previousOpenAiApiKey = process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_KEY; modelRegistryState.models = []; modelRegistryState.available = []; modelRegistryState.getAllError = undefined; @@ -254,7 +262,7 @@ beforeEach(() => { getRuntimeConfig.mockReset(); getRuntimeConfig.mockReturnValue({}); listProfilesForProvider.mockReturnValue([]); - loadModelCatalog.mockClear(); + loadModelCatalog.mockReset(); loadModelCatalog.mockResolvedValue([]); shouldSuppressBuiltInModel.mockReset(); shouldSuppressBuiltInModel.mockReturnValue(false); @@ -269,6 +277,11 @@ beforeEach(() => { afterEach(() => { process.exitCode = previousExitCode; + if (previousOpenAiApiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = previousOpenAiApiKey; + } }); describe("models list/status", () => { @@ -374,8 +387,12 @@ describe("models list/status", () => { await modelsListCommand({ all: true, provider, json: true }, runtime); const payload = parseJsonLog(runtime); - expect(payload.count).toBe(1); - expect(payload.models[0]?.key).toBe("zai/glm-4.7"); + expect(payload.count).toBe(payload.models.length); + expect(payload.models.length).toBeGreaterThan(0); + expect(payload.models.map((model: { key: string }) => model.key)).toContain("zai/glm-4.7"); + expect(payload.models.every((model: { key: string }) => model.key.startsWith("zai/"))).toBe( + true, + ); } function setDefaultZaiRegistry(params: { available?: boolean } = {}) { @@ -530,7 +547,9 @@ describe("models list/status", () => { await modelsListCommand({ all: true, json: true }, runtime); const payload = parseJsonLog(runtime); - expect(payload.models[0]?.available).toBe(false); + expect( + payload.models.find((model: { key?: string }) => model.key === "zai/glm-4.7")?.available, + ).toBe(false); }); it("models list uses trusted workspace plugin auth evidence for configured rows", async () => { @@ -594,7 +613,6 @@ describe("models list/status", () => { it("models list all includes catalog rows with unknown auth availability", async () => { setDefaultZaiRegistry({ available: false }); - loadModelCatalog.mockResolvedValueOnce([MOONSHOT_MODEL]); const runtime = makeRuntime(); await withEnvAsync( @@ -603,11 +621,14 @@ describe("models list/status", () => { ); const payload = parseJsonLog(runtime); - expect(loadModelCatalog).toHaveBeenCalledOnce(); - expect(payload.models).toHaveLength(1); - const model = payload.models[0]; - expect(model.key).toBe("moonshot/kimi-k2.6"); - expect(model.name).toBe("Kimi K2.6"); + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(payload.models.length).toBeGreaterThan(0); + const model = payload.models.find( + (candidate: { key: string }) => candidate.key === "moonshot/kimi-k3", + ); + expect(model).toBeDefined(); + expect(model.key).toBe("moonshot/kimi-k3"); + expect(model.name).toBe("Kimi K3"); expect(model.available).toBeNull(); expect(model.missing).toBe(false); }); @@ -647,7 +668,9 @@ describe("models list/status", () => { code: "MODEL_AVAILABILITY_UNAVAILABLE", }); const runtime = makeRuntime(); - await modelsListCommand({ json: true }, runtime); + await withEnvAsync({ OPENAI_API_KEY: undefined }, () => + modelsListCommand({ json: true }, runtime), + ); expect(runtime.error).not.toHaveBeenCalled(); const payload = parseJsonLog(runtime); diff --git a/src/commands/models/list.auth-index.test.ts b/src/commands/models/list.auth-index.test.ts index 1e65bfa955a6..787cebc5b0af 100644 --- a/src/commands/models/list.auth-index.test.ts +++ b/src/commands/models/list.auth-index.test.ts @@ -4,34 +4,35 @@ import type { createOpenAIModelRoutesResolver } from "../../agents/openai-model- import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { createModelListAuthIndex } from "./list.auth-index.js"; -type PluginSnapshotResult = { - source: "persisted" | "provided" | "derived"; - snapshot: { - plugins: Array<{ enabled?: boolean; syntheticAuthRefs?: string[] }>; - }; - diagnostics: []; -}; +type ExternalCliProfilesResolver = + typeof import("../../agents/auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles; -const pluginRegistryMocks = vi.hoisted(() => ({ - loadPluginRegistrySnapshotWithMetadata: vi.fn( - (): PluginSnapshotResult => ({ - source: "persisted", - snapshot: { plugins: [] }, - diagnostics: [], - }), - ), +const externalCliMocks = vi.hoisted(() => ({ + resolveExternalCliAuthProfiles: vi.fn(() => []), })); -vi.mock("../../plugins/plugin-registry.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadPluginRegistrySnapshotWithMetadata: - pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata, - }; -}); +vi.mock("../../agents/auth-profiles/external-cli-sync.js", () => ({ + resolveExternalCliAuthProfiles: externalCliMocks.resolveExternalCliAuthProfiles, +})); const emptyStore: AuthProfileStore = { version: 1, profiles: {} }; +const emptyMetadataSnapshot = { + registrySource: "persisted", + registryDiagnostics: [], + plugins: [], + index: { plugins: [] }, +} as unknown as PluginMetadataSnapshot; + +function createTestModelListAuthIndex( + params: Omit[0], "metadataSnapshot"> & { + metadataSnapshot?: PluginMetadataSnapshot; + }, +) { + return createModelListAuthIndex({ + metadataSnapshot: emptyMetadataSnapshot, + ...params, + }); +} const dualRouteResolverFactory = (() => () => ({ kind: "routes", @@ -56,16 +57,98 @@ const dualRouteResolverFactory = (() => () => ({ describe("createModelListAuthIndex", () => { beforeEach(() => { - pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReset(); - pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: { plugins: [] }, - diagnostics: [], + externalCliMocks.resolveExternalCliAuthProfiles.mockReset(); + externalCliMocks.resolveExternalCliAuthProfiles.mockReturnValue([]); + }); + + it("keeps a fresh auth scope empty", () => { + const index = createTestModelListAuthIndex({ + cfg: {}, + authStore: emptyStore, + env: {}, + routeResolverFactory: dualRouteResolverFactory, }); + + expect(index.providerDiscoveryProviderIds).toEqual([]); + }); + + it("scopes provider discovery to concrete API-key evidence", () => { + const index = createTestModelListAuthIndex({ + cfg: {}, + authStore: emptyStore, + env: { ANTHROPIC_API_KEY: "test-key" }, + routeResolverFactory: dualRouteResolverFactory, + }); + + expect(index.providerDiscoveryProviderIds).toEqual(["anthropic", "anthropic-openai"]); + }); + + it("scopes provider discovery to external CLI credentials", () => { + externalCliMocks.resolveExternalCliAuthProfiles.mockReturnValue([ + { + profileId: "openai:codex-cli", + credential: { + type: "oauth", + provider: "openai", + access: "external-cli", + refresh: "external-cli-refresh", + expires: Date.now() + 60_000, + }, + }, + ]); + const index = createTestModelListAuthIndex({ + cfg: {}, + authStore: emptyStore, + env: {}, + externalCliProviderIds: ["openai"], + routeResolverFactory: dualRouteResolverFactory, + }); + + expect(index.providerDiscoveryProviderIds).toEqual(["openai"]); + }); + + it("scopes provider discovery to stored credential profiles", () => { + const index = createTestModelListAuthIndex({ + cfg: {}, + authStore: { + version: 1, + profiles: { + "moonshot:stored": { + type: "api_key", + provider: "moonshot", + key: "stored-key", + }, + }, + }, + env: {}, + routeResolverFactory: dualRouteResolverFactory, + }); + + expect(index.providerDiscoveryProviderIds).toEqual(["moonshot"]); + }); + + it("scopes provider discovery to configured auth profiles", () => { + const index = createTestModelListAuthIndex({ + cfg: { + auth: { + profiles: { + "openrouter:configured": { + provider: "openrouter", + mode: "api_key", + }, + }, + }, + }, + authStore: emptyStore, + env: {}, + routeResolverFactory: dualRouteResolverFactory, + }); + + expect(index.providerDiscoveryProviderIds).toEqual(["openrouter"]); }); it("forwards route-aware evaluation through the command adapter", () => { - const index = createModelListAuthIndex({ + const index = createTestModelListAuthIndex({ cfg: {}, authStore: { version: 1, @@ -87,23 +170,26 @@ describe("createModelListAuthIndex", () => { selectedProfileId: "openai:platform", selectedRoute: { authRequirement: "api-key" }, }); + expect(index.providerDiscoveryProviderIds).toEqual(["openai"]); }); - it("uses enabled synthetic refs from a persisted plugin snapshot", () => { - pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: { + it("uses enabled synthetic refs from prepared persisted metadata", () => { + const metadataSnapshot = { + registrySource: "persisted", + registryDiagnostics: [], + plugins: [], + index: { plugins: [ { enabled: true, syntheticAuthRefs: ["codex"] }, { enabled: false, syntheticAuthRefs: ["disabled-provider"] }, ], }, - diagnostics: [], - }); - const index = createModelListAuthIndex({ + } as unknown as PluginMetadataSnapshot; + const index = createTestModelListAuthIndex({ cfg: {}, authStore: emptyStore, env: {}, + metadataSnapshot, routeResolverFactory: dualRouteResolverFactory, }); @@ -128,7 +214,7 @@ describe("createModelListAuthIndex", () => { ], }, } as unknown as PluginMetadataSnapshot; - const index = createModelListAuthIndex({ + const index = createTestModelListAuthIndex({ cfg: {}, authStore: emptyStore, env: {}, @@ -141,23 +227,55 @@ describe("createModelListAuthIndex", () => { evidence: "synthetic", }); expect(index.evaluateModelAuth("disabled-provider").availability).toBeUndefined(); - expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); + }); + + it("maps synthetic auth refs to their configured plugin catalog providers", () => { + const metadataSnapshot = { + registrySource: "persisted", + registryDiagnostics: [], + plugins: [], + index: { + plugins: [ + { + pluginId: "local-runtime", + enabled: true, + syntheticAuthRefs: ["local-cli"], + contributions: { + providers: ["local-runtime"], + modelCatalogProviders: ["local-catalog"], + }, + }, + ], + }, + } as unknown as PluginMetadataSnapshot; + const index = createTestModelListAuthIndex({ + cfg: {}, + authStore: emptyStore, + env: {}, + metadataSnapshot, + syntheticAuthProviderRefs: ["local-cli"], + routeResolverFactory: dualRouteResolverFactory, + }); + + expect(index.providerDiscoveryProviderIds).toEqual(["local-catalog", "local-runtime"]); }); it.each(["derived" as const, "persisted" as const])( "does not trust unusable synthetic refs from a %s snapshot", (source) => { - pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source, - snapshot: { + const metadataSnapshot = { + registrySource: source, + registryDiagnostics: [], + plugins: [], + index: { plugins: [{ enabled: source === "derived", syntheticAuthRefs: ["codex"] }], }, - diagnostics: [], - }); - const index = createModelListAuthIndex({ + } as unknown as PluginMetadataSnapshot; + const index = createTestModelListAuthIndex({ cfg: {}, authStore: emptyStore, env: {}, + metadataSnapshot, routeResolverFactory: dualRouteResolverFactory, }); @@ -172,7 +290,7 @@ describe("createModelListAuthIndex", () => { registrySource: "persisted", plugins: [], } as unknown as PluginMetadataSnapshot; - const index = createModelListAuthIndex({ + const index = createTestModelListAuthIndex({ cfg: {}, authStore: emptyStore, env: {}, @@ -182,7 +300,6 @@ describe("createModelListAuthIndex", () => { }); expect(index.evaluateModelAuth("openai").evidence).toBe("synthetic"); - expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); }); it("fails closed before loading refs from a diagnostic-bearing metadata snapshot", () => { @@ -191,7 +308,7 @@ describe("createModelListAuthIndex", () => { plugins: [{ enabled: true, syntheticAuthRefs: ["codex"] }], registryDiagnostics: [{ level: "error", message: "invalid plugin metadata" }], } as unknown as PluginMetadataSnapshot; - const index = createModelListAuthIndex({ + const index = createTestModelListAuthIndex({ cfg: {}, authStore: emptyStore, env: {}, @@ -200,6 +317,5 @@ describe("createModelListAuthIndex", () => { }); expect(index.evaluateModelAuth("openai").availability).toBe(false); - expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/models/list.auth-index.ts b/src/commands/models/list.auth-index.ts index 10427546a9bb..2638e3c417dc 100644 --- a/src/commands/models/list.auth-index.ts +++ b/src/commands/models/list.auth-index.ts @@ -8,12 +8,12 @@ import { import type { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; -import { loadPluginRegistrySnapshotWithMetadata } from "../../plugins/plugin-registry.js"; export type ModelListAuthRef = ModelAuthAvailabilityRef; export type ModelListAuthEvaluation = ModelAuthAvailabilityEvaluation; export type ModelListAuthIndex = { + providerDiscoveryProviderIds?: readonly string[]; evaluateModelAuth(provider: string, ref?: ModelListAuthRef): ModelListAuthEvaluation; }; @@ -24,41 +24,22 @@ type CreateModelListAuthIndexParams = { workspaceDir?: string; env?: NodeJS.ProcessEnv; syntheticAuthProviderRefs?: readonly string[]; - metadataSnapshot?: PluginMetadataSnapshot; + metadataSnapshot: PluginMetadataSnapshot; externalCliProviderIds?: readonly string[]; routeResolverFactory?: typeof createOpenAIModelRoutesResolver; }; function listValidatedSyntheticAuthProviderRefs(params: { - cfg: OpenClawConfig; - workspaceDir?: string; - env: NodeJS.ProcessEnv; - metadataSnapshot?: PluginMetadataSnapshot; + metadataSnapshot: PluginMetadataSnapshot; }): readonly string[] { - if (params.metadataSnapshot) { - if ( - params.metadataSnapshot.registryDiagnostics.length > 0 || - (params.metadataSnapshot.registrySource !== "persisted" && - params.metadataSnapshot.registrySource !== "provided") - ) { - return []; - } - return params.metadataSnapshot.index.plugins - .filter((plugin) => plugin.enabled) - .flatMap((plugin) => plugin.syntheticAuthRefs ?? []); - } - const result = loadPluginRegistrySnapshotWithMetadata({ - config: params.cfg, - workspaceDir: params.workspaceDir, - env: params.env, - }); if ( - result.diagnostics.length > 0 || - (result.source !== "persisted" && result.source !== "provided") + params.metadataSnapshot.registryDiagnostics.length > 0 || + (params.metadataSnapshot.registrySource !== "persisted" && + params.metadataSnapshot.registrySource !== "provided") ) { return []; } - return result.snapshot.plugins + return params.metadataSnapshot.index.plugins .filter((plugin) => plugin.enabled) .flatMap((plugin) => plugin.syntheticAuthRefs ?? []); } @@ -80,13 +61,11 @@ export function createModelListAuthIndex( syntheticAuthProviderRefs: params.syntheticAuthProviderRefs ?? listValidatedSyntheticAuthProviderRefs({ - cfg: params.cfg, - workspaceDir: params.workspaceDir, - env, metadataSnapshot: params.metadataSnapshot, }), }); return { + providerDiscoveryProviderIds: resolver.providerDiscoveryProviderIds, evaluateModelAuth: (provider, ref) => resolver.evaluateModelAuth(provider, ref), }; } diff --git a/src/commands/models/list.configured.ts b/src/commands/models/list.configured.ts index 00e81f2e0e59..6e6028bae784 100644 --- a/src/commands/models/list.configured.ts +++ b/src/commands/models/list.configured.ts @@ -1,9 +1,11 @@ +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import { modelKey } from "../../agents/model-ref-shared.js"; /** Resolves configured model refs and tags for model-list rows. */ import { buildModelAliasIndex, resolveConfiguredModelRef, resolveModelRefFromString, -} from "../../agents/model-selection.js"; +} from "../../agents/model-selection-shared.js"; import { resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, @@ -12,7 +14,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import type { ConfiguredEntry } from "./list.types.js"; import { createModelCatalogProviderAliasCanonicalizer } from "./provider-aliases.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER, modelKey } from "./shared.js"; const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; diff --git a/src/commands/models/list.format.test.ts b/src/commands/models/list.format.test.ts index d4f9de726dbd..85d13abf9d9e 100644 --- a/src/commands/models/list.format.test.ts +++ b/src/commands/models/list.format.test.ts @@ -1,7 +1,28 @@ // Model list formatting tests cover fixed-width terminal cell helpers. import { describe, expect, it } from "vitest"; import { visibleWidth } from "../../../packages/terminal-core/src/ansi.js"; -import { pad, truncate } from "./list.format.js"; +import { formatTokenK, pad, truncate } from "./list.format.js"; + +describe("formatTokenK", () => { + it("renders token context windows in decimal K", () => { + expect(formatTokenK(200_000)).toBe("200k"); + expect(formatTokenK(128_000)).toBe("128k"); + expect(formatTokenK(1_000_000)).toBe("1000k"); + expect(formatTokenK(195_000)).toBe("195k"); + }); + + it("passes small counts through and switches to K at 1000", () => { + expect(formatTokenK(999)).toBe("999"); + expect(formatTokenK(1_000)).toBe("1k"); + }); + + it("returns a dash for missing or non-finite values", () => { + expect(formatTokenK(undefined)).toBe("-"); + expect(formatTokenK(null)).toBe("-"); + expect(formatTokenK(0)).toBe("-"); + expect(formatTokenK(Number.NaN)).toBe("-"); + }); +}); describe("truncate", () => { it("preserves existing ASCII truncation with an ellipsis suffix", () => { diff --git a/src/commands/models/list.format.ts b/src/commands/models/list.format.ts index c12f11ec36bd..d23cb16da065 100644 --- a/src/commands/models/list.format.ts +++ b/src/commands/models/list.format.ts @@ -5,6 +5,18 @@ import { isRich as isRichTerminal, theme } from "../../../packages/terminal-core const TRUNCATED_SUFFIX = "..."; +/** Formats token counts as compact decimal-K labels. */ +export const formatTokenK = (value?: number | null) => { + if (!value || !Number.isFinite(value)) { + return "-"; + } + // Provider context windows use decimal K, so 200000 must stay "200k". + if (value < 1000) { + return `${Math.round(value)}`; + } + return `${Math.round(value / 1000)}k`; +}; + /** Enables rich formatting only for non-machine-readable output. */ export const isRich = (opts?: { json?: boolean; plain?: boolean }) => isRichTerminal() && !opts?.json && !opts?.plain; diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index c34469a17359..2284c9b6f54c 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -276,6 +276,21 @@ function installModelsListCommandForwardCompatMocks() { }, })); + vi.doMock("./list.scoped-catalog.js", () => ({ + loadScopedListModelCatalogSnapshot: async (params: { + providerIds: readonly string[]; + runtimeProviderIds?: readonly string[]; + manifestFallbackProviderIds?: readonly string[]; + }) => { + const entries = await mocks.loadModelCatalog({ + providerDiscoveryProviderIds: params.providerIds, + providerRuntimeDiscoveryProviderIds: params.runtimeProviderIds, + providerManifestFallbackProviderIds: params.manifestFallbackProviderIds, + }); + return { entries, routeVariants: entries }; + }, + })); + vi.doMock("../../agents/embedded-agent-runner/model.js", () => ({ resolveModelWithRegistry: mocks.resolveModelWithRegistry, })); @@ -674,6 +689,11 @@ describe("modelsListCommand forward-compat", () => { provider: "google", key: "google-fixture", }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "openai-fixture", + }, }, order: {}, }); @@ -701,6 +721,13 @@ describe("modelsListCommand forward-compat", () => { await modelsListCommand({ json: true }, runtime as never); expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(mocks.loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + providerDiscoveryProviderIds: ["google", "openai", "xiaomi"], + providerRuntimeDiscoveryProviderIds: [], + providerManifestFallbackProviderIds: ["google", "openai"], + }), + ); const rows = lastPrintedRows<{ key: string; name: string; available: boolean }>(); expectRowKeys(rows, [ "xiaomi/mimo-v2.5-pro", @@ -1144,6 +1171,11 @@ describe("modelsListCommand forward-compat", () => { expect(modelRegistryOptions().normalizeModels).toBe(false); expect(mocks.resolveModelWithRegistry).not.toHaveBeenCalled(); expect(mocks.loadModelCatalog).toHaveBeenCalledOnce(); + expect(mocks.loadModelCatalog).toHaveBeenCalledWith( + expect.not.objectContaining({ + providerDiscoveryProviderIds: expect.anything(), + }), + ); expectRowKeys(lastPrintedRows<{ key: string }>(), ["openai/gpt-5.4", "moonshot/kimi-k2.6"]); }); @@ -1164,6 +1196,11 @@ describe("modelsListCommand forward-compat", () => { expect(modelRegistryOptions().providerFilter).toBe("openai"); expect(modelRegistryOptions().normalizeModels).toBe(true); expect(mocks.loadModelCatalog).toHaveBeenCalledOnce(); + expect(mocks.loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + providerDiscoveryProviderIds: ["openai"], + }), + ); const rows = lastPrintedRows<{ key: string; available: boolean }>(); expectRowKeys(rows, ["openai/gpt-5.4"]); expectRowFields(rows, "openai/gpt-5.4", { available: true }); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 27baad6c380b..b99bd780b5ef 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -1,6 +1,7 @@ /** Implementation of `openclaw models list`. */ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { parseModelRef } from "../../agents/model-selection.js"; +import { DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import { parseModelRef } from "../../agents/model-selection-normalize.js"; import { requestExitAfterOneShotOutput } from "../../cli/one-shot-exit.js"; import type { ModelRegistry } from "../../llm/model-registry.js"; import type { Model } from "../../llm/types.js"; @@ -10,11 +11,11 @@ import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { createModelListAuthIndex } from "./list.auth-index.js"; import { resolveConfiguredEntries } from "./list.configured.js"; import { formatErrorWithStack } from "./list.errors.js"; +import { ensureFlagCompatibility } from "./list.options.js"; import { printModelTable } from "./list.table.js"; import type { ModelRow } from "./list.types.js"; import { loadModelsConfigWithSource } from "./load-config.js"; import { canonicalizeModelCatalogProviderAlias } from "./provider-aliases.js"; -import { DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; @@ -119,6 +120,30 @@ export async function modelsListCommand( // The default configured view remains lazy; full and filtered views share // the registry and the same committed model generation as the Gateway. const includePreparedCatalog = Boolean(opts.all || providerFilter); + const providerDiscoveryProviderIds = (() => { + if (opts.all && !providerFilter) { + return undefined; + } + if (providerFilter) { + return [providerFilter]; + } + return [ + ...new Set([ + ...(authIndex.providerDiscoveryProviderIds ?? []), + ...entries.map((entry) => entry.ref.provider), + ...Object.keys(cfg.models?.providers ?? {}), + ]), + ].toSorted((left, right) => left.localeCompare(right)); + })(); + const providerRuntimeDiscoveryProviderIds = providerFilter + ? [providerFilter] + : opts.all + ? undefined + : []; + // Default lists use authenticated providers' authored fallback rows. Live + // account discovery remains explicit because it imports full provider runtimes. + const providerManifestFallbackProviderIds = + !providerFilter && !opts.all ? authIndex.providerDiscoveryProviderIds : undefined; const loadRegistryState = async (optsLocal?: { normalizeModels?: boolean; loadAvailability?: boolean; @@ -162,7 +187,11 @@ export async function modelsListCommand( cfg, agentId, agentDir, + inheritedAuthDir: agentDir, authIndex, + providerDiscoveryProviderIds, + providerRuntimeDiscoveryProviderIds, + providerManifestFallbackProviderIds, availableKeys, configuredByKey, discoveredKeys, diff --git a/src/commands/models/list.manifest-catalog.test.ts b/src/commands/models/list.manifest-catalog.test.ts index d0fda1f9c059..c2b37c624ee2 100644 --- a/src/commands/models/list.manifest-catalog.test.ts +++ b/src/commands/models/list.manifest-catalog.test.ts @@ -9,8 +9,11 @@ const mocks = vi.hoisted(() => ({ getRemoteModelCatalogOverlay: vi.fn(), })); -vi.mock("../../plugins/plugin-registry.js", () => ({ +vi.mock("../../plugins/plugin-registry-contributions.js", () => ({ resolvePluginContributionOwners: mocks.resolvePluginContributionOwners, +})); + +vi.mock("../../plugins/plugin-registry-snapshot.js", () => ({ getPluginRecord: mocks.getPluginRecord, isPluginEnabled: mocks.isPluginEnabled, })); @@ -26,6 +29,7 @@ vi.mock("../../model-catalog/remote-overlay.js", () => ({ const moonshotPlugin = { id: "moonshot", + origin: "bundled", providers: ["moonshot"], modelCatalog: { providers: { @@ -41,6 +45,7 @@ const moonshotPlugin = { const openrouterPlugin = { id: "openrouter", + origin: "bundled", providers: ["openrouter"], modelCatalog: { providers: { @@ -56,6 +61,7 @@ const openrouterPlugin = { const openaiRuntimePlugin = { id: "openai", + origin: "bundled", providers: ["openai"], modelCatalog: { providers: { @@ -69,6 +75,23 @@ const openaiRuntimePlugin = { }, }; +const anthropicRuntimeAugmentPlugin = { + id: "anthropic", + origin: "bundled", + providers: ["anthropic"], + modelCatalog: { + runtimeAugment: true, + providers: { + anthropic: { + models: [{ id: "claude-known", name: "Known Claude" }], + }, + }, + discovery: { + anthropic: "refreshable", + }, + }, +}; + describe("loadStaticManifestCatalogRowsForList", () => { beforeEach(() => { vi.clearAllMocks(); @@ -119,6 +142,28 @@ describe("loadStaticManifestCatalogRowsForList", () => { ).toEqual(["moonshot/kimi-k2.6"]); }); + it("loads runtime manifest rows for an explicitly scoped lightweight view", async () => { + const { loadManifestCatalogRowsForList } = await import("./list.manifest-catalog.js"); + const manifestRegistry = { + plugins: [openaiRuntimePlugin, moonshotPlugin], + diagnostics: [], + }; + const metadataSnapshot = { + index: { plugins: [], diagnostics: [] }, + manifestRegistry, + plugins: manifestRegistry.plugins, + }; + + expect( + loadManifestCatalogRowsForList({ + cfg: {}, + metadataSnapshot: metadataSnapshot as unknown as Parameters< + typeof loadManifestCatalogRowsForList + >[0]["metadataSnapshot"], + }).map((row) => row.ref), + ).toEqual(["moonshot/kimi-k2.6", "openai/gpt-known"]); + }); + it("does not expose runtime overlay rows as static manifest models", async () => { const { loadStaticManifestCatalogRowsForList } = await import("./list.manifest-catalog.js"); const manifestRegistry = { @@ -171,3 +216,89 @@ describe("loadStaticManifestCatalogRowsForList", () => { expect(mocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); }); }); + +describe("resolveManifestCatalogCoverageForList", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getPluginRecord.mockImplementation(({ pluginId }: { pluginId: string }) => ({ + pluginId, + })); + mocks.isPluginEnabled.mockReturnValue(true); + mocks.resolvePluginContributionOwners.mockImplementation(({ matches }: { matches: string }) => [ + matches, + ]); + }); + + it("marks only static non-augment owners as complete", async () => { + const { resolveManifestCatalogCoverageForList } = await import("./list.manifest-catalog.js"); + const metadataSnapshot = { + index: { plugins: [], diagnostics: [] }, + manifestRegistry: { + plugins: [ + anthropicRuntimeAugmentPlugin, + moonshotPlugin, + openrouterPlugin, + openaiRuntimePlugin, + ], + diagnostics: [], + }, + }; + + const coverage = resolveManifestCatalogCoverageForList({ + cfg: {}, + providerIds: new Set(["anthropic", "moonshot", "openrouter", "openai"]), + metadataSnapshot: metadataSnapshot as never, + }); + + expect(coverage.ownedProviderIds).toEqual( + new Set(["anthropic", "moonshot", "openrouter", "openai"]), + ); + expect(coverage.completeProviderIds).toEqual(new Set(["moonshot"])); + }); + + it("requires runtime augmentation for external provider plugins", async () => { + const { resolveManifestCatalogCoverageForList } = await import("./list.manifest-catalog.js"); + const externalPlugin = { + ...moonshotPlugin, + id: "external-moonshot", + origin: "external", + }; + const metadataSnapshot = { + index: { plugins: [], diagnostics: [] }, + manifestRegistry: { + plugins: [externalPlugin], + diagnostics: [], + }, + }; + mocks.resolvePluginContributionOwners.mockReturnValue(["external-moonshot"]); + mocks.getPluginRecord.mockReturnValue(undefined); + + const coverage = resolveManifestCatalogCoverageForList({ + cfg: {}, + providerIds: new Set(["moonshot"]), + metadataSnapshot: metadataSnapshot as never, + }); + + expect(coverage.ownedProviderIds).toEqual(new Set(["moonshot"])); + expect(coverage.completeProviderIds).toEqual(new Set()); + }); + + it("treats replace mode as explicit opt-out from provider discovery", async () => { + const { resolveManifestCatalogCoverageForList } = await import("./list.manifest-catalog.js"); + const metadataSnapshot = { + index: { plugins: [], diagnostics: [] }, + manifestRegistry: { + plugins: [openaiRuntimePlugin], + diagnostics: [], + }, + }; + + const coverage = resolveManifestCatalogCoverageForList({ + cfg: { models: { mode: "replace" } }, + providerIds: new Set(["openai"]), + metadataSnapshot: metadataSnapshot as never, + }); + + expect(coverage.completeProviderIds).toEqual(new Set(["openai"])); + }); +}); diff --git a/src/commands/models/list.manifest-catalog.ts b/src/commands/models/list.manifest-catalog.ts index ddfa616adf5f..0d2ed1a898fe 100644 --- a/src/commands/models/list.manifest-catalog.ts +++ b/src/commands/models/list.manifest-catalog.ts @@ -1,23 +1,26 @@ /** Manifest-backed model catalog row loaders for `openclaw models list`. */ import { normalizeModelCatalogProviderId } from "@openclaw/model-catalog-core/model-catalog-refs"; import type { NormalizedModelCatalogRow } from "@openclaw/model-catalog-core/model-catalog-types"; +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { planEffectiveModelCatalogRows } from "../../model-catalog/index.js"; +import type { ManifestModelCatalogRowSelection } from "../../model-catalog/manifest-planner.js"; import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; +import { resolvePluginContributionOwners } from "../../plugins/plugin-registry-contributions.js"; import { getPluginRecord, isPluginEnabled, - resolvePluginContributionOwners, type PluginRegistrySnapshot, -} from "../../plugins/plugin-registry.js"; +} from "../../plugins/plugin-registry-snapshot.js"; -function loadManifestCatalogRowsForPluginIds(params: { +function planManifestCatalogRowsForPluginIds(params: { cfg: OpenClawConfig; registry: PluginManifestRegistry; pluginIds?: readonly string[]; providerFilter?: string; + selection?: ManifestModelCatalogRowSelection; }): readonly NormalizedModelCatalogRow[] { if (params.pluginIds && params.pluginIds.length === 0) { return []; @@ -33,7 +36,7 @@ function loadManifestCatalogRowsForPluginIds(params: { registry, config: params.cfg, ...(params.providerFilter ? { providerFilter: params.providerFilter } : {}), - selection: "static", + ...(params.selection ? { selection: params.selection } : {}), }).rows; } @@ -72,12 +75,95 @@ function resolveDeclaredModelCatalogPluginIds(params: { }); } -/** Loads authoritative static manifest catalog rows for model-list output. */ -export function loadStaticManifestCatalogRowsForList(params: { +function resolveModelCatalogPluginIdsForProvider(params: { + cfg: OpenClawConfig; + index: PluginRegistrySnapshot; + provider: string; +}): readonly string[] { + return [ + ...new Set([ + ...resolveConventionModelCatalogPluginIds({ + cfg: params.cfg, + index: params.index, + providerFilter: params.provider, + }), + ...resolveDeclaredModelCatalogPluginIds({ + cfg: params.cfg, + index: params.index, + providerFilter: params.provider, + }), + ]), + ]; +} + +/** + * Resolves provider ownership and whether static manifest rows require runtime + * augmentation before they can be treated as complete catalog coverage. + */ +export function resolveManifestCatalogCoverageForList(params: { + cfg: OpenClawConfig; + providerIds: ReadonlySet; + env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; +}): { + ownedProviderIds: ReadonlySet; + completeProviderIds: ReadonlySet; +} { + const snapshot = + params.metadataSnapshot ?? + loadManifestMetadataSnapshot({ + config: params.cfg, + env: params.env ?? process.env, + }); + const pluginsById = new Map( + snapshot.manifestRegistry.plugins.map((plugin) => [plugin.id, plugin]), + ); + const ownedProviderIds = new Set(); + const completeProviderIds = new Set(); + for (const rawProvider of params.providerIds) { + const provider = normalizeProviderId(rawProvider); + if (!provider) { + continue; + } + const pluginIds = resolveModelCatalogPluginIdsForProvider({ + cfg: params.cfg, + index: snapshot.index, + provider, + }); + if (pluginIds.length === 0) { + continue; + } + ownedProviderIds.add(provider); + const complete = pluginIds.every((pluginId) => { + const plugin = pluginsById.get(pluginId); + if (!plugin) { + return false; + } + if (plugin.modelCatalog?.runtimeAugment === true || plugin.origin !== "bundled") { + return false; + } + const aliasTarget = plugin.modelCatalog?.aliases?.[provider]?.provider; + const discoveryProvider = normalizeProviderId(aliasTarget ?? provider); + return plugin.modelCatalog?.discovery?.[discoveryProvider] === "static"; + }); + if (complete) { + completeProviderIds.add(provider); + } + } + if (params.cfg.models?.mode === "replace") { + for (const provider of params.providerIds) { + completeProviderIds.add(normalizeProviderId(provider)); + } + } + return { ownedProviderIds, completeProviderIds }; +} + +function loadManifestCatalogRowsForListSelection(params: { cfg: OpenClawConfig; providerFilter?: string; env?: NodeJS.ProcessEnv; metadataSnapshot?: PluginMetadataSnapshot; + selection?: ManifestModelCatalogRowSelection; }): readonly NormalizedModelCatalogRow[] { const providerFilter = params.providerFilter ? normalizeModelCatalogProviderId(params.providerFilter) @@ -90,12 +176,13 @@ export function loadStaticManifestCatalogRowsForList(params: { }); const index = snapshot.index; if (!providerFilter) { - return loadManifestCatalogRowsForPluginIds({ + return planManifestCatalogRowsForPluginIds({ cfg: params.cfg, registry: snapshot.manifestRegistry, + ...(params.selection ? { selection: params.selection } : {}), }); } - const conventionRows = loadManifestCatalogRowsForPluginIds({ + const conventionRows = planManifestCatalogRowsForPluginIds({ cfg: params.cfg, registry: snapshot.manifestRegistry, pluginIds: resolveConventionModelCatalogPluginIds({ @@ -104,11 +191,12 @@ export function loadStaticManifestCatalogRowsForList(params: { providerFilter, }), providerFilter, + ...(params.selection ? { selection: params.selection } : {}), }); if (conventionRows.length > 0) { return conventionRows; } - return loadManifestCatalogRowsForPluginIds({ + return planManifestCatalogRowsForPluginIds({ cfg: params.cfg, registry: snapshot.manifestRegistry, pluginIds: resolveDeclaredModelCatalogPluginIds({ @@ -117,5 +205,26 @@ export function loadStaticManifestCatalogRowsForList(params: { providerFilter, }), providerFilter, + ...(params.selection ? { selection: params.selection } : {}), }); } + +/** Loads manifest catalog rows without importing provider runtimes. */ +export function loadManifestCatalogRowsForList(params: { + cfg: OpenClawConfig; + providerFilter?: string; + env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; +}): readonly NormalizedModelCatalogRow[] { + return loadManifestCatalogRowsForListSelection(params); +} + +/** Loads authoritative static manifest catalog rows for model-list output. */ +export function loadStaticManifestCatalogRowsForList(params: { + cfg: OpenClawConfig; + providerFilter?: string; + env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; +}): readonly NormalizedModelCatalogRow[] { + return loadManifestCatalogRowsForListSelection({ ...params, selection: "static" }); +} diff --git a/src/commands/models/list.model-projection.ts b/src/commands/models/list.model-projection.ts new file mode 100644 index 000000000000..7485d3930039 --- /dev/null +++ b/src/commands/models/list.model-projection.ts @@ -0,0 +1,72 @@ +import { resolveBundledProviderPolicySurface } from "../../plugins/provider-public-artifacts.js"; +import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; +import { createLazyImportLoader } from "../../shared/lazy-promise.js"; +import type { ListRowModel } from "./list.model-row.js"; +import type { RowBuilderContext } from "./list.row-context.js"; + +type ProviderRuntimeModule = typeof import("../../plugins/provider-runtime.js"); + +const providerRuntimeModuleLoader = createLazyImportLoader( + () => import("../../plugins/provider-runtime.js"), +); + +function toListRowInput(input: readonly string[] | undefined): ListRowModel["input"] { + const parsed = input?.filter( + (item): item is NonNullable[number] => + item === "text" || item === "image" || item === "document", + ); + return parsed?.length ? parsed : ["text"]; +} + +function mergeNormalizedListRow( + model: ListRowModel, + normalized: ProviderRuntimeModel, +): ListRowModel { + return { + ...model, + id: normalized.id, + name: normalized.name, + provider: normalized.provider, + api: normalized.api ?? model.api, + baseUrl: normalized.baseUrl ?? model.baseUrl, + input: toListRowInput(normalized.input), + contextWindow: normalized.contextWindow, + contextTokens: normalized.contextTokens, + }; +} + +/** Projects one authored provider row while keeping full runtime loading optional. */ +export async function normalizeConfiguredProviderListRow(params: { + model: ListRowModel; + context: RowBuilderContext; +}): Promise { + const normalizationContext = { + config: params.context.cfg, + agentDir: params.context.agentDir, + workspaceDir: params.context.workspaceDir, + provider: params.model.provider, + modelId: params.model.id, + model: params.model as ProviderRuntimeModel, + }; + const policySurface = resolveBundledProviderPolicySurface(params.model.provider, { + manifestRegistry: params.context.metadataSnapshot?.manifestRegistry, + }); + if (policySurface?.projectConfiguredModelRow) { + const projected = policySurface.projectConfiguredModelRow(normalizationContext); + if (projected === null) { + return params.model; + } + if (projected !== undefined) { + return mergeNormalizedListRow(params.model, projected); + } + } + const { normalizeProviderResolvedModelWithPlugin } = await providerRuntimeModuleLoader.load(); + const normalized = normalizeProviderResolvedModelWithPlugin({ + provider: params.model.provider, + config: params.context.cfg, + workspaceDir: params.context.workspaceDir, + pluginMetadataSnapshot: params.context.metadataSnapshot, + context: normalizationContext, + }); + return normalized ? mergeNormalizedListRow(params.model, normalized) : params.model; +} diff --git a/src/commands/models/list.options.ts b/src/commands/models/list.options.ts new file mode 100644 index 000000000000..2320163d3782 --- /dev/null +++ b/src/commands/models/list.options.ts @@ -0,0 +1,6 @@ +/** Rejects conflicting machine-readable output modes. */ +export function ensureFlagCompatibility(opts: { json?: boolean; plain?: boolean }): void { + if (opts.json && opts.plain) { + throw new Error("Choose either --json or --plain, not both."); + } +} diff --git a/src/commands/models/list.persisted-catalog.test.ts b/src/commands/models/list.persisted-catalog.test.ts new file mode 100644 index 000000000000..cdf5bf2f8349 --- /dev/null +++ b/src/commands/models/list.persisted-catalog.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadPersistedPluginModelCatalogsReadOnly: vi.fn(), + isGeneratedPluginModelCatalog: vi.fn( + (value: unknown) => + typeof value === "object" && + value !== null && + (value as { generatedBy?: unknown }).generatedBy === "openclaw-plugin-model-catalog-v1", + ), + filterGeneratedPluginModelCatalogProviders: vi.fn( + ({ providers }: { providers: Record }) => providers, + ), +})); + +vi.mock("../../agents/plugin-model-catalog.js", () => ({ + loadPersistedPluginModelCatalogsReadOnly: mocks.loadPersistedPluginModelCatalogsReadOnly, + isGeneratedPluginModelCatalog: mocks.isGeneratedPluginModelCatalog, + filterGeneratedPluginModelCatalogProviders: mocks.filterGeneratedPluginModelCatalogProviders, +})); + +import { loadPersistedListCatalogEntries } from "./list.persisted-catalog.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("loadPersistedListCatalogEntries", () => { + it("projects valid generated provider rows with provider transport defaults", () => { + mocks.loadPersistedPluginModelCatalogsReadOnly.mockReturnValueOnce([ + { + pluginId: "openai", + contents: JSON.stringify({ + generatedBy: "openclaw-plugin-model-catalog-v1", + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [ + { + id: "gpt-5.6", + name: "GPT-5.6", + input: ["text", "image", "invalid"], + reasoning: true, + contextWindow: 400_000, + }, + ], + }, + }, + }), + }, + ]); + + expect( + loadPersistedListCatalogEntries({ + agentDir: "/tmp/openclaw-agent", + providerIds: new Set(["openai"]), + }), + ).toEqual([ + { + provider: "openai", + id: "gpt-5.6", + name: "GPT-5.6", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + input: ["text", "image"], + reasoning: true, + contextWindow: 400_000, + }, + ]); + }); + + it("skips malformed, unscoped, and transportless rows", () => { + mocks.loadPersistedPluginModelCatalogsReadOnly.mockReturnValueOnce([ + { pluginId: "bad-json", contents: "{" }, + { + pluginId: "catalog", + contents: JSON.stringify({ + generatedBy: "openclaw-plugin-model-catalog-v1", + providers: { + openai: { models: [{ id: "missing-api" }] }, + google: { + api: "google-generative-ai", + models: [{ id: "gemini-live" }], + }, + }, + }), + }, + ]); + + expect( + loadPersistedListCatalogEntries({ + agentDir: "/tmp/openclaw-agent", + providerIds: new Set(["openai"]), + }), + ).toEqual([]); + }); +}); diff --git a/src/commands/models/list.persisted-catalog.ts b/src/commands/models/list.persisted-catalog.ts new file mode 100644 index 000000000000..a6a1a6428994 --- /dev/null +++ b/src/commands/models/list.persisted-catalog.ts @@ -0,0 +1,113 @@ +/** Reads persisted generated catalogs without constructing a model registry. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import type { ModelCatalogEntry, ModelInputType } from "../../agents/model-catalog.types.js"; +import { + filterGeneratedPluginModelCatalogProviders, + isGeneratedPluginModelCatalog, + loadPersistedPluginModelCatalogsReadOnly, +} from "../../agents/plugin-model-catalog.js"; +import { MODEL_APIS, type ModelApi } from "../../config/types.models.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; + +const modelApis = new Set(MODEL_APIS); +const modelInputs = new Set(["text", "image", "audio", "video", "document"]); + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readPositiveNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function readModelApi(value: unknown): ModelApi | undefined { + const api = readString(value); + return api && modelApis.has(api) ? (api as ModelApi) : undefined; +} + +function readModelInput(value: unknown): ModelInputType[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const input = value.filter( + (item): item is ModelInputType => + typeof item === "string" && modelInputs.has(item as ModelInputType), + ); + return input.length > 0 ? input : undefined; +} + +function parseProviderModels(params: { + providerId: string; + provider: unknown; +}): ModelCatalogEntry[] { + if (!isRecord(params.provider) || !Array.isArray(params.provider.models)) { + return []; + } + const providerApi = readModelApi(params.provider.api); + const providerBaseUrl = readString(params.provider.baseUrl); + return params.provider.models.flatMap((model) => { + if (!isRecord(model)) { + return []; + } + const id = readString(model.id); + const api = readModelApi(model.api) ?? providerApi; + if (!id || !api) { + return []; + } + const baseUrl = readString(model.baseUrl) ?? providerBaseUrl; + const input = readModelInput(model.input); + const contextWindow = readPositiveNumber(model.contextWindow); + const contextTokens = readPositiveNumber(model.contextTokens); + return [ + { + id, + name: readString(model.name) ?? id, + provider: normalizeProviderId(params.providerId), + api, + ...(baseUrl ? { baseUrl } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(contextTokens !== undefined ? { contextTokens } : {}), + ...(typeof model.reasoning === "boolean" ? { reasoning: model.reasoning } : {}), + ...(input ? { input } : {}), + }, + ]; + }); +} + +/** Loads valid provider-owned rows from the agent's generated catalog cache. */ +export function loadPersistedListCatalogEntries(params: { + agentDir: string; + providerIds: ReadonlySet; + metadataSnapshot?: PluginMetadataSnapshot; +}): ModelCatalogEntry[] { + const entries: ModelCatalogEntry[] = []; + for (const catalog of loadPersistedPluginModelCatalogsReadOnly(params.agentDir)) { + let parsed: unknown; + try { + parsed = JSON.parse(catalog.contents) as unknown; + } catch { + continue; + } + if ( + !isRecord(parsed) || + !isGeneratedPluginModelCatalog(parsed) || + !isRecord(parsed.providers) + ) { + continue; + } + const providers = filterGeneratedPluginModelCatalogProviders({ + catalogPluginId: catalog.pluginId, + parsedCatalog: parsed, + pluginMetadataSnapshot: params.metadataSnapshot, + providers: parsed.providers, + }); + for (const [providerId, provider] of Object.entries(providers)) { + if (!params.providerIds.has(normalizeProviderId(providerId))) { + continue; + } + entries.push(...parseProviderModels({ providerId, provider })); + } + } + return entries; +} diff --git a/src/commands/models/list.registry-load.ts b/src/commands/models/list.registry-load.ts index 30fa83f1f18d..9227dc91bb6e 100644 --- a/src/commands/models/list.registry-load.ts +++ b/src/commands/models/list.registry-load.ts @@ -1,3 +1,4 @@ +import { modelKey } from "../../agents/model-ref-shared.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; /** Registry-loading adapters for model-list row construction. */ import { loadPreparedAgentModelRegistry as loadAgentModelRegistry } from "../../agents/prepared-model-registry.js"; @@ -6,7 +7,6 @@ import type { ModelRegistry } from "../../llm/model-registry.js"; import type { Model } from "../../llm/types.js"; import { loadModelRegistry } from "./list.registry.js"; import type { ConfiguredEntry } from "./list.types.js"; -import { modelKey } from "./shared.js"; /** Loads the full model registry and tracks discovered provider/model keys. */ export async function loadListModelRegistry( diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 4d3780760824..db915371fb53 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -1,3 +1,4 @@ +import { modelKey } from "../../agents/model-ref-shared.js"; import { shouldSuppressBuiltInModel, shouldSuppressBuiltInModelFromManifest, @@ -12,7 +13,6 @@ import { MODEL_AVAILABILITY_UNAVAILABLE_CODE, shouldFallbackToAuthHeuristics, } from "./list.errors.js"; -import { modelKey } from "./shared.js"; function createAvailabilityUnavailableError(message: string): Error { const err = new Error(message); diff --git a/src/commands/models/list.row-context.ts b/src/commands/models/list.row-context.ts new file mode 100644 index 000000000000..2eb2771aa2da --- /dev/null +++ b/src/commands/models/list.row-context.ts @@ -0,0 +1,28 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; +import type { ModelListAuthIndex } from "./list.auth-index.js"; +import type { ConfiguredEntry } from "./list.types.js"; + +type RowFilter = { + provider?: string; + local?: boolean; +}; + +/** Context shared by every model-list row source builder. */ +export type RowBuilderContext = { + cfg: OpenClawConfig; + agentId?: string; + agentDir: string; + inheritedAuthDir?: string; + authIndex: ModelListAuthIndex; + providerDiscoveryProviderIds?: readonly string[]; + providerRuntimeDiscoveryProviderIds?: readonly string[]; + providerManifestFallbackProviderIds?: readonly string[]; + availableKeys?: Set; + configuredByKey: Map; + discoveredKeys: Set; + filter: RowFilter; + skipRuntimeModelSuppression?: boolean; + metadataSnapshot?: PluginMetadataSnapshot; + workspaceDir?: string; +}; diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index 7bb744669dd9..50c60dcdbf59 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -5,7 +5,9 @@ import type { ModelRow } from "./list.types.js"; const mocks = vi.hoisted(() => ({ loadModelCatalogSnapshot: vi.fn(), + loadScopedModelCatalogSnapshot: vi.fn(), normalizeProviderResolvedModelWithPlugin: vi.fn(() => undefined), + resolveBundledProviderPolicySurface: vi.fn(() => null), shouldSuppressBuiltInModel: vi.fn(() => { throw new Error("runtime model suppression should be skipped"); }), @@ -21,10 +23,18 @@ vi.mock("../../agents/prepared-model-catalog.js", () => ({ loadPreparedModelCatalogSnapshot: mocks.loadModelCatalogSnapshot, })); +vi.mock("./list.scoped-catalog.js", () => ({ + loadScopedListModelCatalogSnapshot: mocks.loadScopedModelCatalogSnapshot, +})); + vi.mock("../../plugins/provider-runtime.js", () => ({ normalizeProviderResolvedModelWithPlugin: mocks.normalizeProviderResolvedModelWithPlugin, })); +vi.mock("../../plugins/provider-public-artifacts.js", () => ({ + resolveBundledProviderPolicySurface: mocks.resolveBundledProviderPolicySurface, +})); + import { appendAuthenticatedCatalogRows, appendConfiguredRows, @@ -234,7 +244,7 @@ describe("appendPreparedModelCatalogRows", () => { provider: "anthropic", input: ["text"] as "text"[], }; - mocks.loadModelCatalogSnapshot.mockResolvedValueOnce({ + mocks.loadScopedModelCatalogSnapshot.mockReturnValueOnce({ entries: [entry], routeVariants: [entry], }); @@ -247,8 +257,14 @@ describe("appendPreparedModelCatalogRows", () => { cfg: {}, agentId: "worker", agentDir: "/tmp/openclaw-worker", + inheritedAuthDir: "/tmp/openclaw-default", workspaceDir: "/tmp/openclaw-workspace", - authIndex: { evaluateModelAuth: () => authEvaluation(true) }, + providerDiscoveryProviderIds: ["anthropic"], + providerRuntimeDiscoveryProviderIds: ["anthropic"], + providerManifestFallbackProviderIds: ["anthropic"], + authIndex: { + evaluateModelAuth: () => authEvaluation(true), + }, configuredByKey: new Map(), discoveredKeys: new Set(), filter: { provider: "anthropic" }, @@ -257,13 +273,18 @@ describe("appendPreparedModelCatalogRows", () => { }); expect(requireOnlyRow(rows).key).toBe("anthropic/claude-opus-4-7"); - expect(mocks.loadModelCatalogSnapshot).toHaveBeenCalledExactlyOnceWith({ - config: {}, + expect(mocks.loadScopedModelCatalogSnapshot).toHaveBeenCalledExactlyOnceWith({ + cfg: {}, agentId: "worker", agentDir: "/tmp/openclaw-worker", + inheritedAuthDir: "/tmp/openclaw-default", workspaceDir: "/tmp/openclaw-workspace", - readOnly: true, + providerIds: ["anthropic"], + runtimeProviderIds: ["anthropic"], + manifestFallbackProviderIds: ["anthropic"], + configuredKeys: [], }); + expect(mocks.loadModelCatalogSnapshot).not.toHaveBeenCalled(); }); }); @@ -726,6 +747,50 @@ describe("prepared provider catalog projection", () => { }); describe("appendConfiguredProviderRows", () => { + it("skips provider runtime normalization when lightweight policy proves the row canonical", async () => { + mocks.resolveBundledProviderPolicySurface.mockReturnValueOnce({ + projectConfiguredModelRow: () => null, + } as never); + const rows: ModelRow[] = []; + + await appendConfiguredProviderRows({ + rows, + seenKeys: new Set(), + context: { + cfg: { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "http://127.0.0.1:8080/v1", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 96_000, + maxTokens: 128_000, + }, + ], + }, + }, + }, + }, + agentDir: "/tmp/openclaw-agent", + authIndex, + configuredByKey: new Map(), + discoveredKeys: new Set(), + filter: { provider: "openai", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(mocks.normalizeProviderResolvedModelWithPlugin).not.toHaveBeenCalled(); + expect(requireOnlyRow(rows).input).toBe("text"); + }); + it("keeps provider normalization for configured provider models", async () => { mocks.normalizeProviderResolvedModelWithPlugin.mockReturnValueOnce({ provider: "anthropic", @@ -897,7 +962,10 @@ describe("appendAuthenticatedCatalogRows", () => { maxTokens: 4096, }, ]; - mocks.loadModelCatalogSnapshot.mockResolvedValueOnce({ entries, routeVariants: entries }); + mocks.loadScopedModelCatalogSnapshot.mockReturnValueOnce({ + entries, + routeVariants: entries, + }); const rows: ModelRow[] = []; await appendAuthenticatedCatalogRows({ @@ -907,6 +975,8 @@ describe("appendAuthenticatedCatalogRows", () => { cfg: {}, agentDir: "/tmp/openclaw-agent", workspaceDir: "/tmp/openclaw-workspace", + providerDiscoveryProviderIds: ["local-openai"], + providerRuntimeDiscoveryProviderIds: ["local-openai"], authIndex: { evaluateModelAuth: () => ({ availability: undefined, @@ -926,11 +996,14 @@ describe("appendAuthenticatedCatalogRows", () => { local: true, available: true, }); - expect(mocks.loadModelCatalogSnapshot).toHaveBeenCalledWith({ - config: {}, + expect(mocks.loadScopedModelCatalogSnapshot).toHaveBeenCalledWith({ + cfg: {}, agentDir: "/tmp/openclaw-agent", + inheritedAuthDir: "/tmp/openclaw-agent", workspaceDir: "/tmp/openclaw-workspace", - readOnly: true, + providerIds: ["local-openai"], + runtimeProviderIds: ["local-openai"], + configuredKeys: [], }); }); diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index aaafc3e2c84f..ee7097e57d71 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -9,6 +9,7 @@ import { resolveConfiguredModelCatalogOverrides, } from "../../agents/model-catalog-route.js"; import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js"; +import { modelKey } from "../../agents/model-ref-shared.js"; import { modelCatalogLogicalKey } from "../../agents/model-selection-shared.js"; import { shouldSuppressBuiltInModel, @@ -19,49 +20,28 @@ import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/ty import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ModelRegistry } from "../../llm/model-registry.js"; import type { Model } from "../../llm/types.js"; -import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; -import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; -import { normalizeProviderResolvedModelWithPlugin } from "../../plugins/provider-runtime.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; -import type { - ModelListAuthEvaluation, - ModelListAuthIndex, - ModelListAuthRef, -} from "./list.auth-index.js"; +import type { ModelListAuthEvaluation, ModelListAuthRef } from "./list.auth-index.js"; import { isLocalBaseUrl } from "./list.local-url.js"; +import { normalizeConfiguredProviderListRow } from "./list.model-projection.js"; import type { ListRowModel } from "./list.model-row.js"; import { toModelRow } from "./list.model-row.js"; +import type { RowBuilderContext } from "./list.row-context.js"; import type { ConfiguredEntry, ModelRow } from "./list.types.js"; import { canonicalizeModelCatalogProviderAlias } from "./provider-aliases.js"; -import { modelKey } from "./shared.js"; -type ConfiguredByKey = Map; type ModelCatalogModule = typeof import("../../agents/prepared-model-catalog.js"); type ModelResolverModule = typeof import("../../agents/embedded-agent-runner/model.js"); +type ScopedModelCatalogModule = typeof import("./list.scoped-catalog.js"); -type RowFilter = { - provider?: string; - local?: boolean; -}; - -/** Context shared by every model-list row source builder. */ -export type RowBuilderContext = { - cfg: OpenClawConfig; - agentId?: string; - agentDir: string; - authIndex: ModelListAuthIndex; - availableKeys?: Set; - configuredByKey: ConfiguredByKey; - discoveredKeys: Set; - filter: RowFilter; - skipRuntimeModelSuppression?: boolean; - metadataSnapshot?: PluginMetadataSnapshot; - workspaceDir?: string; -}; +export type { RowBuilderContext } from "./list.row-context.js"; const modelCatalogModuleLoader = createLazyImportLoader( () => import("../../agents/prepared-model-catalog.js"), ); +const scopedModelCatalogModuleLoader = createLazyImportLoader( + () => import("./list.scoped-catalog.js"), +); const modelResolverModuleLoader = createLazyImportLoader( () => import("../../agents/embedded-agent-runner/model.js"), ); @@ -69,6 +49,10 @@ function loadPreparedModelCatalogModule(): Promise { return modelCatalogModuleLoader.load(); } +function loadScopedModelCatalogModule(): Promise { + return scopedModelCatalogModuleLoader.load(); +} + function loadModelResolverModule(): Promise { return modelResolverModuleLoader.load(); } @@ -255,40 +239,6 @@ function shouldSuppressListModel(params: { }); } -function normalizeListRowWithProviderPlugin(params: { - model: ListRowModel; - context: RowBuilderContext; -}): ListRowModel { - const normalized = normalizeProviderResolvedModelWithPlugin({ - provider: params.model.provider, - config: params.context.cfg, - workspaceDir: params.context.workspaceDir, - pluginMetadataSnapshot: params.context.metadataSnapshot, - context: { - config: params.context.cfg, - agentDir: params.context.agentDir, - workspaceDir: params.context.workspaceDir, - provider: params.model.provider, - modelId: params.model.id, - model: params.model as ProviderRuntimeModel, - }, - }); - if (!normalized) { - return params.model; - } - return { - ...params.model, - id: normalized.id, - name: normalized.name, - provider: normalized.provider, - api: normalized.api ?? params.model.api, - baseUrl: normalized.baseUrl ?? params.model.baseUrl, - input: toListRowInput(normalized.input), - contextWindow: normalized.contextWindow, - contextTokens: normalized.contextTokens, - }; -} - async function appendVisibleRow(params: { rows: ModelRow[]; model: ListRowModel; @@ -306,7 +256,7 @@ async function appendVisibleRow(params: { return false; } const model = params.normalizeWithProviderPlugin - ? normalizeListRowWithProviderPlugin({ + ? await normalizeConfiguredProviderListRow({ model: params.model, context: params.context, }) @@ -455,8 +405,23 @@ function toFallbackConfiguredListModel( export async function loadListModelCatalogSnapshot( context: RowBuilderContext, ): Promise { - const { loadPreparedModelCatalogSnapshot } = await loadPreparedModelCatalogModule(); const workspaceDir = context.workspaceDir ?? context.metadataSnapshot?.workspaceDir; + if (context.providerDiscoveryProviderIds) { + const { loadScopedListModelCatalogSnapshot } = await loadScopedModelCatalogModule(); + return loadScopedListModelCatalogSnapshot({ + cfg: context.cfg, + ...(context.agentId ? { agentId: context.agentId } : {}), + agentDir: context.agentDir, + inheritedAuthDir: context.inheritedAuthDir ?? context.agentDir, + ...(workspaceDir ? { workspaceDir } : {}), + providerIds: context.providerDiscoveryProviderIds, + runtimeProviderIds: context.providerRuntimeDiscoveryProviderIds, + manifestFallbackProviderIds: context.providerManifestFallbackProviderIds, + configuredKeys: [...context.configuredByKey.keys()], + ...(context.metadataSnapshot ? { metadataSnapshot: context.metadataSnapshot } : {}), + }); + } + const { loadPreparedModelCatalogSnapshot } = await loadPreparedModelCatalogModule(); return loadPreparedModelCatalogSnapshot({ config: context.cfg, ...(context.agentId ? { agentId: context.agentId } : {}), @@ -698,21 +663,16 @@ export async function appendConfiguredRows(params: { } continue; } - // Normalize before the availability decision so the discovered-keys check - // uses the same canonical key the registry rows carry. - const model = normalizeListRowWithProviderPlugin({ - model: resolvedModel, - context: params.context, - }); await appendVisibleRow({ rows: params.rows, - model, + model: resolvedModel, key: entry.key, context: params.context, ...(routeIndex ? { routeIndex } : {}), configuredEntry: entry, + normalizeWithProviderPlugin: true, allowAuthAvailabilityOverride: !params.context.discoveredKeys.has( - modelKey(model.provider, model.id), + modelKey(resolvedModel.provider, resolvedModel.id), ), }); } diff --git a/src/commands/models/list.scoped-catalog.test.ts b/src/commands/models/list.scoped-catalog.test.ts new file mode 100644 index 000000000000..10653c7c8e85 --- /dev/null +++ b/src/commands/models/list.scoped-catalog.test.ts @@ -0,0 +1,435 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadManifestCatalogRowsForList: vi.fn(), + loadStaticManifestCatalogRowsForList: vi.fn(), + resolveManifestCatalogCoverageForList: vi.fn(), + loadPersistedListCatalogEntries: vi.fn(), + prepareScopedReadOnlyLiveModelCatalog: vi.fn(), +})); + +vi.mock("./list.manifest-catalog.js", () => ({ + loadManifestCatalogRowsForList: mocks.loadManifestCatalogRowsForList, + loadStaticManifestCatalogRowsForList: mocks.loadStaticManifestCatalogRowsForList, + resolveManifestCatalogCoverageForList: mocks.resolveManifestCatalogCoverageForList, +})); + +vi.mock("./list.persisted-catalog.js", () => ({ + loadPersistedListCatalogEntries: mocks.loadPersistedListCatalogEntries, +})); + +vi.mock("../../agents/prepared-model-runtime.scoped-catalog.js", () => ({ + prepareScopedReadOnlyLiveModelCatalog: mocks.prepareScopedReadOnlyLiveModelCatalog, +})); + +import { loadScopedListModelCatalogSnapshot } from "./list.scoped-catalog.js"; + +const runtimeRow = { + provider: "openai", + id: "gpt-5.6", + ref: "openai/gpt-5.6", + mergeKey: "openai::gpt-5.6", + name: "GPT-5.6", + source: "manifest" as const, + input: ["text", "image"] as const, + reasoning: true, + status: "available" as const, + api: "openai-responses" as const, + baseUrl: "https://api.openai.com/v1", + contextWindow: 1_050_000, +}; + +const staticRow = { + provider: "moonshot", + id: "kimi-k2.6", + ref: "moonshot/kimi-k2.6", + mergeKey: "moonshot::kimi-k2.6", + name: "Kimi K2.6", + source: "manifest" as const, + input: ["text"] as const, + reasoning: false, + status: "available" as const, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.loadManifestCatalogRowsForList.mockReturnValue([runtimeRow, staticRow]); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([staticRow]); + mocks.resolveManifestCatalogCoverageForList.mockReturnValue({ + ownedProviderIds: new Set(), + completeProviderIds: new Set(), + }); + mocks.loadPersistedListCatalogEntries.mockReturnValue([]); + mocks.prepareScopedReadOnlyLiveModelCatalog.mockResolvedValue({ + entries: [], + routeVariants: [], + }); +}); + +describe("loadScopedListModelCatalogSnapshot", () => { + it("returns an empty snapshot without loading catalog sources for an empty scope", async () => { + await expect( + loadScopedListModelCatalogSnapshot({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + providerIds: [], + configuredKeys: [], + }), + ).resolves.toEqual({ + entries: [], + routeVariants: [], + staticEntries: [], + }); + expect(mocks.loadManifestCatalogRowsForList).not.toHaveBeenCalled(); + expect(mocks.loadStaticManifestCatalogRowsForList).not.toHaveBeenCalled(); + expect(mocks.loadPersistedListCatalogEntries).not.toHaveBeenCalled(); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).not.toHaveBeenCalled(); + }); + + it("uses runtime manifest rows as seeds while retaining live provider discovery", async () => { + const snapshot = await loadScopedListModelCatalogSnapshot({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + providerIds: ["openai"], + configuredKeys: ["openai/gpt-5.6"], + }); + + expect(snapshot.entries).toEqual([ + expect.objectContaining({ + provider: "openai", + id: "gpt-5.6", + api: "openai-responses", + contextWindow: 1_050_000, + }), + ]); + expect(snapshot.routeVariants).toEqual(snapshot.entries); + expect(snapshot.staticEntries).toEqual([]); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ["openai"], + ); + }); + + it("uses authenticated manifest fallback rows without loading provider runtime", async () => { + const snapshot = await loadScopedListModelCatalogSnapshot({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + providerIds: ["openai"], + runtimeProviderIds: [], + manifestFallbackProviderIds: ["openai"], + configuredKeys: [], + }); + + expect(snapshot.entries).toEqual([ + expect.objectContaining({ + provider: "openai", + id: "gpt-5.6", + api: "openai-responses", + contextWindow: 1_050_000, + }), + ]); + expect(snapshot.routeVariants).toEqual(snapshot.entries); + expect(snapshot.staticEntries).toEqual([]); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).not.toHaveBeenCalled(); + }); + + it("keeps static rows in the scoped snapshot", async () => { + mocks.resolveManifestCatalogCoverageForList.mockReturnValueOnce({ + ownedProviderIds: new Set(["moonshot"]), + completeProviderIds: new Set(["moonshot"]), + }); + const snapshot = await loadScopedListModelCatalogSnapshot({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + providerIds: ["moonshot"], + configuredKeys: [], + }); + + expect(snapshot.entries).toEqual([ + expect.objectContaining({ provider: "moonshot", id: "kimi-k2.6" }), + ]); + expect(snapshot.staticEntries).toEqual(snapshot.entries); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).not.toHaveBeenCalled(); + }); + + it("keeps persisted rows as seeds while adding runtime-only models", async () => { + mocks.loadPersistedListCatalogEntries.mockReturnValueOnce([ + { + provider: "openai", + id: "gpt-5.6", + name: "Account GPT-5.6", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + ]); + mocks.prepareScopedReadOnlyLiveModelCatalog.mockResolvedValueOnce({ + entries: [ + { + provider: "openai", + id: "gpt-runtime-only", + name: "Runtime-only GPT", + api: "openai-responses", + }, + ], + routeVariants: [], + }); + + const snapshot = await loadScopedListModelCatalogSnapshot({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + providerIds: ["openai"], + configuredKeys: [], + }); + + expect(snapshot.entries).toEqual([ + expect.objectContaining({ + provider: "openai", + id: "gpt-5.6", + name: "Account GPT-5.6", + api: "openai-chatgpt-responses", + contextWindow: 1_050_000, + }), + expect.objectContaining({ + provider: "openai", + id: "gpt-runtime-only", + }), + ]); + expect(snapshot.routeVariants).toEqual( + expect.arrayContaining([ + expect.objectContaining({ api: "openai-chatgpt-responses" }), + expect.objectContaining({ api: "openai-responses" }), + ]), + ); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ["openai"], + ); + }); + + it("does not discover unowned providers whose explicit models are emitted by the configured row source", async () => { + mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([]); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([]); + + const snapshot = await loadScopedListModelCatalogSnapshot({ + cfg: { + models: { + providers: { + custom: { + baseUrl: "http://127.0.0.1:3000/v1", + api: "openai-responses", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4_096, + }, + ], + }, + }, + }, + }, + agentDir: "/tmp/openclaw-agent", + providerIds: ["custom"], + configuredKeys: ["custom/gpt-5.5"], + }); + + expect(snapshot).toEqual({ + entries: [], + routeVariants: [], + staticEntries: [], + }); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).not.toHaveBeenCalled(); + }); + + it("still discovers owned providers when explicit config contains only part of the catalog", async () => { + mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([]); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([]); + mocks.resolveManifestCatalogCoverageForList.mockReturnValueOnce({ + ownedProviderIds: new Set(["openai"]), + completeProviderIds: new Set(), + }); + mocks.prepareScopedReadOnlyLiveModelCatalog.mockResolvedValueOnce({ + entries: [ + { + provider: "openai", + id: "gpt-runtime-only", + name: "Runtime-only GPT", + api: "openai-responses", + }, + ], + routeVariants: [], + }); + + const snapshot = await loadScopedListModelCatalogSnapshot({ + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + models: [ + { + id: "gpt-configured", + name: "Configured GPT", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4_096, + }, + ], + }, + }, + }, + }, + agentDir: "/tmp/openclaw-agent", + providerIds: ["openai"], + runtimeProviderIds: ["openai"], + configuredKeys: ["openai/gpt-configured"], + }); + + expect(snapshot.entries).toEqual([ + expect.objectContaining({ provider: "openai", id: "gpt-runtime-only" }), + ]); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ["openai"], + ); + }); + + it("runtime-augments partial static catalogs instead of hiding runtime-only rows", async () => { + mocks.resolveManifestCatalogCoverageForList.mockReturnValueOnce({ + ownedProviderIds: new Set(["moonshot"]), + completeProviderIds: new Set(), + }); + mocks.prepareScopedReadOnlyLiveModelCatalog.mockResolvedValueOnce({ + entries: [ + { + provider: "moonshot", + id: "kimi-runtime-only", + name: "Runtime-only Kimi", + api: "openai-completions", + }, + ], + routeVariants: [], + }); + + const snapshot = await loadScopedListModelCatalogSnapshot({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + providerIds: ["moonshot"], + runtimeProviderIds: ["moonshot"], + configuredKeys: [], + }); + + expect(snapshot.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ provider: "moonshot", id: "kimi-k2.6" }), + expect.objectContaining({ provider: "moonshot", id: "kimi-runtime-only" }), + ]), + ); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ["moonshot"], + ); + }); + + it("still discovers configured providers without a listable API route", async () => { + mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([]); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([]); + + await loadScopedListModelCatalogSnapshot({ + cfg: { + models: { + providers: { + custom: { + baseUrl: "http://127.0.0.1:3000/v1", + models: [ + { + id: "custom-model", + name: "Custom Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4_096, + }, + ], + }, + }, + }, + }, + agentDir: "/tmp/openclaw-agent", + providerIds: ["custom"], + runtimeProviderIds: ["custom"], + configuredKeys: ["custom/custom-model"], + }); + + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ["custom"], + ); + }); + + it("does not runtime-discover providers included only to enrich configured refs", async () => { + mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([]); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([]); + + const snapshot = await loadScopedListModelCatalogSnapshot({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + providerIds: ["google-antigravity"], + runtimeProviderIds: [], + configuredKeys: ["google-antigravity/claude-opus-4-6-thinking"], + }); + + expect(snapshot).toEqual({ + entries: [], + routeVariants: [], + staticEntries: [], + }); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).not.toHaveBeenCalled(); + }); + + it("falls back to scoped provider discovery only for providers with no lightweight rows", async () => { + const discovered = { + provider: "google", + id: "gemini-live", + name: "Gemini Live", + api: "google-generative-ai" as const, + }; + mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([]); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([]); + mocks.prepareScopedReadOnlyLiveModelCatalog.mockResolvedValueOnce({ + entries: [discovered], + routeVariants: [discovered], + }); + + const snapshot = await loadScopedListModelCatalogSnapshot({ + cfg: {}, + agentId: "main", + agentDir: "/tmp/openclaw-agent", + inheritedAuthDir: "/tmp/openclaw-default", + workspaceDir: "/tmp/openclaw-workspace", + providerIds: ["google"], + configuredKeys: [], + }); + + expect(snapshot.entries).toEqual([discovered]); + expect(mocks.prepareScopedReadOnlyLiveModelCatalog).toHaveBeenCalledWith( + { + config: {}, + agentId: "main", + agentDir: "/tmp/openclaw-agent", + inheritedAuthDir: "/tmp/openclaw-default", + workspaceDir: "/tmp/openclaw-workspace", + readOnly: true, + }, + ["google"], + ); + }); +}); diff --git a/src/commands/models/list.scoped-catalog.ts b/src/commands/models/list.scoped-catalog.ts new file mode 100644 index 000000000000..014830aeb1cb --- /dev/null +++ b/src/commands/models/list.scoped-catalog.ts @@ -0,0 +1,244 @@ +import type { NormalizedModelCatalogRow } from "@openclaw/model-catalog-core/model-catalog-types"; +/** Dependency-light model catalog snapshots for default model-list views. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js"; +import { modelKey } from "../../agents/model-ref-shared.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; +import { createLazyImportLoader } from "../../shared/lazy-promise.js"; +import { + loadManifestCatalogRowsForList, + loadStaticManifestCatalogRowsForList, + resolveManifestCatalogCoverageForList, +} from "./list.manifest-catalog.js"; + +type PersistedCatalogModule = typeof import("./list.persisted-catalog.js"); +type PreparedScopedCatalogModule = + typeof import("../../agents/prepared-model-runtime.scoped-catalog.js"); + +const persistedCatalogModuleLoader = createLazyImportLoader( + () => import("./list.persisted-catalog.js"), +); +const preparedScopedCatalogModuleLoader = createLazyImportLoader( + () => import("../../agents/prepared-model-runtime.scoped-catalog.js"), +); + +function toCatalogEntry(row: NormalizedModelCatalogRow): ModelCatalogEntry { + return { + id: row.id, + name: row.name, + provider: row.provider, + api: row.api, + ...(row.baseUrl ? { baseUrl: row.baseUrl } : {}), + ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), + ...(row.contextTokens !== undefined ? { contextTokens: row.contextTokens } : {}), + reasoning: row.reasoning, + input: [...row.input], + ...(row.compat ? { compat: row.compat } : {}), + ...(row.mediaInput ? { mediaInput: row.mediaInput } : {}), + status: row.status, + ...(row.statusReason ? { statusReason: row.statusReason } : {}), + ...(row.replaces ? { replaces: [...row.replaces] } : {}), + ...(row.replacedBy ? { replacedBy: row.replacedBy } : {}), + }; +} + +function selectProviderRows( + rows: readonly NormalizedModelCatalogRow[], + providerIds: ReadonlySet, +): NormalizedModelCatalogRow[] { + return rows.filter((row) => providerIds.has(normalizeProviderId(row.provider))); +} + +function entryKey(entry: Pick): string { + return modelKey(normalizeProviderId(entry.provider), entry.id.trim().toLowerCase()); +} + +function routeKey(entry: ModelCatalogEntry): string { + return `${entryKey(entry)}\0${entry.api ?? ""}\0${entry.baseUrl ?? ""}`; +} + +function resolveConfiguredProviderCoverage( + cfg: OpenClawConfig, + providerIds: ReadonlySet, + ownedProviderIds: ReadonlySet, +): ReadonlySet { + const coveredProviders = new Set(); + for (const [provider, providerConfig] of Object.entries(cfg.models?.providers ?? {})) { + const normalizedProvider = normalizeProviderId(provider); + if ( + providerIds.has(normalizedProvider) && + !ownedProviderIds.has(normalizedProvider) && + (providerConfig.models ?? []).some( + (model) => providerConfig.api !== undefined || model.api !== undefined, + ) + ) { + // Unowned custom providers have no runtime catalog beyond their explicit rows. + // Plugin-owned providers remain discoverable unless models.mode is replace. + coveredProviders.add(normalizedProvider); + } + } + return coveredProviders; +} + +function enrichPersistedEntry( + entry: ModelCatalogEntry, + manifestEntry: ModelCatalogEntry | undefined, +): ModelCatalogEntry { + if (!manifestEntry) { + return entry; + } + return { + ...manifestEntry, + ...entry, + name: entry.name || manifestEntry.name, + ...(entry.contextWindow === undefined && manifestEntry.contextWindow !== undefined + ? { contextWindow: manifestEntry.contextWindow } + : {}), + ...(entry.contextTokens === undefined && manifestEntry.contextTokens !== undefined + ? { contextTokens: manifestEntry.contextTokens } + : {}), + ...(entry.reasoning === undefined && manifestEntry.reasoning !== undefined + ? { reasoning: manifestEntry.reasoning } + : {}), + ...(entry.input === undefined && manifestEntry.input !== undefined + ? { input: manifestEntry.input } + : {}), + }; +} + +function mergeSnapshotEntries(snapshots: readonly ModelCatalogSnapshot[]): ModelCatalogSnapshot { + const entries = new Map(); + const staticEntries = new Map(); + const routeVariants = new Map(); + for (const snapshot of snapshots) { + for (const entry of snapshot.entries) { + entries.set(entryKey(entry), entry); + } + for (const entry of snapshot.staticEntries ?? []) { + staticEntries.set(entryKey(entry), entry); + } + for (const entry of snapshot.routeVariants) { + routeVariants.set(routeKey(entry), entry); + } + } + return { + entries: [...entries.values()].toSorted( + (left, right) => + left.provider.localeCompare(right.provider) || left.id.localeCompare(right.id), + ), + routeVariants: [...routeVariants.values()].toSorted( + (left, right) => + left.provider.localeCompare(right.provider) || + left.id.localeCompare(right.id) || + (left.api ?? "").localeCompare(right.api ?? "") || + (left.baseUrl ?? "").localeCompare(right.baseUrl ?? ""), + ), + staticEntries: [...staticEntries.values()].toSorted( + (left, right) => + left.provider.localeCompare(right.provider) || left.id.localeCompare(right.id), + ), + }; +} + +/** Builds an auth-scoped snapshot from manifest metadata already loaded by the command. */ +export async function loadScopedListModelCatalogSnapshot(params: { + cfg: OpenClawConfig; + agentId?: string; + agentDir: string; + inheritedAuthDir?: string; + workspaceDir?: string; + providerIds: readonly string[]; + runtimeProviderIds?: readonly string[]; + manifestFallbackProviderIds?: readonly string[]; + configuredKeys: readonly string[]; + metadataSnapshot?: PluginMetadataSnapshot; +}): Promise { + const providerIds = new Set(params.providerIds.map(normalizeProviderId).filter(Boolean)); + if (providerIds.size === 0) { + return { entries: [], routeVariants: [], staticEntries: [] }; + } + const loaderParams = { + cfg: params.cfg, + ...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}), + }; + const manifestEntries = selectProviderRows( + loadManifestCatalogRowsForList(loaderParams), + providerIds, + ).map(toCatalogEntry); + const manifestByKey = new Map(manifestEntries.map((entry) => [entryKey(entry), entry])); + const configuredKeys = new Set(params.configuredKeys.map((key) => key.trim().toLowerCase())); + const manifestFallbackProviderIds = new Set( + (params.manifestFallbackProviderIds ?? []) + .map(normalizeProviderId) + .filter((provider) => providerIds.has(provider)), + ); + const staticEntries = selectProviderRows( + loadStaticManifestCatalogRowsForList(loaderParams), + providerIds, + ).map(toCatalogEntry); + const { loadPersistedListCatalogEntries } = await persistedCatalogModuleLoader.load(); + const persistedEntries = loadPersistedListCatalogEntries({ + agentDir: params.agentDir, + providerIds, + ...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}), + }).map((entry) => enrichPersistedEntry(entry, manifestByKey.get(entryKey(entry)))); + const { ownedProviderIds, completeProviderIds } = resolveManifestCatalogCoverageForList({ + cfg: params.cfg, + providerIds, + ...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}), + }); + const admittedEntries = new Map(); + for (const entry of [...staticEntries, ...persistedEntries]) { + admittedEntries.set(entryKey(entry), entry); + } + for (const entry of manifestEntries) { + if ( + (configuredKeys.has(entryKey(entry)) || + manifestFallbackProviderIds.has(normalizeProviderId(entry.provider))) && + !admittedEntries.has(entryKey(entry)) + ) { + admittedEntries.set(entryKey(entry), entry); + } + } + const admittedKeys = new Set(admittedEntries.keys()); + const routeVariants = [...persistedEntries]; + for (const entry of manifestEntries) { + if (admittedKeys.has(entryKey(entry))) { + routeVariants.push(entry); + } + } + const lightweightSnapshot: ModelCatalogSnapshot = { + entries: [...admittedEntries.values()], + routeVariants, + staticEntries, + }; + const coveredProviders = new Set([ + ...completeProviderIds, + ...resolveConfiguredProviderCoverage(params.cfg, providerIds, ownedProviderIds), + ]); + const runtimeProviderIds = new Set( + (params.runtimeProviderIds ?? params.providerIds) + .map(normalizeProviderId) + .filter((provider) => providerIds.has(provider)), + ); + const uncoveredProviders = [...runtimeProviderIds].filter( + (provider) => !coveredProviders.has(provider), + ); + if (uncoveredProviders.length === 0) { + return mergeSnapshotEntries([lightweightSnapshot]); + } + const { prepareScopedReadOnlyLiveModelCatalog } = await preparedScopedCatalogModuleLoader.load(); + const fallbackSnapshot = await prepareScopedReadOnlyLiveModelCatalog( + { + config: params.cfg, + ...(params.agentId ? { agentId: params.agentId } : {}), + agentDir: params.agentDir, + inheritedAuthDir: params.inheritedAuthDir ?? params.agentDir, + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + readOnly: true, + }, + uncoveredProviders, + ); + return mergeSnapshotEntries([lightweightSnapshot, fallbackSnapshot]); +} diff --git a/src/commands/models/list.table.ts b/src/commands/models/list.table.ts index 8e3378f49bf5..966034a96257 100644 --- a/src/commands/models/list.table.ts +++ b/src/commands/models/list.table.ts @@ -2,9 +2,8 @@ import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js"; import { colorize, theme } from "../../../packages/terminal-core/src/theme.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; -import { formatTag, isRich, pad, truncate } from "./list.format.js"; +import { formatTag, formatTokenK, isRich, pad, truncate } from "./list.format.js"; import type { ModelRow } from "./list.types.js"; -import { formatTokenK } from "./shared.js"; const MODEL_PAD = 42; const INPUT_PAD = 10; diff --git a/src/commands/models/provider-aliases.ts b/src/commands/models/provider-aliases.ts index eebb20980fba..0e77b74b3edc 100644 --- a/src/commands/models/provider-aliases.ts +++ b/src/commands/models/provider-aliases.ts @@ -1,7 +1,7 @@ /** Provider alias canonicalization for model catalog rows. */ import fs from "node:fs"; import path from "node:path"; -import { normalizeProviderId } from "../../agents/model-selection.js"; +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { loadPluginManifestRegistry, diff --git a/src/commands/models/shared.test.ts b/src/commands/models/shared.test.ts index a16aa37fb8f1..e98058c3e258 100644 --- a/src/commands/models/shared.test.ts +++ b/src/commands/models/shared.test.ts @@ -1,7 +1,7 @@ // Model command shared tests cover shared config and provider helper behavior. import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { formatTokenK, loadValidConfigOrThrow, updateConfig } from "./shared.js"; +import { loadValidConfigOrThrow, updateConfig } from "./shared.js"; const mocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(), @@ -94,27 +94,4 @@ describe("models/shared", () => { expect(replaceParams?.nextConfig).toEqual(sourceConfig); expect(replaceParams?.baseHash).toBe("config-2"); }); - - describe("formatTokenK", () => { - // Token context windows are decimal, so round windows must not shrink - // (regression: /1024 rendered 200000 as "195k" instead of "200k"). - it("renders round token context windows in decimal K", () => { - expect(formatTokenK(200_000)).toBe("200k"); - expect(formatTokenK(128_000)).toBe("128k"); - expect(formatTokenK(1_000_000)).toBe("1000k"); - expect(formatTokenK(195_000)).toBe("195k"); - }); - - it("passes small counts through and switches to K at 1000", () => { - expect(formatTokenK(999)).toBe("999"); - expect(formatTokenK(1_000)).toBe("1k"); - }); - - it("returns a dash for missing or non-finite values", () => { - expect(formatTokenK(undefined)).toBe("-"); - expect(formatTokenK(null)).toBe("-"); - expect(formatTokenK(0)).toBe("-"); - expect(formatTokenK(Number.NaN)).toBe("-"); - }); - }); }); diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 0540be1c8bc4..ec96a578cfe4 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -20,24 +20,8 @@ import type { AgentModelConfig } from "../../config/types.agents-shared.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { canonicalizeModelCatalogProviderRef } from "./provider-aliases.js"; -export const ensureFlagCompatibility = (opts: { json?: boolean; plain?: boolean }) => { - if (opts.json && opts.plain) { - throw new Error("Choose either --json or --plain, not both."); - } -}; - -/** Formats token counts as compact K-suffixed labels. */ -export const formatTokenK = (value?: number | null) => { - if (!value || !Number.isFinite(value)) { - return "-"; - } - // Token counts use decimal-K (/1000), matching formatTokenCount and how - // providers advertise context windows (e.g. 200000 -> "200k", not "195k"). - if (value < 1000) { - return `${Math.round(value)}`; - } - return `${Math.round(value / 1000)}k`; -}; +export { formatTokenK } from "./list.format.js"; +export { ensureFlagCompatibility } from "./list.options.js"; /** Formats millisecond durations for model command output. */ export const formatMs = (value?: number | null) => { diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 34073b78273c..b409f1d3952b 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -16,7 +16,7 @@ import { type ModelAuthAvailabilityEvaluation, type ModelAuthAvailabilityResolver, } from "../../agents/model-auth-availability.js"; -import { hasSyntheticLocalProviderAuthConfig } from "../../agents/model-auth.js"; +import { hasSyntheticLocalProviderAuthConfig } from "../../agents/model-auth-provider-config.js"; import { buildProviderConfigModelCatalogForBrowse, loadPreparedModelCatalogSnapshotForBrowse, diff --git a/src/plugins/plugin-metadata-snapshot.test.ts b/src/plugins/plugin-metadata-snapshot.test.ts index 9198666a0759..e55d98e0d11d 100644 --- a/src/plugins/plugin-metadata-snapshot.test.ts +++ b/src/plugins/plugin-metadata-snapshot.test.ts @@ -21,8 +21,8 @@ const { loadPluginRegistrySnapshotWithMetadata, loadPluginManifestRegistryForIns }; }); -vi.mock("./plugin-registry.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadPluginRegistrySnapshotWithMetadata: (params: unknown) => diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index 3a53048e02a8..229d733be9e5 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -23,7 +23,7 @@ import type { ResolvePluginMetadataSnapshotParams, } from "./plugin-metadata-snapshot.types.js"; import { createPluginRegistryIdNormalizer } from "./plugin-registry-id-normalizer.js"; -import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry.js"; +import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js"; import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js"; const PLUGIN_METADATA_ENV_KEYS = [ diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index bd26985908a1..52b08debcd84 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -1,5 +1,5 @@ /** Control-plane provider discovery helpers that keep runtime imports lazy until catalog hooks run. */ -import { normalizeProviderId } from "../agents/model-selection.js"; +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; diff --git a/src/plugins/provider-policy-surface.ts b/src/plugins/provider-policy-surface.ts index 014a098f9a99..77cb9be9e9da 100644 --- a/src/plugins/provider-policy-surface.ts +++ b/src/plugins/provider-policy-surface.ts @@ -12,6 +12,7 @@ import type { ProviderNormalizeConfigContext, ProviderResolveConfigApiKeyContext, } from "./provider-config-context.types.js"; +import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js"; import type { ProviderDefaultThinkingPolicyContext, ProviderThinkingProfile, @@ -22,10 +23,18 @@ import { } from "./public-surface-loader.js"; const PROVIDER_POLICY_ARTIFACT_CANDIDATES = ["provider-policy-api.js"] as const; -const providerPolicySurfaceByPluginId = new Map(); -/** Provider policy hooks loaded from bundled plugin public artifacts. */ -export type BundledProviderPolicySurface = { +type ProviderProjectConfiguredModelRowContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + provider: string; + modelId: string; + model: ProviderRuntimeModel; +}; + +/** Provider policy hooks supported by bundled and trusted official plugins. */ +export type ProviderPolicySurface = { normalizeConfig?: (ctx: ProviderNormalizeConfigContext) => ModelProviderConfig | null | undefined; applyConfigDefaults?: ( ctx: ProviderApplyConfigDefaultsContext, @@ -42,34 +51,68 @@ export type BundledProviderPolicySurface = { ) => string | null | undefined; }; -function hasProviderPolicyHook( - mod: Record, -): mod is Record & BundledProviderPolicySurface { - return ( - typeof mod.normalizeConfig === "function" || - typeof mod.applyConfigDefaults === "function" || - typeof mod.resolveConfigApiKey === "function" || - typeof mod.resolveThinkingProfile === "function" || - typeof mod.resolveModelRoutes === "function" || - typeof mod.normalizeModelCatalogId === "function" - ); +/** Provider policy hooks loaded only from bundled plugin public artifacts. */ +export type BundledProviderPolicySurface = ProviderPolicySurface & { + projectConfiguredModelRow?: ( + ctx: ProviderProjectConfiguredModelRowContext, + ) => ProviderRuntimeModel | null | undefined; +}; + +const bundledProviderPolicySurfaceByPluginId = new Map< + string, + BundledProviderPolicySurface | null +>(); +const externalProviderPolicySurfaceByPluginId = new Map(); + +const PROVIDER_POLICY_HOOK_KEYS = [ + "normalizeConfig", + "applyConfigDefaults", + "resolveConfigApiKey", + "resolveThinkingProfile", + "resolveModelRoutes", + "normalizeModelCatalogId", +] as const satisfies readonly (keyof ProviderPolicySurface)[]; + +function extractProviderPolicySurface(mod: Record): ProviderPolicySurface | null { + const surface: ProviderPolicySurface = {}; + for (const key of PROVIDER_POLICY_HOOK_KEYS) { + const hook = mod[key]; + if (typeof hook === "function") { + Object.assign(surface, { [key]: hook }); + } + } + return Object.keys(surface).length > 0 ? surface : null; } -function resolveCachedProviderPolicySurface(params: { +function extractBundledProviderPolicySurface( + mod: Record, +): BundledProviderPolicySurface | null { + const surface: BundledProviderPolicySurface = extractProviderPolicySurface(mod) ?? {}; + if (typeof mod.projectConfiguredModelRow === "function") { + surface.projectConfiguredModelRow = + mod.projectConfiguredModelRow as BundledProviderPolicySurface["projectConfiguredModelRow"]; + } + return Object.keys(surface).length > 0 ? surface : null; +} + +function resolveCachedProviderPolicySurface(params: { + cache: Map; cacheKey: string; loadModule: (artifactBasename: string) => Record; missingSurfacePrefix: string; -}): BundledProviderPolicySurface | null { - const cached = providerPolicySurfaceByPluginId.get(params.cacheKey); + extractSurface: (mod: Record) => T | null; +}): T | null { + const cached = params.cache.get(params.cacheKey); if (cached !== undefined) { return cached; } for (const artifactBasename of PROVIDER_POLICY_ARTIFACT_CANDIDATES) { try { const mod = params.loadModule(artifactBasename); - if (hasProviderPolicyHook(mod)) { - providerPolicySurfaceByPluginId.set(params.cacheKey, mod); - return mod; + const surface = params.extractSurface(mod); + if (surface) { + params.cache.set(params.cacheKey, surface); + return surface; } } catch (error) { if (error instanceof Error && error.message.startsWith(params.missingSurfacePrefix)) { @@ -78,7 +121,7 @@ function resolveCachedProviderPolicySurface(params: { throw error; } } - providerPolicySurfaceByPluginId.set(params.cacheKey, null); + params.cache.set(params.cacheKey, null); return null; } @@ -98,6 +141,7 @@ export function resolveDirectBundledProviderPolicySurface( return null; } return resolveCachedProviderPolicySurface({ + cache: bundledProviderPolicySurfaceByPluginId, cacheKey: `${resolveBundledPluginsDir() ?? ""}\0${pluginId}`, loadModule: (artifactBasename) => loadBundledPluginPublicArtifactModuleSync>({ @@ -105,6 +149,7 @@ export function resolveDirectBundledProviderPolicySurface( artifactBasename, }), missingSurfacePrefix: "Unable to resolve bundled plugin public surface ", + extractSurface: extractBundledProviderPolicySurface, }); } @@ -113,11 +158,12 @@ export function resolveTrustedExternalProviderPolicySurface(params: { pluginId: string; pluginRoot: string; trustedOfficialInstall?: boolean; -}): BundledProviderPolicySurface | null { +}): ProviderPolicySurface | null { if (params.trustedOfficialInstall !== true) { return null; } return resolveCachedProviderPolicySurface({ + cache: externalProviderPolicySurfaceByPluginId, cacheKey: `${params.pluginRoot}\0${params.pluginId}`, loadModule: (artifactBasename) => loadPluginPublicArtifactModuleSync>({ @@ -125,5 +171,6 @@ export function resolveTrustedExternalProviderPolicySurface(params: { artifactBasename, }), missingSurfacePrefix: "Unable to resolve plugin public surface ", + extractSurface: extractProviderPolicySurface, }); } diff --git a/src/plugins/provider-public-artifacts.test.ts b/src/plugins/provider-public-artifacts.test.ts index 574043b9131b..bc03199b352a 100644 --- a/src/plugins/provider-public-artifacts.test.ts +++ b/src/plugins/provider-public-artifacts.test.ts @@ -21,6 +21,7 @@ function writeExternalPolicyFixture(): string { ' ? { levels: [{ id: "off" }, { id: "high" }, { id: "max" }], defaultLevel: "off" }', ' : { levels: [{ id: "off" }, { id: "low", label: "on" }], defaultLevel: "off" };', "}", + "export function projectConfiguredModelRow() { return null; }", "", ].join("\n"), "utf8", @@ -65,6 +66,7 @@ describe("provider public artifacts", () => { it("loads a lightweight bundled provider policy artifact smoke", () => { const surface = resolveBundledProviderPolicySurface("openai"); expect(surface?.normalizeConfig).toBeTypeOf("function"); + expect(surface?.projectConfiguredModelRow).toBeTypeOf("function"); const providerConfig: ModelProviderConfig = { baseUrl: "https://api.openai.com/v1", @@ -222,6 +224,7 @@ describe("provider public artifacts", () => { ?.resolveThinkingProfile?.({ provider: "fixture-provider", modelId: "legacy" }) ?.levels.map((level) => level.label), ).toEqual([undefined, "on"]); + expect(surface).not.toHaveProperty("projectConfiguredModelRow"); } finally { restoreBundledPluginEnv(); fs.rmSync(pluginRoot, { recursive: true, force: true }); diff --git a/src/plugins/provider-public-artifacts.ts b/src/plugins/provider-public-artifacts.ts index 0ee6a00a91f1..bf7bf46d3aab 100644 --- a/src/plugins/provider-public-artifacts.ts +++ b/src/plugins/provider-public-artifacts.ts @@ -7,6 +7,7 @@ import { resolveDirectBundledProviderPolicySurface, resolveTrustedExternalProviderPolicySurface, type BundledProviderPolicySurface, + type ProviderPolicySurface, } from "./provider-policy-surface.js"; function resolveBundledProviderPolicyPlugin( @@ -93,7 +94,7 @@ export function resolveBundledProviderPolicySurface( export function resolveProviderPolicySurface( providerId: string, options: { manifestRegistry?: Pick } = {}, -): BundledProviderPolicySurface | null { +): ProviderPolicySurface | null { const bundledSurface = resolveBundledProviderPolicySurface(providerId, options); if (bundledSurface) { return bundledSurface;