mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 04:11:39 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
107
extensions/openai/provider-policy-projection.test.ts
Normal file
107
extensions/openai/provider-policy-projection.test.ts
Normal 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
89
src/agents/model-auth-runtime-config.ts
Normal file
89
src/agents/model-auth-runtime-config.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
53
src/agents/prepared-model-runtime.scoped-catalog.ts
Normal file
53
src/agents/prepared-model-runtime.scoped-catalog.ts
Normal 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");
|
||||
}
|
||||
@@ -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" } } };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
72
src/commands/models/list.model-projection.ts
Normal file
72
src/commands/models/list.model-projection.ts
Normal 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;
|
||||
}
|
||||
6
src/commands/models/list.options.ts
Normal file
6
src/commands/models/list.options.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
98
src/commands/models/list.persisted-catalog.test.ts
Normal file
98
src/commands/models/list.persisted-catalog.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
113
src/commands/models/list.persisted-catalog.ts
Normal file
113
src/commands/models/list.persisted-catalog.ts
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
28
src/commands/models/list.row-context.ts
Normal file
28
src/commands/models/list.row-context.ts
Normal 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;
|
||||
};
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
435
src/commands/models/list.scoped-catalog.test.ts
Normal file
435
src/commands/models/list.scoped-catalog.test.ts
Normal 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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
244
src/commands/models/list.scoped-catalog.ts
Normal file
244
src/commands/models/list.scoped-catalog.ts
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("-");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user