Skip to content
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,12 @@ API_CRYPTO_KEY=
# (secret)
API_OPEN_AI_API_KEY=

# Explicit path to a Codex CLI auth.json file for local development only.
# Container deployments must bind-mount the file and set this path in a compose
# override. Production credentials should use encrypted BYOK settings instead.
# (type: string)
API_CODEX_AUTH_FILE=

# OpenAI-compatible endpoint override (e.g. point to a local model proxy).
# (type: url)
API_OPENAI_FORCE_BASE_URL=
Expand Down
7 changes: 7 additions & 0 deletions .env.schema
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,13 @@ API_CRYPTO_KEY=
# kodus: audience=both
API_OPEN_AI_API_KEY=

# Explicit path to a Codex CLI auth.json file for local development only.
# Container deployments must bind-mount the file and set this path in a compose
# override. Production credentials should use encrypted BYOK settings instead.
# @optional @type=string
# kodus: audience=both
API_CODEX_AUTH_FILE=

# OpenAI-compatible endpoint override (e.g. point to a local model proxy).
# @optional @type=url
# kodus: audience=both
Expand Down
6 changes: 6 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,12 @@ API_CRYPTO_KEY=op://Kodus-Dev/API_CRYPTO_KEY/password
# (secret)
API_OPEN_AI_API_KEY=op://Kodus-Dev/API_OPEN_AI_API_KEY/password

# Explicit path to a Codex CLI auth.json file for local development only.
# Container deployments must bind-mount the file and set this path in a compose
# override. Production credentials should use encrypted BYOK settings instead.
# (type: string)
API_CODEX_AUTH_FILE=

# OpenAI-compatible endpoint override (e.g. point to a local model proxy).
# (type: url)
API_OPENAI_FORCE_BASE_URL=
Expand Down
1 change: 1 addition & 0 deletions docs/_snippets/env-vars-generated.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
| Variable | Required | Type | Scope | Description |
| --- | --- | --- | --- | --- |
| `API_OPEN_AI_API_KEY` | – | secret | ⚙️ Both | OpenAI API key. Required when API_LLM_PROVIDER_MODEL targets an OpenAI model. |
| `API_CODEX_AUTH_FILE` | – | string | ⚙️ Both | Explicit path to a Codex CLI auth.json file for local development only. Container deployments must bind-mount the file and set this path in a compose override. Production credentials should use encrypted BYOK settings instead. |
| `API_OPENAI_FORCE_BASE_URL` | – | url | ⚙️ Both | OpenAI-compatible endpoint override (e.g. point to a local model proxy). |
| `API_TRUST_JSON_SCHEMA_BASE_URLS` | – | string | ⚙️ Both | Comma-separated baseURL substrings trusted for native json_schema structured output on BYOK openai_compatible providers. A capability allowlist: when a BYOK baseURL contains one of these, the code review agent sends response_format=json_schema instead of the slower json_object fallback. Leave empty unless you run a non-vLLM but schema-capable proxy. |
| `API_LLM_PROVIDER_MODEL` | – | string | ⚙️ Both | Force a specific model. "auto" = router picks per task. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,41 @@ describe('BuildUsageSummaryUseCase.execute (enrich)', () => {
expect(report.totalCost.total).toBeCloseTo(row.cost.total, 10);
});

it('reports subscription tokens without resolving or reporting per-token cost', async () => {
const useCase = makeUseCase(
[
{
model: 'chatgpt_subscription:gpt-5.6-luna',
input: 1_000,
output: 400,
total: 1_400,
outputReasoning: 200,
cacheRead: 0,
cacheWrite: 0,
},
],
{},
);

const report = await useCase.execute({} as any);

expect(report.byModel[0]).toMatchObject({
model: 'chatgpt_subscription:gpt-5.6-luna',
input: 1_000,
output: 400,
outputReasoning: 200,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
pricingSource: 'missing',
});
expect(report.totalCost.total).toBe(0);
});

