// Client OpenRouter : complétions en streaming (SSE) et non-streaming. // La clé reste STRICTEMENT côté serveur. export type ChatContent = | string | Array< | { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } } | { type: "file"; file: { filename: string; file_data: string } } >; export type ToolCall = { id: string; type: "function"; function: { name: string; arguments: string } }; export type ChatMessage = | { role: "system" | "user"; content: ChatContent } | { role: "assistant"; content: ChatContent; tool_calls?: ToolCall[] } | { role: "tool"; content: string; tool_call_id: string }; export type StreamEvent = | { type: "delta"; text: string } | { type: "tool-call"; name: string; arguments: string } | { type: "usage"; promptTokens: number; completionTokens: number } | { type: "done" } | { type: "error"; message: string }; const HEADERS = () => ({ Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, "Content-Type": "application/json", "HTTP-Referer": process.env.APP_URL || "http://localhost:3070", "X-Title": "Immbot AI (UQO)", }); function baseUrl(): string { return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1"; } export async function* streamChat(opts: { model: string; messages: ChatMessage[]; temperature?: number; maxTokens?: number; signal?: AbortSignal; fallbackModels?: string[]; }): AsyncGenerator { const body: Record = { model: opts.model, messages: opts.messages, stream: true, usage: { include: true }, temperature: opts.temperature ?? 0.4, max_tokens: opts.maxTokens ?? 4096, }; if (opts.fallbackModels?.length) body.models = [opts.model, ...opts.fallbackModels]; const res = await fetch(`${baseUrl()}/chat/completions`, { method: "POST", headers: HEADERS(), body: JSON.stringify(body), signal: opts.signal, }); if (!res.ok || !res.body) { const text = await res.text().catch(() => ""); yield { type: "error", message: `OpenRouter ${res.status} : ${text.slice(0, 300)}` }; return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { 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.startsWith("data:")) continue; const data = trimmed.slice(5).trim(); if (data === "[DONE]") { yield { type: "done" }; return; } try { const json = JSON.parse(data); const delta = json.choices?.[0]?.delta?.content; if (typeof delta === "string" && delta) yield { type: "delta", text: delta }; if (json.usage) { yield { type: "usage", promptTokens: json.usage.prompt_tokens ?? 0, completionTokens: json.usage.completion_tokens ?? 0, }; } if (json.error) yield { type: "error", message: String(json.error.message ?? "Erreur du fournisseur") }; } catch { // fragment JSON incomplet — ignoré } } } yield { type: "done" }; } /** * Complétion streaming AVEC outils : boucle d'appels d'outils (max `maxRounds`), * exécution via `executeTool`, texte relayé en continu. Les jetons sont cumulés * sur l'ensemble des tours. */ export async function* streamChatWithTools(opts: { model: string; messages: ChatMessage[]; tools: { type: "function"; function: { name: string; description: string; parameters: Record } }[]; executeTool: (name: string, args: string) => string | Promise; temperature?: number; maxTokens?: number; maxRounds?: number; signal?: AbortSignal; }): AsyncGenerator { const messages: ChatMessage[] = [...opts.messages]; let totalIn = 0, totalOut = 0; const maxRounds = opts.maxRounds ?? 4; for (let round = 0; round <= maxRounds; round++) { const lastRound = round === maxRounds; const body: Record = { model: opts.model, messages, stream: true, usage: { include: true }, temperature: opts.temperature ?? 0.4, max_tokens: opts.maxTokens ?? 4096, }; if (!lastRound) { body.tools = opts.tools; body.tool_choice = "auto"; } const res = await fetch(`${baseUrl()}/chat/completions`, { method: "POST", headers: HEADERS(), body: JSON.stringify(body), signal: opts.signal, }); if (!res.ok || !res.body) { const text = await res.text().catch(() => ""); yield { type: "error", message: `OpenRouter ${res.status} : ${text.slice(0, 300)}` }; return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let roundText = ""; const toolCalls: ToolCall[] = []; let finish: string | null = null; while (true) { 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.startsWith("data:")) continue; const data = trimmed.slice(5).trim(); if (data === "[DONE]") continue; try { const json = JSON.parse(data); const choice = json.choices?.[0]; const delta = choice?.delta; if (typeof delta?.content === "string" && delta.content) { roundText += delta.content; yield { type: "delta", text: delta.content }; } for (const tc of delta?.tool_calls ?? []) { const idx = tc.index ?? 0; if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id ?? `call_${idx}`, type: "function", function: { name: "", arguments: "" } }; if (tc.id) toolCalls[idx].id = tc.id; if (tc.function?.name) toolCalls[idx].function.name += tc.function.name; if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments; } if (choice?.finish_reason) finish = choice.finish_reason; if (json.usage) { totalIn += json.usage.prompt_tokens ?? 0; totalOut += json.usage.completion_tokens ?? 0; } if (json.error) yield { type: "error", message: String(json.error.message ?? "Erreur du fournisseur") }; } catch { /* fragment incomplet */ } } } const pendingCalls = toolCalls.filter((t) => t && t.function.name); if (finish === "tool_calls" && pendingCalls.length && !lastRound) { messages.push({ role: "assistant", content: roundText, tool_calls: pendingCalls }); for (const call of pendingCalls.slice(0, 5)) { yield { type: "tool-call", name: call.function.name, arguments: call.function.arguments }; let result: string; try { result = await opts.executeTool(call.function.name, call.function.arguments); } catch (e) { result = "Erreur d'exécution : " + (e instanceof Error ? e.message : String(e)); } messages.push({ role: "tool", content: result.slice(0, 28_000), tool_call_id: call.id }); } continue; // tour suivant avec les résultats d'outils } yield { type: "usage", promptTokens: totalIn, completionTokens: totalOut }; yield { type: "done" }; return; } } export async function completeChat(opts: { model: string; messages: ChatMessage[]; temperature?: number; maxTokens?: number; jsonMode?: boolean; }): Promise<{ text: string; promptTokens: number; completionTokens: number }> { const body: Record = { model: opts.model, messages: opts.messages, temperature: opts.temperature ?? 0.4, max_tokens: opts.maxTokens ?? 4096, usage: { include: true }, }; if (opts.jsonMode) body.response_format = { type: "json_object" }; const res = await fetch(`${baseUrl()}/chat/completions`, { method: "POST", headers: HEADERS(), body: JSON.stringify(body), }); if (!res.ok) throw new Error(`OpenRouter ${res.status} : ${(await res.text()).slice(0, 300)}`); const json = await res.json(); return { text: json.choices?.[0]?.message?.content ?? "", promptTokens: json.usage?.prompt_tokens ?? 0, completionTokens: json.usage?.completion_tokens ?? 0, }; }