"use strict";
/**
 * OAuth-bearer LLM calls for admin-side classifiers.
 *
 * Every admin-side classifier (memory-classify, memory-rank, commitment,
 * inbound-gateway, query classifier, summarisation, email
 * screening) uses Claude Code OAuth credentials — never the Anthropic
 * API key. The API-key path is reserved for the public agent.
 *
 * Mechanism: read the access token from ~/.claude/.credentials.json,
 * refresh it via the standard refresh-token grant if expired, then call
 * api.anthropic.com with `Authorization: Bearer <token>` and the
 * `anthropic-beta: oauth-2025-04-20` header. Billed against the operator's
 * Claude Code subscription, not the API-key key. Refresh uses a
 * module-level mutex so concurrent classifier calls during expiry don't
 * race and invalidate each other's tokens (Anthropic rotates both tokens
 * on refresh).
 *
 * Implementation note: this lib uses raw fetch() so the maxy-code tree
 * carries no Anthropic SDK dependency at all. The Messages API surface
 * we use (model + system + one user message + max_tokens) is small and
 * stable.
 *
 * Failure modes are surfaced as a structured `Result` so callers can
 * pattern-match without parsing error strings. The skill / agent layer
 * is responsible for translating `fallback` results into operator-visible
 * blocker messages (loud failure doctrine).
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.OAUTH_BETA_HEADER = void 0;
exports.callOauthLlm = callOauthLlm;
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const node_os_1 = require("node:os");
// ---------------------------------------------------------------------------
// Constants — token endpoint, client id, beta header, file path.
// All four are pinned values that match the Claude Code CLI's own usage.
// ---------------------------------------------------------------------------
// brand-scoped credentials. The platform/ui/app/lib/paths.ts
// constant uses MAXY_DIR; this lib stays self-contained (per the comment
// at line 152-156) and reads CLAUDE_CONFIG_DIR from systemd's `Environment=`
// block. Both resolve to the same path because port-resolution.ts sets
// `CLAUDE_CONFIG_DIR=${persistDir}/.claude` and `persistDir = ${HOME}/${BRAND.configDir}
// = MAXY_DIR`. Dev fallback (`homedir()/.claude`) covers vitest and ad-hoc
// node runs without the env var set; it is harmless because dev is
// single-brand by definition.
const CREDENTIALS_FILE = process.env.CLAUDE_CONFIG_DIR
    ? (0, node_path_1.resolve)(process.env.CLAUDE_CONFIG_DIR, ".credentials.json")
    : (0, node_path_1.resolve)((0, node_os_1.homedir)(), ".claude", ".credentials.json");
const TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token";
const ANTHROPIC_MESSAGES_ENDPOINT = "https://api.anthropic.com/v1/messages";
const ANTHROPIC_VERSION = "2023-06-01";
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const OAUTH_BETA_HEADER = "oauth-2025-04-20";
exports.OAUTH_BETA_HEADER = OAUTH_BETA_HEADER;
/** Refresh proactively when the token expires within this window. */
const EXPIRING_THRESHOLD_MS = 5 * 60 * 1000;
// Anthropic's 400 response carries a JSON body whose `error.message` names the
// failure. The three phrases below are the load-bearing markers for an oversize
// input across the SDK's vocabulary; matching any one promotes the generic
// status=400 into a typed cause callers can act on (chunk, defer, surface
// remediation). Generic 400s still fall through to cause:"unexpected".
const INPUT_TOO_LARGE_MARKERS = [
    "prompt is too long",
    "context_length_exceeded",
    "too many tokens",
];
function isInputTooLargeBody(body) {
    const lower = body.toLowerCase();
    return INPUT_TOO_LARGE_MARKERS.some((m) => lower.includes(m));
}
// ---------------------------------------------------------------------------
// Credential reading + refresh — minimal duplicate of platform/ui claude-auth.
// We duplicate (not import) so this lib stays self-contained and importable
// from MCP plugins via dist/. The duplicated logic is small and the auth
// mechanism is pinned by Anthropic — divergence risk is low.
// ---------------------------------------------------------------------------
function readCredentials() {
    let raw;
    try {
        raw = (0, node_fs_1.readFileSync)(CREDENTIALS_FILE, "utf-8");
    }
    catch {
        return null;
    }
    let data;
    try {
        data = JSON.parse(raw);
    }
    catch {
        return null;
    }
    const oauth = data.claudeAiOauth;
    if (!oauth || typeof oauth !== "object")
        return null;
    const accessToken = oauth.accessToken;
    const refreshToken = oauth.refreshToken;
    const expiresAt = oauth.expiresAt;
    if (typeof accessToken !== "string" || !accessToken)
        return null;
    if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) {
        return null;
    }
    return {
        accessToken,
        refreshToken: typeof refreshToken === "string" && refreshToken ? refreshToken : null,
        expiresAt,
    };
}
function writeCredentials(tokens) {
    const newExpiresAt = typeof tokens.expiresIn === "number" && tokens.expiresIn > 0
        ? Date.now() + tokens.expiresIn * 1000
        : Date.now() + 3600 * 1000;
    let fileData = {};
    try {
        fileData = JSON.parse((0, node_fs_1.readFileSync)(CREDENTIALS_FILE, "utf-8"));
    }
    catch {
        // start fresh
    }
    const existing = fileData.claudeAiOauth;
    fileData.claudeAiOauth = {
        ...existing,
        accessToken: tokens.accessToken,
        ...(tokens.refreshToken != null ? { refreshToken: tokens.refreshToken } : {}),
        expiresAt: newExpiresAt,
    };
    (0, node_fs_1.writeFileSync)(CREDENTIALS_FILE, JSON.stringify(fileData, null, 2), "utf-8");
    return newExpiresAt;
}
// Module-level mutex serialises refresh calls. Anthropic rotates both
// tokens on refresh; concurrent unsynchronised refreshes invalidate
// each other.
let refreshLock = null;
async function refreshAccessToken(current) {
    if (refreshLock)
        return refreshLock;
    refreshLock = (async () => {
        // Re-read inside the lock — another caller may have refreshed already.
        const fresh = readCredentials();
        if (fresh && fresh.expiresAt - Date.now() > EXPIRING_THRESHOLD_MS) {
            return fresh;
        }
        if (!current.refreshToken) {
            return null;
        }
        let res;
        try {
            res = await fetch(TOKEN_ENDPOINT, {
                method: "POST",
                headers: { "Content-Type": "application/x-www-form-urlencoded" },
                body: new URLSearchParams({
                    grant_type: "refresh_token",
                    refresh_token: current.refreshToken,
                    client_id: CLIENT_ID,
                }),
            });
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            console.error(`[oauth-llm] refresh network error: ${msg}`);
            return null;
        }
        if (!res.ok) {
            const body = await res.text().catch(() => "");
            console.error(`[oauth-llm] refresh failed status=${res.status} body=${body.slice(0, 200)}`);
            return null;
        }
        let tokenData;
        try {
            tokenData = (await res.json());
        }
        catch {
            console.error("[oauth-llm] refresh returned malformed JSON");
            return null;
        }
        const newAccessToken = tokenData.access_token;
        const newRefreshToken = tokenData.refresh_token;
        const expiresIn = tokenData.expires_in;
        if (typeof newAccessToken !== "string" || !newAccessToken) {
            console.error("[oauth-llm] refresh response missing access_token");
            return null;
        }
        const newExpiresAt = writeCredentials({
            accessToken: newAccessToken,
            refreshToken: typeof newRefreshToken === "string" && newRefreshToken
                ? newRefreshToken
                : current.refreshToken,
            expiresIn: typeof expiresIn === "number" && expiresIn > 0 ? expiresIn : undefined,
        });
        console.error(`[oauth-llm] refresh ok expiresAt=${new Date(newExpiresAt).toISOString()}`);
        return {
            accessToken: newAccessToken,
            refreshToken: typeof newRefreshToken === "string" && newRefreshToken
                ? newRefreshToken
                : current.refreshToken,
            expiresAt: newExpiresAt,
        };
    })();
    try {
        return await refreshLock;
    }
    finally {
        refreshLock = null;
    }
}
/**
 * Read credentials and proactively refresh if approaching expiry.
 * Returns null when the credentials are missing or unrecoverable
 * (operator must re-authenticate via Claude Code login).
 */
