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:
parent
e2dac5d5cb
commit
42837a04bf
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
: {
|
||||
|
||||
@ -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" };
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 }) => ({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user