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
12 changes: 0 additions & 12 deletions src/pm/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,18 +269,6 @@ export async function downloadMedia(
}
}

/**
* Converts a downloaded media buffer to a base64 data URI string suitable
* for embedding in HTML or LLM context.
*
* @param buffer - The raw bytes of the media.
* @param mimeType - The MIME type of the media (e.g. `'image/png'`).
* @returns A base64 data URI string, e.g. `'data:image/png;base64,iVBORw...'`.
*/
export function mediaToBase64DataUri(buffer: Buffer, mimeType: string): string {
return `data:${mimeType};base64,${buffer.toString('base64')}`;
}

// ---------------------------------------------------------------------------
// JIRA media URL resolution
// ---------------------------------------------------------------------------
Expand Down
52 changes: 0 additions & 52 deletions src/triggers/github/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,39 +64,6 @@ export function extractTrelloCardId(text: string | null): string | null {
return match ? match[1] : null;
}

/**
* Check if text contains a Trello card URL.
*/
export function hasTrelloCardUrl(text: string | null): boolean {
return extractTrelloCardId(text) !== null;
}

/**
* Extract full Trello card URL from text.
*/
export function extractTrelloCardUrl(text: string | null): string | null {
if (!text) return null;
const match = text.match(TRELLO_CARD_URL_REGEX);
return match ? match[0] : null;
}

/**
* Validate PR body has Trello card URL and extract card ID.
* Returns card ID or null (with logging) if not found.
*/
export function requireTrelloCardId(
prBody: string | null,
context: { prNumber: number; triggerName: string },
): string | null {
if (!hasTrelloCardUrl(prBody)) {
logger.info(`PR does not have Trello card URL, skipping ${context.triggerName}`, {
prNumber: context.prNumber,
});
return null;
}
return extractTrelloCardId(prBody);
}

/**
* Extract a JIRA issue key (e.g., "PROJ-123") from text.
*/
Expand All @@ -119,25 +86,6 @@ export function extractWorkItemId(text: string | null, project: ProjectConfig):
return extractTrelloCardId(text);
}

/**
* Validate PR body has a work item reference and extract the ID.
* Works for both Trello (card URL) and JIRA (issue key) projects.
*/
export function requireWorkItemId(
prBody: string | null,
project: ProjectConfig,
context: { prNumber: number; triggerName: string },
): string | null {
const id = extractWorkItemId(prBody, project);
if (!id) {
logger.info(`PR does not have work item reference, skipping ${context.triggerName}`, {
prNumber: context.prNumber,
pmType: project.pm?.type ?? 'trello',
});
}
return id;
}

/**
* Resolve work item ID for a PR using DB lookup only (pr_work_items table).
* Returns undefined when DB returns null or throws.
Expand Down
59 changes: 1 addition & 58 deletions src/utils/llmMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
/**
* LLM request metrics tracking and logging utilities.
* Provides cost calculation, token estimation, and structured logging.
* Provides cost calculation.
*/
import type { TokenUsage } from 'llmist';

/**
* Simple logger interface matching CASCADE's logger.
*/
interface SimpleLogger {
info(message: string, context?: Record<string, unknown>): void;
}

/**
* Model pricing per 1M tokens (in USD).
* Prices as of January 2026.
Expand Down Expand Up @@ -43,16 +36,6 @@ const MODEL_PRICING: Record<string, { input: number; output: number; cachedInput
'openrouter:minimax/minimax-m2.1': { input: 0.28, output: 1.2 },
};

export interface LLMCallMetrics {
model: string;
iteration: number;
inputTokens: number;
outputTokens: number;
cachedTokens: number;
durationMs: number;
cost: number;
}

/**
* Calculate cost for an LLM call based on model and token usage.
* Returns 0 for unknown models.
Expand All @@ -71,43 +54,3 @@ export function calculateCost(model: string, usage: TokenUsage): number {

return inputCost + outputCost - cachedDiscount;
}

/**
* Estimate input token count from messages.
* Uses rough heuristic of ~4 characters per token.
*/
export function estimateInputTokens(messages: unknown[]): number {
const text = JSON.stringify(messages);
return Math.ceil(text.length / 4);
}

/**
* Log LLM call metrics in a structured format.
*/
export function logLLMMetrics(logger: SimpleLogger, metrics: LLMCallMetrics): void {
logger.info('LLM call complete', {
model: metrics.model,
iteration: metrics.iteration,
inputTokens: metrics.inputTokens,
outputTokens: metrics.outputTokens,
cachedTokens: metrics.cachedTokens,
durationMs: metrics.durationMs,
cost: `$${metrics.cost.toFixed(6)}`,
});
}

/**
* Log LLM call start with estimated input tokens.
*/
export function logLLMCallStart(
logger: SimpleLogger,
iteration: number,
messages: unknown[],
): void {
const estimatedInputTokens = estimateInputTokens(messages);
logger.info('LLM call starting', {
iteration,
estimatedInputTokens,
messageCount: messages.length,
});
}
25 changes: 0 additions & 25 deletions tests/unit/pm/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
extractMarkdownImages,
filterImageMedia,
isImageMimeType,
mediaToBase64DataUri,
resolveJiraMediaUrls,
} from '../../../src/pm/media.js';
import type { MediaReference } from '../../../src/pm/types.js';
Expand Down Expand Up @@ -415,30 +414,6 @@ describe('downloadMedia', () => {
});
});

// ---------------------------------------------------------------------------
// mediaToBase64DataUri
// ---------------------------------------------------------------------------

