import { fetchWithTimeout } from "./http.js";
import { getOllamaThinkValue } from "./reasoning.js";
import { attachTokenCost } from "./tokenAccounting.js";
export const defaultOllamaModel = "qwen2.5-coder:7b";
export const defaultOllamaUrl = "http://127.0.0.1:11434";
export const defaultOllamaPort = 11434;
export class OllamaClient {
    baseUrl;
    runtimeOptions;
    constructor(baseUrl = defaultOllamaUrl, runtimeOptions = readOllamaRuntimeOptions()) {
        this.baseUrl = normalizeOllamaBaseUrl(baseUrl);
        this.runtimeOptions = runtimeOptions;
    }
    async chat(options) {
        const response = await this.fetchOllama("/api/chat", {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify({
                model: options.model,
                messages: options.messages,
                stream: false,
                keep_alive: this.runtimeOptions.keepAlive,
                think: getOllamaThinkValue(options.model, options.reasoningEffort),
                options: {
                    num_ctx: this.runtimeOptions.numCtx,
                    num_predict: this.runtimeOptions.numPredict,
                    temperature: this.runtimeOptions.temperature
                },
                format: options.formatJson ? "json" : undefined
            }),
            signal: options.signal
        });
        const payload = (await readJsonSafely(response));
        if (!response.ok) {
            const reason = payload.error ? ` ${payload.error}` : "";
            throw new Error(`Ollama chat failed for model "${options.model}" at ${this.baseUrl}: HTTP ${response.status}.${reason}`);
        }
        if (payload.error) {
            throw new Error(payload.error);
        }
        const content = payload.message?.content?.trim() ?? "";
        if (isTruncatedDoneReason(payload.done_reason)) {
            throw new Error(`Ollama response for model "${options.model}" was truncated by num_predict (${this.runtimeOptions.numPredict}).`);
        }
        if (!content) {
            throw new Error(`Ollama returned an empty response for model "${options.model}".`);
        }
        return {
            content,
            telemetry: toTelemetry(payload, options.model)
        };
    }
    async listModels() {
        const response = await this.fetchOllama("/api/tags");
        if (!response.ok) {
            throw new Error(`Ollama tags failed with HTTP ${response.status}.`);
        }
        const payload = (await response.json());
        return payload.models?.map((model) => model.name).sort() ?? [];
    }
    async listRunningModels() {
        const response = await this.fetchOllama("/api/ps");
        if (!response.ok) {
            throw new Error(`Ollama ps failed with HTTP ${response.status}.`);
        }
        const payload = (await response.json());
        return (payload.models
            ?.map((model) => {
            const name = model.name?.trim() || model.model?.trim() || "";
            return name
                ? {
                    name,
                    sizeBytes: readNullableFiniteNumber(model.size),
                    sizeVramBytes: readNullableFiniteNumber(model.size_vram),
                    expiresAt: typeof model.expires_at === "string" ? model.expires_at : null,
                    contextLength: readNullableFiniteNumber(model.context_length ?? model.details?.context_length)
                }
                : null;
        })
            .filter((model) => model !== null)
            .sort((left, right) => left.name.localeCompare(right.name)) ?? []);
    }
    async unloadModel(model) {
        const trimmedModel = model.trim();
        if (!trimmedModel) {
            return;
        }
        const response = await this.fetchOllama("/api/generate", {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify({
                model: trimmedModel,
                keep_alive: 0
            })
        });
        if (!response.ok) {
            throw new Error(`Ollama unload failed for model "${trimmedModel}" at ${this.baseUrl}: HTTP ${response.status}.`);
        }
    }
    async fetchOllama(path, init) {
        try {
            return await fetchWithTimeout(`${this.baseUrl}${path}`, init, {
                timeoutMs: init?.method === "POST" ? 120_000 : 3000,
                retries: init?.method === "POST" ? 2 : 1,
                label: `Ollama ${path} at ${this.baseUrl}`
            });
        }
        catch (error) {
            throw new Error(formatOllamaConnectionError(this.baseUrl, error));
        }
    }
}
async function readJsonSafely(response) {
    try {
        return await response.json();
    }
    catch {
        return {};
    }
}
export function resolveOllamaBaseUrl(env = process.env) {
    return normalizeOllamaBaseUrl(env.PATCHPILOT_OLLAMA_URL ?? env.OLLAMA_HOST ?? defaultOllamaUrl);
}
export function normalizeOllamaBaseUrl(value) {
    const trimmedValue = value?.trim() ?? "";
    const normalizedAlias = trimmedValue.toLowerCase();
    if (!trimmedValue || normalizedAlias === "local" || normalizedAlias === "localhost") {
        return defaultOllamaUrl;
    }
    const rawUrl = /^https?:\/\//i.test(trimmedValue) ? trimmedValue : `http://${trimmedValue}`;
    const parsedUrl = new URL(rawUrl);
    if (parsedUrl.hostname === "0.0.0.0" || parsedUrl.hostname === "[::]" || parsedUrl.hostname === "::") {
        parsedUrl.hostname = "127.0.0.1";
    }
    if (!parsedUrl.port && parsedUrl.protocol === "http:") {
        parsedUrl.port = String(defaultOllamaPort);
    }
    if (parsedUrl.pathname === "/api") {
        parsedUrl.pathname = "";
    }
    return parsedUrl.toString().replace(/\/$/, "");
}
export function readOllamaRuntimeOptions(env = process.env) {
    return {
        keepAlive: env.PATCHPILOT_KEEP_ALIVE?.trim() || "15m",
        numCtx: readPositiveInteger(env.PATCHPILOT_NUM_CTX, 8192),
        numPredict: readPositiveInteger(env.PATCHPILOT_NUM_PREDICT, 8192),
        temperature: readTemperature(env.PATCHPILOT_TEMPERATURE, 0.1)
    };
}
function isTruncatedDoneReason(value) {
    return typeof value === "string" && /length|max_?tokens|num_predict/i.test(value);
}
function readPositiveInteger(value, fallback) {
    const parsedValue = Number.parseInt(value ?? "", 10);
    return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : fallback;
}
function readTemperature(value, fallback) {
    const parsedValue = Number.parseFloat(value ?? "");
    return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : fallback;
}
function readNullableFiniteNumber(value) {
    return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function formatOllamaConnectionError(baseUrl, error) {
    const suffix = error instanceof Error ? ` ${error.message}` : "";
    return `Cannot reach Ollama at ${baseUrl}. Start Ollama, or run "ollama serve", then try /doctor.${suffix}`;
}
function toTelemetry(payload, model) {
    const promptTokens = payload.prompt_eval_count ?? 0;
    const responseTokens = payload.eval_count ?? 0;
    const responseDurationMs = nanosToMillis(payload.eval_duration ?? 0);
    return attachTokenCost({
        promptTokens,
        cachedPromptTokens: 0,
        cacheWriteTokens: 0,
        responseTokens,
        totalTokens: promptTokens + responseTokens,
        evalTokensPerSecond: responseTokens > 0 && responseDurationMs > 0 ? responseTokens / (responseDurationMs / 1000) : null,
        promptDurationMs: nanosToMillis(payload.prompt_eval_duration ?? 0),
        responseDurationMs,
        totalDurationMs: nanosToMillis(payload.total_duration ?? 0),
        tokenSource: "provider"
    }, "ollama", model);
}
function nanosToMillis(value) {
    return Math.round(value / 1_000_000);
}
//# sourceMappingURL=ollama.js.map