mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
The comprehensive insight that feeds both the daily briefing and the insights trend narrative is a single reasoning-heavy generation over the full feature snapshot. On a large account it runs past the provider client's fixed 60 s timeout and is aborted mid-stream, so the chain reports an all-failed outcome, no cached block is written, and the surface falls back to its static placeholder on every visit. The warm paths bounded the same generation tighter still (45 s) and counted the failure with no observable signal, so the blank state was permanent and silent. Make the per-request upstream timeout configurable (CompletionParams.timeoutMs, default 60 s for every existing caller) and raise it to 120 s for the comprehensive surface only, across all three generation paths: the on-demand regenerate, the on-demand warm, and the nightly pre-generation. Raise the warm-pass budget to 130 s so the provider's own timeout governs a genuine completion rather than clipping it early. Annotate the comprehensive failure in both the generator and the nightly loop so a generation that keeps failing is queryable instead of leaving the surface empty without a trace.
382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
import { safeFetch } from "@/lib/safe-fetch";
|
|
import type {
|
|
AIProvider,
|
|
AiContentPart,
|
|
AiMessage,
|
|
AiToolCall,
|
|
AiToolDef,
|
|
CompletionParams,
|
|
CompletionResult,
|
|
} from "./types";
|
|
|
|
/**
|
|
* v1.18.9 — Anthropic Messages API content blocks. The text-only path sends a
|
|
* bare string; the vision path (Lab-OCR) sends an array of typed blocks. The
|
|
* uploaded image / PDF is framed strictly as DATA to transcribe by the system
|
|
* prompt — never as instructions (prompt-injection backstop is the human
|
|
* review screen downstream).
|
|
*
|
|
* v1.20.0 — adds the `tool_use` (assistant requests a tool) and `tool_result`
|
|
* (a `role:"user"` turn answering one) blocks so a multi-round tool loop (F1)
|
|
* round-trips natively.
|
|
*/
|
|
type AnthropicContentBlock =
|
|
| { type: "text"; text: string }
|
|
| {
|
|
type: "image";
|
|
source: { type: "base64"; media_type: string; data: string };
|
|
}
|
|
| {
|
|
type: "document";
|
|
source: { type: "base64"; media_type: "application/pdf"; data: string };
|
|
}
|
|
| {
|
|
type: "tool_use";
|
|
id: string;
|
|
name: string;
|
|
input: Record<string, unknown>;
|
|
}
|
|
| {
|
|
type: "tool_result";
|
|
tool_use_id: string;
|
|
content: string;
|
|
};
|
|
|
|
interface AnthropicWireMessage {
|
|
role: "user" | "assistant";
|
|
content: string | AnthropicContentBlock[];
|
|
}
|
|
|
|
interface AnthropicClientConfig {
|
|
apiKey: string;
|
|
model: string;
|
|
baseUrl?: string;
|
|
}
|
|
|
|
const DEFAULT_BASE_URL = "https://api.anthropic.com/v1";
|
|
const ANTHROPIC_VERSION = "2023-06-01";
|
|
|
|
const JSON_INSTRUCTION =
|
|
"\n\nRespond only with valid JSON matching the requested schema. Do not include any prose, markdown fences, or explanation outside the JSON object.";
|
|
|
|
/**
|
|
* Map an `AiContentPart[]` body into Anthropic content blocks. The media blocks
|
|
* (image / document) are emitted FIRST and the text blocks LAST so the model
|
|
* reads the report before the instruction — matching the pre-refactor
|
|
* `buildVisionContent` ordering exactly.
|
|
*/
|
|
function mapParts(parts: AiContentPart[]): AnthropicContentBlock[] {
|
|
const media: AnthropicContentBlock[] = [];
|
|
const text: AnthropicContentBlock[] = [];
|
|
for (const part of parts) {
|
|
if (part.type === "text") {
|
|
text.push({ type: "text", text: part.text });
|
|
} else if (part.type === "image") {
|
|
media.push({
|
|
type: "image",
|
|
source: {
|
|
type: "base64",
|
|
media_type: part.mediaType,
|
|
data: part.dataBase64,
|
|
},
|
|
});
|
|
} else if (part.type === "document") {
|
|
media.push({
|
|
type: "document",
|
|
source: {
|
|
type: "base64",
|
|
media_type: "application/pdf",
|
|
data: part.dataBase64,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
return [...media, ...text];
|
|
}
|
|
|
|
/**
|
|
* Map one `AiMessage` to its Anthropic wire turn. A `role:"tool"` turn becomes
|
|
* a `role:"user"` turn carrying a `tool_result` block (Anthropic's convention).
|
|
* An assistant turn with `toolCalls` emits `tool_use` blocks alongside any text.
|
|
*/
|
|
function mapMessage(m: AiMessage): AnthropicWireMessage {
|
|
if (m.role === "tool") {
|
|
return {
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "tool_result",
|
|
tool_use_id: m.toolCallId ?? "",
|
|
content: typeof m.content === "string" ? m.content : "",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
const hasToolCalls =
|
|
m.role === "assistant" && !!m.toolCalls && m.toolCalls.length > 0;
|
|
|
|
// Plain-string content with no tool calls stays a bare string on the wire —
|
|
// byte-identical to the pre-refactor text-only path. Only build a block array
|
|
// when there are content parts or tool_use blocks to carry.
|
|
if (typeof m.content === "string" && !hasToolCalls) {
|
|
return { role: m.role, content: m.content };
|
|
}
|
|
|
|
const blocks: AnthropicContentBlock[] =
|
|
typeof m.content === "string"
|
|
? m.content.length > 0
|
|
? [{ type: "text", text: m.content }]
|
|
: []
|
|
: mapParts(m.content);
|
|
if (hasToolCalls) {
|
|
for (const tc of m.toolCalls!) {
|
|
let input: Record<string, unknown> = {};
|
|
try {
|
|
input = JSON.parse(tc.arguments) as Record<string, unknown>;
|
|
} catch {
|
|
input = {};
|
|
}
|
|
blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input });
|
|
}
|
|
}
|
|
return { role: m.role, content: blocks };
|
|
}
|
|
|
|
/** Map tool defs into the Anthropic `tools` array. */
|
|
function buildAnthropicTools(tools: AiToolDef[]) {
|
|
return tools.map((t) => ({
|
|
name: t.name,
|
|
description: t.description,
|
|
input_schema: t.parameters,
|
|
}));
|
|
}
|
|
|
|
export class AnthropicClient implements AIProvider {
|
|
readonly type = "anthropic" as const;
|
|
private config: AnthropicClientConfig;
|
|
|
|
constructor(config: AnthropicClientConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
async generateCompletion(
|
|
params: CompletionParams,
|
|
): Promise<CompletionResult> {
|
|
const baseUrl = (this.config.baseUrl ?? DEFAULT_BASE_URL).replace(
|
|
/\/$/,
|
|
"",
|
|
);
|
|
const url = `${baseUrl}/messages`;
|
|
|
|
const hasTools = !!params.tools && params.tools.length > 0;
|
|
// v1.20.0 — the `{`-prefill JSON trick injects an assistant turn that is
|
|
// incompatible with a `tool_use` response in the same call, so tools and
|
|
// the prefill are mutually exclusive: when tools are present we drop the
|
|
// prefill (the tool flow is non-JSON-prefill).
|
|
const usePrefill = params.responseFormat === "json" && !hasTools;
|
|
|
|
const wireMessages: AnthropicWireMessage[] =
|
|
params.messages.map(mapMessage);
|
|
|
|
// JSON-reliability instruction. The pre-refactor client appended this to
|
|
// the user prompt UNCONDITIONALLY (not gated on `responseFormat`), so every
|
|
// Anthropic caller — including the Coach prose path — saw it. Preserve that
|
|
// exactly by appending to the LAST user turn's text. Tools imply the
|
|
// non-JSON tool flow, so skip the instruction when tools are present.
|
|
if (!hasTools) {
|
|
for (let i = wireMessages.length - 1; i >= 0; i -= 1) {
|
|
if (wireMessages[i].role !== "user") continue;
|
|
const turn = wireMessages[i];
|
|
if (typeof turn.content === "string") {
|
|
turn.content = `${turn.content}${JSON_INSTRUCTION}`;
|
|
} else {
|
|
// Merge into the trailing text block when there is one (vision turns
|
|
// end with the instruction text) so the wire stays a single text
|
|
// block — byte-identical to the pre-refactor `wrapForJson` output.
|
|
const last = turn.content[turn.content.length - 1];
|
|
if (last && last.type === "text") {
|
|
last.text = `${last.text}${JSON_INSTRUCTION}`;
|
|
} else {
|
|
turn.content = [
|
|
...turn.content,
|
|
{ type: "text", text: JSON_INSTRUCTION },
|
|
];
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (usePrefill) {
|
|
wireMessages.push({ role: "assistant", content: "{" });
|
|
}
|
|
|
|
// v1.20.0 — prompt-cache the stable system prefix. Anthropic needs an
|
|
// explicit `cache_control` marker; the system block carries the large
|
|
// brand-free reference grounding + persona, so marking it lets repeated
|
|
// calls (status batch, briefing) read it from cache. Surface
|
|
// `cache_read_input_tokens` for observability.
|
|
const system = [
|
|
{
|
|
type: "text" as const,
|
|
text: params.system,
|
|
cache_control: { type: "ephemeral" as const },
|
|
},
|
|
];
|
|
|
|
const tools = hasTools
|
|
? buildAnthropicTools(params.tools as AiToolDef[])
|
|
: undefined;
|
|
const toolChoice =
|
|
hasTools && params.toolChoice
|
|
? params.toolChoice === "none"
|
|
? { type: "none" as const }
|
|
: { type: "auto" as const }
|
|
: undefined;
|
|
|
|
const res = await safeFetch(
|
|
url,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-api-key": this.config.apiKey,
|
|
"anthropic-version": ANTHROPIC_VERSION,
|
|
},
|
|
// NOTE: Anthropic's Messages API has no `seed` parameter, so
|
|
// `params.seed` is intentionally not forwarded here — output on this
|
|
// provider is non-deterministic regardless of the pinned seed.
|
|
body: JSON.stringify({
|
|
model: this.config.model,
|
|
max_tokens: params.maxTokens ?? 1000,
|
|
temperature: params.temperature ?? 0.3,
|
|
system,
|
|
messages: wireMessages,
|
|
...(tools ? { tools } : {}),
|
|
...(toolChoice ? { tool_choice: toolChoice } : {}),
|
|
}),
|
|
},
|
|
// 60 s ceiling — see openai-client.ts for the rationale.
|
|
// v1.11.2 — base URL is user/admin-overridable; pin the connect-time DNS
|
|
// check so a private/metadata address is rejected (SSRF/rebinding).
|
|
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
|
|
// a mid-generation abort tears the upstream call down early.
|
|
// v1.21.5 — honour the caller's per-request timeout override; default 60 s.
|
|
{
|
|
timeoutMs: params.timeoutMs ?? 60_000,
|
|
requirePublicHost: true,
|
|
signal: params.signal,
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
// Mirror the openai-client body-capture so 4xx/5xx upstream incidents
|
|
// (model-not-found, overloaded, rate limit) are diagnosable from logs
|
|
// instead of surfacing as an opaque "Anthropic request failed (5xx)".
|
|
// Strip anything that looks like an Anthropic API key from the excerpt
|
|
// before logging.
|
|
const rawBody = await res.text().catch(() => "");
|
|
const bodyExcerpt = rawBody
|
|
.slice(0, 500)
|
|
.replace(/sk-ant-[A-Za-z0-9_-]{8,}/g, "sk-ant-***redacted***")
|
|
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
|
|
const err = new Error(`Anthropic request failed (${res.status})`);
|
|
Object.assign(err, {
|
|
httpStatus: res.status,
|
|
upstream: "anthropic",
|
|
model: this.config.model,
|
|
bodyExcerpt,
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
const json = (await res.json()) as {
|
|
content?: Array<{
|
|
type: string;
|
|
text?: string;
|
|
id?: string;
|
|
name?: string;
|
|
input?: Record<string, unknown>;
|
|
}>;
|
|
stop_reason?: string;
|
|
usage?: {
|
|
input_tokens?: number;
|
|
output_tokens?: number;
|
|
cache_read_input_tokens?: number;
|
|
};
|
|
};
|
|
|
|
const textBlock = json.content?.find((c) => c.type === "text");
|
|
const rawText = textBlock?.text;
|
|
|
|
// v1.20.0 — surface tool_use blocks for F1's loop.
|
|
const toolUseBlocks = (json.content ?? []).filter(
|
|
(c) => c.type === "tool_use",
|
|
);
|
|
const toolCalls: AiToolCall[] | undefined =
|
|
toolUseBlocks.length > 0
|
|
? toolUseBlocks.map((b) => ({
|
|
id: b.id ?? "",
|
|
name: b.name ?? "",
|
|
arguments: JSON.stringify(b.input ?? {}),
|
|
}))
|
|
: undefined;
|
|
|
|
// A tool_use-only reply carries no text — that is valid (F1). Only an empty
|
|
// reply with neither text NOR a tool call is an error.
|
|
if (!rawText && !toolCalls) {
|
|
// v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell
|
|
// an empty 200-OK reply apart from a transport failure. Cascade unchanged
|
|
// (`status <= 0` is already a hard failure).
|
|
const err = new Error("Anthropic returned empty content");
|
|
Object.assign(err, {
|
|
httpStatus: 0,
|
|
kind: "empty_response",
|
|
upstream: "anthropic",
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
// When we prefilled the assistant turn with `{`, the model continues
|
|
// from there and its returned text omits that leading brace — re-prepend
|
|
// it so the caller parses a complete object. Guard against a model that
|
|
// (rarely) echoes the prefill itself.
|
|
const content =
|
|
usePrefill && rawText && !rawText.trimStart().startsWith("{")
|
|
? `{${rawText}`
|
|
: (rawText ?? "");
|
|
|
|
const inputTokens = json.usage?.input_tokens ?? 0;
|
|
const outputTokens = json.usage?.output_tokens ?? 0;
|
|
const tokensUsed = inputTokens + outputTokens || null;
|
|
const cachedInputTokens = json.usage?.cache_read_input_tokens ?? null;
|
|
|
|
// The presence of tool_use blocks is authoritative for the tool-loop gate:
|
|
// F1's loop continues only when `finishReason === "tool_calls"`. Anthropic
|
|
// normally pairs tool_use blocks with `stop_reason: "tool_use"`, but if it
|
|
// ever returns the blocks under a different (or absent) stop_reason, deriving
|
|
// the reason from `stop_reason` alone would drop the tool request and the
|
|
// loop would surface the empty tool_use-only reply as the final answer.
|
|
const finishReason: CompletionResult["finishReason"] =
|
|
toolCalls && toolCalls.length > 0
|
|
? "tool_calls"
|
|
: json.stop_reason === "tool_use"
|
|
? "tool_calls"
|
|
: json.stop_reason === "max_tokens"
|
|
? "length"
|
|
: json.stop_reason === "end_turn"
|
|
? "stop"
|
|
: undefined;
|
|
|
|
return {
|
|
content,
|
|
tokensUsed,
|
|
cachedInputTokens,
|
|
model: this.config.model,
|
|
providerType: "anthropic",
|
|
...(toolCalls ? { toolCalls } : {}),
|
|
finishReason,
|
|
};
|
|
}
|
|
}
|