fix(models): preserve scoped provider discovery outcomes (#158139)

* fix(models): preserve scoped provider discovery outcomes

* fix(models): preserve upstream catalog integration

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Peter Steinberger 2026-09-25 07:30:23 -07:00 • committed by GitHub
parent 864a494a27
commit 52fc3daa84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 143 additions and 3 deletions

View file

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

View file

@ -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<ProviderPlugin["catalog"]>["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([]);
});

View file

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

View file

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