From 42837a04bfa9b430f70e17de13a81f116d7c1287 Mon Sep 17 00:00:00 2001 From: "peizhe.chen" Date: Mon, 16 Mar 2026 03:21:11 +0800 Subject: [PATCH] fix(models): preserve stream usage compat opt-ins (#45733) Preserves explicit `supportsUsageInStreaming` overrides from built-in provider catalogs and user config instead of unconditionally forcing `false` on non-native openai-completions endpoints. Adds `applyNativeStreamingUsageCompat()` to set `supportsUsageInStreaming: true` on ModelStudio (DashScope) and Moonshot models at config build time so their native streaming usage works out of the box. Closes #46142 Co-authored-by: pezy --- src/agents/model-compat.test.ts | 37 +++++++++ src/agents/model-compat.ts | 6 +- src/agents/models-config.plan.ts | 4 +- ...odels-config.providers.modelstudio.test.ts | 52 ++++++------ .../models-config.providers.moonshot.test.ts | 55 ++++++++++++- src/agents/models-config.providers.ts | 79 ++++++++++++++++++- 6 files changed, 203 insertions(+), 30 deletions(-) diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 733d9a2f47f..bda8ac664db 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -295,6 +295,17 @@ describe("normalizeModelCompat", () => { expect(supportsUsageInStreaming(normalized)).toBe(true); }); + it("preserves explicit supportsUsageInStreaming false on non-native endpoints", () => { + const model = { + ...baseModel(), + provider: "custom-cpa", + baseUrl: "https://proxy.example.com/v1", + compat: { supportsUsageInStreaming: false }, + }; + const normalized = normalizeModelCompat(model); + expect(supportsUsageInStreaming(normalized)).toBe(false); + }); + it("still forces flags off when not explicitly set by user", () => { const model = { ...baseModel(), @@ -348,6 +359,32 @@ describe("normalizeModelCompat", () => { expect(supportsUsageInStreaming(normalized)).toBe(false); expect(supportsStrictMode(normalized)).toBe(false); }); + + it("leaves fully explicit non-native compat untouched", () => { + const model = baseModel(); + model.baseUrl = "https://proxy.example.com/v1"; + model.compat = { + supportsDeveloperRole: false, + supportsUsageInStreaming: true, + supportsStrictMode: true, + }; + const normalized = normalizeModelCompat(model); + expect(normalized).toBe(model); + }); + + it("preserves explicit usage compat when developer role is explicitly enabled", () => { + const model = baseModel(); + model.baseUrl = "https://proxy.example.com/v1"; + model.compat = { + supportsDeveloperRole: true, + supportsUsageInStreaming: true, + supportsStrictMode: true, + }; + const normalized = normalizeModelCompat(model); + expect(supportsDeveloperRole(normalized)).toBe(true); + expect(supportsUsageInStreaming(normalized)).toBe(true); + expect(supportsStrictMode(normalized)).toBe(true); + }); }); describe("isModernModelRef", () => { diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 46e37733aec..26522da6e67 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -66,11 +66,11 @@ export function normalizeModelCompat(model: Model): Model { return model; } const forcedDeveloperRole = compat?.supportsDeveloperRole === true; - const forcedUsageStreaming = compat?.supportsUsageInStreaming === true; + const hasStreamingUsageOverride = compat?.supportsUsageInStreaming !== undefined; const targetStrictMode = compat?.supportsStrictMode ?? false; if ( compat?.supportsDeveloperRole !== undefined && - compat?.supportsUsageInStreaming !== undefined && + hasStreamingUsageOverride && compat?.supportsStrictMode !== undefined ) { return model; @@ -83,7 +83,7 @@ export function normalizeModelCompat(model: Model): Model { ? { ...compat, supportsDeveloperRole: forcedDeveloperRole || false, - supportsUsageInStreaming: forcedUsageStreaming || false, + ...(hasStreamingUsageOverride ? {} : { supportsUsageInStreaming: false }), supportsStrictMode: targetStrictMode, } : { diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts index 601a0edfda1..31794180c3c 100644 --- a/src/agents/models-config.plan.ts +++ b/src/agents/models-config.plan.ts @@ -6,6 +6,7 @@ import { type ExistingProviderConfig, } from "./models-config.merge.js"; import { + applyNativeStreamingUsageCompat, enforceSourceManagedProviderSecrets, normalizeProviders, resolveImplicitProviders, @@ -126,7 +127,8 @@ export async function planOpenClawModelsJson(params: { sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults, secretRefManagedProviders, }) ?? mergedProviders; - const nextContents = `${JSON.stringify({ providers: secretEnforcedProviders }, null, 2)}\n`; + const finalProviders = applyNativeStreamingUsageCompat(secretEnforcedProviders); + const nextContents = `${JSON.stringify({ providers: finalProviders }, null, 2)}\n`; if (params.existingRaw === nextContents) { return { action: "noop" }; diff --git a/src/agents/models-config.providers.modelstudio.test.ts b/src/agents/models-config.providers.modelstudio.test.ts index df4000cc27d..619146d635c 100644 --- a/src/agents/models-config.providers.modelstudio.test.ts +++ b/src/agents/models-config.providers.modelstudio.test.ts @@ -1,32 +1,36 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { withEnvAsync } from "../test-utils/env.js"; -import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; -import { buildModelStudioProvider } from "./models-config.providers.js"; - -const modelStudioApiKeyEnv = ["MODELSTUDIO_API", "KEY"].join("_"); +import { + applyNativeStreamingUsageCompat, + buildModelStudioProvider, +} from "./models-config.providers.js"; describe("Model Studio implicit provider", () => { - it("should include modelstudio when MODELSTUDIO_API_KEY is configured", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const modelStudioApiKey = "test-key"; // pragma: allowlist secret - await withEnvAsync({ [modelStudioApiKeyEnv]: modelStudioApiKey }, async () => { - const providers = await resolveImplicitProvidersForTest({ agentDir }); - expect(providers?.modelstudio).toBeDefined(); - expect(providers?.modelstudio?.apiKey).toBe("MODELSTUDIO_API_KEY"); - expect(providers?.modelstudio?.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); + it("should opt native Model Studio baseUrls into streaming usage", () => { + const providers = applyNativeStreamingUsageCompat({ + modelstudio: buildModelStudioProvider(), }); + expect(providers?.modelstudio).toBeDefined(); + expect(providers?.modelstudio?.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); + expect( + providers?.modelstudio?.models?.every( + (model) => model.compat?.supportsUsageInStreaming === true, + ), + ).toBe(true); }); - it("should build the static Model Studio provider catalog", () => { - const provider = buildModelStudioProvider(); - const modelIds = provider.models.map((model) => model.id); - expect(provider.api).toBe("openai-completions"); - expect(provider.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); - expect(modelIds).toContain("qwen3.5-plus"); - expect(modelIds).toContain("qwen3-coder-plus"); - expect(modelIds).toContain("kimi-k2.5"); + it("should keep streaming usage opt-in disabled for custom Model Studio-compatible baseUrls", () => { + const providers = applyNativeStreamingUsageCompat({ + modelstudio: { + ...buildModelStudioProvider(), + baseUrl: "https://proxy.example.com/v1", + }, + }); + expect(providers?.modelstudio).toBeDefined(); + expect(providers?.modelstudio?.baseUrl).toBe("https://proxy.example.com/v1"); + expect( + providers?.modelstudio?.models?.some( + (model) => model.compat?.supportsUsageInStreaming === true, + ), + ).toBe(false); }); }); diff --git a/src/agents/models-config.providers.moonshot.test.ts b/src/agents/models-config.providers.moonshot.test.ts index 00e1f5949c6..c235266800a 100644 --- a/src/agents/models-config.providers.moonshot.test.ts +++ b/src/agents/models-config.providers.moonshot.test.ts @@ -7,7 +7,11 @@ import { MOONSHOT_CN_BASE_URL, } from "../commands/onboard-auth.models.js"; import { captureEnv } from "../test-utils/env.js"; -import { resolveImplicitProviders } from "./models-config.providers.js"; +import { + applyNativeStreamingUsageCompat, + resolveImplicitProviders, +} from "./models-config.providers.js"; +import { buildMoonshotProvider } from "./models-config.providers.static.js"; describe("moonshot implicit provider (#33637)", () => { it("uses explicit CN baseUrl when provided", async () => { @@ -39,6 +43,31 @@ describe("moonshot implicit provider (#33637)", () => { expect(providers?.moonshot).toBeDefined(); expect(providers?.moonshot?.baseUrl).toBe(MOONSHOT_CN_BASE_URL); expect(providers?.moonshot?.apiKey).toBeDefined(); + expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined(); + } finally { + envSnapshot.restore(); + } + }); + + it("keeps streaming usage opt-in unset before the final compat pass", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["MOONSHOT_API_KEY"]); + process.env.MOONSHOT_API_KEY = "sk-test-custom"; + + try { + const providers = await resolveImplicitProviders({ + agentDir, + explicitProviders: { + moonshot: { + baseUrl: "https://proxy.example.com/v1", + api: "openai-completions", + models: [], + }, + }, + }); + expect(providers?.moonshot).toBeDefined(); + expect(providers?.moonshot?.baseUrl).toBe("https://proxy.example.com/v1"); + expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined(); } finally { envSnapshot.restore(); } @@ -53,8 +82,32 @@ describe("moonshot implicit provider (#33637)", () => { const providers = await resolveImplicitProviders({ agentDir }); expect(providers?.moonshot).toBeDefined(); expect(providers?.moonshot?.baseUrl).toBe(MOONSHOT_AI_BASE_URL); + expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined(); } finally { envSnapshot.restore(); } }); + + it("opts native Moonshot baseUrls into streaming usage only after the final compat pass", () => { + const defaultProviders = applyNativeStreamingUsageCompat({ + moonshot: buildMoonshotProvider(), + }); + expect(defaultProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true); + + const cnProviders = applyNativeStreamingUsageCompat({ + moonshot: { + ...buildMoonshotProvider(), + baseUrl: MOONSHOT_CN_BASE_URL, + }, + }); + expect(cnProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true); + + const customProviders = applyNativeStreamingUsageCompat({ + moonshot: { + ...buildMoonshotProvider(), + baseUrl: "https://proxy.example.com/v1", + }, + }); + expect(customProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined(); + }); }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 03110d3fba5..19d2f1327ba 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -81,6 +81,15 @@ type SecretDefaults = { exec?: string; }; +const MOONSHOT_NATIVE_BASE_URLS = new Set([ + "https://api.moonshot.ai/v1", + "https://api.moonshot.cn/v1", +]); +const MODELSTUDIO_NATIVE_BASE_URLS = new Set([ + "https://coding-intl.dashscope.aliyuncs.com/v1", + "https://coding.dashscope.aliyuncs.com/v1", +]); + const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; function normalizeApiKeyConfig(value: string): string { @@ -89,6 +98,65 @@ function normalizeApiKeyConfig(value: string): string { return match?.[1] ?? trimmed; } +function normalizeProviderBaseUrl(baseUrl: string | undefined): string { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return ""; + } + try { + const url = new URL(trimmed); + url.hash = ""; + url.search = ""; + return url.toString().replace(/\/+$/, "").toLowerCase(); + } catch { + return trimmed.replace(/\/+$/, "").toLowerCase(); + } +} + +function withStreamingUsageCompat(provider: ProviderConfig): ProviderConfig { + if (!Array.isArray(provider.models) || provider.models.length === 0) { + return provider; + } + + let changed = false; + const models = provider.models.map((model) => { + if (model.compat?.supportsUsageInStreaming !== undefined) { + return model; + } + changed = true; + return { + ...model, + compat: { + ...model.compat, + supportsUsageInStreaming: true, + }, + }; + }); + + return changed ? { ...provider, models } : provider; +} + +export function applyNativeStreamingUsageCompat( + providers: Record, +): Record { + let changed = false; + const nextProviders: Record = {}; + + for (const [providerKey, provider] of Object.entries(providers)) { + const normalizedBaseUrl = normalizeProviderBaseUrl(provider.baseUrl); + const isNativeMoonshot = + providerKey === "moonshot" && MOONSHOT_NATIVE_BASE_URLS.has(normalizedBaseUrl); + const isNativeModelStudio = + providerKey === "modelstudio" && MODELSTUDIO_NATIVE_BASE_URLS.has(normalizedBaseUrl); + const nextProvider = + isNativeMoonshot || isNativeModelStudio ? withStreamingUsageCompat(provider) : provider; + nextProviders[providerKey] = nextProvider; + changed ||= nextProvider !== provider; + } + + return changed ? nextProviders : providers; +} + function resolveEnvApiKeyVarName( provider: string, env: NodeJS.ProcessEnv = process.env, @@ -684,7 +752,16 @@ const SIMPLE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ apiKey, })), withApiKey("qianfan", async ({ apiKey }) => ({ ...buildQianfanProvider(), apiKey })), - withApiKey("modelstudio", async ({ apiKey }) => ({ ...buildModelStudioProvider(), apiKey })), + withApiKey("modelstudio", async ({ apiKey, explicitProvider }) => { + const explicitBaseUrl = explicitProvider?.baseUrl; + return { + ...buildModelStudioProvider(), + ...(typeof explicitBaseUrl === "string" && explicitBaseUrl.trim() + ? { baseUrl: explicitBaseUrl.trim() } + : {}), + apiKey, + }; + }), withApiKey("openrouter", async ({ apiKey }) => ({ ...buildOpenrouterProvider(), apiKey })), withApiKey("nvidia", async ({ apiKey }) => ({ ...buildNvidiaProvider(), apiKey })), withApiKey("kilocode", async ({ apiKey }) => ({