feat(ai): add llama-server recipe (#702 reworked)

10th recipe in the registry; first to ship Recipe.probe (D13=A) and the
second user_provided_models recipe (litellm-proxy is the first).

llama.cpp's llama-server exposes an OpenAI-compatible /v1/embeddings
endpoint. Distinct from Ollama: different default port (8080), different
model-management story (you launch it with --model <path>; the server
serves whatever was passed). Recipe ships with `models: []`,
`user_provided_models: true`, `default_dims: 0` so the wizard refuses
implicit defaults and forces explicit --embedding-model + --embedding-dimensions.

Added:
- src/core/ai/recipes/llama-server.ts (61 lines)
- probeLlamaServer() in src/core/ai/probes.ts; reads
  LLAMA_SERVER_BASE_URL with default http://localhost:8080/v1
- Registered in src/core/ai/recipes/index.ts (10 recipes total now)
- test/ai/recipe-llama-server.test.ts (8 cases): registered + shape,
  user_provided_models flag, probe declared + reachability fail-with-hint,
  default-auth covering no-env / API_KEY / URL-shaped-only paths

Hardening: defaultResolveAuth in gateway.ts now skips URL-shaped optional
env entries (names ending in _URL or _BASE_URL) when picking a fallback
auth token. Pre-fix, OLLAMA_BASE_URL=http://my-ollama would have become
the Bearer token; Ollama ignores it but llama-server (and future
local-server recipes) shouldn't depend on the server tolerating garbage
auth. The regression test (recipes-existing-regression) gains one case
pinning this contract.

Per-recipe test file follows D7=B (per-recipe over DRY for readability).

Plan: ~/.claude/plans/ok-lets-turn-this-enumerated-sonnet.md (commit 4
of 11). Reworked from #702 because the original PR didn't model the
recipe-owned probe pattern (D13=A) or user_provided_models (D8=A).

Tests: bun test test/ai/ — 95/95 pass (8 new + 87 existing).

Co-Authored-By: SiyaoZheng <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-09 23:40:13 -07:00
co-authored by SiyaoZheng Claude Opus 4.7
parent 014d4e85b1
commit 74c849f970
6 changed files with 201 additions and 14 deletions
+8 -4
View File
@@ -111,10 +111,14 @@ export function defaultResolveAuth(
const optional = recipe.auth_env?.optional ?? [];
if (required.length === 0) {
// No-auth or optional-auth recipe (e.g. Ollama). Read first optional env;
// if none present, use 'unauthenticated' so createOpenAICompatible has
// something to put in Authorization (servers like Ollama ignore it).
const optKey = optional.find(k => !!env[k]);
// No-auth or optional-auth recipe (e.g. Ollama, llama-server). Read first
// present optional API-key env (ignoring URL-shaped names like
// OLLAMA_BASE_URL, which belong in cfg.base_urls, not auth). If none
// present, use 'unauthenticated' so createOpenAICompatible has something
// to put in Authorization (servers like Ollama / llama-server ignore it).
const optKey = optional.find(
k => !!env[k] && !/_(BASE_)?URL$/.test(k),
);
const token = optKey ? env[optKey]! : 'unauthenticated';
return { headerName: 'Authorization', token: `Bearer ${token}` };
}
+10
View File
@@ -45,3 +45,13 @@ export async function probeLMStudio(): Promise<ProbeResult> {
const url = process.env.LMSTUDIO_BASE_URL ?? 'http://localhost:1234/v1';
return probeOpenAICompat(url);
}
/**
* Probe llama.cpp's `llama-server --embeddings` endpoint. Defaults to port
* 8080 (llama-server's default; distinct from Ollama's 11434 and LM Studio's
* 1234). Override via `LLAMA_SERVER_BASE_URL`.
*/
export async function probeLlamaServer(): Promise<ProbeResult> {
const url = process.env.LLAMA_SERVER_BASE_URL ?? 'http://localhost:8080/v1';
return probeOpenAICompat(url);
}
+2
View File
@@ -15,6 +15,7 @@ import { litellmProxy } from './litellm-proxy.ts';
import { deepseek } from './deepseek.ts';
import { groq } from './groq.ts';
import { together } from './together.ts';
import { llamaServer } from './llama-server.ts';
const ALL: Recipe[] = [
openai,
@@ -26,6 +27,7 @@ const ALL: Recipe[] = [
deepseek,
groq,
together,
llamaServer,
];
/** Map from `provider:id` key to recipe. */
+61
View File
@@ -0,0 +1,61 @@
import type { Recipe } from '../types.ts';
import { probeLlamaServer } from '../probes.ts';
/**
* llama.cpp's `llama-server --embeddings` (also published as
* `@llama.cpp/llama-server`). Exposes an OpenAI-compatible /v1/embeddings
* endpoint. Distinct from Ollama: different default port (8080), different
* model-management story (you launch it with `--model <path>`; the server
* serves whatever model was passed).
*
* Like LiteLLM, this recipe ships with `models: []` because the model
* identity is whatever the user launched llama-server with. They MUST
* pass `--embedding-model llama-server:<id>` and `--embedding-dimensions
* <N>`. The wizard refuses to pick implicit defaults.
*
* Reference: https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
*/
export const llamaServer: Recipe = {
id: 'llama-server',
name: 'llama.cpp llama-server (local)',
tier: 'openai-compat',
implementation: 'openai-compatible',
base_url_default: 'http://localhost:8080/v1',
auth_env: {
required: [],
optional: ['LLAMA_SERVER_BASE_URL', 'LLAMA_SERVER_API_KEY'],
setup_url:
'https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md',
},
touchpoints: {
embedding: {
models: [], // user-driven; whatever model the server was launched with
user_provided_models: true,
default_dims: 0, // forces explicit --embedding-dimensions
cost_per_1m_tokens_usd: 0,
price_last_verified: '2026-05-09',
},
},
/**
* Probe via the OpenAI-compatible /v1/models endpoint. Honors
* LLAMA_SERVER_BASE_URL override; defaults to localhost:8080.
*/
async probe() {
const result = await probeLlamaServer();
if (!result.reachable) {
return {
ready: false,
hint: `llama-server not reachable at ${process.env.LLAMA_SERVER_BASE_URL ?? 'http://localhost:8080/v1'}. Start it with \`./llama-server --model <path> --embeddings\` or set LLAMA_SERVER_BASE_URL.`,
};
}
if (!result.models_endpoint_valid) {
return {
ready: false,
hint: `llama-server reached but /v1/models returned an unexpected shape: ${result.error ?? 'unknown'}.`,
};
}
return { ready: true };
},
setup_hint:
'Build llama.cpp, then `llama-server --model <gguf-path> --embeddings`. Set --embedding-model llama-server:<id> + --embedding-dimensions <N>.',
};
+88
View File
@@ -0,0 +1,88 @@
/**
* llama-server recipe smoke (Commit 4 of the v0.32 wave).
*
* llama-server is the second user-driven-models recipe (alongside
* litellm-proxy). It declares `models: []`, `user_provided_models: true`,
* and a `probe()` that consults LLAMA_SERVER_BASE_URL.
*
* Coverage:
* - Recipe registered + has expected fields
* - user_provided_models is the explicit signal (not the legacy id heuristic)
* - probe is callable and reports `ready: false` with a setup hint when no server is listening
* - default auth resolves to "Bearer unauthenticated" (or the API key if set)
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { getRecipe } from '../../src/core/ai/recipes/index.ts';
import { defaultResolveAuth } from '../../src/core/ai/gateway.ts';
describe('recipe: llama-server', () => {
let savedBaseUrl: string | undefined;
beforeEach(() => {
savedBaseUrl = process.env.LLAMA_SERVER_BASE_URL;
delete process.env.LLAMA_SERVER_BASE_URL;
});
afterEach(() => {
if (savedBaseUrl !== undefined) process.env.LLAMA_SERVER_BASE_URL = savedBaseUrl;
else delete process.env.LLAMA_SERVER_BASE_URL;
});
test('registered with expected shape', () => {
const r = getRecipe('llama-server');
expect(r).toBeDefined();
expect(r!.id).toBe('llama-server');
expect(r!.tier).toBe('openai-compat');
expect(r!.implementation).toBe('openai-compatible');
expect(r!.base_url_default).toBe('http://localhost:8080/v1');
expect(r!.auth_env?.required ?? []).toEqual([]);
expect(r!.auth_env?.optional ?? []).toContain('LLAMA_SERVER_BASE_URL');
expect(r!.auth_env?.optional ?? []).toContain('LLAMA_SERVER_API_KEY');
});
test('embedding touchpoint declares user_provided_models', () => {
const r = getRecipe('llama-server')!;
expect(r.touchpoints.embedding).toBeDefined();
expect(r.touchpoints.embedding!.models).toEqual([]);
expect(r.touchpoints.embedding!.user_provided_models).toBe(true);
expect(r.touchpoints.embedding!.default_dims).toBe(0);
});
test('declares a probe function', () => {
const r = getRecipe('llama-server')!;
expect(typeof r.probe).toBe('function');
});
test('probe returns ready=false with hint when no server listening on default port', async () => {
// Use a guaranteed-unreachable port.
process.env.LLAMA_SERVER_BASE_URL = 'http://127.0.0.1:1/v1';
const r = getRecipe('llama-server')!;
const result = await r.probe!();
expect(result.ready).toBe(false);
expect(result.hint).toBeDefined();
expect(result.hint!.toLowerCase()).toContain('llama-server');
});
test('default auth: no env → "Bearer unauthenticated"', () => {
const r = getRecipe('llama-server')!;
const auth = defaultResolveAuth(r, {}, 'embedding');
expect(auth.headerName).toBe('Authorization');
expect(auth.token).toBe('Bearer unauthenticated');
});
test('default auth: LLAMA_SERVER_API_KEY set → "Bearer <key>"', () => {
const r = getRecipe('llama-server')!;
const auth = defaultResolveAuth(r, { LLAMA_SERVER_API_KEY: 'sk-llama-fake' }, 'embedding');
expect(auth.headerName).toBe('Authorization');
expect(auth.token).toBe('Bearer sk-llama-fake');
});
test('default auth: LLAMA_SERVER_BASE_URL alone does NOT become the Bearer (URL-shaped optional)', () => {
const r = getRecipe('llama-server')!;
const auth = defaultResolveAuth(
r,
{ LLAMA_SERVER_BASE_URL: 'http://my-llama:8080/v1' },
'embedding',
);
expect(auth.token).toBe('Bearer unauthenticated');
});
});
+32 -10
View File
@@ -29,9 +29,9 @@ import type { Recipe } from '../../src/core/ai/types.ts';
const TOUCHPOINTS: Array<'embedding' | 'expansion' | 'chat'> = ['embedding', 'expansion', 'chat'];
describe('IRON RULE: existing 9 recipes survive the v0.32 resolveAuth refactor', () => {
test('the 9 expected recipes are still registered', () => {
const ids = listRecipes().map(r => r.id).sort();
expect(ids).toEqual([
test('all 9 baseline recipes are still registered (subset, allows post-v0.32 additions)', () => {
const ids = new Set(listRecipes().map(r => r.id));
for (const baseline of [
'anthropic',
'deepseek',
'google',
@@ -41,7 +41,9 @@ describe('IRON RULE: existing 9 recipes survive the v0.32 resolveAuth refactor',
'openai',
'together',
'voyage',
]);
]) {
expect(ids.has(baseline), `baseline recipe ${baseline} missing post-refactor`).toBe(true);
}
});
test('every recipe with a non-empty required[] returns Authorization Bearer <key>', () => {
@@ -75,17 +77,16 @@ describe('IRON RULE: existing 9 recipes survive the v0.32 resolveAuth refactor',
}
});
test('Ollama (empty required, optional set) reads first optional env', () => {
test('Ollama (empty required, OLLAMA_API_KEY set) reads it as the Bearer token', () => {
const ollama = getRecipe('ollama');
expect(ollama).toBeDefined();
expect(ollama!.auth_env?.required ?? []).toEqual([]);
const optional = ollama!.auth_env?.optional ?? [];
expect(optional.length).toBeGreaterThan(0);
// First optional present → returned as Bearer.
const env = { [optional[0]]: 'http://localhost:11434' };
const auth = defaultResolveAuth(ollama!, env, 'embedding');
expect(optional).toContain('OLLAMA_API_KEY');
// OLLAMA_API_KEY (a non-URL-shaped optional) becomes the Bearer.
const auth = defaultResolveAuth(ollama!, { OLLAMA_API_KEY: 'fake-token' }, 'embedding');
expect(auth.headerName).toBe('Authorization');
expect(auth.token).toBe('Bearer http://localhost:11434');
expect(auth.token).toBe('Bearer fake-token');
});
test('Ollama (no env at all) falls back to "Bearer unauthenticated"', () => {
@@ -95,6 +96,27 @@ describe('IRON RULE: existing 9 recipes survive the v0.32 resolveAuth refactor',
expect(auth.token).toBe('Bearer unauthenticated');
});
test('URL-shaped optional env (OLLAMA_BASE_URL, LLAMA_SERVER_BASE_URL) does NOT become the Bearer token', () => {
// Regression for the v0.32 default-fallback design: optional entries
// ending in _URL or _BASE_URL are config (cfg.base_urls), not auth.
// The fallback must skip them and consult the next optional API-key entry.
const ollama = getRecipe('ollama');
const auth1 = defaultResolveAuth(
ollama!,
{ OLLAMA_BASE_URL: 'http://my-ollama/v1' },
'embedding',
);
expect(auth1.token, 'OLLAMA_BASE_URL must not become Bearer token').toBe('Bearer unauthenticated');
// When BOTH BASE_URL and API_KEY are set, the API_KEY wins.
const auth2 = defaultResolveAuth(
ollama!,
{ OLLAMA_BASE_URL: 'http://my-ollama/v1', OLLAMA_API_KEY: 'real-key' },
'embedding',
);
expect(auth2.token).toBe('Bearer real-key');
});
test('all 3 touchpoints produce identical auth for the same recipe + env', () => {
// Critical regression: pre-v0.32, embedding had a fallback to
// ${recipe.id.toUpperCase()}_API_KEY that expansion and chat lacked.