Compare commits

..
Author SHA1 Message Date
d9834a7a15 feat(ai): add reranker touchpoint to LiteLLM proxy recipe (takeover of #2455)
LiteLLM normalizes Cohere/Voyage/Jina rerank backends to the wire shape
gateway.rerank() already speaks, so a reranker touchpoint on the litellm
recipe makes any proxied rerank model reachable via
`search.reranker.model litellm:<model>` with no adapter.

Repairs from the original PR:
- path is the LEAF '/rerank' (not '/v1/rerank'): LiteLLM serves both
  /rerank and /v1/rerank, and the recipe's setup_hint allows
  LITELLM_BASE_URL with or without the /v1 suffix — pinning '/v1/rerank'
  doubled to /v1/v1/rerank (404) on /v1-suffixed bases.
- setup_hint appends the rerank guidance to master's current line instead
  of replacing it with a stale pre-/v1-suffix version.
- cost_per_1m_tokens_usd stays undefined (pricing-unknown), matching the
  recipe's embedding/chat touchpoints and budget-tracker's deliberate
  litellm exclusion from the free-provider sets (a proxy can front a paid
  provider; the touchpoint field isn't consumed by rerank pricing anyway).

Test drives gateway.rerank()'s real URL builder via the stubbed transport
for both base-URL forms; the /v1-suffixed case fails with the original
PR's path.

Co-authored-by: ozp <ozp@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:28:27 -07:00
4 changed files with 127 additions and 65 deletions
+8 -40
View File
@@ -5,8 +5,8 @@
* checks if back-links exist, and optionally creates them.
*
* Usage:
* gbrain check-backlinks check [dir] [--dir <brain-dir>] # report missing back-links
* gbrain check-backlinks fix [dir] [--dir <brain-dir>] # create missing back-links
* gbrain check-backlinks check [--dir <brain-dir>] # report missing back-links
* gbrain check-backlinks fix [--dir <brain-dir>] # create missing back-links
* gbrain check-backlinks fix --dry-run # preview fixes
*/
@@ -201,40 +201,6 @@ export interface BacklinksResult {
dryRun: boolean;
}
export interface ParsedBacklinksArgs {
subcommand: string | undefined;
brainDir: string;
dryRun: boolean;
}
export function parseBacklinksArgs(args: string[]): ParsedBacklinksArgs {
const subcommand = args[0];
const dryRun = args.includes('--dry-run');
const dirIdx = args.indexOf('--dir');
const flagDir = dirIdx >= 0 && args[dirIdx + 1] && !args[dirIdx + 1].startsWith('--')
? args[dirIdx + 1]
: undefined;
let positionalDir: string | undefined;
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg === '--dir') {
i++;
continue;
}
if (arg === '--dry-run') continue;
if (arg.startsWith('--')) continue;
positionalDir = arg;
break;
}
return {
subcommand,
brainDir: flagDir ?? positionalDir ?? '.',
dryRun,
};
}
/**
* Library-level backlinks check/fix. Throws on validation errors; returns a
* structured result so Minions handlers + autopilot-cycle can surface counts.
@@ -270,14 +236,16 @@ export async function runBacklinksCore(opts: BacklinksOpts): Promise<BacklinksRe
}
export async function runBacklinks(args: string[]) {
const { subcommand, brainDir, dryRun } = parseBacklinksArgs(args);
const subcommand = args[0];
const dirIdx = args.indexOf('--dir');
const brainDir = dirIdx >= 0 ? args[dirIdx + 1] : '.';
const dryRun = args.includes('--dry-run');
if (!subcommand || !['check', 'fix'].includes(subcommand)) {
console.error('Usage: gbrain check-backlinks <check|fix> [dir] [--dir <brain-dir>] [--dry-run]');
console.error('Usage: gbrain check-backlinks <check|fix> [--dir <brain-dir>] [--dry-run]');
console.error(' check Report missing back-links');
console.error(' fix Create missing back-links (appends to Timeline)');
console.error(' dir Brain directory (default: current directory)');
console.error(' --dir Brain directory override');
console.error(' --dir Brain directory (default: current directory)');
console.error(' --dry-run Preview fixes without writing');
process.exit(1);
}
+31 -1
View File
@@ -56,6 +56,36 @@ export const litellmProxy: Recipe = {
cost_per_1m_output_usd: undefined,
price_last_verified: '2026-06-14',
},
// LiteLLM normalizes Cohere / Voyage / Jina / etc. rerank backends to the
// same wire shape gbrain's gateway.rerank() already speaks (the
// ZeroEntropy/llama.cpp contract):
// { model, query, documents, top_n } → { results: [{ index, relevance_score }] }
// So any rerank model the user registers in their LiteLLM config is
// reachable via `gbrain config set search.reranker.model litellm:<model>`
// with no request/response adapter — same as embeddings ride the proxy.
reranker: {
models: [], // user-provided; whatever rerank models the proxy serves
// No canonical default — the proxy defines its own model ids. The user
// sets search.reranker.model explicitly (mirrors the embedding
// touchpoint's user_provided_models contract).
default_model: '',
// The proxied backend bills (Cohere/Voyage/…); pricing-unknown is the
// honest state — same stance as this recipe's embedding/chat
// touchpoints and budget-tracker's deliberate litellm exclusion from
// the free-provider sets.
cost_per_1m_tokens_usd: undefined,
price_last_verified: '2026-06-27',
max_payload_bytes: 5_000_000,
// LEAF path only (matches llama-server-reranker's convention). LiteLLM
// serves both `/rerank` and `/v1/rerank`, and LITELLM_BASE_URL may be
// set with or without the `/v1` suffix (the setup_hint allows both), so
// the leaf form yields a valid route either way:
// http://localhost:4000 + /rerank → /rerank ✓
// http://localhost:4000/v1 + /rerank → /v1/rerank ✓
// Pinning '/v1/rerank' here would double to /v1/v1/rerank → 404 on
// /v1-suffixed bases.
path: '/rerank',
},
},
setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL (include the /v1 suffix if your proxy serves the OpenAI route there, e.g. http://localhost:4000/v1) + pass --embedding-model litellm:<model> and --embedding-dimensions <N>.',
setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL (include the /v1 suffix if your proxy serves the OpenAI route there, e.g. http://localhost:4000/v1) + pass --embedding-model litellm:<model> and --embedding-dimensions <N>. For rerank: register a rerank model in LiteLLM and set search.reranker.model litellm:<model-name>.',
};
+88
View File
@@ -0,0 +1,88 @@
/**
* litellm-proxy reranker touchpoint smoke.
*
* Sibling of recipe-llama-server-reranker.test.ts. Pins the reranker
* touchpoint on the LiteLLM proxy recipe so:
* - the touchpoint exists with the LEAF '/rerank' path (LiteLLM serves both
* /rerank and /v1/rerank, so the leaf form is valid whether or not the
* user's LITELLM_BASE_URL carries the /v1 suffix the setup_hint allows)
* - a /v1-suffixed base URL does NOT produce /v1/v1/rerank (the original
* community PR pinned '/v1/rerank' which 404s on /v1-suffixed bases)
* - models: [] (user-provided; proxy defines the model ids)
* - pricing stays undefined (proxy can front a paid provider — same honest
* pricing-unknown stance as the embedding/chat touchpoints)
*
* The gateway.rerank() URL tests drive the real URL builder via the stubbed
* transport (same seam as test/ai/rerank.test.ts).
*/
import { describe, expect, test, afterEach } from 'bun:test';
import { getRecipe } from '../../src/core/ai/recipes/index.ts';
import {
configureGateway,
resetGateway,
rerank,
__setRerankTransportForTests,
} from '../../src/core/ai/gateway.ts';
afterEach(() => {
__setRerankTransportForTests(null);
resetGateway();
});
describe('recipe: litellm reranker touchpoint', () => {
test('declares reranker touchpoint with leaf /rerank path', () => {
const r = getRecipe('litellm')!;
const tp = r.touchpoints.reranker;
expect(tp).toBeDefined();
expect(tp!.path).toBe('/rerank');
expect(tp!.max_payload_bytes).toBe(5_000_000);
});
test('reranker touchpoint uses empty models[] for user-provided model ids', () => {
const r = getRecipe('litellm')!;
expect(r.touchpoints.reranker!.models).toEqual([]);
});
test('pricing stays undefined — proxy can front a paid provider', () => {
const r = getRecipe('litellm')!;
expect(r.touchpoints.reranker!.cost_per_1m_tokens_usd).toBeUndefined();
});
test('setup_hint keeps the /v1-suffix guidance AND mentions rerank', () => {
const r = getRecipe('litellm')!;
expect(r.setup_hint).toMatch(/\/v1 suffix/);
expect(r.setup_hint).toMatch(/search\.reranker\.model litellm:/);
});
});
describe('gateway.rerank() URL via litellm recipe', () => {
async function capturedRerankUrl(baseUrl?: string): Promise<string> {
configureGateway({
reranker_model: 'litellm:my-reranker',
env: {},
...(baseUrl ? { base_urls: { litellm: baseUrl } } : {}),
});
let capturedUrl = '';
__setRerankTransportForTests(async (url) => {
capturedUrl = url;
return new Response(
JSON.stringify({ results: [{ index: 0, relevance_score: 0.9 }] }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
await rerank({ query: 'q', documents: ['d'] });
return capturedUrl;
}
test('default base (no /v1 suffix) → /rerank', async () => {
const url = await capturedRerankUrl();
expect(url).toBe('http://localhost:4000/rerank');
});
test('/v1-suffixed base → /v1/rerank, NOT /v1/v1/rerank', async () => {
const url = await capturedRerankUrl('http://localhost:4000/v1');
expect(url).toBe('http://localhost:4000/v1/rerank');
expect(url).not.toContain('/v1/v1/');
});
});
-24
View File
@@ -4,7 +4,6 @@ import {
extractPageTitle,
hasBacklink,
buildBacklinkEntry,
parseBacklinksArgs,
} from '../src/commands/backlinks.ts';
describe('extractEntityRefs', () => {
@@ -105,26 +104,3 @@ describe('findBacklinkGaps dedupe (v0.36.x #967 regression)', () => {
}
});
});
describe('parseBacklinksArgs', () => {
test('uses positional dir for check and fix subcommands', () => {
expect(parseBacklinksArgs(['check', '/tmp/brain']).brainDir).toBe('/tmp/brain');
expect(parseBacklinksArgs(['fix', '/tmp/brain']).brainDir).toBe('/tmp/brain');
});
test('defaults to cwd when no dir given', () => {
expect(parseBacklinksArgs(['check']).brainDir).toBe('.');
});
test('--dir overrides positional dir and preserves dry-run', () => {
const parsed = parseBacklinksArgs(['fix', '/tmp/ignored', '--dir', '/tmp/brain', '--dry-run']);
expect(parsed.subcommand).toBe('fix');
expect(parsed.brainDir).toBe('/tmp/brain');
expect(parsed.dryRun).toBe(true);
});
test('--dir missing its value falls back to positional dir', () => {
expect(parseBacklinksArgs(['check', '/tmp/brain', '--dir']).brainDir).toBe('/tmp/brain');
expect(parseBacklinksArgs(['check', '--dir', '--dry-run']).brainDir).toBe('.');
});
});