Enable tool streaming for newer Z.ai models (#2732)
This commit is contained in:
@@ -55,6 +55,7 @@ const COPILOT_STATIC_HEADERS = {
|
||||
|
||||
const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1";
|
||||
const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh";
|
||||
const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]);
|
||||
|
||||
async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
||||
try {
|
||||
@@ -379,28 +380,29 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
for (const [modelId, model] of Object.entries(data.zai.models)) {
|
||||
const m = model as ModelsDevModel;
|
||||
if (m.tool_call !== true) continue;
|
||||
const supportsImage = m.modalities?.input?.includes("image")
|
||||
const supportsImage = m.modalities?.input?.includes("image");
|
||||
|
||||
models.push({
|
||||
id: modelId,
|
||||
name: m.name || modelId,
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
reasoning: m.reasoning === true,
|
||||
input: supportsImage ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
compat: {
|
||||
supportsDeveloperRole: false,
|
||||
thinkingFormat: "zai",
|
||||
},
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
id: modelId,
|
||||
name: m.name || modelId,
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
reasoning: m.reasoning === true,
|
||||
input: supportsImage ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
compat: {
|
||||
supportsDeveloperRole: false,
|
||||
thinkingFormat: "zai",
|
||||
...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}),
|
||||
},
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +395,9 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
|
||||
|
||||
if (context.tools) {
|
||||
params.tools = convertTools(context.tools, compat);
|
||||
if (compat.zaiToolStream) {
|
||||
(params as any).tool_stream = true;
|
||||
}
|
||||
} else if (hasToolHistory(context.messages)) {
|
||||
// Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results
|
||||
params.tools = [];
|
||||
@@ -835,6 +838,7 @@ function detectCompat(model: Model<"openai-completions">): Required<OpenAIComple
|
||||
: "openai",
|
||||
openRouterRouting: {},
|
||||
vercelGatewayRouting: {},
|
||||
zaiToolStream: false,
|
||||
supportsStrictMode: true,
|
||||
};
|
||||
}
|
||||
@@ -861,6 +865,7 @@ function getCompat(model: Model<"openai-completions">): Required<OpenAICompletio
|
||||
thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat,
|
||||
openRouterRouting: model.compat.openRouterRouting ?? {},
|
||||
vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting,
|
||||
zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream,
|
||||
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,6 +277,8 @@ export interface OpenAICompletionsCompat {
|
||||
openRouterRouting?: OpenRouterRouting;
|
||||
/** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */
|
||||
vercelGatewayRouting?: VercelGatewayRouting;
|
||||
/** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */
|
||||
zaiToolStream?: boolean;
|
||||
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */
|
||||
supportsStrictMode?: boolean;
|
||||
}
|
||||
|
||||
@@ -200,6 +200,159 @@ describe("openai-completions tool_choice", () => {
|
||||
expect(params.reasoning_effort).toBe("medium");
|
||||
});
|
||||
|
||||
it("enables tool_stream for supported z.ai models with tools", async () => {
|
||||
const model = getModel("zai", "glm-5")!;
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: "ping",
|
||||
description: "Ping tool",
|
||||
parameters: Type.Object({
|
||||
ok: Type.Boolean(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
let payload: unknown;
|
||||
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Call ping with ok=true",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools,
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
onPayload: (params: unknown) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
const params = (payload ?? mockState.lastParams) as { tool_stream?: boolean };
|
||||
expect(params.tool_stream).toBe(true);
|
||||
});
|
||||
|
||||
it("stores z.ai tool_stream support in model compat metadata", () => {
|
||||
expect(getModel("zai", "glm-5")?.compat?.zaiToolStream).toBe(true);
|
||||
expect(getModel("zai", "glm-4.7")?.compat?.zaiToolStream).toBe(true);
|
||||
expect(getModel("zai", "glm-4.7-flash")?.compat?.zaiToolStream).toBe(true);
|
||||
expect(getModel("zai", "glm-4.6v")?.compat?.zaiToolStream).toBe(true);
|
||||
expect(getModel("zai", "glm-4.5-air")?.compat?.zaiToolStream).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits tool_stream for unsupported z.ai models", async () => {
|
||||
const model = getModel("zai", "glm-4.5-air")!;
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: "ping",
|
||||
description: "Ping tool",
|
||||
parameters: Type.Object({
|
||||
ok: Type.Boolean(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
let payload: unknown;
|
||||
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Call ping with ok=true",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools,
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
onPayload: (params: unknown) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
const params = (payload ?? mockState.lastParams) as { tool_stream?: boolean };
|
||||
expect(params.tool_stream).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects explicit z.ai tool_stream compat override", async () => {
|
||||
const baseModel = getModel("zai", "glm-4.5-air")!;
|
||||
const model = {
|
||||
...baseModel,
|
||||
compat: {
|
||||
...baseModel.compat,
|
||||
zaiToolStream: true,
|
||||
},
|
||||
} as const;
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: "ping",
|
||||
description: "Ping tool",
|
||||
parameters: Type.Object({
|
||||
ok: Type.Boolean(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
let payload: unknown;
|
||||
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Call ping with ok=true",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools,
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
onPayload: (params: unknown) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
const params = (payload ?? mockState.lastParams) as { tool_stream?: boolean };
|
||||
expect(params.tool_stream).toBe(true);
|
||||
});
|
||||
|
||||
it("omits tool_stream when no tools are provided", async () => {
|
||||
const model = getModel("zai", "glm-5")!;
|
||||
let payload: unknown;
|
||||
|
||||
await streamSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hi",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey: "test",
|
||||
onPayload: (params: unknown) => {
|
||||
payload = params;
|
||||
},
|
||||
},
|
||||
).result();
|
||||
|
||||
const params = (payload ?? mockState.lastParams) as { tool_stream?: boolean };
|
||||
expect(params.tool_stream).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps non-standard provider finish_reason values to stopReason error", async () => {
|
||||
mockState.chunks = [
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ const compat: Required<OpenAICompletionsCompat> = {
|
||||
thinkingFormat: "openai",
|
||||
openRouterRouting: {},
|
||||
vercelGatewayRouting: {},
|
||||
zaiToolStream: false,
|
||||
supportsStrictMode: true,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user