/**
 * context-discovery.ts
 *
 * Multi-provider context window discovery with verbose-first endpoint probing.
 *
 * Implements a provider-agnostic discovery chain that probes endpoints from
 * most-informative (verbose) to least-informative, returning the richest
 * available context window data for each model.
 *
 * Discovery chain (ordered most-verbose to least-verbose):
 *   1. /api/v1/models  — LM Studio rich format (key + max_context_length)
 *   2. /api/tags       — Ollama list + per-model /api/show for context_length
 *   3. /v1/models      — OpenAI compat (id, optionally max_model_len for vLLM)
 *   4. /props          — llama.cpp server-level n_ctx (single context all models)
 *   5. /info           — TGI format (max_input_tokens / max_total_tokens)
 *
 * Feature flag: `local-provider-context-ingestion`
 */
import { logger } from '../utils/logger.js';
import { summarizeError } from '../utils/error-display.js';
import { instrumentedFetch } from '../utils/fetch-with-timeout.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Timeout per individual probe request. */
const PROBE_TIMEOUT_MS = 5_000;
/** Placeholder model ID used when a server-level endpoint has no model list. */
export const SERVER_LEVEL_MODEL_ID = '__server__';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
 * Extract the origin (scheme + host + port) from a base URL string.
 * E.g. `http://192.168.0.85:1234/v1` → `http://192.168.0.85:1234`
 */
function extractOrigin(baseURL) {
    try {
        return new URL(baseURL).origin;
    }
    catch {
        // If URL parsing fails, strip everything after the third slash component.
        const match = baseURL.match(/^(https?:\/\/[^/]+)/);
        return match ? match[1] : baseURL;
    }
}
/**
 * Perform a timed fetch with optional Bearer auth.
 * Returns the Response on success, or null on any error (timeout, network, 4xx/5xx).
 */
async function probe(url, options = {}) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
    timer.unref?.();
    try {
        const headers = { Accept: 'application/json' };
        if (options.apiKey)
            headers['Authorization'] = `Bearer ${options.apiKey}`;
        if (options.body)
            headers['Content-Type'] = 'application/json';
        const response = await instrumentedFetch(url, {
            method: options.method ?? 'GET',
            headers,
            ...(options.body !== undefined ? { body: options.body } : {}),
            signal: controller.signal,
        });
        if (!response.ok) {
            logger.debug('[context-discovery] Non-OK probe response', { url, status: response.status });
            return null;
        }
        return response;
    }
    catch (err) {
        const name = err?.name;
        if (name === 'AbortError') {
            logger.debug('[context-discovery] Probe timed out', { url });
        }
        else {
            logger.debug('[context-discovery] Probe failed', { url, error: summarizeError(err) });
        }
        return null;
    }
    finally {
        clearTimeout(timer);
    }
}
/**
 * Safely parse JSON from a Response. Returns null on parse error.
 */
async function parseJSON(response, url) {
    try {
        return (await response.json());
    }
    catch (error) {
        logger.warn('[context-discovery] Probe returned invalid JSON', {
            url,
            error: summarizeError(error),
        });
        return null;
    }
}
/**
 * Probe `/api/v1/models` (LM Studio rich format).
 * Returns model_id → context_length map, or null if the endpoint is absent.
 */
async function probeLMStudio(origin, apiKey) {
    const url = `${origin}/api/v1/models`;
    const response = await probe(url, { apiKey });
    if (!response)
        return null;
    const json = await parseJSON(response, url);
    if (!Array.isArray(json?.models))
        return null;
    const result = new Map();
    for (const model of json.models) {
        if (typeof model.key !== 'string')
            continue;
        if (typeof model.max_context_length === 'number' && model.max_context_length > 0) {
            result.set(model.key, model.max_context_length);
        }
    }
    if (result.size > 0) {
        logger.debug('[context-discovery] LM Studio probe succeeded', { url, count: result.size });
        return result;
    }
    return null;
}
/**
 * Extract context length from an Ollama `/api/show` response.
 * Tries `model_info.context_length`, then parses `num_ctx` from modelfile.
 */
