From 40b24dfa6b6c8ae3af5e5f5c2e538729f0b7617a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8B=BC=E5=93=A5?= Date: Wed, 1 Apr 2026 09:09:42 +0800 Subject: [PATCH] fix(session-status): infer custom runtime providers from config (#58474) * fix(session-status): infer custom runtime providers from config * test(session-status): satisfy custom provider type checks --- src/agents/model-selection.test.ts | 49 ++++++++++++-- src/agents/model-selection.ts | 57 +++++++++++----- .../openclaw-tools.session-status.test.ts | 66 ++++++++++++++++++- src/gateway/session-utils.test.ts | 47 +++++++++++++ 4 files changed, 198 insertions(+), 21 deletions(-) diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 81f024aa702..24e6e802f47 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -284,7 +284,7 @@ describe("model-selection", () => { }, }, }, - } as OpenClawConfig; + } as unknown as OpenClawConfig; expect( inferUniqueProviderFromConfiguredModels({ @@ -304,7 +304,7 @@ describe("model-selection", () => { }, }, }, - } as OpenClawConfig; + } as unknown as OpenClawConfig; expect( inferUniqueProviderFromConfiguredModels({ @@ -323,7 +323,7 @@ describe("model-selection", () => { }, }, }, - } as OpenClawConfig; + } as unknown as OpenClawConfig; expect( inferUniqueProviderFromConfiguredModels({ @@ -342,7 +342,7 @@ describe("model-selection", () => { }, }, }, - } as OpenClawConfig; + } as unknown as OpenClawConfig; expect( inferUniqueProviderFromConfiguredModels({ @@ -351,6 +351,47 @@ describe("model-selection", () => { }), ).toBe("vercel-ai-gateway"); }); + + it("infers provider from configured provider catalogs when allowlist is absent", () => { + const cfg = { + models: { + providers: { + "qwen-dashscope": { + models: [{ id: "qwen-max" }], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "qwen-max", + }), + ).toBe("qwen-dashscope"); + }); + + it("returns undefined when provider catalog matches are ambiguous", () => { + const cfg = { + models: { + providers: { + "qwen-dashscope": { + models: [{ id: "qwen-max" }], + }, + modelstudio: { + models: [{ id: "qwen-max" }], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "qwen-max", + }), + ).toBeUndefined(); + }); }); describe("buildModelAliasIndex", () => { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 8510ce893af..49e27f9de6e 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -219,25 +219,52 @@ export function inferUniqueProviderFromConfiguredModels(params: { if (!model) { return undefined; } - const configuredModels = params.cfg.agents?.defaults?.models; - if (!configuredModels) { - return undefined; - } const normalized = model.toLowerCase(); const providers = new Set(); - for (const key of Object.keys(configuredModels)) { - const ref = key.trim(); - if (!ref || !ref.includes("/")) { - continue; + const addProvider = (provider: string) => { + const normalizedProvider = normalizeProviderId(provider); + if (!normalizedProvider) { + return; } - const parsed = parseModelRef(ref, DEFAULT_PROVIDER, { - allowPluginNormalization: false, - }); - if (!parsed) { - continue; + providers.add(normalizedProvider); + }; + const configuredModels = params.cfg.agents?.defaults?.models; + if (configuredModels) { + for (const key of Object.keys(configuredModels)) { + const ref = key.trim(); + if (!ref || !ref.includes("/")) { + continue; + } + const parsed = parseModelRef(ref, DEFAULT_PROVIDER, { + allowPluginNormalization: false, + }); + if (!parsed) { + continue; + } + if (parsed.model === model || parsed.model.toLowerCase() === normalized) { + addProvider(parsed.provider); + if (providers.size > 1) { + return undefined; + } + } } - if (parsed.model === model || parsed.model.toLowerCase() === normalized) { - providers.add(parsed.provider); + } + const configuredProviders = params.cfg.models?.providers; + if (configuredProviders) { + for (const [providerId, providerConfig] of Object.entries(configuredProviders)) { + const models = providerConfig?.models; + if (!Array.isArray(models)) { + continue; + } + for (const entry of models) { + const modelId = entry?.id?.trim(); + if (!modelId) { + continue; + } + if (modelId === model || modelId.toLowerCase() === normalized) { + addProvider(providerId); + } + } if (providers.size > 1) { return undefined; } diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index e419f5dfaaf..8b96463c0da 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -14,6 +14,16 @@ const listTasksForRelatedSessionKeyForOwnerMock = vi.hoisted(() => [] as Array>, ), ); +const resolveEnvApiKeyMock = vi.hoisted( + () => vi.fn((_provider?: string, _env?: NodeJS.ProcessEnv) => null), +); +const resolveUsableCustomProviderApiKeyMock = vi.hoisted( + () => + vi.fn( + (_params?: { provider?: string }) => + null as { apiKey: string; source: string } | null, + ), +); const createMockConfig = () => ({ session: { mainKey: "main", scope: "per-sender" }, @@ -147,8 +157,8 @@ function createAuthProfilesModuleMock() { function createModelAuthModuleMock() { return { - resolveEnvApiKey: () => null, - resolveUsableCustomProviderApiKey: () => null, + resolveEnvApiKey: resolveEnvApiKeyMock, + resolveUsableCustomProviderApiKey: resolveUsableCustomProviderApiKeyMock, resolveModelAuthMode: () => "api-key", }; } @@ -208,6 +218,10 @@ function resetSessionStore(store: Record) { buildStatusMessageMock.mockClear(); resolveQueueSettingsMock.mockClear(); resolveQueueSettingsMock.mockReturnValue({ mode: "interrupt" }); + resolveEnvApiKeyMock.mockReset(); + resolveEnvApiKeyMock.mockReturnValue(null); + resolveUsableCustomProviderApiKeyMock.mockReset(); + resolveUsableCustomProviderApiKeyMock.mockReturnValue(null); loadSessionStoreMock.mockClear(); updateSessionStoreMock.mockClear(); callGatewayMock.mockClear(); @@ -512,6 +526,54 @@ describe("session_status tool", () => { ); }); + it("infers configured custom providers for runtime-only models in session_status", async () => { + resetSessionStore({ + main: { + sessionId: "runtime-custom-provider", + updatedAt: 10, + model: "qwen-max", + }, + }); + mockConfig = { + session: { mainKey: "main", scope: "per-sender" }, + agents: { + defaults: { + model: { primary: "openai/gpt-5.4" }, + models: {}, + }, + }, + models: { + providers: { + "qwen-dashscope": { + apiKey: "DASHSCOPE_API_KEY", + models: [{ id: "qwen-max" }], + }, + }, + }, + tools: { + agentToAgent: { enabled: false }, + }, + }; + resolveUsableCustomProviderApiKeyMock.mockImplementation((params) => + params?.provider === "qwen-dashscope" ? { apiKey: "sk-test", source: "models.json" } : null, + ); + + const tool = getSessionStatusTool(); + + await tool.execute("call-runtime-custom-provider", {}); + + expect(buildStatusMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + agent: expect.objectContaining({ + model: expect.objectContaining({ + primary: "qwen-dashscope/qwen-max", + }), + }), + modelAuth: "api-key (models.json)", + }), + ); + }); + it("preserves an unknown runtime provider in the selected status card model", async () => { resetSessionStore({ main: { diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 6242900a3e7..92d96de1a2e 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -726,6 +726,28 @@ describe("resolveSessionModelIdentityRef", () => { expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); }); + test("infers provider from configured provider catalogs when allowlist is absent", () => { + const cfg = createModelDefaultsConfig({ + primary: "google-gemini-cli/gemini-3-pro-preview", + }); + cfg.models = { + providers: { + "qwen-dashscope": { + models: [{ id: "qwen-max" }], + }, + }, + } as unknown as OpenClawConfig["models"]; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "custom-provider-runtime-model", + updatedAt: Date.now(), + model: "qwen-max", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ provider: "qwen-dashscope", model: "qwen-max" }); + }); + test("keeps provider unknown when configured models are ambiguous", () => { const cfg = createModelDefaultsConfig({ primary: "google-gemini-cli/gemini-3-pro-preview", @@ -740,6 +762,31 @@ describe("resolveSessionModelIdentityRef", () => { expect(resolved).toEqual({ model: "claude-sonnet-4-6" }); }); + test("keeps provider unknown when configured provider catalog matches are ambiguous", () => { + const cfg = createModelDefaultsConfig({ + primary: "google-gemini-cli/gemini-3-pro-preview", + }); + cfg.models = { + providers: { + "qwen-dashscope": { + models: [{ id: "qwen-max" }], + }, + modelstudio: { + models: [{ id: "qwen-max" }], + }, + }, + } as unknown as OpenClawConfig["models"]; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "ambiguous-custom-provider-runtime-model", + updatedAt: Date.now(), + model: "qwen-max", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ model: "qwen-max" }); + }); + test("preserves provider from slash-prefixed runtime model", () => { const cfg = createModelDefaultsConfig({ primary: "google-gemini-cli/gemini-3-pro-preview",