import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk";
import fs from "node:fs";
import path from "node:path";
export function createLlmClient(config) {
    let client;
    if (config.llm.protocol === "openai") {
        client = new OpenAIClient(config);
    }
    else {
        client = new ClaudeClient(config);
    }
    if (config.debug) {
        return new DebugLlmClient(client, config.workspace);
    }
    return client;
}
// --- OpenAI Protocol ---
/**
 * Hardcoded reasoning effort sent on every OpenAI chat.completions
 * request. `medium` was chosen because (a) it matches the upstream
 * default's observed latency, (b) explicitly setting a value pins
 * sub2api's gpt-5.5 routing to a predictable upstream account pool
 * (omitting the param landed requests in an unstable pool with
 * occasional ~250s stalls), and (c) keeps every per-instance JSON
 * config identical across Claude/OpenAI agents.
 *
 * Assumes the configured model accepts `reasoning_effort` — true for
 * gpt-5.x. Plain gpt-4o etc. would 400; revisit if that ever ships.
 */
const OPENAI_REASONING_EFFORT = "none";
class OpenAIClient {
    client;
    model;
    maxTokens;
    constructor(config) {
        let baseURL = config.llm.baseUrl.replace(/\/+$/, "");
        if (!baseURL.endsWith("/v1")) {
            baseURL += "/v1";
        }
        this.client = new OpenAI({
            apiKey: config.llm.apiKey,
            baseURL,
        });
        this.model = config.llm.model;
        this.maxTokens = config.llm.maxTokens;
    }
    async chat(messages, tools, systemPrompt, cacheHints, signal) {
        const openaiMessages = this.convertMessages(messages, systemPrompt);
        const openaiTools = tools?.map((t) => this.convertTool(t));
        const params = {
            model: this.model,
            messages: openaiMessages,
        };
        if (this.maxTokens) {
            params.max_tokens = this.maxTokens;
        }
        if (openaiTools && openaiTools.length > 0) {
            params.tools = openaiTools;
        }
        if (cacheHints?.sessionId) {
            // Official OpenAI param (drives their prompt cache routing) and also
            // matches sub2api's priority-3 sticky-session signal.
            params.prompt_cache_key = cacheHints.sessionId;
        }
        params.reasoning_effort = OPENAI_REASONING_EFFORT;
        const response = await this.client.chat.completions.create(params, signal ? { signal } : undefined);
        const choice = response.choices[0];
        const content = choice.message.content || "";
        const toolCalls = (choice.message.tool_calls || []).map((tc) => ({
            id: tc.id,
            name: tc.function.name,
            arguments: JSON.parse(tc.function.arguments),
        }));
        let stopReason = "stop";
        if (choice.finish_reason === "tool_calls") {
            stopReason = "tool_use";
        }
        else if (choice.finish_reason === "length") {
            stopReason = "length";
        }
        return {
            content,
            toolCalls,
            stopReason,
            usage: extractOpenAIUsage(response.usage),
        };
    }
    async *chatStream(messages, tools, systemPrompt, cacheHints, signal) {
        const openaiMessages = this.convertMessages(messages, systemPrompt);
        const openaiTools = tools?.map((t) => this.convertTool(t));
        const params = {
            model: this.model,
            messages: openaiMessages,
            stream: true,
        };
        if (this.maxTokens) {
            params.max_tokens = this.maxTokens;
        }
        if (openaiTools && openaiTools.length > 0) {
            params.tools = openaiTools;
        }
        if (cacheHints?.sessionId) {
            params.prompt_cache_key = cacheHints.sessionId;
        }
        params.reasoning_effort = OPENAI_REASONING_EFFORT;
        const stream = await this.client.chat.completions.create(params, signal ? { signal } : undefined);
        // Track tool calls being assembled across chunks
        const pendingToolCalls = new Map();
        for await (const chunk of stream) {
            const delta = chunk.choices?.[0]?.delta;
            if (!delta)
                continue;
            // Text content
            if (delta.content) {
                yield { type: "text_delta", text: delta.content };
            }
            // Tool calls (streamed incrementally)
            if (delta.tool_calls) {
                for (const tc of delta.tool_calls) {
                    const idx = tc.index;
                    if (tc.id) {
                        // New tool call starting
                        pendingToolCalls.set(idx, {
                            id: tc.id,
                            name: tc.function?.name || "",
                            args: tc.function?.arguments || "",
                        });
                        yield {
                            type: "tool_call_start",
                            toolCall: { id: tc.id, name: tc.function?.name || "", arguments: {} },
                        };
                    }
                    else {
                        // Continuing existing tool call
                        const pending = pendingToolCalls.get(idx);
                        if (pending) {
                            if (tc.function?.name)
                                pending.name += tc.function.name;
                            if (tc.function?.arguments)
                                pending.args += tc.function.arguments;
                        }
                    }
                }
            }
            // Check if stream is done
            if (chunk.choices?.[0]?.finish_reason) {
                // Emit completed tool calls
                for (const [, pending] of pendingToolCalls) {
                    try {
                        yield {
                            type: "tool_call_end",
                            toolCall: {
                                id: pending.id,
                                name: pending.name,
                                arguments: JSON.parse(pending.args || "{}"),
                            },
                        };
                    }
                    catch {
                        yield {
                            type: "tool_call_end",
                            toolCall: { id: pending.id, name: pending.name, arguments: {} },
                        };
                    }
                }
                yield { type: "done" };
            }
        }
    }
    convertMessages(messages, systemPrompt) {
        const result = [];
        if (systemPrompt) {
            result.push({ role: "system", content: systemPrompt });
        }
        for (const msg of messages) {
            if (msg.role === "tool" && msg.toolCallId) {
                result.push({
                    role: "tool",
                    tool_call_id: msg.toolCallId,
                    content: typeof msg.content === "string"
                        ? msg.content
                        : JSON.stringify(msg.content),
                });
            }
            else if (msg.role === "assistant" && msg.toolCalls?.length) {
                result.push({
                    role: "assistant",
                    content: typeof msg.content === "string" ? msg.content || null : null,
                    tool_calls: msg.toolCalls.map((tc) => ({
                        id: tc.id,
                        type: "function",
                        function: {
                            name: tc.name,
                            arguments: JSON.stringify(tc.arguments),
                        },
                    })),
                });
            }
            else {
                const content = this.convertContent(msg.content);
                result.push({
                    role: msg.role === "system" ? "system" : msg.role,
                    content,
                });
            }
        }
        return result;
    }
    convertContent(content) {
        if (typeof content === "string")
            return content;
        return content.map((block) => {
            if (block.type === "image" && block.imageData) {
                return {
                    type: "image_url",
                    image_url: {
                        url: `data:${block.mediaType || "image/png"};base64,${block.imageData}`,
                    },
                };
            }
            return { type: "text", text: block.text || "" };
        });
    }
    convertTool(tool) {
        return {
            type: "function",
            function: {
                name: tool.name,
                description: tool.description,
                parameters: tool.parameters,
            },
        };
    }
}
/**
 * Pull token counters from an OpenAI chat.completions response.usage. The
 * automatic prompt cache (no cache_control needed; kicks in past ~1024 tokens
 * of stable prefix) reports cached input under `prompt_tokens_details.cached_tokens`
 * — we surface that as cacheReadInputTokens so dipclaw's logs / monitoring
 * code can treat the two providers uniformly.
 *
 * OpenAI has no separate "cache write" concept — the first request just pays
 * full rate and a later identical-prefix request gets the cached read.
 */
