Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ai-client.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

ai-client.tsBlame126 lines · 1 contributor
3ef4c9dClaude1/**
2 * Shared Anthropic client + helpers for all AI-powered features.
3 * Centralised here so model choice, caching, and key handling live in one place.
4 */
5
6import Anthropic from "@anthropic-ai/sdk";
7import { config } from "./config";
8
9let _client: Anthropic | null = null;
10
11export function getAnthropic(): Anthropic {
12 if (!_client) {
13 if (!config.anthropicApiKey) {
14 throw new Error("ANTHROPIC_API_KEY is not set");
15 }
16 _client = new Anthropic({ apiKey: config.anthropicApiKey });
17 }
18 return _client;
19}
20
21export function isAiAvailable(): boolean {
22 return !!config.anthropicApiKey;
23}
24
a5705cfClaude25/** Primary model for all AI features — code understanding, review, generation */
9769a35Claude26export const MODEL_SONNET = "claude-sonnet-4-6";
bc519aeClaude27/** Light-task model — never reference directly; route through modelForTask() */
3ef4c9dClaude28export const MODEL_HAIKU = "claude-haiku-4-5-20251001";
29
bc519aeClaude30/**
31 * Task → model routing.
32 *
33 * Every AI feature names its task here and asks `modelForTask()` which model
34 * to run. Routing policy is deliberately conservative:
35 *
36 * - Haiku is allowed ONLY for short, low-stakes outputs that a human
37 * always reviews and can trivially override (commit-message drafts,
38 * issue/PR triage suggestions, label suggestions). Worst-case failure
39 * is a bad suggestion someone ignores.
40 * - EVERYTHING else — anything that writes code, judges code, or produces
41 * user-facing documents — stays on Sonnet. Unknown tasks default to
42 * Sonnet too, so a typo can never silently downgrade quality.
43 *
44 * Kill-switch: set AI_FORCE_SONNET=1 to route every task to Sonnet. The env
45 * var is read at call time, so flipping it takes effect on the next request
46 * without a restart.
47 */
48export type AiTask =
49 // Haiku-eligible (short, human-reviewed, low-stakes suggestions)
50 | "commit-message"
51 | "issue-triage"
52 | "pr-triage"
53 | "label-suggest"
54 // Sonnet-only (writes or judges code, or produces user-facing docs)
55 | "code-review"
56 | "code-completion"
57 | "spec-to-pr"
58 | "ci-heal"
59 | "pr-summary"
60 | "changelog";
61
62/** Tasks allowed to run on Haiku. Additions require explicit owner sign-off. */
63const HAIKU_ALLOWLIST: ReadonlySet<AiTask> = new Set<AiTask>([
64 "commit-message",
65 "issue-triage",
66 "pr-triage",
67 "label-suggest",
68]);
69
70/**
71 * Resolve the model for a task. Allowlisted light tasks get Haiku; everything
72 * else (including unknown task strings) gets Sonnet. `AI_FORCE_SONNET=1`
73 * forces Sonnet for all tasks.
74 */
75export function modelForTask(task: AiTask): string {
76 // Read at call time so the kill-switch works without a restart.
77 if (process.env.AI_FORCE_SONNET === "1") return MODEL_SONNET;
78 return HAIKU_ALLOWLIST.has(task) ? MODEL_HAIKU : MODEL_SONNET;
79}
80
3ef4c9dClaude81/**
82 * Extract text content from an Anthropic message response.
83 */
84export function extractText(
85 message: Anthropic.Messages.Message
86): string {
87 for (const block of message.content) {
88 if (block.type === "text") return block.text;
89 }
90 return "";
91}
92
93/**
94 * Safely parse JSON from a Claude response that may include surrounding prose
95 * or a ```json code block.
96 */
97export function parseJsonResponse<T = unknown>(text: string): T | null {
98 // Prefer ```json blocks
99 const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/);
100 const candidate = fenced ? fenced[1] : null;
101 if (candidate) {
102 try {
103 return JSON.parse(candidate) as T;
104 } catch {
105 // fall through
106 }
107 }
108 // Fall back to first top-level {...} or [...]
109 const braceMatch = text.match(/\{[\s\S]*\}/);
110 if (braceMatch) {
111 try {
112 return JSON.parse(braceMatch[0]) as T;
113 } catch {
114 // fall through
115 }
116 }
117 const bracketMatch = text.match(/\[[\s\S]*\]/);
118 if (bracketMatch) {
119 try {
120 return JSON.parse(bracketMatch[0]) as T;
121 } catch {
122 return null;
123 }
124 }
125 return null;
126}