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