CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(ai): migrate chat/editor call sites to callModel #5628

Merged⚡ AI-generatedXSccantynz wants to mergefeat/ai-metering-migrate-chatmainopened 18h ago
9 changed files+216−166
Modifiedsrc/__tests__/ai-metering-coverage.test.ts+0−8View fileUnifiedSplit
3333const LEGACY_DIRECT_CALLERS = new Set([
3434 "lib/advancement-scanner.ts",
3535 "lib/ai-archaeology.ts",
36 "lib/ai-chat.ts",
3736 "lib/ai-ci-healer.ts",
3837 "lib/ai-commit-message.ts",
39 "lib/ai-completion.ts",
4038 "lib/ai-cost-tracker.ts",
4139 "lib/ai-doc-updater.ts",
42 "lib/ai-explain.ts",
4340 "lib/ai-generators.ts",
4441 "lib/ai-incident.ts",
45 "lib/ai-pair.ts",
4642 "lib/ai-patch-generator.ts",
4743 "lib/ai-proactive-monitor.ts",
4844 "lib/ai-release-notes.ts",
5147 "lib/ai-standup.ts",
5248 "lib/ai-test-generator.ts",
5349 "lib/ai-tests.ts",
54 "lib/ai-workspace.ts",
5550 "lib/auto-repair.ts",
5651 "lib/ci-autofix.ts",
57 "lib/claude-semantic-search.ts",
5852 "lib/codebase-migrator.ts",
5953 "lib/cross-repo-impact.ts",
6054 "lib/debt-analyzer.ts",
6559 "lib/merge-resolver.ts",
6660 "lib/migration-assistant.ts",
6761 "lib/multi-repo-refactor.ts",
68 "lib/nl-search.ts",
6962 "lib/org-health.ts",
7063 "lib/pattern-detector.ts",
7164 "lib/personal-chat.ts",
8477 "lib/streaming-review.ts",
8578 "lib/test-gaps.ts",
8679 "lib/voice-to-pr.ts",
87 "routes/ai-editor.ts",
8880]);
8981
9082function walk(dir: string, out: string[] = []): string[] {
Modifiedsrc/lib/ai-chat.ts+19−16View fileUnifiedSplit
1515 */
1616
1717import {
18 getAnthropic,
18 callModel,
1919 MODEL_SONNET,
2020 extractText,
2121 isAiAvailable,
119119 citedFiles: [],
120120 };
121121 }
122 const client = getAnthropic();
123
124122 const mentioned = extractFileMentions(userMessage);
125123 const { context: repoContext, files } = repo
126124 ? await buildRepoContext(owner, repo, mentioned)
140138 },
141139 ];
142140
143 const response = await client.messages.create({
141 const response = await callModel({
144142 model: MODEL_SONNET,
145 max_tokens: 2048,
146 system,
147 messages: messages.map((m) => ({ role: m.role, content: m.content })),
143 category: "chat",
144 request: {
145 max_tokens: 2048,
146 system,
147 messages: messages.map((m) => ({ role: m.role, content: m.content })),
148 },
148149 });
149150
150151 return {
165166 if (!isAiAvailable()) {
166167 return "AI explanations are not available — server needs ANTHROPIC_API_KEY.";
167168 }
168 const client = getAnthropic();
169 const message = await client.messages.create({
169 const message = await callModel({
170170 model: MODEL_SONNET,
171 max_tokens: 1024,
172 messages: [
173 {
174 role: "user",
175 content: `Explain this file from ${owner}/${repo} in plain English.
171 category: "chat",
172 request: {
173 max_tokens: 1024,
174 messages: [
175 {
176 role: "user",
177 content: `Explain this file from ${owner}/${repo} in plain English.
176178
177179Structure:
1781801. **Purpose** — one sentence
186188\`\`\`
187189${content.slice(0, 40000)}
188190\`\`\``,
189 },
190 ],
191 },
192 ],
193 },
191194 });
192195 return extractText(message).trim();
193196}
Modifiedsrc/lib/ai-completion.ts+18−16View fileUnifiedSplit
2020 */
2121
2222import {
23 getAnthropic,
23 callModel,
2424 MODEL_HAIKU,
2525 MODEL_SONNET,
2626 extractText,
125125 }
126126
127127 try {
128 const client = getAnthropic();
129 const response = await client.messages.create({
128 const response = await callModel({
130129 model: MODEL_SONNET,
131 max_tokens: maxTokens,
132 system:
133 "You are a code completion engine. Given a prefix and optional suffix, output ONLY the characters that should be inserted at the cursor. No explanations. No markdown fences. No commentary.",
134 messages: [
135 {
136 role: "user",
137 content:
138 `Language: ${language}\n` +
139 `Repo: ${repoHint}\n\n` +
140 `PREFIX:\n${prefix}\n\n` +
141 `SUFFIX:\n${suffix}`,
142 },
143 ],
130 category: "other",
131 request: {
132 max_tokens: maxTokens,
133 system:
134 "You are a code completion engine. Given a prefix and optional suffix, output ONLY the characters that should be inserted at the cursor. No explanations. No markdown fences. No commentary.",
135 messages: [
136 {
137 role: "user",
138 content:
139 `Language: ${language}\n` +
140 `Repo: ${repoHint}\n\n` +
141 `PREFIX:\n${prefix}\n\n` +
142 `SUFFIX:\n${suffix}`,
143 },
144 ],
145 },
144146 });
145147
146148 const raw = extractText(response);
Modifiedsrc/lib/ai-explain.ts+10−6View fileUnifiedSplit
1717import type { GitTreeEntry } from "../git/repository";
1818import {
1919 MODEL_SONNET,
20 callModel,
2021 extractText,
21 getAnthropic,
2222 isAiAvailable,
2323} from "./ai-client";
2424
115115 let result: { summary: string; markdown: string; model: string };
116116 if (isAiAvailable() && samples.files.length > 0) {
117117 try {
118 result = await callClaude(args.owner, args.repo, samples);
118 result = await callClaude(args.owner, args.repo, args.repositoryId, samples);
119119 } catch {
120120 result = buildFallbackMarkdown(args.owner, args.repo, samples);
121121 }
383383async function callClaude(
384384 owner: string,
385385 repo: string,
386 repositoryId: string,
386387 samples: Samples
387388): Promise<{ summary: string; markdown: string; model: string }> {
388 const client = getAnthropic();
389389 const treeListing = samples.topLevelTree
390390 .slice(0, 80)
391391 .map((e) => (e.type === "tree" ? `${e.name}/` : e.name))
428428Representative files:
429429${fileBlob}`;
430430
431 const message = await client.messages.create({
431 const message = await callModel({
432432 model: MODEL_SONNET,
433 max_tokens: 2048,
434 messages: [{ role: "user", content: prompt }],
433 category: "other",
434 repositoryId,
435 request: {
436 max_tokens: 2048,
437 messages: [{ role: "user", content: prompt }],
438 },
435439 });
436440
437441 const markdown = extractText(message).trim();
Modifiedsrc/lib/ai-pair.ts+43−39View fileUnifiedSplit
3232 pullRequests,
3333 repositories,
3434} from "../db/schema";
35import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
35import { callModel, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
3636import { extractSymbols, detectLanguage } from "./symbols";
3737import { refRange, treeish } from "../git/repository";
3838
521521
522522 if (!hasInterestingContext(context)) {
523523 // Pure completion path.
524 const client = getAnthropic();
525 const response = await client.messages.create({
524 const response = await callModel({
526525 model: MODEL_SONNET,
527 max_tokens: 256,
528 system:
529 "You are an expert pair programmer embedded in a code editor. " +
530 `The developer is editing ${filePath}. ` +
531 "Output ONLY the characters that should be inserted at the cursor. " +
532 "No explanations. No markdown fences.",
533 messages: [
534 {
535 role: "user",
536 content:
537 `PREFIX:\n${clippedPrefix}\n\nSUFFIX:\n${clippedSuffix}`,
538 },
539 ],
526 category: "chat",
527 request: {
528 max_tokens: 256,
529 system:
530 "You are an expert pair programmer embedded in a code editor. " +
531 `The developer is editing ${filePath}. ` +
532 "Output ONLY the characters that should be inserted at the cursor. " +
533 "No explanations. No markdown fences.",
534 messages: [
535 {
536 role: "user",
537 content:
538 `PREFIX:\n${clippedPrefix}\n\nSUFFIX:\n${clippedSuffix}`,
539 },
540 ],
541 },
540542 });
541543 const completion = extractText(response).replace(/^\s*```[A-Za-z0-9_+-]*\s*\n?/, "").replace(/\n?\s*```\s*$/, "");
542544 return {
549551 // Context-aware path — ask Claude for a structured suggestion.
550552 const contextText = formatContext(context);
551553
552 const client = getAnthropic();
553 const response = await client.messages.create({
554 const response = await callModel({
554555 model: MODEL_SONNET,
555 max_tokens: 800,
556 system:
557 "You are an expert pair programmer embedded in a code editor. " +
558 `The developer is editing ${filePath}. ` +
559 "Analyse the context provided (open PRs, CI failures, related issues, file symbols) " +
560 "and return a single JSON object with the shape: " +
561 '{ "type": "completion"|"warning"|"context_note"|"fix_available", ' +
562 '"headline": "<one line, max 120 chars>", ' +
563 '"detail": "<optional expanded explanation>", ' +
564 '"actionLabel": "<optional button label>", ' +
565 '"actionPayload": "<optional diff or URL>" }. ' +
566 "Respond with ONLY the JSON object — no prose, no markdown fences.",
567 messages: [
568 {
569 role: "user",
570 content:
571 `CONTEXT:\n${contextText}\n\n` +
572 `PREFIX (last 4000 chars):\n${clippedPrefix}\n\n` +
573 `SUFFIX (first 1000 chars):\n${clippedSuffix}`,
574 },
575 ],
556 category: "chat",
557 request: {
558 max_tokens: 800,
559 system:
560 "You are an expert pair programmer embedded in a code editor. " +
561 `The developer is editing ${filePath}. ` +
562 "Analyse the context provided (open PRs, CI failures, related issues, file symbols) " +
563 "and return a single JSON object with the shape: " +
564 '{ "type": "completion"|"warning"|"context_note"|"fix_available", ' +
565 '"headline": "<one line, max 120 chars>", ' +
566 '"detail": "<optional expanded explanation>", ' +
567 '"actionLabel": "<optional button label>", ' +
568 '"actionPayload": "<optional diff or URL>" }. ' +
569 "Respond with ONLY the JSON object — no prose, no markdown fences.",
570 messages: [
571 {
572 role: "user",
573 content:
574 `CONTEXT:\n${contextText}\n\n` +
575 `PREFIX (last 4000 chars):\n${clippedPrefix}\n\n` +
576 `SUFFIX (first 1000 chars):\n${clippedSuffix}`,
577 },
578 ],
579 },
576580 });
577581
578582 const raw = extractText(response);
Modifiedsrc/lib/ai-workspace.ts+38−16View fileUnifiedSplit
2525 pullRequests,
2626 workspaceJobs,
2727} from "../db/schema";
28import { getAnthropic, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
28import { callModel, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
2929import { applyEditsToNewBranch, type FileEdit } from "./spec-git";
3030import { getBlob } from "../git/repository";
3131import { getBotUserIdOrFallback } from "./bot-user";
216216 const repoDiskPath = join(reposBase, job.ownerName, `${job.repoName}.git`);
217217
218218 const fileTree = await getFileTree(repoDiskPath);
219 const relevantFiles = await pickRelevantFiles(issueCtx, fileTree);
219 const relevantFiles = await pickRelevantFiles(
220 issueCtx,
221 fileTree,
222 job.repoId,
223 triggeredByUserId
224 );
220225 const fileContents = await readFileContents(
221226 job.ownerName,
222227 job.repoName,
224229 );
225230
226231 // Step 3 — Generate implementation plan
227 const plan = await generatePlan(issueCtx, fileContents);
232 const plan = await generatePlan(
233 issueCtx,
234 fileContents,
235 job.repoId,
236 triggeredByUserId
237 );
228238
229239 // Post plan as issue comment
230240 const planBody = formatPlanComment(plan);
370380
371381async function pickRelevantFiles(
372382 ctx: IssueContext,
373 fileTree: string[]
383 fileTree: string[],
384 repoId: string,
385 userId: string
374386): Promise<string[]> {
375387 if (fileTree.length === 0) return [];
376388
381393 `Return JSON: {"files": string[]}`;
382394
383395 try {
384 const client = getAnthropic();
385 const msg = await client.messages.create({
396 const msg = await callModel({
386397 model: MODEL_SONNET,
387 max_tokens: 512,
388 temperature: 0,
389 messages: [{ role: "user", content: prompt }],
398 category: "other",
399 repositoryId: repoId,
400 ownerUserId: userId,
401 request: {
402 max_tokens: 512,
403 temperature: 0,
404 messages: [{ role: "user", content: prompt }],
405 },
390406 });
391407 const text = extractText(msg);
392408 const parsed = parseJsonResponse<{ files: string[] }>(text);
442458
443459async function generatePlan(
444460 ctx: IssueContext,
445 fileContents: Array<{ path: string; content: string }>
461 fileContents: Array<{ path: string; content: string }>,
462 repoId: string,
463 userId: string
446464): Promise<WorkspacePlan> {
447465 const fileSection = fileContents
448466 .map((f) => `=== ${f.path} ===\n${f.content}`)
473491 "Branch name should follow the pattern: workspace/issue-<number>-<short-slug>. " +
474492 "Return only valid JSON — no prose, no markdown fences.";
475493
476 const client = getAnthropic();
477 const msg = await client.messages.create({
494 const msg = await callModel({
478495 model: MODEL_SONNET,
479 max_tokens: 4096,
480 temperature: 0.2,
481 system: systemPrompt,
482 messages: [{ role: "user", content: userPrompt }],
496 category: "other",
497 repositoryId: repoId,
498 ownerUserId: userId,
499 request: {
500 max_tokens: 4096,
501 temperature: 0.2,
502 system: systemPrompt,
503 messages: [{ role: "user", content: userPrompt }],
504 },
483505 });
484506
485507 const text = extractText(msg);
Modifiedsrc/lib/claude-semantic-search.ts+11−8View fileUnifiedSplit
2323 * Fallback: if ANTHROPIC_API_KEY is not set, falls back to `git grep`.
2424 */
2525
26import { getAnthropic, MODEL_HAIKU, MODEL_SONNET, parseJsonResponse } from "./ai-client";
26import { callModel, MODEL_HAIKU, MODEL_SONNET, parseJsonResponse } from "./ai-client";
2727import { config } from "./config";
2828import { getRepoPath } from "../git/repository";
2929
296296
297297async function rankFilesWithClaude(
298298 query: string,
299 fileIndex: Array<{ path: string; head: string }>
299 fileIndex: Array<{ path: string; head: string }>,
300 repoId: string
300301): Promise<ClaudeRankedFile[]> {
301 const client = getAnthropic();
302
303302 // Build a compact text index, respecting the char cap.
304303 let indexText = "";
305304 for (const { path, head } of fileIndex) {
321320Only include files with confidence > 0.2. Return [] if nothing is relevant.`;
322321
323322 try {
324 const message = await client.messages.create({
323 const message = await callModel({
325324 model: MODEL_SONNET,
326 max_tokens: 1000,
327 messages: [{ role: "user", content: prompt }],
325 category: "other",
326 repositoryId: repoId,
327 request: {
328 max_tokens: 1000,
329 messages: [{ role: "user", content: prompt }],
330 },
328331 });
329332
330333 const text =
499502 }
500503
501504 // Step 4: ask Claude to rank files
502 const ranked = await rankFilesWithClaude(q, fileIndex);
505 const ranked = await rankFilesWithClaude(q, fileIndex, repoId);
503506
504507 if (ranked.length === 0) {
505508 // Claude found nothing — try keyword fallback
Modifiedsrc/lib/nl-search.ts+35−26View fileUnifiedSplit
2020 * 5. Cache results in-memory per `${repoId}:${query}` for 15 minutes.
2121 */
2222
23import { getAnthropic, MODEL_SONNET, isAiAvailable, parseJsonResponse } from "./ai-client";
23import { callModel, MODEL_SONNET, isAiAvailable, parseJsonResponse } from "./ai-client";
2424import { getRepoPath } from "../git/repository";
2525import { db } from "../db";
2626import { codeChunks } from "../db/schema";
205205 fileTypes: string[];
206206}
207207
208async function extractKeywords(query: string): Promise<KeywordExtraction> {
209 const client = getAnthropic();
208async function extractKeywords(
209 query: string,
210 repoId: string
211): Promise<KeywordExtraction> {
210212 try {
211 const msg = await client.messages.create({
213 const msg = await callModel({
212214 model: MODEL_SONNET,
213 max_tokens: 256,
214 messages: [
215 {
216 role: "user",
217 content:
218 `Extract 3-5 grep-friendly keywords from this natural language search query. ` +
219 `Return JSON only, no prose: {"keywords": string[], "fileTypes": string[]}\n` +
220 `Keywords should be short, concrete identifiers/patterns likely to appear in code. ` +
221 `fileTypes is an optional list of file extensions (e.g. [".ts", ".tsx"]) to narrow the search. ` +
222 `Return [] for fileTypes if the query is language-agnostic.\n\n` +
223 `Query: ${query}`,
224 },
225 ],
215 category: "other",
216 repositoryId: repoId,
217 request: {
218 max_tokens: 256,
219 messages: [
220 {
221 role: "user",
222 content:
223 `Extract 3-5 grep-friendly keywords from this natural language search query. ` +
224 `Return JSON only, no prose: {"keywords": string[], "fileTypes": string[]}\n` +
225 `Keywords should be short, concrete identifiers/patterns likely to appear in code. ` +
226 `fileTypes is an optional list of file extensions (e.g. [".ts", ".tsx"]) to narrow the search. ` +
227 `Return [] for fileTypes if the query is language-agnostic.\n\n` +
228 `Query: ${query}`,
229 },
230 ],
231 },
226232 });
227233 const text = msg.content.find((b) => b.type === "text")?.text ?? "";
228234 const parsed = parseJsonResponse<KeywordExtraction>(text);
374380
375381async function reasonWithClaude(
376382 query: string,
377 contextStr: string
383 contextStr: string,
384 repoId: string
378385): Promise<NlSearchResult[]> {
379 const client = getAnthropic();
380
381386 const systemPrompt =
382387 `You are a code analysis expert. Find all places in the provided code that match the user's query. ` +
383388 `Be precise about file paths and line numbers. Only return matches that genuinely satisfy the query — ` +
400405 `Return {"results": []} if nothing matches. Sort by confidence descending. Max 10 results.`;
401406
402407 try {
403 const msg = await client.messages.create({
408 const msg = await callModel({
404409 model: MODEL_SONNET,
405 max_tokens: 3000,
406 system: systemPrompt,
407 messages: [{ role: "user", content: userPrompt }],
410 category: "other",
411 repositoryId: repoId,
412 request: {
413 max_tokens: 3000,
414 system: systemPrompt,
415 messages: [{ role: "user", content: userPrompt }],
416 },
408417 });
409418
410419 const text = msg.content.find((b) => b.type === "text")?.text ?? "";
473482
474483 try {
475484 // Step 1 — Extract keywords
476 const extraction = await extractKeywords(q);
485 const extraction = await extractKeywords(q, repoId);
477486
478487 // Step 2 — Gather candidate files
479488 const candidates = await gatherCandidates(
496505 }
497506
498507 // Step 4 — Claude reasoning pass
499 const results = await reasonWithClaude(q, contextStr);
508 const results = await reasonWithClaude(q, contextStr, repoId);
500509
501510 const response: NlSearchResponse = {
502511 query: q,
Modifiedsrc/routes/ai-editor.ts+42−31View fileUnifiedSplit
1212
1313import { Hono } from "hono";
1414import { requireAuth } from "../middleware/auth";
15import { isAiAvailable, getAnthropic, MODEL_HAIKU, MODEL_SONNET, extractText } from "../lib/ai-client";
15import { isAiAvailable, callModel, MODEL_HAIKU, MODEL_SONNET, extractText } from "../lib/ai-client";
1616import type { AuthEnv } from "../middleware/auth";
1717
1818const aiEditor = new Hono<AuthEnv>();
8888 "Complete the code at the cursor position. Return ONLY the completion text to insert, no explanation, no markdown, no code fences.";
8989
9090 try {
91 const anthropic = getAnthropic();
92 const message = await anthropic.messages.create({
91 const message = await callModel({
9392 model: MODEL_SONNET,
94 max_tokens: 256,
95 system:
96 "You are a code completion AI. Complete the code snippet at the cursor. " +
97 "Return ONLY the completion text — no explanation, no markdown, no code fences. " +
98 "Keep completions concise (prefer single-line or a few lines). " +
99 "If you have nothing useful to add, return an empty string.",
100 messages: [{ role: "user", content: prompt }],
93 category: "other",
94 ownerUserId: user.id,
95 request: {
96 max_tokens: 256,
97 system:
98 "You are a code completion AI. Complete the code snippet at the cursor. " +
99 "Return ONLY the completion text — no explanation, no markdown, no code fences. " +
100 "Keep completions concise (prefer single-line or a few lines). " +
101 "If you have nothing useful to add, return an empty string.",
102 messages: [{ role: "user", content: prompt }],
103 },
101104 });
102105
103106 const suggestion = extractText(message).trimEnd();
132135 }
133136
134137 try {
135 const anthropic = getAnthropic();
136 const message = await anthropic.messages.create({
138 const user = c.get("user")!;
139 const message = await callModel({
137140 model: MODEL_SONNET,
138 max_tokens: 512,
139 system:
140 "You are a senior engineer explaining code to a fellow developer. " +
141 "Be concise and precise. Use plain text (no markdown headers). " +
142 "One or two short paragraphs maximum.",
143 messages: [
144 {
145 role: "user",
146 content: `Language: ${language}\n\nExplain this code:\n\`\`\`\n${code}\n\`\`\``,
147 },
148 ],
141 category: "other",
142 ownerUserId: user.id,
143 request: {
144 max_tokens: 512,
145 system:
146 "You are a senior engineer explaining code to a fellow developer. " +
147 "Be concise and precise. Use plain text (no markdown headers). " +
148 "One or two short paragraphs maximum.",
149 messages: [
150 {
151 role: "user",
152 content: `Language: ${language}\n\nExplain this code:\n\`\`\`\n${code}\n\`\`\``,
153 },
154 ],
155 },
149156 });
150157
151158 const explanation = extractText(message).trim();
189196 '"explanation" (one short sentence describing what was wrong).';
190197
191198 try {
192 const anthropic = getAnthropic();
193 const message = await anthropic.messages.create({
199 const user = c.get("user")!;
200 const message = await callModel({
194201 model: MODEL_SONNET,
195 max_tokens: 1024,
196 system:
197 "You are an expert code fixer. Given an error and code, return valid JSON with " +
198 '"fix" (corrected code, no markdown fences) and "explanation" (one-sentence reason). ' +
199 "Return ONLY the JSON object, nothing else.",
200 messages: [{ role: "user", content: prompt }],
202 category: "other",
203 ownerUserId: user.id,
204 request: {
205 max_tokens: 1024,
206 system:
207 "You are an expert code fixer. Given an error and code, return valid JSON with " +
208 '"fix" (corrected code, no markdown fences) and "explanation" (one-sentence reason). ' +
209 "Return ONLY the JSON object, nothing else.",
210 messages: [{ role: "user", content: prompt }],
211 },
201212 });
202213
203214 const raw = extractText(message).trim();
204215
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts