import { emitTrace, newSpanId, TRACE_ID_HEADER, TRACE_SPAN_HEADER, } from "../trace.js";
export class OpenAIBackendError extends Error {
    code;
    constructor(code, message, options) {
        super(message, options);
        this.code = code;
        this.name = "OpenAIBackendError";
    }
}
export class OpenAIStreamParseError extends OpenAIBackendError {
    constructor(message, options) {
        super("malformed_sse", message, options);
        this.name = "OpenAIStreamParseError";
    }
}
const DEFAULT_BACKEND_REQUEST_TIMEOUT_MS = 120_000;
export class OpenAICompatibleProvider {
    config;
    logger;
    options;
    name = "openai-compatible";
    constructor(config, logger, options = {
        logFullPayload: false,
    }) {
        this.config = config;
        this.logger = logger;
        this.options = options;
    }
    async *streamCompletion(messages, options = {}) {
        for await (const event of this.streamCompletionEvents(messages, [], options)) {
            if (event.type === "text") {
                yield event.text;
            }
        }
    }
    async *streamCompletionEvents(messages, tools = [], options = {}) {
        if (this.config.hardcodedResponse !== undefined) {
            this.logger?.info("model backend hardcoded response", {
                modelId: this.config.id,
                providerModel: this.config.providerModel,
                responseChars: this.config.hardcodedResponse.length,
            });
            yield { type: "text", text: this.config.hardcodedResponse };
            return;
        }
        const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
        const requestBody = {
            model: this.config.providerModel,
            messages,
            stream: true,
            ...(tools.length > 0 ? { tools } : {}),
        };
        const requestTimeoutMs = options.timeoutMs ??
            this.config.requestTimeoutMs ??
            DEFAULT_BACKEND_REQUEST_TIMEOUT_MS;
        const traceId = options.traceId ?? process.env.FUSION_TRACE_ID ?? undefined;
        const traceSpan = newSpanId();
        const traceHeaders = traceId !== undefined
            ? { [TRACE_ID_HEADER]: traceId, [TRACE_SPAN_HEADER]: traceSpan }
            : {};
        emitTrace({
            event_type: "cursor.route",
            traceId,
            spanId: traceSpan,
            modelId: this.config.id,
            payload: {
                message: "model backend request",
                provider_model: this.config.providerModel,
                url,
                tool_count: tools.length,
                message_count: messages.length,
            },
        });
        this.logger?.info("model backend request", {
            modelId: this.config.id,
            providerModel: this.config.providerModel,
            url,
            stream: true,
            requestTimeoutMs,
            toolCount: tools.length,
            toolNames: tools.map((tool) => tool.function.name),
            ...summarizeMessages(messages),
            ...(this.options.logFullPayload
                ? {
                    payload: requestBody,
                }
                : {}),
        });
        const started = Date.now();
        const abortContext = createRequestAbortContext(requestTimeoutMs, options.signal);
        let response;
        try {
            response = await fetch(url, {
                method: "POST",
                headers: {
                    connection: "close",
                    "content-type": "application/json",
                    ...(this.config.apiKey
                        ? { authorization: `Bearer ${this.config.apiKey}` }
                        : {}),
                    ...traceHeaders,
                },
                body: JSON.stringify(requestBody),
                signal: abortContext.signal,
            });
        }
        catch (error) {
            const classified = classifyBackendError(error, abortContext);
            this.logger?.warn("model backend request failed", {
                modelId: this.config.id,
                providerModel: this.config.providerModel,
                url,
                durationMs: Date.now() - started,
                error: classified instanceof Error ? classified.message : String(error),
                code: classified instanceof OpenAIBackendError
                    ? classified.code
                    : undefined,
                cause: error instanceof Error && error.cause instanceof Error
                    ? error.cause.message
                    : undefined,
            });
            abortContext.dispose();
            throw classified;
        }
        if (!response.ok || response.body === null) {
            const body = await response.text().catch(() => "");
            const error = new OpenAIBackendError("http_error", `Model backend returned ${response.status}: ${body}`);
            this.logger?.warn("model backend response failed", {
                modelId: this.config.id,
                providerModel: this.config.providerModel,
                url,
                status: response.status,
                durationMs: Date.now() - started,
                bodyPreview: body.slice(0, 500),
                code: error.code,
            });
            abortContext.dispose();
            throw error;
        }
        let chunkCount = 0;
        let responseChars = 0;
        try {
            for await (const text of parseOpenAIStream(response.body)) {
                if (typeof text === "string") {
                    chunkCount += 1;
                    responseChars += text.length;
                    yield { type: "text", text };
                }
                else if (text.type === "tool_calls") {
                    chunkCount += 1;
                    yield text;
                }
            }
            this.logger?.info("model backend response complete", {
                modelId: this.config.id,
                providerModel: this.config.providerModel,
                url,
                status: response.status,
                durationMs: Date.now() - started,
                chunkCount,
                responseChars,
            });
            emitTrace({
                event_type: "cursor.route",
                traceId,
                spanId: traceSpan,
                modelId: this.config.id,
                payload: {
                    message: "model backend response complete",
                    status: response.status,
                    duration_ms: Date.now() - started,
                    chunk_count: chunkCount,
                    response_chars: responseChars,
                },
            });
        }
        catch (error) {
            const classified = classifyBackendError(error, abortContext);
            this.logger?.warn("model backend stream failed", {
                modelId: this.config.id,
                providerModel: this.config.providerModel,
                url,
                status: response.status,
                durationMs: Date.now() - started,
                chunkCount,
                responseChars,
                error: classified instanceof Error ? classified.message : String(error),
                code: classified instanceof OpenAIBackendError
                    ? classified.code
                    : undefined,
            });
            throw classified;
        }
        finally {
            abortContext.dispose();
        }
    }
}
function summarizeMessages(messages) {
    return {
        messageCount: messages.length,
        messageRoles: messages.map((message) => message.role),
        messageChars: messages.reduce((total, message) => total + message.content.length, 0),
        firstMessagePreviewChars: messagePreviewChars(messages[0]?.content),
        lastMessagePreviewChars: messagePreviewChars(messages.at(-1)?.content),
    };
}
function messagePreviewChars(value) {
    return value === undefined
        ? undefined
        : value.replace(/\s+/g, " ").trim().slice(0, 500).length;
}
function preview(value) {
    if (value === undefined) {
        return undefined;
    }
    return value.replace(/\s+/g, " ").trim().slice(0, 500);
}
export async function* parseOpenAIStream(stream) {
    const decoder = new TextDecoder("utf-8", { fatal: true });
    let buffer = "";
    const state = {
        done: false,
        toolCalls: new Map(),
    };
    for await (const chunk of stream) {
        buffer += decodeStreamText(decoder, chunk, true);
        const lines = buffer.split(/\r?\n/);
        buffer = lines.pop() ?? "";
        yield* parseSseLines(lines, state);
        if (state.done) {
            return;
        }
    }
    buffer += decodeStreamText(decoder, undefined, false);
    if (buffer.length > 0 && !state.done) {
        yield* parseSseLines(buffer.split(/\r?\n/), state);
    }
}
function* parseSseLines(lines, state) {
    for (const line of lines) {
        const trimmed = line.trim();
        if (trimmed.length === 0 || trimmed.startsWith(":")) {
            continue;
        }
        if (!trimmed.startsWith("data:")) {
            continue;
        }
        const data = trimmed.slice("data:".length).trim();
        if (data.length === 0) {
            continue;
        }
        if (data === "[DONE]") {
            state.done = true;
            return;
        }
        let parsed;
        try {
            parsed = JSON.parse(data);
        }
        catch (error) {
            throw new OpenAIStreamParseError(`OpenAI stream contained malformed JSON data: ${preview(data)}`, error instanceof Error ? { cause: error } : undefined);
        }
        const content = parsed.choices?.[0]?.delta?.content;
        if (typeof content === "string" && content.length > 0) {
            yield content;
        }
        const deltas = parsed.choices?.[0]?.delta?.tool_calls;
        if (Array.isArray(deltas)) {
            mergeToolCallDeltas(state.toolCalls, deltas);
        }
        if (parsed.choices?.[0]?.finish_reason === "tool_calls") {
            yield {
                type: "tool_calls",
                toolCalls: Array.from(state.toolCalls.values()),
            };
            state.toolCalls.clear();
        }
    }
}
function mergeToolCallDeltas(toolCalls, deltas) {
    for (const delta of deltas) {
        const index = delta.index ?? toolCalls.size;
        const current = toolCalls.get(index) ??
            {
                id: delta.id ?? `tool-${String(index)}`,
                type: "function",
                function: { name: "", arguments: "" },
            };
        if (delta.id !== undefined) {
            current.id = delta.id;
        }
        if (delta.type === "function") {
            current.type = "function";
        }
        if (delta.function?.name !== undefined) {
            current.function.name += delta.function.name;
        }
        if (delta.function?.arguments !== undefined) {
            current.function.arguments += delta.function.arguments;
        }
        toolCalls.set(index, current);
    }
}
function decodeStreamText(decoder, chunk, stream) {
    try {
        return chunk === undefined
            ? decoder.decode()
            : decoder.decode(chunk, { stream });
    }
    catch (error) {
        throw new OpenAIStreamParseError("OpenAI stream contained malformed UTF-8", error instanceof Error ? { cause: error } : undefined);
    }
}
function createRequestAbortContext(timeoutMs, upstreamSignal) {
    const controller = new AbortController();
    let didTimeout = false;
    const timer = setTimeout(() => {
        didTimeout = true;
        controller.abort();
    }, timeoutMs);
    const abortFromUpstream = () => controller.abort();
    if (upstreamSignal !== undefined) {
        if (upstreamSignal.aborted) {
            controller.abort();
        }
        else {
            upstreamSignal.addEventListener("abort", abortFromUpstream, {
                once: true,
            });
        }
    }
    return {
        signal: controller.signal,
        timedOut: () => didTimeout,
        dispose: () => {
            clearTimeout(timer);
            upstreamSignal?.removeEventListener("abort", abortFromUpstream);
        },
    };
}
function classifyBackendError(error, abortContext) {
    if (error instanceof OpenAIBackendError) {
        return error;
    }
    if (abortContext.signal.aborted) {
        const code = abortContext.timedOut()
            ? "request_timeout"
            : "request_aborted";
        return new OpenAIBackendError(code, code === "request_timeout"
            ? "Model backend request timed out"
            : "Model backend request was aborted", error instanceof Error ? { cause: error } : undefined);
    }
    return error;
}
