"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.OllamaBackendError = exports.OllamaBackend = void 0;
exports.registerOllamaBackend = registerOllamaBackend;
/**
 * Ollama backend (#629, Phase 2 of #510).
 *
 * Implements `ModelBackend` against a local or remote Ollama HTTP API.
 * Exports `OllamaBackend` directly for tests and `registerOllamaBackend(...)`
 * for the YAML→registry boot bridge in `resources/models/bootstrap.ts`.
 *
 * Component shape matches the pattern in `components/mcp/index.ts` (PR #649):
 * core imports a register helper and calls it during boot; not a
 * `handleApplication(scope)` self-loader.
 */
const backendRegistry_ts_1 = require("../../resources/models/backendRegistry.js");
const backendHelpers_ts_1 = require("../../resources/models/backendHelpers.js");
const hdbError_ts_1 = require("../../utility/errors/hdbError.js");
const DEFAULT_HOST = 'localhost:11434';
const MAX_NDJSON_LINE_BYTES = 1 << 20; // 1 MiB — Ollama chunks are normally sub-KiB; anything larger is pathological.
/**
 * `ModelBackend` implementation talking to Ollama's HTTP API.
 *
 * - `embed` → `POST /api/embed` (the legacy `/api/embeddings` is deprecated upstream).
 * - `generate` → `POST /api/generate` for string prompts, `POST /api/chat` for
 *   messages-array input.
 * - `generateStream` → same routing as `generate` with `stream: true`; consumes
 *   Ollama's NDJSON wire format and yields `GenerateChunk` per JSON line.
 *
 * Capabilities advertise `tools: false` and `adapters: false`. Ollama tool-call
 * support exists on some models but is uneven across the model catalog; we keep
 * the v1 portability guarantee honest and skip them here.
 */
class OllamaBackend {
    name = 'ollama';
    #origin;
    #defaultModel;
    #requestTimeoutMs;
    #fetch;
    constructor(config = {}, fetchImpl = fetch) {
        this.#origin = (0, backendHelpers_ts_1.normalizeOrigin)(config.host, { host: DEFAULT_HOST, secure: false });
        this.#defaultModel = config.model;
        this.#requestTimeoutMs = config.requestTimeoutMs;
        this.#fetch = fetchImpl;
    }
    capabilities() {
        return { embed: true, generate: true, stream: true, tools: false, adapters: false };
    }
    async embed(input, opts) {
        const model = opts.model ?? this.#defaultModel;
        (0, backendHelpers_ts_1.requireModel)(model, 'embed', OllamaBackendError);
        const texts = Array.isArray(input) ? input : [input];
        const prepared = texts.map((t) => applyEmbedPrefix(model, t, opts.inputType));
        const res = await this.#post('/api/embed', { model, input: prepared }, opts.signal);
        const data = await (0, backendHelpers_ts_1.parseJsonResponse)(res, 'Ollama /api/embed', OllamaBackendError);
        if (!Array.isArray(data.embeddings)) {
            throw new OllamaBackendError("Ollama /api/embed response missing 'embeddings' array");
        }
        if (data.embeddings.length !== prepared.length) {
            throw new OllamaBackendError(`Ollama /api/embed returned ${data.embeddings.length} vectors for ${prepared.length} inputs`);
        }
        const output = data.embeddings.map((v, i) => {
            if (!Array.isArray(v) || !v.every(Number.isFinite)) {
                throw new OllamaBackendError(`Ollama /api/embed vector at index ${i} is not an array of finite numbers`);
            }
            return Float32Array.from(v);
        });
        const usage = {};
        (0, backendHelpers_ts_1.assignFiniteTokenCount)(usage, 'embeddingTokens', data.prompt_eval_count);
        return { status: 'completed', output, usage };
    }
    async generate(input, opts) {
        const model = opts.model ?? this.#defaultModel;
        (0, backendHelpers_ts_1.requireModel)(model, 'generate', OllamaBackendError);
        const { endpoint, body } = buildGenerateRequest(model, input, opts, false);
        const res = await this.#post(endpoint, body, opts.signal);
        const data = await (0, backendHelpers_ts_1.parseJsonResponse)(res, `Ollama ${endpoint}`, OllamaBackendError);
        const rawContent = endpoint === '/api/chat' ? data.message?.content : data.response;
        if (rawContent !== undefined && typeof rawContent !== 'string') {
            throw new OllamaBackendError(`Ollama ${endpoint} response content is not a string`);
        }
        const usage = {};
        (0, backendHelpers_ts_1.assignFiniteTokenCount)(usage, 'promptTokens', data.prompt_eval_count);
        (0, backendHelpers_ts_1.assignFiniteTokenCount)(usage, 'completionTokens', data.eval_count);
        return {
            status: 'completed',
            output: { content: rawContent ?? '', finishReason: mapFinishReason(data.done_reason) },
            usage,
        };
    }
    async *generateStream(input, opts) {
        const model = opts.model ?? this.#defaultModel;
        (0, backendHelpers_ts_1.requireModel)(model, 'generateStream', OllamaBackendError);
        const { endpoint, body } = buildGenerateRequest(model, input, opts, true);
        const res = await this.#post(endpoint, body, opts.signal);
        if (!res.body)
            throw new OllamaBackendError(`Ollama ${endpoint} returned no body for streaming`);
        for await (const obj of readNdjson(res.body)) {
            yield toGenerateChunk(obj, endpoint);
        }
    }
    async #post(path, body, callerSignal) {
        const signal = (0, backendHelpers_ts_1.composeSignal)(callerSignal, this.#requestTimeoutMs);
        const res = await this.#fetch(`${this.#origin}${path}`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body),
            signal,
        });
        if (!res.ok) {
            throw new OllamaBackendError(`Ollama ${path} returned HTTP ${res.status}`);
        }
        return res;
    }
}
exports.OllamaBackend = OllamaBackend;
/**
 * Boot-bridge helper. Called from `resources/models/bootstrap.ts` for each
 * `models.embedding.<name>` / `models.generative.<name>` entry whose
 * `backend: ollama`.
 */
