/**
 * OpenAI adapter for the LLMClient port.
 *
 * Uses fetch() against the Chat Completions API — same transport pattern as
 * the existing grader (`packages/eval/src/pipeline/grader-api.ts`). No SDK
 * dependency. Centralizes retry, rate-limit handling, cost accounting, and
 * per-call telemetry tagging via `context.feature`.
 *
 * Per `.claude/rules/eval-pipeline.md` and `.claude/rules/typescript.md`:
 * the adapter never reads `process.env`. The composition root maps env vars
 * to typed constructor args.
 */
import { z } from "zod";
import { OpenAIChatResponseSchema, splitModelId, } from "../../_vendor/ailf-core/index.js";
import { DEFAULT_RETRY_POLICY, LLMParseError, parseRetryAfterSeconds, runWithRetry, } from "./retry.js";
const DEFAULT_BASE_URL = "https://api.openai.com/v1/chat/completions";
/**
 * Conservative defaults for the models in `packages/eval/config/models.ts`.
 * Update when models are added or vendor pricing changes. Unknown models
 * fall through to `cost: 0` with a warning logged.
 *
 * Pricing reference: https://openai.com/api/pricing
 */
const DEFAULT_PRICING = {
    "gpt-5.2": { inputPer1k: 0.005, outputPer1k: 0.015 },
    "gpt-5": { inputPer1k: 0.005, outputPer1k: 0.015 },
    "gpt-5.4": { inputPer1k: 0.005, outputPer1k: 0.015 },
};
export class OpenAILLMClient {
    apiKey;
    baseUrl;
    pricing;
    retryPolicy;
    logger;
    sleep;
    rng;
    constructor(options) {
        this.apiKey = options.apiKey;
        this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
        this.pricing = { ...DEFAULT_PRICING, ...(options.pricing ?? {}) };
        this.retryPolicy = {
            ...DEFAULT_RETRY_POLICY,
            ...(options.retryPolicy ?? {}),
        };
        if (options.logger)
            this.logger = options.logger;
        if (options.sleep)
            this.sleep = options.sleep;
        if (options.rng)
            this.rng = options.rng;
    }
    async complete(args) {
        const { modelName } = splitModelId(args.model);
        const body = buildBody(modelName, args.prompt, {
            temperature: args.temperature,
            maxTokens: args.maxTokens,
            stop: args.stop,
        });
        const data = await this.callApi(body);
        const text = data.choices?.[0]?.message?.content;
        if (text == null || text === "") {
            throw new Error(`OpenAI returned empty completion for model ${args.model}`);
        }
        const usage = extractUsage(data.usage);
        const cost = this.computeCost(modelName, usage);
        this.logTelemetry(args.context, args.model, usage, cost);
        return { text, usage, cost, model: args.model };
    }
    async completeStructured(args) {
        const { modelName } = splitModelId(args.model);
        // Derive the JSON Schema from the caller's Zod schema. Zod v4 natively
        // emits `additionalProperties: false` on every nested z.object node —
        // this is required for OpenAI strict-mode.
        const jsonSchema = z.toJSONSchema(args.schema, { target: "draft-2020-12" });
        // OpenAI strict-mode requires the root to be a plain object schema (no
        // anyOf/oneOf/allOf at the top level). Discriminated unions produce
        // anyOf at the root — callers must wrap them in a discriminator object.
        assertSchemaIsObjectRoot(jsonSchema, args.model);
        const body = buildBody(modelName, args.prompt, {
            temperature: args.temperature ?? 0.1,
            maxTokens: args.maxTokens ?? 2000,
            responseFormat: {
                type: "json_schema",
                json_schema: {
                    name: args.context?.cardId ?? "structured_output",
                    schema: jsonSchema,
                    strict: true,
                },
            },
        });
        const data = await this.callApi(body);
        const raw = data.choices?.[0]?.message?.content;
        if (raw == null || raw === "") {
            throw new Error(`OpenAI returned empty structured completion for model ${args.model}`);
        }
        let parsed;
        try {
            parsed = JSON.parse(raw);
        }
        catch (err) {
            // Sanitize: SyntaxError.message embeds a snippet at the failure offset,
            // which can leak prompt text or user content echoed back by the model.
            // Keep the raw body on the instance for callers that opt in via .raw,
            // mirroring the LLMHttpError pattern (verified by the "does not leak
            // the response body" test in openai-llm-client.test.ts).
            throw new LLMParseError(`OpenAI structured completion returned invalid JSON for model ${args.model}`, raw, { cause: err });
        }
        // strict:true guarantees a valid-against-the-schema JSON document, but
        // the Zod parse is still load-bearing — it brands the result as T and is
        // the only contract the engine trusts (D0045 parse-don't-validate).
        const value = args.schema.parse(parsed);
        const usage = extractUsage(data.usage);
        const cost = this.computeCost(modelName, usage);
        this.logTelemetry(args.context, args.model, usage, cost);
        return { value, usage, cost, model: args.model };
    }
    async callApi(body) {
        return runWithRetry({
            policy: this.retryPolicy,
            ...(this.sleep ? { sleep: this.sleep } : {}),
            ...(this.rng ? { rng: this.rng } : {}),
            attempt: async () => {
                const response = await fetch(this.baseUrl, {
                    method: "POST",
                    headers: {
                        Authorization: `Bearer ${this.apiKey}`,
                        "Content-Type": "application/json",
                    },
                    body: JSON.stringify(body),
                });
                if (!response.ok) {
                    const text = await response.text();
                    const retryAfter = parseRetryAfterSeconds(response.headers.get("retry-after"));
                    return {
                        ok: false,
                        status: response.status,
                        body: text,
                        ...(retryAfter !== undefined
                            ? { retryAfterSeconds: retryAfter }
                            : {}),
                    };
                }
                const json = await response.json();
                const data = OpenAIChatResponseSchema.parse(json);
                if (data.error?.message) {
                    throw new Error(`OpenAI API error: ${data.error.message}`);
                }
                return { ok: true, value: data };
            },
        });
    }
    computeCost(modelName, usage) {
        const price = this.pricing[modelName];
        if (!price) {
            this.logger?.warn(`OpenAI cost unknown for model "${modelName}" — recording cost=0. Add it to OpenAILLMClientOptions.pricing.`);
            return 0;
        }
        return ((usage.promptTokens / 1000) * price.inputPer1k +
            (usage.completionTokens / 1000) * price.outputPer1k);
    }
    logTelemetry(context, model, usage, cost) {
        if (!this.logger)
            return;
        const tag = context ? ` feature=${context.feature}` : "";
        const runTag = context?.runId ? ` runId=${context.runId}` : "";
        const cardTag = context?.cardId ? ` cardId=${context.cardId}` : "";
        this.logger.debug(`LLM call (openai)${tag}${runTag}${cardTag} model=${model} ` +
            `prompt_tokens=${usage.promptTokens} completion_tokens=${usage.completionTokens} ` +
            `cost_usd=${cost.toFixed(6)}`);
    }
}
/**
 * Assert that the JSON Schema root is a plain object type.
 *
 * OpenAI strict-mode requires the root schema to be `{ type: "object" }`.
 * A discriminated union (`z.union([...])`) produces `{ anyOf: [...] }` at
 * the root — callers must wrap the union in a discriminator object before
 * passing it to `completeStructured`.
 *
 * Per AI-SPEC §3 Pitfall 6 + T-05-03-01: caught at request-build time to
 * avoid wasting API budget on a guaranteed 400.
 */
