Skip to content

Commit 9b6e920

Browse files
committed
feat(usage-ingest): accept authenticated usage events
Validate bounded usage events and await queue acceptance. Extract the shared Zod-only usage contracts while preserving compatibility exports and DB checks.
1 parent 1bb57cb commit 9b6e920

22 files changed

Lines changed: 675 additions & 190 deletions

‎packages/db/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"dependencies": {
3030
"@kilocode/app-shared": "workspace:*",
3131
"@kilocode/kiloclaw-instance-tiers": "workspace:*",
32+
"@kilocode/usage-contracts": "workspace:*",
3233
"drizzle-orm": "catalog:",
3334
"pg": "8.20.0",
3435
"zod": "catalog:"

‎packages/db/src/schema-types.ts‎

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,30 +1249,8 @@ export type AuthProviderId =
12491249
| 'fake-login'
12501250
| 'workos';
12511251

1252-
// --- AbuseClassification ---
1253-
1254-
export type AbuseClassification = (typeof ABUSE_CLASSIFICATION)[keyof typeof ABUSE_CLASSIFICATION];
1255-
export const ABUSE_CLASSIFICATION = {
1256-
NOT_ABUSE: -100,
1257-
CLASSIFICATION_ERROR: -50,
1258-
NOT_CLASSIFIED: 0,
1259-
LIKELY_ABUSE: 200,
1260-
} as const;
1261-
1262-
// --- Microdollar Usage --
1263-
1264-
export const GatewayApiKindSchema = z.enum([
1265-
'chat_completions',
1266-
'embeddings',
1267-
'fim_completions',
1268-
'edit_completions',
1269-
'messages',
1270-
'responses',
1271-
'audio_transcriptions',
1272-
'systemone',
1273-
]);
1274-
1275-
export type GatewayApiKind = z.infer<typeof GatewayApiKindSchema>;
1252+
export { ABUSE_CLASSIFICATION, GatewayApiKindSchema } from '@kilocode/usage-contracts';
1253+
export type { AbuseClassification, GatewayApiKind } from '@kilocode/usage-contracts';
12761254

12771255
// --- Integration types ---
12781256

‎packages/usage-contracts/README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Usage contracts
2+
3+
Shared usage request/response schemas, inferred types, and enum definitions for
4+
the gateway, usage-ingest Worker, and usage writer. Runtime dependencies are
5+
limited to Zod; keep this package independent of Next.js and database packages.
6+
7+
The original web-shared contract path and DB enum exports remain compatibility
8+
re-exports. Database/type alignment checks belong in web-shared, outside this
9+
package. Preserve nullable fields, safe integer limits, ISO timestamps, optional
10+
bouncer data, and unknown-key stripping when changing the contract.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@kilocode/usage-contracts",
3+
"version": "0.0.1",
4+
"private": true,
5+
"type": "module",
6+
"main": "./src/index.ts",
7+
"types": "./src/index.ts",
8+
"exports": {
9+
".": "./src/index.ts"
10+
},
11+
"scripts": {
12+
"typecheck": "tsgo --noEmit",
13+
"lint": "pnpm -w exec oxlint --config .oxlintrc.json packages/usage-contracts/src",
14+
"test": "vitest run"
15+
},
16+
"dependencies": {
17+
"zod": "catalog:"
18+
},
19+
"devDependencies": {
20+
"@typescript/native-preview": "catalog:",
21+
"typescript": "catalog:",
22+
"vitest": "catalog:"
23+
}
24+
}

packages/web-shared/src/lib/ai-gateway/usage-record-contract.test.ts renamed to packages/usage-contracts/src/index.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { describe, expect, test } from '@jest/globals';
1+
import { describe, expect, test } from 'vitest';
22

3-
import { UsageRecordRequestSchema } from './usage-record-contract';
3+
import { UsageRecordRequestSchema } from './index';
44

