fix(control-ui): hide unusable models from picker (#121852)

* fix(ui): hide unusable models from picker

* refactor(ui): remove stale model availability helper

* refactor(ui): simplify catalog state guards

* style: format provider catalog imports

* chore: refresh plugin SDK API baseline

* refactor(core): break provider catalog type cycle

* chore(protocol): refresh models list Swift output

* chore: refresh plugin SDK API baseline after rebase

* fix(gateway): preserve full catalog preload semantics

* fix(ui): keep model status within startup budget

* fix(ui): preserve provider status within startup budget

* fix(models): scope live catalog outcomes

* test(ui): expect agent-scoped model refresh

* test(ui): align model refresh e2e fixtures
This commit is contained in:
Patrick Erichsen
2026-08-11 09:13:44 -07:00
committed by GitHub
parent 8d3f3377da
commit ad704f35c4
63 changed files with 1104 additions and 301 deletions
@@ -12990,18 +12990,22 @@ public struct ModelsAuthStatusParams: Codable, Sendable {
}
public struct ModelsListParams: Codable, Sendable {
public let agentid: String?
public let includeprovidercapabilities: Bool?
public let view: AnyCodable?
public init(
agentid: String? = nil,
includeprovidercapabilities: Bool? = nil,
view: AnyCodable? = nil)
{
self.agentid = agentid
self.includeprovidercapabilities = includeprovidercapabilities
self.view = view
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case includeprovidercapabilities = "includeProviderCapabilities"
case view
}
@@ -13009,15 +13013,19 @@ public struct ModelsListParams: Codable, Sendable {
public struct ModelsListResult: Codable, Sendable {
public let models: [ModelChoice]
public let provideroutcomes: [[String: AnyCodable]]?
public init(
models: [ModelChoice])
models: [ModelChoice],
provideroutcomes: [[String: AnyCodable]]? = nil)
{
self.models = models
self.provideroutcomes = provideroutcomes
}
private enum CodingKeys: String, CodingKey {
case models
case provideroutcomes = "providerOutcomes"
}
}
@@ -1 +1 @@
{"contentHash":"4f8b570b60781e746c19cd370d5c5967ad68047b255e81fd29d0dff95bfc9f3e","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"12cd4ca949d700a5e350532ef6358758e933f4cdd4a203b90ac184b3b726d7f0","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"bf900891a3c6ee746fdf5033882e8ca88bc03ae0b2c971bb5977c0f0db506506","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"69a94ce26778740b1851981f59c14d496423940977883f7c8f6894a5edc38a59","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"6fe3155f10712bc5d001f1f69e502f3e1a0b4179bb5ed7100d0ca7df933085be","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
{"contentHash":"96db2cbf307a1fb22e9374a85a41da49ac538b0affe7ac40d9fcc99bcd646277","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"28666b0cf5d0bdf28648baffffcf1239e4d27727408c785fa06f5c271fc0843a","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"ba838118f480f9dda7e17f2aae734455bcfa9eaa04def1eecc44a953f30777cf","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"49a549e6a39d55279ecafe650e7e00496ebd6da5266cb11e486f5ee19f53b4c1","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"b8c908a5595a4a986a5b273c144e86118663200f5442e8089d596ca33a8db3b4","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"970ba9dd27f5e4d6a09b0ffec37264cdb3f745a5934e2cb8cc53eee78f2c176a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"018523b2135e2e6a56d13762780497ce3c5813bf685eab9ba6759fcf15c0bd75","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"574fa42cccdf71a38cb19a0662dacbcbd94486069f01fda496e950384897ddbf","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"dba5f7a7214801fad31471070bc490fab414f0789df068c2be354f0e55175a82","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"897b21d2b152cacd699378eabe938caaee71171ec4002a3dd6dd42efabcda0f7","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"e03086edb20e4fdfd3cbe9b9c217ec65e72fad776b6ab5be7ba1232400f953be","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"db165d4f6bdb943dae0cb3015cafcb6d94d2482fff712eda98eb5442d418b095","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"cd81c27ce42d21ddd58698e008ca66bbb8ec70963bafcd3c22bb9270d7c71cdc","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"4b32922587dab4f465f85b7fea02e3b64d89ebea73a496501aa3c59fa7ef6337","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"97a1bec418d885b030c71fbca4d89f3e3a78f9b023ee24912316095c30e82c1b","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"579472d01b563dae9696e37050977dfdceb51df2df05e10df3e34304d1f9baf4","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
{"contentHash":"6ae2801252f1bed0c001ce3f68aa74228b6893ecba27cec90a13338cfc68d7d3","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
@@ -1 +1 @@
{"contentHash":"c1acc4ca962cb62ae6036308b6d90c2e018420cf8b0f12594b2b876f5a5b0ca2","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"93affe4ecc7ff530f12561d6ec4b465d09ecae3d7abf4ec7c3461aa0e3692e0b","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
@@ -1 +1 @@
{"contentHash":"18782530d1c5bb66e086dd0177eb8ee52d152805aa68932a945fbc35178c3b03","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"13439d99b5059dbc96ce7419b37a7a2001754e0c527df3c7f19b2a118929aebe","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"3f92b4ce6954e9bbe85a7675f7ad000e01e6b34049523a9a3e38d23966ad959a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"b36fe0846bc4470774334d5e4c6fe4f17479683f5b308163fd78ef573e8c189a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"60b7d6caebf369d35a367e431f77eb3f7e5437870ef1da5f024ea8c71217b1cb","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"55a9017c833c34ce3104399e1284599d65ade7bc410abf7053da3101cae9edab","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"36aad63a3fda1116df864fcdaa3c26d2c0d7af809603d3d4afaf96dcccdd8391","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"97c70337f3f8ade7db7cf7b6b64091f8071fbfb914a125daa119ce97ebcbf599","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"39bc2bdca81266a16eab23271664ad8cbffdf2fc9b7f0f1d04c98c2611c53c5d","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"58c25a8013bacfcd77146d79671c04cacc429e6d6abe883fc891277710b03a76","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"1600c91e3364cf9bd9cceaec9403f3c96503a145620e18a30502af3b53571e4c","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"51b619ddb708736ee882a8a5eb60922441f73cb69928f0a3a9be65d4c8894cfd","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
+53 -17
View File
@@ -21,6 +21,15 @@ const mocks = vi.hoisted(() => ({
resolveProviderAuthProfileMetadata: vi.fn(),
}));
type OpenAITestCatalogResult = {
provider: ModelProviderConfig;
outcomes: readonly {
provider: string;
profileId?: string;
status: "ready" | "auth-rejected" | "unavailable";
}[];
};
async function runCatalogWithFetchGuard(params: {
fetchGuard: LiveModelCatalogFetchGuard;
auth: {
@@ -32,7 +41,7 @@ async function runCatalogWithFetchGuard(params: {
};
accountId?: string;
baseUrl?: string;
}): Promise<ModelProviderConfig> {
}): Promise<OpenAITestCatalogResult> {
if (params.auth.mode === "oauth") {
mocks.resolveApiKeyForProvider.mockResolvedValue({
...params.auth,
@@ -67,7 +76,7 @@ async function runCatalogWithFetchGuard(params: {
if (!result || "provider" in result || !result.providers.openai) {
throw new Error("expected OpenAI live provider catalog");
}
return result.providers.openai;
return { provider: result.providers.openai, outcomes: result.outcomes ?? [] };
} finally {
fetchSpy.mockRestore();
}
@@ -78,11 +87,13 @@ async function buildOpenAILiveProviderConfig(params: {
baseUrl?: string;
fetchGuard: LiveModelCatalogFetchGuard;
}): Promise<ModelProviderConfig> {
return await runCatalogWithFetchGuard({
fetchGuard: params.fetchGuard,
auth: { mode: "api_key", apiKey: params.apiKey, source: "profile" },
baseUrl: params.baseUrl,
});
return (
await runCatalogWithFetchGuard({
fetchGuard: params.fetchGuard,
auth: { mode: "api_key", apiKey: params.apiKey, source: "profile" },
baseUrl: params.baseUrl,
})
).provider;
}
async function buildOpenAICodexLiveProviderConfig(params: {
@@ -90,16 +101,18 @@ async function buildOpenAICodexLiveProviderConfig(params: {
accountId?: string;
fetchGuard: LiveModelCatalogFetchGuard;
}): Promise<ModelProviderConfig> {
return await runCatalogWithFetchGuard({
fetchGuard: params.fetchGuard,
auth: {
mode: "oauth",
apiKey: params.discoveryApiKey,
profileId: "openai:chatgpt",
source: "profile",
},
accountId: params.accountId,
});
return (
await runCatalogWithFetchGuard({
fetchGuard: params.fetchGuard,
auth: {
mode: "oauth",
apiKey: params.discoveryApiKey,
profileId: "openai:chatgpt",
source: "profile",
},
accountId: params.accountId,
})
).provider;
}
vi.mock("./openai-chatgpt-provider.runtime.js", () => ({
@@ -1154,6 +1167,29 @@ describe("buildOpenAIProvider", () => {
expect(release).toHaveBeenCalledOnce();
});
it("reports when the account catalog rejects saved OAuth credentials", async () => {
const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async () => ({
response: new Response("unauthorized", { status: 401 }),
finalUrl: "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
release: async () => undefined,
}));
const result = await runCatalogWithFetchGuard({
fetchGuard,
auth: {
mode: "oauth",
apiKey: "rejected-oauth-token",
profileId: "openai:chatgpt",
source: "profile",
},
});
expect(result.provider.models).toEqual([]);
expect(result.outcomes).toEqual([
{ provider: "openai", profileId: "openai:chatgpt", status: "auth-rejected" },
]);
});
it.each(["gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"])(
"prefers auth-aware Codex runtime metadata for %s over static OpenAI catalog rows",
(modelId) => {
+82 -39
View File
@@ -9,7 +9,10 @@ import {
LiveModelCatalogHttpError,
type LiveModelCatalogFetchGuard,
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
import {
buildManifestModelProviderConfig,
type ProviderCatalogOutcome,
} from "openclaw/plugin-sdk/provider-catalog-shared";
import {
DEFAULT_CONTEXT_TOKENS,
normalizeProviderId,
@@ -254,9 +257,25 @@ function buildOpenAIDiscoverablePlatformModels(baseUrl: string): ModelDefinition
}));
}
type OpenAILiveProviderCatalog = {
provider: ModelProviderConfig;
outcome?: ProviderCatalogOutcome;
};
function scopeOpenAICatalogOutcome(
catalog: OpenAILiveProviderCatalog,
profileId: string | undefined,
): OpenAILiveProviderCatalog {
const scopedProfileId = profileId?.trim();
if (!catalog.outcome || !scopedProfileId) {
return catalog;
}
return { ...catalog, outcome: { ...catalog.outcome, profileId: scopedProfileId } };
}
async function buildOpenAILiveProviderConfig(
params: BuildOpenAILiveProviderConfigParams,
): Promise<ModelProviderConfig> {
): Promise<OpenAILiveProviderCatalog> {
const baseUrl =
normalizeOptionalString(params.baseUrl) ?? resolveOpenAIDefaultBaseUrl(params.env);
const models = buildOpenAIManifestModelsForBaseUrl(baseUrl);
@@ -267,7 +286,7 @@ async function buildOpenAILiveProviderConfig(
models,
};
if (!shouldFetchOpenAILiveModels(baseUrl)) {
return fallback;
return { provider: fallback };
}
try {
const rows = await getCachedLiveProviderModelRows({
@@ -297,23 +316,29 @@ async function buildOpenAILiveProviderConfig(
// A successful account catalog is authoritative even when it has no
// visible supported models; static rows cannot grant model access.
return {
...fallback,
models: [...models, ...buildOpenAIDiscoverablePlatformModels(baseUrl)].filter((model) => {
if (!discoveredIds.has(model.id) || selectedIds.has(model.id)) {
return false;
}
selectedIds.add(model.id);
return true;
}),
provider: {
...fallback,
models: [...models, ...buildOpenAIDiscoverablePlatformModels(baseUrl)].filter((model) => {
if (!discoveredIds.has(model.id) || selectedIds.has(model.id)) {
return false;
}
selectedIds.add(model.id);
return true;
}),
},
outcome: { provider: PROVIDER_ID, status: "ready" },
};
} catch (error) {
if (
error instanceof LiveModelCatalogHttpError &&
(error.status === 401 || error.status === 403)
) {
return { ...fallback, models: [] };
return {
provider: { ...fallback, models: [] },
outcome: { provider: PROVIDER_ID, status: "auth-rejected" },
};
}
return fallback;
return { provider: fallback, outcome: { provider: PROVIDER_ID, status: "unavailable" } };
}
}
@@ -565,7 +590,7 @@ async function buildOpenAICodexLiveProviderConfig(params: {
accountId?: string;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
}): Promise<ModelProviderConfig> {
}): Promise<OpenAILiveProviderCatalog> {
try {
const rows = await getCachedLiveProviderModelRows({
providerId: PROVIDER_ID,
@@ -595,22 +620,31 @@ async function buildOpenAICodexLiveProviderConfig(params: {
// A successful account-scoped response is authoritative even when all
// rows are hidden; static hints must not invent subscription access.
return {
baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL,
api: "openai-chatgpt-responses",
auth: "oauth",
models,
provider: {
baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL,
api: "openai-chatgpt-responses",
auth: "oauth",
models,
},
outcome: { provider: PROVIDER_ID, status: "ready" },
};
} catch (error) {
if (
error instanceof LiveModelCatalogHttpError &&
(error.status === 401 || error.status === 403)
) {
return { ...buildOpenAICodexStaticProviderConfig(), models: [] };
return {
provider: { ...buildOpenAICodexStaticProviderConfig(), models: [] },
outcome: { provider: PROVIDER_ID, status: "auth-rejected" },
};
}
// Codex/ChatGPT discovery is advisory. Static OpenAI rows stay available
// when OAuth refresh or the remote model list is unavailable.
}
return buildOpenAICodexStaticProviderConfig();
return {
provider: buildOpenAICodexStaticProviderConfig(),
outcome: { provider: PROVIDER_ID, status: "unavailable" },
};
}
function isCodexCatalogAuthMode(mode: string): boolean {
@@ -943,39 +977,48 @@ export function buildOpenAIProvider(): ProviderPlugin {
? { profileId: runtimeAuth.profileId ?? auth.profileId }
: {}),
});
const provider = await buildOpenAICodexLiveProviderConfig({
discoveryApiKey: runtimeAuth.apiKey,
accountId: metadata.accountId,
});
return { providers: { [PROVIDER_ID]: provider } };
const catalog = scopeOpenAICatalogOutcome(
await buildOpenAICodexLiveProviderConfig({
discoveryApiKey: runtimeAuth.apiKey,
accountId: metadata.accountId,
}),
runtimeAuth.profileId ?? auth.profileId,
);
return {
providers: { [PROVIDER_ID]: catalog.provider },
...(catalog.outcome ? { outcomes: [catalog.outcome] } : {}),
};
}
} catch {
// OAuth discovery is advisory; fall through so configured API-key
// auth can still publish the standard OpenAI catalog.
}
if (auth.mode === "api_key" && auth.apiKey) {
const catalog = scopeOpenAICatalogOutcome(
await buildOpenAILiveProviderConfig({
apiKey: auth.apiKey,
baseUrl: resolveOpenAICatalogBaseUrl(ctx),
discoveryApiKey: auth.discoveryApiKey,
}),
auth.profileId,
);
return {
providers: {
[PROVIDER_ID]: await buildOpenAILiveProviderConfig({
apiKey: auth.apiKey,
baseUrl: resolveOpenAICatalogBaseUrl(ctx),
discoveryApiKey: auth.discoveryApiKey,
}),
},
providers: { [PROVIDER_ID]: catalog.provider },
...(catalog.outcome ? { outcomes: [catalog.outcome] } : {}),
};
}
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID);
if (!apiKey.apiKey) {
return null;
}
const catalog = await buildOpenAILiveProviderConfig({
apiKey: apiKey.apiKey,
baseUrl: resolveOpenAICatalogBaseUrl(ctx),
discoveryApiKey: apiKey.discoveryApiKey,
});
return {
providers: {
[PROVIDER_ID]: await buildOpenAILiveProviderConfig({
apiKey: apiKey.apiKey,
baseUrl: resolveOpenAICatalogBaseUrl(ctx),
discoveryApiKey: apiKey.discoveryApiKey,
}),
},
providers: { [PROVIDER_ID]: catalog.provider },
...(catalog.outcome ? { outcomes: [catalog.outcome] } : {}),
};
},
},
@@ -181,6 +181,7 @@ describe("ModelsListParamsSchema", () => {
ModelsListParamsSchema,
{ view: "provider-config" },
{
agentId: "writer",
view: "all",
includeProviderCapabilities: true,
},
@@ -221,13 +222,31 @@ describe("ModelsListResultSchema", () => {
input: ["text", "image", "audio", "video", "document"],
};
expectAccepted(ModelsListResultSchema, { models: [model] });
expectAccepted(
ModelsListResultSchema,
{ models: [model] },
{
models: [],
providerOutcomes: [
{
provider: "openai",
profileId: "openai:chatgpt",
status: "auth-rejected",
},
],
},
);
expectRejected(
ModelsListResultSchema,
{
models: [{ ...model, agentRuntime: { id: "codex", source: "unknown" } }],
},
{ models: [{ ...model, input: ["text", "binary"] }] },
{ models: [], providerOutcomes: [{ provider: "openai", status: "unknown" }] },
{
models: [],
providerOutcomes: [{ provider: "openai", profileId: "", status: "auth-rejected" }],
},
);
});
});
@@ -221,6 +221,7 @@ export const AgentsFilesSetResultSchema = closedObject({
/** Model catalog request with optional visibility scope. */
export const ModelsListParamsSchema = closedObject({
agentId: Type.Optional(Type.String()),
includeProviderCapabilities: Type.Optional(Type.Boolean()),
view: Type.Optional(
Type.Union([
@@ -246,8 +247,19 @@ export const ModelsAuthLogoutParamsSchema = closedObject({
});
/** Model catalog result. */
export const ModelCatalogProviderOutcomeSchema = closedObject({
provider: NonEmptyString,
profileId: Type.Optional(NonEmptyString),
status: Type.Union([
Type.Literal("ready"),
Type.Literal("auth-rejected"),
Type.Literal("unavailable"),
]),
});
export const ModelsListResultSchema = closedObject({
models: Type.Array(ModelChoiceSchema),
providerOutcomes: Type.Optional(Type.Array(ModelCatalogProviderOutcomeSchema)),
});
/** Runs a bounded live credential probe for one model provider. */
@@ -1103,6 +1115,7 @@ export type AgentsListParams = Static<typeof AgentsListParamsSchema>;
export type AgentsListResult = Static<typeof AgentsListResultSchema>;
export type ModelChoice = Static<typeof ModelChoiceSchema>;
export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
export type ModelCatalogProviderOutcome = Static<typeof ModelCatalogProviderOutcomeSchema>;
export type ModelsListResult = Static<typeof ModelsListResultSchema>;
export type ModelsAuthStatusParams = Static<typeof ModelsAuthStatusParamsSchema>;
export type ModelsAuthLogoutParams = Static<typeof ModelsAuthLogoutParamsSchema>;
+3
View File
@@ -5,6 +5,7 @@
*/
import type { ModelCatalogStatus } from "@openclaw/model-catalog-core/model-catalog-types";
import type { ModelApi, ModelCompatConfig, ModelMediaInputConfig } from "../config/types.models.js";
import type { ProviderCatalogOutcome } from "../plugins/provider-catalog-outcome.js";
/** Input modalities a catalog entry can advertise. */
export type ModelInputType = "text" | "image" | "audio" | "video" | "document";
@@ -37,6 +38,8 @@ export type ModelCatalogEntry = {
export type ModelCatalogSnapshot = {
entries: ModelCatalogEntry[];
routeVariants: ModelCatalogEntry[];
/** Provider-owned outcome of each live catalog request in this generation. */
providerOutcomes?: readonly ProviderCatalogOutcome[];
/** Static provider-hook rows captured alongside the full lifecycle generation. */
staticEntries?: ModelCatalogEntry[];
/**
+9
View File
@@ -5,6 +5,7 @@
*/
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import type { ProviderCatalogOutcome } from "../plugins/provider-catalog.types.js";
import type { PreparedProviderStaticCatalog } from "../plugins/provider-discovery.js";
import { isRecord } from "../utils.js";
import {
@@ -110,6 +111,7 @@ async function resolveProvidersForModelsJsonWithDeps(
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
onProviderCatalogOutcome?: (outcome: ProviderCatalogOutcome) => void;
},
deps?: {
resolveImplicitProviders?: ResolveImplicitProvidersForModelsJson;
@@ -147,6 +149,9 @@ async function resolveProvidersForModelsJsonWithDeps(
? { providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs }
: {}),
...(params.providerDiscoveryEntriesOnly === true ? { providerDiscoveryEntriesOnly: true } : {}),
...(params.onProviderCatalogOutcome
? { onProviderCatalogOutcome: params.onProviderCatalogOutcome }
: {}),
});
return mergeProviders({
implicit: implicitProviders,
@@ -230,6 +235,7 @@ async function planOpenClawModelsJsonWithDeps(
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
onProviderCatalogOutcome?: (outcome: ProviderCatalogOutcome) => void;
},
deps?: {
resolveImplicitProviders?: ResolveImplicitProvidersForModelsJson;
@@ -258,6 +264,9 @@ async function planOpenClawModelsJsonWithDeps(
...(params.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
...(params.onProviderCatalogOutcome
? { onProviderCatalogOutcome: params.onProviderCatalogOutcome }
: {}),
},
deps,
);
@@ -192,6 +192,23 @@ describe("resolveImplicitProviders startup discovery scope", () => {
expect(catalogOptions?.timeoutMs).toBe(1234);
});
it("records an unavailable outcome when live catalog discovery times out", async () => {
mocks.runProviderCatalog.mockImplementationOnce(() => new Promise<void>(() => {}));
const outcomes: Array<{ provider: string; status: string }> = [];
await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
config: {},
env: {} as NodeJS.ProcessEnv,
explicitProviders: {},
providerDiscoveryProviderIds: ["openai"],
providerDiscoveryTimeoutMs: 1,
onProviderCatalogOutcome: (outcome) => outcomes.push(outcome),
});
expect(outcomes).toEqual([{ provider: "openai", status: "unavailable" }]);
});
it("can keep startup discovery on provider discovery entries only", async () => {
await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
@@ -12,6 +12,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import type { ProviderCatalogOutcome } from "../plugins/provider-catalog.types.js";
import {
groupPluginDiscoveryProvidersByOrder,
normalizePluginDiscoveryResult,
@@ -68,6 +69,7 @@ type ImplicitProviderParams = {
staticCatalogProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
onProviderCatalogOutcome?: (outcome: ProviderCatalogOutcome) => void;
};
type ImplicitProviderContext = ImplicitProviderParams & {
@@ -438,6 +440,7 @@ async function resolvePluginImplicitProviders(
resolveProviderApiKey: resolveCatalogProviderApiKey,
resolveProviderAuth: (providerId, options) =>
ctx.resolveProviderAuth(providerId?.trim() || provider.id, options),
reportCatalogOutcome: ctx.onProviderCatalogOutcome,
timeoutMs: ctx.providerDiscoveryTimeoutMs ?? resolveLiveProviderCatalogTimeoutMs(ctx.env),
});
}
@@ -526,6 +529,10 @@ async function runProviderCatalogWithTimeout(
} catch (error) {
const message = formatErrorMessage(error);
if (message.includes("provider catalog timed out after")) {
params.reportCatalogOutcome?.({
provider: params.provider.id,
status: "unavailable",
});
log.warn(`${message}; skipping provider discovery`);
return undefined;
}
+12 -2
View File
@@ -22,6 +22,7 @@ import {
resolvePluginMetadataSnapshot,
type PluginMetadataSnapshot,
} from "../plugins/plugin-metadata-snapshot.js";
import type { ProviderCatalogOutcome } from "../plugins/provider-catalog.types.js";
import type { PreparedProviderStaticCatalog } from "../plugins/provider-discovery.js";
import {
resolveAgentWorkspaceDir,
@@ -64,8 +65,11 @@ type EnsureOpenClawModelsJsonOptions = {
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
onProviderCatalogOutcome?: (outcome: ProviderCatalogOutcome) => void;
};
type PlanOpenClawModelsJsonSourceOptions = EnsureOpenClawModelsJsonOptions;
type PlannedOpenClawModelsJsonSource = Readonly<{
agentDir: string;
modelsJsonContents: string | null;
@@ -376,7 +380,7 @@ async function prepareOpenClawModelsJsonSource(
const fingerprint = sourceFingerprint.fingerprint;
const cacheKey = modelsJsonReadyCacheKey(targetPath, fingerprint);
const cached = MODELS_JSON_STATE.readyCache.get(cacheKey);
if (cached) {
if (cached && !options.onProviderCatalogOutcome) {
const settled = await cached;
await ensureModelsFileModeForModelsJson(targetPath);
return {
@@ -418,6 +422,9 @@ async function prepareOpenClawModelsJsonSource(
...(options.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
...(options.onProviderCatalogOutcome
? { onProviderCatalogOutcome: options.onProviderCatalogOutcome }
: {}),
});
if (plan.action === "skip") {
@@ -499,7 +506,7 @@ async function prepareOpenClawModelsJsonSource(
export async function planOpenClawModelsJsonSource(
config?: OpenClawConfig,
agentDirOverride?: string,
options: EnsureOpenClawModelsJsonOptions = {},
options: PlanOpenClawModelsJsonSourceOptions = {},
): Promise<PlannedOpenClawModelsJsonSource> {
const resolved = resolveModelsConfigInput(config);
const cfg = resolved.config;
@@ -549,6 +556,9 @@ export async function planOpenClawModelsJsonSource(
...(options.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
...(options.onProviderCatalogOutcome
? { onProviderCatalogOutcome: options.onProviderCatalogOutcome }
: {}),
});
return {
agentDir,
+31 -3
View File
@@ -13,6 +13,7 @@ import {
getPreparedMessageToolCatalog,
getPreparedMessageToolCatalogForRegistry,
} from "../plugins/prepared-message-tool-catalog.js";
import type { ProviderCatalogOutcome } from "../plugins/provider-catalog.types.js";
import { resolveLoadedProviderRuntimePlugin } from "../plugins/provider-hook-runtime.js";
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js";
@@ -102,6 +103,7 @@ export type PreparedModelRuntimeCatalogFacts = {
export type PreparedModelRuntimeCatalogSource = Readonly<{
modelsJsonContents: string | null;
pluginCatalogs: readonly PersistedPluginModelCatalog[];
providerOutcomes?: readonly ProviderCatalogOutcome[];
}>;
type PreparedConfiguredRegistryGroup = {
@@ -462,7 +464,12 @@ export async function prepareFullCatalogFacts(
}
}
const staticEntries = [...staticModels.values()].map(toStaticCatalogEntry);
const completeModelCatalog = { ...modelCatalog, staticEntries };
const providerOutcomes = catalogSource?.providerOutcomes ?? [];
const completeModelCatalog = {
...modelCatalog,
staticEntries,
...(providerOutcomes.length > 0 ? { providerOutcomes } : {}),
};
if (catalogMode === "live") {
fullModelCatalogSnapshots.add(completeModelCatalog);
}
@@ -641,6 +648,19 @@ export async function prepareAgentCatalogSource(
sourceOptions: { providerDiscoveryProviderIds?: readonly string[] } = {},
): Promise<PreparedModelRuntimeCatalogSource> {
const { env, input, providerIds } = agentFacts;
const providerOutcomes = new Map<string, ProviderCatalogOutcome>();
const recordProviderOutcome = (outcome: ProviderCatalogOutcome) => {
const provider = normalizeProviderId(outcome.provider);
if (provider) {
providerOutcomes.set(`${provider}\0${outcome.profileId ?? ""}`, { ...outcome, provider });
}
};
const resultOutcomes = () =>
[...providerOutcomes.values()].toSorted(
(left, right) =>
left.provider.localeCompare(right.provider) ||
(left.profileId ?? "").localeCompare(right.profileId ?? ""),
);
const options = {
pluginMetadataSnapshot: pluginGeneration.pluginMetadataSnapshot,
...(pluginGeneration.preparedStaticProviderCatalog
@@ -661,19 +681,27 @@ export async function prepareAgentCatalogSource(
}),
};
if (!persist) {
const source = await planOpenClawModelsJsonSource(input.config, input.agentDir, options);
const source = await planOpenClawModelsJsonSource(input.config, input.agentDir, {
...options,
...(catalogMode === "live" ? { onProviderCatalogOutcome: recordProviderOutcome } : {}),
});
return {
modelsJsonContents: source.modelsJsonContents,
pluginCatalogs: source.pluginCatalogs,
providerOutcomes: resultOutcomes(),
};
}
if (!input.readOnly) {
await ensureOpenClawModelsJson(input.config, input.agentDir, options);
await ensureOpenClawModelsJson(input.config, input.agentDir, {
...options,
...(catalogMode === "live" ? { onProviderCatalogOutcome: recordProviderOutcome } : {}),
});
}
// Capture immediately after the serialized write. Another owner may share this directory and
// publish a different workspace generation before full-catalog parsing begins.
return {
modelsJsonContents: captureModelsJsonContents(input.agentDir),
pluginCatalogs: loadPersistedPluginModelCatalogsReadOnly(input.agentDir),
providerOutcomes: resultOutcomes(),
};
}
+22
View File
@@ -169,6 +169,28 @@ describe("prepared model runtime snapshots", () => {
);
});
it("keeps provider catalog outcomes on the published live snapshot", async () => {
mocks.ensureOpenClawModelsJson.mockImplementationOnce(async (...args: unknown[]) => {
const options = args[2] as {
onProviderCatalogOutcome?: (outcome: {
provider: string;
status: "ready" | "auth-rejected" | "unavailable";
}) => void;
};
options.onProviderCatalogOutcome?.({ provider: "openai", status: "auth-rejected" });
return { agentDir: "/tmp/provider-outcome-agent", wrote: false };
});
const snapshot = await publishPreparedModelRuntimeSnapshot({
config: {},
agentDir: "/tmp/provider-outcome-agent",
});
expect(snapshot.modelCatalog.providerOutcomes).toEqual([
{ provider: "openai", status: "auth-rejected" },
]);
});
it("captures static provider-hook rows in the same lifecycle generation", async () => {
mocks.loadStaticCatalog.mockResolvedValueOnce([
{
@@ -78,6 +78,19 @@ function createHarness(
);
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot: async (params?: { readOnly?: boolean }) => {
const modelCatalog =
params?.readOnly === false && owner.loadFullModelCatalog
? await owner.loadFullModelCatalog()
: owner.modelCatalog;
return {
...modelCatalog,
agentId: owner.agentId,
agentDir: owner.agentDir,
workspaceDir: owner.workspaceDir,
config: owner.config,
};
},
logGateway: {
debug: vi.fn(),
info: vi.fn(),
@@ -439,6 +452,61 @@ describe("gateway chat metadata runtime", () => {
},
);
test("keeps live provider discovery off chat metadata projection", async () => {
const config = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: { "openai/gpt-5.6-sol": {} },
},
list: [{ id: "main", default: true }],
},
} as OpenClawConfig;
const harness = createHarness(config, { useDefaultProjection: true });
const credentials: AgentCredentialMap = {
openai: {
type: "oauth",
access: "rejected-access-token",
refresh: "rejected-refresh-token",
expires: Date.now() + 30 * 60_000,
},
};
const owner = createOwner(
config,
"gpt-5.6-sol",
credentials,
"openai",
"openai-chatgpt-responses",
);
const loadFullModelCatalog = vi.fn(async () => ({
...owner.modelCatalog,
providerOutcomes: [{ provider: "openai", status: "auth-rejected" as const }],
}));
harness.setOwner({
...owner,
loadFullModelCatalog,
});
harness.setAuthStore({
version: 1,
profiles: {
"openai:chatgpt": {
type: "oauth",
provider: "openai",
access: "rejected-access-token",
refresh: "rejected-refresh-token",
expires: Date.now() + 30 * 60_000,
},
},
});
await harness.runtime.refresh();
await expect(harness.runtime.read({ agentId: "main" })).resolves.toMatchObject({
models: [expect.objectContaining({ id: "gpt-5.6-sol", available: true })],
});
expect(loadFullModelCatalog).not.toHaveBeenCalled();
});
test("retains a generation while auth store revisions are unchanged", async () => {
const harness = createHarness();
harness.getPreparedAuthStore.mockImplementation(() => ({ version: 1, profiles: {} }));
@@ -235,10 +235,13 @@ async function defaultBuildProjection(params: {
}): Promise<{ modelCatalog: ModelCatalogEntry[]; models?: unknown[] }> {
const { buildModelsListResult, createGatewayAgentModelCatalogProjector } =
await import("./models-list-result.js");
// Chat metadata must stay on process-published facts. Live discovery belongs to explicit
// models.list control-plane reads so a slow provider cannot delay chat startup.
const snapshot = params.facts.owner.modelCatalog;
const projector = createGatewayAgentModelCatalogProjector({
cfg: params.facts.owner.config,
agentId: params.facts.agentId,
snapshot: params.facts.owner.modelCatalog,
snapshot,
metadataSnapshot: params.facts.owner.metadataSnapshot,
preparedAuthStore: params.facts.authStore,
// The owner records usable auth at discovery; metadata must share that exact generation fact.
@@ -258,13 +261,13 @@ async function defaultBuildProjection(params: {
preloadedCatalog: {
agentId: params.facts.agentId,
config: params.facts.owner.config,
snapshot: params.facts.owner.modelCatalog,
snapshot,
},
preloadedOnly: true,
catalogProjector: projector,
}),
]);
return { modelCatalog, ...metadata };
return { modelCatalog, models: metadata.models };
}
export function createGatewayChatMetadataRuntime(params: {
@@ -0,0 +1,173 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
buildModelsListResult,
createGatewayAgentModelCatalogProjector,
} from "./models-list-result.js";
import type { GatewayRequestContext } from "./types.js";
describe("models.list provider catalog outcomes", () => {
it("preserves an auth rejection when no usable models are visible", async () => {
const config = {} as OpenClawConfig;
const snapshot = {
agentId: "main",
agentDir: "/tmp/models-list-provider-outcomes-agent",
config,
entries: [],
routeVariants: [],
providerOutcomes: [
{
provider: "openai",
profileId: "openai:chatgpt",
status: "auth-rejected" as const,
},
],
};
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot: vi.fn(() => Promise.resolve(snapshot)),
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
await expect(buildModelsListResult({ context, params: { view: "all" } })).resolves.toEqual({
models: [],
providerOutcomes: [
{ provider: "openai", profileId: "openai:chatgpt", status: "auth-rejected" },
],
});
});
it("marks configured rows unavailable when stored credentials were rejected", async () => {
const config = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: { "openai/*": {}, "openai/gpt-5.6-sol": {} },
},
},
} as OpenClawConfig;
const model = {
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
provider: "openai",
api: "openai-chatgpt-responses" as const,
baseUrl: "https://chatgpt.com/backend-api/codex",
};
const snapshot = {
entries: [model],
routeVariants: [model],
providerOutcomes: [
{
provider: "openai",
profileId: "openai:chatgpt",
status: "auth-rejected" as const,
},
],
};
const projector = createGatewayAgentModelCatalogProjector({
cfg: config,
agentId: "main",
snapshot,
preparedAuthStore: {
version: 1,
profiles: {
"openai:chatgpt": {
type: "oauth",
provider: "openai",
access: "rejected-access-token",
refresh: "rejected-refresh-token",
expires: Date.now() + 30 * 60_000,
},
"openai:other": {
type: "oauth",
provider: "openai",
access: "accepted-access-token",
refresh: "accepted-refresh-token",
expires: Date.now() + 30 * 60_000,
},
},
},
preferredProfileId: "openai:chatgpt",
});
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot: vi.fn(),
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
await expect(
buildModelsListResult({
context,
agentId: "main",
params: { view: "configured" },
preloadedCatalog: { agentId: "main", config, snapshot, fullyDiscovered: true },
preloadedOnly: true,
catalogProjector: projector,
}),
).resolves.toEqual({
models: [expect.objectContaining({ id: "gpt-5.6-sol", available: false })],
providerOutcomes: [
{ provider: "openai", profileId: "openai:chatgpt", status: "auth-rejected" },
],
});
});
it("does not apply one profile rejection to a different selected profile", async () => {
const config = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: { "openai/*": {}, "openai/gpt-5.6-sol": {} },
},
},
} as OpenClawConfig;
const model = {
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
provider: "openai",
api: "openai-chatgpt-responses" as const,
baseUrl: "https://chatgpt.com/backend-api/codex",
};
const snapshot = {
entries: [model],
routeVariants: [model],
providerOutcomes: [
{
provider: "openai",
profileId: "openai:rejected",
status: "auth-rejected" as const,
},
],
};
const projector = createGatewayAgentModelCatalogProjector({
cfg: config,
agentId: "main",
snapshot,
preferredProfileId: "openai:accepted",
preparedAuthStore: {
version: 1,
profiles: {
"openai:rejected": {
type: "oauth",
provider: "openai",
access: "rejected-access-token",
refresh: "rejected-refresh-token",
expires: Date.now() + 30 * 60_000,
},
"openai:accepted": {
type: "oauth",
provider: "openai",
access: "accepted-access-token",
refresh: "accepted-refresh-token",
expires: Date.now() + 30 * 60_000,
},
},
},
});
await expect(projector.evaluateEntry(model, [model])).resolves.toMatchObject({
availability: true,
selectedProfileId: "openai:accepted",
});
});
});
@@ -52,6 +52,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { resolveManifestProviderAuthChoices } from "../../plugins/provider-auth-choices.js";
import type { ProviderCatalogOutcome } from "../../plugins/provider-catalog.types.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import type { GatewayAgentRuntime } from "../../shared/session-types.js";
import { createModelsListAuthResolver } from "./models-list-auth-resolver.js";
@@ -72,6 +73,10 @@ type ApiKeyProviderCapabilities = {
};
type ModelsListAvailability = ModelAuthAvailability;
type ModelsListEntryEvaluation = ModelAuthAvailabilityEvaluation;
type ModelsListResult = {
models: ModelsListEntryWithCapabilities[];
providerOutcomes?: readonly ProviderCatalogOutcome[];
};
let loggedSlowModelsListCatalog = false;
@@ -157,6 +162,7 @@ function createModelsListEntryEvaluator(params: {
cfg: OpenClawConfig;
agentId: string;
authResolver: ModelAuthAvailabilityResolver;
providerOutcomes?: readonly ProviderCatalogOutcome[];
preferredProfileId?: string;
lockedProfileId?: string;
}): (
@@ -181,18 +187,30 @@ function createModelsListEntryEvaluator(params: {
baseUrl: variant.baseUrl,
})),
});
return evaluation.routeResolution === null && normalizeProviderId(entry.provider) !== "openai"
? {
...evaluation,
availability: resolveLegacyEntryAvailability({
authResolver: params.authResolver,
entry,
primaryAvailability: evaluation.availability,
cfg: params.cfg,
agentId: params.agentId,
}),
}
: evaluation;
const resolved =
evaluation.routeResolution === null && normalizeProviderId(entry.provider) !== "openai"
? {
...evaluation,
availability: resolveLegacyEntryAvailability({
authResolver: params.authResolver,
entry,
primaryAvailability: evaluation.availability,
cfg: params.cfg,
agentId: params.agentId,
}),
}
: evaluation;
const provider = normalizeProviderId(entry.provider);
// 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 params.providerOutcomes?.some(
(outcome) =>
outcome.status === "auth-rejected" &&
normalizeProviderId(outcome.provider) === provider &&
(outcome.profileId === undefined || outcome.profileId === resolved.selectedProfileId),
)
? { ...resolved, availability: false }
: resolved;
});
pending.set(cacheKey, next);
return next;
@@ -348,6 +366,7 @@ export function createGatewayAgentModelCatalogProjector(params: {
cfg: params.cfg,
agentId: params.agentId,
authResolver,
providerOutcomes: params.snapshot.providerOutcomes,
...(params.preferredProfileId ? { preferredProfileId: params.preferredProfileId } : {}),
...(params.lockedProfileId ? { lockedProfileId: params.lockedProfileId } : {}),
});
@@ -476,6 +495,8 @@ type BuildModelsListResultParams = {
agentId: string;
config: OpenClawConfig;
snapshot: ModelCatalogSnapshot;
/** The owner already ran full discovery for this exact snapshot. */
fullyDiscovered?: boolean;
};
catalogProjector?: ReturnType<typeof createGatewayAgentModelCatalogProjector>;
preloadedOnly?: boolean;
@@ -484,7 +505,7 @@ type BuildModelsListResultParams = {
export async function buildModelsListResult(
params: BuildModelsListResultParams,
): Promise<{ models: ModelsListEntryWithCapabilities[] }> {
): Promise<ModelsListResult> {
const initialConfig = params.context.getRuntimeConfig();
const initialAgentId = normalizeAgentId(params.agentId ?? resolveDefaultAgentId(initialConfig));
const view = resolveModelsListView(params.params);
@@ -513,7 +534,12 @@ export async function buildModelsListResult(
view,
loadCatalog: async (loadParams) => {
loadedReadOnly = loadParams.readOnly ?? true;
if (preloadedCatalog && loadedReadOnly) {
// A read-only preload cannot satisfy a full-discovery request. Reuse it only when the
// owner carried the completed-discovery fact with the exact snapshot.
if (
preloadedCatalog &&
(loadedReadOnly || (params.preloadedOnly && preloadedCatalog.fullyDiscovered === true))
) {
usedPreloadedCatalog = true;
return preloadedCatalog.snapshot;
}
@@ -580,6 +606,8 @@ export async function buildModelsListResult(
resolveDefaultAgentWorkspaceDir();
const catalog = snapshot.entries;
const routeVariants = snapshot.routeVariants;
const providerOutcomes = snapshot.providerOutcomes;
const outcomeProjection = providerOutcomes?.length ? { providerOutcomes } : {};
const metadataSnapshot =
(usedPreloadedCatalog ? params.catalogProjector?.metadataSnapshot : undefined) ??
getCurrentPluginMetadataSnapshot({
@@ -606,6 +634,7 @@ export async function buildModelsListResult(
),
}),
routeVariants,
...(providerOutcomes?.length ? { providerOutcomes } : {}),
};
const inventoryProjector = createGatewayAgentModelCatalogProjector({
cfg,
@@ -624,6 +653,7 @@ export async function buildModelsListResult(
preserveUnknownAvailability: true,
...(capableProviders ? { apiKeyCapabilities: capableProviders } : {}),
}),
...outcomeProjection,
};
}
const defaultModel = resolveAgentEffectiveModelPrimary(cfg, agentId);
@@ -651,6 +681,7 @@ export async function buildModelsListResult(
workspaceDir,
routeResolverFactory: params.routeResolverFactory,
}),
providerOutcomes,
});
const models = await resolveLogicalVisibleModelCatalog({
cfg,
@@ -687,5 +718,6 @@ export async function buildModelsListResult(
evaluateEntry,
...(capableProviders ? { apiKeyCapabilities: capableProviders } : {}),
}),
...outcomeProjection,
};
}
+28
View File
@@ -56,6 +56,7 @@ function requestModelsList(params: {
workspaceDir?: string;
}) => Promise<Array<Record<string, unknown>>>;
reqId?: string;
agentId?: string;
includeProviderCapabilities?: boolean;
}) {
const respond = params.respond ?? vi.fn();
@@ -71,11 +72,13 @@ function requestModelsList(params: {
method: "models.list",
params: {
view: params.view,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.includeProviderCapabilities ? { includeProviderCapabilities: true } : {}),
},
},
params: {
view: params.view,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.includeProviderCapabilities ? { includeProviderCapabilities: true } : {}),
},
respond: respond as RespondFn,
@@ -106,6 +109,31 @@ function requestModelsList(params: {
}
describe("models.list", () => {
it("loads the requested agent catalog", async () => {
const loadGatewayModelCatalog = vi.fn(async () => [
{ id: "writer-model", name: "Writer Model", provider: "test" },
]);
const { request } = requestModelsList({
view: "configured",
agentId: "writer",
runtimeConfig: {
agents: {
list: [
{ id: "main", default: true },
{ id: "writer", model: "test/writer-model" },
],
},
},
loadGatewayModelCatalog,
});
await request;
expect(loadGatewayModelCatalog).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "writer" }),
);
});
it("uses the replacement owner config for the whole catalog projection", async () => {
const initialConfig = {
agents: { defaults: { models: { "test/old": {} } } },
+6 -1
View File
@@ -15,6 +15,11 @@ export const modelsHandlers: GatewayRequestHandlers = {
if (!assertValidParams(params, validateModelsListParams, "models.list", respond)) {
return;
}
respond(true, await buildModelsListResult({ context, params }), undefined);
const agentId = typeof params.agentId === "string" ? params.agentId : undefined;
respond(
true,
await buildModelsListResult({ context, params, ...(agentId ? { agentId } : {}) }),
undefined,
);
},
};
+20
View File
@@ -156,6 +156,26 @@ describe("gateway prepared model catalog", () => {
});
});
it("carries provider outcomes through the gateway owner projection", async () => {
const config = ownerConfig();
const modelCatalog: ModelCatalogSnapshot = {
entries: [],
routeVariants: [],
providerOutcomes: [{ provider: "openai", status: "auth-rejected" }],
};
const loadPublishedPreparedModelCatalogOwnerSnapshot = vi.fn(async () =>
ownerSnapshot(config, modelCatalog),
);
await expect(
loadGatewayModelCatalogSnapshot({
getConfig: () => config,
loadPublishedPreparedModelCatalogOwnerSnapshot,
readOnly: false,
}),
).resolves.toMatchObject({ providerOutcomes: modelCatalog.providerOutcomes });
});
it("does not hide lifecycle publication failures behind stale data", async () => {
const error = new Error("generation failed");
const loadPublishedPreparedModelCatalogOwnerSnapshot = vi.fn(async () => {
+5 -1
View File
@@ -22,7 +22,11 @@ import { pruneMapToMaxSize } from "../infra/map-size.js";
import type { ProviderPlugin } from "../plugins/types.js";
import type { ModelProviderConfig } from "./provider-model-shared.js";
export type { ProviderCatalogContext, ProviderCatalogResult } from "../plugins/types.js";
export type {
ProviderCatalogContext,
ProviderCatalogOutcome,
ProviderCatalogResult,
} from "../plugins/types.js";
export {
buildPairedProviderApiKeyCatalog,
+6
View File
@@ -0,0 +1,6 @@
export type ProviderCatalogOutcome = {
provider: string;
/** Auth profile tested by discovery; omission means provider-wide auth. */
profileId?: string;
status: "ready" | "auth-rejected" | "unavailable";
};
+38 -1
View File
@@ -6,7 +6,13 @@ import {
isRecordWithoutThrowing,
readRecordValue,
} from "../shared/safe-record.js";
import type { ProviderCatalogResult } from "./types.js";
import type { ProviderCatalogOutcome, ProviderCatalogResult } from "./types.js";
const PROVIDER_CATALOG_OUTCOME_STATUSES = new Set<ProviderCatalogOutcome["status"]>([
"ready",
"auth-rejected",
"unavailable",
]);
const MODEL_PROVIDER_CONFIG_KEYS = [
"baseUrl",
@@ -69,6 +75,37 @@ export function copyProviderCatalogResultProjection(
return providers.length > 0 ? { kind: "providers", providers } : { kind: "empty" };
}
/** Copies valid, secret-free provider outcomes out of a catalog hook result. */
export function copyProviderCatalogOutcomes(
result: ProviderCatalogResult,
): ProviderCatalogOutcome[] {
return copyArrayEntries(readRecordValue(result, "outcomes")).flatMap((entry) => {
if (!isRecordWithoutThrowing(entry)) {
return [];
}
const provider = readRecordValue(entry, "provider");
const profileId = readRecordValue(entry, "profileId");
const status = readRecordValue(entry, "status");
if (
typeof provider !== "string" ||
provider.trim().length === 0 ||
(profileId !== undefined &&
(typeof profileId !== "string" || profileId.trim().length === 0)) ||
typeof status !== "string" ||
!PROVIDER_CATALOG_OUTCOME_STATUSES.has(status as ProviderCatalogOutcome["status"])
) {
return [];
}
return [
{
provider: provider.trim(),
...(typeof profileId === "string" ? { profileId: profileId.trim() } : {}),
status: status as ProviderCatalogOutcome["status"],
},
];
});
}
/** Copies provider catalog result entries, using providerId for single-provider results. */
export function copyProviderCatalogResultEntries(params: {
providerId: string;
+11 -2
View File
@@ -5,6 +5,9 @@ import type {
import type { ModelCatalogEntry } from "../agents/model-catalog.types.js";
import type { ModelProviderConfig } from "../config/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ProviderCatalogOutcome } from "./provider-catalog-outcome.js";
export type { ProviderCatalogOutcome } from "./provider-catalog-outcome.js";
export type ProviderCatalogOrder = "simple" | "profile" | "paired" | "late";
@@ -32,8 +35,14 @@ export type ProviderCatalogContext = {
};
export type ProviderCatalogResult =
| { provider: ModelProviderConfig }
| { providers: Record<string, ModelProviderConfig> }
| {
provider: ModelProviderConfig;
outcomes?: readonly ProviderCatalogOutcome[];
}
| {
providers: Record<string, ModelProviderConfig>;
outcomes?: readonly ProviderCatalogOutcome[];
}
| null
| undefined;
+46
View File
@@ -6,6 +6,7 @@ import type { ModelDefinitionConfig, ModelProviderConfig } from "../config/types
import {
groupPluginDiscoveryProvidersByOrder,
normalizePluginDiscoveryResult,
runProviderCatalog,
runProviderStaticCatalog,
} from "./provider-discovery.js";
import * as providerDiscoveryModule from "./provider-discovery.js";
@@ -133,6 +134,51 @@ describe("groupPluginDiscoveryProvidersByOrder", () => {
});
});
describe("runProviderCatalog", () => {
it("carries explicit provider-owned catalog outcomes across an async hook", async () => {
const outcomes: Array<{
provider: string;
profileId?: string;
status: "ready" | "auth-rejected" | "unavailable";
}> = [];
const provider: ProviderPlugin = {
id: "openai",
label: "OpenAI",
auth: [],
catalog: {
run: async () => {
await Promise.resolve();
return {
providers: {},
outcomes: [
{
provider: "openai",
profileId: "openai:chatgpt",
status: "auth-rejected",
},
],
};
},
},
};
await runProviderCatalog({
provider,
config: {},
agentDir: "/tmp/openclaw-agent",
workspaceDir: "/tmp/openclaw-workspace",
env: {},
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }),
reportCatalogOutcome: (outcome) => outcomes.push(outcome),
});
expect(outcomes).toEqual([
{ provider: "openai", profileId: "openai:chatgpt", status: "auth-rejected" },
]);
});
});
describe("normalizePluginDiscoveryResult", () => {
const cases: NormalizePluginDiscoveryResultCase[] = [
{
+16 -3
View File
@@ -4,7 +4,11 @@ import type { ModelProviderConfig } from "../config/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import type { PluginMetadataRegistryView } from "./plugin-metadata-snapshot.types.js";
import { copyProviderCatalogResultProjection } from "./provider-catalog-result.js";
import {
copyProviderCatalogOutcomes,
copyProviderCatalogResultProjection,
} from "./provider-catalog-result.js";
import type { ProviderCatalogOutcome } from "./provider-catalog.types.js";
import type { ProviderCatalogOrder, ProviderPlugin } from "./types.js";
const DISCOVERY_ORDER: readonly ProviderCatalogOrder[] = ["simple", "profile", "paired", "late"];
@@ -140,7 +144,7 @@ export function normalizePluginDiscoveryResult(params: {
return normalized;
}
export function runProviderCatalog(params: {
export async function runProviderCatalog(params: {
provider: ProviderPlugin;
config: OpenClawConfig;
agentDir?: string;
@@ -160,8 +164,13 @@ export function runProviderCatalog(params: {
source: "env" | "profile" | "none";
profileId?: string;
};
reportCatalogOutcome?: (outcome: ProviderCatalogOutcome) => void;
}) {
return resolveProviderCatalogHook(params.provider)?.run({
const hook = resolveProviderCatalogHook(params.provider);
if (!hook) {
return undefined;
}
const result = await hook.run({
config: params.config,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
@@ -169,6 +178,10 @@ export function runProviderCatalog(params: {
resolveProviderApiKey: params.resolveProviderApiKey,
resolveProviderAuth: params.resolveProviderAuth,
});
for (const outcome of copyProviderCatalogOutcomes(result)) {
params.reportCatalogOutcome?.(outcome);
}
return result;
}
export function runProviderStaticCatalog(params: { provider: ProviderPlugin }) {
+3
View File
@@ -821,6 +821,9 @@ export type ModelCatalogEntry = {
apiKeySupported?: boolean;
};
export type ModelCatalogProviderOutcome =
import("../../../packages/gateway-protocol/src/schema/agents-models-skills.js").ModelCatalogProviderOutcome;
export type ToolCatalogProfile =
import("../../../packages/gateway-protocol/src/schema.js").ToolCatalogProfile;
export type ToolsCatalogResult =
+29 -2
View File
@@ -160,7 +160,9 @@ suite.define(() => {
await expect.poll(() => model.isVisible()).toBe(true);
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
expect(await gateway.getRequests("models.list")).toHaveLength(0);
const modelRequests = await gateway.getRequests("models.list");
expect(modelRequests).toHaveLength(1);
expect(modelRequests[0]?.params).toEqual({ view: "configured" });
await expect.poll(() => contextUsage.isVisible()).toBe(true);
await expect.poll(() => usage.isVisible()).toBe(false);
await expect.poll(() => settings.isVisible()).toBe(true);
@@ -703,6 +705,14 @@ suite.define(() => {
},
],
},
"models.list": {
cases: [
{
match: { agentId: "other", view: "configured" },
response: { models: [otherModel] },
},
],
},
"sessions.list": {
count: 2,
defaults: {
@@ -746,6 +756,9 @@ suite.define(() => {
activeComposer().locator('[data-chat-model-option="openai/work-model"]').count(),
)
.toBe(1);
expect(await gateway.getRequests("models.list")).toEqual([
expect.objectContaining({ params: { view: "configured" } }),
]);
await navigateToControlUiSession(page, "agent:other:main");
const startupRequests = await gateway.getRequests("chat.startup");
@@ -767,6 +780,10 @@ suite.define(() => {
activeComposer().locator('[data-chat-model-option="openai/work-model"]').count(),
)
.toBe(0);
expect(await gateway.getRequests("models.list")).toEqual([
expect.objectContaining({ params: { view: "configured" } }),
expect.objectContaining({ params: { agentId: "other", view: "configured" } }),
]);
});
});
@@ -811,6 +828,14 @@ suite.define(() => {
sessionId: "control-ui-e2e-session",
thinkingLevel: null,
},
"models.list": {
cases: [
{
match: { agentId: "work", view: "configured" },
response: { models: [] },
},
],
},
},
});
@@ -830,7 +855,9 @@ suite.define(() => {
)
.not.toContain("GPT Default");
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
expect(await gateway.getRequests("models.list")).toHaveLength(0);
expect(await gateway.getRequests("models.list")).toEqual([
expect.objectContaining({ params: { agentId: "work", view: "configured" } }),
]);
});
});
});
+9 -1
View File
@@ -334,6 +334,14 @@ suite.define(() => {
defaultAgentId: "ops",
deferredMethods: ["chat.startup"],
historyMessages: [],
models: [
{
available: true,
id: "startup-model",
name: "Startup Model",
provider: "openai",
},
],
sessionKey: "global",
});
@@ -438,7 +446,7 @@ suite.define(() => {
commands: (await gateway.getRequests("commands.list")).length,
metadata: (await gateway.getRequests("chat.metadata")).length,
models: (await gateway.getRequests("models.list")).length,
}).toEqual({ commands: 0, metadata: 0, models: 0 });
}).toEqual({ commands: 0, metadata: 0, models: 1 });
expect(await gateway.getRequests("agents.list")).toHaveLength(0);
} finally {
await suite.closeBrowserContext(context);
+7 -13
View File
@@ -58,7 +58,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
await server?.close();
});
it("surfaces credential-only model setup as the primary action", async () => {
it("surfaces rejected provider credentials as the primary setup action", async () => {
const context = await browser.newContext({
colorScheme: "dark",
locale: "en-US",
@@ -84,15 +84,8 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
{
match: { view: "all", includeProviderCapabilities: true },
response: {
models: [
{
id: "gpt-5.6",
name: "GPT-5.6",
provider: "openai",
available: false,
apiKeySupported: true,
},
],
models: [],
providerOutcomes: [{ provider: "openai", status: "auth-rejected" }],
},
},
],
@@ -128,8 +121,9 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
await expect
.poll(async () => readiness.textContent())
.toContain("Connect a verified AI model");
await expect.poll(async () => readiness.textContent()).toContain("No models available");
await expect.poll(async () => openaiCard.textContent()).toContain("Signed in");
await expect.poll(async () => readiness.textContent()).toContain("Model required");
await expect.poll(async () => openaiCard.textContent()).toContain("Credentials rejected");
await expect.poll(async () => openaiCard.textContent()).not.toContain("Signed in");
expect(await page.locator(".model-providers__defaults").count()).toBe(0);
if (recordVisuals) {
@@ -159,7 +153,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
await page.setViewportSize({ height: 1000, width: 1440 });
}
await readiness.getByRole("button", { name: "Choose another provider" }).click();
await readiness.getByRole("button", { name: "Connect a verified AI model" }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/model-setup");
} finally {
await context.close();
+1
View File
@@ -3945,6 +3945,7 @@ export const en: TranslationMap = {
expired: "Expired",
missing: "Not signed in",
apiKey: "API key",
denied: "Credentials rejected",
},
expiresIn: "Credential expires in {time}",
models: "{count} models",
+4 -7
View File
@@ -170,7 +170,7 @@ describe("chat-model-select-state", () => {
expect(resolveChatModelSelectState(state).currentOverride).toBe("deepseek/deepseek-chat");
});
it("preserves already-qualified active-session models when the provider is stale and the catalog is empty", () => {
it("keeps the active model value but does not synthesize a picker option when the catalog is empty", () => {
const state = createChatModelState({
sessionsResult: createSessionsListResult({
model: "openai/gpt-5-mini",
@@ -180,15 +180,12 @@ describe("chat-model-select-state", () => {
const resolved = resolveChatModelSelectState(state);
expect(resolved.currentOverride).toBe("openai/gpt-5-mini");
expect(resolved.options).toEqual([
{ value: "openai/gpt-5-mini", label: "gpt-5-mini · openai" },
{ value: "openai/gpt-5", label: "gpt-5 · openai" },
]);
expect(resolved.defaultSelectable).toBe(false);
expect(resolved.options).toEqual([]);
});
it("does not synthesize configured models when options are restricted to catalog results", () => {
it("does not synthesize configured models outside catalog results", () => {
const state = createChatModelState({
restrictOptionsToCatalog: true,
sessionsResult: createSessionsListResult({
model: "openai/gpt-5-mini",
modelProvider: "openai",
+8 -65
View File
@@ -22,7 +22,6 @@ type ChatModelSelectStateInput = {
agentDefaultModel?: string;
chatModelCatalog: ModelCatalogEntry[];
modelOverrides: Readonly<Record<string, string | null | undefined>>;
restrictOptionsToCatalog?: boolean;
sessionKey: string;
sessionsResult: SessionsListResult | null;
};
@@ -120,31 +119,6 @@ function normalizeChatModelAvailabilityKey(value: string): string {
)}`;
}
function buildUnavailableChatModelValues(
catalog: ModelCatalogEntry[],
displayLookup: ReturnType<typeof buildCatalogDisplayLookup>,
): Set<string> {
const availableValues = new Set(
catalog
.filter((entry) => entry.available !== false)
.map((entry) =>
normalizeChatModelAvailabilityKey(
buildChatModelOptionFromLookup(entry, displayLookup).value,
),
),
);
return new Set(
catalog
.filter((entry) => entry.available === false)
.map((entry) =>
normalizeChatModelAvailabilityKey(
buildChatModelOptionFromLookup(entry, displayLookup).value,
),
)
.filter((value) => !availableValues.has(value)),
);
}
function resolveAvailableChatModelValue(
value: string,
catalog: ModelCatalogEntry[],
@@ -179,22 +153,13 @@ function resolveAvailableChatModelValue(
function buildChatModelOptions(
catalog: ModelCatalogEntry[],
displayLookup: ReturnType<typeof buildCatalogDisplayLookup>,
currentOverride: string,
defaultModel: string,
restrictOptionsToCatalog: boolean,
): ChatModelSelectOption[] {
const seen = new Set<string>();
const options: ChatModelSelectOption[] = [];
const unavailableValues = buildUnavailableChatModelValues(catalog, displayLookup);
const addOption = (value: string, label?: string) => {
pushUniqueTrimmedSelectOption(options, seen, value, (trimmed) => label ?? trimmed);
};
const addAvailableOption = (value: string, label?: string) => {
if (!unavailableValues.has(normalizeChatModelAvailabilityKey(value))) {
addOption(value, label);
}
};
for (const entry of catalog) {
if (entry.available === false) {
@@ -203,19 +168,6 @@ function buildChatModelOptions(
const option = buildChatModelOptionFromLookup(entry, displayLookup);
addOption(option.value, option.label);
}
if (!restrictOptionsToCatalog && currentOverride) {
addAvailableOption(
currentOverride,
formatCatalogChatModelDisplayFromLookup(currentOverride, displayLookup),
);
}
if (!restrictOptionsToCatalog && defaultModel) {
addAvailableOption(
defaultModel,
formatCatalogChatModelDisplayFromLookup(defaultModel, displayLookup),
);
}
return options;
}
@@ -237,24 +189,15 @@ export function resolveChatModelSelectState(
displayLookup,
);
const defaultDisplay = formatCatalogChatModelDisplayFromLookup(defaultModel, displayLookup);
const unavailableValues = buildUnavailableChatModelValues(catalog, displayLookup);
const options = buildChatModelOptions(
catalog,
displayLookup,
currentOverride,
defaultModel,
state.restrictOptionsToCatalog === true,
const options = buildChatModelOptions(catalog, displayLookup);
const defaultSelectable = Boolean(
defaultModel &&
options.some(
(option) =>
normalizeChatModelAvailabilityKey(option.value) ===
normalizeChatModelAvailabilityKey(defaultModel),
),
);
const defaultSelectable = state.restrictOptionsToCatalog
? Boolean(
defaultModel &&
options.some(
(option) =>
normalizeChatModelAvailabilityKey(option.value) ===
normalizeChatModelAvailabilityKey(defaultModel),
),
)
: !defaultModel || !unavailableValues.has(normalizeChatModelAvailabilityKey(defaultModel));
return {
currentOverride,
+14 -4
View File
@@ -358,7 +358,7 @@ describe("refreshChat", () => {
expect(requestUpdate).not.toHaveBeenCalled();
});
it("uses startup-shipped metadata without requesting chat.metadata", async () => {
it("uses explicit model discovery after startup metadata", async () => {
const startup = createDeferred<unknown>();
const host = makeChatHost({
hello: {
@@ -366,6 +366,16 @@ describe("refreshChat", () => {
} as TestChatHost["hello"],
requestHandlers: {
"chat.startup": () => startup.promise,
"models.list": {
models: [
{
available: true,
id: "live-model",
name: "Live Model",
provider: "openai",
},
],
},
},
});
@@ -397,14 +407,14 @@ describe("refreshChat", () => {
expect(host.chatModelCatalog).toEqual([
{
available: true,
id: "startup-model",
name: "Startup Model",
id: "live-model",
name: "Live Model",
provider: "openai",
},
]),
);
expect(host.request).not.toHaveBeenCalledWith("chat.metadata", expect.anything());
expect(host.request).not.toHaveBeenCalledWith("models.list", expect.anything());
expect(host.request).toHaveBeenCalledWith("models.list", { view: "configured" });
expect(host.request).not.toHaveBeenCalledWith("commands.list", expect.anything());
});
+20 -13
View File
@@ -48,6 +48,7 @@ type ChatMetadataRequest = {
type ChatMetadataRefreshOptions = {
preserveModelCatalogOnFallback?: boolean;
refreshModelCatalog?: boolean;
requestVersion?: number;
};
@@ -175,8 +176,17 @@ function ownsChatMetadataRequest(request: ChatMetadataRequest): boolean {
);
}
async function refreshCompatibilityModelCatalog(request: ChatMetadataRequest) {
const models = await loadModels(request.client);
async function refreshCompatibilityModelCatalog(
request: ChatMetadataRequest,
opts?: { refresh?: boolean },
) {
const agentId = canUseCompatibilityModelCatalog(request.host, request.agentId)
? undefined
: request.agentId?.trim() || undefined;
const models = await loadModels(request.client, {
...(agentId ? { agentId } : {}),
...(opts?.refresh ? { refresh: true } : {}),
});
if (ownsChatMetadataRequest(request)) {
request.host.chatModelCatalog = models;
}
@@ -212,13 +222,10 @@ async function refreshMissingChatMetadata(
const modelsRefresh =
applied.models || preserveModels
? Promise.resolve()
: canUseCompatibilityModelCatalog(request.host, request.agentId)
? refreshCompatibilityModelCatalog(request)
: Promise.resolve().then(() => {
if (ownsChatMetadataRequest(request)) {
request.host.chatModelCatalog = [];
}
});
: refreshCompatibilityModelCatalog(
request,
opts?.refreshModelCatalog ? { refresh: true } : undefined,
);
await Promise.allSettled([commandsRefresh, modelsRefresh]);
}
@@ -435,11 +442,11 @@ export function refreshPageChat(host: ChatPageHost, opts?: ChatRefreshOptions) {
return;
}
rememberChatMetadata(client, agentId, metadata);
const applied = applyChatMetadataResult(host, 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 });
if (!applied.models || !applied.commands) {
// chat.startup owns the first metadata load. Fill only omitted fields here;
// a parallel chat.metadata request would repeat the same catalog discovery.
await refreshMissingChatMetadata(request, applied);
await refreshMissingChatMetadata(request, applied, { refreshModelCatalog: true });
}
} finally {
if (ownsChatMetadataRequest(request)) {
+12 -4
View File
@@ -1625,8 +1625,14 @@ describe("refreshChatMetadata", () => {
expect(request).toHaveBeenCalledTimes(1);
});
it("does not load unscoped compatibility models for a non-default agent", async () => {
const request = vi.fn(async (method: string) => {
it("loads agent-scoped compatibility models for a non-default agent", async () => {
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "models.list") {
expect(params).toEqual({ view: "configured", agentId: "work" });
return {
models: [{ id: "work-model", name: "Work Model", provider: "openai" }],
};
}
expect(method).toBe("commands.list");
return { commands: [] };
});
@@ -1638,9 +1644,11 @@ describe("refreshChatMetadata", () => {
await refreshChatMetadata(state);
expect(state.chatModelCatalog).toEqual([]);
expect(state.chatModelCatalog).toEqual([
{ id: "work-model", name: "Work Model", provider: "openai" },
]);
expect(state.chatModelsLoading).toBe(false);
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledTimes(2);
});
it("does not apply compatibility commands after switching agents", async () => {
+15 -1
View File
@@ -5151,6 +5151,20 @@ describe("chat model controls", () => {
expect(modelSelect.getAttribute("aria-disabled")).toBe("true");
});
it("shows an empty state instead of a configured default when no usable models exist", () => {
const { state } = createChatHeaderState({
model: "gpt-5.6-sol",
modelProvider: "openai",
models: [],
});
const container = renderModelControls(state);
expect(container.querySelectorAll("[data-chat-model-option]")).toHaveLength(0);
expect(
container.querySelector('[data-chat-model-catalog-state="ready"]')?.textContent,
).toContain("No models available");
});
it("applies a model selection immediately", () => {
const { state } = createOpenAiHeaderState();
const onModelSelect = vi.fn(async () => true);
@@ -5450,7 +5464,7 @@ describe("chat model controls", () => {
container.querySelectorAll<HTMLButtonElement>("[data-chat-model-provider]"),
);
const providerLabels = providerButtons.map((button) => button.textContent?.trim());
expect(providerLabels).toEqual(["OpenAI", "Google", "OpenCode", "Moonshot AI"]);
expect(providerLabels).toEqual(["Google", "OpenCode", "Moonshot AI"]);
expect(new Set(providerLabels).size).toBe(providerLabels.length);
expect(
container.querySelector('[data-chat-model-provider-group="google"]')?.textContent,
@@ -157,7 +157,6 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
agentDefaultModel: props.agentDefaultModel,
chatModelCatalog: props.modelCatalog,
modelOverrides: props.modelOverrides ?? {},
restrictOptionsToCatalog: props.modelCatalogState !== undefined,
sessionKey: props.sessionKey,
sessionsResult: props.sessionsResult,
});
@@ -251,14 +250,16 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
? thinking.defaultLabel
: (thinking.options.find((entry) => entry.value === thinking.currentOverride)?.label ??
thinking.currentOverride);
const managedCatalog = props.modelCatalogState;
const managedCatalog = props.modelCatalogState ?? {
hasSnapshot: !props.modelsLoading,
status: props.modelsLoading ? ("loading" as const) : ("ready" as const),
};
const catalogLoadingWithoutSnapshot =
managedCatalog !== undefined &&
!managedCatalog.hasSnapshot &&
["idle", "loading", "refreshing"].includes(managedCatalog.status);
const catalogErrorWithoutSnapshot =
managedCatalog?.status === "error" && !managedCatalog.hasSnapshot;
const catalogSnapshotEmpty = managedCatalog?.hasSnapshot === true && modelOptions.length === 0;
managedCatalog.status === "error" && !managedCatalog.hasSnapshot;
const catalogSnapshotEmpty = managedCatalog.hasSnapshot && modelOptions.length === 0;
const catalogTriggerStatus = catalogLoadingWithoutSnapshot
? t("chat.modelControls.loadingModels")
: catalogErrorWithoutSnapshot
@@ -275,11 +276,11 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
commonDisabled ||
Boolean(props.modelMutationDisabledReason) ||
catalogLoadingWithoutSnapshot ||
(managedCatalog === undefined && Boolean(props.modelsLoading) && selectOptions.length === 0);
(Boolean(props.modelsLoading) && selectOptions.length === 0);
const thinkingDisabled =
commonDisabled ||
effortMutationDisabled ||
(managedCatalog !== undefined && !managedCatalog.hasSnapshot) ||
!managedCatalog.hasSnapshot ||
(thinking.options.length === 0 && thinking.currentOverride === "");
const showFastMode = props.showFastMode !== false;
const effortDisabled =
+25
View File
@@ -32,6 +32,31 @@ describe("loadModels", () => {
expect(first).toBe(second);
});
it("keeps model catalogs scoped by agent", async () => {
const request = vi.fn(async (_method: string, params: { agentId?: string }) => ({
models: [
{
id: params.agentId ?? "default-model",
name: params.agentId ?? "Default Model",
provider: "openai",
},
],
}));
const client = { request } as unknown as GatewayBrowserClient;
const writer = await loadModels(client, { agentId: "writer" });
const reviewer = await loadModels(client, { agentId: "reviewer" });
await loadModels(client, { agentId: "writer" });
expect(writer[0]?.id).toBe("writer");
expect(reviewer[0]?.id).toBe("reviewer");
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenCalledWith("models.list", {
view: "configured",
agentId: "writer",
});
});
it("keeps a late stale response from clobbering a fresher refresh result", async () => {
const stale = [{ id: "stale", name: "Stale", provider: "openai" }];
const fresh = [{ id: "fresh", name: "Fresh", provider: "openai" }];
+25 -8
View File
@@ -10,13 +10,24 @@ type ModelCatalogCacheEntry = {
inFlight?: Promise<ModelCatalogEntry[]>;
};
const modelCatalogCache = new WeakMap<GatewayBrowserClient, ModelCatalogCacheEntry>();
const modelCatalogCache = new WeakMap<GatewayBrowserClient, Map<string, ModelCatalogCacheEntry>>();
function modelCatalogCacheFor(client: GatewayBrowserClient): Map<string, ModelCatalogCacheEntry> {
let cache = modelCatalogCache.get(client);
if (!cache) {
cache = new Map();
modelCatalogCache.set(client, cache);
}
return cache;
}
export async function loadModels(
client: GatewayBrowserClient,
opts?: { refresh?: boolean },
opts?: { agentId?: string; refresh?: boolean },
): Promise<ModelCatalogEntry[]> {
const cached = modelCatalogCache.get(client);
const cache = modelCatalogCacheFor(client);
const cacheKey = opts?.agentId?.trim() ?? "";
const cached = cache.get(cacheKey);
const now = Date.now();
if (!opts?.refresh && cached?.models && cached.expiresAt > now) {
return cached.models;
@@ -28,11 +39,15 @@ export async function loadModels(
// The cache write happens here, gated on inFlight identity: a refresh call
// replaces inFlight, so an older request resolving late cannot clobber the
// fresher result with pre-mutation catalog data.
const inFlight: Promise<ModelCatalogEntry[]> = requestModels(client, cached?.models)
const inFlight: Promise<ModelCatalogEntry[]> = requestModels(
client,
cached?.models,
cacheKey || undefined,
)
.then((result) => {
const latest = modelCatalogCache.get(client);
const latest = cache.get(cacheKey);
if (!latest || latest.inFlight === inFlight) {
modelCatalogCache.set(client, {
cache.set(cacheKey, {
expiresAt: result.fresh ? Date.now() + MODEL_CATALOG_CACHE_TTL_MS : 0,
models: result.models,
});
@@ -40,12 +55,12 @@ export async function loadModels(
return result.models;
})
.finally(() => {
const latest = modelCatalogCache.get(client);
const latest = cache.get(cacheKey);
if (latest?.inFlight === inFlight) {
delete latest.inFlight;
}
});
modelCatalogCache.set(client, {
cache.set(cacheKey, {
expiresAt: cached?.expiresAt ?? 0,
models: cached?.models ?? [],
inFlight,
@@ -63,10 +78,12 @@ export function applyModelCatalogResult(models: unknown): ModelCatalogEntry[] |
async function requestModels(
client: GatewayBrowserClient,
fallback: ModelCatalogEntry[] | undefined,
agentId: string | undefined,
): Promise<{ models: ModelCatalogEntry[]; fresh: boolean }> {
try {
const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {
view: "configured",
...(agentId ? { agentId } : {}),
});
return { models: result?.models ?? [], fresh: true };
} catch {
+15
View File
@@ -56,6 +56,21 @@ describe("buildModelProviderCards", () => {
expect(cards[1]).toMatchObject({ modelCount: 1, availableModelCount: 0 });
});
it("keeps provider-owned catalog failures when no model rows are usable", () => {
const cards = buildModelProviderCards({
...EMPTY_INPUT,
providerOutcomes: [{ provider: "openai", status: "auth-rejected" }],
});
expect(cards).toHaveLength(1);
expect(firstCard(cards)).toMatchObject({
id: "openai",
catalogStatus: "auth-rejected",
modelCount: 0,
availableModelCount: 0,
});
});
it("propagates explicit API-key capability onto provider cards", () => {
const cards = buildModelProviderCards({
...EMPTY_INPUT,
+23
View File
@@ -13,6 +13,7 @@ import type {
ModelAuthStatusProfile,
ModelAuthStatusResult,
ModelCatalogEntry,
ModelCatalogProviderOutcome,
} from "../../api/types.ts";
import { providerDisplayLabel } from "../../components/provider-icon.ts";
@@ -53,6 +54,7 @@ export type ModelProviderCard = {
hasConfigApiKey: boolean;
modelCount: number;
availableModelCount: number;
catalogStatus?: ModelCatalogProviderOutcome["status"];
/** Live provider-reported usage (quota windows, billing, cost history). */
usage?: ProviderUsageSnapshot;
/** Locally-computed session spend for the requested window. */
@@ -63,6 +65,7 @@ type ModelProviderCardsInput = {
authStatus: ModelAuthStatusResult | null;
models: ModelCatalogEntry[] | null;
catalogModels?: ModelCatalogEntry[] | null;
providerOutcomes?: ModelCatalogProviderOutcome[];
configProviderIds?: string[] | null;
configApiKeyProviderIds?: string[] | null;
configProviderAuthModes?: Record<string, string> | null;
@@ -221,6 +224,25 @@ export function buildModelProviderCards(input: ModelProviderCardsInput): ModelPr
}
}
const outcomeSeverity: ReadonlyArray<ModelCatalogProviderOutcome["status"]> = [
"auth-rejected",
"unavailable",
"ready",
];
for (const outcome of input.providerOutcomes ?? []) {
const id = canonicalProviderId(outcome.provider);
if (!id) {
continue;
}
const card = ensureDraft(drafts, id, providerDisplayLabel(id)).card;
if (
!card.catalogStatus ||
outcomeSeverity.indexOf(outcome.status) < outcomeSeverity.indexOf(card.catalogStatus)
) {
card.catalogStatus = outcome.status;
}
}
for (const entry of input.models ?? []) {
const id = canonicalProviderId(entry.provider);
if (!id) {
@@ -325,6 +347,7 @@ export function buildModelProviderCards(input: ModelProviderCardsInput): ModelPr
draft.hasUsageSnapshot ||
Boolean(draft.card.usage) ||
draft.card.modelCount > 0 ||
Boolean(draft.card.catalogStatus) ||
(draft.card.localCost?.totalTokens ?? 0) > 0,
)
.map((draft) => {
+18 -5
View File
@@ -4,7 +4,12 @@
import type { UsageSummary } from "../../../../src/infra/provider-usage.types.js";
import type { SessionModelUsage } from "../../../../src/infra/session-cost-usage.types.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ConfigSnapshot, ModelAuthStatusResult, ModelCatalogEntry } from "../../api/types.ts";
import type {
ConfigSnapshot,
ModelAuthStatusResult,
ModelCatalogEntry,
ModelCatalogProviderOutcome,
} from "../../api/types.ts";
import { resolveEditableSnapshotConfig } from "../../lib/config/index.ts";
import {
formatMissingOperatorReadScopeMessage,
@@ -21,6 +26,7 @@ export type ModelProvidersData = {
authStatus: ModelAuthStatusResult | null;
models: ModelCatalogEntry[] | null;
catalogModels: ModelCatalogEntry[] | null;
providerOutcomes: ModelCatalogProviderOutcome[];
config: Record<string, unknown> | null;
providerUsage: UsageSummary | null;
costByProvider: SessionModelUsage[] | null;
@@ -28,10 +34,16 @@ export type ModelProvidersData = {
error: string | null;
};
type ModelProvidersCatalogResult = {
models: ModelCatalogEntry[];
providerOutcomes?: ModelCatalogProviderOutcome[];
};
export const EMPTY_MODEL_PROVIDERS_DATA: ModelProvidersData = {
authStatus: null,
models: null,
catalogModels: null,
providerOutcomes: [],
config: null,
providerUsage: null,
costByProvider: null,
@@ -65,18 +77,18 @@ export async function loadModelProvidersData(
: params === undefined
? client.request<T>(method)
: client.request<T>(method, params);
const [authStatus, models, catalogModels, config, providerUsage, costByProvider] =
const [authStatus, models, catalogResult, config, providerUsage, costByProvider] =
await Promise.all([
loadModelAuthStatus(client, opts).then(
(result) => ({ ok: true as const, result }),
(error: unknown) => ({ ok: false as const, error }),
),
loadModels(client, opts).catch(() => null),
request<{ models?: ModelCatalogEntry[] }>("models.list", {
request<ModelProvidersCatalogResult>("models.list", {
view: "all",
includeProviderCapabilities: true,
})
.then((result) => result?.models ?? null)
.then((result) => result ?? null)
.catch(() => null),
request<ConfigSnapshot>("config.get", {})
.then((snapshot) => resolveEditableSnapshotConfig(snapshot))
@@ -95,7 +107,8 @@ export async function loadModelProvidersData(
authStatus:
authStatus.ok && Array.isArray(authStatus.result?.providers) ? authStatus.result : null,
models,
catalogModels,
catalogModels: catalogResult?.models ?? null,
providerOutcomes: catalogResult?.providerOutcomes ?? [],
config,
providerUsage,
costByProvider,
@@ -0,0 +1,80 @@
import { html, nothing } from "lit";
import { renderSettingsStatus } from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import type { ModelProviderAuthKind, ModelProviderCard } from "./data.ts";
const AUTH_KIND_I18N: Record<ModelProviderAuthKind, string> = {
ok: "modelProviders.status.ok",
expiring: "modelProviders.status.expiring",
expired: "modelProviders.status.expired",
missing: "modelProviders.status.missing",
"api-key": "modelProviders.status.apiKey",
};
const AUTH_KIND_STATUS: Record<ModelProviderAuthKind, "ok" | "warn" | "danger" | "muted"> = {
ok: "ok",
expiring: "warn",
expired: "danger",
missing: "danger",
"api-key": "muted",
};
function renderAuthStatus(card: ModelProviderCard) {
const auth = card.auth;
if (!auth) {
return nothing;
}
const label = t(AUTH_KIND_I18N[auth.kind]);
const detail = auth.expiryLabel
? t("modelProviders.expiresIn", { time: auth.expiryLabel })
: undefined;
return html`
<span title=${detail ?? label}>
${renderSettingsStatus({ kind: AUTH_KIND_STATUS[auth.kind], label })}
</span>
`;
}
function hasProviderCredentials(card: ModelProviderCard): boolean {
return card.hasConfigApiKey || Boolean(card.apiKey) || card.profiles.length > 0;
}
export function hasValidProviderSignIn(card: ModelProviderCard): boolean {
const catalogUnavailable =
card.catalogStatus === "auth-rejected" || card.catalogStatus === "unavailable";
return card.auth?.kind === "ok" && !catalogUnavailable;
}
export function renderProviderStatus(card: ModelProviderCard) {
if (
card.auth?.kind === "expired" ||
card.auth?.kind === "missing" ||
card.auth?.kind === "expiring"
) {
return renderAuthStatus(card);
}
if (card.catalogStatus === "auth-rejected") {
return renderSettingsStatus({ kind: "danger", label: t("modelProviders.status.denied") });
}
if (card.catalogStatus === "unavailable") {
return renderSettingsStatus({
kind: "warn",
label: t("common.failed"),
});
}
if (!hasProviderCredentials(card)) {
return renderAuthStatus(card);
}
if (card.availableModelCount > 0 && (hasValidProviderSignIn(card) || !card.auth)) {
return renderSettingsStatus({
kind: "ok",
label: t("modelProviders.status.ready"),
});
}
return hasValidProviderSignIn(card)
? renderSettingsStatus({
kind: "muted",
label: t("modelProviders.status.ok"),
})
: renderAuthStatus(card);
}
+22
View File
@@ -539,6 +539,28 @@ describe("renderModelProviders", () => {
expect(onOpenModelSetup).toHaveBeenCalledOnce();
});
it("does not present catalog-rejected credentials as signed in", () => {
const container = mount(
props({
cards: [
card({
auth: { kind: "ok", profileCount: 1 },
profiles: [{ profileId: "openai:chatgpt", type: "oauth", status: "ok" }],
catalogStatus: "auth-rejected",
modelCount: 0,
availableModelCount: 0,
}),
],
configuredModels: [],
defaultModels: { primary: "", fallbacks: [], utilityModel: null },
}),
);
const provider = container.querySelector('[data-provider-id="openai"]');
expect(text(provider)).toContain("Credentials rejected");
expect(text(provider)).not.toContain("Signed in");
});
it("does not report an unverified API key as ready", () => {
const container = mount(
props({
+1 -65
View File
@@ -25,12 +25,12 @@ import "../../styles/usage.css";
import type {
DefaultModelSelection,
ModelPickerEntry,
ModelProviderAuthKind,
ModelProviderCard,
ModelProviderLogoutTarget,
ProviderOption,
} from "./data.ts";
import { renderDefaultModels } from "./default-models-view.ts";
import { hasValidProviderSignIn, renderProviderStatus } from "./view-status.ts";
export type ModelProviderRowMessage = {
kind: "success" | "error";
@@ -194,70 +194,6 @@ function renderModelBehavior(props: ModelProvidersViewProps) {
`;
}
const AUTH_KIND_I18N: Record<ModelProviderAuthKind, string> = {
ok: "modelProviders.status.ok",
expiring: "modelProviders.status.expiring",
expired: "modelProviders.status.expired",
missing: "modelProviders.status.missing",
"api-key": "modelProviders.status.apiKey",
};
const AUTH_KIND_STATUS: Record<ModelProviderAuthKind, "ok" | "warn" | "danger" | "muted"> = {
ok: "ok",
expiring: "warn",
expired: "danger",
missing: "danger",
"api-key": "muted",
};
function renderAuthStatus(card: ModelProviderCard) {
const auth = card.auth;
if (!auth) {
return nothing;
}
const label = t(AUTH_KIND_I18N[auth.kind]);
const detail = auth.expiryLabel
? t("modelProviders.expiresIn", { time: auth.expiryLabel })
: undefined;
return html`
<span title=${detail ?? label}>
${renderSettingsStatus({ kind: AUTH_KIND_STATUS[auth.kind], label })}
</span>
`;
}
function hasProviderCredentials(card: ModelProviderCard): boolean {
return card.hasConfigApiKey || Boolean(card.apiKey) || card.profiles.length > 0;
}
function hasValidProviderSignIn(card: ModelProviderCard): boolean {
return card.auth?.kind === "ok";
}
function renderProviderStatus(card: ModelProviderCard) {
if (card.auth?.kind === "expired" || card.auth?.kind === "missing") {
return renderAuthStatus(card);
}
if (card.auth?.kind === "expiring") {
return renderAuthStatus(card);
}
if (!hasProviderCredentials(card)) {
return renderAuthStatus(card);
}
if (card.availableModelCount > 0 && (hasValidProviderSignIn(card) || !card.auth)) {
return renderSettingsStatus({
kind: "ok",
label: t("modelProviders.status.ready"),
});
}
return hasValidProviderSignIn(card)
? renderSettingsStatus({
kind: "muted",
label: t("modelProviders.status.ok"),
})
: renderAuthStatus(card);
}
function modelsText(card: ModelProviderCard): string | null {
if (card.modelCount === 0) {
return null;