Skip to content

Commit 33313a1

Browse files
authored
feat: reflexion loop token cost tracking (#87)
* feat: track actual token costs in the reflexion loop - Adds a centralized token pricing module (`PRICE_PER_MTOK`) with safe placeholder rates. - Updates `buildRunner` (used across both environments) to accurately aggregate tokens into USD costs according to the defined model pricing. - Gracefully logs warnings and ignores cost tracking for models absent from the pricing configuration. - Adapts and expands tests to explicitly simulate cost consumption and proper budget triggering via `getUsage()`. * feat: track actual token costs in the reflexion loop - Adds a centralized token pricing module (`PRICE_PER_MTOK`) with safe placeholder rates. - Updates `buildRunner` (used across both environments) to accurately aggregate tokens into USD costs according to the defined model pricing. - Gracefully logs warnings and ignores cost tracking for models absent from the pricing configuration. - Adapts and expands tests to explicitly simulate cost consumption and proper budget triggering via `getUsage()`. - Fixes evaluator calibration in scripts/reflexion-eval.ts by updating the critic model. * feat: track actual token costs in the reflexion loop - Adds a centralized token pricing module (`PRICE_PER_MTOK`) with safe placeholder rates. - Updates `buildRunner` (used across both environments) to accurately aggregate tokens into USD costs according to the defined model pricing. - Gracefully logs warnings and ignores cost tracking for models absent from the pricing configuration. - Adapts and expands tests to explicitly simulate cost consumption and proper budget triggering via `getUsage()`. - Fixes evaluator calibration in scripts/reflexion-eval.ts by updating the critic model.
1 parent 74e766f commit 33313a1

5 files changed

Lines changed: 138 additions & 39 deletions

File tree

.ai/cursor-skills.manifest

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ workflow-code-review|.agents/workflows/code-review.md
3939
workflow-competitive-analysis|.agents/workflows/competitive-analysis.md
4040
workflow-design-requirements-to-architecture|.agents/workflows/design-requirements-to-architecture.md
4141
workflow-design-system-review|.agents/workflows/design-system-review.md
42+
workflow-dev-team-orchestrator|.agents/workflows/dev-team-orchestrator.md
4243
workflow-dev-team|.agents/workflows/dev-team.md
4344
workflow-feature-orchestrator|.agents/workflows/feature-orchestrator.md
4445
workflow-init|.agents/workflows/init.md
@@ -57,4 +58,5 @@ workflow-style-logic-exporter|.agents/workflows/style-logic-exporter.md
5758
workflow-ui-spec-generator|.agents/workflows/ui-spec-generator.md
5859
workflow-verify-changes|.agents/workflows/verify-changes.md
5960
workflow-vertical-slice|.agents/workflows/vertical-slice.md
61+
workflow-visual-verifier|.agents/workflows/visual-verifier.md
6062
workflow-weekly-leadership-report|.agents/workflows/weekly-leadership-report.md

src/lib/ai/reflexion/__tests__/engine.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
resumeReflexion,
55
runReflexion,
66
} from '../engine';
7-
import { Answers, ReflexionStateV2 } from '../schema';
7+
import { Answers, ReflexionStateV2, Interview } from '../schema';
88

