feat(llm): add BYOK tier + 4 provider adapters (OpenAI, Anthropic, Gemini, Mistral)

Phase 1-3 of BYOK support. Introduces a 5th LLM tier 'byok' that
routes to user-provided API keys via direct browser fetches.

shared-llm additions:
- LlmTier extended with 'byok' (rank 3, between mana-server and cloud)
- ByokBackend: LlmBackend implementation that delegates key lookup
  to an app-provided resolver callback, then dispatches to the right
  provider adapter
- 4 provider adapters:
  - OpenAI (gpt-5, gpt-4o, o1 family)
  - Anthropic (Claude Opus/Sonnet/Haiku 4.6) with CORS header
  - Gemini (2.5 Pro/Flash) — REST API with different message format
  - Mistral — OpenAI-compatible, reuses shared openai-compat adapter
- Pricing table for 20+ models with USD per 1M tokens
- estimateCost() + formatCost() helpers

Keys stay device-local (IndexedDB in next phase). Browser-direct
fetches mean keys never touch Mana's server.

Updates two existing tier maps (memoro DetailView, SourceBadge) to
include the new tier.

Planning doc at docs/architecture/BYOK_PLAN.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 15:06:48 +02:00
parent 3817111f80
commit a33857fa39
15 changed files with 1026 additions and 11 deletions

View file

@ -0,0 +1,122 @@
/**
* Anthropic adapter.
*
* Differs from OpenAI:
* - Uses x-api-key header (not Bearer)
* - Needs anthropic-version header
* - Needs anthropic-dangerous-direct-browser-access for CORS
* - System prompt goes in its own `system` field, not as a message
* - SSE event schema is different (content_block_delta with text)
*/
import type { ByokProvider, ByokCallOptions } from './types';
import type { GenerateResult } from '../../types';
export const anthropicProvider: ByokProvider = {
id: 'anthropic',
displayName: 'Anthropic',
defaultModel: 'claude-sonnet-4-5',
availableModels: [
'claude-opus-4-6',
'claude-opus-4-5',
'claude-sonnet-4-6',
'claude-sonnet-4-5',
'claude-haiku-4-5',
],
async call(opts: ByokCallOptions): Promise<GenerateResult> {
return callAnthropic(opts);
},
};
async function callAnthropic(opts: ByokCallOptions): Promise<GenerateResult> {
const startedAt = Date.now();
// Anthropic wants system prompt separately, user/assistant inline
const systemMessages = opts.messages.filter((m) => m.role === 'system');
const chatMessages = opts.messages.filter((m) => m.role !== 'system');
const system = systemMessages.map((m) => m.content).join('\n\n') || undefined;
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': opts.apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model: opts.model,
system,
messages: chatMessages.map((m) => ({ role: m.role, content: m.content })),
temperature: opts.temperature ?? 0.7,
max_tokens: opts.maxTokens ?? 1024,
stream: true,
}),
});
if (!response.ok) {
const errText = await response.text().catch(() => response.statusText);
throw new Error(`Anthropic API ${response.status}: ${errText.slice(0, 300)}`);
}
if (!response.body) {
throw new Error('Anthropic API: kein Response-Body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let content = '';
let promptTokens = 0;
let completionTokens = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIdx: number;
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIdx).trim();
buffer = buffer.slice(newlineIdx + 1);
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
try {
const parsed = JSON.parse(payload) as {
type?: string;
delta?: { type?: string; text?: string };
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
usage?: { input_tokens?: number; output_tokens?: number };
};
if (parsed.type === 'content_block_delta' && parsed.delta?.type === 'text_delta') {
const token = parsed.delta.text ?? '';
if (token) {
content += token;
opts.onToken?.(token);
}
} else if (parsed.type === 'message_start' && parsed.message?.usage) {
promptTokens = parsed.message.usage.input_tokens ?? 0;
} else if (parsed.type === 'message_delta' && parsed.usage) {
completionTokens = parsed.usage.output_tokens ?? completionTokens;
}
} catch {
// Ignore malformed lines
}
}
}
return {
content,
usage: {
promptTokens,
completionTokens,
totalTokens: promptTokens + completionTokens,
},
latencyMs: Date.now() - startedAt,
};
}

