// LLM provider abstraction (Stage 1 — D+ plan, 2026-05-23).
//
// The goal here is intentionally small: give agim a way to do
// "single-shot LLM call → text" *without* spawning a CLI agent (Claude
// Code / Codex / OpenCode). agim stays a CLI bridge in spirit — these
// providers are only used by internal introspection callers
// (memory-distill, memory-consolidate, intent-llm, future heartbeat /
// notification evaluator).
//
// Stage 1 explicitly does NOT implement:
//   - tool calling / agent loop (that's Stage 2)
//   - MCP client (Stage 2)
//   - file / shell tools (delegated to MCP in Stage 2)
//   - Anthropic native messages API (Stage 2)
//
// What it DOES support:
//   - `chat(messages, opts) → ChatResult` for buffered text responses
//   - `chatStream(...)` async iterable for incremental delivery
//   - timeouts via AbortSignal
//   - usage / cost telemetry surfaced through onUsage callback
//
// Concrete providers (Stage 1: OpenAICompatProvider only) subclass and
// implement `_callApi` / `_callApiStream`. The base wraps with timeout +
// telemetry plumbing so providers stay focused on transport details.
/**
 * Base class for every LLM provider. The transport-specific work happens
 * in `_callApi` / `_callApiStream` which subclasses implement. The base
 * adds:
 *   - timeout / AbortSignal merging
 *   - default system prompt injection
 *   - onUsage plumbing (callers shouldn't have to defend against missing
 *     usage info)
 *   - finishReason normalization
 */
