bhcto.com

lib/batch.js

// Anthropic writing helpers for the 3-round pipeline.
// Highest-quality model is Fable 5; we probe it once and fall back to Opus 4.8 if
// the org's data-retention setting (or any unavailability) rejects it.
//
// NIR runs the LIVE Messages API (streamed, bounded concurrency) rather than the
// Message Batches API, to speed turnaround — `runRound` is the live implementation.
// The batch path is kept as `runRoundBatch` (+ submit/await/collect) for fallback.

const Anthropic = require("@anthropic-ai/sdk");
const crypto = require("crypto");
const { env } = require("./env");

// Anthropic batch custom_id max is 64 chars; several slugs are longer. Map a slug
// to a short, deterministic, unique id and translate results back.
function shortId(slug) {
  if (slug.length <= 64) return slug;
  const h = crypto.createHash("sha1").update(slug).digest("hex").slice(0, 8);
  return slug.slice(0, 55) + "-" + h; // 55 + 1 + 8 = 64
}

const FABLE = "claude-fable-5";
const OPUS = "claude-opus-4-8";

// Server-side web search (dynamic-filtering variant — supported on Fable 5 / Opus 4.8).
// Cap uses to bound cost per post.
const WEB_SEARCH = { type: "web_search_20260209", name: "web_search", max_uses: 10 };

let _client = null;
function client() {
  if (!_client) {
    _client = new Anthropic({ apiKey: env("ANTHROPIC_API_KEY", { required: true }) });
  }
  return _client;
}

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

// Model-specific request knobs. Fable 5 has always-on thinking (omit the param);
// Opus 4.8 takes adaptive thinking. Both take effort via output_config.
function modelParams(model) {
  const p = { model, output_config: { effort: "high" } };
  if (model !== FABLE) p.thinking = { type: "adaptive" };
  return p;
}

// Probe Fable 5 with one tiny request. Returns the model id to use for the run.
// Falls back to Opus 4.8 on a 400 (retention/availability) or any error.
async function probeModel(preferred = FABLE) {
  if (preferred !== FABLE) return preferred;
  try {
    const res = await client().messages.create({
      ...modelParams(FABLE),
      max_tokens: 16,
      messages: [{ role: "user", content: "Reply with the single word: ok" }],
    });
    if (res.stop_reason === "refusal") {
      console.warn("[probe] Fable 5 refused the probe — falling back to Opus 4.8");
      return OPUS;
    }
    console.log("[probe] Fable 5 available — using it.");
    return FABLE;
  } catch (e) {
    const status = e?.status || e?.statusCode;
    console.warn(
      `[probe] Fable 5 unavailable (${status || ""} ${e?.message || e}) — falling back to Opus 4.8`,
    );
    return OPUS;
  }
}

// Build a batch request object. `tools` is optional (e.g. [WEB_SEARCH]).
function request(custom_id, { model, system, userContent, max_tokens = 16000, tools }) {
  const params = {
    ...modelParams(model),
    max_tokens,
    system: [{ type: "text", text: system, cache_control: { type: "ephemeral" } }],
    messages: [{ role: "user", content: userContent }],
  };
  if (tools && tools.length) params.tools = tools;
  return { custom_id, params };
}

async function submitBatch(requests) {
  const batch = await client().messages.batches.create({ requests });
  console.log(`[batch] submitted ${batch.id} (${requests.length} requests)`);
  return batch.id;
}

// Poll until processing_status === "ended".
async function awaitBatch(id, { intervalMs = 45000 } = {}) {
  for (;;) {
    const b = await client().messages.batches.retrieve(id);
    if (b.processing_status === "ended") {
      console.log(
        `[batch] ${id} ended — succeeded:${b.request_counts.succeeded} errored:${b.request_counts.errored} canceled:${b.request_counts.canceled} expired:${b.request_counts.expired}`,
      );
      return b;
    }
    console.log(
      `[batch] ${id} ${b.processing_status} — processing:${b.request_counts.processing}`,
    );
    await sleep(intervalMs);
  }
}

// Stream results -> map keyed by custom_id. Each value: {ok, text, stop_reason, model, error}.
async function collectResults(id) {
  const out = new Map();
  for await (const r of await client().messages.batches.results(id)) {
    const cid = r.custom_id;
    if (r.result.type === "succeeded") {
      const msg = r.result.message;
      const text = (msg.content || [])
        .filter((b) => b.type === "text")
        .map((b) => b.text)
        .join("");
      out.set(cid, {
        ok: msg.stop_reason !== "refusal" && !!text,
        text,
        stop_reason: msg.stop_reason,
        model: msg.model,
      });
    } else {
      out.set(cid, {
        ok: false,
        error: r.result.type,
        detail: r.result.error ? JSON.stringify(r.result.error) : null,
      });
    }
  }
  return out;
}