it('prices a 3-bracket model per bracket and aligns costByTier with byTier', async () => {
const useCase = makeUseCase(
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import {

import { CacheService } from '@libs/core/cache/cache.service';

import { ModelCostCalculator } from './model-cost-calculator';
import {
isChatGptSubscriptionUsage,
ModelCostCalculator,
} from './model-cost-calculator';
import { PricingResolver } from './pricing-resolver';

// Overview cache TTLs. A window that ends before today is immutable (historical
Expand Down Expand Up @@ -165,7 +168,20 @@ export class BuildUsageSummaryUseCase {
row: BaseUsageContract,
overrides?: ManualPricingOverrides,
): Promise<EnrichedModelUsage> {
const resolved = await this.pricingResolver.resolve(row.model, overrides);
if (isChatGptSubscriptionUsage(row.model)) {
const costByTier = row.byTier?.map(() => zeroCost());
return {
...row,
cost: zeroCost(),
...(costByTier ? { costByTier } : {}),
pricingSource: 'missing',
};
}

const resolved = await this.pricingResolver.resolve(
row.model,
overrides,
);
const pricingSource = TO_API_SOURCE[resolved.source];

if (row.byTier) {
Expand Down Expand Up @@ -201,11 +217,7 @@ export class BuildUsageSummaryUseCase {
/** Epoch ms for 00:00 UTC today — the cutoff for "immutable past window". */
function startOfTodayUtc(): number {
const now = new Date();
return Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
);
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
}

function zeroCost(): CostBreakdown {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ const pricingFromMillions = (opts: {
const withTier = (def: number, tieredPerM?: number) => ({
default: def,
...(tieredPerM !== undefined
? { tiers: [{ threshold: 200_000, rate: perToken(tieredPerM) ?? 0 }] }
? {
tiers: [
{ threshold: 200_000, rate: perToken(tieredPerM) ?? 0 },
],
}
: {}),
});
return {
Expand Down Expand Up @@ -77,6 +81,24 @@ describe('ModelCostCalculator', () => {
expect(tokenPricingUseCase.execute).not.toHaveBeenCalled();
});

it('reports subscription usage but contributes zero to monthly spend', async () => {
const usage = {
input: 100_000,
output: 40_000,
outputReasoning: 20_000,
model: 'chatgpt_subscription:gpt-5.6-luna',
};

expect(await calculator.spendByModel([usage])).toEqual([
{
model: 'chatgpt_subscription:gpt-5.6-luna',
spentUsd: 0,
},
]);
expect(await calculator.totalCost([usage])).toBe(0);
expect(tokenPricingUseCase.execute).not.toHaveBeenCalled();
});

it('prices a flat row at default rates (no byTier present)', async () => {
tokenPricingUseCase.execute.mockResolvedValue(
pricingFromMillions({
Expand Down Expand Up @@ -119,17 +141,20 @@ describe('ModelCostCalculator', () => {
cacheRead: 200_000,
cacheWrite: 50_000,
model: 'g',
byTier: [tier({
byTier: [
tier({
input: 200_000,
output: 100_000,
cacheRead: 50_000,
cacheWrite: 20_000,
}), tier({
}),
tier({
input: 800_000,
output: 400_000,
cacheRead: 150_000,
cacheWrite: 30_000,
})],
}),
],
},
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import {
import { PricingResolver } from './pricing-resolver';

const UNKNOWN_MODEL = '(unknown)';
const CHATGPT_SUBSCRIPTION_PREFIX = 'chatgpt_subscription:';

export function isChatGptSubscriptionUsage(model: string): boolean {
return model.startsWith(CHATGPT_SUBSCRIPTION_PREFIX);
}

/**
* A usage row carrying token counts and (optionally) the model that produced
Expand Down Expand Up @@ -112,7 +117,8 @@ export class ModelCostCalculator {
agg: ModelUsageAgg,
overrides?: ManualPricingOverrides,
): Promise<number> {
if (model === UNKNOWN_MODEL) return 0;
if (model === UNKNOWN_MODEL || isChatGptSubscriptionUsage(model))
return 0;

const { rates } = await this.pricingResolver.resolve(model, overrides);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ const SEED_SPANS = [
'gen_ai.usage.input_tokens': 250000,
'gen_ai.usage.output_tokens': 50000,
'gen_ai.response.model': 'google_gemini:gemini-2.5-pro',
type: 'byok',
prNumber: 7,
'type': 'byok',
'prNumber': 7,
}),
// internal system-analysis run-name (sys=true) — excluded from byok=false view
span({
Expand All @@ -55,7 +55,7 @@ const SEED_SPANS = [
'gen_ai.usage.output_tokens': 200,
'gen_ai.usage.reasoning_tokens': 80,
'gen_ai.response.model': 'openai:gpt-5',
type: 'system',
'type': 'system',
'gen_ai.run.name': 'generateCodeSuggestions',
}),
// ordinary billable-view span
Expand All @@ -64,11 +64,19 @@ const SEED_SPANS = [
'gen_ai.usage.input_tokens': 400,
'gen_ai.usage.output_tokens': 100,
'gen_ai.response.model': 'claude-sonnet-5',
type: 'system',
'type': 'system',
'gen_ai.run.name': 'code-review-security',
}),
// subscription usage keeps its transport prefix so pricing remains suppressed
span({
'gen_ai.usage.total_tokens': 2,
'gen_ai.usage.input_tokens': 1,
'gen_ai.usage.output_tokens': 1,
'gen_ai.response.model': 'chatgpt_subscription:gpt-5.6-luna',
'type': 'byok',
}),
// wrapper span with NO usage — must be left unstamped
span({ 'gen_ai.response.model': 'claude-sonnet-5', type: 'system' }),
span({ 'gen_ai.response.model': 'claude-sonnet-5', 'type': 'system' }),
];

beforeAll(async () => {
Expand Down Expand Up @@ -104,7 +112,7 @@ describe('token-usage migration (integration)', () => {
const c = db.collection(TELEMETRY);
// Seed a legacy dead index to prove the drop.
await c.createIndex(
{ 'attributes.organizationId': 1, createdAt: -1 },
{ 'attributes.organizationId': 1, 'createdAt': -1 },
{ name: 'attributes.organizationId_1_createdAt_-1' },
);

Expand All @@ -116,7 +124,9 @@ describe('token-usage migration (integration)', () => {
expect(names).toContain('tu_cover_sys');
expect(names).not.toContain('attributes.organizationId_1_createdAt_-1');

const byok = (await c.indexes()).find((i) => i.name === 'tu_cover_byok');
const byok = (await c.indexes()).find(
(i) => i.name === 'tu_cover_byok',
);
expect(byok?.partialFilterExpression).toEqual({
'attributes.tu.isByok': true,
});
Expand All @@ -134,8 +144,11 @@ describe('token-usage migration (integration)', () => {
const res = await c.insertMany(SEED_SPANS.map((s) => ({ ...s })));
const ids = Object.values(res.insertedIds);

const first = await backfillTokenUsageTu(db, { sleepMs: 0, batch: 100 });
expect(first.stamped).toBe(3); // the 3 with usage; wrapper skipped
const first = await backfillTokenUsageTu(db, {
sleepMs: 0,
batch: 100,
});
expect(first.stamped).toBe(4); // the 4 with usage; wrapper skipped

// The load-bearing check: pipeline output === write-path deriveTu output.
const docs = await c.find({ _id: { $in: ids } }).toArray();
Expand All @@ -158,8 +171,18 @@ describe('token-usage migration (integration)', () => {
input: 250000,
total: 300000,
});

const second = await backfillTokenUsageTu(db, { sleepMs: 0, batch: 100 });
expect(
docs.find(
(d) =>
d.attributes.tu?.model ===
'chatgpt_subscription:gpt-5.6-luna',
)?.attributes.tu.model,
).toBe('chatgpt_subscription:gpt-5.6-luna');

const second = await backfillTokenUsageTu(db, {
sleepMs: 0,
batch: 100,
});
expect(second.stamped).toBe(0); // idempotent
});
});
Expand Down
31 changes: 26 additions & 5 deletions libs/core/infrastructure/database/mongo/token-usage/backfill-tu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* - Throttled: sleeps between batches to leave replication/WT-cache headroom.
*/
import { Db, ObjectId } from 'mongodb';
import { CHATGPT_SUBSCRIPTION_PREFIX } from '@libs/core/log/token-usage-tu';

const COLLECTION = 'observability_telemetry';

Expand Down Expand Up @@ -54,11 +55,29 @@ export const BACKFILL_ROUTING_TASKS = [

const gf = (f: string) => ({ $getField: { field: f, input: '$attributes' } });

// Canonical model = last ':'-segment of gen_ai.response.model (mirrors deriveTu).
export const BACKFILL_CHATGPT_SUBSCRIPTION_PREFIX = CHATGPT_SUBSCRIPTION_PREFIX;

const rawModelExpr = { $ifNull: [gf('gen_ai.response.model'), ''] };

// Canonical model = last ':'-segment for ordinary provider prefixes. Preserve
// the subscription prefix so included ChatGPT usage is never API-priced.
const modelExpr = {
$arrayElemAt: [
{ $split: [{ $ifNull: [gf('gen_ai.response.model'), ''] }, ':'] },
-1,
$cond: [
{
$eq: [
{
$indexOfCP: [
rawModelExpr,
BACKFILL_CHATGPT_SUBSCRIPTION_PREFIX,
],
},
0,
],
},
rawModelExpr,
{
$arrayElemAt: [{ $split: [rawModelExpr, ':'] }, -1],
},
],
};

Expand Down Expand Up @@ -181,7 +200,9 @@ const SET_TU = {
then: 'codeReview',
},
{
case: { $eq: ['$$a', 'kody_rules'] },
case: {
$eq: ['$$a', 'kody_rules'],
},
then: 'kodyRulesReview',
},
{
Expand Down
Loading