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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
ai-client.ts4.2 KB · 137 lines
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;
}