function extractOllamaContextLength(show) {
    // Prefer structured model_info
    if (show.model_info) {
        const ctx = show.model_info['context_length'];
        if (typeof ctx === 'number' && ctx > 0)
            return ctx;
        // Explicit key scan for context_length variants
        for (const [key, val] of Object.entries(show.model_info)) {
            if (/context[_.]?len/i.test(key) && typeof val === 'number' && val > 0) {
                return val;
            }
        }
    }
    // Parse num_ctx from modelfile when structured metadata is absent.
    if (typeof show.modelfile === 'string') {
        const match = show.modelfile.match(/^\s*PARAMETER\s+num_ctx\s+(\d+)/im);
        if (match) {
            const ctx = parseInt(match[1], 10);
            if (ctx > 0)
                return ctx;
        }
    }
    return null;
}
/**
 * Probe `/api/tags` (Ollama model list) then per-model `/api/show`.
 * Returns model_id → context_length map, or null if the endpoint is absent.
 */
async function probeOllama(origin, apiKey) {
    const tagsUrl = `${origin}/api/tags`;
    const tagsResponse = await probe(tagsUrl, { apiKey });
    if (!tagsResponse)
        return null;
    const tagsJson = await parseJSON(tagsResponse, tagsUrl);
    if (!Array.isArray(tagsJson?.models))
        return null;
    const result = new Map();
    // Fetch show data for each model in groups of SHOW_CONCURRENCY to bound parallelism.
    const SHOW_CONCURRENCY = 10;
    const names = tagsJson.models.filter((m) => typeof m.name === 'string').map((m) => m.name);
    for (let i = 0; i < names.length; i += SHOW_CONCURRENCY) {
        const batch = names.slice(i, i + SHOW_CONCURRENCY);
        const batchResults = await Promise.allSettled(batch.map(async (name) => {
            const showUrl = `${origin}/api/show`;
            const showResponse = await probe(showUrl, {
                apiKey,
                method: 'POST',
                body: JSON.stringify({ name }),
            });
            if (!showResponse)
                return;
            const showJson = await parseJSON(showResponse, showUrl);
            if (!showJson)
                return;
            const ctx = extractOllamaContextLength(showJson);
            if (ctx !== null) {
                result.set(name, ctx);
            }
        }));
        // Rejections here are unexpected; ordinary probe failures return null.
        for (const r of batchResults) {
            if (r.status === 'rejected') {
                logger.warn('[context-discovery] Ollama /api/show error', { error: summarizeError(r.reason) });
            }
        }
    }
    if (result.size > 0) {
        logger.debug('[context-discovery] Ollama probe succeeded', { url: tagsUrl, count: result.size });
        return result;
    }
    return null;
}
function extractOpenAIContextLength(model) {
    if (typeof model.max_model_len === 'number' && model.max_model_len > 0)
        return model.max_model_len;
    if (typeof model.max_context_length === 'number' && model.max_context_length > 0)
        return model.max_context_length;
    if (typeof model.context_length === 'number' && model.context_length > 0)
        return model.context_length;
    if (model.limits) {
        if (typeof model.limits.max_context_length === 'number' && model.limits.max_context_length > 0) {
            return model.limits.max_context_length;
        }
        if (typeof model.limits.context_length === 'number' && model.limits.context_length > 0) {
            return model.limits.context_length;
        }
    }
    return null;
}
/**
 * Probe `/v1/models` (OpenAI-compatible endpoint).
 * Returns model_id → context_length map (may be empty if no context fields present),
 * or null if the endpoint is absent.
 */
async function probeOpenAICompat(baseURL, apiKey) {
    const url = baseURL.replace(/\/$/, '') + '/models';
    const response = await probe(url, { apiKey });
    if (!response)
        return null;
    const json = await parseJSON(response, url);
    if (!Array.isArray(json?.data))
        return null;
    const result = new Map();
    for (const model of json.data) {
        if (typeof model.id !== 'string')
            continue;
        const ctx = extractOpenAIContextLength(model);
        if (ctx !== null) {
            result.set(model.id, ctx);
        }
    }
    // Return even if empty — the endpoint existing is meaningful
    logger.debug('[context-discovery] OpenAI compat probe completed', { url, count: result.size });
    return result;
}
/**
 * Probe `/props` (llama.cpp server-level context).
 * Returns a single-entry map keyed by SERVER_LEVEL_MODEL_ID, or null.
 */