export function extractOpenAIUsage(usage) {
    if (!usage || typeof usage !== "object")
        return undefined;
    const u = usage;
    if (typeof u.prompt_tokens !== "number")
        return undefined;
    return {
        inputTokens: u.prompt_tokens,
        outputTokens: u.completion_tokens ?? 0,
        cacheReadInputTokens: u.prompt_tokens_details?.cached_tokens ?? 0,
    };
}
// --- Claude/Anthropic Protocol ---
/** Anthropic API requires max_tokens — default to 16384 if not configured */
const CLAUDE_DEFAULT_MAX_TOKENS = 16384;
class ClaudeClient {
    client;
    model;
    maxTokens;
    deviceId;
    constructor(config) {
        this.client = new Anthropic({
            apiKey: config.llm.apiKey,
            baseURL: config.llm.baseUrl,
        });
        this.model = config.llm.model;
        this.maxTokens = config.llm.maxTokens ?? CLAUDE_DEFAULT_MAX_TOKENS;
        // Stable per-instance device tag for sub2api's metadata.user_id; the
        // sticky-session hash only keys off session_id but device_id is part
        // of the format sub2api parses.
        this.deviceId = `dipclaw-${config.name || "default"}`;
    }
    async chat(messages, tools, systemPrompt, cacheHints, signal) {
        const anthropicMessages = this.convertMessages(messages);
        if (cacheHints?.cacheMessages) {
            applyMessagesCacheBreakpoint(anthropicMessages);
        }
        const anthropicTools = tools?.map((t) => this.convertTool(t));
        if (cacheHints?.cacheTools && anthropicTools && anthropicTools.length > 0) {
            applyToolsCacheBreakpoint(anthropicTools);
        }
        const params = {
            model: this.model,
            max_tokens: this.maxTokens,
            messages: anthropicMessages,
        };
        const systemValue = buildSystemValue(systemPrompt, cacheHints);
        if (systemValue !== undefined) {
            params.system = systemValue;
        }
        if (anthropicTools && anthropicTools.length > 0) {
            params.tools = anthropicTools;
        }
        const userIdTag = this.buildUserIdTag(cacheHints?.sessionId);
        if (userIdTag) {
            params.metadata = { user_id: userIdTag };
        }
        const response = await this.client.messages.create(params, signal ? { signal } : undefined);
        let content = "";
        const toolCalls = [];
        for (const block of response.content) {
            if (block.type === "text") {
                content += block.text;
            }
            else if (block.type === "tool_use") {
                toolCalls.push({
                    id: block.id,
                    name: block.name,
                    arguments: block.input,
                });
            }
        }
        let stopReason = "stop";
        if (response.stop_reason === "tool_use") {
            stopReason = "tool_use";
        }
        else if (response.stop_reason === "max_tokens") {
            stopReason = "length";
        }
        return {
            content,
            toolCalls,
            stopReason,
            usage: extractClaudeUsage(response.usage),
        };
    }
    async *chatStream(messages, tools, systemPrompt, cacheHints, signal) {
        const anthropicMessages = this.convertMessages(messages);
        if (cacheHints?.cacheMessages) {
            applyMessagesCacheBreakpoint(anthropicMessages);
        }
        const anthropicTools = tools?.map((t) => this.convertTool(t));
        if (cacheHints?.cacheTools && anthropicTools && anthropicTools.length > 0) {
            applyToolsCacheBreakpoint(anthropicTools);
        }
        const params = {
            model: this.model,
            max_tokens: this.maxTokens,
            messages: anthropicMessages,
            stream: true,
        };
        const systemValue = buildSystemValue(systemPrompt, cacheHints);
        if (systemValue !== undefined) {
            params.system = systemValue;
        }
        if (anthropicTools && anthropicTools.length > 0) {
            params.tools = anthropicTools;
        }
        const userIdTag = this.buildUserIdTag(cacheHints?.sessionId);
        if (userIdTag) {
            params.metadata = { user_id: userIdTag };
        }
        const stream = this.client.messages.stream(params, signal ? { signal } : undefined);
        let currentToolId = "";
        let currentToolName = "";
        let currentToolInput = "";
        for await (const event of stream) {
            if (event.type === "content_block_start") {
                const block = event.content_block;
                if (block?.type === "tool_use") {
                    currentToolId = block.id;
                    currentToolName = block.name;
                    currentToolInput = "";
                    yield {
                        type: "tool_call_start",
                        toolCall: { id: block.id, name: block.name, arguments: {} },
                    };
                }
            }
            else if (event.type === "content_block_delta") {
                const delta = event.delta;
                if (delta?.type === "text_delta") {
                    yield { type: "text_delta", text: delta.text };
                }
                else if (delta?.type === "input_json_delta") {
                    currentToolInput += delta.partial_json;
                }
            }
            else if (event.type === "content_block_stop") {
                if (currentToolId) {
                    try {
                        yield {
                            type: "tool_call_end",
                            toolCall: {
                                id: currentToolId,
                                name: currentToolName,
                                arguments: JSON.parse(currentToolInput || "{}"),
                            },
                        };
                    }
                    catch {
                        yield {
                            type: "tool_call_end",
                            toolCall: { id: currentToolId, name: currentToolName, arguments: {} },
                        };
                    }
                    currentToolId = "";
                    currentToolName = "";
                    currentToolInput = "";
                }
            }
            else if (event.type === "message_stop") {
                yield { type: "done" };
            }
        }
    }
    convertMessages(messages) {
        const result = [];
        for (let i = 0; i < messages.length; i++) {
            const msg = messages[i];
            if (msg.role === "system")
                continue;
            if (msg.role === "tool" && msg.toolCallId) {
                // Collect all consecutive tool messages into a single user message
                const toolResults = [];
                while (i < messages.length && messages[i].role === "tool" && messages[i].toolCallId) {
                    const toolMsg = messages[i];
                    toolResults.push({
                        type: "tool_result",
                        tool_use_id: toolMsg.toolCallId,
                        content: typeof toolMsg.content === "string"
                            ? toolMsg.content
                            : JSON.stringify(toolMsg.content),
                    });
                    i++;
                }
                i--; // adjust for the outer loop increment
                result.push({
                    role: "user",
                    content: toolResults,
                });
            }
            else if (msg.role === "assistant" && msg.toolCalls?.length) {
                const contentBlocks = [];
                if (typeof msg.content === "string" && msg.content) {
                    contentBlocks.push({ type: "text", text: msg.content });
                }
                for (const tc of msg.toolCalls) {
                    contentBlocks.push({
                        type: "tool_use",
                        id: tc.id,
                        name: tc.name,
                        input: tc.arguments,
                    });
                }
                result.push({ role: "assistant", content: contentBlocks });
            }
            else if (msg.role === "user") {
                const content = this.convertContent(msg.content);
                result.push({ role: "user", content });
            }
            else if (msg.role === "assistant") {
                result.push({
                    role: "assistant",
                    content: typeof msg.content === "string"
                        ? msg.content
                        : msg.content.map((b) => ({
                            type: "text",
                            text: b.text || "",
                        })),
                });
            }
        }
        return result;
    }
    convertContent(content) {
        if (typeof content === "string")
            return content;
        return content.map((block) => {
            if (block.type === "image" && block.imageData) {
                return {
                    type: "image",
                    source: {
                        type: "base64",
                        media_type: (block.mediaType || "image/png"),
                        data: block.imageData,
                    },
                };
            }
            return { type: "text", text: block.text || "" };
        });
    }
    convertTool(tool) {
        return {
            name: tool.name,
            description: tool.description,
            input_schema: tool.parameters,
        };
    }
    /**
     * Render the `metadata.user_id` payload sub2api expects (Claude Code ≥
     * 2.1.78 shape). sub2api's sticky-session router keys off the embedded
     * `session_id`; `device_id` and `account_uuid` are part of the format
     * but not used for routing. Returns null when no session id is supplied
     * so we leave the field unset rather than emit a meaningless tag.
     */
    buildUserIdTag(sessionId) {
        if (!sessionId)
            return null;
        return JSON.stringify({
            device_id: this.deviceId,
            account_uuid: "00000000-0000-0000-0000-000000000000",
            session_id: sessionId,
        });
    }
}
// --- Anthropic cache_control helpers ---
const EPHEMERAL_CACHE = { type: "ephemeral" };
/**
 * Pull all four token counters out of an Anthropic response.usage object.
 * The cache_creation/cache_read fields are only present when at least one
 * cache_control breakpoint was used; default to 0 so downstream loggers
 * don't have to handle undefined separately. Without this, our execution
 * logs only see `input_tokens` (the uncached delta) and dramatically
 * under-report real input volume on cached requests.
 */
