feat(app): add multi-provider AI adapter pattern

Create unified AIAdapter interface (types.ts) with streaming chat,
model listing, and capability flags. Implement three adapters:
claude-adapter.ts (Claude proxy + vault key), openrouter-adapter.ts
(OpenAI-compatible SSE), ollama-adapter.ts (local NDJSON streaming).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 21:06:06 +00:00
co-authored by Claude Opus 4.6
parent 3bbcfad43d
commit 9c60d18528
4 changed files with 347 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
import type { AIAdapter, ChatMessage, ChatOptions } from './types'
import { getApiKey } from '@/utils/key-vault'
const CLAUDE_PATH = '/api/claude/v1/messages'
export const claudeAdapter: AIAdapter = {
id: 'claude',
name: 'Claude (Max)',
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
models() {
return [
{ id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4' },
{ id: 'claude-opus-4', name: 'Claude Opus 4' },
]
},
async chat(messages, options, onToken, onError) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const vaultKey = await getApiKey('claude')
if (vaultKey) {
headers['x-api-key'] = vaultKey
}
const apiMessages = messages
.filter(m => m.role !== 'system')
.map(m => ({ role: m.role, content: m.content }))
const res = await fetch(CLAUDE_PATH, {
method: 'POST',
headers,
body: JSON.stringify({
model: options.model,
system: options.systemPrompt,
messages: apiMessages,
stream: true,
webSearch: options.webSearch ?? false,
}),
signal: options.signal,
})
if (!res.ok) {
const body = await res.text().catch(() => 'Could not read error body')
onError(`Claude proxy error ${res.status}: ${body}`)
return
}
await readSSE(res, (data) => {
const parsed = JSON.parse(data)
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
onToken(parsed.delta.text)
} else if (parsed.type === 'error') {
onError(parsed.error?.message ?? 'Claude stream error')
}
}, onError, options.signal)
},
}
async function readSSE(
res: Response,
onData: (data: string) => void,
onError: (err: string) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = res.body?.getReader()
if (!reader) {
onError('No response body')
return
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (signal?.aborted) {
reader.cancel()
return
}
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data: ')) continue
const payload = trimmed.slice(6)
if (payload === '[DONE]') return
try {
onData(payload)
} catch {
// skip malformed chunks
}
}
}
} finally {
reader.cancel().catch(() => {})
}
}
@@ -0,0 +1,94 @@
import type { AIAdapter, ChatOptions } from './types'
const OLLAMA_BASE = 'http://localhost:11434'
export const ollamaAdapter: AIAdapter = {
id: 'ollama',
name: 'Ollama (Local)',
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
models() {
return [
{ id: 'llama3.2', name: 'Llama 3.2' },
{ id: 'mistral', name: 'Mistral' },
{ id: 'gemma2', name: 'Gemma 2' },
{ id: 'qwen2.5', name: 'Qwen 2.5' },
]
},
async chat(messages, options, onToken, onError) {
const ollamaMessages = messages.map(m => ({
role: m.role,
content: m.content,
}))
if (options.systemPrompt) {
ollamaMessages.unshift({ role: 'system', content: options.systemPrompt })
}
let res: Response
try {
res = await fetch(`${OLLAMA_BASE}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: options.model,
messages: ollamaMessages,
stream: true,
}),
signal: options.signal,
})
} catch {
onError('Cannot connect to Ollama. Is it running on localhost:11434?')
return
}
if (!res.ok) {
const body = await res.text().catch(() => 'Could not read error body')
onError(`Ollama error ${res.status}: ${body}`)
return
}
// Ollama uses newline-delimited JSON (not SSE)
const reader = res.body?.getReader()
if (!reader) {
onError('No response body')
return
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (options.signal?.aborted) {
reader.cancel()
return
}
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try {
const parsed = JSON.parse(line)
if (parsed.message?.content) {
onToken(parsed.message.content)
}
if (parsed.done) return
} catch {
// skip malformed lines
}
}
}
} finally {
reader.cancel().catch(() => {})
}
},
}
@@ -0,0 +1,113 @@
import type { AIAdapter, ChatOptions } from './types'
import { getApiKey } from '@/utils/key-vault'
const OPENROUTER_PATH = '/api/openrouter'
export const openrouterAdapter: AIAdapter = {
id: 'openrouter',
name: 'OpenRouter',
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
models() {
return [
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' },
{ id: 'qwen/qwen3-235b-a22b-thinking-2507', name: 'Qwen3 235B Thinking' },
{ id: 'mistralai/mistral-small-3.1-24b-instruct:free', name: 'Mistral Small 3.1 (free)' },
{ id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' },
]
},
async chat(messages, options, onToken, onError) {
const orMessages = messages.map(m => ({
role: m.role as 'user' | 'assistant' | 'system',
content: m.content,
}))
// Prepend system prompt as system message
if (options.systemPrompt) {
orMessages.unshift({ role: 'system', content: options.systemPrompt })
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'AIUI',
}
const vaultKey = await getApiKey('openrouter')
if (vaultKey) {
headers['Authorization'] = `Bearer ${vaultKey}`
}
const res = await fetch(OPENROUTER_PATH, {
method: 'POST',
headers,
body: JSON.stringify({
model: options.model,
messages: orMessages,
stream: true,
}),
signal: options.signal,
})
if (!res.ok) {
const body = await res.text().catch(() => 'Could not read error body')
onError(`OpenRouter error ${res.status}: ${body}`)
return
}
await readSSE(res, (data) => {
if (data === '[DONE]') return
const parsed = JSON.parse(data)
const delta = parsed.choices?.[0]?.delta?.content
if (delta) onToken(delta)
}, onError, options.signal)
},
}
async function readSSE(
res: Response,
onData: (data: string) => void,
onError: (err: string) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = res.body?.getReader()
if (!reader) {
onError('No response body')
return
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (signal?.aborted) {
reader.cancel()
return
}
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data: ')) continue
const payload = trimmed.slice(6)
if (payload === '[DONE]') return
try {
onData(payload)
} catch {
// skip malformed chunks
}
}
}
} finally {
reader.cancel().catch(() => {})
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Unified AI adapter interface for normalizing different provider APIs.
*/
export interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export interface ChatOptions {
model: string
systemPrompt?: string
webSearch?: boolean
signal?: AbortSignal
}
export interface AIAdapter {
id: string
name: string
supportsStreaming: boolean
supportsVision: boolean
supportsTools: boolean
/** List available models for this adapter */
models(): { id: string; name: string }[]
/** Stream chat completion, yielding text tokens */
chat(
messages: ChatMessage[],
options: ChatOptions,
onToken: (text: string) => void,
onError: (err: string) => void,
): Promise<void>
}