async function probeLlamaCpp(origin, apiKey) {
    const url = `${origin}/props`;
    const response = await probe(url, { apiKey });
    if (!response)
        return null;
    const json = await parseJSON(response, url);
    if (typeof json?.n_ctx !== 'number' || json.n_ctx <= 0)
        return null;
    logger.debug('[context-discovery] llama.cpp /props probe succeeded', { url, n_ctx: json.n_ctx });
    return new Map([[SERVER_LEVEL_MODEL_ID, json.n_ctx]]);
}
/**
 * Probe `/info` (TGI single-model server info).
 * Prefers max_total_tokens, falls back to max_input_tokens.
 * Returns a single-entry map keyed by SERVER_LEVEL_MODEL_ID, or null.
 */
async function probeTGI(origin, apiKey) {
    const url = `${origin}/info`;
    const response = await probe(url, { apiKey });
    if (!response)
        return null;
    const json = await parseJSON(response, url);
    if (!json)
        return null;
    const ctx = (typeof json.max_total_tokens === 'number' && json.max_total_tokens > 0
        ? json.max_total_tokens
        : null) ??
        (typeof json.max_input_tokens === 'number' && json.max_input_tokens > 0
            ? json.max_input_tokens
            : null);
    if (ctx === null)
        return null;
    logger.debug('[context-discovery] TGI /info probe succeeded', { url, ctx });
    return new Map([[SERVER_LEVEL_MODEL_ID, ctx]]);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Discover context window sizes for all models at the given provider base URL.
 *
 * Probes endpoints in verbose-first order:
 *   1. LM Studio `/api/v1/models`
 *   2. Ollama `/api/tags` + `/api/show`
 *   3. OpenAI-compat `/v1/models`
 *   4. llama.cpp `/props`
 *   5. TGI `/info`
 *
 * The first probe that yields a non-null result populates the map. Subsequent
 * probes only ADD entries for model IDs not yet present — they never overwrite
 * data from a more-informative probe.
 *
 * @param baseURL - Provider base URL (e.g. `http://localhost:11434/v1`).
 *   The `/v1` suffix is used for the OpenAI-compat probe; the origin is
 *   derived automatically for all other probes.
 * @param apiKey  - Optional Bearer token sent with every probe request.
 * @returns Map of model ID → context window in tokens. Empty map if all
 *          probes fail or none return context length data.
 */
export async function discoverContextWindows(baseURL, apiKey) {
    const origin = extractOrigin(baseURL);
    const result = new Map();
    /**
     * Merge entries from a probe result into the accumulator.
     * Only adds entries not already present (verbose-first wins).
     */
    function merge(probeResult) {
        if (!probeResult)
            return;
        for (const [id, ctx] of probeResult) {
            if (!result.has(id)) {
                result.set(id, ctx);
            }
        }
    }
    // Probes run sequentially (not in parallel) because of verbose-first ordering:
    // each successful probe short-circuits by populating result, and merge() skips
    // model IDs already present. Parallel probing would lose this precedence guarantee.
    // Probe 1: LM Studio (richest — has max_context_length per model)
    merge(await probeLMStudio(origin, apiKey));
    // Probe 2: Ollama (per-model show with context_length / num_ctx)
    merge(await probeOllama(origin, apiKey));
    // Probe 3: OpenAI compat (id + optional max_model_len/max_context_length)
    merge(await probeOpenAICompat(baseURL, apiKey));
    // Probe 4: llama.cpp /props (server-level n_ctx)
    merge(await probeLlamaCpp(origin, apiKey));
    // Probe 5: TGI /info (server-level max_total_tokens)
    merge(await probeTGI(origin, apiKey));
    logger.debug('[context-discovery] Discovery complete', {
        baseURL,
        totalModels: result.size,
    });
    return result;
}
