fix(models): bind discovered auth profile at runtime

This commit is contained in:
Patrick Erichsen
2026-08-12 18:02:56 -07:00
parent 88639ab9a2
commit 31865d3e33
10 changed files with 215 additions and 30 deletions
@@ -76,4 +76,58 @@ describe("embedded run auth plan provider pin", () => {
},
});
});
it("prefers the profile whose loaded catalog authorized the selected model", () => {
const preparedModelRuntime = {
modelCatalog: {
entries: [],
routeVariants: [],
providerOutcomes: [
{
provider: "openai",
profileId: "openai:first",
status: "ready" as const,
modelIds: ["gpt-5.6-sol"],
},
{
provider: "openai",
profileId: "openai:second",
status: "ready" as const,
modelIds: ["gpt-5.6-terra"],
},
],
},
} as unknown as Parameters<
typeof authPlanTesting.resolveEmbeddedRunPreferredProfileId
>[0]["preparedModelRuntime"];
expect(
authPlanTesting.resolveEmbeddedRunPreferredProfileId({
provider: "openai",
modelId: "gpt-5.6-terra",
preparedModelRuntime,
requestedProfileId: "openai:first",
ignoreAutoPreferredProfile: false,
}),
).toBe("openai:second");
expect(
authPlanTesting.resolveEmbeddedRunPreferredProfileId({
provider: "openai",
modelId: "gpt-5.6-terra",
preparedModelRuntime,
requestedProfileId: "openai:first",
lockedProfileId: "openai:first",
ignoreAutoPreferredProfile: false,
}),
).toBe("openai:first");
expect(
authPlanTesting.resolveEmbeddedRunPreferredProfileId({
provider: "openai",
modelId: "gpt-5.6-sol",
preparedModelRuntime,
requestedProfileId: "openai:first",
ignoreAutoPreferredProfile: false,
}),
).toBe("openai:first");
});
});
@@ -1,5 +1,6 @@
import "./auth-plan.js";
import type { AuthProfileStore } from "../../auth-profiles.js";
import type { PreparedModelRuntimeSnapshot } from "../../prepared-model-runtime.js";
import type { RunEmbeddedAgentParams } from "./params.js";
type EmbeddedRunAuthPlanTestApi = {
@@ -8,6 +9,14 @@ type EmbeddedRunAuthPlanTestApi = {
config: RunEmbeddedAgentParams["config"];
externalCliProviderIds: Iterable<string>;
}): AuthProfileStore;
resolveEmbeddedRunPreferredProfileId(params: {
provider: string;
modelId: string;
preparedModelRuntime?: PreparedModelRuntimeSnapshot;
requestedProfileId?: string;
lockedProfileId?: string;
ignoreAutoPreferredProfile: boolean;
}): string | undefined;
};
function getTestApi(): EmbeddedRunAuthPlanTestApi {
@@ -18,4 +27,6 @@ function getTestApi(): EmbeddedRunAuthPlanTestApi {
export const testing: EmbeddedRunAuthPlanTestApi = {
loadEmbeddedRunAuthProfileStore: (params) => getTestApi().loadEmbeddedRunAuthProfileStore(params),
resolveEmbeddedRunPreferredProfileId: (params) =>
getTestApi().resolveEmbeddedRunPreferredProfileId(params),
};
@@ -1,3 +1,4 @@
import { listProviderModelAuthorizingProfileIds } from "../../../plugins/provider-catalog-outcome.js";
import { resolveProviderAuthProfileId } from "../../../plugins/provider-runtime.js";
import type { AuthProfileStore } from "../../auth-profiles.js";
import { resolveExternalCliAuthOverlayScopeFromSelection } from "../../auth-profiles/external-cli-auth-selection.js";
@@ -7,6 +8,7 @@ import {
ensureAuthProfileStoreWithoutExternalProfiles,
} from "../../model-auth.js";
import { OPENAI_PROVIDER_ID } from "../../openai-routing.js";
import { getLoadedFullModelCatalog } from "../../prepared-model-runtime-full-catalog.js";
import type { PreparedModelRuntimeSnapshot } from "../../prepared-model-runtime.js";
import {
createPreparedRuntimeModelMaterializer,
@@ -22,6 +24,35 @@ import type { RunEmbeddedAgentParams } from "./params.js";
type ModelResolution = Awaited<ReturnType<typeof resolveModelAsync>>;
type RuntimeModel = NonNullable<ModelResolution["model"]>;
function resolveEmbeddedRunPreferredProfileId(params: {
provider: string;
modelId: string;
preparedModelRuntime?: PreparedModelRuntimeSnapshot;
requestedProfileId?: string;
lockedProfileId?: string;
ignoreAutoPreferredProfile: boolean;
}): string | undefined {
const loadedCatalog = getLoadedFullModelCatalog(params.preparedModelRuntime);
const authorizingProfileIds = params.lockedProfileId
? []
: listProviderModelAuthorizingProfileIds({
outcomes:
loadedCatalog?.providerOutcomes ??
params.preparedModelRuntime?.modelCatalog.providerOutcomes,
provider: params.provider,
modelId: params.modelId,
});
// Discovery made this model selectable with one of these exact profiles. Preserve an already
// authorizing auto-selection; otherwise bind the first attempt to catalog provenance.
const catalogAuthorizedProfileId =
params.requestedProfileId && authorizingProfileIds.includes(params.requestedProfileId)
? params.requestedProfileId
: authorizingProfileIds[0];
return params.ignoreAutoPreferredProfile && !params.lockedProfileId
? undefined
: (params.lockedProfileId ?? catalogAuthorizedProfileId ?? params.requestedProfileId);
}
function loadEmbeddedRunAuthProfileStore(params: {
agentDir: string;
config: RunEmbeddedAgentParams["config"];
@@ -40,7 +71,7 @@ function loadEmbeddedRunAuthProfileStore(params: {
// must stay provable without composing a full embedded runner.
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.embeddedRunAuthPlanTestApi")] =
{ loadEmbeddedRunAuthProfileStore };
{ loadEmbeddedRunAuthProfileStore, resolveEmbeddedRunPreferredProfileId };
}
export async function prepareEmbeddedRunAuthPlan(params: {
@@ -132,10 +163,14 @@ export async function prepareEmbeddedRunAuthPlan(params: {
const requestedProfileId = runParams.authProfileId?.trim() || undefined;
const lockedProfileId = runParams.authProfileIdSource === "user" ? requestedProfileId : undefined;
const preferredProfileId =
externalCliAuthScope.ignoreAutoPreferredProfile && !lockedProfileId
? undefined
: requestedProfileId;
const preferredProfileId = resolveEmbeddedRunPreferredProfileId({
provider: params.provider,
modelId: params.modelId,
preparedModelRuntime: params.preparedModelRuntime,
requestedProfileId,
lockedProfileId,
ignoreAutoPreferredProfile: externalCliAuthScope.ignoreAutoPreferredProfile,
});
const createAuthPreparation = () => {
const harness = params.getAgentHarness();
return prepareAgentRuntimeAuth({
@@ -180,7 +215,7 @@ export async function prepareEmbeddedRunAuthPlan(params: {
config: runParams.config,
getModel: params.getRuntimeModel,
nativeModelOwned: params.nativeModelOwned,
requestedProfileId: runParams.authProfileId,
requestedProfileId: preferredProfileId,
providerUsesProfileScopedModelMetadata,
resolveModel: ({ config, authProfileId, authProfileMode }) =>
resolveModelAsync(params.provider, params.modelId, params.agentDir, config, {
@@ -0,0 +1,30 @@
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
import type { PreparedModelRuntimeSnapshot } from "./prepared-model-runtime.types.js";
const loadedFullModelCatalogAccessor = Symbol("openclaw.loadedFullModelCatalogAccessor");
type SnapshotWithLoadedFullModelCatalogAccessor = PreparedModelRuntimeSnapshot & {
[loadedFullModelCatalogAccessor]?: () => ModelCatalogSnapshot | undefined;
};
/** Carries the lifecycle-owned full-catalog closure without widening the public snapshot shape. */
export function attachLoadedFullModelCatalogAccessor(
snapshot: PreparedModelRuntimeSnapshot,
accessor: () => ModelCatalogSnapshot | undefined,
): void {
Object.defineProperty(snapshot, loadedFullModelCatalogAccessor, {
configurable: false,
enumerable: true,
value: accessor,
writable: false,
});
}
/** Returns the full catalog only after this exact generation has already loaded it. */
export function getLoadedFullModelCatalog(
snapshot: PreparedModelRuntimeSnapshot | undefined,
): ModelCatalogSnapshot | undefined {
return (snapshot as SnapshotWithLoadedFullModelCatalogAccessor | undefined)?.[
loadedFullModelCatalogAccessor
]?.();
}
+7 -3
View File
@@ -7,6 +7,7 @@ import { resolveUsableAgentCredentialModes } from "./agent-auth-credentials.js";
import { getPreparedRuntimeAuthMaterializations } from "./auth-profiles/runtime-materializations.js";
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
import { setPreparedModelRuntimeAuthMaterializations } from "./prepared-model-runtime-auth.js";
import { attachLoadedFullModelCatalogAccessor } from "./prepared-model-runtime-full-catalog.js";
import { PreparedModelRuntimePublicationSupersededError } from "./prepared-model-runtime.errors.js";
import {
fingerprintPreparedRuntimeFacts,
@@ -37,6 +38,7 @@ const MAX_CONCURRENT_FULL_MODEL_CATALOG_BUILDS = 1;
const limitFullModelCatalogBuild = pLimit(MAX_CONCURRENT_FULL_MODEL_CATALOG_BUILDS);
type PreparedModelRuntimeCatalogAccess = Readonly<{
getLoadedFullModelCatalog: () => ModelCatalogSnapshot | undefined;
loadFullModelCatalog: () => Promise<ModelCatalogSnapshot>;
}>;
type PreparedModelRuntimeBuildGuards =
@@ -117,6 +119,7 @@ function createFullModelCatalogAccess(params: {
}
};
return {
getLoadedFullModelCatalog: () => fullCatalog,
loadFullModelCatalog: () => {
if (fullCatalog) {
return Promise.resolve(fullCatalog);
@@ -177,7 +180,7 @@ function createSnapshot(
const authStorage = AuthStorage.inMemory(credentials);
return { authStorage, modelRegistry: templateModelRegistry.fork(authStorage) };
};
const snapshot: PreparedModelRuntimeSnapshot = Object.freeze({
const snapshot: PreparedModelRuntimeSnapshot = {
...(input.agentId ? { agentId: input.agentId } : {}),
agentDir: input.agentDir,
activeProjectKeys: [],
@@ -195,12 +198,13 @@ function createSnapshot(
configuredRuntimeModels,
inlineProviderModels,
createStores,
});
};
attachLoadedFullModelCatalogAccessor(snapshot, catalogAccess.getLoadedFullModelCatalog);
setPreparedModelRuntimeAuthMaterializations(
snapshot,
Object.freeze([...getPreparedRuntimeAuthMaterializations(input.agentDir)]),
);
return snapshot;
return Object.freeze(snapshot);
}
async function buildSnapshotBatch(
@@ -178,6 +178,7 @@ vi.mock("../logging/subsystem.js", () => ({
const { getPreparedModelRuntimeSnapshot, refreshPreparedModelRuntimeSnapshots } =
await import("./prepared-model-runtime.js");
const { getLoadedFullModelCatalog } = await import("./prepared-model-runtime-full-catalog.js");
const { prepareScopedReadOnlyLiveModelCatalog, prepareScopedReadOnlyModelCatalog } =
await import("./prepared-model-runtime.scoped-catalog.js");
const { resetPreparedModelRuntimeSnapshotsForTest } =
@@ -380,7 +381,9 @@ describe("prepared model runtime Gateway catalog mode", () => {
expect(snapshot?.pluginRegistry).toBeDefined();
expect(snapshot?.messageToolCatalog).toBeUndefined();
expect(snapshot?.mediaCapabilityProviders).toBeDefined();
expect(getLoadedFullModelCatalog(snapshot)).toBeUndefined();
const fullCatalog = await snapshot?.loadFullModelCatalog?.();
expect(getLoadedFullModelCatalog(snapshot)).toBe(fullCatalog);
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledWith(
config,
@@ -1,6 +1,9 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import type { ModelAuthAvailabilityEvaluation } from "../../agents/model-auth-availability.js";
import type { ProviderCatalogOutcome } from "../../plugins/provider-catalog.types.js";
import {
listProviderModelAuthorizingProfileIds,
type ProviderCatalogOutcome,
} from "../../plugins/provider-catalog-outcome.js";
export function projectPublicProviderCatalogOutcomes(
outcomes: readonly ProviderCatalogOutcome[] | undefined,
@@ -22,14 +25,11 @@ export function applyProviderCatalogOutcomesToModelAuth(params: {
const provider = normalizeProviderId(params.provider);
const outcomes =
params.outcomes?.filter((outcome) => normalizeProviderId(outcome.provider) === provider) ?? [];
const modelId = params.modelId.trim().toLowerCase();
const authorizingProfileIds = outcomes.flatMap((outcome) =>
outcome.status === "ready" &&
outcome.profileId &&
outcome.modelIds?.some((candidate) => candidate.trim().toLowerCase() === modelId)
? [outcome.profileId]
: [],
);
const authorizingProfileIds = listProviderModelAuthorizingProfileIds({
outcomes,
provider,
modelId: params.modelId,
});
if (
authorizingProfileIds.length > 0 &&
!authorizingProfileIds.includes(params.resolved.selectedProfileId ?? "")
+20
View File
@@ -1,3 +1,5 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
export type ProviderCatalogOutcome = {
provider: string;
/** Auth profile tested by discovery; omission means provider-wide auth. */
@@ -6,3 +8,21 @@ export type ProviderCatalogOutcome = {
/** Models the tested profile's authoritative catalog allowed in this generation. */
modelIds?: readonly string[];
};
/** Profiles whose authoritative live catalog included this exact model. */
export function listProviderModelAuthorizingProfileIds(params: {
outcomes: readonly ProviderCatalogOutcome[] | undefined;
provider: string;
modelId: string;
}): string[] {
const provider = normalizeProviderId(params.provider);
const modelId = params.modelId.trim().toLowerCase();
return (params.outcomes ?? []).flatMap((outcome) =>
normalizeProviderId(outcome.provider) === provider &&
outcome.status === "ready" &&
outcome.profileId &&
outcome.modelIds?.some((candidate) => candidate.trim().toLowerCase() === modelId)
? [outcome.profileId]
: [],
);
}
+15 -8
View File
@@ -227,7 +227,9 @@ async function refreshMissingChatMetadata(
const commandsRefresh = applied.commands
? Promise.resolve()
: refreshCompatibilityCommands(request);
const preserveModels = opts?.preserveModelCatalogOnFallback;
const preserveModels =
opts?.preserveModelCatalogOnFallback ||
isGatewayMethodAdvertised(request.host as unknown as ChatState, "models.list") === false;
const modelsRefresh =
applied.models || preserveModels
? Promise.resolve()
@@ -267,11 +269,12 @@ export async function refreshChatMetadata(
if (!ownsChatMetadataRequest(request)) {
return EMPTY_CHAT_METADATA_APPLY_RESULT;
}
// chat.metadata remains the compatibility source for commands only. Picker inventory must
// always come from the live, agent-scoped models.list result so stale static models cannot
// reappear when chat.startup omits metadata or an older Gateway serves this fallback path.
// Current Gateways use the live, agent-scoped catalog. A Gateway that explicitly lacks
// models.list keeps its metadata catalog so the compatibility path does not empty the picker.
const useMetadataModels =
isGatewayMethodAdvertised(host as unknown as ChatState, "models.list") === false;
const metadataApplied = applyChatMetadataResult(host, client, agentId, result, {
models: false,
models: useMetadataModels,
});
if (!metadataApplied.models || !metadataApplied.commands) {
await refreshMissingChatMetadata(request, metadataApplied, opts);
@@ -461,9 +464,13 @@ export function refreshPageChat(host: ChatPageHost, opts?: ChatRefreshOptions) {
return;
}
rememberChatMetadata(client, agentId, metadata);
// Startup metadata stays on the published static catalog so opening chat never waits on
// provider discovery. The explicit models.list read below owns the live picker inventory.
const applied = applyChatMetadataResult(host, client, agentId, metadata, { models: false });
// Only an explicitly legacy Gateway owns picker inventory through startup metadata.
// Current and capability-unknown Gateways continue into live, agent-scoped discovery.
const useMetadataModels =
isGatewayMethodAdvertised(host as unknown as ChatState, "models.list") === false;
const applied = applyChatMetadataResult(host, client, agentId, metadata, {
models: useMetadataModels,
});
if (!applied.models || !applied.commands) {
await refreshMissingChatMetadata(request, applied, { refreshModelCatalog: true });
}
+25 -4
View File
@@ -1410,7 +1410,7 @@ describe("refreshChatMetadata", () => {
agentsList: null,
assistantAgentId: "main",
client: { request },
hello: { features: { methods: ["chat.metadata"] } },
hello: { features: { methods: ["chat.metadata", "models.list"] } },
sessionKey: "agent:work:main",
...overrides,
} as unknown as ChatPageHost;
@@ -1644,7 +1644,7 @@ describe("refreshChatMetadata", () => {
chatMetadataRequestVersion: 2,
chatModelCatalog: [{ id: "stale-model", name: "Stale Model", provider: "openai" }],
chatModelsLoading: true,
hello: { features: { methods: [] } },
hello: { features: { methods: ["models.list"] } },
sessionKey: "agent:main:main",
});
@@ -1658,6 +1658,27 @@ describe("refreshChatMetadata", () => {
expect(request).toHaveBeenCalledTimes(2);
});
it("retains metadata models when the gateway explicitly lacks models.list", async () => {
const metadataModel = {
id: "metadata-model",
name: "Metadata Model",
provider: "openai",
available: true,
};
const request = vi.fn(async (method: string) => {
expect(method).toBe("chat.metadata");
return { commands: [], models: [metadataModel] };
});
const state = createMetadataState(request, {
hello: { features: { methods: ["chat.metadata"] } },
});
await refreshChatMetadata(state);
expect(state.chatModelCatalog).toEqual([metadataModel]);
expect(request).toHaveBeenCalledOnce();
});
it("surfaces a first-load models.list failure instead of publishing an empty catalog", async () => {
const request = vi.fn(async (method: string) => {
if (method === "models.list") {
@@ -1668,7 +1689,7 @@ describe("refreshChatMetadata", () => {
});
const state = createMetadataState(request, {
chatModelCatalog: [],
hello: { features: { methods: [] } },
hello: { features: { methods: ["models.list"] } },
sessionKey: "agent:main:main",
});
@@ -1715,7 +1736,7 @@ describe("refreshChatMetadata", () => {
const state = createMetadataState(request, {
agentsList: { defaultId: "main" } as ChatPageHost["agentsList"],
chatModelCatalog: [{ id: "stale-model", name: "Stale Model", provider: "openai" }],
hello: { features: { methods: [] } },
hello: { features: { methods: ["models.list"] } },
});
await refreshChatMetadata(state);