diff --git a/src/core/ai/capabilities.ts b/src/core/ai/capabilities.ts index 5d860a0ab..ac73d9350 100644 --- a/src/core/ai/capabilities.ts +++ b/src/core/ai/capabilities.ts @@ -22,6 +22,7 @@ */ import { resolveRecipe } from './model-resolver.ts'; +import { listRecipes } from './recipes/index.ts'; import { AIConfigError } from './errors.ts'; export interface ProviderCapabilities { @@ -77,7 +78,10 @@ export function getProviderCapabilities(modelString: string): ProviderCapabiliti if (!chat) { throw new AIConfigError( `Provider "${recipe.id}" does not offer a chat touchpoint.`, - `Known providers with chat: openai, anthropic, google, openrouter, litellm-proxy, deepseek, groq, together, azure-openai, dashscope, minimax, zhipu, ollama, llama-server. Pick one for models.tier.subagent.`, + // Computed from the registry so the hint can't drift into listing + // chat-less providers (the pre-fix list falsely included embedding-only + // recipes, sending users in circles — #1157). + `Known providers with chat: ${listRecipes().filter(r => r.touchpoints.chat).map(r => r.id).join(', ')}. Pick one for models.tier.subagent.`, ); } diff --git a/src/core/ai/recipes/zhipu.ts b/src/core/ai/recipes/zhipu.ts index 758c5c9cb..75ec63efe 100644 --- a/src/core/ai/recipes/zhipu.ts +++ b/src/core/ai/recipes/zhipu.ts @@ -1,9 +1,10 @@ import type { Recipe } from '../types.ts'; /** - * Zhipu AI (智谱AI) BigModel Open Platform. OpenAI-compatible /embeddings - * endpoint at open.bigmodel.cn. Hosts embedding-2 (1024d) and embedding-3 - * (Matryoshka up to 2048d). + * Zhipu AI (智谱AI) BigModel Open Platform. OpenAI-compatible /embeddings and + * /chat/completions endpoints at open.bigmodel.cn. Hosts embedding-2 (1024d), + * embedding-3 (Matryoshka up to 2048d), and the GLM chat family (glm-5.1 etc.) + * with native tool calling — usable for models.tier.subagent (#1157). * * embedding-3 at 2048 dims exceeds pgvector's HNSW cap of 2000 — those * brains fall back to exact vector scans (see @@ -25,6 +26,20 @@ export const zhipu: Recipe = { setup_url: 'https://open.bigmodel.cn/', }, touchpoints: { + chat: { + // Informational list (openai-compat tier: assertTouchpoint doesn't + // enforce it), so newer GLM ids pass without a recipe edit. + models: ['glm-5.1', 'glm-4.6', 'glm-4.5'], + supports_tools: true, + // gbrain-side stable tool ids (v0.38 D11) decoupled the loop from + // Anthropic response formats; GLM tool calling is stable through the + // OpenAI-compat path, same as deepseek/groq. + supports_subagent_loop: true, + // Anthropic-style cache_control markers are not honored on the + // OpenAI-compat path — the loop runs hot (degraded:no_caching warn). + supports_prompt_cache: false, + max_context_tokens: 128000, + }, embedding: { models: ['embedding-3', 'embedding-2'], default_dims: 1024, @@ -36,5 +51,5 @@ export const zhipu: Recipe = { }, }, setup_hint: - 'Get an API key at https://open.bigmodel.cn/, then `export ZHIPUAI_API_KEY=...`', + 'Get an API key at https://open.bigmodel.cn/, then `export ZHIPUAI_API_KEY=...`. Chat/subagent: use `zhipu:glm-5.1`.', }; diff --git a/test/ai/recipe-zhipu.test.ts b/test/ai/recipe-zhipu.test.ts index dfb5f112a..1a9507bd7 100644 --- a/test/ai/recipe-zhipu.test.ts +++ b/test/ai/recipe-zhipu.test.ts @@ -69,6 +69,45 @@ describe('recipe: zhipu', () => { expect(sql.toLowerCase()).toContain('hnsw'); }); + test('chat touchpoint declares GLM models with tool + subagent-loop support (#1157)', () => { + const r = getRecipe('zhipu')!; + expect(r.touchpoints.chat).toBeDefined(); + expect(r.touchpoints.chat!.models).toContain('glm-5.1'); + expect(r.touchpoints.chat!.supports_tools).toBe(true); + expect(r.touchpoints.chat!.supports_subagent_loop).toBe(true); + expect(r.touchpoints.chat!.supports_prompt_cache).toBe(false); + }); + + test('zhipu:glm-5.1 passes the subagent capability gate (degraded:no_caching, not refused)', async () => { + // Pre-fix: getProviderCapabilities threw "does not offer a chat touchpoint" + // and classifyCapabilities returned 'unknown' → subagent submit refused. + const { getProviderCapabilities, classifyCapabilities } = + await import('../../src/core/ai/capabilities.ts'); + const caps = getProviderCapabilities('zhipu:glm-5.1'); + expect(caps.supportsToolCalling).toBe(true); + expect(classifyCapabilities('zhipu:glm-5.1')).toBe('degraded:no_caching'); + }); + + test('no-chat-touchpoint error hint lists only providers that actually have chat', async () => { + // The hint is computed from the registry; every provider it names must + // really carry a chat touchpoint (pre-fix it hardcoded zhipu/dashscope/ + // minimax, all embedding-only at the time). + const { getProviderCapabilities } = await import('../../src/core/ai/capabilities.ts'); + const { listRecipes } = await import('../../src/core/ai/recipes/index.ts'); + let hint = ''; + try { + getProviderCapabilities('voyage:voyage-3'); + throw new Error('expected AIConfigError for embedding-only provider'); + } catch (e) { + hint = (e as { fix?: string }).fix ?? String(e); + } + const listed = hint.match(/chat: ([^.]+)\./)?.[1]?.split(', ') ?? []; + expect(listed.length).toBeGreaterThan(0); + const withChat = new Set(listRecipes().filter(r => r.touchpoints.chat).map(r => r.id)); + for (const id of listed) expect(withChat.has(id)).toBe(true); + expect(listed).toContain('zhipu'); + }); + test('dimsProviderOptions threads dimensions for embedding-3 (Matryoshka)', async () => { // Codex finding #1: Zhipu embedding-3 is Matryoshka 256-2048. Without // `dimensions` on the wire, user-selected non-default dims are