// Run one full round: submit -> await -> collect. If the model was Fable 5 and any
// items refused/errored, re-queue just those on Opus 4.8.
async function runRoundBatch(model, items, buildSystemAndUser) {
  // custom_id = shortId(slug); keep a map to translate results back to slugs.
  const buildRequests = (its, m) => {
    const cidToSlug = new Map();
    const reqs = its.map((it) => {
      const cid = shortId(it.slug);
      cidToSlug.set(cid, it.slug);
      const { system, userContent, max_tokens, tools } = buildSystemAndUser(it);
      return request(cid, { model: m, system, userContent, max_tokens, tools });
    });
    return { reqs, cidToSlug };
  };

  const { reqs, cidToSlug } = buildRequests(items, model);
  const id = await submitBatch(reqs);
  await awaitBatch(id);
  const raw = await collectResults(id); // keyed by custom_id
  const results = new Map();
  for (const [cid, v] of raw) results.set(cidToSlug.get(cid) || cid, v);

  // Re-queue Fable 5 refusals on Opus 4.8 (fallbacks aren't available in batch).
  if (model === FABLE) {
    const retry = items.filter((it) => {
      const r = results.get(it.slug);
      return !r || !r.ok;
    });
    if (retry.length) {
      console.warn(`[round] re-queueing ${retry.length} failed item(s) on Opus 4.8`);
      const { reqs: rreq, cidToSlug: rmap } = buildRequests(retry, OPUS);
      const rid = await submitBatch(rreq);
      await awaitBatch(rid);
      const rraw = await collectResults(rid);
      for (const [cid, v] of rraw) results.set(rmap.get(cid) || cid, v);
    }
  }
  return results; // keyed by slug
}

// ---- Live Messages API path (default for NIR) ------------------------------

// Bounded-concurrency async map (preserves input order in the returned array).
async function mapLimit(items, limit, fn) {
  const ret = new Array(items.length);
  let next = 0;
  async function worker() {
    while (next < items.length) {
      const idx = next++;
      ret[idx] = await fn(items[idx], idx);
    }
  }
  const n = Math.max(1, Math.min(limit, items.length));
  await Promise.all(Array.from({ length: n }, worker));
  return ret;
}

// One live streamed generation. Streaming is used because high max_tokens + adaptive
// thinking + web search can exceed the non-streaming request window. Same result shape
// as collectResults' values: {ok, text, stop_reason, model} or {ok:false, error, detail}.
async function createLive(model, { system, userContent, max_tokens = 16000, tools }) {
  const params = {
    ...modelParams(model),
    max_tokens,
    system: [{ type: "text", text: system, cache_control: { type: "ephemeral" } }],
    messages: [{ role: "user", content: userContent }],
  };
  if (tools && tools.length) params.tools = tools;
  const stream = client().messages.stream(params);
  const msg = await stream.finalMessage();
  const text = (msg.content || [])
    .filter((b) => b.type === "text")
    .map((b) => b.text)
    .join("");
  return {
    ok: msg.stop_reason !== "refusal" && !!text,
    text,
    stop_reason: msg.stop_reason,
    model: msg.model,
  };
}

// Concurrency cap for live calls (override via DL_CONCURRENCY). Modest by default to
// stay under web-search / token rate limits while still fanning the 15 briefs out.
const LIVE_CONCURRENCY = Number(process.env.DL_CONCURRENCY || 4);

// Live equivalent of runRoundBatch: same signature, same slug-keyed return Map.
// Per-item Fable 5 -> Opus 4.8 fallback on refusal/error.
async function runRoundLive(model, items, buildSystemAndUser) {
  const results = new Map();
  await mapLimit(items, LIVE_CONCURRENCY, async (it) => {
    const spec = buildSystemAndUser(it);
    let r;
    try {
      r = await createLive(model, spec);
    } catch (e) {
      r = { ok: false, error: "api_error", detail: String(e?.message || e) };
    }
    if (model === FABLE && (!r || !r.ok)) {
      console.warn(`[live] ${it.slug} failed on Fable 5 — retrying on Opus 4.8`);
      try {
        r = await createLive(OPUS, spec);
      } catch (e) {
        r = { ok: false, error: "api_error", detail: String(e?.message || e) };
      }
    }
    results.set(it.slug, r);
    console.log(
      `[live] ${it.slug} ${r.ok ? "ok" : "FAIL"} (${r.model || model}${r.ok ? "" : " — " + (r.error || r.stop_reason)})`,
    );
    return r;
  });
  return results;
}

// Default export: the live path. Swap to runRoundBatch to use Message Batches instead.
const runRound = runRoundLive;

module.exports = {
  FABLE,
  OPUS,
  WEB_SEARCH,
  shortId,
  client,
  probeModel,
  modelParams,
  request,
  submitBatch,
  awaitBatch,
  collectResults,
  createLive,
  runRoundLive,
  runRoundBatch,
  runRound,
};