55
function validCore() {
66
return {
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { ABUSE_CLASSIFICATION, GatewayApiKindSchema } from './schema-types';
2+
import { z } from 'zod';
3+
4+
export { ABUSE_CLASSIFICATION, GatewayApiKindSchema } from './schema-types';
5+
export type { AbuseClassification, GatewayApiKind } from './schema-types';
6+
7+
/**
8+
* Wire contract for `POST /api/internal/usage/record`.
9+
*
10+
* The AI gateway runs on `kilocode-global-app`, whose functions execute in both
11+
* Frankfurt and SFO, while the PostgreSQL primary is Frankfurt-only. The usage
12+
* write holds row locks on `kilocode_users` / `organizations` /
13+
* `organization_user_usage` across several sequential statements, so from SFO
14+
* the lock hold is dominated by transatlantic round trips rather than database
15+
* work. This contract lets an SFO instance hand the whole write to a
16+
* Frankfurt-local endpoint so those round trips become local.
17+
*
18+
* Invariants this schema deliberately encodes:
19+
*
20+
* - Nullable fields use `.nullable()`. Except for additive rollout fields that
21+
* default to null, missing keys are rejected: `JSON.stringify` drops
22+
* `undefined`-valued keys, and `null` is load-bearing here. For organization
23+
* usage `toInsertableDbUsageRecord` explicitly sets `user_prompt_prefix` and
24+
* `system_prompt_prefix` to `null` so prompt text is never persisted.
25+
* - `created_at` is validated as strict ISO 8601. Both `core.created_at` and
26+
* `metadata.created_at` come from `new Date().toISOString()` on the sender, so
27+
* this holds by construction. It would NOT hold for a value read back out of
28+
* PostgreSQL, which has shape `2026-04-29 01:16:12.945+00` — see
29+
* `packages/db/AGENTS.md` "Timestamp boundaries". Keep the strict validator so
30+
* a DB-shaped timestamp can never enter this path.
31+
* - `cost` and the token counts are PostgreSQL `bigint` mapped to JS `number`,
32+
* so they are validated as safe integers.
33+
*
34+
* NUL-byte sanitization (`stripNulBytesInPlace`) happens on the sender inside
35+
* `toInsertableDbUsageRecord`, before serialization. `JSON.stringify` preserves
36+
* `\u0000` happily, so that sanitization is part of the sending contract: do not
37+
* move it to the receiver.
38+
*/
39+
40+
/** PostgreSQL `bigint` columns surfaced to JS as `number`. */
41+
const bigintAsNumber = z.number().int().safe();
42+
43+
const CoreUsageSchema = z.object({
44+
id: z.string().uuid(),
45+
kilo_user_id: z.string(),
46+
cost: bigintAsNumber,
47+
input_tokens: bigintAsNumber,
48+
output_tokens: bigintAsNumber,
49+
cache_write_tokens: bigintAsNumber,
50+
cache_hit_tokens: bigintAsNumber,
51+
created_at: z.string().datetime(),
52+
provider: z.string().nullable(),
53+
model: z.string().nullable(),
54+
requested_model: z.string().nullable(),
55+
cache_discount: bigintAsNumber.nullable(),
56+
has_error: z.boolean(),
57+
abuse_classification: z.nativeEnum(ABUSE_CLASSIFICATION),
58+
organization_id: z.string().uuid().nullable(),
59+
inference_provider: z.string().nullable(),
60+
project_id: z.string().nullable(),
61+
});
62+
63+
const UsageMetadataSchema = z.object({
64+
id: z.string(),
65+
message_id: z.string(),
66+
created_at: z.string().datetime(),
67+
http_x_forwarded_for: z.string().nullable(),
68+
http_x_vercel_ip_city: z.string().nullable(),
69+
http_x_vercel_ip_country: z.string().nullable(),
70+
http_x_vercel_ip_latitude: z.number().nullable(),
71+
http_x_vercel_ip_longitude: z.number().nullable(),
72+
http_x_vercel_ja4_digest: z.string().nullable(),
73+
user_prompt_prefix: z.string().nullable(),
74+
system_prompt_prefix: z.string().nullable(),
75+
system_prompt_length: z.number().int().nullable(),
76+
http_user_agent: z.string().nullable(),
77+
max_tokens: z.number().int().nullable(),
78+
has_middle_out_transform: z.boolean().nullable(),
79+
status_code: z.number().int().nullable(),
80+
upstream_id: z.string().nullable(),
81+
finish_reason: z.string().nullable(),
82+
latency: z.number().nullable(),
83+
moderation_latency: z.number().nullable(),
84+
generation_time: z.number().nullable(),
85+
is_byok: z.boolean().nullable(),
86+
is_user_byok: z.boolean(),
87+
streamed: z.boolean().nullable(),
88+
cancelled: z.boolean().nullable(),
89+
editor_name: z.string().nullable(),
90+
api_kind: GatewayApiKindSchema.nullable(),
91+
has_tools: z.boolean().nullable(),
92+
machine_id: z.string().nullable(),
93+
feature: z.string().nullable(),
94+
session_id: z.string().nullable(),
95+
mode: z.string().nullable(),
96+
auto_model: z.string().nullable(),
97+
// Like bouncer_usage_event, older gateway senders omit this additive field.
98+
// Default it to null so parsed metadata still satisfies UsageMetaData.
99+
reasoning_setting: z.string().nullable().default(null),
100+
market_cost: z.number().nullable(),
101+
is_free: z.boolean().nullable(),
102+
abuse_delay: z.number().nullable(),
103+
abuse_downgraded_from: z.string().nullable(),
104+
});
105+
106+
/**
107+
* The Bouncer usage event to enqueue in the usage-write transaction, for a request whose decide
108+
* verdict said `spendWatch`. `payload` is the shaped usage-event wire body; the outbox row's owner
109+
* is `core.kilo_user_id`.
110+
*/
111+
export const BouncerUsageEventEnqueueSchema = z.object({
112+
request_id: z.string().min(1),
113+
payload: z.record(z.string(), z.unknown()),
114+
});
115+
116+
export type BouncerUsageEventEnqueue = z.infer<typeof BouncerUsageEventEnqueueSchema>;
117+
118+
export const UsageRecordRequestSchema = z.object({
119+
core: CoreUsageSchema,
120+
metadata: UsageMetadataSchema,
121+
prior_microdollar_usage: z.number(),
122+
posthog_distinct_id: z.string().nullable(),
123+
// Optional, unlike the fields above: a sender from before this field existed omits it, and that
124+
// must still record the billing row. Null or absent means no outbox row.
125+
bouncer_usage_event: BouncerUsageEventEnqueueSchema.nullable().optional(),
126+
});
127+
128+
export type UsageRecordRequest = z.infer<typeof UsageRecordRequestSchema>;
129+
130+
/**
131+
* `duplicate` means the usage row already existed, so this delivery was a retry
132+
* of a call that had already committed. Callers must treat it as success: the
133+
* row is persisted and its post-commit side effects ran on the first delivery.
134+
*
135+
* `not_recorded` mirrors today's local behaviour where `insertUsageRecord`
136+
* swallows database errors and returns `null`.
137+
*/
138+
export const UsageRecordResponseSchema = z.object({
139+
status: z.enum(['recorded', 'duplicate', 'not_recorded']),
140+
result: z
141+
.object({
142+
usageId: z.string(),
143+
createdAt: z.string(),
144+
newMicrodollarsUsed: z.number().nullable(),
145+
})
146+
.nullable(),
147+
});
148+
149+
export type UsageRecordResponse = z.infer<typeof UsageRecordResponseSchema>;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { z } from 'zod';
2+
3+
// --- AbuseClassification ---
4+
5+
export type AbuseClassification = (typeof ABUSE_CLASSIFICATION)[keyof typeof ABUSE_CLASSIFICATION];
6+
export const ABUSE_CLASSIFICATION = {
7+
NOT_ABUSE: -100,
8+
CLASSIFICATION_ERROR: -50,
9+
NOT_CLASSIFIED: 0,
10+
LIKELY_ABUSE: 200,
11+
} as const;
12+
13+
// --- Microdollar Usage --
14+
15+
export const GatewayApiKindSchema = z.enum([
16+
'chat_completions',
17+
'embeddings',
18+
'fim_completions',
19+
'edit_completions',
20+
'messages',
21+
'responses',
22+
'audio_transcriptions',
23+
'systemone',
24+
]);
25+
26+
export type GatewayApiKind = z.infer<typeof GatewayApiKindSchema>;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"module": "ESNext",
5+
"moduleResolution": "bundler",
6+
"lib": ["ESNext", "WebWorker"],
7+
"strict": true,
8+
"skipLibCheck": true,
9+
"forceConsistentCasingInFileNames": true,
10+
"noEmit": true,
11+
"isolatedModules": true,
12+
"noImplicitReturns": true,
13+
"noFallthroughCasesInSwitch": true
14+
},
15+
"include": ["src/**/*"],
16+
"exclude": ["node_modules"]
17+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: { environment: 'node', include: ['src/**/*.test.ts'] },
5+
});

‎packages/web-shared/AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# web-shared
22

33
Server code shared by `apps/web` and `apps/ai-gateway`, moved out of `apps/web/src`.
4+
The usage wire contract lives in `@kilocode/usage-contracts`. The existing
5+
usage-record contract module re-exports it and checks alignment with DB types.
46

57
## Module resolution
68

0 commit comments

Comments
 (0)