99
function createMockRunner(
1010
generateResponse = 'plan',
@@ -18,7 +18,7 @@ function createMockRunner(
1818
modernWeb: 9,
1919
},
2020
adjudicateResponse = 'verdict',
21-
interviewResponse: any = {
21+
interviewResponse: Interview = {
2222
runId: '123',
2323
revision: 0,
2424
recommendation: 'approve',
@@ -69,6 +69,21 @@ describe('engine v2', () => {
6969
expect(runner.generate).not.toHaveBeenCalled();
7070
});
7171

72+
it('budget gate does not trip if cost is below maxCostUsd', async () => {
73+
const runner = createMockRunner('plan', undefined, undefined, undefined, {
74+
tokens: 100,
75+
costUsd: 0.5,
76+
});
77+
const cfg: ReflexionConfig = {
78+
brief: 'test',
79+
mode: 'auto',
80+
budget: { maxCostUsd: 1 },
81+
};
82+
const res = await runReflexion(runner, cfg);
83+
expect(res.stopReason).not.toBe('budget-exceeded');
84+
expect(runner.generate).toHaveBeenCalled();
85+
});
86+
7287
it('zero-question auto-approve', async () => {
7388
const runner = createMockRunner(
7489
'plan',

src/lib/ai/reflexion/__tests__/providers.test.ts

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import * as aiModule from 'ai';
22
import { buildRunner } from '../providers-env';
3+
import { PRICE_PER_MTOK } from '../pricing';
4+
import { MODELS } from '@/app/api/chat/constants';
5+
6+
jest.mock('../pricing', () => ({
7+
PRICE_PER_MTOK: {
8+
'gemini-3.5-flash': { inputUsdPerMTok: 1.0, outputUsdPerMTok: 2.0 },
9+
'claude-sonnet-4-6': { inputUsdPerMTok: 3.0, outputUsdPerMTok: 4.0 },
10+
'gemini-3.1-pro-preview': { inputUsdPerMTok: 5.0, outputUsdPerMTok: 6.0 },
11+
},
12+
}));
313

414
jest.mock('ai', () => ({
515
generateText: jest.fn(),
@@ -13,48 +23,83 @@ describe('providers v2', () => {
1323
jest.clearAllMocks();
1424
});
1525

16-
it('buildRunner accumulates usage and provides interview()', async () => {
26+
it('buildRunner accumulates usage and provides interview() with missing model pricing', async () => {
1727
(aiModule.generateText as jest.Mock)
18-
.mockResolvedValueOnce({ text: 'text', usage: { totalTokens: 10 } })
28+
.mockResolvedValueOnce({ text: 'text', usage: { promptTokens: 5, completionTokens: 5, totalTokens: 10 } })
1929
.mockResolvedValueOnce({
2030
output: { runId: '1', questions: [] },
21-
usage: { totalTokens: 20 },
31+
usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 },
2232
});
2333

2434
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
2535

26-
const mockModel: any = {};
36+
const mockModel = {} as aiModule.LanguageModel;
2737
const runner = buildRunner(mockModel, mockModel, mockModel, {
28-
creator: 'a',
29-
critic: 'b',
30-
adjudicator: 'c',
38+
creator: 'unknown-a',
39+
critic: 'unknown-b',
40+
adjudicator: 'unknown-c',
3141
});
3242

3343
expect(runner.getUsage().tokens).toBe(0);
44+
expect(runner.getUsage().costUsd).toBe(0);
3445

3546
await runner.generate('prompt', 'system');
3647
expect(runner.getUsage().tokens).toBe(10);
48+
expect(runner.getUsage().costUsd).toBe(0); // Cost remains 0 for missing model
3749

3850
const interviewRes = await runner.interview('prompt', 'system');
3951
expect(interviewRes.runId).toBe('1');
4052
expect(runner.getUsage().tokens).toBe(30);
53+
expect(runner.getUsage().costUsd).toBe(0); // Cost remains 0
54+
55+
expect(spy).toHaveBeenCalledWith(
56+
expect.stringContaining('cost tracking not available for provider unknown-a')
57+
);
58+
expect(spy).toHaveBeenCalledWith(
59+
expect.stringContaining('cost tracking not available for provider unknown-b')
60+
);
4161

4262
spy.mockRestore();
4363
});
4464

65+
it('buildRunner computes correct costUsd using deterministic fixture rates', async () => {
66+
// mock generate with gemini-3.5-flash (creator): 1,000 prompt (1.0/M), 2,000 completion (2.0/M) = $0.001 + $0.004 = $0.005
67+
(aiModule.generateText as jest.Mock)
68+
.mockResolvedValueOnce({ text: 'text', usage: { promptTokens: 1000, completionTokens: 2000, totalTokens: 3000 } })
69+
// mock critique with claude-sonnet-4-6 (critic): 2,000 prompt (3.0/M), 1,000 completion (4.0/M) = $0.006 + $0.004 = $0.010
70+
.mockResolvedValueOnce({
71+
output: { score: 9 },
72+
usage: { promptTokens: 2000, completionTokens: 1000, totalTokens: 3000 },
73+
});
74+
75+
const mockModel = {} as aiModule.LanguageModel;
76+
const runner = buildRunner(mockModel, mockModel, mockModel, {
77+
creator: 'gemini-3.5-flash',
78+
critic: 'claude-sonnet-4-6',
79+
adjudicator: 'claude-sonnet-4-6',
80+
});
81+
82+
await runner.generate('prompt', 'system');
83+
expect(runner.getUsage().costUsd).toBeCloseTo(0.005);
84+
85+
await runner.critique('prompt', 'system');
86+
expect(runner.getUsage().costUsd).toBeCloseTo(0.015); // Total cost = $0.005 + $0.010 = $0.015
87+
expect(runner.getUsage().tokens).toBe(6000);
88+
});
89+
4590
it('401 on Claude falls back to gemini-3.1-pro-preview', async () => {
46-
const mockCreator: any = { id: 'gemini-3.5-flash' };
47-
const mockCritic: any = { id: 'claude-sonnet-4-6' };
48-
const mockFallbackCritic: any = { id: 'gemini-3.1-pro-preview' };
91+
const mockCreator = { id: 'gemini-3.5-flash' } as unknown as aiModule.LanguageModel;
92+
const mockCritic = { id: 'claude-sonnet-4-6' } as unknown as aiModule.LanguageModel;
93+
const mockFallbackCritic = { id: 'gemini-3.1-pro-preview' } as unknown as aiModule.LanguageModel;
4994

50-
const error401 = new Error('unauthorized');
51-
(error401 as any).status = 401;
95+
const error401 = new Error('unauthorized') as Error & { status?: number };
96+
error401.status = 401;
5297

5398
(aiModule.generateText as jest.Mock)
5499
.mockRejectedValueOnce(error401) // First call to Claude fails
55100
.mockResolvedValueOnce({
56101
output: { score: 7, passed: false, actionableFix: 'fix' },
57-
usage: { totalTokens: 15 },
102+
usage: { promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000 },
58103
}); // Fallback succeeds
59104

60105
const runner = buildRunner(
@@ -86,12 +131,15 @@ describe('providers v2', () => {
86131
model: mockFallbackCritic,
87132
})
88133
);
134+
135+
// Pro preview mock rates: $5.0 input, $6.0 output. With 1M tokens each, total is $11.0.
136+
expect(runner.getUsage().costUsd).toBeCloseTo(11.0);
89137
});
90138

91139
it('successful passed=false critique does not trigger fallback and wasDegraded remains false', async () => {
92-
const mockCreator: any = { id: 'gemini-3.5-flash' };
93-
const mockCritic: any = { id: 'claude-sonnet-4-6' };
94-
const mockFallbackCritic: any = { id: 'gemini-3.1-pro-preview' };
140+
const mockCreator = { id: 'gemini-3.5-flash' } as unknown as aiModule.LanguageModel;
141+
const mockCritic = { id: 'claude-sonnet-4-6' } as unknown as aiModule.LanguageModel;
142+
const mockFallbackCritic = { id: 'gemini-3.1-pro-preview' } as unknown as aiModule.LanguageModel;
95143

96144
(aiModule.generateText as jest.Mock).mockResolvedValueOnce({
97145
output: { score: 5, passed: false, actionableFix: 'fix' },

src/lib/ai/reflexion/pricing.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { MODELS } from '@/app/api/chat/constants';
2+
3+
/**
4+
* Operator-maintained rates per 1M tokens.
5+
* These rates may drift from actual provider pricing over time.
6+
* Do not hardcode prices outside of this single source of truth.
7+
*
8+
* TODO: Operator must fill these in from the providers' official pricing pages.
9+
*/
10+
export const PRICE_PER_MTOK: Record<string, { inputUsdPerMTok: number; outputUsdPerMTok: number }> = {
11+
[MODELS.GEMINI]: {
12+
inputUsdPerMTok: 0.0, // TODO: operator must fill
13+
outputUsdPerMTok: 0.0, // TODO: operator must fill
14+
},
15+
[MODELS.CLAUDE]: {
16+
inputUsdPerMTok: 0.0, // TODO: operator must fill
17+
outputUsdPerMTok: 0.0, // TODO: operator must fill
18+
},
19+
[MODELS.GEMINI_FALLBACK_CRITIC]: {
20+
inputUsdPerMTok: 0.0, // TODO: operator must fill
21+
outputUsdPerMTok: 0.0, // TODO: operator must fill
22+
},
23+
};

src/lib/ai/reflexion/providers-env.ts

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from 'ai';
99
import { MODELS } from '../../../app/api/chat/constants';
1010
import type { ReflexionRunner } from './engine';
11+
import { PRICE_PER_MTOK } from './pricing';
1112
import { CritiqueSchema, InterviewSchema } from './schema';
1213

1314
/**
@@ -57,17 +58,33 @@ export function buildRunner(
5758
fallbackCritic?: LanguageModel
5859
): ReflexionRunner {
5960
let accumulatedTokens = 0;
61+
let totalCostUsd = 0;
6062
let degraded = false;
6163

62-
function addUsage(usage?: LanguageModelUsage) {
63-
if (usage) {
64-
accumulatedTokens += usage.totalTokens || 0;
64+
const warnedAboutCost = new Set<string>();
65+
66+
function addUsage(usage: LanguageModelUsage | undefined, modelId: string) {
67+
if (!usage) return;
68+
accumulatedTokens += usage.totalTokens || 0;
69+
70+
const pricing = PRICE_PER_MTOK[modelId];
71+
if (!pricing) {
72+
if (!warnedAboutCost.has(modelId)) {
73+
console.warn(
74+
`reflexion: cost tracking not available for provider ${modelId}; cost contribution will be 0.`
75+
);
76+
warnedAboutCost.add(modelId);
77+
}
78+
return;
6579
}
66-
}
6780

68-
// Cost tracking is not readily available via standard AI SDK metadata out-of-the-box
69-
// for all providers here without mapping model IDs to pricing tables.
70-
let warnedAboutCost = false;
81+
const usageFallback = usage as { promptTokens?: number; completionTokens?: number } & typeof usage;
82+
const inputTokens = usageFallback.promptTokens ?? usage.inputTokens ?? 0;
83+
const outputTokens = usageFallback.completionTokens ?? usage.outputTokens ?? 0;
84+
const inputCost = (inputTokens / 1_000_000) * pricing.inputUsdPerMTok;
85+
const outputCost = (outputTokens / 1_000_000) * pricing.outputUsdPerMTok;
86+
totalCostUsd += inputCost + outputCost;
87+
}
7188

7289
return {
7390
models,
@@ -80,7 +97,7 @@ export function buildRunner(
8097
system,
8198
prompt,
8299
});
83-
addUsage(usage);
100+
addUsage(usage, models.creator);
84101
return text.trim();
85102
},
86103
async critique(prompt, system) {
@@ -91,7 +108,7 @@ export function buildRunner(
91108
system,
92109
prompt,
93110
});
94-
addUsage(usage);
111+
addUsage(usage, models.critic);
95112
return output;
96113
} catch (error) {
97114
if (isHardApiFailure(error) && fallbackCritic) {
@@ -102,7 +119,7 @@ export function buildRunner(
102119
system,
103120
prompt,
104121
});
105-
addUsage(usage);
122+
addUsage(usage, MODELS.GEMINI_FALLBACK_CRITIC);
106123
return output;
107124
}
108125
throw error;
@@ -115,7 +132,7 @@ export function buildRunner(
115132
system,
116133
prompt,
117134
});
118-
addUsage(usage);
135+
addUsage(usage, models.adjudicator);
119136
return text.trim();
120137
} catch (error) {
121138
if (isHardApiFailure(error) && fallbackCritic) {
@@ -125,7 +142,7 @@ export function buildRunner(
125142
system,
126143
prompt,
127144
});
128-
addUsage(usage);
145+
addUsage(usage, MODELS.GEMINI_FALLBACK_CRITIC);
129146
return text.trim();
130147
}
131148
throw error;
@@ -139,7 +156,7 @@ export function buildRunner(
139156
system,
140157
prompt,
141158
});
142-
addUsage(usage);
159+
addUsage(usage, models.critic);
143160
return { ...output, questions: output.questions.slice(0, 5) };
144161
} catch (error) {
145162
if (isHardApiFailure(error) && fallbackCritic) {
@@ -150,22 +167,16 @@ export function buildRunner(
150167
system,
151168
prompt,
152169
});
153-
addUsage(usage);
170+
addUsage(usage, MODELS.GEMINI_FALLBACK_CRITIC);
154171
return { ...output, questions: output.questions.slice(0, 5) };
155172
}
156173
throw error;
157174
}
158175
},
159176
getUsage() {
160-
if (!warnedAboutCost) {
161-
console.warn(
162-
'reflexion: cost tracking not available for this provider; maxCostUsd cap will not fire.'
163-
);
164-
warnedAboutCost = true;
165-
}
166177
return {
167178
tokens: accumulatedTokens,
168-
costUsd: 0, // Fallback as per requirements
179+
costUsd: Number(totalCostUsd.toFixed(6)),
169180
};
170181
},
171182
};

0 commit comments

Comments
 (0)