export function extractClaudeUsage(usage) {
    const u = usage;
    return {
        inputTokens: u.input_tokens,
        outputTokens: u.output_tokens,
        cacheCreationInputTokens: u.cache_creation_input_tokens ?? 0,
        cacheReadInputTokens: u.cache_read_input_tokens ?? 0,
    };
}
/**
 * Build the `system` field for Anthropic.messages.create. Returns:
 *   - undefined when there is no system prompt to send
 *   - a plain string when no cache hint is given (cheapest path, current behavior)
 *   - a TextBlockParam[] with cache_control on the static head when the caller
 *     provided systemStaticChars (the typical path once prompt caching is on).
 *
 * Splitting the string at systemStaticChars keeps the existing single-string
 * `systemPrompt` parameter on LlmClient — callers don't need to thread two
 * separate strings down the stack to participate in caching.
 */
function buildSystemValue(systemPrompt, cacheHints) {
    if (!systemPrompt)
        return undefined;
    const staticChars = cacheHints?.systemStaticChars;
    if (typeof staticChars !== "number" ||
        staticChars <= 0 ||
        staticChars >= systemPrompt.length) {
        // No useful split — fall back to single block. Still mark it cacheable
        // when staticChars covers the entire prompt (the dynamic tail was empty).
        if (typeof staticChars === "number" &&
            staticChars > 0 &&
            staticChars >= systemPrompt.length) {
            return [
                {
                    type: "text",
                    text: systemPrompt,
                    cache_control: EPHEMERAL_CACHE,
                },
            ];
        }
        return systemPrompt;
    }
    const head = systemPrompt.slice(0, staticChars);
    const tail = systemPrompt.slice(staticChars);
    return [
        { type: "text", text: head, cache_control: EPHEMERAL_CACHE },
        { type: "text", text: tail },
    ];
}
/**
 * Add cache_control to the last tool in the list. Anthropic caches every tool
 * up to the breakpoint, which is what we want for the full tools array.
 */