View file

@ -0,0 +1,129 @@
/**
* Gemini adapter direct REST API.
*
* Differs from OpenAI:
* - API key goes in query string (?key=...)
* - Messages use different schema: { role, parts: [{ text }] }
* - Roles are 'user' and 'model' (not 'assistant')
* - System prompt goes in `systemInstruction` field
* - Streaming via SSE at :streamGenerateContent endpoint
*/
import type { ByokProvider, ByokCallOptions } from './types';
import type { GenerateResult, ChatMessage } from '../../types';
export const geminiProvider: ByokProvider = {
id: 'gemini',
displayName: 'Google Gemini',
defaultModel: 'gemini-2.5-flash',
availableModels: [
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemini-2.0-flash',
],
async call(opts: ByokCallOptions): Promise<GenerateResult> {
return callGemini(opts);
},
};
interface GeminiMessage {
role: 'user' | 'model';
parts: { text: string }[];
}
function toGeminiMessages(messages: ChatMessage[]): {
system?: string;
contents: GeminiMessage[];
} {
const systemMessages = messages.filter((m) => m.role === 'system');
const chatMessages = messages.filter((m) => m.role !== 'system');
return {
system: systemMessages.map((m) => m.content).join('\n\n') || undefined,
contents: chatMessages.map((m) => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }],
})),
};
}
async function callGemini(opts: ByokCallOptions): Promise<GenerateResult> {
const startedAt = Date.now();
const { system, contents } = toGeminiMessages(opts.messages);
const url = `https://generativelanguage.googleapis.com/v1beta/models/${opts.model}:streamGenerateContent?alt=sse&key=${opts.apiKey}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
systemInstruction: system ? { parts: [{ text: system }] } : undefined,
contents,
generationConfig: {
temperature: opts.temperature ?? 0.7,
maxOutputTokens: opts.maxTokens ?? 1024,
},
}),
});
if (!response.ok) {
const errText = await response.text().catch(() => response.statusText);
throw new Error(`Gemini API ${response.status}: ${errText.slice(0, 300)}`);
}
if (!response.body) {
throw new Error('Gemini API: kein Response-Body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let content = '';
let promptTokens = 0;
let completionTokens = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIdx: number;
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIdx).trim();
buffer = buffer.slice(newlineIdx + 1);
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
try {
const parsed = JSON.parse(payload) as {
candidates?: { content?: { parts?: { text?: string }[] } }[];
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number };
};
const token = parsed.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
if (token) {
content += token;
opts.onToken?.(token);
}
if (parsed.usageMetadata) {
promptTokens = parsed.usageMetadata.promptTokenCount ?? promptTokens;
completionTokens = parsed.usageMetadata.candidatesTokenCount ?? completionTokens;
}
} catch {
// Ignore malformed lines
}
}
}
return {
content,
usage: {
promptTokens,
completionTokens,
totalTokens: promptTokens + completionTokens,
},
latencyMs: Date.now() - startedAt,
};
}

View file

@ -0,0 +1,19 @@
export { openaiProvider } from './openai';
export { anthropicProvider } from './anthropic';
export { geminiProvider } from './gemini';
export { mistralProvider } from './mistral';
export type { ByokProvider, ByokProviderId, ByokCallOptions } from './types';
import { openaiProvider } from './openai';
import { anthropicProvider } from './anthropic';
import { geminiProvider } from './gemini';
import { mistralProvider } from './mistral';
import type { ByokProvider } from './types';
/** All built-in BYOK providers. Apps can still add custom ones. */
export const BUILTIN_BYOK_PROVIDERS: readonly ByokProvider[] = [
openaiProvider,
anthropicProvider,
geminiProvider,
mistralProvider,
];

View file

@ -0,0 +1,23 @@
import type { ByokProvider, ByokCallOptions } from './types';
import { callOpenAiCompat } from './openai-compat';
import type { GenerateResult } from '../../types';
export const mistralProvider: ByokProvider = {
id: 'mistral',
displayName: 'Mistral AI',
defaultModel: 'mistral-small-latest',
availableModels: [
'mistral-large-latest',
'mistral-small-latest',
'mistral-medium-latest',
'open-mistral-nemo',
'codestral-latest',
],
async call(opts: ByokCallOptions): Promise<GenerateResult> {
return callOpenAiCompat(
{ baseUrl: 'https://api.mistral.ai/v1', providerName: 'Mistral' },
opts
);
},
};

View file

@ -0,0 +1,101 @@
/**
* OpenAI-compatible API adapter (base for OpenAI, Mistral, Groq, etc.)
*
* Uses the ChatCompletions API schema. Streaming via SSE, parsing
* the `data: {json}` lines, extracting `choices[0].delta.content`.
*/
import type { GenerateResult } from '../../types';
import type { ByokCallOptions } from './types';
export interface OpenAiCompatConfig {
baseUrl: string;
providerName: string; // For error messages
extraHeaders?: Record<string, string>;
}
export async function callOpenAiCompat(
config: OpenAiCompatConfig,
opts: ByokCallOptions
): Promise<GenerateResult> {
const startedAt = Date.now();
const url = `${config.baseUrl.replace(/\/$/, '')}/chat/completions`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${opts.apiKey}`,
...(config.extraHeaders ?? {}),
},
body: JSON.stringify({
model: opts.model,
messages: opts.messages.map((m) => ({ role: m.role, content: m.content })),
temperature: opts.temperature ?? 0.7,
max_tokens: opts.maxTokens ?? 1024,
stream: true,
stream_options: { include_usage: true },
}),
});
if (!response.ok) {
const errText = await response.text().catch(() => response.statusText);
const short = errText.slice(0, 300);
throw new Error(`${config.providerName} API ${response.status}: ${short}`);
}
if (!response.body) {
throw new Error(`${config.providerName} API: kein Response-Body`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let content = '';
let promptTokens = 0;
let completionTokens = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIdx: number;
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIdx).trim();
buffer = buffer.slice(newlineIdx + 1);
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (payload === '[DONE]') continue;
try {
const parsed = JSON.parse(payload) as {
choices?: { delta?: { content?: string } }[];
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
const token = parsed.choices?.[0]?.delta?.content ?? '';
if (token) {
content += token;
opts.onToken?.(token);
}
if (parsed.usage) {
promptTokens = parsed.usage.prompt_tokens ?? 0;
completionTokens = parsed.usage.completion_tokens ?? 0;
}
} catch {
// Ignore malformed lines
}
}
}
return {
content,
usage: {
promptTokens,
completionTokens,
totalTokens: promptTokens + completionTokens,
},
latencyMs: Date.now() - startedAt,
};
}

