8000 Route LLM calls through AI Gateway for per-request cost tracking by tim-inkeep · Pull Request #2803 · inkeep/agents · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
7b1deb0
[US-001] Add usage_events table to runtime schema
tim-inkeep Mar 19, 2026
46ae1a5
[US-002] Create pricing service with gateway and models.dev fallback
tim-inkeep Mar 19, 2026
a004ff5
[US-003] Create usage events data access layer
tim-inkeep Mar 19, 2026
a63b7c3
[US-004] Create usage tracker wrapper and OTel enrichment
tim-inkeep Mar 19, 2026
2a41bb3
[US-005] Resolve usage/totalUsage in resolveGenerationResponse
tim-inkeep Mar 19, 2026
255404f
[US-006] Instrument main generation path with usage tracking
tim-inkeep Mar 19, 2026
4597b1a
[US-007] Instrument status update and artifact metadata generations
tim-inkeep Mar 19, 2026
a31d94b
Move token-estimator from agents-api to agents-core
tim-inkeep Mar 19, 2026
d4c0414
Refactor usage tracking to trackedGenerate wrapper
tim-inkeep Mar 19, 2026
e85c836
[US-009/US-010] Add usage summary and events API endpoints
tim-inkeep Mar 19, 2026
fe7c0bf
[US-011/US-012] Add usage dashboard to Manage UI
tim-inkeep Mar 19, 2026
613dd0a
Add shared UsageDashboard component and project-level usage page
tim-inkeep Mar 19, 2026
2d321c0
Fix usage API auth + initialize pricing service on startup
tim-inkeep Mar 19, 2026
ac46527
Fix cost estimation: use parsed model name for pricing lookup
tim-inkeep Mar 19, 2026
a27c78f
Add step-level usage, trace/span IDs, cost in traces, compression tra…
tim-inkeep Mar 20, 2026
f73bd84
Migrate usage tracking from Postgres to SigNoz OTel spans
tim-inkeep Mar 20, 2026
b77c288
added new token and cost tracking
tim-inkeep Mar 23, 2026
fbc060d
updated all to use real token counts
tim-inkeep Mar 23, 2026
725d4cc
update pricing server
tim-inkeep Mar 23, 2026
7267edd
updated changset
tim-inkeep Mar 23, 2026
daea46d
Merge branch 'main' into implement/usage-tracker
tim-inkeep Mar 23, 2026
b675151
style: auto-format with biome
github-actions[bot] Mar 23, 2026
ba09180
updated with comments
tim-inkeep Mar 23, 2026
7ab059e
updated with comments
tim-inkeep Mar 23, 2026
e626b64
merged ui from main
tim-inkeep Mar 23, 2026
62bfdad
updated again
tim-inkeep Mar 23, 2026
62647f1
updated
tim-inkeep Mar 23, 2026
0b87184
fixed for all llm calls
tim-inkeep Mar 24, 2026
adb2750
fixed tests
tim-inkeep Mar 24, 2026 8000
d61caec
knipped
tim-inkeep Mar 24, 2026
6ad46e2
merged main
tim-inkeep Mar 24, 2026
2140a36
updated pricing server refresh strategy
tim-inkeep Mar 24, 2026
a2994de
Merge branch 'main' into implement/usage-tracker
tim-inkeep Mar 24, 2026
f29e54b
updated
tim-inkeep Mar 24, 2026
7308c05
Merge branch 'main' into implement/usage-tracker
tim-inkeep Mar 24, 2026
5751924
updated otel attributes and querying style
tim-inkeep Mar 24, 2026
026772a
removed manage-ui from security filter, we will just not track compon…
tim-inkeep Mar 24, 2026
2eb1617
knip
tim-inkeep Mar 24, 2026
df5ec74
updated with no pricing server
tim-inkeep Mar 25, 2026
1df0a2a
Merge branch 'main' into implement/usage-tracker
tim-inkeep Mar 25, 2026
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
< 8000 button name="w" value="0" hidden="hidden" type="submit" data-view-component="true" class="btn-invisible btn-sm btn color-fg-accent py-0 tmp-py-0 px-1 tmp-px-1 rounded-1"> Show whitespace
Diff view
6 changes: 6 additions & 0 deletions .changeset/golden-crimson-emu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@inkeep/agents-core": patch
"@inkeep/agents-api": patch
---

