SPB Git
6.3 KB · 72 lines typescript
1import OpenAI from "openai";2import Anthropic from "@anthropic-ai/sdk";3import type { AICallOptions, AIEvent, AIResult, AIUsage } from "./types";4import { getModel, costUsd } from "./catalog";56let _oa: OpenAI | null = null, _an: Anthropic | null = null;7const oa = () => (_oa ??= new OpenAI({ maxRetries: 2, timeout: 10 * 60_000 }));8const an = () => (_an ??= new Anthropic({ maxRetries: 2, timeout: 10 * 60_000 }));9const isReasoningOpenAI = (m: string) => /^(o\d|gpt-5|gpt-6)/.test(m) && !/chat-latest/.test(m);10const now = () => performance.now();11const reqOpts = (o: AICallOptions) => ({ ...(o.signal ? { signal: o.signal } : {}), ...(o.timeoutMs ? { timeout: o.timeoutMs } : {}) });12const REFUSAL_RE = /^(I can['’]t|I cannot|I['’]m unable to) (provide|share|help with|give)[^.]{0,80}(access code|credential|password|secret)/i;1314export async function* streamAI(o: AICallOptions): AsyncGenerator<AIEvent> {15  const t0 = now(); let ttft: number | null = null;16  try {17    if (o.provider === "openai") {18      const input: any[] = o.messages.map((m) => ({ role: m.role, content: m.role === "user" ? [...(m.images ?? []).map((im) => ({ type: "input_image", image_url: `data:${im.mime};base64,${im.dataBase64}`, detail: "auto" })), { type: "input_text", text: m.content }] : m.content }));19      const body: any = { model: o.model, input, max_output_tokens: o.maxTokens ?? 8000, stream: true };20      if (o.system) body.instructions = o.system;21      if (o.jsonSchema) body.text = { format: { type: "json_schema", name: o.jsonSchema.name, schema: o.jsonSchema.schema, strict: true } };22      if (o.tools) body.tools = o.tools.map((t) => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: false }));23      if (o.effort && isReasoningOpenAI(o.model)) body.reasoning = { effort: o.effort };24      const stream: any = await oa().responses.create(body, reqOpts(o));25      let final: any = null; let text = "";26      for await (const ev of stream) {27        if (ev.type === "response.output_text.delta") { if (ttft === null) ttft = now() - t0; text += ev.delta; yield { type: "text", delta: ev.delta }; }28        else if (ev.type === "response.function_call_arguments.delta" && ttft === null) ttft = now() - t0;29        else if (ev.type === "response.completed" || ev.type === "response.incomplete") final = ev.response;30        else if (ev.type === "response.failed" || ev.type === "error") throw new Error(JSON.stringify(ev.response?.error ?? ev.error ?? ev).slice(0, 400));31      }32      for (const item of final?.output ?? []) if (item.type === "function_call") { let args: unknown = item.arguments; try { args = JSON.parse(item.arguments); } catch {} yield { type: "tool_call", id: item.call_id, name: item.name, args }; }33      const u = final?.usage;34      const usage: AIUsage = { inputTokens: u?.input_tokens ?? 0, outputTokens: u?.output_tokens ?? 0, cachedTokens: u?.input_tokens_details?.cached_tokens ?? 0, reasoningTokens: u?.output_tokens_details?.reasoning_tokens ?? 0 };35      yield { type: "done", usage, stop: final?.status ?? null, ttftMs: ttft, totalMs: now() - t0, refusal: REFUSAL_RE.test(text.trim()) ? "credential-style refusal text" : undefined };36    } else {37      const messages: any[] = o.messages.map((m) => ({ role: m.role, content: m.role === "user" && m.images?.length ? [...m.images.map((im) => ({ type: "image", source: { type: "base64", media_type: im.mime, data: im.dataBase64 } })), { type: "text", text: m.content }] : m.content }));38      const body: any = { model: o.model, max_tokens: o.maxTokens ?? 8000, messages };39      if (o.system) body.system = [{ type: "text", text: o.system, cache_control: { type: "ephemeral" } }];40      if (o.jsonSchema) body.output_config = { ...(body.output_config ?? {}), format: { type: "json_schema", schema: o.jsonSchema.schema } };41      if (o.tools) body.tools = o.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters }));42      if (/4-5-\d{8}$/.test(o.model)) { if (o.effort && o.effort !== "low") body.thinking = { type: "enabled", budget_tokens: 2048 }; }43      else if (o.effort) { body.thinking = { type: "adaptive" }; body.output_config = { ...(body.output_config ?? {}), effort: o.effort }; }44      const stream = an().messages.stream(body, reqOpts(o));45      for await (const ev of stream as any) {46        if (ev.type === "content_block_delta") {47          if (ev.delta.type === "text_delta") { if (ttft === null) ttft = now() - t0; yield { type: "text", delta: ev.delta.text }; }48          else if (ev.delta.type === "input_json_delta" && ttft === null) ttft = now() - t0;49        }50      }51      const final: any = await stream.finalMessage();52      for (const b of final.content) if (b.type === "tool_use") yield { type: "tool_call", id: b.id, name: b.name, args: b.input };53      const u = final.usage;54      const usage: AIUsage = { inputTokens: (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0), outputTokens: u.output_tokens ?? 0, cachedTokens: u.cache_read_input_tokens ?? 0, reasoningTokens: u.output_tokens_details?.thinking_tokens ?? 0 };55      yield { type: "done", usage, stop: final.stop_reason, ttftMs: ttft, totalMs: now() - t0, refusal: final.stop_reason === "refusal" ? `refusal (${final.stop_details?.category ?? "?"})` : undefined };56    }57  } catch (e: any) {58    if (o.signal?.aborted) { yield { type: "error", message: "aborted" }; return; }59    yield { type: "error", message: String(e?.message ?? e).slice(0, 500), status: typeof e?.status === "number" ? e.status : undefined };60  }61}6263/** Non-streaming convenience: collects the stream into a result. */64export async function callAI(o: AICallOptions): Promise<AIResult> {65  let text = ""; const toolCalls: AIResult["toolCalls"] = []; let done: Extract<AIEvent, { type: "done" }> | null = null; let err: Extract<AIEvent, { type: "error" }> | null = null;66  for await (const ev of streamAI(o)) { if (ev.type === "text") text += ev.delta; else if (ev.type === "tool_call") toolCalls.push(ev); else if (ev.type === "done") done = ev; else if (ev.type === "error") err = ev; }67  if (err) throw Object.assign(new Error(err.message), { status: err.status });68  const m = await getModel(o.model);69  const usage = done!.usage;70  return { text, toolCalls, usage, stop: done!.stop, ttftMs: done!.ttftMs, totalMs: done!.totalMs, costUsd: costUsd(m, usage), model: o.model, provider: o.provider, refusal: done!.refusal };71}72
Files · main
Menu

Theme