function assertSchemaIsObjectRoot(schema, modelId) {
    if (typeof schema !== "object" || schema === null) {
        throw new Error(`OpenAILLMClient: OpenAI strict-mode requires a single z.object at the ` +
            `schema root for model ${modelId}; got non-object JSON Schema root.`);
    }
    const node = schema;
    if (node.type !== "object") {
        // Identify the kind so the error message is actionable.
        const kind = "anyOf" in node
            ? "z.union"
            : "oneOf" in node
                ? "z.discriminatedUnion"
                : "allOf" in node
                    ? "z.intersection"
                    : String(node.type ?? "unknown");
        throw new Error(`OpenAILLMClient: OpenAI strict-mode requires a single z.object at the ` +
            `schema root; got ${kind}. Wrap the union in a discriminator object.`);
    }
}
function buildBody(modelName, prompt, opts) {
    const body = {
        model: modelName,
        messages: [{ role: "user", content: prompt }],
    };
    if (opts.temperature !== undefined)
        body.temperature = opts.temperature;
    if (opts.maxTokens !== undefined)
        body.max_tokens = opts.maxTokens;
    if (opts.stop && opts.stop.length > 0)
        body.stop = opts.stop;
    if (opts.responseFormat)
        body.response_format = opts.responseFormat;
    return body;
}
function extractUsage(usage) {
    return {
        promptTokens: usage?.prompt_tokens ?? 0,
        completionTokens: usage?.completion_tokens ?? 0,
    };
}
