/** * LLM Client for Franklin * Calls BlockRun API directly with x402 payment handling and streaming. * Original implementation — not derived from any existing codebase. */ import { type Chain } from '../config.js'; import type { Dialogue, CapabilityDefinition, ContentPart, CapabilityInvocation } from './types.js'; /** * Anthropic-compatible tool_choice. Forwarded as-is through the proxy and on * to the backend (Anthropic / OpenAI / Gemini gateways translate as needed). * * - `auto` — model decides (default if omitted) * - `any` — must call SOME tool, model picks which * - `tool` — must call the specifically named tool * - `none` — must not call any tool * * Used by the grounding-retry path in `loop.ts`: when the evaluator catches * an ungrounded answer that should have invoked tools, the next round sets * `tool_choice` to force tool use rather than relying on a soft instruction * the model can defy by fabricating citations. */ export type ToolChoice = { type: 'auto'; } | { type: 'any'; } | { type: 'tool'; name: string; } | { type: 'none'; }; export interface ModelRequest { model: string; messages: Dialogue[]; system?: string; tools?: CapabilityDefinition[]; max_tokens?: number; stream?: boolean; temperature?: number; tool_choice?: ToolChoice; } export interface StreamChunk { kind: 'content_block_start' | 'content_block_delta' | 'content_block_stop' | 'message_start' | 'message_delta' | 'message_stop' | 'ping' | 'error'; payload: Record; } export interface CompletionUsage { inputTokens: number; outputTokens: number; /** * Anthropic prompt-cache fields. `input_tokens` only counts the base * (uncached) portion; the cache-creation and cache-read counts are * separate and billed at different rates (1.25× / 0.1× of base input, * respectively). Pre-fix, Franklin only read `input_tokens` and * silently undercounted every vision / cache-using call's total * token spend — verified 2026-05-11 from an Opus 4.7 turn billed * $0.567 with audit logging `inputTokens: 3653` (implies ~113K real * billed input tokens). Surface all three so audits, stats, and any * future estimation paths see the full picture. */ cacheCreationInputTokens?: number; cacheReadInputTokens?: number; } export interface LLMClientOptions { apiUrl: string; chain: Chain; debug?: boolean; } /** * Replace Unicode box-drawing characters with their ASCII equivalents. * * Models occasionally emit U+2502 (`│`) and U+2500 (`─`) in markdown tables * — sometimes mixed with ASCII `|` / `-` in the same table. No markdown * renderer parses the mix, and the "table" displays as run-on text. Verified * 2026-05-06 in a real session: opus-4.7 emitted a CRCL fundamentals table * with `│` data rows and `|` separator, ignoring the system-prompt nudge * added in 3.15.76. The unconditional swap fixes the rendering at the * streaming boundary so every downstream surface (user terminal, conversation * history, audit log) gets the corrected version. * * Trade: the rare case where a user genuinely wants box-drawing in output * (e.g. asking what U+2502 looks like) loses fidelity. Acceptable — that * case has no real-world frequency, the broken-tables case has weekly. */ export declare function sanitizeTableUnicode(s: string): string; /** * Extract the most human-readable message from an error body. * Some gateways wrap provider errors multiple times, e.g. * `{"error":{"message":"{\"error\":{\"message\":\"...\"}}"}}`. * Peel those layers so the UI doesn't show raw nested JSON. */ export declare function extractApiErrorMessage(errorBody: string): string; /** * True if the given Anthropic model accepts the `thinking: { type: 'enabled' }` * API flag (so-called *extended thinking*). Models using *adaptive thinking* * (Opus 4.7 and later) reject that flag — the behavior is built in and not * opt-in via API. Keeping the allowlist explicit, not derived from a regex, * so a future model that happens to include "opus" in its name doesn't * silently re-enable extended thinking on a model that can't handle it. * * Exported so tests can pin this decision without a live API. */ export declare function modelHasExtendedThinking(model: string): boolean; /** * Classify an unparseable tool-call JSON failure so the user and the model * get an actionable message instead of a single generic line. Exported for * direct unit testing — the happy path hits it only on stream error. */ export declare function classifyToolCallFailure(toolName: string, rawInput: string, signal: AbortSignal | undefined, model: string): string; export declare function isRoleplayedJsonToolCallText(text: string, knownToolNames?: ReadonlySet): boolean; export declare class ModelClient { private apiUrl; private chain; private debug; private walletAddress; private cachedBaseWallet; private cachedSolanaWallet; private walletCacheTime; /** * USDC actually charged on the most recent x402 settlement, parsed * from `details.amount` (micro-USDC → USD). Reset to 0 at the start * of every `streamCompletion`, written by `signBasePayment` / * `signSolanaPayment`. Callers read it via `getLastPaidUsd()` after * the stream completes so franklin-stats.json records the real wallet * charge instead of a token-catalog estimate. */ private lastPaidUsd; private static WALLET_CACHE_TTL; constructor(opts: LLMClientOptions); /** * Stream a completion from the BlockRun API. * Yields parsed SSE chunks as they arrive. * Handles x402 payment automatically on 402 responses. */ /** * Resolve virtual routing profiles (blockrun/auto, blockrun/free) to * concrete models. This is the final safety net — if the router in * loop.ts didn't resolve it (e.g. old global install without router), * we resolve it here before hitting the API. Legacy blockrun/eco and * blockrun/premium fall through the unknown-key path to the same * default model. */ private resolveVirtualModel; /** * USDC actually charged for the most recent stream. 0 if no payment * was made (free model / cached / pre-stream error). Callers should * read this after the stream finishes — before that it carries the * value from a previous call. */ getLastPaidUsd(): number; streamCompletion(request: ModelRequest, signal?: AbortSignal): AsyncGenerator; private parseNonStreamingMessage; /** * Non-streaming completion for simple requests. */ complete(request: ModelRequest, signal?: AbortSignal, onToolReady?: (tool: CapabilityInvocation) => void, onStreamDelta?: (delta: { type: 'text' | 'thinking'; text: string; }) => void): Promise<{ content: ContentPart[]; usage: CompletionUsage; stopReason: string; }>; private signPayment; private recordSettledPayment; private signBasePayment; private signSolanaPayment; private extractPaymentReq; private parseSSEStream; private mapEventType; }