export class LlmProvider {
    name;
    model;
    providerType;
    apiKey;
    defaultTemperature;
    defaultMaxTokens;
    constructor(cfg) {
        if (!cfg.name)
            throw new Error('LlmProvider: name is required');
        if (!cfg.model)
            throw new Error('LlmProvider: model is required');
        if (!cfg.providerType)
            throw new Error('LlmProvider: providerType is required');
        this.name = cfg.name;
        this.model = cfg.model;
        this.providerType = cfg.providerType;
        this.apiKey = cfg.apiKey;
        this.defaultTemperature = cfg.defaultTemperature ?? 0.3;
        this.defaultMaxTokens = cfg.defaultMaxTokens ?? 1024;
    }
    /** True when the provider has the credentials it needs. False suggests
     *  the operator forgot to set the secret; the registry filters these
     *  out at load time so `getProvider()` callers don't have to check. */
    isConfigured() {
        return Boolean(this.apiKey);
    }
    /** True when this provider can interpret LlmMessage.media (typically
     *  image attachments). Default false — only the openai-compat
     *  provider currently overrides this (operators opt-in via the
     *  provider's `vision: true` config or a model-name heuristic).
     *  Anthropic / others fall back to text-only. */
    supportsVision() {
        return false;
    }
    /**
     * Buffered chat call: send the full message list, await the entire
     * response. Used by all Stage-1 introspection callers because the
     * downstream parsers want a complete JSON string anyway.
     */
    async chat(messages, opts = {}) {
        const prepared = this._prepareMessages(messages, opts);
        const merged = this._mergeOptions(opts);
        const signal = this._buildSignal(opts.signal, merged.timeoutMs);
        const result = await this._callApi(prepared, merged, signal.signal);
        signal.cleanup();
        if (opts.onUsage) {
            try {
                opts.onUsage(result.usage);
            }
            catch { /* swallow — telemetry is best-effort */ }
        }
        return result;
    }
    /**
     * Streaming chat call: async-iterable of text deltas. Stage-1 callers
     * don't use this yet, but it's in the base API so Stage 2's agent loop
     * (and the future "stream thinking to web UI" feature) can build on it
     * without API churn.
     */
    async *chatStream(messages, opts = {}) {
        const prepared = this._prepareMessages(messages, opts);
        const merged = this._mergeOptions(opts);
        const signal = this._buildSignal(opts.signal, merged.timeoutMs);
        try {
            let lastUsage;
            for await (const chunk of this._callApiStream(prepared, merged, signal.signal)) {
                if (chunk.usage)
                    lastUsage = chunk.usage;
                yield chunk;
            }
            if (lastUsage && opts.onUsage) {
                try {
                    opts.onUsage(lastUsage);
                }
                catch { /* swallow */ }
            }
        }
        finally {
            signal.cleanup();
        }
    }
    /** Concrete providers implement this for streamed responses. Default
     *  implementation falls back to buffered + single-chunk yield, so
     *  providers that don't support streaming still work (the caller sees
     *  one big chunk on completion). Override for real streaming. */
    async *_callApiStream(messages, opts, signal) {
        const result = await this._callApi(messages, opts, signal);
        yield {
            textDelta: result.text,
            finishReason: result.finishReason,
            usage: result.usage,
        };
    }
    /**
     * Prepend `opts.system` as a system message iff the caller didn't
     * already supply one. Saves callers from threading the same conditional
     * everywhere; documented in ChatOptions so behaviour is unsurprising.
     */
    _prepareMessages(messages, opts) {
        if (!opts.system)
            return messages;
        if (messages.length > 0 && messages[0].role === 'system')
            return messages;
        return [{ role: 'system', content: opts.system }, ...messages];
    }
    _mergeOptions(opts) {
        return {
            model: opts.model || this.model,
            temperature: opts.temperature ?? this.defaultTemperature,
            maxTokens: opts.maxTokens ?? this.defaultMaxTokens,
            timeoutMs: opts.timeoutMs,
            providerOptions: opts.providerOptions ?? {},
            tools: opts.tools && opts.tools.length > 0 ? opts.tools : undefined,
            toolChoice: opts.toolChoice,
        };
    }
    /**
     * Merge the caller's AbortSignal with the optional timeout into a
     * single composite signal. We deliberately don't use AbortSignal.any
     * because Node 18 doesn't have it; the manual wiring is small enough
     * to keep us portable. Returns a cleanup fn the caller must invoke to
     * clear the timeout (prevents the test runner from keeping the event
     * loop alive past test completion).
     */
    _buildSignal(userSignal, timeoutMs) {
        if (!userSignal && !timeoutMs) {
            // Fast path: no constraints, return a never-aborted controller so
            // _callApi has a single signal contract to work with.
            const ctrl = new AbortController();
            return { signal: ctrl.signal, cleanup: () => { } };
        }
        const ctrl = new AbortController();
        let timer = null;
        const onUserAbort = () => {
            ctrl.abort(userSignal?.reason ?? new Error('aborted'));
        };
        if (userSignal) {
            if (userSignal.aborted)
                onUserAbort();
            else
                userSignal.addEventListener('abort', onUserAbort, { once: true });
        }
        if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
            timer = setTimeout(() => ctrl.abort(new Error(`LLM call timed out after ${timeoutMs}ms`)), timeoutMs);
            if (typeof timer === 'object' && timer && 'unref' in timer) {
                timer.unref();
            }
        }
        return {
            signal: ctrl.signal,
            cleanup: () => {
                if (timer)
                    clearTimeout(timer);
                if (userSignal)
                    userSignal.removeEventListener('abort', onUserAbort);
            },
        };
    }
}
/**
 * Helper for providers that want to format an error consistently. Wrapping
 * the underlying fetch / SDK error in LlmProviderError makes it easy for
 * callers (and the registry's "is the provider healthy?" probe) to tell
 * a transport failure apart from a logic bug.
 */
export class LlmProviderError extends Error {
    providerName;
    httpStatus;
    transient;
    constructor(providerName, message, opts = {}) {
        super(message);
        this.name = 'LlmProviderError';
        this.providerName = providerName;
        this.httpStatus = opts.httpStatus;
        // 429 / 408 / 503 / 504 are conventional "retry-me" codes. Callers
        // can use this flag to decide whether to back off or fall back to a
        // CLI agent right away.
        this.transient = opts.transient ?? (opts.httpStatus === 408 ||
            opts.httpStatus === 429 ||
            opts.httpStatus === 503 ||
            opts.httpStatus === 504);
        if (opts.cause !== undefined) {
            this.cause = opts.cause;
        }
    }
}
//# sourceMappingURL=provider-base.js.map