mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:31:33 +00:00
fix(models): preserve scoped catalog coverage
This commit is contained in:
@@ -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,6 +28,7 @@ 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 {
|
||||
@@ -40,12 +38,12 @@ import {
|
||||
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,
|
||||
@@ -538,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",
|
||||
@@ -560,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,
|
||||
|
||||
@@ -17,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,
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,26 +3,24 @@
|
||||
*/
|
||||
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";
|
||||
|
||||
export {
|
||||
assertRuntimeProviderSecretOwnerAvailable,
|
||||
resolveManagedSecretRefRuntimeProviderAuth,
|
||||
} from "./model-auth-runtime-config.js";
|
||||
|
||||
/** Precomputed provider-auth lookup tables reused during one runtime turn. */
|
||||
export type RuntimeProviderAuthLookup = {
|
||||
envApiKey: Pick<
|
||||
@@ -103,111 +101,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 +181,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 +291,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,41 +7,32 @@ import { createModelListAuthIndex } from "./list.auth-index.js";
|
||||
type ExternalCliProfilesResolver =
|
||||
typeof import("../../agents/auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles;
|
||||
|
||||
type PluginSnapshotResult = {
|
||||
source: "persisted" | "provided" | "derived";
|
||||
snapshot: {
|
||||
plugins: Array<{ enabled?: boolean; syntheticAuthRefs?: string[] }>;
|
||||
};
|
||||
diagnostics: [];
|
||||
};
|
||||
|
||||
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",
|
||||
@@ -68,16 +59,10 @@ describe("createModelListAuthIndex", () => {
|
||||
beforeEach(() => {
|
||||
externalCliMocks.resolveExternalCliAuthProfiles.mockReset();
|
||||
externalCliMocks.resolveExternalCliAuthProfiles.mockReturnValue([]);
|
||||
pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReset();
|
||||
pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
|
||||
source: "persisted",
|
||||
snapshot: { plugins: [] },
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a fresh auth scope empty", () => {
|
||||
const index = createModelListAuthIndex({
|
||||
const index = createTestModelListAuthIndex({
|
||||
cfg: {},
|
||||
authStore: emptyStore,
|
||||
env: {},
|
||||
@@ -88,7 +73,7 @@ describe("createModelListAuthIndex", () => {
|
||||
});
|
||||
|
||||
it("scopes provider discovery to concrete API-key evidence", () => {
|
||||
const index = createModelListAuthIndex({
|
||||
const index = createTestModelListAuthIndex({
|
||||
cfg: {},
|
||||
authStore: emptyStore,
|
||||
env: { ANTHROPIC_API_KEY: "test-key" },
|
||||
@@ -111,7 +96,7 @@ describe("createModelListAuthIndex", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
const index = createModelListAuthIndex({
|
||||
const index = createTestModelListAuthIndex({
|
||||
cfg: {},
|
||||
authStore: emptyStore,
|
||||
env: {},
|
||||
@@ -123,7 +108,7 @@ describe("createModelListAuthIndex", () => {
|
||||
});
|
||||
|
||||
it("scopes provider discovery to stored credential profiles", () => {
|
||||
const index = createModelListAuthIndex({
|
||||
const index = createTestModelListAuthIndex({
|
||||
cfg: {},
|
||||
authStore: {
|
||||
version: 1,
|
||||
@@ -143,7 +128,7 @@ describe("createModelListAuthIndex", () => {
|
||||
});
|
||||
|
||||
it("scopes provider discovery to configured auth profiles", () => {
|
||||
const index = createModelListAuthIndex({
|
||||
const index = createTestModelListAuthIndex({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
@@ -163,7 +148,7 @@ describe("createModelListAuthIndex", () => {
|
||||
});
|
||||
|
||||
it("forwards route-aware evaluation through the command adapter", () => {
|
||||
const index = createModelListAuthIndex({
|
||||
const index = createTestModelListAuthIndex({
|
||||
cfg: {},
|
||||
authStore: {
|
||||
version: 1,
|
||||
@@ -188,21 +173,23 @@ describe("createModelListAuthIndex", () => {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -227,7 +214,7 @@ describe("createModelListAuthIndex", () => {
|
||||
],
|
||||
},
|
||||
} as unknown as PluginMetadataSnapshot;
|
||||
const index = createModelListAuthIndex({
|
||||
const index = createTestModelListAuthIndex({
|
||||
cfg: {},
|
||||
authStore: emptyStore,
|
||||
env: {},
|
||||
@@ -240,7 +227,6 @@ 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", () => {
|
||||
@@ -262,7 +248,7 @@ describe("createModelListAuthIndex", () => {
|
||||
],
|
||||
},
|
||||
} as unknown as PluginMetadataSnapshot;
|
||||
const index = createModelListAuthIndex({
|
||||
const index = createTestModelListAuthIndex({
|
||||
cfg: {},
|
||||
authStore: emptyStore,
|
||||
env: {},
|
||||
@@ -277,17 +263,19 @@ describe("createModelListAuthIndex", () => {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -302,7 +290,7 @@ describe("createModelListAuthIndex", () => {
|
||||
registrySource: "persisted",
|
||||
plugins: [],
|
||||
} as unknown as PluginMetadataSnapshot;
|
||||
const index = createModelListAuthIndex({
|
||||
const index = createTestModelListAuthIndex({
|
||||
cfg: {},
|
||||
authStore: emptyStore,
|
||||
env: {},
|
||||
@@ -312,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", () => {
|
||||
@@ -321,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: {},
|
||||
@@ -330,6 +317,5 @@ describe("createModelListAuthIndex", () => {
|
||||
});
|
||||
|
||||
expect(index.evaluateModelAuth("openai").availability).toBe(false);
|
||||
expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ 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;
|
||||
@@ -25,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 ?? []);
|
||||
}
|
||||
@@ -81,9 +61,6 @@ export function createModelListAuthIndex(
|
||||
syntheticAuthProviderRefs:
|
||||
params.syntheticAuthProviderRefs ??
|
||||
listValidatedSyntheticAuthProviderRefs({
|
||||
cfg: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
metadataSnapshot: params.metadataSnapshot,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -120,12 +120,21 @@ 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 =
|
||||
opts.all && !providerFilter
|
||||
? undefined
|
||||
: providerFilter
|
||||
? [providerFilter]
|
||||
: authIndex.providerDiscoveryProviderIds;
|
||||
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 loadRegistryState = async (optsLocal?: {
|
||||
normalizeModels?: boolean;
|
||||
loadAvailability?: boolean;
|
||||
|
||||
@@ -7,12 +7,12 @@ import type { ManifestModelCatalogRowSelection } from "../../model-catalog/manif
|
||||
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 planManifestCatalogRowsForPluginIds(params: {
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -268,7 +268,12 @@ describe("appendPreparedModelCatalogRows", () => {
|
||||
expect(requireOnlyRow(rows).key).toBe("anthropic/claude-opus-4-7");
|
||||
expect(mocks.loadScopedModelCatalogSnapshot).toHaveBeenCalledExactlyOnceWith({
|
||||
cfg: {},
|
||||
agentId: "worker",
|
||||
agentDir: "/tmp/openclaw-worker",
|
||||
inheritedAuthDir: "/tmp/openclaw-default",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
providerIds: ["anthropic"],
|
||||
configuredKeys: [],
|
||||
});
|
||||
expect(mocks.loadModelCatalogSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -939,7 +944,11 @@ describe("appendAuthenticatedCatalogRows", () => {
|
||||
});
|
||||
expect(mocks.loadScopedModelCatalogSnapshot).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
inheritedAuthDir: "/tmp/openclaw-agent",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
providerIds: ["local-openai"],
|
||||
configuredKeys: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -474,7 +474,12 @@ export async function loadListModelCatalogSnapshot(
|
||||
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,
|
||||
configuredKeys: [...context.configuredByKey.keys()],
|
||||
...(context.metadataSnapshot ? { metadataSnapshot: context.metadataSnapshot } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadManifestCatalogRowsForList: vi.fn(),
|
||||
loadStaticManifestCatalogRowsForList: vi.fn(),
|
||||
loadPersistedListCatalogEntries: vi.fn(),
|
||||
prepareScopedReadOnlyModelCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./list.manifest-catalog.js", () => ({
|
||||
@@ -10,6 +12,14 @@ vi.mock("./list.manifest-catalog.js", () => ({
|
||||
loadStaticManifestCatalogRowsForList: mocks.loadStaticManifestCatalogRowsForList,
|
||||
}));
|
||||
|
||||
vi.mock("./list.persisted-catalog.js", () => ({
|
||||
loadPersistedListCatalogEntries: mocks.loadPersistedListCatalogEntries,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/prepared-model-runtime.scoped-catalog.js", () => ({
|
||||
prepareScopedReadOnlyModelCatalog: mocks.prepareScopedReadOnlyModelCatalog,
|
||||
}));
|
||||
|
||||
import { loadScopedListModelCatalogSnapshot } from "./list.scoped-catalog.js";
|
||||
|
||||
const runtimeRow = {
|
||||
@@ -43,23 +53,39 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.loadManifestCatalogRowsForList.mockReturnValue([runtimeRow, staticRow]);
|
||||
mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([staticRow]);
|
||||
mocks.loadPersistedListCatalogEntries.mockReturnValue([]);
|
||||
mocks.prepareScopedReadOnlyModelCatalog.mockResolvedValue({
|
||||
entries: [],
|
||||
routeVariants: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadScopedListModelCatalogSnapshot", () => {
|
||||
it("returns an empty snapshot without loading manifest catalogs for an empty auth scope", () => {
|
||||
expect(loadScopedListModelCatalogSnapshot({ cfg: {}, providerIds: [] })).toEqual({
|
||||
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.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("admits runtime manifest rows only for authenticated providers", () => {
|
||||
const snapshot = loadScopedListModelCatalogSnapshot({
|
||||
it("uses runtime manifest rows to enrich configured model ids", async () => {
|
||||
const snapshot = await loadScopedListModelCatalogSnapshot({
|
||||
cfg: {},
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
providerIds: ["openai"],
|
||||
configuredKeys: ["openai/gpt-5.6"],
|
||||
});
|
||||
|
||||
expect(snapshot.entries).toEqual([
|
||||
@@ -72,17 +98,95 @@ describe("loadScopedListModelCatalogSnapshot", () => {
|
||||
]);
|
||||
expect(snapshot.routeVariants).toEqual(snapshot.entries);
|
||||
expect(snapshot.staticEntries).toEqual([]);
|
||||
expect(mocks.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps static rows in the scoped snapshot", () => {
|
||||
const snapshot = loadScopedListModelCatalogSnapshot({
|
||||
it("keeps static rows in the scoped snapshot", async () => {
|
||||
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.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses persisted runtime rows and manifest metadata without loading provider runtimes", 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",
|
||||
},
|
||||
]);
|
||||
|
||||
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(snapshot.routeVariants).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ api: "openai-chatgpt-responses" }),
|
||||
expect.objectContaining({ api: "openai-responses" }),
|
||||
]),
|
||||
);
|
||||
expect(mocks.prepareScopedReadOnlyModelCatalog).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.prepareScopedReadOnlyModelCatalog.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.prepareScopedReadOnlyModelCatalog).toHaveBeenCalledWith(
|
||||
{
|
||||
config: {},
|
||||
agentId: "main",
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
inheritedAuthDir: "/tmp/openclaw-default",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
readOnly: true,
|
||||
},
|
||||
["google"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,26 @@ import type { NormalizedModelCatalogRow } from "@openclaw/model-catalog-core/mod
|
||||
/** 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,
|
||||
} 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,
|
||||
@@ -36,12 +49,85 @@ function selectProviderRows(
|
||||
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 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 function loadScopedListModelCatalogSnapshot(params: {
|
||||
export async function loadScopedListModelCatalogSnapshot(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
agentDir: string;
|
||||
inheritedAuthDir?: string;
|
||||
workspaceDir?: string;
|
||||
providerIds: readonly string[];
|
||||
configuredKeys: readonly string[];
|
||||
metadataSnapshot?: PluginMetadataSnapshot;
|
||||
}): ModelCatalogSnapshot {
|
||||
}): Promise<ModelCatalogSnapshot> {
|
||||
const providerIds = new Set(params.providerIds.map(normalizeProviderId).filter(Boolean));
|
||||
if (providerIds.size === 0) {
|
||||
return { entries: [], routeVariants: [], staticEntries: [] };
|
||||
@@ -50,16 +136,61 @@ export function loadScopedListModelCatalogSnapshot(params: {
|
||||
cfg: params.cfg,
|
||||
...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}),
|
||||
};
|
||||
const entries = selectProviderRows(loadManifestCatalogRowsForList(loaderParams), providerIds).map(
|
||||
toCatalogEntry,
|
||||
);
|
||||
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 staticEntries = selectProviderRows(
|
||||
loadStaticManifestCatalogRowsForList(loaderParams),
|
||||
providerIds,
|
||||
).map(toCatalogEntry);
|
||||
return {
|
||||
entries,
|
||||
routeVariants: [...entries],
|
||||
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 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)) && !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(
|
||||
lightweightSnapshot.entries.map((entry) => normalizeProviderId(entry.provider)),
|
||||
);
|
||||
const uncoveredProviders = [...providerIds].filter((provider) => !coveredProviders.has(provider));
|
||||
if (uncoveredProviders.length === 0) {
|
||||
return mergeSnapshotEntries([lightweightSnapshot]);
|
||||
}
|
||||
const { prepareScopedReadOnlyModelCatalog } = await preparedScopedCatalogModuleLoader.load();
|
||||
const fallbackSnapshot = await prepareScopedReadOnlyModelCatalog(
|
||||
{
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user