function registerOllamaBackend(args) {
    const backend = new OllamaBackend(args.config);
    if (args.kind === 'embedding')
        (0, backendRegistry_ts_1.setEmbedding)(args.logicalName, backend);
    else
        (0, backendRegistry_ts_1.setGenerative)(args.logicalName, backend);
}
class OllamaBackendError extends hdbError_ts_1.ServerError {
    constructor(message) {
        super(message);
        this.name = 'OllamaBackendError';
    }
}
exports.OllamaBackendError = OllamaBackendError;
// ---------- internals ----------
function applyEmbedPrefix(model, text, inputType) {
    if (!inputType)
        return text;
    // nomic-embed-text v1.5+ uses these application-layer prefixes to distinguish
    // document-corpus encodings from query encodings. Models that don't recognize
    // them silently return slightly different (still usable) vectors. Other model
    // families (BGE, e5, etc.) use their own conventions; add cases as we validate.
    if (/nomic-embed-text/i.test(model)) {
        return (inputType === 'document' ? 'search_document: ' : 'search_query: ') + text;
    }
    return text;
}
function buildGenerateRequest(model, input, opts, stream) {
    const optionsBag = buildOptionsBag(opts);
    if (typeof input === 'string') {
        return { endpoint: '/api/generate', body: { model, prompt: input, stream, ...optionsBag } };
    }
    const { messages, system } = normalizeMessages(input);
    // Ollama chat has no top-level system field; prepend it as the first message
    // when the caller supplied one separately.
    const chatMessages = system
        ? [{ role: 'system', content: system }, ...messages.map(toOllamaMessage)]
        : messages.map(toOllamaMessage);
    return { endpoint: '/api/chat', body: { model, messages: chatMessages, stream, ...optionsBag } };
}
function buildOptionsBag(opts) {
    const out = {};
    const options = {};
    if (typeof opts.temperature === 'number')
        options.temperature = opts.temperature;
    if (typeof opts.maxTokens === 'number')
        options.num_predict = opts.maxTokens;
    if (Object.keys(options).length > 0)
        out.options = options;
    if (opts.responseFormat === 'json') {
        out.format = 'json';
    }
    else if (opts.responseFormat && typeof opts.responseFormat === 'object' && 'schema' in opts.responseFormat) {
        out.format = opts.responseFormat.schema;
    }
    return out;
}
function normalizeMessages(input) {
    if (Array.isArray(input))
        return { messages: input };
    return { messages: input.messages, system: input.system };
}
function toOllamaMessage(m) {
    // Tools intentionally not forwarded — see capabilities().tools = false.
    return { role: m.role, content: m.content };
}
function mapFinishReason(reason) {
    switch (reason) {
        case 'length':
            return 'length';
        case 'stop':
        default:
            return 'stop';
    }
}
function toGenerateChunk(data, endpoint) {
    const chunk = {};
    const deltaContent = endpoint === '/api/chat' ? data.message?.content : data.response;
    if (typeof deltaContent === 'string' && deltaContent.length > 0)
        chunk.deltaContent = deltaContent;
    if (data.done === true)
        chunk.finishReason = mapFinishReason(data.done_reason);
    return chunk;
}
async function* readNdjson(body) {
    const decoder = new TextDecoder('utf-8');
    let buf = '';
    for await (const chunk of body) {
        buf += decoder.decode(chunk, { stream: true });
        if (buf.length > MAX_NDJSON_LINE_BYTES) {
            throw new OllamaBackendError(`Ollama NDJSON line exceeds ${MAX_NDJSON_LINE_BYTES} bytes without a newline`);
        }
        let nl;
        while ((nl = buf.indexOf('\n')) >= 0) {
            const line = buf.slice(0, nl).trim();
            buf = buf.slice(nl + 1);
            if (!line)
                continue;
            yield parseJsonLine(line);
        }
    }
    buf += decoder.decode();
    const tail = buf.trim();
    if (tail)
        yield parseJsonLine(tail);
}
function parseJsonLine(line) {
    try {
        return JSON.parse(line);
    }
    catch {
        // Deliberately static — the JSON parser's message echoes the offending bytes,
        // which can include upstream-derived content. Matches the sanitization posture
        // of `hdb_model_calls.error_code` (analyticsTable.ts:35).
        throw new OllamaBackendError('Invalid NDJSON line from Ollama');
    }
}
//# sourceMappingURL=index.js.map