diff --git a/src/core/ai/recipes/dashscope-rerank.ts b/src/core/ai/recipes/dashscope-rerank.ts new file mode 100644 index 000000000..155c20b56 --- /dev/null +++ b/src/core/ai/recipes/dashscope-rerank.ts @@ -0,0 +1,61 @@ +import type { Recipe } from '../types.ts'; + +/** + * Alibaba DashScope (灵积) reranker. DashScope's OpenAI-compatible surface + * splits by capability: embeddings live under `/compatible-mode/v1` (see the + * sibling `dashscope` recipe) while rerank lives under `/compatible-api/v1` + * with a PLURAL leaf — `POST {base}/reranks`. Wire shape matches ZeroEntropy: + * request `{model, query, documents, top_n?}`, response + * `{results: [{index, relevance_score}]}` — so it rides gateway.rerank()'s + * native path with only the recipe-pluggable `path` override (v0.40.6.1). + * + * This is a SEPARATE recipe rather than a reranker touchpoint on `dashscope` + * because the two capabilities need different base URLs (`compatible-mode` + * vs `compatible-api`) and `provider_base_urls` is keyed by recipe id — one + * recipe can't point embeddings and rerank at different prefixes. Same + * topology precedent as llama-server vs llama-server-reranker. + * + * Live-verified against the China endpoint (2026-07): `/reranks` with + * `qwen3-rerank` → 200 `results[].relevance_score`; `/rerank` (singular) + * → 404; `gte-rerank-v2` → 404 "Unsupported model for OpenAI compatibility + * mode" (native-API only, so it is deliberately NOT listed here). + * + * Note: the international endpoint requires a region-aware DASHSCOPE_API_KEY. + * China-region users point at https://dashscope.aliyuncs.com/compatible-api/v1 + * via `provider_base_urls['dashscope-rerank']`, mirroring the embedding + * recipe's convention. + */ +export const dashscopeRerank: Recipe = { + id: 'dashscope-rerank', + name: 'Alibaba DashScope (灵积, reranker)', + tier: 'openai-compat', + implementation: 'openai-compatible', + base_url_default: 'https://dashscope-intl.aliyuncs.com/compatible-api/v1', + auth_env: { + required: ['DASHSCOPE_API_KEY'], + setup_url: 'https://help.aliyun.com/zh/model-studio/getting-started/', + }, + touchpoints: { + reranker: { + // Only the model verified live on the OpenAI-compat /reranks surface. + // gte-rerank-v2 exists on DashScope's native API but the compat path + // rejects it ("Unsupported model for OpenAI compatibility mode"). + models: ['qwen3-rerank'], + default_model: 'qwen3-rerank', + // Mirror ZE's defensive per-request ceiling; gateway.rerank() + // pre-flights body size and fails open. + max_payload_bytes: 5_000_000, + // PLURAL leaf under compatible-api — the whole reason this recipe + // exists. `${base_url}${path}` → `…/compatible-api/v1/reranks`. + path: '/reranks', + // Hosted API: no local warmup, but cross-region latency can exceed + // the 5s gateway default (same rationale as llama-server-reranker). + default_timeout_ms: 30_000, + }, + }, + setup_hint: + 'Get an API key at https://help.aliyun.com/zh/model-studio/getting-started/, then ' + + '`export DASHSCOPE_API_KEY=...` and `gbrain config set search.reranker.model ' + + 'dashscope-rerank:qwen3-rerank`. China-region accounts: `gbrain config set ' + + 'provider_base_urls.dashscope-rerank https://dashscope.aliyuncs.com/compatible-api/v1`.', +}; diff --git a/src/core/ai/recipes/index.ts b/src/core/ai/recipes/index.ts index eb751ec61..5cba83749 100644 --- a/src/core/ai/recipes/index.ts +++ b/src/core/ai/recipes/index.ts @@ -19,6 +19,7 @@ import { together } from './together.ts'; import { llamaServer } from './llama-server.ts'; import { minimax } from './minimax.ts'; import { dashscope } from './dashscope.ts'; +import { dashscopeRerank } from './dashscope-rerank.ts'; import { zhipu } from './zhipu.ts'; import { azureOpenAI } from './azure-openai.ts'; import { zeroentropyai } from './zeroentropyai.ts'; @@ -42,6 +43,7 @@ const ALL: Recipe[] = [ llamaServerReranker, minimax, dashscope, + dashscopeRerank, zhipu, azureOpenAI, zeroentropyai, diff --git a/test/ai/recipe-dashscope-rerank.test.ts b/test/ai/recipe-dashscope-rerank.test.ts new file mode 100644 index 000000000..ef009064b --- /dev/null +++ b/test/ai/recipe-dashscope-rerank.test.ts @@ -0,0 +1,79 @@ +/** + * dashscope-rerank recipe smoke. + * + * Sibling of recipe-llama-server-reranker.test.ts. Pins the recipe shape so: + * - id + tier + implementation + base_url stay byte-stable + * - reranker touchpoint declares the PLURAL `/reranks` leaf (the whole + * reason this recipe exists — DashScope's compatible-api surface 404s + * on singular `/rerank`) + `default_timeout_ms` + * - only live-verified models are listed (gte-rerank-v2 is native-API only + * and rejected by the OpenAI-compat surface) + */ + +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'; + +describe('recipe: dashscope-rerank', () => { + test('registered with expected shape', () => { + const r = getRecipe('dashscope-rerank'); + expect(r).toBeDefined(); + expect(r!.id).toBe('dashscope-rerank'); + expect(r!.tier).toBe('openai-compat'); + expect(r!.implementation).toBe('openai-compatible'); + expect(r!.base_url_default).toBe( + 'https://dashscope-intl.aliyuncs.com/compatible-api/v1', + ); + expect(r!.auth_env?.required).toEqual(['DASHSCOPE_API_KEY']); + }); + + test('declares reranker touchpoint with PLURAL /reranks path + timeout', () => { + const r = getRecipe('dashscope-rerank')!; + const tp = r.touchpoints.reranker; + expect(tp).toBeDefined(); + expect(tp!.path).toBe('/reranks'); + expect(tp!.default_timeout_ms).toBe(30_000); + expect(tp!.max_payload_bytes).toBe(5_000_000); + }); + + test('base_url + path concatenation produces /v1/reranks, NOT /v1/v1/…', () => { + const r = getRecipe('dashscope-rerank')!; + const combined = + r.base_url_default!.replace(/\/$/, '') + (r.touchpoints.reranker!.path ?? '/models/rerank'); + expect(combined).toBe('https://dashscope-intl.aliyuncs.com/compatible-api/v1/reranks'); + expect(combined).not.toContain('/v1/v1/'); + expect(combined.endsWith('/reranks')).toBe(true); + }); + + test('lists only the live-verified compat-surface model', () => { + const r = getRecipe('dashscope-rerank')!; + const tp = r.touchpoints.reranker!; + expect(tp.models).toEqual(['qwen3-rerank']); + expect(tp.default_model).toBe('qwen3-rerank'); + // gte-rerank-v2 is native-API only; the compat surface rejects it. + expect(tp.models).not.toContain('gte-rerank-v2'); + }); + + test('default auth: DASHSCOPE_API_KEY set → Bearer token', () => { + const r = getRecipe('dashscope-rerank')!; + const auth = defaultResolveAuth( + r, + { DASHSCOPE_API_KEY: 'sk-dashscope-fake' }, + 'reranker', + ); + expect(auth.headerName).toBe('Authorization'); + expect(auth.token).toBe('Bearer sk-dashscope-fake'); + }); + + test('default auth: missing DASHSCOPE_API_KEY → AIConfigError', () => { + const r = getRecipe('dashscope-rerank')!; + expect(() => defaultResolveAuth(r, {}, 'reranker')).toThrow(AIConfigError); + }); + + test('does not perturb the sibling dashscope embedding recipe', () => { + const emb = getRecipe('dashscope')!; + expect(emb.base_url_default).toBe('https://dashscope-intl.aliyuncs.com/compatible-mode/v1'); + expect(emb.touchpoints.reranker).toBeUndefined(); + }); +});