From da34dda894200217ecd2902d877a7f56f33622cc Mon Sep 17 00:00:00 2001 From: chioarub Date: Sat, 15 Aug 2026 01:39:46 +0300 Subject: [PATCH 1/2] feat(zai): expand Coding Plan catalog support Signed-off-by: chioarub --- .env.example | 5 + README.md | 2 +- docs/integrations/how-to/add-model.md | 15 ++- src/integrations/brands/glm.ts | 1 + src/integrations/descriptors.ts | 5 + src/integrations/models/glm.ts | 4 + src/integrations/runtimeMetadata.test.ts | 105 +++++++++++++++++- src/integrations/runtimeMetadata.ts | 10 +- src/integrations/vendors/zai.ts | 16 +++ src/services/api/openaiShim.test.ts | 93 ++++++++++++++++ src/utils/context.test.ts | 6 + src/utils/effort.codex.test.ts | 25 +++++ src/utils/model/modelOptions.gateways.test.ts | 15 +++ src/utils/thinking.test.ts | 2 + src/utils/visionUtils.test.ts | 36 ++++++ src/utils/visionUtils.ts | 16 ++- 16 files changed, 349 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 5d5aad5970..600ddfa61a 100644 --- a/.env.example +++ b/.env.example @@ -192,7 +192,12 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # For Z.AI GLM Coding Plan, set: # OPENAI_BASE_URL=https://api.z.ai/api/coding/paas/v4 # OPENAI_MODEL=glm-5.2 +# Optional: OPENAI_MODEL=glm-5.3 (the default remains glm-5.2) # Optional: OPENAI_MODEL=GLM-5.1, GLM-5-Turbo, GLM-4.7, or GLM-4.5-Air +# Optional GLM-5.3 thinking controls: +# OPENAI_MODEL='glm-5.3?reasoning=high' # requests Z.AI reasoning_effort=high +# OPENAI_MODEL='glm-5.3?reasoning=xhigh' # maps to Z.AI reasoning_effort=max +# OPENAI_MODEL='glm-5.3?thinking=disabled' # requests low/lightweight thinking # 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 diff --git a/README.md b/README.md index 2e99e53356..1f7c88fdd6 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,7 @@ OpenClaude supports multiple providers, but behavior is not identical across all - Some providers impose lower output caps than the CLI defaults, and OpenClaude adapts where possible - AI/ML API uses the OpenAI-compatible route, defaults to `gpt-4o`, and only surfaces chat-capable models from its public catalog - 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. +- Z.AI GLM Coding Plan uses `https://api.z.ai/api/coding/paas/v4` with `glm-5.2` by default. GLM-5.3 is selectable as `glm-5.3`; use `glm-5.3?reasoning=high` or `glm-5.3?reasoning=xhigh` to request its documented high or maximum effort, and `glm-5.3?thinking=disabled` to request low/lightweight thinking. The existing GLM-5.2 query controls remain supported. - Xiaomi MiMo uses `api-key` header auth on the direct OpenAI-compatible route and currently does not support `/usage` reporting in OpenClaude - GitHub Copilot serializes sub-agent execution by default to reduce Premium Request consumption — see [Agent Routing and Step Limits](docs/agent-routing.md#github-copilot-sub-agent-optimization) for tuning diff --git a/docs/integrations/how-to/add-model.md b/docs/integrations/how-to/add-model.md index a15bb0a151..6177cb1c4b 100644 --- a/docs/integrations/how-to/add-model.md +++ b/docs/integrations/how-to/add-model.md @@ -31,7 +31,10 @@ still the source of truth for where a model is offered. capabilities. 4. Add optional shared metadata. Include `brandId`, `contextWindow`, `maxOutputTokens`, and `cacheConfig` - when the data is stable enough to be reused. + when the data is stable enough to be reused. Set + `runtimeMetadataScope: 'catalog'` when verified limits and capabilities + should apply only on route catalogs that explicitly reference the + descriptor. 5. Add `providerModelMap` only when the same model needs route-specific API names across multiple catalogs. 6. Update route-owned catalogs only if the model should be offered by those @@ -47,6 +50,11 @@ Model descriptor files should: - avoid encoding gateway availability as if every route automatically exposes the shared model. +Shared runtime metadata uses the legacy global model-name fallback by default. +Use `runtimeMetadataScope: 'catalog'` for a model whose verified limits and +capabilities belong to specific routes; that metadata then applies only when a +route catalog entry names the descriptor through `modelDescriptorId`. + Normal contributor-facing examples should not call `registerModel(...)` directly. @@ -190,6 +198,11 @@ Model lookup should prefer: second built-in model table. Built-in model limits belong in model descriptor files. +A descriptor with `runtimeMetadataScope: 'catalog'` is intentionally excluded +from global name-only lookups. Its limits and capabilities are available only +through an explicit route catalog entry, preventing one vendor's verified +contract from leaking onto an uncataloged gateway model with the same API name. + ## What not to do Avoid these patterns: diff --git a/src/integrations/brands/glm.ts b/src/integrations/brands/glm.ts index 15f1eb3ca0..7dd71e38c2 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.3', 'glm-5.2', 'GLM-5.1', 'GLM-5-Turbo', diff --git a/src/integrations/descriptors.ts b/src/integrations/descriptors.ts index 637dbc4d74..363bd3c7f3 100644 --- a/src/integrations/descriptors.ts +++ b/src/integrations/descriptors.ts @@ -355,6 +355,11 @@ export interface ModelDescriptor { reasoning?: ReasoningControlMetadata contextWindow?: number maxOutputTokens?: number + /** + * Restrict shared runtime metadata to catalog entries that explicitly + * reference this descriptor. Omit for the legacy global model-name fallback. + */ + runtimeMetadataScope?: 'global' | 'catalog' cacheConfig?: CacheConfig } diff --git a/src/integrations/models/glm.ts b/src/integrations/models/glm.ts index 64d6549c9f..28e421fae0 100644 --- a/src/integrations/models/glm.ts +++ b/src/integrations/models/glm.ts @@ -1,4 +1,5 @@ import { defineModel } from '../define.js' +import type { ModelDescriptor } from '../descriptors.js' const glmCapabilities = { supportsVision: false, @@ -14,6 +15,7 @@ function glmModel( label: string, contextWindow: number, maxOutputTokens: number, + runtimeMetadataScope?: ModelDescriptor['runtimeMetadataScope'], ) { return defineModel({ id, @@ -25,10 +27,12 @@ function glmModel( capabilities: glmCapabilities, contextWindow, maxOutputTokens, + ...(runtimeMetadataScope ? { runtimeMetadataScope } : {}), }) } export default [ + glmModel('glm-5.3', 'GLM 5.3', 1_000_000, 131_072, 'catalog'), defineModel({ id: 'glm-5v-turbo', label: 'GLM 5V Turbo', diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index bdafd2fc34..d587400a52 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -17,9 +17,55 @@ import { getRouteDiscoveryHeaders, } from './discoveryService' import { setClaudeConfigHomeDirForTesting } from '../utils/envUtils.js' +import glmBrand from './brands/glm.js' +import glmModels from './models/glm.js' +import zaiVendor from './vendors/zai.js' const originalConfigDir = process.env.CLAUDE_CONFIG_DIR +describe('Z.AI GLM-5.3 descriptor contract', () => { + it('wires the verified shared model, brand, and direct catalog entry without changing the default', () => { + const model = glmModels.find(candidate => candidate.id === 'glm-5.3') + expect(model).toMatchObject({ + id: 'glm-5.3', + label: 'GLM 5.3', + vendorId: 'zai', + brandId: 'glm', + classification: ['chat', 'reasoning', 'coding'], + defaultModel: 'glm-5.3', + contextWindow: 1_000_000, + maxOutputTokens: 131_072, + runtimeMetadataScope: 'catalog', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + }) + expect(glmBrand.modelIds?.[0]).toBe('glm-5.3') + + const catalogEntry = zaiVendor.catalog?.models?.[0] + expect(catalogEntry).toMatchObject({ + id: 'glm-5.3', + apiName: 'glm-5.3', + label: 'GLM-5.3', + modelDescriptorId: 'glm-5.3', + reasoning: { + mode: 'levels', + levels: ['high', 'xhigh'], + wireFormat: 'zai_compatible', + }, + transportOverrides: { + openaiShim: { enableToolStreaming: true }, + }, + }) + expect(zaiVendor.defaultModel).toBe('glm-5.2') + }) +}) + async function withTempConfigDir(fn: () => Promise): Promise { await acquireSharedMutationLock('integrations/runtimeMetadata.test.ts') let tempDir: string | null = null @@ -120,7 +166,24 @@ describe('resolveModelRuntimeLimits', () => { } }) }) - it('uses built-in Z.AI GLM-5.2 runtime limits', () => { + it.each([ + 'glm-5.3', + 'glm-5.3?reasoning=xhigh', + 'glm-5.3?thinking=disabled', + ])('uses verified Z.AI GLM-5.3 runtime limits for %s', model => { + const limits = resolveModelRuntimeLimits({ + model, + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://api.z.ai/api/coding/paas/v4', + }, + }) + + expect(limits.contextWindow).toBe(1_000_000) + expect(limits.maxOutputTokens).toBe(131_072) + }) + + it('keeps the built-in Z.AI GLM-5.2 runtime limits', () => { const limits = resolveModelRuntimeLimits({ model: 'glm-5.2', processEnv: { @@ -131,6 +194,23 @@ describe('resolveModelRuntimeLimits', () => { expect(limits.contextWindow).toBe(1_000_000) expect(limits.maxOutputTokens).toBe(131_072) }) + + it.each([ + ['NVIDIA NIM', 'https://integrate.api.nvidia.com/v1', { NVIDIA_NIM: '1' }], + ['OpenRouter', 'https://openrouter.ai/api/v1', { CLAUDE_CODE_USE_OPENAI: '1' }], + ['custom endpoint', 'https://proxy.example.test/v1', { CLAUDE_CODE_USE_OPENAI: '1' }], + ] as const)('does not leak direct Z.AI GLM-5.3 limits onto %s', (_name, baseUrl, routeEnv) => { + expect(resolveModelRuntimeLimits({ + model: 'glm-5.3', + processEnv: { + ...routeEnv, + OPENAI_BASE_URL: baseUrl, + }, + })).toEqual({ + contextWindow: undefined, + maxOutputTokens: undefined, + }) + }) it('uses the applied provider profile route before generic custom base URL fallback', () => { expect( resolveModelRuntimeLimits({ @@ -309,6 +389,29 @@ describe('resolveOpenAIShimRuntimeContext - Z.A.I GLM-5.2', () => { }) }) +describe('resolveOpenAIShimRuntimeContext - Z.A.I GLM-5.3', () => { + it.each([ + 'glm-5.3', + 'glm-5.3?reasoning=xhigh', + 'glm-5.3?thinking=disabled', + ])('uses the explicit direct-route GLM-5.3 contract 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.3') + expect(result.openaiShimConfig.thinkingRequestFormat).toBe('zai-compatible') + expect(result.openaiShimConfig.preserveReasoningContent).toBe(true) + expect(result.openaiShimConfig.requireReasoningContentOnAssistantMessages).toBe(true) + expect(result.openaiShimConfig.maxTokensField).toBe('max_tokens') + expect(result.openaiShimConfig.removeBodyFields).toContain('store') + expect(result.openaiShimConfig.enableToolStreaming).toBe(true) + }) +}) + describe('resolveOpenAIShimRuntimeContext - GLM on a non-Z.AI gateway (#1896)', () => { it('infers the GLM reasoning shim but not tool streaming for a third-party gateway', () => { const result = resolveOpenAIShimRuntimeContext({ diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 889e9d2179..aaa109ba01 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -509,10 +509,16 @@ export function resolveModelRuntimeLimits(options: { modelApiName, runtimeEnv, ) - const modelDescriptor = + const catalogModelDescriptor = getModelDescriptorForCatalogEntry(catalogEntry) ?? - getModelDescriptorForCatalogEntry(cachedCatalogEntry) ?? + getModelDescriptorForCatalogEntry(cachedCatalogEntry) + const inferredModelDescriptor = findModelDescriptorForApiName(routeId, modelApiName) + const modelDescriptor = + catalogModelDescriptor ?? + (inferredModelDescriptor?.runtimeMetadataScope === 'catalog' + ? null + : inferredModelDescriptor) const externalContextWindow = getOpenAIContextWindowMatches( modelApiName, runtimeEnv, diff --git a/src/integrations/vendors/zai.ts b/src/integrations/vendors/zai.ts index 2fa8a8f62a..adba02591b 100644 --- a/src/integrations/vendors/zai.ts +++ b/src/integrations/vendors/zai.ts @@ -44,6 +44,22 @@ export default defineVendor({ catalog: { source: 'static', models: [ + { + id: 'glm-5.3', + apiName: 'glm-5.3', + label: 'GLM-5.3', + modelDescriptorId: 'glm-5.3', + reasoning: { + mode: 'levels', + levels: ['high', 'xhigh'], + wireFormat: 'zai_compatible', + }, + transportOverrides: { + openaiShim: { + enableToolStreaming: true, + }, + }, + }, { id: 'glm-5.2', apiName: 'glm-5.2', diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 767c2702d9..1059b63d70 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -5437,6 +5437,98 @@ test.each([ expect(requestBody?.reasoning_effort).toBe(effort) }) +test.each([ + ['glm-5.3', undefined, undefined], + ['glm-5.3?reasoning=high', 'enabled', 'high'], + ['glm-5.3?reasoning=xhigh', 'enabled', 'max'], + ['glm-5.3?thinking=disabled', 'disabled', undefined], +] as const)('Z.AI GLM-5.3 serializes the verified request contract for %s', async ( + model, + thinkingType, + reasoningEffort, +) => { + 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.3', + 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.3') + expect(requestBody?.max_tokens).toBe(64) + expect(requestBody?.max_completion_tokens).toBeUndefined() + expect(requestBody?.store).toBeUndefined() + expect(requestBody?.thinking).toEqual( + thinkingType ? { type: thinkingType } : undefined, + ) + expect(requestBody?.reasoning_effort).toBe(reasoningEffort) +}) + +test('streaming direct Z.AI GLM-5.3 tool requests opt into 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.3', + choices: [{ index: 0, delta: { content: 'ok' }, finish_reason: null }], + }, + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model: 'glm-5.3', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + }, + ])) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const stream = await client.beta.messages.create({ + model: 'glm-5.3', + messages: [{ role: 'user', content: 'add two numbers' }], + max_tokens: 64, + stream: true, + tools: [{ + name: 'add_numbers', + description: 'Add two numbers', + input_schema: { + type: 'object', + properties: { a: { type: 'number' }, b: { type: 'number' } }, + required: ['a', 'b'], + }, + }], + }) + for await (const _event of stream as AsyncIterable) { + // Drain the mocked response so request execution completes. + } + + expect(requestBody?.tool_stream).toBe(true) +}) + test.each([ 'GLM-5.1?reasoning=high', 'GLM-4.5-Air?reasoning=high', @@ -5477,6 +5569,7 @@ test.each([ 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 NVIDIA GLM-5.3 request with tools', 'https://integrate.api.nvidia.com/v1', true, true, 'glm-5.3'], ['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 diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index 8ce2bd2b3e..d09096a643 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -907,8 +907,14 @@ test('DashScope glm-4.7 uses provider-specific context and output caps', () => { test('Z.AI GLM models use Coding Plan output caps', () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS + expect(getContextWindowForModel('glm-5.3')).toBe(1_000_000) + expect(getModelMaxOutputTokens('glm-5.3')).toEqual({ + default: 131_072, + upperLimit: 131_072, + }) expect(getContextWindowForModel('glm-5.2')).toBe(1_000_000) expect(getModelMaxOutputTokens('glm-5.2')).toEqual({ default: 131_072, diff --git a/src/utils/effort.codex.test.ts b/src/utils/effort.codex.test.ts index a32f1b074e..c86c9016b0 100644 --- a/src/utils/effort.codex.test.ts +++ b/src/utils/effort.codex.test.ts @@ -1150,6 +1150,31 @@ test('compat Z.AI routes expose only verified levels and clamp stale values', as expect(resolveAppliedEffort('GLM-5.1', 'xhigh')).toBe('high') }) +test('direct Z.AI GLM-5.3 resolves effort from explicit catalog metadata', async () => { + const { + getAvailableEffortLevels, + resolveAppliedEffort, + resolveModelReasoningControl, + } = await importFreshEffortModule({ + provider: 'openai', + supportsCodexReasoningEffort: false, + routeId: 'zai', + }) + + expect(resolveModelReasoningControl('glm-5.3')).toMatchObject({ + supportsReasoning: true, + controllable: true, + source: 'metadata', + mode: 'levels', + levels: ['high', 'xhigh'], + defaultLevel: undefined, + wireFormat: 'zai_compatible', + }) + expect(getAvailableEffortLevels('glm-5.3')).toEqual(['high', 'xhigh']) + expect(resolveAppliedEffort('glm-5.3', 'low')).toBe('high') + expect(resolveAppliedEffort('glm-5.3', 'xhigh')).toBe('xhigh') +}) + test('provider override support context ignores ambient catalog metadata', async () => { const { modelSupportsShimReasoningEffort } = await importFreshEffortModule({ provider: 'openai', diff --git a/src/utils/model/modelOptions.gateways.test.ts b/src/utils/model/modelOptions.gateways.test.ts index 35372c7080..37c3f50ea5 100644 --- a/src/utils/model/modelOptions.gateways.test.ts +++ b/src/utils/model/modelOptions.gateways.test.ts @@ -116,6 +116,21 @@ test('Kimi Code keeps context variants distinct in the active route picker', asy expect(options.find(option => option.value === 'k3-256k')?.label).toBe('Kimi K3 (256K)') }) +test('Z.AI surfaces GLM-5.3 exactly once ahead of GLM-5.2 without changing the default', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' + process.env.OPENAI_MODEL = 'glm-5.2' + process.env.OPENAI_API_KEY = 'sk-zai-test' + + const options = await getOpenAIModelOptions() + const values = options.map(option => option.value) + + expect(values.filter(value => value === 'glm-5.3')).toHaveLength(1) + expect(values.indexOf('glm-5.3')).toBeLessThan(values.indexOf('glm-5.2')) + expect(options.find(option => option.value === 'glm-5.3')?.label).toBe('GLM-5.3') + expect(options.find(option => option.value === null)?.description).toContain('glm-5.2') +}) + test('custom Anthropic endpoints use the third-party default description', async () => { process.env.ANTHROPIC_BASE_URL = 'https://proxy.example/v1' process.env.ANTHROPIC_MODEL = 'proxy-model' diff --git a/src/utils/thinking.test.ts b/src/utils/thinking.test.ts index 0e385e8c0b..0a07a2a010 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.3')).toBe(true) + expect(modelSupportsThinking('glm-5.3?thinking=disabled')).toBe(true) expect(modelSupportsThinking('glm-5.2?thinking=disabled')).toBe(true) expect(modelSupportsThinking('glm-5.2 ?thinking=disabled')).toBe(true) }) diff --git a/src/utils/visionUtils.test.ts b/src/utils/visionUtils.test.ts index 4faddfb83d..40c12b64d9 100644 --- a/src/utils/visionUtils.test.ts +++ b/src/utils/visionUtils.test.ts @@ -136,6 +136,24 @@ describe('isVisionSupported', () => { expect(isVisionSupported('gemini-2.5-pro')).toBe(true) }) + test('scopes GLM-5.3 text-only metadata to the direct Z.AI catalog', () => { + expect( + isVisionSupported('glm-5.3', { + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + }), + ).toBe(false) + expect( + isVisionSupported('glm-5.3', { + baseUrl: 'https://integrate.api.nvidia.com/v1', + }), + ).toBe(true) + expect( + isVisionSupported('glm-5.3', { + baseUrl: 'https://proxy.example.test/v1', + }), + ).toBe(true) + }) + test('falls open for unknown models so custom / non-registered providers keep working', () => { expect(isVisionSupported('custom-vision-corp/secret-model-v1')).toBe(true) expect(isVisionSupported('not-a-real-model-xyz')).toBe(true) @@ -196,6 +214,24 @@ describe('checkVisionCapabilityForFile (issue #1421)', () => { expect(result.result).toBe(true) }) + test('only blocks GLM-5.3 image reads on the direct Z.AI route', () => { + expect( + checkVisionCapabilityForFile('x.png', 'glm-5.3', { + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + }).result, + ).toBe(false) + expect( + checkVisionCapabilityForFile('x.png', 'glm-5.3', { + baseUrl: 'https://integrate.api.nvidia.com/v1', + }).result, + ).toBe(true) + expect( + checkVisionCapabilityForFile('x.png', 'glm-5.3', { + baseUrl: 'https://proxy.example.test/v1', + }).result, + ).toBe(true) + }) + test('does not gate text-file reads on non-vision models', () => { const result = checkVisionCapabilityForFile('C:\\foo\\bar.txt', 'mimo-v2.5-pro') expect(result.result).toBe(true) diff --git a/src/utils/visionUtils.ts b/src/utils/visionUtils.ts index b0918f3007..296274a0ab 100644 --- a/src/utils/visionUtils.ts +++ b/src/utils/visionUtils.ts @@ -37,6 +37,12 @@ function hasModelNamePrefix(modelApiName: string, registeredName: string): boole return next === undefined || next === ':' || next === '/' || next === '@' } +function isCatalogScopedRuntimeDescriptor( + descriptor: ReturnType | undefined, +): boolean { + return descriptor?.runtimeMetadataScope === 'catalog' +} + function findModelDescriptorFromCatalog( modelApiName: string, routeId?: string, @@ -100,15 +106,21 @@ export function findModelDescriptorForApiNameWithRoute( } const direct = getModel(trimmed) - if (direct) return direct + if (direct && !isCatalogScopedRuntimeDescriptor(direct)) return direct const catalogDescriptor = findModelDescriptorFromCatalog(trimmed) - if (catalogDescriptor) return catalogDescriptor + if ( + catalogDescriptor && + !isCatalogScopedRuntimeDescriptor(catalogDescriptor) + ) { + return catalogDescriptor + } const normalized = normalizedName(trimmed) const models = getAllModels() const candidates = models + .filter(model => !isCatalogScopedRuntimeDescriptor(model)) .map(model => ({ model, names: [ From e2cd7378e24c274096b6a4dd69371cdb12ded65d Mon Sep 17 00:00:00 2001 From: chioarub Date: Sat, 15 Aug 2026 02:16:42 +0300 Subject: [PATCH 2/2] fix(zai): use supported low reasoning mode Signed-off-by: chioarub --- .env.example | 2 +- README.md | 2 +- src/integrations/runtimeMetadata.test.ts | 3 ++- src/integrations/vendors/zai.ts | 2 +- src/services/api/openaiShim.test.ts | 4 +++- src/utils/effort.codex.test.ts | 27 +++++++++++++++++++++--- src/utils/effort.ts | 19 ++++++++++++++--- src/utils/thinking.test.ts | 2 +- 8 files changed, 49 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 600ddfa61a..6f982dd2a1 100644 --- a/.env.example +++ b/.env.example @@ -195,9 +195,9 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # Optional: OPENAI_MODEL=glm-5.3 (the default remains glm-5.2) # Optional: OPENAI_MODEL=GLM-5.1, GLM-5-Turbo, GLM-4.7, or GLM-4.5-Air # Optional GLM-5.3 thinking controls: +# OPENAI_MODEL='glm-5.3?reasoning=low' # requests Z.AI reasoning_effort=low # OPENAI_MODEL='glm-5.3?reasoning=high' # requests Z.AI reasoning_effort=high # OPENAI_MODEL='glm-5.3?reasoning=xhigh' # maps to Z.AI reasoning_effort=max -# OPENAI_MODEL='glm-5.3?thinking=disabled' # requests low/lightweight thinking # 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 diff --git a/README.md b/README.md index 1f7c88fdd6..9983154727 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,7 @@ OpenClaude supports multiple providers, but behavior is not identical across all - Some providers impose lower output caps than the CLI defaults, and OpenClaude adapts where possible - AI/ML API uses the OpenAI-compatible route, defaults to `gpt-4o`, and only surfaces chat-capable models from its public catalog - 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. GLM-5.3 is selectable as `glm-5.3`; use `glm-5.3?reasoning=high` or `glm-5.3?reasoning=xhigh` to request its documented high or maximum effort, and `glm-5.3?thinking=disabled` to request low/lightweight thinking. The existing GLM-5.2 query controls remain supported. +- Z.AI GLM Coding Plan uses `https://api.z.ai/api/coding/paas/v4` with `glm-5.2` by default. GLM-5.3 is selectable as `glm-5.3`; use `glm-5.3?reasoning=low`, `glm-5.3?reasoning=high`, or `glm-5.3?reasoning=xhigh` to request its documented low, high, or maximum effort. The existing GLM-5.2 query controls remain supported. - Xiaomi MiMo uses `api-key` header auth on the direct OpenAI-compatible route and currently does not support `/usage` reporting in OpenClaude - GitHub Copilot serializes sub-agent execution by default to reduce Premium Request consumption — see [Agent Routing and Step Limits](docs/agent-routing.md#github-copilot-sub-agent-optimization) for tuning diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index d587400a52..83695888c9 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -55,7 +55,7 @@ describe('Z.AI GLM-5.3 descriptor contract', () => { modelDescriptorId: 'glm-5.3', reasoning: { mode: 'levels', - levels: ['high', 'xhigh'], + levels: ['low', 'high', 'xhigh'], wireFormat: 'zai_compatible', }, transportOverrides: { @@ -168,6 +168,7 @@ describe('resolveModelRuntimeLimits', () => { }) it.each([ 'glm-5.3', + 'glm-5.3?reasoning=low', 'glm-5.3?reasoning=xhigh', 'glm-5.3?thinking=disabled', ])('uses verified Z.AI GLM-5.3 runtime limits for %s', model => { diff --git a/src/integrations/vendors/zai.ts b/src/integrations/vendors/zai.ts index adba02591b..00877867c3 100644 --- a/src/integrations/vendors/zai.ts +++ b/src/integrations/vendors/zai.ts @@ -51,7 +51,7 @@ export default defineVendor({ modelDescriptorId: 'glm-5.3', reasoning: { mode: 'levels', - levels: ['high', 'xhigh'], + levels: ['low', 'high', 'xhigh'], wireFormat: 'zai_compatible', }, transportOverrides: { diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 1059b63d70..256f425016 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -5439,9 +5439,11 @@ test.each([ test.each([ ['glm-5.3', undefined, undefined], + ['glm-5.3?reasoning=low', 'enabled', 'low'], ['glm-5.3?reasoning=high', 'enabled', 'high'], ['glm-5.3?reasoning=xhigh', 'enabled', 'max'], - ['glm-5.3?thinking=disabled', 'disabled', undefined], + ['glm-5.3?thinking=disabled', 'enabled', 'low'], + ['glm-5.3?thinking=disabled&reasoning=high', 'enabled', 'high'], ] as const)('Z.AI GLM-5.3 serializes the verified request contract for %s', async ( model, thinkingType, diff --git a/src/utils/effort.codex.test.ts b/src/utils/effort.codex.test.ts index c86c9016b0..aa521eae95 100644 --- a/src/utils/effort.codex.test.ts +++ b/src/utils/effort.codex.test.ts @@ -1166,12 +1166,12 @@ test('direct Z.AI GLM-5.3 resolves effort from explicit catalog metadata', async controllable: true, source: 'metadata', mode: 'levels', - levels: ['high', 'xhigh'], + levels: ['low', 'high', 'xhigh'], defaultLevel: undefined, wireFormat: 'zai_compatible', }) - expect(getAvailableEffortLevels('glm-5.3')).toEqual(['high', 'xhigh']) - expect(resolveAppliedEffort('glm-5.3', 'low')).toBe('high') + expect(getAvailableEffortLevels('glm-5.3')).toEqual(['low', 'high', 'xhigh']) + expect(resolveAppliedEffort('glm-5.3', 'low')).toBe('low') expect(resolveAppliedEffort('glm-5.3', 'xhigh')).toBe('xhigh') }) @@ -1369,6 +1369,27 @@ test('explicit compat metadata wire formats are controllable and feed the reques model: 'custom-zai-low-only', requestedEffort: 'low', reasoningControl: zaiLowOnlyControl, + })).toEqual({ + thinkingType: 'enabled', + reasoningEffort: 'low', + wireFormat: 'zai_compatible', + source: 'metadata', + }) + expect(resolveOpenAIShimReasoningRequestPlan({ + model: 'custom-zai-low-only', + requestThinkingType: 'disabled', + reasoningControl: zaiLowOnlyControl, + })).toEqual({ + thinkingType: 'enabled', + reasoningEffort: 'low', + wireFormat: 'zai_compatible', + source: 'metadata', + }) + expect(resolveOpenAIShimReasoningRequestPlan({ + model: 'custom-zai-low-only', + requestedEffort: 'high', + requestThinkingType: 'disabled', + reasoningControl: zaiLowOnlyControl, })).toEqual({ thinkingType: 'enabled', reasoningEffort: 'high', diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 9c756683c5..dbad42dab8 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -208,7 +208,9 @@ function normalizeDeepSeekReasoningEffort( function normalizeZaiReasoningEffort( effort: OpenAIShimEffortLevel, -): 'high' | 'max' { + supportsLowEffort = false, +): 'low' | 'high' | 'max' { + if (supportsLowEffort && effort === 'low') return 'low' return effort === 'xhigh' || effort === 'max' ? 'max' : 'high' } @@ -635,8 +637,16 @@ export function resolveOpenAIShimReasoningRequestPlan(options: { if (wireFormat === 'zai_compatible') { const thinkingType = requestedThinkingType ?? defaultThinkingType if (thinkingType === 'disabled') { + const supportsLowEffort = + metadataWireFormat === 'zai_compatible' && + options.reasoningControl?.levels.includes('low') === true && + options.reasoningControl.disableFormat !== 'thinking_type_disabled' + const translatedEffort = supportsLowEffort && options.requestedEffort + ? normalizeZaiReasoningEffort(options.requestedEffort, true) + : 'low' return { - thinkingType: 'disabled', + thinkingType: supportsLowEffort ? 'enabled' : 'disabled', + reasoningEffort: supportsLowEffort ? translatedEffort : undefined, wireFormat, source, } @@ -651,7 +661,10 @@ export function resolveOpenAIShimReasoningRequestPlan(options: { metadataWireFormat !== 'zai_compatible' && supportsZaiReasoningEffort(options.model) )) - ? normalizeZaiReasoningEffort(options.requestedEffort) + ? normalizeZaiReasoningEffort( + options.requestedEffort, + options.reasoningControl?.levels.includes('low') === true, + ) : undefined return { thinkingType: shouldEnableThinking ? 'enabled' : undefined, diff --git a/src/utils/thinking.test.ts b/src/utils/thinking.test.ts index 0a07a2a010..c86e749765 100644 --- a/src/utils/thinking.test.ts +++ b/src/utils/thinking.test.ts @@ -80,7 +80,7 @@ describe('modelSupportsThinking — Z.AI GLM', () => { expect(modelSupportsThinking('GLM-4.7')).toBe(true) expect(modelSupportsThinking('GLM-4.5-Air')).toBe(true) expect(modelSupportsThinking('glm-5.3')).toBe(true) - expect(modelSupportsThinking('glm-5.3?thinking=disabled')).toBe(true) + expect(modelSupportsThinking('glm-5.3?reasoning=low')).toBe(true) expect(modelSupportsThinking('glm-5.2?thinking=disabled')).toBe(true) expect(modelSupportsThinking('glm-5.2 ?thinking=disabled')).toBe(true) })