SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
8.4 KB · 242 lines typescript
Raw Blame History
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