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
This commit is contained in:
Bogdan
2026-06-19 22:58:46 +08:00
committed by GitHub
parent c4aa756689
commit b581bd9ece
18 changed files with 632 additions and 28 deletions
+6 -2
View File
@@ -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
+5 -3
View File
@@ -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.<key>.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.<key>.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.
+1
View File
@@ -13,6 +13,7 @@ export default defineBrand({
supportsPreciseTokenCount: false,
},
modelIds: [
'glm-5.2',
'GLM-5.1',
'GLM-5-Turbo',
'GLM-5',
+2 -1
View File
@@ -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'). */
+1
View File
@@ -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),
+33
View File
@@ -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', () => {
+21 -8
View File
@@ -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,
)
+13 -2
View File
@@ -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',
+416
View File
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<Record<string, unknown>> = []
globalThis.fetch = (async (_input, init) => {
requestBodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>)
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<string, unknown> | 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<string, unknown> | 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<Record<string, unknown>>
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<string, unknown> | undefined
+60 -5
View File
@@ -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
@@ -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' })
+26
View File
@@ -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' })
})
+23 -3
View File
@@ -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,
}
}
+6 -1
View File
@@ -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,
+1 -1
View File
@@ -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')
})
})
+1 -1
View File
@@ -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)
})
+2
View File
@@ -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 () => {
+1 -1
View File
@@ -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,