Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 28 additions & 28 deletions src/routes/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,33 +79,23 @@ export async function botRoutes(app: FastifyInstance) {
if (apiLine) snippet = apiLine.trim();

if (diffResult.deltaCents !== 0 || diffResult.addedServices.length > 0 || diffResult.removedServices.length > 0) {
// Pick the most informative snippet: prefer AST-extracted snippets, then fall back to line scan
const astSnippet = diffResult.headLines[0]?.snippet || apiLine?.trim() || 'await client.chat.completions.create(...)';

fileReports.push({
filename: file.filename,
deltaCents: diffResult.deltaCents,
added: diffResult.addedServices,
removed: diffResult.removedServices,
snippet: snippet
models: diffResult.headLines, // real per-detection service+model data
snippet: astSnippet,
});
handler += 1;
}
}

// --- HACKATHON DEMO SAFETY NET ---
// If the user's PR didn't actually contain any OpenAI/AWS code, we inject a demo payload
// so that their live presentation to the judges still looks amazing.
if (totalHeadCost === 0 && fileReports.length === 0) {
inLoop = 1;
handler = 1;
totalBaseCost = 0;
totalHeadCost = 75000;
fileReports.push({
filename: files[0]?.filename || 'src/app.js',
deltaCents: 75000,
added: ['openai'],
removed: [],
snippet: 'await openai.chat.completions.create({ model: "gpt-4o", messages })'
});
}
// If no cloud cost patterns were detected, report a clean bill of health
// (no fake fallback — real results only)

const totalDelta = totalHeadCost - totalBaseCost;
const formatDollar = (cents: number) => `$${(Math.abs(cents) / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
Expand Down Expand Up @@ -144,17 +134,27 @@ export async function botRoutes(app: FastifyInstance) {
markdown += `|:---|:---|---:|\n`;
for (const report of fileReports) {
const sgn = report.deltaCents > 0 ? '+' : report.deltaCents < 0 ? '-' : '';
const changes = [];
if (report.added.length) changes.push(`+ ${report.added.join(', ')}`);
if (report.removed.length) changes.push(`- ${report.removed.join(', ')}`);

let serviceName = changes.join(', ');
let snippet = report.snippet || 'await client.chat.completions.create(...)';

if (serviceName.includes('openai') || report.added.includes('openai')) {
serviceName = '**OpenAI** `gpt-4o`';
} else if (serviceName.includes('aws') || report.added.includes('aws')) {
serviceName = '**AWS** `lambda`';
const snippet = report.snippet || 'await client.chat.completions.create(...)';

// Build a precise service label from the actual detected services + models
let serviceName: string;
if (report.models && report.models.length > 0) {
// Use actual model names returned by the AST diff engine
serviceName = report.models
.map((m: { service: string; model?: string }) =>
m.model ? `**${m.service}** \`${m.model}\`` : `**${m.service}**`
)
.join(', ');
} else if (report.added.includes('openai')) {
serviceName = '**OpenAI**';
} else if (report.added.includes('anthropic')) {
serviceName = '**Anthropic**';
} else if (report.added.includes('aws-lambda') || report.added.includes('aws')) {
serviceName = '**AWS Lambda**';
} else if (report.added.includes('s3')) {
serviceName = '**AWS S3**';
} else {
serviceName = report.added.length ? `**${report.added.join(', ')}**` : '**Unknown**';
}

markdown += `| ${serviceName} | \`${snippet}\` | **${sgn}${formatDollar(report.deltaCents)}/mo** |\n`;
Expand Down
62 changes: 45 additions & 17 deletions src/services/analyzer/detectors/anthropic.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,52 @@
import type { DetectionMatch, DetectorFn } from '../types.js';

const MODEL_ALIASES: Record<string, string> = {
'claude-3-5-sonnet': 'claude-3-5-sonnet',
'claude-3-opus': 'claude-3-opus',
'claude-3-sonnet': 'claude-3-sonnet',
'claude-3-haiku': 'claude-3-haiku',
'claude-2': 'claude-2',
'claude-instant': 'claude-instant',
// Short names
'claude-3-5-sonnet': 'claude-3-5-sonnet',
'claude-3-opus': 'claude-3-opus',
'claude-3-sonnet': 'claude-3-sonnet',
'claude-3-haiku': 'claude-3-haiku',
'claude-2': 'claude-2',
'claude-instant': 'claude-instant',
// Full versioned names
'claude-3-opus-20240229': 'claude-3-opus',
'claude-3-sonnet-20240229': 'claude-3-sonnet',
'claude-3-haiku-20240307': 'claude-3-haiku',
'claude-3-5-sonnet-20240620':'claude-3-5-sonnet',
};

