diff --git a/src/gateway/server-methods/models-list-provider-outcomes.ts b/src/gateway/server-methods/models-list-provider-outcomes.ts new file mode 100644 index 000000000000..79a68062367e --- /dev/null +++ b/src/gateway/server-methods/models-list-provider-outcomes.ts @@ -0,0 +1,54 @@ +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"; + +export function projectPublicProviderCatalogOutcomes( + outcomes: readonly ProviderCatalogOutcome[] | undefined, +): ProviderCatalogOutcome[] | undefined { + return outcomes?.map(({ provider, profileId, status }) => ({ + provider, + ...(profileId ? { profileId } : {}), + status, + })); +} + +export function applyProviderCatalogOutcomesToModelAuth(params: { + provider: string; + modelId: string; + outcomes?: readonly ProviderCatalogOutcome[]; + resolved: ModelAuthAvailabilityEvaluation; + evaluateForProfile: (profileId: string) => ModelAuthAvailabilityEvaluation; +}): ModelAuthAvailabilityEvaluation { + 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] + : [], + ); + if ( + authorizingProfileIds.length > 0 && + !authorizingProfileIds.includes(params.resolved.selectedProfileId ?? "") + ) { + for (const profileId of authorizingProfileIds) { + const candidate = params.evaluateForProfile(profileId); + if (candidate.availability === true) { + return candidate; + } + } + return { ...params.resolved, availability: false }; + } + // Stored credentials prove presence, not acceptance. Apply rejection only to + // the profile discovery tested, or another valid profile would be hidden. + return outcomes.some( + (outcome) => + outcome.status === "auth-rejected" && + (outcome.profileId === undefined || outcome.profileId === params.resolved.selectedProfileId), + ) + ? { ...params.resolved, availability: false } + : params.resolved; +} diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 4d8c436d069b..803a6d2a7808 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -58,6 +58,10 @@ import { normalizeAgentId } from "../../routing/session-key.js"; import type { GatewayAgentRuntime } from "../../shared/session-types.js"; import { resolveGatewayModelThinkingProfile } from "../session-utils-model.js"; import { createModelsListAuthResolver } from "./models-list-auth-resolver.js"; +import { + applyProviderCatalogOutcomesToModelAuth, + projectPublicProviderCatalogOutcomes, +} from "./models-list-provider-outcomes.js"; import type { GatewayRequestContext } from "./types.js"; type ModelsListEntry = Pick< @@ -76,14 +80,6 @@ type ModelsListResult = { providerOutcomes?: readonly ProviderCatalogOutcome[]; }; -function projectProviderCatalogOutcomes(outcomes: readonly ProviderCatalogOutcome[] | undefined) { - return outcomes?.map(({ provider, profileId, status }) => ({ - provider, - ...(profileId ? { profileId } : {}), - status, - })); -} - let loggedSlowModelsListCatalog = false; // Unknown views are rejected by protocol validation first; this helper keeps the @@ -210,39 +206,13 @@ function createModelsListEntryEvaluator(params: { }), } : evaluation; - const provider = normalizeProviderId(entry.provider); - const outcomes = - params.providerOutcomes?.filter( - (outcome) => normalizeProviderId(outcome.provider) === provider, - ) ?? []; - const modelId = (identity?.id ?? entry.id).trim().toLowerCase(); - const authorizingProfiles = outcomes.filter( - (outcome) => - outcome.status === "ready" && - outcome.profileId !== undefined && - outcome.modelIds?.some((candidate) => candidate.trim().toLowerCase() === modelId), - ); - if ( - authorizingProfiles.length > 0 && - !authorizingProfiles.some((outcome) => outcome.profileId === resolved.selectedProfileId) - ) { - for (const outcome of authorizingProfiles) { - const candidate = evaluateForProfile(outcome.profileId); - if (candidate.availability === true) { - return candidate; - } - } - return { ...resolved, availability: false }; - } - // Stored credentials prove presence, not acceptance. Apply the live rejection only to the - // profile discovery tested; widening it would hide routes backed by another valid profile. - return outcomes.some( - (outcome) => - outcome.status === "auth-rejected" && - (outcome.profileId === undefined || outcome.profileId === resolved.selectedProfileId), - ) - ? { ...resolved, availability: false } - : resolved; + return applyProviderCatalogOutcomesToModelAuth({ + provider: entry.provider, + modelId: identity?.id ?? entry.id, + outcomes: params.providerOutcomes, + resolved, + evaluateForProfile, + }); }); pending.set(cacheKey, next); return next; @@ -649,7 +619,7 @@ export async function buildModelsListResult( const catalog = snapshot.entries; const routeVariants = snapshot.routeVariants; const providerOutcomes = snapshot.providerOutcomes; - const publicProviderOutcomes = projectProviderCatalogOutcomes(providerOutcomes); + const publicProviderOutcomes = projectPublicProviderCatalogOutcomes(providerOutcomes); const outcomeProjection = publicProviderOutcomes?.length ? { providerOutcomes: publicProviderOutcomes } : {};