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
This commit is contained in:
Vincent Koc
2026-08-02 00:01:37 +08:00
committed by GitHub
parent 30972edaea
commit 5ae401d908
50 changed files with 2386 additions and 455 deletions

View File

@@ -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;
}

View File

@@ -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<string, ModelProviderConfig | undefined> } };
}): 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") {

View File

@@ -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();
},
);
});

View File

@@ -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<string>();
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) =>

View File

@@ -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

View File

@@ -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,

View File

@@ -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";

View File

@@ -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 &&

View File

@@ -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";

View File

@@ -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);
}
}

View File

@@ -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)`,

View File

@@ -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 {

View File

@@ -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" }],

View File

@@ -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<ModelCatalogSnapshot> {
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<ModelCatalogSnapshot> {
if (params.readOnly && params.providerDiscoveryProviderIds) {
return loadScopedReadOnlyModelCatalog(params);
}
return (await loadPreparedModelCatalogOwnerSnapshot(params)).modelCatalog;
}

View File

@@ -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<ModelCatalogSnapshot>();
type PreparedModelRuntimeAgentBaseFacts = {
input: PreparedModelRuntimeInput;
@@ -111,6 +112,7 @@ function prepareAgentFacts(
input: PreparedModelRuntimeInput,
catalogMode: PreparedModelRuntimeCatalogMode,
ambientCredentials: Readonly<AgentCredentialMap>,
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<ModelCatalogEntry, "id" | "provider">): 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<PreparedModelRuntimeCatalogSource> {
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) {

View File

@@ -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<ModelCatalogSnapshot> {
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<ModelCatalogSnapshot> {
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<ModelCatalogSnapshot> {
return prepareScopedReadOnlyModelCatalogWithMode(input, providerDiscoveryProviderIds, "live");
}

View File

@@ -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" } } };

View File

@@ -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,

View File

@@ -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<unkno
beforeEach(() => {
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);

View File

@@ -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<ExternalCliProfilesResolver>(() => []),
}));
vi.mock("../../plugins/plugin-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../plugins/plugin-registry.js")>();
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<Parameters<typeof createModelListAuthIndex>[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();
});
});

View File

@@ -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),
};
}

View File

@@ -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;

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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,

View File

@@ -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"]));
});
});

View File

@@ -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<string>;
env?: NodeJS.ProcessEnv;
metadataSnapshot?: PluginMetadataSnapshot;
}): {
ownedProviderIds: ReadonlySet<string>;
completeProviderIds: ReadonlySet<string>;
} {
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<string>();
const completeProviderIds = new Set<string>();
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" });
}

View File

@@ -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<ProviderRuntimeModule>(
() => import("../../plugins/provider-runtime.js"),
);
function toListRowInput(input: readonly string[] | undefined): ListRowModel["input"] {
const parsed = input?.filter(
(item): item is NonNullable<ListRowModel["input"]>[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<ListRowModel> {
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;
}

View File

@@ -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.");
}
}

View File

@@ -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<string, unknown> }) => 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([]);
});
});

View File

@@ -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<string>(MODEL_APIS);
const modelInputs = new Set<ModelInputType>(["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<string>;
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;
}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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<string>;
configuredByKey: Map<string, ConfiguredEntry>;
discoveredKeys: Set<string>;
filter: RowFilter;
skipRuntimeModelSuppression?: boolean;
metadataSnapshot?: PluginMetadataSnapshot;
workspaceDir?: string;
};

View File

@@ -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: [],
});
});

View File

@@ -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<string, ConfiguredEntry>;
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<string>;
configuredByKey: ConfiguredByKey;
discoveredKeys: Set<string>;
filter: RowFilter;
skipRuntimeModelSuppression?: boolean;
metadataSnapshot?: PluginMetadataSnapshot;
workspaceDir?: string;
};
export type { RowBuilderContext } from "./list.row-context.js";
const modelCatalogModuleLoader = createLazyImportLoader<ModelCatalogModule>(
() => import("../../agents/prepared-model-catalog.js"),
);
const scopedModelCatalogModuleLoader = createLazyImportLoader<ScopedModelCatalogModule>(
() => import("./list.scoped-catalog.js"),
);
const modelResolverModuleLoader = createLazyImportLoader<ModelResolverModule>(
() => import("../../agents/embedded-agent-runner/model.js"),
);
@@ -69,6 +49,10 @@ function loadPreparedModelCatalogModule(): Promise<ModelCatalogModule> {
return modelCatalogModuleLoader.load();
}
function loadScopedModelCatalogModule(): Promise<ScopedModelCatalogModule> {
return scopedModelCatalogModuleLoader.load();
}
function loadModelResolverModule(): Promise<ModelResolverModule> {
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<ModelCatalogSnapshot> {
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),
),
});
}

View File

@@ -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"],
);
});
});

View File

@@ -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<PersistedCatalogModule>(
() => import("./list.persisted-catalog.js"),
);
const preparedScopedCatalogModuleLoader = createLazyImportLoader<PreparedScopedCatalogModule>(
() => 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<string>,
): NormalizedModelCatalogRow[] {
return rows.filter((row) => providerIds.has(normalizeProviderId(row.provider)));
}
function entryKey(entry: Pick<ModelCatalogEntry, "provider" | "id">): 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<string>,
ownedProviderIds: ReadonlySet<string>,
): ReadonlySet<string> {
const coveredProviders = new Set<string>();
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<string, ModelCatalogEntry>();
const staticEntries = new Map<string, ModelCatalogEntry>();
const routeVariants = new Map<string, ModelCatalogEntry>();
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<ModelCatalogSnapshot> {
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<string, ModelCatalogEntry>();
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]);
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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("-");
});
});
});

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -21,8 +21,8 @@ const { loadPluginRegistrySnapshotWithMetadata, loadPluginManifestRegistryForIns
};
});
vi.mock("./plugin-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./plugin-registry.js")>();
vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./plugin-registry-snapshot.js")>();
return {
...actual,
loadPluginRegistrySnapshotWithMetadata: (params: unknown) =>

View File

@@ -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 = [

View File

@@ -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";

View File

@@ -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<string, BundledProviderPolicySurface | null>();
/** 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<string, unknown>,
): mod is Record<string, unknown> & 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<string, ProviderPolicySurface | null>();
const PROVIDER_POLICY_HOOK_KEYS = [
"normalizeConfig",
"applyConfigDefaults",
"resolveConfigApiKey",
"resolveThinkingProfile",
"resolveModelRoutes",
"normalizeModelCatalogId",
] as const satisfies readonly (keyof ProviderPolicySurface)[];
function extractProviderPolicySurface(mod: Record<string, unknown>): 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<string, unknown>,
): 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<T extends ProviderPolicySurface>(params: {
cache: Map<string, T | null>;
cacheKey: string;
loadModule: (artifactBasename: string) => Record<string, unknown>;
missingSurfacePrefix: string;
}): BundledProviderPolicySurface | null {
const cached = providerPolicySurfaceByPluginId.get(params.cacheKey);
extractSurface: (mod: Record<string, unknown>) => 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<Record<string, unknown>>({
@@ -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<Record<string, unknown>>({
@@ -125,5 +171,6 @@ export function resolveTrustedExternalProviderPolicySurface(params: {
artifactBasename,
}),
missingSurfacePrefix: "Unable to resolve plugin public surface ",
extractSurface: extractProviderPolicySurface,
});
}

View File

@@ -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 });

View File

@@ -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<PluginManifestRegistry, "plugins"> } = {},
): BundledProviderPolicySurface | null {
): ProviderPolicySurface | null {
const bundledSurface = resolveBundledProviderPolicySurface(providerId, options);
if (bundledSurface) {
return bundledSurface;