ui : route chat requests through protocol adapters

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-16 19:22:47 +02:00
parent 946b08a99c
commit 69870519b7
14 changed files with 801 additions and 209 deletions
@@ -67,7 +67,7 @@
<span class="mb-2 block text-xs font-medium select-none">API format</span> <span class="mb-2 block text-xs font-medium select-none">API format</span>
<Select.Root <Select.Root
onValueChange={(value) => onChange({ protocol: value as BackendProtocol })} onValueChange={(value) => onChange({ compat: undefined, protocol: value as BackendProtocol })}
type="single" type="single"
value={backend.protocol} value={backend.protocol}
> >
@@ -66,6 +66,7 @@
...draft, ...draft,
baseUrl: preset.baseUrl, baseUrl: preset.baseUrl,
chatPath: preset.chatPath, chatPath: preset.chatPath,
compat: preset.compat,
modelsPath: preset.modelsPath, modelsPath: preset.modelsPath,
name: preset.id === 'custom' ? '' : preset.name, name: preset.id === 'custom' ? '' : preset.name,
protocol: preset.protocol protocol: preset.protocol
@@ -1,4 +1,9 @@
import type { BackendCapabilities, BackendPreset, BackendProtocol } from '$lib/types'; import type {
BackendCapabilities,
BackendCompat,
BackendPreset,
BackendProtocol
} from '$lib/types';
/** Version sent with the Anthropic Messages API. */ /** Version sent with the Anthropic Messages API. */
export const ANTHROPIC_API_VERSION = '2023-06-01'; export const ANTHROPIC_API_VERSION = '2023-06-01';
@@ -48,6 +53,15 @@ export const BACKEND_CAPABILITIES: Record<BackendProtocol, BackendCapabilities>
openai: COMPATIBLE_CAPABILITIES openai: COMPATIBLE_CAPABILITIES
}; };
/** Default wire quirks per protocol. */
export const BACKEND_COMPAT: Record<BackendProtocol, BackendCompat> = {
// the Messages API has no OpenAI-style token cap or usage-in-stream toggle
anthropic: { maxTokensField: 'max_tokens', supportsUsageInStreaming: false },
// llama-server reports its own timings, so it needs no usage chunk
'llama.cpp': { maxTokensField: 'max_tokens', supportsUsageInStreaming: false },
openai: { maxTokensField: 'max_tokens', supportsUsageInStreaming: true }
};
/** /**
* Ready-made endpoints offered when adding a backend. `custom` intentionally * Ready-made endpoints offered when adding a backend. `custom` intentionally
* carries no URL so the user starts from an empty form. * carries no URL so the user starts from an empty form.
@@ -85,6 +99,8 @@ export const BACKEND_PRESETS: readonly BackendPreset[] = [
}, },
{ {
baseUrl: 'https://api.openai.com', baseUrl: 'https://api.openai.com',
// newer OpenAI models reject max_tokens
compat: { maxTokensField: 'max_completion_tokens' },
id: 'openai', id: 'openai',
name: 'OpenAI', name: 'OpenAI',
protocol: 'openai' protocol: 'openai'
@@ -5,6 +5,8 @@ const MCP_SESSION_ID_VISIBLE_CHARS = 5;
export const HEADERS = { export const HEADERS = {
/** Anthropic-compatible backends authenticate with this header instead of Authorization */ /** Anthropic-compatible backends authenticate with this header instead of Authorization */
ANTHROPIC_API_KEY: 'x-api-key', ANTHROPIC_API_KEY: 'x-api-key',
/** Browser opt-in required by the Anthropic Messages API direct browser access */
ANTHROPIC_BROWSER_ACCESS: 'anthropic-dangerous-direct-browser-access',
/** Required version header for the Anthropic Messages API */ /** Required version header for the Anthropic Messages API */
ANTHROPIC_VERSION: 'anthropic-version', ANTHROPIC_VERSION: 'anthropic-version',
/** Canonical casing for the Authorization header (RFC 7235) */ /** Canonical casing for the Authorization header (RFC 7235) */
+116 -190
View File
@@ -33,6 +33,8 @@ import {
ReasoningFormat, ReasoningFormat,
StreamConnectionState StreamConnectionState
} from '$lib/enums'; } from '$lib/enums';
import { getProtocolAdapter } from '$lib/services/protocols';
import { extractModelName } from '$lib/services/protocols/openai';
import { modelsStore } from '$lib/stores/models/index.svelte'; import { modelsStore } from '$lib/stores/models/index.svelte';
import { serverStore } from '$lib/stores/server.svelte'; import { serverStore } from '$lib/stores/server.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte'; import { settingsStore } from '$lib/stores/settings/index.svelte';
@@ -51,41 +53,6 @@ import { formatAttachmentText } from '$lib/utils/formatters';
import { streamIdentity } from '$lib/utils/stream-identity'; import { streamIdentity } from '$lib/utils/stream-identity';
import { buildTimingsFromUsage } from '$lib/utils/timings'; import { buildTimingsFromUsage } from '$lib/utils/timings';
/**
* llama.cpp-only chat request fields. Strict OpenAI-compatible endpoints
* reject unknown parameters, so they are dropped for those backends.
*/
const COMPAT_ONLY_OMIT_REQUEST_FIELDS = [
'add_generation_prompt',
'backend_sampling',
'cache_prompt',
'chat_template_kwargs',
'continue_final_message',
'dry_allowed_length',
'dry_base',
'dry_multiplier',
'dry_penalty_last_n',
'dynatemp_exponent',
'dynatemp_range',
'id_slot',
'min_p',
'n_keep',
'n_predict',
'reasoning_control',
'reasoning_format',
'repeat_last_n',
'repeat_penalty',
'return_progress',
'samplers',
'sse_ping_interval',
'thinking_budget_tokens',
'timings_per_token',
'top_k',
'typ_p',
'xtc_probability',
'xtc_threshold'
];
interface ResumableStreamState { interface ResumableStreamState {
bytesReceived: number; bytesReceived: number;
updatedAt: number; updatedAt: number;
@@ -539,6 +506,8 @@ export class ChatService {
let liveTimingsAt = 0; let liveTimingsAt = 0;
let usage: ApiChatCompletionUsage | undefined; let usage: ApiChatCompletionUsage | undefined;
// the protocol decides how payloads map onto canonical events
const streamReader = getProtocolAdapter(getBackend()).createStreamReader();
const finalizeOpenToolCallBatch = () => { const finalizeOpenToolCallBatch = () => {
if (!hasOpenToolCallBatch) { if (!hasOpenToolCallBatch) {
return; return;
@@ -578,6 +547,32 @@ export class ChatService {
onToolCallChunk?.(serializedToolCalls); onToolCallChunk?.(serializedToolCalls);
} }
}; };
// backends that do not stream their own timings report progress from wall
// clock time and the streamed delta count, throttled to keep updates cheap
const markToken = () => {
firstTokenAt ??= Date.now();
lastTokenAt = Date.now();
streamedTokens++;
if (
serverStore.capabilities.props ||
Date.now() - liveTimingsAt < STREAM_LIVE_TIMINGS_INTERVAL_MS
) {
return;
}
liveTimingsAt = Date.now();
const liveTimings = buildTimingsFromUsage(
usage,
{ firstTokenAt, lastTokenAt, startedAt },
streamedTokens
);
if (liveTimings) {
ChatService.notifyTimings(liveTimings, undefined, onTimings);
}
};
const onVisibilityChange = () => { const onVisibilityChange = () => {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
@@ -679,82 +674,89 @@ export class ChatService {
continue; continue;
} }
let parsed: unknown;
try { try {
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data); parsed = JSON.parse(data);
const choice = parsed.choices?.[0]; } catch (parseError) {
const content = choice?.delta?.content; console.error('Error parsing JSON chunk:', parseError);
const reasoningContent = choice?.delta?.reasoning_content;
const toolCalls = choice?.delta?.tool_calls;
const timings = parsed.timings;
const promptProgress = parsed.prompt_progress;
const chunkUsage = parsed.usage;
const chunkModel = ChatService.extractModelName(parsed);
if (chunkUsage) usage = chunkUsage; continue;
}
if (chunkModel && !modelEmitted) { for (const event of streamReader.readChunk(parsed)) {
modelEmitted = true; switch (event.type) {
onModel?.(chunkModel); case 'done':
} streamFinished = true;
if (parsed.id && !idEmitted) { break;
idEmitted = true;
onCompletionId?.(parsed.id);
}
if (promptProgress) { case 'error':
ChatService.notifyTimings(undefined, promptProgress, onTimings); throw new Error(event.message);
}
if (timings) { case 'id':
ChatService.notifyTimings(timings, promptProgress, onTimings); if (!idEmitted) {
lastTimings = timings; idEmitted = true;
} onCompletionId?.(event.id);
if (content) {
finalizeOpenToolCallBatch();
aggregatedContent += content;
if (!abortSignal?.aborted) {
onChunk?.(content);
}
}
if (reasoningContent) {
finalizeOpenToolCallBatch();
fullReasoningContent += reasoningContent;
if (!abortSignal?.aborted) {
onReasoningChunk?.(reasoningContent);
}
}
processToolCallDelta(toolCalls);
if (content || reasoningContent) {
firstTokenAt ??= Date.now();
lastTokenAt = Date.now();
streamedTokens++;
if (
!serverStore.capabilities.props &&
Date.now() - liveTimingsAt >= STREAM_LIVE_TIMINGS_INTERVAL_MS
) {
liveTimingsAt = Date.now();
const liveTimings = buildTimingsFromUsage(
usage,
{ firstTokenAt, lastTokenAt, startedAt },
streamedTokens
);
if (liveTimings) {
ChatService.notifyTimings(liveTimings, undefined, onTimings);
} }
}
break;
case 'model':
if (!modelEmitted) {
modelEmitted = true;
onModel?.(event.model);
}
break;
case 'prompt_progress':
ChatService.notifyTimings(undefined, event.progress, onTimings);
break;
case 'text':
finalizeOpenToolCallBatch();
aggregatedContent += event.text;
if (!abortSignal?.aborted) {
onChunk?.(event.text);
}
markToken();
break;
case 'thinking':
finalizeOpenToolCallBatch();
fullReasoningContent += event.text;
if (!abortSignal?.aborted) {
onReasoningChunk?.(event.text);
}
markToken();
break;
case 'timings':
ChatService.notifyTimings(event.timings, event.promptProgress, onTimings);
lastTimings = event.timings;
break;
case 'tool_calls':
processToolCallDelta(event.deltas);
break;
case 'usage':
// providers may split usage across chunks (Anthropic reports input
// tokens on message_start and output tokens on message_delta)
usage = { ...usage, ...event.usage };
break;
} }
} catch (e) {
console.error('Error parsing JSON chunk:', e);
} }
} }
} }
@@ -1313,12 +1315,6 @@ export class ChatService {
if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token; if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token;
// OpenAI-compatible servers report token counts in a final usage chunk, which
// the client side timing fallback in handleStreamResponse relies on
if (stream && !serverStore.capabilities.props && requestBody.stream_options === undefined) {
requestBody.stream_options = { include_usage: true };
}
if (custom) { if (custom) {
try { try {
const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom; const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom;
@@ -1342,10 +1338,17 @@ export class ChatService {
ChatService.saveStreamState(conversationId, 0, options.model ?? null); ChatService.saveStreamState(conversationId, 0, options.model ?? null);
} }
ChatService.stripBackendSpecificFields(requestBody); // the protocol adapter owns the wire format: it strips llama.cpp-only
// fields, applies the backend's token cap field and adds the usage chunk
const backend = getBackend();
const wireBody = backend
? getProtocolAdapter(backend).buildChatRequest(
requestBody as unknown as Record<string, unknown>,
backend
)
: (requestBody as unknown as Record<string, unknown>);
const response = await fetch(apiChatUrl(), { const response = await fetch(apiChatUrl(), {
body: JSON.stringify(requestBody), body: JSON.stringify(wireBody),
headers, headers,
method: 'POST', method: 'POST',
signal signal
@@ -1490,61 +1493,6 @@ export class ChatService {
return apiUrl(`${API_STREAM.BASE}?${query}${offset}`); return apiUrl(`${API_STREAM.BASE}?${query}${offset}`);
} }
/**
* Extracts model name from Chat Completions API response data.
* Handles various response formats including streaming chunks and final responses.
*
* WORKAROUND: In single model mode, llama-server returns a default/incorrect model name
* in the response. We override it with the actual model name from serverStore.
*
* @param data - Raw response data from the Chat Completions API
* @returns Model name string if found, undefined otherwise
* @private
*/
private static extractModelName(data: unknown): string | undefined {
const asRecord = (value: unknown): Record<string, unknown> | undefined => {
return typeof value === 'object' && value !== null
? (value as Record<string, unknown>)
: undefined;
};
const getTrimmedString = (value: unknown): string | undefined => {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
const root = asRecord(data);
if (!root) return undefined;
// 1) root (some implementations provide `model` at the top level)
const rootModel = getTrimmedString(root.model);
if (rootModel) {
return rootModel;
}
// 2) streaming choice (delta) or final response (message)
const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
if (!firstChoice) {
return undefined;
}
// priority: delta.model (first chunk) else message.model (final response)
const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
if (deltaModel) {
return deltaModel;
}
const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
if (messageModel) {
return messageModel;
}
// avoid guessing from non-standard locations (metadata, etc.)
return undefined;
}
/** /**
* Handles non-streaming response from the chat completion API. * Handles non-streaming response from the chat completion API.
* Parses the JSON response and extracts the generated content. * Parses the JSON response and extracts the generated content.
@@ -1577,7 +1525,7 @@ export class ChatService {
} }
const data: ApiChatCompletionResponse = JSON.parse(responseText); const data: ApiChatCompletionResponse = JSON.parse(responseText);
const responseModel = ChatService.extractModelName(data); const responseModel = extractModelName(data);
if (responseModel) { if (responseModel) {
onModel?.(responseModel); onModel?.(responseModel);
@@ -1739,28 +1687,6 @@ export class ChatService {
* Strips legacy inline reasoning content tags from message content. * Strips legacy inline reasoning content tags from message content.
* Handles both plain string content and multipart content arrays. * Handles both plain string content and multipart content arrays.
*/ */
private static stripBackendSpecificFields(body: ApiChatCompletionRequest): void {
const backend = getBackend();
if (!backend || backend.protocol === 'llama.cpp') return;
const record = body as unknown as Record<string, unknown>;
for (const field of COMPAT_ONLY_OMIT_REQUEST_FIELDS) {
delete record[field];
}
// compatible endpoints reject the reasoning_content message extension
for (const message of body.messages) {
delete (message as unknown as Record<string, unknown>).reasoning_content;
}
// -1 is llama.cpp's "no limit" sentinel; compatible endpoints reject it
if (typeof body.max_tokens === 'number' && body.max_tokens <= 0) {
delete record.max_tokens;
}
}
private static stripReasoningContent( private static stripReasoningContent(
content: string | ApiChatMessageContentPart[] content: string | ApiChatMessageContentPart[]
): string | ApiChatMessageContentPart[] { ): string | ApiChatMessageContentPart[] {
@@ -0,0 +1,347 @@
/**
* Anthropic Messages protocol.
*
* The Messages API differs from the OpenAI shape in three ways that matter
* here: the system prompt is a top level field, messages carry typed content
* blocks, and tool calls are content blocks rather than a message field.
*/
import type { ChatProtocolAdapter, ChatStreamEvent, ChatStreamReader } from './types';
import { ANTHROPIC_API_VERSION, HEADERS } from '$lib/constants';
import { ContentPartType, MessageRole } from '$lib/enums';
import type { Backend } from '$lib/types';
import type {
ApiChatCompletionTool,
ApiChatCompletionUsage,
ApiChatMessageContentPart,
ApiChatMessageData
} from '$lib/types/api';
/** Output cap sent when the user did not configure one; the field is required. */
const DEFAULT_MAX_TOKENS = 4096;
interface AnthropicBlock {
type: string;
[key: string]: unknown;
}
interface AnthropicTool {
description?: string;
input_schema: Record<string, unknown>;
name: string;
}
interface AnthropicStreamEvent {
type?: string;
index?: number;
content_block?: { type?: string; id?: string; name?: string };
delta?: { type?: string; text?: string; thinking?: string; partial_json?: string };
error?: { message?: string };
message?: {
id?: string;
model?: string;
usage?: Record<string, unknown>;
};
usage?: Record<string, unknown>;
}
function authHeaders(backend: Backend): Record<string, string> {
const headers: Record<string, string> = {
...(backend.headers ?? {}),
[HEADERS.ANTHROPIC_BROWSER_ACCESS]: 'true',
[HEADERS.ANTHROPIC_VERSION]: ANTHROPIC_API_VERSION
};
const apiKey = backend.apiKey?.trim();
if (apiKey) {
headers[HEADERS.ANTHROPIC_API_KEY] = apiKey;
}
return headers;
}
function textOf(content: string | ApiChatMessageContentPart[]): string {
if (typeof content === 'string') return content;
return content
.filter((part) => part.type === ContentPartType.TEXT)
.map((part) => part.text ?? '')
.join('');
}
function imageBlock(url: string): AnthropicBlock {
const dataUrl = url.match(/^data:([^;,]+);base64,(.*)$/s);
if (dataUrl) {
return { source: { data: dataUrl[2], media_type: dataUrl[1], type: 'base64' }, type: 'image' };
}
return { source: { type: 'url', url }, type: 'image' };
}
function userBlocks(content: string | ApiChatMessageContentPart[]): AnthropicBlock[] {
if (typeof content === 'string') {
return content ? [{ text: content, type: 'text' }] : [];
}
return content.flatMap((part): AnthropicBlock[] => {
if (part.type === ContentPartType.TEXT) {
return part.text ? [{ text: part.text, type: 'text' }] : [];
}
if (part.type === ContentPartType.IMAGE_URL && part.image_url?.url) {
return [imageBlock(part.image_url.url)];
}
// audio and video have no Messages API equivalent
return [];
});
}
function assistantBlocks(message: ApiChatMessageData): AnthropicBlock[] {
const blocks: AnthropicBlock[] = [];
const text = textOf(message.content);
if (text) blocks.push({ text, type: 'text' });
for (const call of message.tool_calls ?? []) {
let input: unknown = {};
try {
input = call.function?.arguments ? JSON.parse(call.function.arguments) : {};
} catch {
input = {};
}
blocks.push({ id: call.id, input, name: call.function?.name, type: 'tool_use' });
}
return blocks;
}
function convertTools(tools: unknown): AnthropicTool[] | undefined {
if (!Array.isArray(tools) || tools.length === 0) return undefined;
return (tools as ApiChatCompletionTool[]).map((tool) => ({
description: tool.function?.description,
input_schema: tool.function?.parameters ?? { properties: {}, type: 'object' },
name: tool.function?.name
}));
}
function buildChatRequest(
body: Record<string, unknown>,
_backend: Backend
): Record<string, unknown> {
const messages = Array.isArray(body.messages) ? (body.messages as ApiChatMessageData[]) : [];
const configuredMaxTokens = typeof body.max_tokens === 'number' ? body.max_tokens : 0;
const maxTokens = configuredMaxTokens > 0 ? configuredMaxTokens : DEFAULT_MAX_TOKENS;
const system: string[] = [];
const converted: { content: AnthropicBlock[]; role: 'assistant' | 'user' }[] = [];
// the Messages API requires strictly alternating roles, so adjacent blocks
// of the same role (tool results, split user turns) are merged
const push = (role: 'assistant' | 'user', content: AnthropicBlock[]): void => {
if (content.length === 0) return;
const last = converted.at(-1);
if (last?.role === role) {
last.content.push(...content);
} else {
converted.push({ content, role });
}
};
for (const message of messages) {
if (message.role === MessageRole.SYSTEM) {
const text = textOf(message.content);
if (text) system.push(text);
continue;
}
if (message.role === MessageRole.TOOL) {
push('user', [
{
content: textOf(message.content),
tool_use_id: message.tool_call_id,
type: 'tool_result'
}
]);
continue;
}
if (message.role === MessageRole.ASSISTANT) {
push('assistant', assistantBlocks(message));
continue;
}
push('user', userBlocks(message.content));
}
const request: Record<string, unknown> = {
max_tokens: maxTokens,
messages: converted,
model: body.model
};
if (system.length > 0) request.system = system.join('\n\n');
if (body.stream) request.stream = true;
if (typeof body.temperature === 'number') request.temperature = body.temperature;
if (typeof body.top_p === 'number') request.top_p = body.top_p;
const tools = convertTools(body.tools);
if (tools) request.tools = tools;
const chatTemplateKwargs = body.chat_template_kwargs as Record<string, unknown> | undefined;
const budgetTokens =
typeof body.thinking_budget_tokens === 'number' ? body.thinking_budget_tokens : 0;
// extended thinking needs a budget below max_tokens, and Anthropic rejects
// temperature and top_p while thinking is enabled
if (
chatTemplateKwargs?.enable_thinking === true &&
budgetTokens > 0 &&
budgetTokens < maxTokens
) {
request.thinking = { budget_tokens: budgetTokens, type: 'enabled' };
delete request.temperature;
delete request.top_p;
}
return request;
}
/** Map an Anthropic usage object onto the compatible fields, defined keys only. */
function usageOf(raw: Record<string, unknown> | undefined): ApiChatCompletionUsage | undefined {
if (!raw) return undefined;
const usage: ApiChatCompletionUsage = {};
if (typeof raw.input_tokens === 'number') usage.input_tokens = raw.input_tokens;
if (typeof raw.output_tokens === 'number') usage.output_tokens = raw.output_tokens;
return Object.keys(usage).length > 0 ? usage : undefined;
}
function createStreamReader(): ChatStreamReader {
// Anthropic indexes content blocks across text and tool_use, while the
// canonical tool call deltas are indexed among tool calls only
const toolIndexes = new Map<number, number>();
let toolCount = 0;
const toolIndex = (blockIndex: number | undefined): number => {
if (typeof blockIndex !== 'number') return toolCount++;
const known = toolIndexes.get(blockIndex);
if (known !== undefined) return known;
const index = toolCount++;
toolIndexes.set(blockIndex, index);
return index;
};
return {
readChunk(payload: unknown): ChatStreamEvent[] {
if (!payload || typeof payload !== 'object') return [];
const event = payload as AnthropicStreamEvent;
const events: ChatStreamEvent[] = [];
switch (event.type) {
case 'message_start': {
if (event.message?.id) events.push({ id: event.message.id, type: 'id' });
if (event.message?.model) events.push({ model: event.message.model, type: 'model' });
const usage = usageOf(event.message?.usage);
if (usage) events.push({ type: 'usage', usage });
break;
}
case 'content_block_start': {
if (event.content_block?.type === 'tool_use') {
events.push({
deltas: [
{
function: { name: event.content_block.name },
id: event.content_block.id,
index: toolIndex(event.index),
type: 'function'
}
],
type: 'tool_calls'
});
}
break;
}
case 'content_block_delta': {
const delta = event.delta;
if (delta?.type === 'text_delta' && delta.text) {
events.push({ text: delta.text, type: 'text' });
} else if (delta?.type === 'thinking_delta' && delta.thinking) {
events.push({ text: delta.thinking, type: 'thinking' });
} else if (delta?.type === 'input_json_delta' && delta.partial_json) {
events.push({
deltas: [
{
function: { arguments: delta.partial_json },
index: toolIndex(event.index)
}
],
type: 'tool_calls'
});
}
break;
}
case 'message_delta': {
const usage = usageOf(event.usage);
if (usage) events.push({ type: 'usage', usage });
break;
}
case 'message_stop':
events.push({ type: 'done' });
break;
case 'error':
events.push({
message: event.error?.message ?? 'Anthropic stream error',
type: 'error'
});
break;
}
return events;
}
};
}
export const anthropicAdapter: ChatProtocolAdapter = {
authHeaders,
buildChatRequest,
createStreamReader
};
@@ -0,0 +1,26 @@
/**
* Protocol adapter registry.
*
* Resolves the wire mapping for a backend by its protocol. Unknown or
* not-yet-loaded backends fall back to the OpenAI-compatible adapter, which is
* what the local llama-server speaks.
*/
import { anthropicAdapter } from './anthropic';
import { openaiAdapter } from './openai';
import type { ChatProtocolAdapter } from './types';
import type { Backend, BackendProtocol } from '$lib/types';
const ADAPTERS: Record<BackendProtocol, ChatProtocolAdapter> = {
anthropic: anthropicAdapter,
'llama.cpp': openaiAdapter,
openai: openaiAdapter
};
export function getProtocolAdapter(backend?: Backend): ChatProtocolAdapter {
if (!backend) return openaiAdapter;
return ADAPTERS[backend.protocol] ?? openaiAdapter;
}
export type { ChatProtocolAdapter, ChatStreamEvent, ChatStreamReader } from './types';
@@ -0,0 +1,183 @@
/**
* OpenAI-compatible protocol: llama-server, hosted OpenAI endpoints and any
* compatible gateway. llama-server accepts a superset of the wire format, so
* its only specialization is that nothing gets stripped.
*/
import type { ChatProtocolAdapter, ChatStreamEvent, ChatStreamReader } from './types';
import { HEADERS } from '$lib/constants';
import type { Backend } from '$lib/types';
import type { ApiChatCompletionStreamChunk } from '$lib/types/api';
import { getBackendCompat } from '$lib/utils/backend';
/**
* llama.cpp-only chat request fields. Strict OpenAI-compatible endpoints
* reject unknown parameters, so they are dropped for those backends.
*/
const COMPAT_ONLY_OMIT_REQUEST_FIELDS = [
'add_generation_prompt',
'backend_sampling',
'cache_prompt',
'chat_template_kwargs',
'continue_final_message',
'dry_allowed_length',
'dry_base',
'dry_multiplier',
'dry_penalty_last_n',
'dynatemp_exponent',
'dynatemp_range',
'id_slot',
'min_p',
'n_keep',
'n_predict',
'reasoning_control',
'reasoning_format',
'repeat_last_n',
'repeat_penalty',
'return_progress',
'samplers',
'sse_ping_interval',
'thinking_budget_tokens',
'timings_per_token',
'top_k',
'typ_p',
'xtc_probability',
'xtc_threshold'
];
function authHeaders(backend: Backend): Record<string, string> {
const headers: Record<string, string> = { ...(backend.headers ?? {}) };
const apiKey = backend.apiKey?.trim();
if (apiKey) {
headers[HEADERS.AUTHORIZATION] = `${HEADERS.BEARER}${apiKey}`;
}
return headers;
}
function buildChatRequest(
body: Record<string, unknown>,
backend: Backend
): Record<string, unknown> {
if (backend.protocol === 'llama.cpp') return body;
const compat = getBackendCompat(backend);
const request: Record<string, unknown> = { ...body };
for (const field of COMPAT_ONLY_OMIT_REQUEST_FIELDS) {
delete request[field];
}
// compatible endpoints reject the reasoning_content message extension
const messages = request.messages as { reasoning_content?: string }[] | undefined;
for (const message of messages ?? []) {
delete message.reasoning_content;
}
// -1 is llama.cpp's "no limit" sentinel; compatible endpoints reject it
if (typeof request.max_tokens === 'number' && request.max_tokens <= 0) {
delete request.max_tokens;
}
// newer OpenAI models require max_completion_tokens, most compatible
// endpoints only understand max_tokens
if (compat.maxTokensField === 'max_completion_tokens' && request.max_tokens !== undefined) {
request.max_completion_tokens = request.max_tokens;
delete request.max_tokens;
}
// a final usage chunk is what the client side timing fallback reads
if (request.stream && compat.supportsUsageInStreaming && request.stream_options === undefined) {
request.stream_options = { include_usage: true };
}
return request;
}
/**
* Model name a payload reports. Streaming chunks carry it on the delta, final
* responses on the message, and some gateways on metadata or the choice itself.
*/
export function extractModelName(data: unknown): string | undefined {
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
const getTrimmedString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value.trim() : undefined;
const root = asRecord(data);
if (!root) return undefined;
const rootModel = getTrimmedString(root.model);
if (rootModel) return rootModel;
const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
if (!firstChoice) return undefined;
const metadataModel = getTrimmedString(asRecord(firstChoice.metadata)?.model);
if (metadataModel) return metadataModel;
const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
if (deltaModel) return deltaModel;
const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
if (messageModel) return messageModel;
return getTrimmedString(firstChoice.model);
}
function readChunk(payload: unknown): ChatStreamEvent[] {
if (!payload || typeof payload !== 'object') return [];
const chunk = payload as ApiChatCompletionStreamChunk;
const events: ChatStreamEvent[] = [];
const model = extractModelName(chunk);
if (chunk.id) events.push({ id: chunk.id, type: 'id' });
if (model) events.push({ model, type: 'model' });
if (chunk.prompt_progress) {
events.push({ progress: chunk.prompt_progress, type: 'prompt_progress' });
}
if (chunk.timings) {
events.push({
promptProgress: chunk.prompt_progress,
timings: chunk.timings,
type: 'timings'
});
}
if (chunk.usage) events.push({ type: 'usage', usage: chunk.usage });
const choice = chunk.choices?.[0];
const delta = choice?.delta;
if (!delta) return events;
if (delta.content) events.push({ text: delta.content, type: 'text' });
// endpoints disagree on the reasoning field name; take the first one set
const reasoning = delta.reasoning_content ?? delta.reasoning ?? delta.reasoning_text;
if (reasoning) events.push({ text: reasoning, type: 'thinking' });
if (delta.tool_calls) events.push({ deltas: delta.tool_calls, type: 'tool_calls' });
return events;
}
export const openaiAdapter: ChatProtocolAdapter = {
authHeaders,
buildChatRequest,
createStreamReader(): ChatStreamReader {
return { readChunk };
}
};
@@ -0,0 +1,42 @@
/**
* Backend protocol adapters.
*
* A backend speaks one wire protocol. ChatService owns the transport (fetch,
* SSE framing, resume offsets) and delegates the parts that differ per
* protocol here: credential headers, request shaping and stream decoding.
*
* Decoding is per-stream: the Anthropic reader tracks content block state, so
* it must not be shared between concurrent requests.
*/
import type { Backend } from '$lib/types';
import type { ApiChatCompletionToolCallDelta, ApiChatCompletionUsage } from '$lib/types/api';
import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat';
/** One canonical delta decoded from a backend's stream payload. */
export type ChatStreamEvent =
| { type: 'done' }
| { type: 'error'; message: string }
| { type: 'id'; id: string }
| { type: 'model'; model: string }
| { type: 'prompt_progress'; progress: ChatMessagePromptProgress }
| { type: 'text'; text: string }
| { type: 'thinking'; text: string }
| { type: 'timings'; timings: ChatMessageTimings; promptProgress?: ChatMessagePromptProgress }
| { type: 'tool_calls'; deltas: ApiChatCompletionToolCallDelta[] }
| { type: 'usage'; usage: ApiChatCompletionUsage };
/** Decodes one stream's payloads. Create one per request. */
export interface ChatStreamReader {
/** Canonical deltas for one parsed SSE payload. */
readChunk(payload: unknown): ChatStreamEvent[];
}
/** Wire mapping for one protocol. */
export interface ChatProtocolAdapter {
/** Credential and protocol-required headers for a backend. */
authHeaders(backend: Backend): Record<string, string>;
/** Rewrite the canonical request body into this protocol's wire body. */
buildChatRequest(body: Record<string, unknown>, backend: Backend): Record<string, unknown>;
createStreamReader(): ChatStreamReader;
}
+2
View File
@@ -358,7 +358,9 @@ export interface ApiChatCompletionStreamChunk {
metadata?: { model?: string }; metadata?: { model?: string };
delta: { delta: {
content?: string; content?: string;
reasoning?: string;
reasoning_content?: string; reasoning_content?: string;
reasoning_text?: string;
model?: string; model?: string;
tool_calls?: ApiChatCompletionToolCallDelta[]; tool_calls?: ApiChatCompletionToolCallDelta[];
}; };
+15
View File
@@ -10,6 +10,17 @@
/** Request/response shape a backend speaks. */ /** Request/response shape a backend speaks. */
export type BackendProtocol = 'llama.cpp' | 'openai' | 'anthropic'; export type BackendProtocol = 'llama.cpp' | 'openai' | 'anthropic';
/**
* Wire-level quirks of a backend's protocol. Capabilities gate llama.cpp
* features; compat describes how the request and stream payloads differ.
*/
export interface BackendCompat {
/** Field carrying the output token cap. */
maxTokensField: 'max_completion_tokens' | 'max_tokens';
/** Whether the endpoint accepts stream_options.include_usage. */
supportsUsageInStreaming: boolean;
}
/** /**
* Features a backend supports. A llama.cpp server exposes extra endpoints on * Features a backend supports. A llama.cpp server exposes extra endpoints on
* top of the OpenAI-compatible API; plain OpenAI- and Anthropic-compatible * top of the OpenAI-compatible API; plain OpenAI- and Anthropic-compatible
@@ -45,6 +56,8 @@ export interface Backend {
baseUrl: string; baseUrl: string;
/** Chat completions path override, e.g. /v1/messages. */ /** Chat completions path override, e.g. /v1/messages. */
chatPath?: string; chatPath?: string;
/** Wire quirks overriding the protocol defaults. */
compat?: Partial<BackendCompat>;
/** Disabled backends stay configured but are not queried. */ /** Disabled backends stay configured but are not queried. */
enabled: boolean; enabled: boolean;
/** Extra headers merged into every request to this backend. */ /** Extra headers merged into every request to this backend. */
@@ -63,6 +76,8 @@ export interface BackendPreset {
apiKeyHelp?: string; apiKeyHelp?: string;
baseUrl: string; baseUrl: string;
chatPath?: string; chatPath?: string;
/** Wire quirks this preset needs on top of the protocol defaults. */
compat?: Partial<BackendCompat>;
id: string; id: string;
modelsPath?: string; modelsPath?: string;
name: string; name: string;
+7 -1
View File
@@ -37,7 +37,13 @@ export type {
} from './api'; } from './api';
// Backend types // Backend types
export type { Backend, BackendCapabilities, BackendPreset, BackendProtocol } from './backend'; export type {
Backend,
BackendCapabilities,
BackendCompat,
BackendPreset,
BackendProtocol
} from './backend';
// HuggingFace types // HuggingFace types
export type { export type {
+4 -15
View File
@@ -1,7 +1,8 @@
import { getBackend } from './api-base'; import { getBackend } from './api-base';
import { redactValue } from './redact'; import { redactValue } from './redact';
import { ANTHROPIC_API_VERSION, CORS_PROXY, HEADERS } from '$lib/constants'; import { CORS_PROXY, HEADERS } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums'; import { MimeTypeApplication } from '$lib/enums';
import { getProtocolAdapter } from '$lib/services/protocols';
import { settingsStore } from '$lib/stores/settings/index.svelte'; import { settingsStore } from '$lib/stores/settings/index.svelte';
import type { Backend } from '$lib/types'; import type { Backend } from '$lib/types';
@@ -23,22 +24,10 @@ export function getAuthHeaders(backendId?: string): Record<string, string> {
/** /**
* Get authorization headers for a backend object, including one that is not * Get authorization headers for a backend object, including one that is not
* registered yet (used by the connection test on the add-backend form). * registered yet (used by the connection test on the add-backend form).
* Anthropic-compatible backends authenticate with x-api-key instead of Bearer. * The protocol adapter owns the credential scheme and any required headers.
*/ */
export function getAuthHeadersForBackend(backend: Backend): Record<string, string> { export function getAuthHeadersForBackend(backend: Backend): Record<string, string> {
const headers: Record<string, string> = { ...(backend.headers ?? {}) }; return getProtocolAdapter(backend).authHeaders(backend);
const apiKey = backend.apiKey?.trim();
if (!apiKey) return headers;
if (backend.protocol === 'anthropic') {
headers[HEADERS.ANTHROPIC_API_KEY] = apiKey;
headers[HEADERS.ANTHROPIC_VERSION] = ANTHROPIC_API_VERSION;
} else {
headers[HEADERS.AUTHORIZATION] = `${HEADERS.BEARER}${apiKey}`;
}
return headers;
} }
/** /**
+38 -1
View File
@@ -8,13 +8,14 @@
import { import {
BACKEND_CAPABILITIES, BACKEND_CAPABILITIES,
BACKEND_COMPAT,
BACKEND_ID_PREFIX, BACKEND_ID_PREFIX,
BACKEND_PROTOCOLS, BACKEND_PROTOCOLS,
DEFAULT_BACKEND_CHAT_PATH, DEFAULT_BACKEND_CHAT_PATH,
DEFAULT_BACKEND_MODELS_PATH, DEFAULT_BACKEND_MODELS_PATH,
LOCAL_BACKEND_ID LOCAL_BACKEND_ID
} from '$lib/constants'; } from '$lib/constants';
import type { Backend, BackendCapabilities, BackendProtocol } from '$lib/types'; import type { Backend, BackendCapabilities, BackendCompat, BackendProtocol } from '$lib/types';
/** Absolute chat completions URL for a backend. */ /** Absolute chat completions URL for a backend. */
export function backendChatUrl(backend: Backend): string { export function backendChatUrl(backend: Backend): string {
@@ -31,6 +32,11 @@ export function getBackendCapabilities(backend: Backend): BackendCapabilities {
return BACKEND_CAPABILITIES[backend.protocol] ?? BACKEND_CAPABILITIES.openai; return BACKEND_CAPABILITIES[backend.protocol] ?? BACKEND_CAPABILITIES.openai;
} }
/** Wire quirks for a backend: protocol defaults overridden by the backend. */
export function getBackendCompat(backend: Backend): BackendCompat {
return { ...(BACKEND_COMPAT[backend.protocol] ?? BACKEND_COMPAT.openai), ...backend.compat };
}
/** The built-in backend pointing at the server that serves this UI. */ /** The built-in backend pointing at the server that serves this UI. */
export function createLocalBackend(apiKey?: string, enabled = true): Backend { export function createLocalBackend(apiKey?: string, enabled = true): Backend {
return { return {
@@ -107,6 +113,7 @@ function parseBackendEntry(entry: unknown, index: number): Backend | null {
apiKey, apiKey,
baseUrl, baseUrl,
chatPath: parseOptionalPath(raw.chatPath), chatPath: parseOptionalPath(raw.chatPath),
compat: parseBackendCompat(raw.compat, protocol),
enabled: raw.enabled !== false, enabled: raw.enabled !== false,
headers: parseBackendHeaders(raw.headers), headers: parseBackendHeaders(raw.headers),
id, id,
@@ -116,6 +123,36 @@ function parseBackendEntry(entry: unknown, index: number): Backend | null {
}; };
} }
// only keep the override keys the protocol understands, so a stale persisted
// value can never inject an unknown field into a request
function parseBackendCompat(
raw: unknown,
protocol: BackendProtocol
): Partial<BackendCompat> | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const entry = raw as Record<string, unknown>;
const defaults = BACKEND_COMPAT[protocol] ?? BACKEND_COMPAT.openai;
const overrides: Partial<BackendCompat> = {};
if (entry.maxTokensField === 'max_tokens' || entry.maxTokensField === 'max_completion_tokens') {
overrides.maxTokensField = entry.maxTokensField;
}
if (typeof entry.supportsUsageInStreaming === 'boolean') {
overrides.supportsUsageInStreaming = entry.supportsUsageInStreaming;
}
// drop a no-op override so an unmodified backend stays undefined
const isDefault =
(overrides.maxTokensField === undefined ||
overrides.maxTokensField === defaults.maxTokensField) &&
(overrides.supportsUsageInStreaming === undefined ||
overrides.supportsUsageInStreaming === defaults.supportsUsageInStreaming);
return isDefault ? undefined : overrides;
}
function parseBackendHeaders(raw: unknown): Record<string, string> | undefined { function parseBackendHeaders(raw: unknown): Record<string, string> | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;