ui : drop the anthropic protocol from the backends

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-16 23:47:09 +02:00
parent 2181583700
commit ddf146248f
13 changed files with 24 additions and 411 deletions
@@ -10,7 +10,6 @@
import { findBackendPreset } from '$lib/utils';
const PROTOCOL_LABELS: Record<BackendProtocol, string> = {
anthropic: 'Anthropic',
'llama.cpp': 'llama.cpp',
openai: 'OpenAI'
};
@@ -8,7 +8,6 @@
const PROTOCOL_OPTIONS: Array<{ label: string; value: BackendProtocol }> = [
{ label: 'OpenAI-compatible', value: 'openai' },
{ label: 'Anthropic-compatible', value: 'anthropic' },
{ label: 'llama.cpp (llama-server)', value: 'llama.cpp' }
];
@@ -26,7 +25,6 @@
let protocolLabel = $derived(
PROTOCOL_OPTIONS.find((option) => option.value === backend.protocol)?.label ?? ''
);
let isAnthropic = $derived(backend.protocol === 'anthropic');
</script>
<div class="grid gap-2">
@@ -95,13 +93,7 @@
value={backend.apiKey ?? ''}
/>
<p class="mt-1.5 text-xs text-muted-foreground">
{#if isAnthropic}
Sent as the x-api-key header.
{:else}
Sent as a Bearer token.
{/if}
</p>
<p class="mt-1.5 text-xs text-muted-foreground">Sent as a Bearer token.</p>
</div>
<Collapsible.Root bind:open={showAdvanced}>
@@ -132,7 +132,7 @@
<Dialog.Header>
<Dialog.Title>{isEdit ? 'Edit backend' : 'Add backend'}</Dialog.Title>
<Dialog.Description>Connect an OpenAI- or Anthropic-compatible endpoint.</Dialog.Description>
<Dialog.Description>Connect an OpenAI-compatible endpoint.</Dialog.Description>
</Dialog.Header>
{#if !isEdit}
@@ -72,7 +72,7 @@
<Empty.Title>Add another backend</Empty.Title>
<Empty.Description>Connect an OpenAI- or Anthropic-compatible endpoint.</Empty.Description>
<Empty.Description>Connect an OpenAI-compatible endpoint.</Empty.Description>
</Empty.Header>
<Empty.Content>
@@ -5,14 +5,11 @@ import type {
BackendProtocol
} from '$lib/types';
/** Version sent with the Anthropic Messages API. */
export const ANTHROPIC_API_VERSION = '2023-06-01';
/** Prefix for generated ids of user-added backends. */
export const BACKEND_ID_PREFIX = 'backend';
/** Protocols a configured backend can speak, in display order. */
export const BACKEND_PROTOCOLS: readonly BackendProtocol[] = ['llama.cpp', 'openai', 'anthropic'];
export const BACKEND_PROTOCOLS: readonly BackendProtocol[] = ['llama.cpp', 'openai'];
/** Chat completions path used when a backend does not override it. */
export const DEFAULT_BACKEND_CHAT_PATH = '/v1/chat/completions';
@@ -34,7 +31,7 @@ const LLAMA_CPP_CAPABILITIES: BackendCapabilities = {
statusFeed: true,
tools: true
};
/** Capabilities of a plain OpenAI- or Anthropic-compatible endpoint. */
/** Capabilities of a plain OpenAI-compatible endpoint. */
const COMPATIBLE_CAPABILITIES: BackendCapabilities = {
corsProxy: false,
loadUnload: false,
@@ -48,15 +45,12 @@ const COMPATIBLE_CAPABILITIES: BackendCapabilities = {
/** Capabilities per backend protocol. */
export const BACKEND_CAPABILITIES: Record<BackendProtocol, BackendCapabilities> = {
anthropic: COMPATIBLE_CAPABILITIES,
'llama.cpp': LLAMA_CPP_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 }
@@ -3,12 +3,6 @@ const MCP_SESSION_ID_VISIBLE_CHARS = 5;
/** HTTP header handling for API and MCP requests. */
export const HEADERS = {
/** Anthropic-compatible backends authenticate with this header instead of Authorization */
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 */
ANTHROPIC_VERSION: 'anthropic-version',
/** Canonical casing for the Authorization header (RFC 7235) */
AUTHORIZATION: 'Authorization',
/** Bearer scheme prefix used for Authorization headers (RFC 6750) */
+1 -1
View File
@@ -751,7 +751,7 @@ export class ChatService {
break;
case 'usage':
// providers may split usage across chunks (Anthropic reports input
// providers may split usage across chunks (some report input
// tokens on message_start and output tokens on message_delta)
usage = { ...usage, ...event.usage };
@@ -1,355 +0,0 @@
/**
* 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 = {};
const fields = [
'cache_creation_input_tokens',
'cache_read_input_tokens',
'input_tokens',
'output_tokens'
] as const;
for (const field of fields) {
const value = raw[field];
if (typeof value === 'number') usage[field] = value;
}
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
};
@@ -6,13 +6,11 @@
* 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
};
+2 -2
View File
@@ -5,8 +5,8 @@
* 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.
* Decoding is per-stream: a reader keeps per-stream state, so it must not be
* shared between concurrent requests.
*/
import type { Backend } from '$lib/types';
-4
View File
@@ -379,12 +379,8 @@ export interface ApiChatCompletionStreamChunk {
}
export interface ApiChatCompletionUsage {
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
cached_tokens?: number;
completion_tokens?: number;
input_tokens?: number;
output_tokens?: number;
prompt_cache_hit_tokens?: number;
prompt_tokens?: number;
prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
+3 -3
View File
@@ -3,12 +3,12 @@
*
* A backend is one API endpoint the UI can talk to. The built-in `local`
* backend is the llama-server serving the UI. External backends are
* user-configured endpoints that speak an OpenAI- or Anthropic-compatible
* user-configured endpoints that speak an OpenAI-compatible
* protocol.
*/
/** Request/response shape a backend speaks. */
export type BackendProtocol = 'llama.cpp' | 'openai' | 'anthropic';
export type BackendProtocol = 'llama.cpp' | 'openai';
/**
* Wire-level quirks of a backend's protocol. Capabilities gate llama.cpp
@@ -23,7 +23,7 @@ export interface BackendCompat {
/**
* 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-compatible
* endpoints only provide chat and model listing.
*/
export interface BackendCapabilities {
+11 -16
View File
@@ -1,7 +1,7 @@
/**
* Client side timing fallback for backends that do not report their own.
*
* llama.cpp streams per-token timings; OpenAI and Anthropic compatible servers
* llama.cpp streams per-token timings; OpenAI-compatible servers
* do not. Token counts come from the usage block of the final chunk (or the
* count of streamed deltas as a fallback), times are measured locally: the wait
* for the first token is attributed to prompt processing, the rest to
@@ -18,32 +18,27 @@ export interface StreamClock {
}
/**
* Prompt/output/cache token counts, accepting OpenAI and Anthropic usage
* fields. `promptTokens` excludes the cache read tokens, which are returned
* separately as `cacheTokens`, so the two always add up to the prompt size.
* Prompt/output/cache token counts. `promptTokens` excludes the cache read
* tokens, which are returned separately as `cacheTokens`, so the two always
* add up to the prompt size.
*/
export function usageTokenCounts(usage: ApiChatCompletionUsage | undefined): {
cacheTokens: number;
completionTokens: number;
promptTokens: number;
} {
// Anthropic reports the input excluding cache tokens and splits reads from
// writes; OpenAI-compatible servers report a total that includes the reads
const isAnthropicStyle = usage?.input_tokens !== undefined;
const cacheTokens = isAnthropicStyle
? (usage?.cache_read_input_tokens ?? 0)
: (usage?.prompt_tokens_details?.cached_tokens ??
// a total that includes the cache reads, which are reported separately
const cacheTokens =
usage?.prompt_tokens_details?.cached_tokens ??
usage?.prompt_cache_hit_tokens ??
usage?.cached_tokens ??
0);
const promptTotal = isAnthropicStyle
? (usage?.input_tokens ?? 0) + (usage?.cache_creation_input_tokens ?? 0)
: (usage?.prompt_tokens ?? 0);
0;
const promptTotal = usage?.prompt_tokens ?? 0;
return {
cacheTokens,
completionTokens: usage?.completion_tokens ?? usage?.output_tokens ?? 0,
promptTokens: isAnthropicStyle ? promptTotal : Math.max(0, promptTotal - cacheTokens)
completionTokens: usage?.completion_tokens ?? 0,
promptTokens: Math.max(0, promptTotal - cacheTokens)
};
}