function applyToolsCacheBreakpoint(tools) {
    if (tools.length === 0)
        return;
    const last = tools[tools.length - 1];
    tools[tools.length - 1] = {
        ...last,
        cache_control: EPHEMERAL_CACHE,
    };
}
/**
 * Add cache_control to the final content block of the final message. Anthropic
 * uses this as a prefix breakpoint covering all prior messages plus the system
 * and tools. Skips when the last block can't carry cache_control (e.g. when
 * the message is unexpectedly an empty string).
 */
function applyMessagesCacheBreakpoint(messages) {
    if (messages.length === 0)
        return;
    const last = messages[messages.length - 1];
    const content = last.content;
    if (Array.isArray(content)) {
        if (content.length === 0)
            return;
        const blockIndex = content.length - 1;
        const block = content[blockIndex];
        // CacheControlEphemeral is accepted on every Anthropic block type we
        // emit (text, tool_use, tool_result, image). Add it idempotently.
        content[blockIndex] = {
            ...block,
            cache_control: EPHEMERAL_CACHE,
        };
        return;
    }
    if (typeof content === "string" && content.length > 0) {
        // Promote string content to a single text block so we can attach
        // cache_control without losing the original text.
        messages[messages.length - 1] = {
            ...last,
            content: [
                {
                    type: "text",
                    text: content,
                    cache_control: EPHEMERAL_CACHE,
                },
            ],
        };
    }
}
// --- Debug wrapper ---
class DebugLlmClient {
    inner;
    logFile;
    seq = 0;
    constructor(inner, workspace) {
        this.inner = inner;
        const logDir = path.join(workspace, "logs");
        fs.mkdirSync(logDir, { recursive: true });
        this.logFile = path.join(logDir, "llm-debug.jsonl");
        console.log(`🐛 LLM debug log: ${this.logFile}`);
    }
    async chat(messages, tools, systemPrompt, cacheHints, signal) {
        const id = ++this.seq;
        this.write({
            id,
            type: "request",
            ts: new Date().toISOString(),
            systemPrompt,
            messages,
            tools,
            cacheHints,
        });
        const start = Date.now();
        const response = await this.inner.chat(messages, tools, systemPrompt, cacheHints, signal);
        this.write({
            id,
            type: "response",
            ts: new Date().toISOString(),
            durationMs: Date.now() - start,
            content: response.content,
            toolCalls: response.toolCalls,
            stopReason: response.stopReason,
            usage: response.usage,
        });
        return response;
    }
    async *chatStream(messages, tools, systemPrompt, cacheHints, signal) {
        const id = ++this.seq;
        this.write({
            id,
            type: "stream_request",
            ts: new Date().toISOString(),
            systemPrompt,
            messages,
            tools,
            cacheHints,
        });
        const start = Date.now();
        let fullText = "";
        const toolCalls = [];
        for await (const event of this.inner.chatStream(messages, tools, systemPrompt, cacheHints, signal)) {
            if (event.type === "text_delta" && event.text) {
                fullText += event.text;
            }
            else if (event.type === "tool_call_end" && event.toolCall) {
                toolCalls.push(event.toolCall);
            }
            yield event;
        }
        this.write({
            id,
            type: "stream_response",
            ts: new Date().toISOString(),
            durationMs: Date.now() - start,
            content: fullText,
            toolCalls,
        });
    }
    write(entry) {
        fs.appendFileSync(this.logFile, JSON.stringify(entry) + "\n", "utf-8");
    }
}
//# sourceMappingURL=client.js.map