async function ensureValidToken() {
    const creds = readCredentials();
    if (!creds)
        return null;
    const remaining = creds.expiresAt - Date.now();
    if (remaining > EXPIRING_THRESHOLD_MS)
        return creds;
    if (remaining > 0 && !creds.refreshToken)
        return creds; // valid but no way to renew
    if (remaining <= 0 && !creds.refreshToken)
        return null; // dead
    return refreshAccessToken(creds);
}
// ---------------------------------------------------------------------------
// First-call beta-header diagnostic — emitted once per process so server.log
// makes the OAuth path visible without flooding on every classifier call.
// ---------------------------------------------------------------------------
let betaHeaderLogged = false;
function logBetaHeaderOnce() {
    if (betaHeaderLogged)
        return;
    betaHeaderLogged = true;
    console.error(`[oauth-llm] beta-header=${OAUTH_BETA_HEADER}`);
}
async function callOauthLlm(params) {
    const { model, system, userMessage, maxTokens = 4096, timeoutMs = 60_000, tools, toolChoiceName, } = params;
    if (tools && tools.length > 0 && !toolChoiceName) {
        return {
            kind: "fallback",
            cause: "unexpected",
            reason: "callOauthLlm: `tools` provided without `toolChoiceName`. Forced tool selection is required.",
        };
    }
    logBetaHeaderOnce();
    const creds = await ensureValidToken();
    if (!creds) {
        return {
            kind: "fallback",
            cause: "missing-creds",
            reason: "Claude Code OAuth credentials missing or expired with no refresh token available — operator must re-authenticate.",
        };
    }
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    let res;
    try {
        res = await fetch(ANTHROPIC_MESSAGES_ENDPOINT, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "anthropic-version": ANTHROPIC_VERSION,
                "anthropic-beta": OAUTH_BETA_HEADER,
                Authorization: `Bearer ${creds.accessToken}`,
            },
            body: JSON.stringify({
                model,
                max_tokens: maxTokens,
                system,
                messages: [{ role: "user", content: userMessage }],
                ...(tools && tools.length > 0 ? { tools } : {}),
                ...(toolChoiceName
                    ? { tool_choice: { type: "tool", name: toolChoiceName } }
                    : {}),
            }),
            signal: controller.signal,
        });
    }
    catch (err) {
        clearTimeout(timer);
        const msg = err instanceof Error ? err.message : String(err);
        if (controller.signal.aborted) {
            return {
                kind: "fallback",
                cause: "timeout",
                reason: `Model call timed out after ${timeoutMs}ms.`,
            };
        }
        return {
            kind: "fallback",
            cause: "network-error",
            reason: `Network error reaching Anthropic: ${msg}`,
        };
    }
    clearTimeout(timer);
    if (!res.ok) {
        const body = await res.text().catch(() => "");
        if (res.status === 401 || res.status === 403) {
            return {
                kind: "fallback",
                cause: "auth-error",
                reason: `OAuth bearer rejected (status=${res.status}) — token may be revoked or beta header rejected. body=${body.slice(0, 200)}`,
            };
        }
        if (res.status === 429) {
            return {
                kind: "fallback",
                cause: "rate-limit",
                reason: `Anthropic rate limit hit (status=429): ${body.slice(0, 200)}`,
            };
        }
        if (res.status >= 500) {
            return {
                kind: "fallback",
                cause: "server-error",
                reason: `Anthropic server error (status=${res.status}): ${body.slice(0, 200)}`,
            };
        }
        if (res.status === 400 && isInputTooLargeBody(body)) {
            return {
                kind: "fallback",
                cause: "input-too-large",
                reason: `Input exceeds Haiku context window (status=400 body=${body.slice(0, 200)}). Split the source into smaller segments.`,
            };
        }
        return {
            kind: "fallback",
            cause: "unexpected",
            reason: `Anthropic returned status=${res.status}: ${body.slice(0, 200)}`,
        };
    }
    let payload;
    try {
        payload = (await res.json());
    }
    catch {
        return {
            kind: "fallback",
            cause: "malformed-response",
            reason: "Anthropic returned malformed JSON.",
        };
    }
    if (payload.error) {
        return {
            kind: "fallback",
            cause: "unexpected",
            reason: `Anthropic API error: ${payload.error.type ?? "unknown"} ${payload.error.message ?? ""}`,
        };
    }
    const stopReason = typeof payload.stop_reason === "string" ? payload.stop_reason : undefined;
    const outputTokens = typeof payload.usage?.output_tokens === "number" ? payload.usage.output_tokens : undefined;
    // When the caller forced a tool, prefer the tool_use block — its input
    // is the structured output the caller wants.
    if (toolChoiceName) {
        const toolBlock = payload.content?.find((block) => block.type === "tool_use");
        if (!toolBlock || !toolBlock.input || typeof toolBlock.input !== "object") {
            return {
                kind: "fallback",
                cause: "empty-response",
                reason: "Model returned no tool_use block despite forced tool selection.",
            };
        }
        return {
            kind: "ok-tool",
            toolName: toolBlock.name ?? toolChoiceName,
            input: toolBlock.input,
            stopReason,
            outputTokens,
        };
    }
    const textBlock = payload.content?.find((block) => {
        if (block.type !== "text")
            return false;
        const candidate = block.text;
        return typeof candidate === "string" && candidate.trim().length > 0;
    });
    if (!textBlock) {
        return {
            kind: "fallback",
            cause: "empty-response",
            reason: "Model returned no text content.",
        };
    }
    return { kind: "ok", text: textBlock.text, stopReason, outputTokens };
}
//# sourceMappingURL=index.js.map