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