/**
 * Shared HTTP helpers for AI provider adapters: a timeout-bounded fetch, a
 * status->AIConnectionStatus mapper, and an SSRF guard for custom base URLs.
 * Built-in providers use fixed hostnames; only custom endpoints are
 * admin-controlled and therefore validated/resolved.
 */
import { resolveAndCheck, validateWebhookUrl } from '../../security/webhook.js';
/** Map an HTTP response status to a connection-health value. */
export function statusFromHttp(status) {
    if (status >= 200 && status < 300)
        return 'connected';
    if (status === 401 || status === 403)
        return 'invalid_key';
    if (status === 429)
        return 'rate_limited';
    if (status >= 500)
        return 'provider_down';
    return 'unknown';
}
/** A short, safe message for a status (never contains the key). */
export function messageFromStatus(status, httpStatus) {
    switch (status) {
        case 'connected':
            return 'Connection successful.';
        case 'invalid_key':
            return 'The API key was rejected (invalid or unauthorized).';
        case 'rate_limited':
            return 'The provider rate-limited the request. Try again shortly.';
        case 'provider_down':
            return 'The provider returned a server error. It may be temporarily unavailable.';
        case 'needs_setup':
            return 'No API key is configured for this provider.';
        default:
            if (httpStatus === 400 || httpStatus === 422) {
                return 'The provider rejected the request. Check the selected model in Settings > AI.';
            }
            if (httpStatus === 404) {
                return 'The provider endpoint or model was not found.';
            }
            return 'Could not determine connection status.';
    }
}
/** Best-effort extraction of a provider error message from a JSON error body. */
export function extractProviderErrorMessage(payload) {
    if (!payload || typeof payload !== 'object')
        return undefined;
    const root = payload;
    const nested = root.error;
    if (nested && typeof nested === 'object') {
        const message = nested.message;
        if (typeof message === 'string' && message.trim())
            return message.trim();
    }
    const message = root.message;
    if (typeof message === 'string' && message.trim())
        return message.trim();
    return undefined;
}
export async function timedFetch(url, init, ctx) {
    const doFetch = ctx.fetchImpl ?? globalThis.fetch;
    if (typeof doFetch !== 'function') {
        throw new Error('fetch is not available in this runtime');
    }
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), ctx.timeoutMs ?? 10_000);
    try {
        return await doFetch(url, { ...init, redirect: 'error', signal: controller.signal });
    }
    finally {
        clearTimeout(timer);
    }
}
/**
 * Validate a custom provider base URL against SSRF (no private/internal hosts,
 * https/http only) AND resolve its DNS to defend against rebinding. Throws on
 * rejection with a safe message.
 */
export async function assertSafeBaseUrl(baseUrl) {
    const check = validateWebhookUrl(baseUrl);
    if (!check.valid) {
        throw new Error(`Unsafe base URL: ${check.error ?? 'invalid'}`);
    }
    let host;
    try {
        host = new URL(baseUrl).hostname;
    }
    catch {
        throw new Error('Unsafe base URL: invalid URL');
    }
    const resolved = await resolveAndCheck(host);
    if (!resolved.safe) {
        throw new Error(`Unsafe base URL: ${resolved.error ?? 'resolves to a private address'}`);
    }
}
/** Strip a trailing slash so we can append `/models` etc. cleanly. */
export function trimTrailingSlash(url) {
    return url.replace(/\/+$/, '');
}
function num(value) {
    return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
}
/** Build an adapter error from a non-2xx response, including provider detail when safe. */
export async function adapterHttpErrorFromResponse(res) {
    const status = statusFromHttp(res.status);
    let detail;
    try {
        const text = await res.text();
        if (text.trim()) {
            try {
                detail = extractProviderErrorMessage(JSON.parse(text));
            }
            catch {
                detail = text.replace(/\s+/g, ' ').trim().slice(0, 300);
            }
        }
    }
    catch {
        // Body read failures should not hide the HTTP status.
    }
    return new AdapterHttpError(status, res.status, detail);
}
/**
 * Call an OpenAI-compatible `/chat/completions` endpoint. Shared by the OpenAI,
 * xAI, and custom adapters (all speak the same wire format). `base` must already
 * be validated/SSRF-checked by the caller for admin-controlled endpoints.
 */
export async function openAiChat(base, headers, ctx, opts) {
    const messages = [];
    if (opts.system)
        messages.push({ role: 'system', content: opts.system });
    messages.push({ role: 'user', content: opts.prompt });
    const res = await timedFetch(`${trimTrailingSlash(base)}/chat/completions`, {
        method: 'POST',
        headers: { ...headers, 'content-type': 'application/json' },
        body: JSON.stringify({
            model: opts.model,
            messages,
            max_tokens: opts.maxTokens ?? 1024,
            ...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
        }),
    }, ctx);
    if (!res.ok) {
        throw await adapterHttpErrorFromResponse(res);
    }
    const payload = (await res.json());
    const text = payload.choices?.[0]?.message?.content;
    const promptTokens = num(payload.usage?.prompt_tokens);
    const completionTokens = num(payload.usage?.completion_tokens);
    return {
        text: typeof text === 'string' ? text : '',
        usage: {
            promptTokens,
            completionTokens,
            totalTokens: num(payload.usage?.total_tokens) || promptTokens + completionTokens,
        },
        model: opts.model,
    };
}
/** Error carrying a mapped connection status so the runtime can react sensibly. */
export class AdapterHttpError extends Error {
    connectionStatus;
    httpStatus;
    constructor(connectionStatus, httpStatus, detail) {
        const base = messageFromStatus(connectionStatus, httpStatus);
        const safeDetail = detail?.replace(/\s+/g, ' ').trim().slice(0, 500);
        super(safeDetail ? `${base} ${safeDetail}` : base);
        this.connectionStatus = connectionStatus;
        this.httpStatus = httpStatus;
        this.name = 'AdapterHttpError';
    }
}
/** Best-effort extraction of model ids from an OpenAI-compatible list payload. */
export function extractModelIds(payload) {
    const data = payload?.data;
    if (!Array.isArray(data))
        return [];
    const ids = [];
    for (const entry of data) {
        const id = entry?.id;
        if (typeof id === 'string' && id.trim())
            ids.push(id.trim());
    }
    return ids;
}
//# sourceMappingURL=http.js.map