diff --git a/docs/plugins/sdk-provider-plugins/model-catalogs.md b/docs/plugins/sdk-provider-plugins/model-catalogs.md index e7e40c21667f..b4b7d2e2bdd8 100644 --- a/docs/plugins/sdk-provider-plugins/model-catalogs.md +++ b/docs/plugins/sdk-provider-plugins/model-catalogs.md @@ -59,6 +59,8 @@ seed models as a successful refresh. HTTP 401/403 produces a catalog-scoped Neither a static catalog nor skipped discovery produces a live outcome. Each outcome carries the profile selected for the actual request, when one supplied its credential. Family providers report each sibling independently. +Provider-scoped refreshes preserve explicit outcomes reported under a registered +alias of the selected provider; unrelated sibling outcomes remain excluded. With a positive cache lifetime, validated empty results use the same successful-observation lifetime as nonempty results. After expiry, ordinary catalog reads return retained rows while the existing inventory owner refreshes @@ -80,8 +82,9 @@ without the selected credential. The strict and advisory paths share the same guarded transport and cache, with separate cache identities. Advisory calls still retain only nonempty results. Custom live builders can use `runLiveProviderCatalog` at their catalog hook -to convert acquisition errors into outcomes. Keep metadata-feed fallback -separate from account discovery; do not retry a rejected account request +to report successful acquisition and convert acquisition errors into outcomes. +Returning provider configuration alone does not establish a live discovery outcome. +Keep metadata-feed fallback separate from account discovery; do not retry a rejected account request anonymously or substitute seed rows inside a strict builder. Custom catalog hooks may receive optional `mode` metadata from diff --git a/src/agents/models-config.providers.implicit.outcomes.test.ts b/src/agents/models-config.providers.implicit.outcomes.test.ts new file mode 100644 index 000000000000..063d8980a432 --- /dev/null +++ b/src/agents/models-config.providers.implicit.outcomes.test.ts @@ -0,0 +1,122 @@ +import { afterAll, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import type { ModelProviderConfig } from "../config/types.models.js"; +import { createPluginMetadataSnapshotFixture } from "../plugins/plugin-metadata.test-support.js"; +import type { ProviderCatalogOutcome } from "../plugins/provider-catalog.types.js"; +import type { ProviderPlugin } from "../plugins/types.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../test-utils/openclaw-test-state.js"; +import { resolveImplicitProviders } from "./models-config.providers.implicit.js"; + +const fixture = vi.hoisted(() => ({ providers: [] as ProviderPlugin[] })); + +vi.mock("../plugins/provider-discovery.runtime.js", () => ({ + resolvePluginDiscoveryProvidersRuntime: () => fixture.providers, +})); + +let state: OpenClawTestState; +beforeAll(async () => { + state = await createOpenClawTestState({ label: "implicit-catalog-outcomes" }); +}); +afterAll(async () => { + await state.cleanup(); +}); +beforeEach(() => { + fixture.providers = []; +}); + +function config(ids: string[] = ["learned"]): ModelProviderConfig { + return { + baseUrl: "https://catalog.example.invalid/v1", + api: "openai-completions", + models: ids.map((id) => ({ + id, + name: id, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32768, + maxTokens: 4096, + })), + }; +} + +function provider(id: string, run: NonNullable["run"]): ProviderPlugin { + return { + id, + pluginId: "catalog-owner", + label: id, + auth: [], + catalog: { order: "profile", run }, + }; +} + +async function discover(selected: string) { + const outcomes: ProviderCatalogOutcome[] = []; + const pluginMetadataSnapshot = createPluginMetadataSnapshotFixture({ + plugins: [ + { + id: "catalog-owner", + providers: ["canonical", "alias", "sibling"], + modelCatalog: { aliases: { alias: { provider: "canonical" } } }, + }, + ], + }); + const providers = await resolveImplicitProviders({ + agentDir: state.agentDir(), + env: state.env, + config: {}, + authStore: { version: 1, profiles: {} }, + pluginMetadataSnapshot, + providerDiscoveryProviderIds: [selected], + onProviderCatalogOutcome: (outcome) => outcomes.push(outcome), + }); + return { providers, outcomes }; +} + +it.each([ + { selected: "canonical", reported: "alias" }, + { selected: "alias", reported: "canonical" }, +])( + "keeps explicit $reported authority when only $selected is selected", + async ({ selected, reported }) => { + const failure: ProviderCatalogOutcome = { + provider: reported, + profileId: "canonical:account", + status: "unavailable", + }; + const entry = provider("canonical", async (ctx) => { + expect(ctx.providerIds).toEqual([selected]); + return { + provider: config(), + outcomes: [ + failure, + { provider: "sibling", profileId: "sibling:account", status: "auth-rejected" }, + ], + }; + }); + entry.aliases = ["alias"]; + entry.hookAliases = ["sibling"]; + fixture.providers = [entry]; + const result = await discover(selected); + expect(Object.keys(result.providers ?? {})).toEqual([selected]); + expect(result.outcomes).toEqual([failure]); + }, +); + +it("does not import a distinct sibling account outcome through a shared hook", async () => { + const entry = provider("canonical", async (ctx) => { + expect(ctx.providerIds).toEqual(["canonical"]); + return { + provider: config(), + outcomes: [{ provider: "sibling", profileId: "sibling:account", status: "auth-rejected" }], + }; + }); + entry.aliases = ["alias"]; + entry.hookAliases = ["sibling"]; + fixture.providers = [entry]; + const result = await discover("canonical"); + expect(Object.keys(result.providers ?? {})).toEqual(["canonical"]); + expect(result.outcomes).toEqual([]); +}); diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index c4edcbbdc3df..3b06228fb612 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -32,6 +32,7 @@ import { resolveNonEnvSecretRefApiKeyMarker } from "../secrets/provider-credenti import { ensureAuthProfileStore } from "./auth-profiles/store-runtime.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; +import { createPreparedModelCatalogProviderNormalizer } from "./model-catalog-provider-normalizer.js"; import { mergeProviderModels, type SourceModelFields } from "./models-config.merge.js"; import { buildPluginCatalogConfig, @@ -92,6 +93,7 @@ type ImplicitProviderContext = ImplicitProviderParams & { providerDiscoveryScope?: ProviderDiscoveryScope; resolveProviderApiKey: ProviderApiKeyResolver; resolveProviderAuth: ProviderAuthResolver; + normalizeProviderForScope: (provider: string) => string; }; function resolveLiveProviderCatalogTimeoutMs(env: NodeJS.ProcessEnv): number | null { @@ -303,6 +305,7 @@ async function resolvePluginImplicitProviders( } else { result = await runProviderCatalogWithTimeout({ provider, + normalizeProviderForScope: ctx.normalizeProviderForScope, authStore: ctx.authStore, ...(providerIds !== undefined ? { providerIds } : {}), config: catalogConfig, @@ -565,8 +568,17 @@ export async function resolveImplicitProviders( params.workspaceDir, discoveryAuthEnv, ] as const; + const metadata = params.pluginMetadataSnapshot; const context: ImplicitProviderContext = { ...params, + normalizeProviderForScope: + discoveryScope && metadata + ? createPreparedModelCatalogProviderNormalizer( + { ...metadata, plugins: metadata.manifestRegistry.plugins }, + params.config ?? {}, + env, + ) + : normalizeProviderId, get authStore() { return getAuthStore(); }, diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index 4dbdcdc9ad44..54705e08328b 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -151,6 +151,8 @@ export function normalizePluginDiscoveryResult(params: { export async function runProviderCatalog(params: { provider: ProviderPlugin; providerIds?: readonly string[]; + /** Captured catalog identities; the hook still receives its original provider scope. */ + normalizeProviderForScope?: (provider: string) => string; config: OpenClawConfig; agentDir?: string; workspaceDir?: string; @@ -176,11 +178,12 @@ export async function runProviderCatalog(params: { if (params.isActive?.() === false) { return undefined; } + const normalizeProvider = params.normalizeProviderForScope ?? normalizeProviderId; for (const outcome of copyProviderCatalogOutcomes(result)) { if ( params.providerIds !== undefined && !params.providerIds.some( - (providerId) => normalizeProviderId(providerId) === normalizeProviderId(outcome.provider), + (providerId) => normalizeProvider(providerId) === normalizeProvider(outcome.provider), ) ) { continue;