From b581bd9eceb7dea7edfdcd65f1caf23707cb0365 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 19 Jun 2026 16:58:46 +0200 Subject: [PATCH] feat(zai): add GLM-5.2 support (#1689) * feat(zai): add GLM-5.2 thinking support * fix(provider): derive GHE Copilot URL from base URL * fix(zai): gate GLM reasoning effort by model --- .env.example | 8 +- README.md | 8 +- src/integrations/brands/glm.ts | 1 + src/integrations/descriptors.ts | 3 +- src/integrations/models/glm.ts | 1 + src/integrations/runtimeMetadata.test.ts | 33 ++ src/integrations/runtimeMetadata.ts | 29 +- src/integrations/vendors/zai.ts | 15 +- src/services/api/openaiShim.test.ts | 416 ++++++++++++++++++ src/services/api/openaiShim.ts | 65 ++- .../api/providerConfig.github.test.ts | 14 + src/services/api/providerConfig.test.ts | 26 ++ src/services/api/providerConfig.ts | 26 +- src/utils/context.test.ts | 7 +- src/utils/providerFlag.test.ts | 2 +- src/utils/providerProfiles.test.ts | 2 +- src/utils/thinking.test.ts | 2 + src/utils/thinking.ts | 2 +- 18 files changed, 632 insertions(+), 28 deletions(-) create mode 100644 src/services/api/providerConfig.test.ts diff --git a/.env.example b/.env.example index 960d20bb5..d10eaa823 100644 --- a/.env.example +++ b/.env.example @@ -183,8 +183,12 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # Legacy aliases also work: deepseek-chat and deepseek-reasoner # For Z.AI GLM Coding Plan, set: # OPENAI_BASE_URL=https://api.z.ai/api/coding/paas/v4 -# OPENAI_MODEL=GLM-5.1 -# Optional: OPENAI_MODEL=GLM-5-Turbo, GLM-4.7, or GLM-4.5-Air +# OPENAI_MODEL=glm-5.2 +# Optional: OPENAI_MODEL=GLM-5.1, GLM-5-Turbo, GLM-4.7, or GLM-4.5-Air +# Optional GLM-5.2 thinking controls: +# OPENAI_MODEL='glm-5.2?reasoning=high' # enhanced reasoning +# OPENAI_MODEL='glm-5.2?reasoning=xhigh' # maps to Z.AI reasoning_effort=max +# OPENAI_MODEL='glm-5.2?thinking=disabled' # faster direct answers for simple tasks # For Hicap, use the OpenAI-compatible route flag above and set: # HICAP_API_KEY=your-hicap-key-here # OPENAI_BASE_URL=https://api.hicap.ai/v1 diff --git a/README.md b/README.md index d43ea23f9..ed8737c24 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Inside OpenClaude: - run `/provider` for guided provider setup and saved profiles - run `/onboard-github` for GitHub Models onboarding -> **Note:** OpenClaude does not automatically load project `.env` files. We recommend using the `/provider` command for setup, which securely stores credentials. If you prefer environment variables, export them explicitly or run `openclaude --provider-env-file .env` for provider/setup variables. Export runtime/debug knobs from your shell or launcher. +> **Note:** OpenClaude does not automatically load project `.env` files. We recommend using the `/provider` command for setup, which saves provider profiles and credentials in `.openclaude-profile.json`. If you prefer environment variables, export them explicitly or run `openclaude --provider-env-file .env` for provider/setup variables. Export runtime/debug knobs from your shell or launcher. ### Background sessions @@ -195,6 +195,7 @@ Advanced and source-build guides: | Provider | Setup Path | Notes | | --- | --- | --- | | OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers | +| Z.AI GLM Coding Plan | `/provider` or OpenAI-compatible env vars | Uses `OPENAI_API_KEY` at `https://api.z.ai/api/coding/paas/v4` and defaults to `glm-5.2` | | Hicap | `/provider` or OpenAI-compatible env vars | Uses `api-key` auth, discovers models from unauthenticated `/models`, and supports Responses mode for `gpt-` models | | Fireworks AI | `/provider` or env vars | First-class provider with 276 curated models (DeepSeek, Qwen, Llama, Gemma, and more); uses `FIREWORKS_API_KEY` | | Gemini | `/provider` or env vars | Supports API key only | @@ -228,6 +229,7 @@ OpenClaude supports multiple providers, but behavior is not identical across all - Smaller local models can struggle with long multi-step tool flows - Some providers impose lower output caps than the CLI defaults, and OpenClaude adapts where possible - Gitlawb Opengateway is the fresh-install startup default and requires an API key from https://gitlawb.com/opengateway/keys. It uses one OpenAI-compatible base URL; switch between `mimo-*` and `google/gemini-3.1-flash-lite-preview` with `/model`, and do not pin the base URL to `/v1/xiaomi-mimo`. +- Z.AI GLM Coding Plan uses `https://api.z.ai/api/coding/paas/v4` with `glm-5.2` by default. Use `glm-5.2?reasoning=high` for enhanced reasoning, `glm-5.2?reasoning=xhigh` to request Z.AI `reasoning_effort=max`, or `glm-5.2?thinking=disabled` for faster direct answers. - Xiaomi MiMo uses `api-key` header auth on the direct OpenAI-compatible route and currently does not support `/usage` reporting in OpenClaude ### GitHub Copilot sub-agent optimization @@ -261,7 +263,7 @@ Add to `~/.openclaude.json`: "api_key": "sk-your-key" }, "zai-default": { - "model": "glm-5.1", + "model": "glm-5.2", "base_url": "https://api.z.ai/api/coding/paas/v4", "api_key": "sk-your-key" }, @@ -282,7 +284,7 @@ Add to `~/.openclaude.json`: When no routing match is found, the global provider remains the fallback. -`agentRouting` values and explicit Agent tool `model` overrides match keys in `agentModels`. By default, that key is also the model string sent to the provider. Set `agentModels..model` when you want a local route key such as `zai-default` to call a different provider model name such as `glm-5.1`. +`agentRouting` values and explicit Agent tool `model` overrides match keys in `agentModels`. By default, that key is also the model string sent to the provider. Set `agentModels..model` when you want a local route key such as `zai-default` to call a different provider model name such as `glm-5.2`. > **Note:** `/provider` changes the global/parent provider for your current session. `agentModels` and `agentRouting` are specifically for configuring per-agent provider overrides while keeping the parent session unchanged. diff --git a/src/integrations/brands/glm.ts b/src/integrations/brands/glm.ts index f419a515a..15f1eb3ca 100644 --- a/src/integrations/brands/glm.ts +++ b/src/integrations/brands/glm.ts @@ -13,6 +13,7 @@ export default defineBrand({ supportsPreciseTokenCount: false, }, modelIds: [ + 'glm-5.2', 'GLM-5.1', 'GLM-5-Turbo', 'GLM-5', diff --git a/src/integrations/descriptors.ts b/src/integrations/descriptors.ts index d4833f76d..8873d8f4f 100644 --- a/src/integrations/descriptors.ts +++ b/src/integrations/descriptors.ts @@ -37,7 +37,8 @@ export interface OpenAIShimTransportConfig { preserveReasoningContent?: boolean requireReasoningContentOnAssistantMessages?: boolean reasoningContentFallback?: '' | 'omit' - thinkingRequestFormat?: 'none' | 'deepseek-compatible' + thinkingRequestFormat?: 'none' | 'deepseek-compatible' | 'zai-compatible' + enableToolStreaming?: boolean maxTokensField?: OpenAIShimTokenField removeBodyFields?: string[] /** Override the endpoint path for this model (e.g., '/responses', '/messages'). */ diff --git a/src/integrations/models/glm.ts b/src/integrations/models/glm.ts index e91a2baaf..8f9b41b5e 100644 --- a/src/integrations/models/glm.ts +++ b/src/integrations/models/glm.ts @@ -29,6 +29,7 @@ function glmModel( } export default [ + glmModel('glm-5.2', 'GLM 5.2', 1_000_000, 131_072), glmModel('GLM-5.1', 'GLM-5.1', 202_752, 131_072), glmModel('GLM-5-Turbo', 'GLM-5-Turbo', 202_752, 131_072), glmModel('GLM-5', 'GLM-5', 202_752, 131_072), diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 73f302b65..670f27e7f 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -70,6 +70,39 @@ describe('resolveModelRuntimeLimits', () => { ).toBe(1_000_000) }) }) + + it('uses built-in Z.AI GLM-5.2 runtime limits', () => { + const limits = resolveModelRuntimeLimits({ + model: 'glm-5.2', + processEnv: { + OPENAI_BASE_URL: 'https://api.z.ai/api/coding/paas/v4', + }, + }) + + expect(limits.contextWindow).toBe(1_000_000) + expect(limits.maxOutputTokens).toBe(131_072) + }) +}) + +describe('resolveOpenAIShimRuntimeContext - Z.AI GLM-5.2', () => { + it.each([ + 'glm-5.2', + 'glm-5.2?reasoning=high', + 'glm-5.2?thinking=disabled', + ])('uses Z.AI GLM-5.2 shim settings for %s', model => { + const result = resolveOpenAIShimRuntimeContext({ + model, + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + processEnv: {}, + }) + + expect(result.routeId).toBe('zai') + expect(result.catalogEntry?.id).toBe('glm-5.2') + expect(result.openaiShimConfig.thinkingRequestFormat).toBe('zai-compatible') + expect(result.openaiShimConfig.preserveReasoningContent).toBe(true) + expect(result.openaiShimConfig.requireReasoningContentOnAssistantMessages).toBe(true) + expect(result.openaiShimConfig.enableToolStreaming).toBe(true) + }) }) describe('resolveOpenAIShimRuntimeContext - segment-boundary heuristic', () => { diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index d2c9e62c9..724a44081 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -29,8 +29,20 @@ import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js' function normalizeModelApiName( value: string | undefined, ): string | null { - const trimmed = value?.trim().toLowerCase() - return trimmed ? trimmed : null + const baseModel = getBaseModelApiName(value) + return baseModel ? baseModel.toLowerCase() : null +} + +function getBaseModelApiName(value: string | undefined): string | null { + const trimmed = value?.trim() + if (!trimmed) { + return null + } + + const queryIndex = trimmed.indexOf('?') + const baseModel = + queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex).trim() + return baseModel || null } function matchesCatalogEntryModel( @@ -269,7 +281,7 @@ function findModelDescriptorForApiName( routeId: string | null, modelApiName: string | undefined, ) { - const trimmedModel = modelApiName?.trim() + const trimmedModel = getBaseModelApiName(modelApiName) if (!trimmedModel) { return null } @@ -385,22 +397,23 @@ export function resolveModelRuntimeLimits(options: { const routeId = resolveActiveRouteIdFromEnv(runtimeEnv, { activeProfileProvider: options.activeProfileProvider, }) - const catalogEntry = findCatalogEntryForApiName(routeId, options.model) + const modelApiName = getBaseModelApiName(options.model) ?? options.model + const catalogEntry = findCatalogEntryForApiName(routeId, modelApiName) const cachedCatalogEntry = findCachedCatalogEntryForApiName( routeId, - options.model, + modelApiName, runtimeEnv, ) const modelDescriptor = getModelDescriptorForCatalogEntry(catalogEntry) ?? getModelDescriptorForCatalogEntry(cachedCatalogEntry) ?? - findModelDescriptorForApiName(routeId, options.model) + findModelDescriptorForApiName(routeId, modelApiName) const externalContextWindow = getOpenAIContextWindowMatches( - options.model, + modelApiName, runtimeEnv, ) const externalMaxOutputTokens = getOpenAIMaxOutputTokenMatches( - options.model, + modelApiName, runtimeEnv, ) diff --git a/src/integrations/vendors/zai.ts b/src/integrations/vendors/zai.ts index 2e889b82d..2fa8a8f62 100644 --- a/src/integrations/vendors/zai.ts +++ b/src/integrations/vendors/zai.ts @@ -5,7 +5,7 @@ export default defineVendor({ label: 'Z.AI', classification: 'openai-compatible', defaultBaseUrl: 'https://api.z.ai/api/coding/paas/v4', - defaultModel: 'GLM-5.1', + defaultModel: 'glm-5.2', requiredEnvVars: ['OPENAI_API_KEY'], setup: { requiresAuth: true, @@ -18,7 +18,7 @@ export default defineVendor({ preserveReasoningContent: true, requireReasoningContentOnAssistantMessages: true, reasoningContentFallback: '', - thinkingRequestFormat: 'deepseek-compatible', + thinkingRequestFormat: 'zai-compatible', maxTokensField: 'max_tokens', removeBodyFields: ['store'], }, @@ -44,6 +44,17 @@ export default defineVendor({ catalog: { source: 'static', models: [ + { + id: 'glm-5.2', + apiName: 'glm-5.2', + label: 'GLM-5.2', + modelDescriptorId: 'glm-5.2', + transportOverrides: { + openaiShim: { + enableToolStreaming: true, + }, + }, + }, { id: 'GLM-5.1', apiName: 'GLM-5.1', diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index a92843d92..1359ff75c 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -5188,6 +5188,7 @@ test('self-heals tool-call incompatibility by retrying local Ollama requests wit (Array.isArray(requestBodies[1]?.tools) && requestBodies[1]?.tools.length === 0), ).toBe(true) expect(requestBodies[1]?.tool_choice).toBeUndefined() + expect(requestBodies[1]?.tool_stream).toBeUndefined() }) test('preserves valid tool_result and drops orphan tool_result', async () => { @@ -6332,6 +6333,421 @@ test('Z.AI: thinking mode enabled when requested', async () => { expect(requestBody?.max_tokens).toBe(1024) }) +test('Z.AI GLM-5.2: default request relies on provider thinking defaults', async () => { + process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' + process.env.OPENAI_API_KEY = 'sk-zai-test' + + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'glm-5.2', + choices: [ + { message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'glm-5.2', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 64, + stream: false, + }) + + expect(requestBody?.model).toBe('glm-5.2') + expect(requestBody?.thinking).toBeUndefined() + expect(requestBody?.reasoning_effort).toBeUndefined() +}) + +test('Z.AI GLM-5.2: user-selected xhigh effort maps to provider max effort', async () => { + process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' + process.env.OPENAI_API_KEY = 'sk-zai-test' + + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'glm-5.2', + choices: [ + { message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as unknown as FetchType + + const client = createOpenAIShimClient({ + reasoningEffort: 'xhigh', + }) as OpenAIShimClient + await client.beta.messages.create({ + model: 'glm-5.2', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 64, + stream: false, + }) + + expect(requestBody?.model).toBe('glm-5.2') + expect(requestBody?.thinking).toEqual({ type: 'enabled' }) + expect(requestBody?.reasoning_effort).toBe('max') +}) + +test.each([ + ['glm-5.2?reasoning=low', 'high'], + ['glm-5.2?reasoning=medium', 'high'], + ['glm-5.2?reasoning=high', 'high'], + ['glm-5.2?reasoning=xhigh', 'max'], +] as const)('Z.AI GLM-5.2: %s enables mapped reasoning effort', async (model, effort) => { + process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' + process.env.OPENAI_API_KEY = 'sk-zai-test' + + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'glm-5.2', + choices: [ + { message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model, + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 64, + stream: false, + }) + + expect(requestBody?.model).toBe('glm-5.2') + expect(requestBody?.thinking).toEqual({ type: 'enabled' }) + expect(requestBody?.reasoning_effort).toBe(effort) +}) + +test.each([ + 'GLM-5.1?reasoning=high', + 'GLM-4.5-Air?reasoning=high', +] as const)('Z.AI GLM: %s does not receive GLM-5.2-only reasoning_effort', async model => { + process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' + process.env.OPENAI_API_KEY = 'sk-zai-test' + + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model, + choices: [ + { message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model, + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 64, + stream: false, + }) + + expect(requestBody?.model).toBe(model.split('?', 1)[0]) + expect(requestBody?.thinking).toEqual({ type: 'enabled' }) + expect(requestBody?.reasoning_effort).toBeUndefined() +}) + +test('Z.AI GLM-5.2: model-query thinking disable omits reasoning effort', async () => { + process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' + process.env.OPENAI_API_KEY = 'sk-zai-test' + + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'glm-5.2', + choices: [ + { message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'glm-5.2?thinking=disabled&reasoning=xhigh', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 64, + stream: false, + }) + + expect(requestBody?.model).toBe('glm-5.2') + expect(requestBody?.thinking).toEqual({ type: 'disabled' }) + expect(requestBody?.reasoning_effort).toBeUndefined() +}) + +test('Z.AI GLM-5.2: per-turn thinking overrides model-query default', async () => { + process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' + process.env.OPENAI_API_KEY = 'sk-zai-test' + + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'glm-5.2', + choices: [ + { message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'glm-5.2?thinking=disabled&reasoning=high', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 64, + stream: false, + thinking: { type: 'enabled' }, + }) + + expect(requestBody?.thinking).toEqual({ type: 'enabled' }) + expect(requestBody?.reasoning_effort).toBe('high') +}) + +test('Z.AI GLM-5.2: streaming requests with tools send tool_stream', async () => { + process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' + process.env.OPENAI_API_KEY = 'sk-zai-test' + + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return makeSseResponse(makeStreamChunks([ + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model: 'glm-5.2', + choices: [{ index: 0, delta: { content: 'ok' }, finish_reason: null }], + }, + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model: 'glm-5.2', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + }, + ])) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'glm-5.2', + messages: [{ role: 'user', content: 'run pwd' }], + tools: [ + { + name: 'Bash', + description: 'Run a shell command', + input_schema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + }, + }, + ], + max_tokens: 64, + stream: true, + }) + + expect(requestBody?.tool_stream).toBe(true) +}) + +test('Z.AI GLM-5.2: remote tool incompatibility does not use local toolless retry', async () => { + process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' + process.env.OPENAI_API_KEY = 'sk-zai-test' + + const requestBodies: Array> = [] + globalThis.fetch = (async (_input, init) => { + requestBodies.push(JSON.parse(String(init?.body)) as Record) + return new Response('tool_calls are not supported', { + status: 400, + headers: { 'Content-Type': 'text/plain' }, + }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await expect( + client.beta.messages.create({ + model: 'glm-5.2', + messages: [{ role: 'user', content: 'run pwd' }], + tools: [ + { + name: 'Bash', + description: 'Run a shell command', + input_schema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + }, + }, + ], + max_tokens: 64, + stream: true, + }), + ).rejects.toThrow() + + expect(requestBodies).toHaveLength(1) + expect(requestBodies[0]?.tool_stream).toBe(true) +}) + +test.each([ + ['non-streaming Z.AI request with tools', 'https://api.z.ai/api/coding/paas/v4', false, true, 'glm-5.2'], + ['streaming Z.AI request without tools', 'https://api.z.ai/api/coding/paas/v4', true, false, 'glm-5.2'], + ['streaming non-Z.AI request with tools', 'https://api.openai.com/v1', true, true, 'gpt-4o'], +] as const)('does not send tool_stream for %s', async (_name, baseUrl, stream, includeTools, model) => { + process.env.OPENAI_BASE_URL = baseUrl + process.env.OPENAI_API_KEY = 'sk-test' + + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + if (stream) { + return makeSseResponse(makeStreamChunks([ + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model, + choices: [{ index: 0, delta: { content: 'ok' }, finish_reason: null }], + }, + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + }, + ])) + } + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model, + choices: [ + { message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model, + messages: [{ role: 'user', content: 'hi' }], + tools: includeTools + ? [ + { + name: 'Bash', + description: 'Run a shell command', + input_schema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + }, + }, + ] + : undefined, + max_tokens: 64, + stream, + }) + + expect(requestBody?.tool_stream).toBeUndefined() +}) + +test('Z.AI GLM-5.2: preserved thinking round-trips with tool calls', async () => { + process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' + process.env.OPENAI_API_KEY = 'sk-zai-test' + + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'glm-5.2', + choices: [ + { message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'glm-5.2', + messages: [ + { role: 'user', content: 'inspect files' }, + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Need to list files before answering.' }, + { + type: 'tool_use', + id: 'call_bash_1', + name: 'Bash', + input: { command: 'ls' }, + }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'call_bash_1', content: 'README.md' }, + ], + }, + ], + max_tokens: 64, + stream: false, + }) + + const messages = requestBody?.messages as Array> + const assistantWithToolCall = messages.find( + message => message.role === 'assistant' && Array.isArray(message.tool_calls), + ) + + expect(assistantWithToolCall?.reasoning_content).toBe( + 'Need to list files before answering.', + ) + expect(assistantWithToolCall?.tool_calls).toEqual([ + { + id: 'call_bash_1', + type: 'function', + function: { + name: 'Bash', + arguments: JSON.stringify({ command: 'ls' }), + }, + }, + ]) +}) + test('strips Anthropic attribution header block from chat-completions system prompt (#607)', async () => { let capturedBody: Record | undefined diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 6dbd80143..7b97b757d 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -207,6 +207,30 @@ function normalizeDeepSeekReasoningEffort( return effort === 'xhigh' ? 'max' : 'high' } +function normalizeZaiReasoningEffort( + effort: 'low' | 'medium' | 'high' | 'xhigh', +): 'high' | 'max' { + return effort === 'xhigh' ? 'max' : 'high' +} + +function supportsZaiReasoningEffort(model: string | undefined): boolean { + const normalized = model?.trim().split('?', 1)[0]?.trim().toLowerCase() + return normalized === 'glm-5.2' +} + +function normalizeThinkingType( + value: string | undefined, +): 'enabled' | 'disabled' | undefined { + const normalized = value?.trim().toLowerCase() + if (normalized === 'disabled') { + return 'disabled' + } + if (normalized === 'enabled' || normalized === 'adaptive') { + return 'enabled' + } + return undefined +} + function formatRetryAfterHint(response: Response): string { const ra = response.headers.get('retry-after') return ra ? ` (Retry-After: ${ra})` : '' @@ -2480,11 +2504,7 @@ class OpenAIShimMessages { if (shimConfig.thinkingRequestFormat === 'deepseek-compatible') { const requestedThinkingType = (params.thinking as { type?: string } | undefined)?.type const deepSeekThinkingType = - requestedThinkingType === 'disabled' - ? 'disabled' - : requestedThinkingType === 'enabled' || requestedThinkingType === 'adaptive' - ? 'enabled' - : undefined + normalizeThinkingType(requestedThinkingType) if (deepSeekThinkingType) { body.thinking = { type: deepSeekThinkingType } @@ -2498,6 +2518,33 @@ class OpenAIShimMessages { } } + if (shimConfig.thinkingRequestFormat === 'zai-compatible') { + const requestedThinkingType = (params.thinking as { type?: string } | undefined)?.type + const zaiThinkingType = + normalizeThinkingType(requestedThinkingType) ?? + normalizeThinkingType(request.thinking?.type) + const zaiSupportsReasoningEffort = supportsZaiReasoningEffort( + request.resolvedModel, + ) + + if (zaiThinkingType === 'disabled') { + body.thinking = { type: 'disabled' } + delete body.reasoning_effort + } else if (zaiThinkingType === 'enabled' || request.reasoning?.effort) { + body.thinking = { type: 'enabled' } + } + + if (zaiThinkingType !== 'disabled' && request.reasoning?.effort) { + if (zaiSupportsReasoningEffort) { + body.reasoning_effort = normalizeZaiReasoningEffort( + request.reasoning.effort, + ) + } else { + delete body.reasoning_effort + } + } + } + if (params.tools && params.tools.length > 0) { const converted = convertTools( params.tools as Array<{ @@ -2509,6 +2556,13 @@ class OpenAIShimMessages { ) if (converted.length > 0) { body.tools = converted + if ( + effectiveTransport === 'chat_completions' && + params.stream && + shimConfig.enableToolStreaming === true + ) { + body.tool_stream = true + } if (params.tool_choice) { const tc = params.tool_choice as { type?: string; name?: string } if (tc.type === 'auto') { @@ -3241,6 +3295,7 @@ class OpenAIShimMessages { didRetryWithoutTools = true delete body.tools delete body.tool_choice + delete body.tool_stream omitResponsesTools = true omitAnthropicTools = true omitGeminiTools = true diff --git a/src/services/api/providerConfig.github.test.ts b/src/services/api/providerConfig.github.test.ts index 23447247b..b6953e374 100644 --- a/src/services/api/providerConfig.github.test.ts +++ b/src/services/api/providerConfig.github.test.ts @@ -118,6 +118,20 @@ test('resolveProviderRequest expands Enterprise origin base URL to Copilot API p expect(r.transport).toBe('codex_responses') }) +test('resolveProviderRequest expands GHE base URL without Enterprise env', () => { + const r = resolveProviderRequest({ + model: 'github:copilot:gpt-5.3-codex', + processEnv: { + CLAUDE_CODE_USE_GITHUB: '1', + OPENAI_BASE_URL: 'https://octo.ghe.com', + }, + }) + + expect(r.baseUrl).toBe('https://octo.ghe.com/api/copilot') + expect(r.resolvedModel).toBe('gpt-5.3-codex') + expect(r.transport).toBe('codex_responses') +}) + test('resolveProviderRequest leaves model unchanged without GitHub flag', () => { delete process.env.CLAUDE_CODE_USE_GITHUB const r = resolveProviderRequest({ model: 'github:gpt-4o' }) diff --git a/src/services/api/providerConfig.test.ts b/src/services/api/providerConfig.test.ts new file mode 100644 index 000000000..a3ce55abc --- /dev/null +++ b/src/services/api/providerConfig.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'bun:test' + +import { resolveProviderRequest } from './providerConfig.js' + +test('resolveProviderRequest strips GLM model-query suffixes from API model value', () => { + const request = resolveProviderRequest({ + model: 'glm-5.2?reasoning=high', + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + processEnv: {}, + }) + + expect(request.requestedModel).toBe('glm-5.2?reasoning=high') + expect(request.resolvedModel).toBe('glm-5.2') + expect(request.reasoning).toEqual({ effort: 'high' }) +}) + +test('resolveProviderRequest exposes model-query thinking defaults', () => { + const request = resolveProviderRequest({ + model: 'glm-5.2?thinking=disabled', + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + processEnv: {}, + }) + + expect(request.resolvedModel).toBe('glm-5.2') + expect(request.thinking).toEqual({ type: 'disabled' }) +}) diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index a298e163b..94ee94378 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -118,6 +118,7 @@ const CODEX_ALIAS_MODELS: Record< type CodexAlias = keyof typeof CODEX_ALIAS_MODELS type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' +type ThinkingType = 'enabled' | 'disabled' const OPENAI_CODEX_SHORTCUT_ALIASES = new Set(['codexplan', 'codexspark']) @@ -132,6 +133,9 @@ export type ResolvedProviderRequest = { reasoning?: { effort: ReasoningEffort } + thinking?: { + type: ThinkingType + } } export type ResolvedCodexCredentials = { @@ -147,6 +151,9 @@ type ModelDescriptor = { reasoning?: { effort: ReasoningEffort } + thinking?: { + type: ThinkingType + } } const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']) @@ -251,6 +258,14 @@ function parseReasoningEffort(value: string | undefined): ReasoningEffort | unde return undefined } +function parseThinkingType(value: string | undefined): ThinkingType | undefined { + if (!value) return undefined + const normalized = value.trim().toLowerCase() + return normalized === 'enabled' || normalized === 'disabled' + ? normalized + : undefined +} + export function parseOpenAICompatibleApiFormat( value: string | undefined, ): OpenAICompatibleApiFormat | undefined { @@ -309,11 +324,13 @@ function parseModelDescriptor(model: string): ModelDescriptor { (aliasConfig?.reasoningEffort ? { effort: aliasConfig.reasoningEffort } : undefined) + const thinking = parseThinkingType(params.get('thinking') ?? undefined) return { raw: trimmed, baseModel: resolvedBaseModel, reasoning: typeof reasoning === 'string' ? { effort: reasoning } : reasoning, + thinking: thinking ? { type: thinking } : undefined, } } @@ -818,9 +835,11 @@ export function resolveProviderRequest(options?: { ? normalizeGithubModelsApiModel(requestedModel) : requestedModel - // For GHE instances, build the Copilot API base URL from GITHUB_ENTERPRISE_URL - const gheCopilotBaseUrl = gheUrl - ? buildGithubEnterpriseCopilotBaseUrl(gheUrl) + // For GHE instances, build the Copilot API base URL from either + // GITHUB_ENTERPRISE_URL or an already-classified GHE OPENAI_BASE_URL. + const gheBaseUrl = isGithubGhe ? (gheUrl ?? rawBaseUrl) : undefined + const gheCopilotBaseUrl = gheBaseUrl + ? buildGithubEnterpriseCopilotBaseUrl(gheBaseUrl) : undefined const requestedApiFormat = @@ -883,6 +902,7 @@ export function resolveProviderRequest(options?: { : DEFAULT_OPENAI_BASE_URL)))) ).replace(/\/+$/, ''), reasoning, + thinking: descriptor.thinking, } } diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index 1bceb39fe..541471b36 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -718,10 +718,15 @@ test('DashScope glm-4.7 uses provider-specific context and output caps', () => { }) }) -test('Z.AI uppercase GLM models use Coding Plan output caps', () => { +test('Z.AI GLM models use Coding Plan output caps', () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS + expect(getContextWindowForModel('glm-5.2')).toBe(1_000_000) + expect(getModelMaxOutputTokens('glm-5.2')).toEqual({ + default: 131_072, + upperLimit: 131_072, + }) expect(getContextWindowForModel('GLM-5.1')).toBe(202_752) expect(getModelMaxOutputTokens('GLM-5.1')).toEqual({ default: 131_072, diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index 9aea2972d..17b8ac6e9 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -512,7 +512,7 @@ describe('applyProviderFlag - zai', () => { expect(result.error).toBeUndefined() expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') expect(process.env.OPENAI_BASE_URL).toBe('https://api.z.ai/api/coding/paas/v4') - expect(process.env.OPENAI_MODEL).toBe('GLM-5.1') + expect(process.env.OPENAI_MODEL).toBe('glm-5.2') }) }) diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index a3ecc2a84..90abe1662 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -1689,7 +1689,7 @@ describe('getProviderPresetDefaults', () => { expect(defaults.provider).toBe('zai') expect(defaults.name).toBe('Z.AI - GLM Coding Plan') expect(defaults.baseUrl).toBe('https://api.z.ai/api/coding/paas/v4') - expect(defaults.model).toBe('GLM-5.1') + expect(defaults.model).toBe('glm-5.2') expect(defaults.requiresApiKey).toBe(true) }) diff --git a/src/utils/thinking.test.ts b/src/utils/thinking.test.ts index 12a29d0db..1083f8d04 100644 --- a/src/utils/thinking.test.ts +++ b/src/utils/thinking.test.ts @@ -79,6 +79,8 @@ describe('modelSupportsThinking — Z.AI GLM', () => { expect(modelSupportsThinking('GLM-5-Turbo')).toBe(true) expect(modelSupportsThinking('GLM-4.7')).toBe(true) expect(modelSupportsThinking('GLM-4.5-Air')).toBe(true) + expect(modelSupportsThinking('glm-5.2?thinking=disabled')).toBe(true) + expect(modelSupportsThinking('glm-5.2 ?thinking=disabled')).toBe(true) }) test('does not enable GLM thinking on non-Z.AI OpenAI-compatible endpoints', async () => { diff --git a/src/utils/thinking.ts b/src/utils/thinking.ts index 73317007f..55a2bbf2c 100644 --- a/src/utils/thinking.ts +++ b/src/utils/thinking.ts @@ -90,7 +90,7 @@ function routeCatalogSupportsThinking(model: string): boolean | undefined { return undefined } - const normalizedModel = model.trim().toLowerCase() + const normalizedModel = model.trim().split('?', 1)[0]!.trim().toLowerCase() const entry = getCatalogEntriesForRoute(routeId).find(catalogEntry => catalogEntry.apiName.trim().toLowerCase() === normalizedModel || catalogEntry.id.trim().toLowerCase() === normalizedModel,