mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
864a494a27
commit
52fc3daa84
4 changed files with 143 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
122
src/agents/models-config.providers.implicit.outcomes.test.ts
Normal file
122
src/agents/models-config.providers.implicit.outcomes.test.ts
Normal 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([]);
|
||||
});
|
||||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue