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.tsBlame137 lines · 2 contributors
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
e2206a6Test User25/**
26 * Test-only: drop the cached client so the next getAnthropic() call
27 * constructs a fresh one. Needed because the SDK captures a fetch
28 * reference at construction time rather than reading globalThis.fetch
29 * fresh per call — without this, a test's `globalThis.fetch = mock`
30 * done after the first getAnthropic() call in a test run has no effect.
31 */
32export function __resetAnthropicClientForTests(): void {
33 _client = null;
34}
35
a5705cfClaude36/** Primary model for all AI features — code understanding, review, generation */
9769a35Claude37export const MODEL_SONNET = "claude-sonnet-4-6";
bc519aeClaude38/** Light-task model — never reference directly; route through modelForTask() */
3ef4c9dClaude39export const MODEL_HAIKU = "claude-haiku-4-5-20251001";
40
bc519aeClaude41/**
42 * Task → model routing.
43 *
44 * Every AI feature names its task here and asks `modelForTask()` which model
45 * to run. Routing policy is deliberately conservative:
46 *
47 * - Haiku is allowed ONLY for short, low-stakes outputs that a human
48 * always reviews and can trivially override (commit-message drafts,
49 * issue/PR triage suggestions, label suggestions). Worst-case failure
50 * is a bad suggestion someone ignores.
51 * - EVERYTHING else — anything that writes code, judges code, or produces
52 * user-facing documents — stays on Sonnet. Unknown tasks default to
53 * Sonnet too, so a typo can never silently downgrade quality.
54 *
55 * Kill-switch: set AI_FORCE_SONNET=1 to route every task to Sonnet. The env
56 * var is read at call time, so flipping it takes effect on the next request
57 * without a restart.
58 */
59export type AiTask =
60 // Haiku-eligible (short, human-reviewed, low-stakes suggestions)
61 | "commit-message"
62 | "issue-triage"
63 | "pr-triage"
64 | "label-suggest"
65 // Sonnet-only (writes or judges code, or produces user-facing docs)
66 | "code-review"
67 | "code-completion"
68 | "spec-to-pr"
69 | "ci-heal"
70 | "pr-summary"
71 | "changelog";
72
73/** Tasks allowed to run on Haiku. Additions require explicit owner sign-off. */
74const HAIKU_ALLOWLIST: ReadonlySet<AiTask> = new Set<AiTask>([
75 "commit-message",
76 "issue-triage",
77 "pr-triage",
78 "label-suggest",
79]);
80
81/**
82 * Resolve the model for a task. Allowlisted light tasks get Haiku; everything
83 * else (including unknown task strings) gets Sonnet. `AI_FORCE_SONNET=1`
84 * forces Sonnet for all tasks.
85 */
86export function modelForTask(task: AiTask): string {
87 // Read at call time so the kill-switch works without a restart.
88 if (process.env.AI_FORCE_SONNET === "1") return MODEL_SONNET;
89 return HAIKU_ALLOWLIST.has(task) ? MODEL_HAIKU : MODEL_SONNET;
90}
91
3ef4c9dClaude92/**
93 * Extract text content from an Anthropic message response.
94 */
95export function extractText(
96 message: Anthropic.Messages.Message
97): string {
98 for (const block of message.content) {
99 if (block.type === "text") return block.text;
100 }
101 return "";
102}
103
104/**
105 * Safely parse JSON from a Claude response that may include surrounding prose
106 * or a ```json code block.
107 */
108export function parseJsonResponse<T = unknown>(text: string): T | null {
109 // Prefer ```json blocks
110 const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/);
111 const candidate = fenced ? fenced[1] : null;
112 if (candidate) {
113 try {
114 return JSON.parse(candidate) as T;
115 } catch {
116 // fall through
117 }
118 }
119 // Fall back to first top-level {...} or [...]
120 const braceMatch = text.match(/\{[\s\S]*\}/);
121 if (braceMatch) {
122 try {
123 return JSON.parse(braceMatch[0]) as T;
124 } catch {
125 // fall through
126 }
127 }
128 const bracketMatch = text.match(/\[[\s\S]*\]/);
129 if (bracketMatch) {
130 try {
131 return JSON.parse(bracketMatch[0]) as T;
132 } catch {
133 return null;
134 }
135 }
136 return null;
137}