diff --git a/src/core/ai/recipes/index.ts b/src/core/ai/recipes/index.ts index 3ebe56361..d4810f2e8 100644 --- a/src/core/ai/recipes/index.ts +++ b/src/core/ai/recipes/index.ts @@ -18,6 +18,7 @@ import { together } from './together.ts'; import { llamaServer } from './llama-server.ts'; import { minimax } from './minimax.ts'; import { dashscope } from './dashscope.ts'; +import { zhipu } from './zhipu.ts'; const ALL: Recipe[] = [ openai, @@ -32,6 +33,7 @@ const ALL: Recipe[] = [ llamaServer, minimax, dashscope, + zhipu, ]; /** Map from `provider:id` key to recipe. */ diff --git a/src/core/ai/recipes/zhipu.ts b/src/core/ai/recipes/zhipu.ts new file mode 100644 index 000000000..758c5c9cb --- /dev/null +++ b/src/core/ai/recipes/zhipu.ts @@ -0,0 +1,40 @@ +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). + * + * embedding-3 at 2048 dims exceeds pgvector's HNSW cap of 2000 — those + * brains fall back to exact vector scans (see + * src/core/ai/vector-index.ts:PGVECTOR_HNSW_VECTOR_MAX_DIMS). v0.32 ships + * with `default_dims: 1024` (HNSW-compatible) and exposes 2048 via + * dims_options for users who want the full embedding fidelity at the + * cost of slower retrieval. + * + * Reference: https://open.bigmodel.cn/ + */ +export const zhipu: Recipe = { + id: 'zhipu', + name: 'Zhipu AI (智谱AI BigModel)', + tier: 'openai-compat', + implementation: 'openai-compatible', + base_url_default: 'https://open.bigmodel.cn/api/paas/v4', + auth_env: { + required: ['ZHIPUAI_API_KEY'], + setup_url: 'https://open.bigmodel.cn/', + }, + touchpoints: { + embedding: { + models: ['embedding-3', 'embedding-2'], + default_dims: 1024, + // 2048 exposed but breaks HNSW (exact-scan fallback). 1024/512/256 + // stay HNSW-compatible. + dims_options: [256, 512, 1024, 2048], + max_batch_tokens: 8192, + chars_per_token: 2, + }, + }, + setup_hint: + 'Get an API key at https://open.bigmodel.cn/, then `export ZHIPUAI_API_KEY=...`', +}; diff --git a/test/ai/recipe-zhipu.test.ts b/test/ai/recipe-zhipu.test.ts new file mode 100644 index 000000000..65cce385c --- /dev/null +++ b/test/ai/recipe-zhipu.test.ts @@ -0,0 +1,71 @@ +/** + * Zhipu AI (BigModel) recipe smoke (Commit 7 of the v0.32 wave). + * + * Coverage: + * - Recipe registered with expected shape + * - default auth: ZHIPUAI_API_KEY → "Bearer "; missing → AIConfigError + * - dims_options exposes [256, 512, 1024, 2048]; default 1024 (HNSW-compatible) + * - 2048-dim path falls into exact-scan branch via chunkEmbeddingIndexSql + * from src/core/vector-index.ts + */ + +import { describe, expect, test } from 'bun:test'; +import { getRecipe } from '../../src/core/ai/recipes/index.ts'; +import { defaultResolveAuth } from '../../src/core/ai/gateway.ts'; +import { AIConfigError } from '../../src/core/ai/errors.ts'; +import { + PGVECTOR_HNSW_VECTOR_MAX_DIMS, + chunkEmbeddingIndexSql, +} from '../../src/core/vector-index.ts'; + +describe('recipe: zhipu', () => { + test('registered with expected shape', () => { + const r = getRecipe('zhipu'); + expect(r).toBeDefined(); + expect(r!.id).toBe('zhipu'); + expect(r!.tier).toBe('openai-compat'); + expect(r!.implementation).toBe('openai-compatible'); + expect(r!.base_url_default).toBe('https://open.bigmodel.cn/api/paas/v4'); + expect(r!.auth_env?.required).toEqual(['ZHIPUAI_API_KEY']); + }); + + test('embedding touchpoint declares embedding-3 first + 1024 dims (HNSW-compatible default)', () => { + const r = getRecipe('zhipu')!; + expect(r.touchpoints.embedding).toBeDefined(); + expect(r.touchpoints.embedding!.models[0]).toBe('embedding-3'); + expect(r.touchpoints.embedding!.models).toContain('embedding-2'); + expect(r.touchpoints.embedding!.default_dims).toBe(1024); + expect(r.touchpoints.embedding!.dims_options).toEqual([256, 512, 1024, 2048]); + // The default must stay HNSW-compatible. + expect(r.touchpoints.embedding!.default_dims).toBeLessThanOrEqual( + PGVECTOR_HNSW_VECTOR_MAX_DIMS, + ); + }); + + test('default auth: ZHIPUAI_API_KEY set → "Bearer "', () => { + const r = getRecipe('zhipu')!; + const auth = defaultResolveAuth(r, { ZHIPUAI_API_KEY: 'fake-zhipu-key' }, 'embedding'); + expect(auth.headerName).toBe('Authorization'); + expect(auth.token).toBe('Bearer fake-zhipu-key'); + }); + + test('default auth: missing ZHIPUAI_API_KEY → AIConfigError', () => { + const r = getRecipe('zhipu')!; + expect(() => defaultResolveAuth(r, {}, 'embedding')).toThrow(AIConfigError); + }); + + test('2048-dim option from dims_options falls into exact-scan branch', () => { + // 2048d exceeds the HNSW cap, so chunkEmbeddingIndexSql returns the + // exact-scan-skip-index path. Users picking 2048 trade ANN speed for + // full embedding fidelity. + const sql = chunkEmbeddingIndexSql(2048); + expect(sql.toLowerCase()).toContain('skipped'); + expect(sql.toLowerCase()).toContain('hnsw'); + }); + + test('1024-dim default returns the HNSW index SQL (fast path)', () => { + const sql = chunkEmbeddingIndexSql(1024); + expect(sql.toLowerCase()).toContain('create index'); + expect(sql.toLowerCase()).toContain('hnsw'); + }); +});