mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
+6
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export default defineBrand({
|
||||
supportsPreciseTokenCount: false,
|
||||
},
|
||||
modelIds: [
|
||||
'glm-5.2',
|
||||
'GLM-5.1',
|
||||
'GLM-5-Turbo',
|
||||
'GLM-5',
|
||||
|
||||
@@ -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'). */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Vendored
+13
-2
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user