describe('mediaToBase64DataUri', () => {
it('returns a correctly formatted data URI', () => {
const buffer = Buffer.from('hello');
const result = mediaToBase64DataUri(buffer, 'image/png');
expect(result).toBe(`data:image/png;base64,${Buffer.from('hello').toString('base64')}`);
});

it('works for different MIME types', () => {
const buffer = Buffer.from([0xff, 0xd8, 0xff]);
const result = mediaToBase64DataUri(buffer, 'image/jpeg');
expect(result).toMatch(/^data:image\/jpeg;base64,/);
});

it('empty buffer produces valid (empty content) data URI', () => {
const buffer = Buffer.alloc(0);
const result = mediaToBase64DataUri(buffer, 'image/gif');
expect(result).toBe('data:image/gif;base64,');
});
});

// ---------------------------------------------------------------------------
// resolveJiraMediaUrls
// ---------------------------------------------------------------------------
Expand Down
46 changes: 0 additions & 46 deletions tests/unit/triggers/github-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import {
extractJiraIssueKey,
extractTrelloCardId,
extractWorkItemId,
hasTrelloCardUrl,
requireWorkItemId,
resolveWorkItemId,
} from '../../../src/triggers/github/utils.js';
import type { ProjectConfig } from '../../../src/types/index.js';
Expand Down Expand Up @@ -72,24 +70,6 @@ describe('extractTrelloCardId', () => {
});
});

describe('hasTrelloCardUrl', () => {
it('returns false for null input', () => {
expect(hasTrelloCardUrl(null)).toBe(false);
});

it('returns false for text without URL', () => {
expect(hasTrelloCardUrl('No URL here')).toBe(false);
});

it('returns true for text with Trello URL', () => {
expect(hasTrelloCardUrl('https://trello.com/c/abc123/card')).toBe(true);
});

it('returns true for partial match in longer text', () => {
expect(hasTrelloCardUrl('Check out this card: https://trello.com/c/xyz789')).toBe(true);
});
});

describe('extractJiraIssueKey', () => {
it('returns null for null input', () => {
expect(extractJiraIssueKey(null)).toBeNull();
Expand Down Expand Up @@ -153,32 +133,6 @@ describe('extractWorkItemId', () => {
});
});

describe('requireWorkItemId', () => {
const context = { prNumber: 42, triggerName: 'test-trigger' };

it('returns null when no ID found', () => {
const result = requireWorkItemId('No work item reference', mockTrelloProject, context);
expect(result).toBeNull();
});

it('returns ID when present in Trello project', () => {
const text = 'Implements https://trello.com/c/abc123/card';
const result = requireWorkItemId(text, mockTrelloProject, context);
expect(result).toBe('abc123');
});

it('returns ID when present in JIRA project', () => {
const text = 'Fixes PROJ-789';
const result = requireWorkItemId(text, mockJiraProject, context);
expect(result).toBe('PROJ-789');
});

it('returns null for null input', () => {
const result = requireWorkItemId(null, mockTrelloProject, context);
expect(result).toBeNull();
});
});

describe('resolveWorkItemId', () => {
beforeEach(() => {
vi.mocked(lookupWorkItemForPR).mockResolvedValue(null);
Expand Down
78 changes: 2 additions & 76 deletions tests/unit/utils/llmMetrics.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import {
calculateCost,
estimateInputTokens,
logLLMCallStart,
logLLMMetrics,
} from '../../../src/utils/llmMetrics.js';
import { describe, expect, it } from 'vitest';
import { calculateCost } from '../../../src/utils/llmMetrics.js';

describe.concurrent('llmMetrics', () => {
describe('calculateCost', () => {
Expand Down Expand Up @@ -76,73 +71,4 @@ describe.concurrent('llmMetrics', () => {
expect(cost).toBeCloseTo(0.00015 + 0.0003, 8);
});
});

describe('estimateInputTokens', () => {
it('estimates tokens from messages', () => {
const messages = [{ role: 'user', content: 'Hello world' }];
const estimate = estimateInputTokens(messages);

// JSON.stringify length / 4, ceiling
expect(estimate).toBeGreaterThan(0);
expect(estimate).toBe(Math.ceil(JSON.stringify(messages).length / 4));
});

it('handles empty messages array', () => {
const estimate = estimateInputTokens([]);

expect(estimate).toBeGreaterThan(0); // [] still has length 2
});

it('handles large messages', () => {
const longContent = 'a'.repeat(4000);
const messages = [{ role: 'user', content: longContent }];
const estimate = estimateInputTokens(messages);

expect(estimate).toBeGreaterThanOrEqual(1000);
});
});

describe('logLLMMetrics', () => {
it('logs metrics with formatted cost', () => {
const mockLogger = { info: vi.fn() };

logLLMMetrics(mockLogger, {
model: 'test-model',
iteration: 5,
inputTokens: 1000,
outputTokens: 500,
cachedTokens: 200,
durationMs: 1500,
cost: 0.003456,
});

expect(mockLogger.info).toHaveBeenCalledWith('LLM call complete', {
model: 'test-model',
iteration: 5,
inputTokens: 1000,
outputTokens: 500,
cachedTokens: 200,
durationMs: 1500,
cost: '$0.003456',
});
});
});

describe('logLLMCallStart', () => {
it('logs call start with estimated tokens and message count', () => {
const mockLogger = { info: vi.fn() };
const messages = [
{ role: 'system', content: 'You are helpful' },
{ role: 'user', content: 'Hello' },
];

logLLMCallStart(mockLogger, 3, messages);

expect(mockLogger.info).toHaveBeenCalledWith('LLM call starting', {
iteration: 3,
estimatedInputTokens: expect.any(Number),
messageCount: 2,
});
});
});
});
Loading