View file

@ -0,0 +1,14 @@
import type { ByokProvider, ByokCallOptions } from './types';
import { callOpenAiCompat } from './openai-compat';
import type { GenerateResult } from '../../types';
export const openaiProvider: ByokProvider = {
id: 'openai',
displayName: 'OpenAI',
defaultModel: 'gpt-4o-mini',
availableModels: ['gpt-5', 'gpt-5-mini', 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1', 'o1-mini'],
async call(opts: ByokCallOptions): Promise<GenerateResult> {
return callOpenAiCompat({ baseUrl: 'https://api.openai.com/v1', providerName: 'OpenAI' }, opts);
},
};

View file

@ -0,0 +1,39 @@
/**
* BYOK Provider abstraction.
*
* Each supported third-party LLM (OpenAI, Anthropic, Gemini, Mistral, ...)
* implements this interface. Adapters do the direct browser-to-provider
* fetch using the user's API key.
*/
import type { ChatMessage, GenerateResult } from '../../types';
export type ByokProviderId = 'openai' | 'anthropic' | 'gemini' | 'mistral';
export interface ByokProvider {
readonly id: ByokProviderId;
readonly displayName: string;
readonly defaultModel: string;
readonly availableModels: readonly string[];
/**
* Call the provider with the user's API key.
* Throws on network errors, auth errors, or content policy blocks.
*/
call(opts: ByokCallOptions): Promise<GenerateResult>;
}
export interface ByokCallOptions {
apiKey: string;
model: string;
messages: ChatMessage[];
temperature?: number;
maxTokens?: number;
onToken?: (token: string) => void;
}
export interface ByokProviderError extends Error {
provider: ByokProviderId;
status?: number;
code?: string;
}

View file

@ -0,0 +1,132 @@
/**
* BYOK Backend routes LLM calls through the user's own API keys.
*
* The backend itself lives in shared-llm (so the orchestrator can
* instantiate it alongside browser/mana-server/cloud), but the
* actual keys live in the consuming app's encrypted IndexedDB.
*
* Apps inject a `ByokKeyResolver` callback at init time. The backend
* calls it whenever it needs a key, gets back `{ apiKey, model,
* provider }`, and dispatches to the matching provider adapter.
*
* If no key is configured for any provider, isAvailable() returns
* false and the orchestrator skips this tier.
*/
import type { GenerateResult, LlmBackend, LlmTaskRequest } from '../types';
import type { ByokProvider, ByokProviderId } from './byok-providers/types';
export interface ResolvedByokKey {
provider: ByokProviderId;
apiKey: string;
model: string;
}
/** App-side callback — looks up the appropriate key for a call. */
export type ByokKeyResolver = (opts: {
/** Task name from LlmTaskRequest (e.g. "companion.chat") */
taskName: string;
/** Optional user-forced provider (from settings.byok.defaultProvider
* or from task-specific override like 'byok:anthropic') */
preferredProvider?: ByokProviderId;
}) => Promise<ResolvedByokKey | null>;
/** Called after a successful generation so the app can increment usage counters. */
export type ByokUsageCallback = (opts: {
provider: ByokProviderId;
model: string;
promptTokens: number;
completionTokens: number;
latencyMs: number;
}) => void;
export interface ByokBackendOptions {
resolver: ByokKeyResolver;
providers: readonly ByokProvider[];
onUsage?: ByokUsageCallback;
}
export class ByokBackend implements LlmBackend {
readonly tier = 'byok' as const;
private readonly resolver: ByokKeyResolver;
private readonly providers: Map<ByokProviderId, ByokProvider>;
private readonly onUsage?: ByokUsageCallback;
/** Whether at least one key has been configured. Set after first
* resolver call; the orchestrator uses isAvailable() to skip the
* tier when the user hasn't added any keys yet. */
private keyConfigured: boolean | null = null;
constructor(opts: ByokBackendOptions) {
this.resolver = opts.resolver;
this.providers = new Map(opts.providers.map((p) => [p.id, p]));
this.onUsage = opts.onUsage;
}
/** Inform the backend that the user has added/removed keys flips
* the cached availability flag so isAvailable() re-probes on the
* next call. */
invalidateAvailability(): void {
this.keyConfigured = null;
}
isAvailable(): boolean {
// If we haven't probed yet, assume available and let resolver
// fail gracefully. After the first resolver miss we cache false.
return this.keyConfigured !== false;
}
async isReady(): Promise<boolean> {
// Probe with a null task to see if *any* key resolves
try {
const key = await this.resolver({ taskName: '__probe__' });
this.keyConfigured = key !== null;
return this.keyConfigured;
} catch {
this.keyConfigured = false;
return false;
}
}
async generate(req: LlmTaskRequest): Promise<GenerateResult> {
// Parse optional provider override from task name (e.g. "companion.chat"
// with a taskOverride of "byok:anthropic" → caller should pass
// preferredProvider via the resolver path, not via taskName).
const resolved = await this.resolver({ taskName: req.taskName });
if (!resolved) {
this.keyConfigured = false;
throw new Error(
'Kein BYOK-Schluessel konfiguriert. Bitte unter Einstellungen → KI-Keys hinterlegen.'
);
}
this.keyConfigured = true;
const provider = this.providers.get(resolved.provider);
if (!provider) {
throw new Error(`BYOK-Provider nicht unterstuetzt: ${resolved.provider}`);
}
const startedAt = Date.now();
const result = await provider.call({
apiKey: resolved.apiKey,
model: resolved.model,
messages: req.messages,
temperature: req.temperature,
maxTokens: req.maxTokens,
onToken: req.onToken,
});
const latencyMs = Date.now() - startedAt;
// Report usage so the app can update per-key counters
if (this.onUsage && result.usage) {
this.onUsage({
provider: resolved.provider,
model: resolved.model,
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
latencyMs,
});
}
return { ...result, latencyMs };
}
}