TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1// Client OpenRouter : complétions en streaming (SSE) et non-streaming.2// La clé reste STRICTEMENT côté serveur.34export type ChatContent =5 | string6 | Array<7 | { type: "text"; text: string }8 | { type: "image_url"; image_url: { url: string } }9 | { type: "file"; file: { filename: string; file_data: string } }10 >;1112export type ToolCall = { id: string; type: "function"; function: { name: string; arguments: string } };1314export type ChatMessage =15 | { role: "system" | "user"; content: ChatContent }16 | { role: "assistant"; content: ChatContent; tool_calls?: ToolCall[] }17 | { role: "tool"; content: string; tool_call_id: string };1819export type StreamEvent =20 | { type: "delta"; text: string }21 | { type: "tool-call"; name: string; arguments: string }22 | { type: "usage"; promptTokens: number; completionTokens: number }23 | { type: "done" }24 | { type: "error"; message: string };2526const HEADERS = () => ({27 Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,28 "Content-Type": "application/json",29 "HTTP-Referer": process.env.APP_URL || "http://localhost:3070",30 "X-Title": "Immbot AI (UQO)",31});3233function baseUrl(): string {34 return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";35}3637export async function* streamChat(opts: {38 model: string;39 messages: ChatMessage[];40 temperature?: number;41 maxTokens?: number;42 signal?: AbortSignal;43 fallbackModels?: string[];44}): AsyncGenerator<StreamEvent> {45 const body: Record<string, unknown> = {46 model: opts.model,47 messages: opts.messages,48 stream: true,49 usage: { include: true },50 temperature: opts.temperature ?? 0.4,51 max_tokens: opts.maxTokens ?? 4096,52 };53 if (opts.fallbackModels?.length) body.models = [opts.model, ...opts.fallbackModels];5455 const res = await fetch(`${baseUrl()}/chat/completions`, {56 method: "POST",57 headers: HEADERS(),58 body: JSON.stringify(body),59 signal: opts.signal,60 });61 if (!res.ok || !res.body) {62 const text = await res.text().catch(() => "");63 yield { type: "error", message: `OpenRouter ${res.status} : ${text.slice(0, 300)}` };64 return;65 }66 const reader = res.body.getReader();67 const decoder = new TextDecoder();68 let buffer = "";69 while (true) {70 const { done, value } = await reader.read();71 if (done) break;72 buffer += decoder.decode(value, { stream: true });73 const lines = buffer.split("\n");74 buffer = lines.pop() ?? "";75 for (const line of lines) {76 const trimmed = line.trim();77 if (!trimmed.startsWith("data:")) continue;78 const data = trimmed.slice(5).trim();79 if (data === "[DONE]") {80 yield { type: "done" };81 return;82 }83 try {84 const json = JSON.parse(data);85 const delta = json.choices?.[0]?.delta?.content;86 if (typeof delta === "string" && delta) yield { type: "delta", text: delta };87 if (json.usage) {88 yield {89 type: "usage",90 promptTokens: json.usage.prompt_tokens ?? 0,91 completionTokens: json.usage.completion_tokens ?? 0,92 };93 }94 if (json.error) yield { type: "error", message: String(json.error.message ?? "Erreur du fournisseur") };95 } catch {96 // fragment JSON incomplet — ignoré97 }98 }99 }100 yield { type: "done" };101}102103/**104 * Complétion streaming AVEC outils : boucle d'appels d'outils (max `maxRounds`),105 * exécution via `executeTool`, texte relayé en continu. Les jetons sont cumulés106 * sur l'ensemble des tours.107 */108export async function* streamChatWithTools(opts: {109 model: string;110 messages: ChatMessage[];111 tools: { type: "function"; function: { name: string; description: string; parameters: Record<string, unknown> } }[];112 executeTool: (name: string, args: string) => string | Promise<string>;113 temperature?: number;114 maxTokens?: number;115 maxRounds?: number;116 signal?: AbortSignal;117}): AsyncGenerator<StreamEvent> {118 const messages: ChatMessage[] = [...opts.messages];119 let totalIn = 0, totalOut = 0;120 const maxRounds = opts.maxRounds ?? 4;121122 for (let round = 0; round <= maxRounds; round++) {123 const lastRound = round === maxRounds;124 const body: Record<string, unknown> = {125 model: opts.model,126 messages,127 stream: true,128 usage: { include: true },129 temperature: opts.temperature ?? 0.4,130 max_tokens: opts.maxTokens ?? 4096,131 };132 if (!lastRound) {133 body.tools = opts.tools;134 body.tool_choice = "auto";135 }136137 const res = await fetch(`${baseUrl()}/chat/completions`, {138 method: "POST",139 headers: HEADERS(),140 body: JSON.stringify(body),141 signal: opts.signal,142 });143 if (!res.ok || !res.body) {144 const text = await res.text().catch(() => "");145 yield { type: "error", message: `OpenRouter ${res.status} : ${text.slice(0, 300)}` };146 return;147 }148149 const reader = res.body.getReader();150 const decoder = new TextDecoder();151 let buffer = "";152 let roundText = "";153 const toolCalls: ToolCall[] = [];154 let finish: string | null = null;155156 while (true) {157 const { done, value } = await reader.read();158 if (done) break;159 buffer += decoder.decode(value, { stream: true });160 const lines = buffer.split("\n");161 buffer = lines.pop() ?? "";162 for (const line of lines) {163 const trimmed = line.trim();164 if (!trimmed.startsWith("data:")) continue;165 const data = trimmed.slice(5).trim();166 if (data === "[DONE]") continue;167 try {168 const json = JSON.parse(data);169 const choice = json.choices?.[0];170 const delta = choice?.delta;171 if (typeof delta?.content === "string" && delta.content) {172 roundText += delta.content;173 yield { type: "delta", text: delta.content };174 }175 for (const tc of delta?.tool_calls ?? []) {176 const idx = tc.index ?? 0;177 if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id ?? `call_${idx}`, type: "function", function: { name: "", arguments: "" } };178 if (tc.id) toolCalls[idx].id = tc.id;179 if (tc.function?.name) toolCalls[idx].function.name += tc.function.name;180 if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments;181 }182 if (choice?.finish_reason) finish = choice.finish_reason;183 if (json.usage) {184 totalIn += json.usage.prompt_tokens ?? 0;185 totalOut += json.usage.completion_tokens ?? 0;186 }187 if (json.error) yield { type: "error", message: String(json.error.message ?? "Erreur du fournisseur") };188 } catch { /* fragment incomplet */ }189 }190 }191192 const pendingCalls = toolCalls.filter((t) => t && t.function.name);193 if (finish === "tool_calls" && pendingCalls.length && !lastRound) {194 messages.push({ role: "assistant", content: roundText, tool_calls: pendingCalls });195 for (const call of pendingCalls.slice(0, 5)) {196 yield { type: "tool-call", name: call.function.name, arguments: call.function.arguments };197 let result: string;198 try {199 result = await opts.executeTool(call.function.name, call.function.arguments);200 } catch (e) {201 result = "Erreur d'exécution : " + (e instanceof Error ? e.message : String(e));202 }203 messages.push({ role: "tool", content: result.slice(0, 28_000), tool_call_id: call.id });204 }205 continue; // tour suivant avec les résultats d'outils206 }207208 yield { type: "usage", promptTokens: totalIn, completionTokens: totalOut };209 yield { type: "done" };210 return;211 }212}213214export async function completeChat(opts: {215 model: string;216 messages: ChatMessage[];217 temperature?: number;218 maxTokens?: number;219 jsonMode?: boolean;220}): Promise<{ text: string; promptTokens: number; completionTokens: number }> {221 const body: Record<string, unknown> = {222 model: opts.model,223 messages: opts.messages,224 temperature: opts.temperature ?? 0.4,225 max_tokens: opts.maxTokens ?? 4096,226 usage: { include: true },227 };228 if (opts.jsonMode) body.response_format = { type: "json_object" };229 const res = await fetch(`${baseUrl()}/chat/completions`, {230 method: "POST",231 headers: HEADERS(),232 body: JSON.stringify(body),233 });234 if (!res.ok) throw new Error(`OpenRouter ${res.status} : ${(await res.text()).slice(0, 300)}`);235 const json = await res.json();236 return {237 text: json.choices?.[0]?.message?.content ?? "",238 promptTokens: json.usage?.prompt_tokens ?? 0,239 completionTokens: json.usage?.completion_tokens ?? 0,240 };241}242