disclosure-bureau/web/lib/chat/index.ts

156 lines
4.4 KiB
TypeScript
Raw Normal View History

/**
* 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<SendChatResp> {
const t0 = Date.now();
async function viaOpenRouter(): Promise<SendChatResp> {
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<Uint8Array>;
/** 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<string, unknown>; 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 };
}