Compare commits

..
Author SHA1 Message Date
SinabinaandClaude Fable 5 453c480989 fix(gateway): add chat touchpoint to zhipu recipe so GLM subagents work (#1157)
The zhipu recipe was embedding-only, so models.tier.subagent=zhipu:glm-5.1
threw "does not offer a chat touchpoint" — while the error hint falsely
listed zhipu (and dashscope/minimax, also embedding-only) among providers
with chat.

- zhipu recipe: add a chat touchpoint (glm-5.1 family, supports_tools +
  supports_subagent_loop; no Anthropic-style prompt cache on the
  OpenAI-compat path, so the loop runs with the degraded:no_caching warn).
  openai-compat tier means newer GLM ids pass without a recipe edit.
- capabilities.ts: compute the "Known providers with chat" hint from the
  recipe registry instead of a hardcoded list, so it can never drift into
  naming chat-less providers again.
- Declines the originally requested models.anthropic_compatible_prefixes
  config: v0.38's recipe-driven capability gate already replaced the
  Anthropic-only enforcement, so a recipe chat touchpoint is the whole fix.

Fixes #1157

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:22:03 -07:00
8 changed files with 78 additions and 112 deletions
+5 -1
View File
@@ -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.`,
);
}
+19 -4
View File
@@ -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`.',
};
+4 -13
View File
@@ -14,7 +14,7 @@
*/
import type { BrainEngine } from './engine.ts';
import { PGVECTOR_HNSW_VECTOR_MAX_DIMS, hnswMaxDimsForType } from './vector-index.ts';
import { PGVECTOR_HNSW_VECTOR_MAX_DIMS } from './vector-index.ts';
import { gbrainPath } from './config.ts';
import { resolveRecipe } from './ai/model-resolver.ts';
import type { Recipe } from './ai/types.ts';
@@ -609,17 +609,6 @@ export function buildFactsAlterRecipe(
const opclass = columnType === 'halfvec' ? 'halfvec_cosine_ops' : 'vector_cosine_ops';
const targetType = columnType === 'halfvec' ? `halfvec(${configuredDims})` : `vector(${configuredDims})`;
const dimsChanged = columnDims !== configuredDims;
const hnswMaxDims = hnswMaxDimsForType(columnType);
const indexLines = configuredDims <= hnswMaxDims
? [
`CREATE INDEX idx_facts_embedding_hnsw`,
` ON facts USING hnsw (embedding ${opclass})`,
` WHERE embedding IS NOT NULL AND expired_at IS NULL;`,
]
: [
`-- Skip reindex. ${columnType}(${configuredDims}) exceeds pgvector's HNSW cap of ${hnswMaxDims};`,
`-- fact similarity falls back to exact scans.`,
];
return [
`-- ALTER ${columnType}(${columnDims}) → ${columnType}(${configuredDims}) on indexed column.`,
`-- HOLD a maintenance window: this rewrites every row's embedding.`,
@@ -640,7 +629,9 @@ export function buildFactsAlterRecipe(
: []),
`ALTER TABLE facts ALTER COLUMN embedding TYPE ${targetType}`,
` USING embedding::${targetType};`,
...indexLines,
`CREATE INDEX idx_facts_embedding_hnsw`,
` ON facts USING hnsw (embedding ${opclass})`,
` WHERE embedding IS NOT NULL AND expired_at IS NULL;`,
].join('\n');
}
+8 -21
View File
@@ -1,7 +1,6 @@
import type { BrainEngine } from './engine.ts';
import { slugifyPath } from './sync.ts';
import { getFtsLanguage } from './fts-language.ts';
import { hnswMaxDimsForType } from './vector-index.ts';
/**
* Schema migrations run automatically on initSchema().
@@ -2277,19 +2276,11 @@ export const MIGRATIONS: Migration[] = [
useHalfvec = true;
}
const columnType = useHalfvec ? 'halfvec' : 'vector';
const vecType = columnType.toUpperCase();
const vecType = useHalfvec ? 'HALFVEC' : 'VECTOR';
// HNSW operator class must match the column type:
// VECTOR(n) → vector_cosine_ops
// HALFVEC(n) → halfvec_cosine_ops
const opclass = useHalfvec ? 'halfvec_cosine_ops' : 'vector_cosine_ops';
const hnswMaxDims = hnswMaxDimsForType(columnType);
const factsEmbeddingIndexSql = embeddingDim <= hnswMaxDims
? `CREATE INDEX IF NOT EXISTS idx_facts_embedding_hnsw
ON facts USING hnsw (embedding ${opclass})
WHERE embedding IS NOT NULL AND expired_at IS NULL;`
: `-- idx_facts_embedding_hnsw skipped: pgvector HNSW ${columnType} indexes support
-- at most ${hnswMaxDims} dimensions; exact vector scans remain available.`;
// FK to sources is added in a separate ALTER TABLE rather than inline
// on the column. Inline `REFERENCES` worked on PGLite but silently
// got dropped by postgres.js's `unsafe()` multi-statement path on
@@ -2363,7 +2354,9 @@ export const MIGRATIONS: Migration[] = [
ON facts(source_id, entity_slug)
WHERE consolidated_at IS NULL AND expired_at IS NULL;
${factsEmbeddingIndexSql}
CREATE INDEX IF NOT EXISTS idx_facts_embedding_hnsw
ON facts USING hnsw (embedding ${opclass})
WHERE embedding IS NOT NULL AND expired_at IS NULL;
`;
await engine.runMigration(40, factsDDL);
@@ -2877,16 +2870,8 @@ export const MIGRATIONS: Migration[] = [
useHalfvec = true;
}
const columnType = useHalfvec ? 'halfvec' : 'vector';
const vecType = columnType.toUpperCase();
const vecType = useHalfvec ? 'HALFVEC' : 'VECTOR';
const opclass = useHalfvec ? 'halfvec_cosine_ops' : 'vector_cosine_ops';
const hnswMaxDims = hnswMaxDimsForType(columnType);
const queryCacheEmbeddingIndexSql = embeddingDim <= hnswMaxDims
? `CREATE INDEX IF NOT EXISTS idx_query_cache_embedding_hnsw
ON query_cache USING hnsw (embedding ${opclass})
WHERE embedding IS NOT NULL;`
: `-- idx_query_cache_embedding_hnsw skipped: pgvector HNSW ${columnType} indexes support
-- at most ${hnswMaxDims} dimensions; exact vector scans remain available.`;
const ddl = `
CREATE TABLE IF NOT EXISTS query_cache (
@@ -2905,7 +2890,9 @@ export const MIGRATIONS: Migration[] = [
CREATE INDEX IF NOT EXISTS idx_query_cache_source_created
ON query_cache(source_id, created_at DESC);
${queryCacheEmbeddingIndexSql}
CREATE INDEX IF NOT EXISTS idx_query_cache_embedding_hnsw
ON query_cache USING hnsw (embedding ${opclass})
WHERE embedding IS NOT NULL;
`;
await engine.runMigration(55, ddl);
-5
View File
@@ -17,7 +17,6 @@
import type { BrainEngine } from './engine.ts';
export const PGVECTOR_HNSW_VECTOR_MAX_DIMS = 2000;
export const PGVECTOR_HNSW_HALFVEC_MAX_DIMS = 4000;
const CHUNK_EMBEDDING_HNSW_INDEX =
'CREATE INDEX IF NOT EXISTS idx_chunks_embedding ON content_chunks USING hnsw (embedding vector_cosine_ops);';
@@ -30,10 +29,6 @@ export function chunkEmbeddingIndexSql(dims: number): string {
].join('\n');
}
export function hnswMaxDimsForType(columnType: 'vector' | 'halfvec'): number {
return columnType === 'halfvec' ? PGVECTOR_HNSW_HALFVEC_MAX_DIMS : PGVECTOR_HNSW_VECTOR_MAX_DIMS;
}
export function applyChunkEmbeddingIndexPolicy(sql: string, dims: number): string {
return sql.replaceAll(CHUNK_EMBEDDING_HNSW_INDEX, chunkEmbeddingIndexSql(dims));
}
+39
View File
@@ -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
+3 -11
View File
@@ -122,9 +122,9 @@ describe('buildFactsAlterRecipe', () => {
});
test('vector recipe uses vector_cosine_ops + vector(N) USING cast', () => {
const recipe = buildFactsAlterRecipe(1024, 1536, 'vector');
expect(recipe).toContain('vector(1536)');
expect(recipe).toContain('USING embedding::vector(1536)');
const recipe = buildFactsAlterRecipe(1024, 2048, 'vector');
expect(recipe).toContain('vector(2048)');
expect(recipe).toContain('USING embedding::vector(2048)');
expect(recipe).toContain('vector_cosine_ops');
expect(recipe).not.toContain('halfvec_cosine_ops');
});
@@ -163,14 +163,6 @@ describe('buildFactsAlterRecipe', () => {
expect(recipe).not.toContain('UPDATE facts SET embedding = NULL');
expect(recipe).toContain('USING embedding::vector(1536)');
});
test('halfvec recipe skips HNSW rebuild above pgvector cap', () => {
const recipe = buildFactsAlterRecipe(1536, 4096, 'halfvec');
expect(recipe).toContain('halfvec(4096)');
expect(recipe).toContain('Skip reindex');
expect(recipe).toContain("exceeds pgvector's HNSW cap of 4000");
expect(recipe).not.toMatch(/CREATE INDEX idx_facts_embedding_hnsw[\s\S]*USING hnsw/);
});
});
describe('FactsEmbeddingDimMismatchError', () => {
-57
View File
@@ -11,7 +11,6 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
let engine: PGLiteEngine;
@@ -94,60 +93,4 @@ describe('migration v45 facts column shape', () => {
);
expect(after[0].udt_name).toBe(before[0].udt_name);
});
});
describe('migration v45/v55 large-dim HNSW policy', () => {
let largeDimEngine: PGLiteEngine;
beforeAll(async () => {
configureGateway({
embedding_model: 'litellm:custom-4096d',
embedding_dimensions: 4096,
env: { ...process.env },
});
largeDimEngine = new PGLiteEngine();
await largeDimEngine.connect({});
await largeDimEngine.initSchema();
});
afterAll(async () => {
await largeDimEngine.disconnect();
resetGateway();
});
test('4096d init skips unsupported HNSW indexes but keeps vector columns', async () => {
const formatRows = await largeDimEngine.executeRaw<{ format_type: string }>(
`SELECT format_type(atttypid, atttypmod) AS format_type
FROM pg_attribute
WHERE attrelid = 'facts'::regclass AND attname = 'embedding'`,
);
expect(formatRows[0]?.format_type).toMatch(/(halfvec|vector)\(4096\)/);
const indexRows = await largeDimEngine.executeRaw<{ exists: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'facts'
AND indexname = 'idx_facts_embedding_hnsw'
) AS exists`,
);
expect(indexRows[0]?.exists).toBe(false);
const queryCacheFormatRows = await largeDimEngine.executeRaw<{ format_type: string }>(
`SELECT format_type(atttypid, atttypmod) AS format_type
FROM pg_attribute
WHERE attrelid = 'query_cache'::regclass AND attname = 'embedding'`,
);
expect(queryCacheFormatRows[0]?.format_type).toMatch(/(halfvec|vector)\(4096\)/);
const queryCacheIndexRows = await largeDimEngine.executeRaw<{ exists: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'query_cache'
AND indexname = 'idx_query_cache_embedding_hnsw'
) AS exists`,
);
expect(queryCacheIndexRows[0]?.exists).toBe(false);
}, 60000);
});