spb/note-spboucher-ai
Public
1/**2 * Provider adapters used by discovery + benchmarks. One function per provider:3 * streaming text call with optional tools / JSON schema / image / reasoning control.4 */5import { openai, anthropic, now, errInfo, type CallResult, RED_PNG_B64 } from "./lib.ts";67export interface CallOpts {8 model: string;9 system?: string;10 prompt: string;11 maxTokens?: number;12 jsonSchema?: { name: string; schema: Record<string, unknown> };13 tools?: { name: string; description: string; parameters: Record<string, unknown> }[];14 image?: boolean;15 reasoning?: "low" | "medium" | "high" | "none";16 stream?: boolean;17 timeoutMs?: number;18}1920const isReasoningOpenAI = (m: string) => /^(o\d|gpt-5|gpt-6)/.test(m) && !/chat-latest|^chat-latest/.test(m);2122export async function callOpenAI(o: CallOpts): Promise<CallResult> {23 const t0 = now();24 let ttft: number | null = null;25 const content: any[] = [{ type: "input_text", text: o.prompt }];26 if (o.image) content.unshift({ type: "input_image", image_url: `data:image/png;base64,${RED_PNG_B64}`, detail: "low" });27 const body: any = {28 model: o.model,29 input: [{ role: "user", content }],30 max_output_tokens: o.maxTokens ?? 2048,31 };32 if (o.system) body.instructions = o.system;33 if (o.jsonSchema) body.text = { format: { type: "json_schema", name: o.jsonSchema.name, schema: o.jsonSchema.schema, strict: true } };34 if (o.tools) body.tools = o.tools.map((t) => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: false }));35 if (o.reasoning && isReasoningOpenAI(o.model)) body.reasoning = { effort: o.reasoning };36 try {37 let text = "";38 let final: any;39 if (o.stream !== false) {40 const stream = await openai.responses.create({ ...body, stream: true }, { timeout: o.timeoutMs });41 for await (const ev of stream as any) {42 if (ev.type === "response.output_text.delta") { if (ttft === null) ttft = now() - t0; text += ev.delta; }43 else if (ev.type === "response.function_call_arguments.delta" && ttft === null) ttft = now() - t0;44 else if (ev.type === "response.completed") final = ev.response;45 else if (ev.type === "response.failed" || ev.type === "error") throw new Error(JSON.stringify(ev.response?.error ?? ev.error ?? ev).slice(0, 500));46 else if (ev.type === "response.incomplete") final = ev.response;47 }48 } else {49 final = await openai.responses.create(body, { timeout: o.timeoutMs });50 text = final.output_text ?? "";51 ttft = now() - t0;52 }53 const total = now() - t0;54 const toolCalls = (final?.output ?? []).filter((x: any) => x.type === "function_call").map((x: any) => ({ name: x.name, args: safeJSON(x.arguments) }));55 if (!text && final?.output_text) text = final.output_text;56 const u = final?.usage;57 return {58 ok: true, text, toolCalls, ttft_ms: ttft, total_ms: total, stop: final?.status ?? null,59 usage: u ? { input_tokens: u.input_tokens, output_tokens: u.output_tokens, cached_tokens: u.input_tokens_details?.cached_tokens ?? 0, reasoning_tokens: u.output_tokens_details?.reasoning_tokens ?? 0, source: "provider" } : null,60 raw: final?.incomplete_details ?? undefined,61 };62 } catch (e: any) {63 return { ok: false, text: "", toolCalls: [], usage: null, ttft_ms: ttft, total_ms: now() - t0, ...errInfo(e) };64 }65}6667export async function callAnthropic(o: CallOpts): Promise<CallResult> {68 const t0 = now();69 let ttft: number | null = null;70 const content: any[] = [{ type: "text", text: o.prompt }];71 if (o.image) content.unshift({ type: "image", source: { type: "base64", media_type: "image/png", data: RED_PNG_B64 } });72 const body: any = { model: o.model, max_tokens: o.maxTokens ?? 2048, messages: [{ role: "user", content }] };73 if (o.system) body.system = o.system;74 if (o.jsonSchema) body.output_config = { ...(body.output_config ?? {}), format: { type: "json_schema", schema: o.jsonSchema.schema } };75 if (o.tools) body.tools = o.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters }));76 if (o.reasoning && o.reasoning !== "none") {77 // Current models: adaptive thinking + effort. Older (4.5) models: budget_tokens.78 if (/4-5-\d{8}$/.test(o.model)) body.thinking = { type: "enabled", budget_tokens: 2048 }, body.max_tokens = Math.max(body.max_tokens, 4096);79 else { body.thinking = { type: "adaptive" }; body.output_config = { ...(body.output_config ?? {}), effort: o.reasoning }; }80 }81 try {82 let text = "";83 let final: any;84 if (o.stream !== false) {85 const stream = anthropic.messages.stream(body, { timeout: o.timeoutMs });86 for await (const ev of stream as any) {87 if (ev.type === "content_block_delta") {88 if (ev.delta.type === "text_delta") { if (ttft === null) ttft = now() - t0; text += ev.delta.text; }89 else if (ev.delta.type === "input_json_delta" && ttft === null) ttft = now() - t0;90 }91 }92 final = await stream.finalMessage();93 } else {94 final = await anthropic.messages.create(body, { timeout: o.timeoutMs });95 ttft = now() - t0;96 text = final.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("");97 }98 const total = now() - t0;99 const toolCalls = final.content.filter((b: any) => b.type === "tool_use").map((b: any) => ({ name: b.name, args: b.input }));100 const u = final.usage;101 return {102 ok: true, text, toolCalls, ttft_ms: ttft, total_ms: total, stop: final.stop_reason,103 usage: { input_tokens: u.input_tokens + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0), output_tokens: u.output_tokens, cached_tokens: u.cache_read_input_tokens ?? 0, reasoning_tokens: u.output_tokens_details?.thinking_tokens ?? 0, source: "provider" },104 raw: final.stop_reason === "refusal" ? final.stop_details : undefined,105 };106 } catch (e: any) {107 return { ok: false, text: "", toolCalls: [], usage: null, ttft_ms: ttft, total_ms: now() - t0, ...errInfo(e) };108 }109}110111export function call(provider: "openai" | "anthropic", o: CallOpts) {112 return provider === "openai" ? callOpenAI(o) : callAnthropic(o);113}114115export function safeJSON(s: unknown) { try { return typeof s === "string" ? JSON.parse(s) : s; } catch { return s; } }116