From ad704f35c4facb6ed15f06ded25c2f91b9a44146 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 11 Aug 2026 09:13:44 -0700 Subject: [PATCH] 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 --- .../OpenClawProtocol/GatewayModels.swift | 10 +- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../agent-runtime.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../gateway-runtime.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- extensions/openai/openai-provider.test.ts | 70 +++++-- extensions/openai/openai-provider.ts | 121 ++++++++---- .../src/schema/agents-models-skills.test.ts | 21 ++- .../src/schema/agents-models-skills.ts | 13 ++ src/agents/model-catalog.types.ts | 3 + src/agents/models-config.plan.ts | 9 + ...providers.implicit.discovery-scope.test.ts | 17 ++ .../models-config.providers.implicit.ts | 7 + src/agents/models-config.ts | 14 +- src/agents/prepared-model-runtime.facts.ts | 34 +++- src/agents/prepared-model-runtime.test.ts | 22 +++ .../chat-metadata-runtime.test.ts | 68 +++++++ .../server-methods/chat-metadata-runtime.ts | 9 +- ...dels-list-result.provider-outcomes.test.ts | 173 ++++++++++++++++++ .../server-methods/models-list-result.ts | 60 ++++-- src/gateway/server-methods/models.test.ts | 28 +++ src/gateway/server-methods/models.ts | 7 +- src/gateway/server-model-catalog.test.ts | 20 ++ src/plugin-sdk/provider-catalog-shared.ts | 6 +- src/plugins/provider-catalog-outcome.ts | 6 + src/plugins/provider-catalog-result.ts | 39 +++- src/plugins/provider-catalog.types.ts | 13 +- src/plugins/provider-discovery.test.ts | 46 +++++ src/plugins/provider-discovery.ts | 19 +- ui/src/api/types.ts | 3 + ui/src/e2e/chat-composer-redesign.e2e.test.ts | 31 +++- ui/src/e2e/chat-flow.follow-ups.e2e.test.ts | 10 +- ui/src/e2e/model-providers.e2e.test.ts | 20 +- ui/src/i18n/locales/en.ts | 1 + ui/src/lib/chat/model-select-state.test.ts | 11 +- ui/src/lib/chat/model-select-state.ts | 73 +------- ui/src/pages/chat/chat-send.test.ts | 18 +- ui/src/pages/chat/chat-state-refresh.ts | 33 ++-- ui/src/pages/chat/chat-state.test.ts | 16 +- ui/src/pages/chat/chat-view.test.ts | 16 +- .../chat/components/chat-model-controls.ts | 15 +- ui/src/pages/chat/models.test.ts | 25 +++ ui/src/pages/chat/models.ts | 33 +++- ui/src/pages/model-providers/data.test.ts | 15 ++ ui/src/pages/model-providers/data.ts | 23 +++ ui/src/pages/model-providers/load.ts | 23 ++- ui/src/pages/model-providers/view-status.ts | 80 ++++++++ ui/src/pages/model-providers/view.test.ts | 22 +++ ui/src/pages/model-providers/view.ts | 66 +------ 63 files changed, 1104 insertions(+), 301 deletions(-) create mode 100644 src/gateway/server-methods/models-list-result.provider-outcomes.test.ts create mode 100644 src/plugins/provider-catalog-outcome.ts create mode 100644 ui/src/pages/model-providers/view-status.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 0f8e13215add..45c4c4f5ca65 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" } } diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 8662605eaec0..4db155d0c455 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 0bb8c23913c5..867a31cb4a2b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"bf900891a3c6ee746fdf5033882e8ca88bc03ae0b2c971bb5977c0f0db506506","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"69a94ce26778740b1851981f59c14d496423940977883f7c8f6894a5edc38a59","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index 58e9df0de487..405597317ed3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"6fe3155f10712bc5d001f1f69e502f3e1a0b4179bb5ed7100d0ca7df933085be","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"96db2cbf307a1fb22e9374a85a41da49ac538b0affe7ac40d9fcc99bcd646277","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index db59d8e3a75e..8c0fd9f807e1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"28666b0cf5d0bdf28648baffffcf1239e4d27727408c785fa06f5c271fc0843a","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"ba838118f480f9dda7e17f2aae734455bcfa9eaa04def1eecc44a953f30777cf","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index ac6f25b32791..0b4fed03a6e8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 223e8998ba3f..fb6c7b8c857a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"970ba9dd27f5e4d6a09b0ffec37264cdb3f745a5934e2cb8cc53eee78f2c176a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"018523b2135e2e6a56d13762780497ce3c5813bf685eab9ba6759fcf15c0bd75","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 8b486f113f57..b3e14b57aa2b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"574fa42cccdf71a38cb19a0662dacbcbd94486069f01fda496e950384897ddbf","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"dba5f7a7214801fad31471070bc490fab414f0789df068c2be354f0e55175a82","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 330c3caa63af..4bceb94d41e9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 382ae89272d0..7712f9c3687a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"db165d4f6bdb943dae0cb3015cafcb6d94d2482fff712eda98eb5442d418b095","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"cd81c27ce42d21ddd58698e008ca66bbb8ec70963bafcd3c22bb9270d7c71cdc","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index bcddf2069abb..363ffef35790 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"4b32922587dab4f465f85b7fea02e3b64d89ebea73a496501aa3c59fa7ef6337","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"97a1bec418d885b030c71fbca4d89f3e3a78f9b023ee24912316095c30e82c1b","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 1a66b3460962..c71ec1917847 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"579472d01b563dae9696e37050977dfdceb51df2df05e10df3e34304d1f9baf4","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"6ae2801252f1bed0c001ce3f68aa74228b6893ecba27cec90a13338cfc68d7d3","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index bd8498a4e797..0b5c3efcb8f1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 7a41c18b1de9..dd4e477039d6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"18782530d1c5bb66e086dd0177eb8ee52d152805aa68932a945fbc35178c3b03","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"13439d99b5059dbc96ce7419b37a7a2001754e0c527df3c7f19b2a118929aebe","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 5c6de3320dcf..44f78c8cb1a7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"3f92b4ce6954e9bbe85a7675f7ad000e01e6b34049523a9a3e38d23966ad959a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"b36fe0846bc4470774334d5e4c6fe4f17479683f5b308163fd78ef573e8c189a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 94915801c751..06083ab03058 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"60b7d6caebf369d35a367e431f77eb3f7e5437870ef1da5f024ea8c71217b1cb","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"55a9017c833c34ce3104399e1284599d65ade7bc410abf7053da3101cae9edab","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index d701478c5238..74aeba82dc6c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 3fcfbe8cd172..885241a26630 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"39bc2bdca81266a16eab23271664ad8cbffdf2fc9b7f0f1d04c98c2611c53c5d","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"58c25a8013bacfcd77146d79671c04cacc429e6d6abe883fc891277710b03a76","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index dab252bbcbc0..6703c18e6077 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"1600c91e3364cf9bd9cceaec9403f3c96503a145620e18a30502af3b53571e4c","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"51b619ddb708736ee882a8a5eb60922441f73cb69928f0a3a9be65d4c8894cfd","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index c8e94c4249f7..b8b29e675ba9 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -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 { +}): Promise { 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 { - 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 { - 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) => { diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index f261c0828710..aef942eabafa 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -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 { +): Promise { 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 { +}): Promise { 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] } : {}), }; }, }, diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts index 3c89d0a9a332..065828b2d838 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts @@ -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" }], + }, ); }); }); diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index a4744c73601e..56fd03ad0282 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -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; export type AgentsListResult = Static; export type ModelChoice = Static; export type ModelsListParams = Static; +export type ModelCatalogProviderOutcome = Static; export type ModelsListResult = Static; export type ModelsAuthStatusParams = Static; export type ModelsAuthLogoutParams = Static; diff --git a/src/agents/model-catalog.types.ts b/src/agents/model-catalog.types.ts index 9febe8e9ead9..bf5f766f75e6 100644 --- a/src/agents/model-catalog.types.ts +++ b/src/agents/model-catalog.types.ts @@ -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[]; /** diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts index e18724e11287..44f2d24d1333 100644 --- a/src/agents/models-config.plan.ts +++ b/src/agents/models-config.plan.ts @@ -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, ); diff --git a/src/agents/models-config.providers.implicit.discovery-scope.test.ts b/src/agents/models-config.providers.implicit.discovery-scope.test.ts index c91050954cc8..0850e6f54108 100644 --- a/src/agents/models-config.providers.implicit.discovery-scope.test.ts +++ b/src/agents/models-config.providers.implicit.discovery-scope.test.ts @@ -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(() => {})); + 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", diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index 58151e78ca87..6ae312f50640 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -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; } diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 628818a347a9..c118014541b9 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -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 { 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, diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index 83839840e9b1..b0de82a0ce69 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -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 { const { env, input, providerIds } = agentFacts; + const providerOutcomes = new Map(); + 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(), }; } diff --git a/src/agents/prepared-model-runtime.test.ts b/src/agents/prepared-model-runtime.test.ts index 8038c860406c..acacd9058df7 100644 --- a/src/agents/prepared-model-runtime.test.ts +++ b/src/agents/prepared-model-runtime.test.ts @@ -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([ { diff --git a/src/gateway/server-methods/chat-metadata-runtime.test.ts b/src/gateway/server-methods/chat-metadata-runtime.test.ts index 25b2c060e38c..f7b4cca28e10 100644 --- a/src/gateway/server-methods/chat-metadata-runtime.test.ts +++ b/src/gateway/server-methods/chat-metadata-runtime.test.ts @@ -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: {} })); diff --git a/src/gateway/server-methods/chat-metadata-runtime.ts b/src/gateway/server-methods/chat-metadata-runtime.ts index 22d584068396..80d1a34eebfa 100644 --- a/src/gateway/server-methods/chat-metadata-runtime.ts +++ b/src/gateway/server-methods/chat-metadata-runtime.ts @@ -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: { diff --git a/src/gateway/server-methods/models-list-result.provider-outcomes.test.ts b/src/gateway/server-methods/models-list-result.provider-outcomes.test.ts new file mode 100644 index 000000000000..8647a6afffd3 --- /dev/null +++ b/src/gateway/server-methods/models-list-result.provider-outcomes.test.ts @@ -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", + }); + }); +}); diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 101c1e6f4bdf..eea3745ac731 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -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; preloadedOnly?: boolean; @@ -484,7 +505,7 @@ type BuildModelsListResultParams = { export async function buildModelsListResult( params: BuildModelsListResultParams, -): Promise<{ models: ModelsListEntryWithCapabilities[] }> { +): Promise { 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, }; } diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index fdbb4eaca1fb..4039d681dfe0 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -56,6 +56,7 @@ function requestModelsList(params: { workspaceDir?: string; }) => Promise>>; 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": {} } } }, diff --git a/src/gateway/server-methods/models.ts b/src/gateway/server-methods/models.ts index 3b3411224810..04cf089e6a24 100644 --- a/src/gateway/server-methods/models.ts +++ b/src/gateway/server-methods/models.ts @@ -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, + ); }, }; diff --git a/src/gateway/server-model-catalog.test.ts b/src/gateway/server-model-catalog.test.ts index bf380ed4c685..b9a64c67954d 100644 --- a/src/gateway/server-model-catalog.test.ts +++ b/src/gateway/server-model-catalog.test.ts @@ -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 () => { diff --git a/src/plugin-sdk/provider-catalog-shared.ts b/src/plugin-sdk/provider-catalog-shared.ts index 03ae097b4fc4..1844d24115ff 100644 --- a/src/plugin-sdk/provider-catalog-shared.ts +++ b/src/plugin-sdk/provider-catalog-shared.ts @@ -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, diff --git a/src/plugins/provider-catalog-outcome.ts b/src/plugins/provider-catalog-outcome.ts new file mode 100644 index 000000000000..696642f5f09b --- /dev/null +++ b/src/plugins/provider-catalog-outcome.ts @@ -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"; +}; diff --git a/src/plugins/provider-catalog-result.ts b/src/plugins/provider-catalog-result.ts index 247c6134b384..3139b1d7bd6b 100644 --- a/src/plugins/provider-catalog-result.ts +++ b/src/plugins/provider-catalog-result.ts @@ -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([ + "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; diff --git a/src/plugins/provider-catalog.types.ts b/src/plugins/provider-catalog.types.ts index 897593dbeb21..441813556300 100644 --- a/src/plugins/provider-catalog.types.ts +++ b/src/plugins/provider-catalog.types.ts @@ -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 } + | { + provider: ModelProviderConfig; + outcomes?: readonly ProviderCatalogOutcome[]; + } + | { + providers: Record; + outcomes?: readonly ProviderCatalogOutcome[]; + } | null | undefined; diff --git a/src/plugins/provider-discovery.test.ts b/src/plugins/provider-discovery.test.ts index 534360fd663c..28cbbfe89d68 100644 --- a/src/plugins/provider-discovery.test.ts +++ b/src/plugins/provider-discovery.test.ts @@ -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[] = [ { diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index 99bc87e3435a..6c2d8fe1aba5 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -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 }) { diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 3ce737ae03eb..a8640419d8d5 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -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 = diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts index c7a6df6f57d6..059fb407b7b8 100644 --- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts +++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts @@ -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" } }), + ]); }); }); }); diff --git a/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts index 4f6d1572e725..f0923e41fc8e 100644 --- a/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts +++ b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts @@ -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); diff --git a/ui/src/e2e/model-providers.e2e.test.ts b/ui/src/e2e/model-providers.e2e.test.ts index 7b102cbd089f..74bfa9ed3ad0 100644 --- a/ui/src/e2e/model-providers.e2e.test.ts +++ b/ui/src/e2e/model-providers.e2e.test.ts @@ -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(); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 65effff06005..fbbf60dd5694 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/lib/chat/model-select-state.test.ts b/ui/src/lib/chat/model-select-state.test.ts index bd215194fc12..ee095614cedb 100644 --- a/ui/src/lib/chat/model-select-state.test.ts +++ b/ui/src/lib/chat/model-select-state.test.ts @@ -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", diff --git a/ui/src/lib/chat/model-select-state.ts b/ui/src/lib/chat/model-select-state.ts index cf89cd44ee37..36bf4b32998e 100644 --- a/ui/src/lib/chat/model-select-state.ts +++ b/ui/src/lib/chat/model-select-state.ts @@ -22,7 +22,6 @@ type ChatModelSelectStateInput = { agentDefaultModel?: string; chatModelCatalog: ModelCatalogEntry[]; modelOverrides: Readonly>; - restrictOptionsToCatalog?: boolean; sessionKey: string; sessionsResult: SessionsListResult | null; }; @@ -120,31 +119,6 @@ function normalizeChatModelAvailabilityKey(value: string): string { )}`; } -function buildUnavailableChatModelValues( - catalog: ModelCatalogEntry[], - displayLookup: ReturnType, -): Set { - 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, - currentOverride: string, - defaultModel: string, - restrictOptionsToCatalog: boolean, ): ChatModelSelectOption[] { const seen = new Set(); 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, diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index a8d5d6c831bb..c60823c8d2ea 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -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(); 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()); }); diff --git a/ui/src/pages/chat/chat-state-refresh.ts b/ui/src/pages/chat/chat-state-refresh.ts index a8465f203df5..780d8370fb7d 100644 --- a/ui/src/pages/chat/chat-state-refresh.ts +++ b/ui/src/pages/chat/chat-state-refresh.ts @@ -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)) { diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index b06defc2f7a9..2e93bfc943a6 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -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 () => { diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 05c2bbe723f5..7e63e4cbc6d9 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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("[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, diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index d684bbc23710..11aa6a0e525c 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -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 = diff --git a/ui/src/pages/chat/models.test.ts b/ui/src/pages/chat/models.test.ts index af3186af535b..653c1f3594d6 100644 --- a/ui/src/pages/chat/models.test.ts +++ b/ui/src/pages/chat/models.test.ts @@ -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" }]; diff --git a/ui/src/pages/chat/models.ts b/ui/src/pages/chat/models.ts index fefd56cc3466..a9313ae578dc 100644 --- a/ui/src/pages/chat/models.ts +++ b/ui/src/pages/chat/models.ts @@ -10,13 +10,24 @@ type ModelCatalogCacheEntry = { inFlight?: Promise; }; -const modelCatalogCache = new WeakMap(); +const modelCatalogCache = new WeakMap>(); + +function modelCatalogCacheFor(client: GatewayBrowserClient): Map { + 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 { - 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 = requestModels(client, cached?.models) + const inFlight: Promise = 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 { diff --git a/ui/src/pages/model-providers/data.test.ts b/ui/src/pages/model-providers/data.test.ts index 5be7e37b7e86..f80662bbf50e 100644 --- a/ui/src/pages/model-providers/data.test.ts +++ b/ui/src/pages/model-providers/data.test.ts @@ -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, diff --git a/ui/src/pages/model-providers/data.ts b/ui/src/pages/model-providers/data.ts index f27308c8c91c..048bacf6621a 100644 --- a/ui/src/pages/model-providers/data.ts +++ b/ui/src/pages/model-providers/data.ts @@ -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 | null; @@ -221,6 +224,25 @@ export function buildModelProviderCards(input: ModelProviderCardsInput): ModelPr } } + const outcomeSeverity: ReadonlyArray = [ + "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) => { diff --git a/ui/src/pages/model-providers/load.ts b/ui/src/pages/model-providers/load.ts index c88695422666..4c21a52a07cc 100644 --- a/ui/src/pages/model-providers/load.ts +++ b/ui/src/pages/model-providers/load.ts @@ -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 | 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(method) : client.request(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("models.list", { view: "all", includeProviderCapabilities: true, }) - .then((result) => result?.models ?? null) + .then((result) => result ?? null) .catch(() => null), request("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, diff --git a/ui/src/pages/model-providers/view-status.ts b/ui/src/pages/model-providers/view-status.ts new file mode 100644 index 000000000000..1898b923e352 --- /dev/null +++ b/ui/src/pages/model-providers/view-status.ts @@ -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 = { + 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 = { + 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` + + ${renderSettingsStatus({ kind: AUTH_KIND_STATUS[auth.kind], label })} + + `; +} + +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); +} diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index fbe2d2786155..41c970fb6f39 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -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({ diff --git a/ui/src/pages/model-providers/view.ts b/ui/src/pages/model-providers/view.ts index 14577b7907dc..cdc9eab45f8b 100644 --- a/ui/src/pages/model-providers/view.ts +++ b/ui/src/pages/model-providers/view.ts @@ -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 = { - 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 = { - 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` - - ${renderSettingsStatus({ kind: AUTH_KIND_STATUS[auth.kind], label })} - - `; -} - -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;