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 <peizhe.chen@vbot.cn>
This commit is contained in:
peizhe.chen 2026-03-16 03:21:11 +08:00 committed by GitHub
parent e2dac5d5cb
commit 42837a04bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 203 additions and 30 deletions

View File

@ -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", () => {

View File

@ -66,11 +66,11 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
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<Api>): Model<Api> {
? {
...compat,
supportsDeveloperRole: forcedDeveloperRole || false,
supportsUsageInStreaming: forcedUsageStreaming || false,
...(hasStreamingUsageOverride ? {} : { supportsUsageInStreaming: false }),
supportsStrictMode: targetStrictMode,
}
: {

View File

@ -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" };

View File

@ -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);
});
});

View File

@ -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();
});
});

View File

@ -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<string, ProviderConfig>,
): Record<string, ProviderConfig> {
let changed = false;
const nextProviders: Record<string, ProviderConfig> = {};
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 }) => ({