Use actual AI SDK token usage for compression decisions and fix pricing service model ID lookup
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ vi.mock('../../../logger.js', () => ({
}));

vi.mock('ai', () => ({
generateObject: vi.fn(),
generateText: vi.fn(),
Output: { object: vi.fn((opts: any) => opts) },
}));

import { EvaluationService } from '../../../domains/evals/services/EvaluationService';
Expand Down
56 changes: 26 additions & 30 deletions agents-api/src/__tests__/run/agents/ModelFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Anthropic');
expect(model).toHaveProperty('modelId');
});

test('should create OpenAI model with explicit config', () => {
Expand All @@ -130,7 +130,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('OpenAI');
expect(model).toHaveProperty('modelId');
});

test('should create Anthropic model with proper provider prefix', () => {
Expand All @@ -141,7 +141,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Anthropic');
expect(model).toHaveProperty('modelId');
});

test('should create Anthropic model with custom provider options', () => {
Expand All @@ -159,7 +159,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Anthropic');
expect(model).toHaveProperty('modelId');
});

test('should create OpenAI model with custom provider options', () => {
Expand All @@ -176,7 +176,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('OpenAI');
expect(model).toHaveProperty('modelId');
});

// Google/Gemini specific tests
Expand All @@ -188,8 +188,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Google');
expect(model).toHaveProperty('modelId', 'gemini-2.5-flash');
expect(model).toHaveProperty('modelId');
});

test('should create Google Gemini Pro model', () => {
Expand All @@ -200,7 +199,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Google');
expect(model).toHaveProperty('modelId');
});

test('should create Google Gemini Flash Lite model', () => {
Expand All @@ -211,7 +210,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Google');
expect(model).toHaveProperty('modelId');
});

test('should create Google model with custom provider options', () => {
Expand All @@ -227,9 +226,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Google');
// Note: We can't reliably test the mock calls due to interference in the full suite
// The functionality itself works correctly
expect(model).toHaveProperty('modelId');
});

test('should handle Google model with gateway configuration', () => {
Expand All @@ -247,7 +244,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Google');
expect(model).toHaveProperty('modelId');
// Note: We can't reliably test the mock calls due to interference in the full suite
// The functionality itself works correctly
});
Expand Down Expand Up @@ -277,7 +274,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Anthropic');
expect(model).toHaveProperty('modelId');
});

test('should throw error for unknown provider', () => {
Expand All @@ -300,7 +297,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Anthropic');
expect(model).toHaveProperty('modelId');
});
});

