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:
co-authored by
Claude Opus 4.6
parent
3bbcfad43d
commit
9c60d18528
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user