/** * Chat orchestrator. * * Exports: * sendChat(req) — non-streaming, no tools (used by tests, fallback paths) * streamChat(req, cb) — streaming + tool calling via OpenRouter (Pattern C) * * CHAT_PROVIDER controls which path: * 'openrouter' (default for Pattern C) — full tools + streaming * 'claude-code' — simple Q&A via OAuth subprocess, NO tools * 'auto' — claude-code first; on rate-limit/error fall back to OpenRouter (no tools) */ import { claudeCodeProvider } from "./claude-code"; import { sendOnce, streamWithTools } from "./openrouter"; import { createEventStream } from "./agui"; import type { ToolHandlerContext } from "./tools"; export type Provider = "claude-code" | "openrouter"; const MODE = (process.env.CHAT_PROVIDER || "openrouter") as Provider | "auto"; /* ─── Non-streaming (legacy/fallback) ───────────────────────────────────── */ export interface SendChatReq { system: string; messages: Array<{ role: "user" | "assistant" | "system"; content: string }>; maxTokens?: number; } export interface SendChatResp { provider: Provider; model: string; content: string; tokensIn?: number; tokensOut?: number; costUsd?: number; durationMs: number; } export async function sendChat(req: SendChatReq): Promise { const t0 = Date.now(); async function viaOpenRouter(): Promise { const r = await sendOnce({ system: req.system, messages: req.messages, maxTokens: req.maxTokens, }); return { provider: "openrouter", model: r.model, content: r.content, tokensIn: r.tokensIn, tokensOut: r.tokensOut, costUsd: 0, durationMs: Date.now() - t0, }; } if (MODE === "openrouter") return viaOpenRouter(); if (MODE === "claude-code") { if (!claudeCodeProvider.isAvailable()) { throw new Error("claude-code mode but CLAUDE_CODE_OAUTH_TOKEN not set"); } const r = await claudeCodeProvider.send({ system: req.system, messages: req.messages, maxTokens: req.maxTokens, }); return { ...r, durationMs: Date.now() - t0 }; } // auto if (claudeCodeProvider.isAvailable()) { try { const r = await claudeCodeProvider.send({ system: req.system, messages: req.messages, maxTokens: req.maxTokens, }); return { ...r, durationMs: Date.now() - t0 }; } catch (e) { const isRate = (e as Error & { isRateLimit?: boolean }).isRateLimit; if (isRate || /401|403|oauth|token/i.test((e as Error).message)) { return viaOpenRouter(); } throw e; } } return viaOpenRouter(); } /* ─── Streaming + tool calling (Pattern C) ──────────────────────────────── */ export interface StreamChatReq { system: string; history: Array<{ role: "user" | "assistant"; content: string }>; userTurn: string; ctx: ToolHandlerContext; } export interface StreamChatResult { stream: ReadableStream; /** Resolves AFTER the stream completes — usable in a deferred persist step. */ done: Promise<{ content: string; model: string; tokensIn: number; tokensOut: number; toolCalls: Array<{ name: string; args: Record; result: unknown }>; }>; } /** * Returns immediately with a ReadableStream the caller can pipe to Response. * The `done` promise resolves when the full conversation (including all tool * rounds) is finished — so the caller can then persist the assistant message * to the database. */ export function streamChat(req: StreamChatReq): StreamChatResult { const { stream, emit, close } = createEventStream(); const done = (async () => { try { const result = await streamWithTools( { system: req.system, history: req.history, userTurn: req.userTurn, ctx: req.ctx, }, { emit }, ); emit({ type: "done", provider: "openrouter", model: result.model, usage: { tokens_in: result.tokensIn, tokens_out: result.tokensOut, tool_calls: result.toolCalls.length, }, }); close(); return result; } catch (e) { emit({ type: "error", message: e instanceof Error ? e.message : String(e) }); close(); throw e; } })(); return { stream, done }; }