/** Resolve raw model string (full versioned or short) to a canonical alias. */
function resolveAnthropicModel(raw: string | null): string {
if (!raw) return 'claude-3-5-sonnet';
const lower = raw.toLowerCase();
if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower];
// Partial match — prefer longest key that is a substring of raw
const match = Object.keys(MODEL_ALIASES)
.filter(k => lower.includes(k))
.sort((a, b) => b.length - a.length)[0];
return match ? MODEL_ALIASES[match] : 'claude-3-5-sonnet';
}

/** Returns true if this AST node is nested inside a for/while/forEach loop. */
function isInsideLoop(_node: any, ancestors: any[]): boolean {
return ancestors.some(a =>
a.type === 'ForStatement' ||
a.type === 'ForInStatement' ||
a.type === 'ForOfStatement' ||
a.type === 'WhileStatement' ||
a.type === 'DoWhileStatement' ||
(a.type === 'CallExpression' && /forEach|map|reduce|filter/.test(a.callee?.property?.name ?? '')),
);
}

/**
* Detects Anthropic SDK calls in ASTs.
* Matches: anthropic.messages.create / client.messages.create / anthropic.complete
*/
export const anthropicDetector: DetectorFn = (ast, code) => {
const matches: DetectionMatch[] = [];

function walk(node: any): void {
function walk(node: any, ancestors: any[] = []): void {
if (
node.type === 'CallExpression' &&
node.callee?.type === 'MemberExpression' &&
Expand All @@ -29,32 +59,30 @@ export const anthropicDetector: DetectorFn = (ast, code) => {
/anthropic\.(complete|createMessage)/i.test(callStr);

if (isAnthropic) {
const model = extractStringArg(node.arguments, 'model');
const maxTok = extractNumberArg(node.arguments, 'max_tokens') ?? 1024;
const rawModel = model?.toLowerCase() ?? '';
const resolvedModel =
Object.keys(MODEL_ALIASES).find(k => rawModel.includes(k)) ??
'claude-3-5-sonnet';
const model = extractStringArg(node.arguments, 'model');
const maxTok = extractNumberArg(node.arguments, 'max_tokens') ?? 1_024;
const inLoop = isInsideLoop(node, ancestors);

matches.push({
service: 'anthropic',
model: resolvedModel,
model: resolveAnthropicModel(model),
operation: 'messages.create',
inputTokens: 500,
outputTokens: maxTok,
callsPerMonth: 10_000,
callsPerMonth: inLoop ? 50_000 : 10_000,
line: node.loc.start.line,
column: node.loc.start.column,
snippet: callStr.slice(0, 80),
});
}
}

const nextAncestors = [...ancestors, node];
for (const key of Object.keys(node)) {
const child = (node as any)[key];
if (child && typeof child === 'object') {
if (Array.isArray(child)) child.forEach((c: any) => c?.type && walk(c));
else if (child.type) walk(child);
if (Array.isArray(child)) child.forEach((c: any) => c?.type && walk(c, nextAncestors));
else if (child.type) walk(child, nextAncestors);
}
}
}
Expand Down
59 changes: 44 additions & 15 deletions src/services/analyzer/detectors/openai.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,49 @@
import type { DetectionMatch, DetectorFn } from '../types.js';

const MODEL_ALIASES: Record<string, string> = {
'gpt-4o': 'gpt-4o',
'gpt-4o-mini': 'gpt-4o-mini',
'gpt-4-turbo': 'gpt-4-turbo',
'gpt-4': 'gpt-4',
'gpt-3.5-turbo': 'gpt-3.5-turbo',
'text-embedding-3-small': 'text-embedding-3-small',
'text-embedding-3-large': 'text-embedding-3-large',
'gpt-4o': 'gpt-4o',
'gpt-4o-mini': 'gpt-4o-mini',
'gpt-4-turbo': 'gpt-4-turbo',
'gpt-4-vision-preview': 'gpt-4o', // vision maps to gpt-4o pricing
'gpt-4': 'gpt-4',
'gpt-3.5-turbo': 'gpt-3.5-turbo',
'text-embedding-3-small': 'text-embedding-3-small',
'text-embedding-3-large': 'text-embedding-3-large',
};

/** Resolve a raw model string (possibly versioned, e.g. "gpt-4-vision-preview") to a canonical alias key. */
function resolveOpenAIModel(raw: string | null): string {
if (!raw) return 'gpt-4o';
const lower = raw.toLowerCase();
// Exact match first
if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower];
// Partial prefix match
for (const key of Object.keys(MODEL_ALIASES)) {
if (lower.startsWith(key) || lower.includes(key)) return MODEL_ALIASES[key];
}
return 'gpt-4o';
}

/** Returns true if this AST node is nested inside a for/while/forEach loop. */
function isInsideLoop(node: any, ancestors: any[]): boolean {
return ancestors.some(a =>
a.type === 'ForStatement' ||
a.type === 'ForInStatement' ||
a.type === 'ForOfStatement' ||
a.type === 'WhileStatement' ||
a.type === 'DoWhileStatement' ||
(a.type === 'CallExpression' && /forEach|map|reduce|filter/.test(a.callee?.property?.name ?? '')),
);
}

/**
* Detects OpenAI API calls in ASTs.
* Matches: openai.chat.completions.create / client.chat.completions / openai.completions
*/
export const openaiDetector: DetectorFn = (ast, code) => {
const matches: DetectionMatch[] = [];

function walk(node: any): void {
function walk(node: any, ancestors: any[] = []): void {
if (
node.type === 'CallExpression' &&
node.callee?.type === 'MemberExpression' &&
Expand All @@ -30,29 +56,32 @@ export const openaiDetector: DetectorFn = (ast, code) => {
/openai\.createChatCompletion/i.test(callStr);

if (isOpenAI) {
const model = extractStringArg(node.arguments, 'model');
const maxTok = extractNumberArg(node.arguments, 'max_tokens') ?? 1000;
const isEmbed = /embeddings/i.test(callStr);
const rawModel = extractStringArg(node.arguments, 'model');
const maxTok = extractNumberArg(node.arguments, 'max_tokens') ?? 1_000;
const isEmbed = /embeddings/i.test(callStr);
// If the call is inside a loop, assume it runs once per item in a typical batch (1 000 items/month)
const inLoop = isInsideLoop(node, ancestors);

matches.push({
service: 'openai',
model: model ? (MODEL_ALIASES[model] ?? model) : 'gpt-4o',
model: resolveOpenAIModel(rawModel),
operation: isEmbed ? 'embeddings.create' : 'chat.completions.create',
inputTokens: 500,
outputTokens: isEmbed ? 0 : maxTok,
callsPerMonth: 10_000,
callsPerMonth: inLoop ? 50_000 : 10_000,
line: node.loc.start.line,
column: node.loc.start.column,
snippet: callStr.slice(0, 80),
});
}
}

const nextAncestors = [...ancestors, node];
for (const key of Object.keys(node)) {
const child = (node as any)[key];
if (child && typeof child === 'object') {
if (Array.isArray(child)) child.forEach((c: any) => c?.type && walk(c));
else if (child.type) walk(child);
if (Array.isArray(child)) child.forEach((c: any) => c?.type && walk(c, nextAncestors));
else if (child.type) walk(child, nextAncestors);
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/services/diff/cost-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface CostDiffResult {
removedServices: string[];
addedModels: string[];
removedModels: string[];
/** Per-detection lines in the HEAD with service, model, and snippet info */
headLines: { service: string; model?: string; snippet: string; monthlyCents: number }[];
}

/**
Expand Down Expand Up @@ -57,5 +59,6 @@ export async function computeCostDiff(
removedServices: [...baseServices].filter(s => !headServices.has(s)),
addedModels: [...headModels].filter(m => !baseModels.has(m)),
removedModels: [...baseModels].filter(m => !headModels.has(m)),
headLines: headR.lines.map(l => ({ service: l.service, model: l.model, snippet: l.snippet, monthlyCents: l.monthlyCents })),
};
}
Loading