Expand Down Expand Up @@ -509,7 +506,7 @@ describe('ModelFactory', () => {

expect(config).toHaveProperty('model');
expect(config.model).toBeDefined();
expect(config.model.constructor.name).toContain('Anthropic');
expect(config.model).toHaveProperty('modelId');
expect(config).toHaveProperty('temperature', 0.8);
expect(config).toHaveProperty('maxTokens', 2048);
expect(config).not.toHaveProperty('apiKey'); // Should be filtered out
Expand All @@ -534,7 +531,7 @@ describe('ModelFactory', () => {
const config = ModelFactory.prepareGenerationConfig(modelSettings);

expect(config).toHaveProperty('model');
expect(config.model.constructor.name).toContain('OpenAI');
expect(config.model).toHaveProperty('modelId');
expect(config).toHaveProperty('temperature', 0.3);
expect(config).toHaveProperty('frequencyPenalty', 0.1);
expect(config).not.toHaveProperty('baseURL'); // Should be filtered out
Expand All @@ -548,7 +545,7 @@ describe('ModelFactory', () => {
const config = ModelFactory.prepareGenerationConfig(modelSettings);

expect(config).toHaveProperty('model');
expect(config.model.constructor.name).toContain('Anthropic');
expect(config.model).toHaveProperty('modelId');
// Should only have the model property, no generation params
expect(Object.keys(config)).toEqual(['model']);
});
Expand Down Expand Up @@ -592,7 +589,7 @@ describe('ModelFactory', () => {
const config = ModelFactory.prepareGenerationConfig(modelSettings);

expect(config).toHaveProperty('model');
expect(config.model.constructor.name).toContain('Google');
expect(config.model).toHaveProperty('modelId');
expect(config).toHaveProperty('temperature', 0.5);
expect(config).toHaveProperty('maxTokens', 1024);
expect(config).toHaveProperty('topP', 0.9);
Expand All @@ -618,7 +615,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Anthropic');
expect(model).toHaveProperty('modelId');
});

test('should handle model names with multiple slashes', () => {
Expand All @@ -629,7 +626,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('OpenAI');
expect(model).toHaveProperty('modelId');
});

test('should require provider prefix in model string', () => {
Expand Down Expand Up @@ -657,7 +654,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Anthropic');
expect(model).toHaveProperty('modelId');
});

test('should handle OpenAI provider configuration', () => {
Expand All @@ -673,7 +670,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('OpenAI');
expect(model).toHaveProperty('modelId');
});

test('should handle both baseUrl and baseURL variants', () => {
Expand All @@ -689,7 +686,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Anthropic');
expect(model).toHaveProperty('modelId');
});

test('should handle provider configuration with only generation params', () => {
Expand All @@ -705,7 +702,7 @@ describe('ModelFactory', () => {
const model = ModelFactory.createModel(config);

expect(model).toBeDefined();
expect(model.constructor.name).toContain('Anthropic');
expect(model).toHaveProperty('modelId');
});
});

Expand Down Expand Up @@ -874,7 +871,7 @@ describe('ModelFactory', () => {
const config: ModelSettings = { model: modelString };
const model = ModelFactory.createModel(config);
expect(model).toBeDefined();
expect(model.constructor.name).toBe('OpenRouterChatLanguageModel');
expect(model).toHaveProperty('modelId');
}
});

Expand All @@ -891,8 +888,7 @@ describe('ModelFactory', () => {
const config: ModelSettings = { model: modelString };
const model = ModelFactory.createModel(config);
expect(model).toBeDefined();
// Gateway returns a LanguageModel object, not a string
expect(model).toHaveProperty('modelId', modelString.replace('gateway/', ''));
expect(model).toHaveProperty('modelId');
}
});

Expand All @@ -908,7 +904,7 @@ describe('ModelFactory', () => {
const config: ModelSettings = { model: modelString };
const model = ModelFactory.createModel(config);
expect(model).toBeDefined();
expect(model).toHaveProperty('modelId', modelString.replace('nim/', ''));
expect(model).toHaveProperty('modelId');
}
});

Expand Down Expand Up @@ -984,7 +980,7 @@ describe('ModelFactory', () => {
const gatewayModel = ModelFactory.createModel(gatewayConfig);

expect(openrouterModel).toBeDefined();
expect(openrouterModel.constructor.name).toBe('OpenRouterChatLanguageModel');
expect(openrouterModel).toHaveProperty('modelId');
expect(gatewayModel).toBeDefined();
expect(gatewayModel).toHaveProperty('modelId', baseModel);
}
Expand Down
73 changes: 58 additions & 15 deletions agents-api/src/domains/evals/services/EvaluationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createEvaluationRun,
type EvaluationResultSelect,
filterConversationsForJob,
GENERATION_TYPES,
generateId,
getConversationHistory,
getEvaluationJobConfigById,
Expand All @@ -25,7 +26,8 @@ import {
updateEvaluationResult,
withRef,
} from '@inkeep/agents-core';
import { generateObject, generateText } from 'ai';
import { context as otelContext, propagation } from '@opentelemetry/api';
import { generateText, Output } from 'ai';
import { z } from 'zod';
import manageDbClient from '../../../data/db/manageDbClient';
i 10BC8 mport manageDbPool from '../../../data/db/manageDbPool';
Expand All @@ -51,6 +53,14 @@ export interface ChatApiResponse {
error?: string;
}

function withOtelBaggage<T>(entries: Record<string, string>, fn: () => Promise<T>): Promise<T> {
const bag = Object.entries(entries).reduce(
(b, [key, value]) => b.setEntry(key, { value }),
propagation.getBaggage(otelContext.active()) ?? propagation.createBaggage()
);
return otelContext.with(propagation.setBaggage(otelContext.active(), bag), fn);
}

/**
* Service for running dataset items through the chat API endpoint
*/
Expand Down Expand Up @@ -346,10 +356,25 @@ export class EvaluationService {
'Generating next user message with simulation agent'
);

const simulationResponse = await generateText({
const simulationConfig = {
...simulationModelConfig,
prompt: simulationPrompt,
});
experimental_telemetry: {
isEnabled: true,
metadata: {
tenantId,
projectId,
agentId,
conversationId,
generationType: GENERATION_TYPES.EVAL_SIMULATION,
},
},
};

const simulationResponse = await withOtelBaggage(
{ 'tenant.id': tenantId, 'project.id': projectId, 'conversation.id': conversationId },
() => generateText(simulationConfig)
);

const nextUserMessage = simulationResponse.text.trim();

Expand Down Expand Up @@ -910,6 +935,9 @@ Generate the next user message:`;
prompt: evaluationPrompt,
modelConfig,
schema: schemaObj,
tenantId,
projectId,
conversationId: conversation.id,
});

return {
Expand Down Expand Up @@ -969,14 +997,17 @@ Return your evaluation as a JSON object matching the schema above.`;
}

/**
* Call LLM API using AI SDK's generateObject for structured output
* Call LLM API using AI SDK's generateText with structured output
*/
private async callLLM(params: {
prompt: string;
modelConfig: ModelSettings;
schema: Record<string, unknown>;
tenantId: string;
projectId: string;
conversationId: string;
}): Promise<{ result: Record<string, unknown>; metadata: Record<string, unknown> }> {
const { prompt, modelConfig, schema } = params;
const { prompt, modelConfig, schema, tenantId, projectId, conversationId } = params;

const languageModel = ModelFactory.prepareGenerationConfig(modelConfig);
const providerOptions = modelConfig?.providerOptions || {};
Expand All @@ -1002,23 +1033,35 @@ Return your evaluation as a JSON object matching the schema above.`;
const evaluationSchema = resultSchema;

try {
// Try generateObject first - this should work with proper schema
logger.info(
{
promptLength: prompt.length,
model: modelConfig.model,
},
'Calling generateObject'
'Calling generateText with structured output for eval scoring'
);
const result = await withOtelBaggage(
{ 'tenant.id': tenantId, 'project.id': projectId, 'conversation.id': conversationId },
() =>
generateText({
...languageModel,
output: Output.object({ schema: evaluationSchema }),
prompt,
temperature: (providerOptions.temperature as number) ?? 0.3,
experimental_telemetry: {
isEnabled: true,
metadata: {
tenantId,
projectId,
conversationId,
generationType: GENERATION_TYPES.EVAL_SCORING,
},
},
})
);
const result = await generateObject({
...languageModel,
schema: evaluationSchema,
prompt,
temperature: (providerOptions.temperature as number) ?? 0.3,
});

return {
result: result.object as Record<string, unknown>,
result: (result as any).object as Record<string, unknown>,
metadata: {
usage: result.usage,
},
Expand All @@ -1031,7 +1074,7 @@ Return your evaluation as a JSON object matching the schema above.`;
schema: JSON.stringify(schema, null, 2),
promptPreview: prompt.substring(0, 500),
},
'Evaluation failed with generateObject'
'Evaluation failed with generateText structured output'
);
throw new Error(`Evaluation failed: ${errorMessage}`);
}
Expand Down
Loading
Loading
0