Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion __tests__/ai-chat-rules.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Unit tests for the rule-based AI response path in lib/domain/ai-chat.ts.
* This is the fallback used when ANTHROPIC_API_KEY is not set.
* This is the fallback used when no chain provider is configured.
* ruleBasedResponse is a pure function: no DB, no network.
*/
import { describe, it, expect, vi } from "vitest";
Expand Down
2 changes: 1 addition & 1 deletion __tests__/ai-forms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const field = (name: string) => {

const writable = CHECK_IN_FORM.fields.filter((f) => !f.aiExcluded).map((f) => f.name);

/** Stands in for the route's `complete` on a deployment with no ANTHROPIC_API_KEY. */
/** Stands in for the route's `complete` on a deployment with no chain provider configured. */
const keylessComplete = (instruction: string, intent: "fill" | "refine") => async () =>
JSON.stringify({ values: keywordFallback(instruction, intent), message: "" });

Expand Down
2 changes: 1 addition & 1 deletion __tests__/check-in-parse.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Unit tests for the keyword-based check-in parser (lib/domain/check-in-parse.ts).
* keywordParse is pure: no DB, no network, no mocks needed.
* This is the fallback path used when ANTHROPIC_API_KEY is not set.
* This is the fallback path used when no chain provider is configured.
*/
import { describe, it, expect } from "vitest";
import { keywordParse } from "@/lib/domain/check-in-parse";
Expand Down
8 changes: 6 additions & 2 deletions __tests__/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,15 @@ beforeEach(() => {
});

describe("GET /api/health", () => {
it("returns 200 with { success: true } when the database answers", async () => {
it("returns 200 with { success: true } and the LLM chain's health when the database answers", async () => {
execute.mockResolvedValueOnce([{ "?column?": 1 }]);
const res = await GET();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ success: true });
const body = await res.json();
expect(body.success).toBe(true);
// Informational only — asserting shape, not a specific status, since
// this must never gate the 200/503 the deploy pipeline checks.
expect(body.llm).toMatchObject({ status: expect.any(String) });
});

it("returns 503 with { success: false } when the database is unreachable", async () => {
Expand Down
6 changes: 3 additions & 3 deletions app/api/admin/clients/[id]/session-prep/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Pre-session AI prep: generates a clinical summary for a practitioner
* before a session with a specific client.
* Uses Anthropic API directly (no SDK). Gracefully degrades when key is absent.
* Uses the fleet's ai-kit provider chain (Groq -> OpenRouter). Gracefully degrades when no chain provider is configured.
*/
import { formatEnumValue, roundOne } from "@/lib/utils";
import { db } from "@/lib/db";
Expand All @@ -18,7 +18,7 @@ import {
} from "@/lib/db/schema";
import { eq, desc, and, gte, isNull } from "drizzle-orm";
import type { ProgramPhase } from "@/lib/domain/program";
import { callClaude } from "@/lib/domain/anthropic";
import { callLLM } from "@/lib/domain/llm";
import { localDateString, addDaysISO } from "@/lib/utils";
import { computeDailyAdherenceTrend } from "@/lib/domain/techniques";
import { computeCurrentProgramWeek } from "@/lib/domain/check-in";
Expand Down Expand Up @@ -140,7 +140,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
activeEnrollment ?? null,
);

const aiSummary = await callClaude({
const aiSummary = await callLLM({
messages: [
{
role: "user",
Expand Down
11 changes: 6 additions & 5 deletions app/api/ai/form-assist/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
*
* Why this is hand-written instead of `createFormAssistHandler`: the package's
* handler owns the request, and this app needs the raw instruction inside
* `complete` so it can answer from the keyword parser when there is no
* ANTHROPIC_API_KEY. Owning the request here is what keeps the check-in form
* working on a deployment with no model at all — the property the previous
* `complete` so it can answer from the keyword parser when no chain provider
* (GROQ_API_KEY / OPENROUTER_API_KEY) is configured. Owning the request here
* is what keeps the check-in form working on a deployment with no model at
* all — the property the previous
* /api/check-in/parse route had and which a straight port would have deleted.
*/

Expand All @@ -23,7 +24,7 @@ import { runFormAssist, type CompleteFn } from "@fleet/ai-forms";
import { requireAuth } from "@/lib/api";
import { AI_FORMS } from "@/lib/config/ai-forms";
import { API_ERR_INVALID_INPUT, FIELD_MAX_MEDIUM } from "@/lib/constants";
import { callClaude } from "@/lib/domain/anthropic";
import { callLLM } from "@/lib/domain/llm";
import { keywordFallback } from "@/lib/domain/check-in-parse";

const requestSchema = z.object({
Expand Down Expand Up @@ -57,7 +58,7 @@ export async function POST(req: Request) {
}

const complete: CompleteFn = async ({ system, prompt, maxTokens }) => {
const text = await callClaude({
const text = await callLLM({
messages: [{ role: "user", content: prompt }],
system,
maxTokens,
Expand Down
2 changes: 1 addition & 1 deletion app/api/cron/ai-digest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Runs every Sunday at 19:00 CET (after weekly-report at 17:00).
* For each client with ≥3 check-ins in the past 7 days, generates an AI
* narrative summary and stores it in the most recent check-in's aiInsight field.
* Gracefully degrades when ANTHROPIC_API_KEY is absent.
* Gracefully degrades when no chain provider (GROQ_API_KEY / OPENROUTER_API_KEY) is configured.
*/
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
Expand Down
7 changes: 6 additions & 1 deletion app/api/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { getLLMHealth } from "@/lib/domain/llm";

// Public health check (fleet convention): 200 = the app is up AND its database
// answers. Deploy monitoring curls this after every push — a status code is the
// contract, the body is for humans. The body never exposes internals; the
// detail goes to the logs, where the person fixing it is already looking.
//
// `llm` is informational only — it never flips the status code. A dead
// provider key can't be fixed by a restart, so it must never fail the check
// that triggers one. @see ai-kit's createHealthTracker, adopted fleet-wide.
export async function GET() {
try {
await db.execute(sql`select 1`);
Expand All @@ -16,5 +21,5 @@ export async function GET() {
return NextResponse.json({ success: false, error: "database unreachable" }, { status: 503 });
}

return NextResponse.json({ success: true });
return NextResponse.json({ success: true, llm: getLLMHealth() });
}
3 changes: 1 addition & 2 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@ export const COMPANY_ADDRESS = "Surf Your Life · Zollikerstrasse 183, 8008 Zür
// "today" matches what the user sees on their wall clock, not UTC.
export const CLINIC_TZ = "Europe/Zurich";

// AI model identifiers — update here when upgrading models
export const AI_MODEL_FAST = "claude-haiku-4-5-20251001";
// Embedding model — chat models come from ai-kit's chain, not from here.
export const EMBEDDING_MODEL = "text-embedding-3-small";
export const OPENAI_EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings";

Expand Down
18 changes: 9 additions & 9 deletions lib/domain/ai-chat.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* AI chat logic for the client portal.
*
* When ANTHROPIC_API_KEY is set: calls Claude with check-in context.
* When not set: returns a rule-based response grounded in the client's actual data.
* When GROQ_API_KEY or OPENROUTER_API_KEY is set: calls the chain with
* check-in context. When neither is set: returns a rule-based response
* grounded in the client's actual data.
*
* To enable AI: set ANTHROPIC_API_KEY in your environment.
* The generateAiReply function handles both paths — no other changes needed.
*/

Expand All @@ -29,7 +29,7 @@ import {
} from "@/lib/constants";
import { summariseCheckIns, computeCurrentProgramWeek } from "@/lib/domain/check-in";
export { summariseCheckIns, type CheckInSummaryRow } from "@/lib/domain/check-in";
import { callClaude } from "@/lib/domain/anthropic";
import { callLLM } from "@/lib/domain/llm";
import { semanticCheckInSearch } from "@/lib/domain/embeddings";
import { localDateString, addDaysISO } from "@/lib/utils";
import { computeDailyAdherenceTrend } from "@/lib/domain/techniques";
Expand Down Expand Up @@ -472,9 +472,9 @@ export function ruleBasedResponse(
return `Here's a quick snapshot: over your last ${stats.count} check-ins, your average energy is ${stats.avgEnergy.toFixed(1)}/10${sleepSummary}, and you've had ${stats.pemCount} PEM episode${stats.pemCount === 1 ? "" : "s"}. Ask me about any of these in more detail. ${dataNote}`;
}

// ─── AI call (to be enabled when ANTHROPIC_API_KEY is set) ────────────────────
// ─── AI call (no-op when no chain provider is configured) ─────────────────────

async function callAnthropicApi(
async function callChainChat(
userMessage: string,
context: BuildContextResult,
history: { role: "user" | "assistant"; content: string }[],
Expand Down Expand Up @@ -548,7 +548,7 @@ ${techniqueLines}
Current program:
${programLine}`;

return callClaude({
return callLLM({
messages: [...history.slice(-AI_CHAT_CONTEXT_WINDOW), { role: "user", content: userMessage }],
system: systemPrompt,
maxTokens: 500,
Expand All @@ -572,8 +572,8 @@ export async function generateAiReply(
activeEnrollment,
} = context;

// Try AI first (no-op until ANTHROPIC_API_KEY is set)
const aiReply = await callAnthropicApi(userMessage, context, history);
// Try AI first (no-op if no chain provider is configured)
const aiReply = await callChainChat(userMessage, context, history);
if (aiReply) return aiReply;

// Fall back to rule-based response grounded in actual data
Expand Down
57 changes: 0 additions & 57 deletions lib/domain/anthropic.ts

This file was deleted.

4 changes: 2 additions & 2 deletions lib/domain/check-in-parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* Parse a free-text description of a day into structured check-in fields,
* using keyword heuristics and no network call at all.
*
* This is what /api/ai/form-assist answers with when ANTHROPIC_API_KEY is not
* set, so the check-in form still fills itself in on a deployment with no
* This is what /api/ai/form-assist answers with when no chain provider is
* configured, so the check-in form still fills itself in on a deployment with no
* model. The AI path lives in lib/config/ai-forms.ts — the field list there is
* the only description of these fields the model ever sees, which is why there
* is no hand-written extraction prompt duplicating the list below.
Expand Down
4 changes: 2 additions & 2 deletions lib/domain/digest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { callClaude } from "@/lib/domain/anthropic";
import { callLLM } from "@/lib/domain/llm";

type CheckInRow = {
createdAt: Date;
Expand Down Expand Up @@ -63,5 +63,5 @@ Write a concise clinical narrative (3-5 sentences) summarising:

Be factual, empathetic, and clinically precise. No bullet points — flowing prose only.`;

return callClaude({ messages: [{ role: "user", content: prompt }], maxTokens: 300 });
return callLLM({ messages: [{ role: "user", content: prompt }], maxTokens: 300 });
}
93 changes: 93 additions & 0 deletions lib/domain/llm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Chat completion via the fleet's shared AI provider chain (Groq -> OpenRouter).
*
* This used to be a bespoke Anthropic-only client, the one hand-rolled
* provider in a fleet where everything else installs `ai-kit`. It needed its
* own `ANTHROPIC_API_KEY` — a credential this fleet doesn't otherwise carry —
* so every caller degraded (silently, by design) on every deployment that
* only ever had GROQ_API_KEY / OPENROUTER_API_KEY set, which is all of them.
*
* Same graceful-degrade contract as before: returns null on any failure
* (no key configured, every vendor refused) rather than throwing, because
* every caller here already treats a null response as "fall back to the
* non-AI path," not as an error to surface.
*/
import { freeChain, usableChain, chainFrom, tryChain, createHealthTracker } from "ai-kit";

const health = createHealthTracker({ downAfter: 3 });

export function getLLMHealth() {
return health.getHealth();
}

type Message = { role: "user" | "assistant"; content: string };

type CallOptions = {
messages: Message[];
system?: string;
maxTokens?: number;
/** Vendor-specific model id. Ignored — model choice comes from the chain. */
model?: string;
};

/**
* Call the chain and return the text of the first choice, or null on
* failure (no provider configured, or every provider in the chain refused).
*/
export async function callLLM({
messages,
system,
maxTokens = 500,
}: CallOptions): Promise<string | null> {
const links = usableChain(freeChain("SURF"), process.env);
if (links.length === 0) return null;

// One link per vendor — a second model at the same vendor draws on the
// same daily budget, so it is not a real fallback. @see ai-kit chain.ts
const seen = new Set<string>();
const chain = links.filter((link) => {
if (seen.has(link.provider.id)) return false;
seen.add(link.provider.id);
return true;
});

try {
return await tryChain(chain, {
health,
attempt: async (link) => {
const [resolved] = chainFrom(undefined, [link]);
const res = await fetch(`${link.provider.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env[link.provider.keyEnv] ?? ""}`,
},
body: JSON.stringify({
model: resolved?.model ?? link.model,
max_tokens: maxTokens,
messages: system ? [{ role: "system", content: system }, ...messages] : messages,
}),
});

if (!res.ok) {
// Body goes on the thrown error for the log only — never returned.
const detail = await res.text().catch(() => "");
throw new Error(
`${link.provider.id} chat failed (${res.status}): ${detail.slice(0, 200)}`,
);
}

const body = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const text = body.choices?.[0]?.message?.content;
if (!text) throw new Error(`${link.provider.id} returned no content`);
return text;
},
});
} catch {
// ChainExhaustedError or an empty chain — every caller treats null as
// "fall back," matching the contract this replaces.
return null;
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@auth/drizzle-adapter": "^1.11.1",
"@fleet/ai-forms": "github:bitbaum/ai-forms#v0.1.0",
"@sentry/nextjs": "^10.66.0",
"ai-kit": "github:bitbaum/ai-kit#v0.6.2",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
Loading