From a94cdad9de2d651afc7966d50375c1eabe7c6fbd Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Thu, 28 May 2026 12:06:50 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20unify=20debit=20fields=20=E2=80=94?= =?UTF-8?q?=20single=20Debit=20type=20(number=20|=20PriceExpr)=20across=20?= =?UTF-8?q?all=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- examples/ai-sdk-wrapper-usage.ts | 4 +- examples/ai-token-stream-expr-usage.ts | 14 +- examples/ai-token-stream-usage.ts | 8 +- examples/basic-usage-expr.ts | 8 +- examples/basic-usage.ts | 4 +- examples/middleware-usage.ts | 2 +- packages/scrawn/README.md | 2 +- packages/scrawn/src/core/ai/track.ts | 10 +- packages/scrawn/src/core/ai/types.ts | 18 +- packages/scrawn/src/core/pricing/builders.ts | 6 +- packages/scrawn/src/core/pricing/index.ts | 1 - packages/scrawn/src/core/pricing/types.ts | 27 +- packages/scrawn/src/core/scrawn.ts | 255 ++++++---------- packages/scrawn/src/core/types/event.ts | 173 +++++------ .../tests/unit/scrawn/middleware.test.ts | 8 +- .../scrawn/tests/unit/scrawn/scrawn.test.ts | 9 +- .../unit/types/aiTokenUsagePayload.test.ts | 277 +++--------------- .../tests/unit/types/eventPayload.test.ts | 147 ++-------- 19 files changed, 280 insertions(+), 695 deletions(-) diff --git a/README.md b/README.md index d9515bc..0dd1ece 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,6 @@ const biller = scrawn({ // Track a billable event await biller.sdkCallEventConsumer({ userId: "user-123", - debitAmount: 100, + debit: 100, }); ``` diff --git a/examples/ai-sdk-wrapper-usage.ts b/examples/ai-sdk-wrapper-usage.ts index cd00a12..c400986 100644 --- a/examples/ai-sdk-wrapper-usage.ts +++ b/examples/ai-sdk-wrapper-usage.ts @@ -6,8 +6,8 @@ config({ path: ".env.local" }); async function main() { const aii = biller.ai(ai, { - inputDebit: { tag: "PREMIUM_CALL" }, - outputDebit: { tag: "EXTRA_FEE" }, + inputDebit: biller.tag("PREMIUM_CALL"), + outputDebit: biller.tag("EXTRA_FEE"), }); const result = await aii.streamText({ diff --git a/examples/ai-token-stream-expr-usage.ts b/examples/ai-token-stream-expr-usage.ts index 6316c55..343e77b 100644 --- a/examples/ai-token-stream-expr-usage.ts +++ b/examples/ai-token-stream-expr-usage.ts @@ -18,10 +18,8 @@ async function* tokenUsageFromAIStream(): AsyncGenerator< model: "gpt-4", inputTokens: 150, outputTokens: 0, - inputDebit: { expr: biller.expr("PER_TOKEN_INPUT") }, - outputDebit: { - expr: biller.expr(mul(biller.tag("EXTRA_FEE"), outputTokens())), - }, + inputDebit: biller.expr("PER_TOKEN_INPUT"), + outputDebit: mul(biller.tag("EXTRA_FEE"), outputTokens()), }; yield { @@ -29,12 +27,8 @@ async function* tokenUsageFromAIStream(): AsyncGenerator< model: "gpt-4", inputTokens: 0, outputTokens: 75, - inputDebit: { - expr: biller.expr(mul(biller.tag("PREMIUM_CALL"), inputTokens())), - }, - outputDebit: { - expr: biller.expr(mul(biller.tag("EXTRA_FEE"), outputTokens())), - }, + inputDebit: mul(biller.tag("PREMIUM_CALL"), inputTokens()), + outputDebit: mul(biller.tag("EXTRA_FEE"), outputTokens()), }; } diff --git a/examples/ai-token-stream-usage.ts b/examples/ai-token-stream-usage.ts index 136fb2a..afc7785 100644 --- a/examples/ai-token-stream-usage.ts +++ b/examples/ai-token-stream-usage.ts @@ -9,8 +9,8 @@ async function* tokenUsageFromAIStream() { model: "gpt-4", inputTokens: 150, outputTokens: 0, - inputDebit: { amount: 1 }, - outputDebit: { amount: 0 }, + inputDebit: 1, + outputDebit: 0, }; // Output tokens as they stream @@ -19,8 +19,8 @@ async function* tokenUsageFromAIStream() { model: "gpt-4", inputTokens: 0, outputTokens: 75, - inputDebit: { amount: 0 }, - outputDebit: { amount: 1 }, + inputDebit: 0, + outputDebit: 1, }; } diff --git a/examples/basic-usage-expr.ts b/examples/basic-usage-expr.ts index e40a97e..1cd5963 100644 --- a/examples/basic-usage-expr.ts +++ b/examples/basic-usage-expr.ts @@ -6,19 +6,17 @@ config({ path: ".env.local" }); async function main() { await biller.basicUsageEventConsumer({ userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f", - debitExpr: biller.expr(mul(biller.tag("PREMIUM_CALL"), 3)), + debit: mul(biller.tag("PREMIUM_CALL"), 3), }); await biller.basicUsageEventConsumer({ userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f", - debitExpr: biller.expr(mul(biller.tag("EXTRA_FEE"), 3)), + debit: mul(biller.tag("EXTRA_FEE"), 3), }); await biller.basicUsageEventConsumer({ userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f", - debitExpr: biller.expr( - add(biller.expr("COMPLEX_FEE"), mul(biller.tag("PREMIUM_CALL"), 5)) - ), + debit: add(biller.expr("COMPLEX_FEE"), mul(biller.tag("PREMIUM_CALL"), 5)), }); console.log("Basic usage expression events consumed successfully"); diff --git a/examples/basic-usage.ts b/examples/basic-usage.ts index 3bcd10b..515563a 100644 --- a/examples/basic-usage.ts +++ b/examples/basic-usage.ts @@ -3,12 +3,12 @@ import { biller } from "./scrawn/biller.ts"; async function main() { await biller.basicUsageEventConsumer({ userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f", - debitAmount: 3000, + debit: 3000, }); await biller.basicUsageEventConsumer({ userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f", - debitTag: "PREMIUM_CALL", + debit: biller.tag("PREMIUM_CALL"), }); console.log("Basic usage events consumed successfully"); diff --git a/examples/middleware-usage.ts b/examples/middleware-usage.ts index 2cdcafc..be0ac46 100644 --- a/examples/middleware-usage.ts +++ b/examples/middleware-usage.ts @@ -8,7 +8,7 @@ app.use( biller.middlewareEventConsumer({ extractor: (req) => ({ userId: (req.headers?.["x-user-id"] as string) || "anonymous", - debitAmount: req.body?.cost || 1, + debit: req.body?.cost || 1, }), blacklist: ["/api/collect-payment", "/api/status"], }) diff --git a/packages/scrawn/README.md b/packages/scrawn/README.md index 8268f1e..dca2feb 100644 --- a/packages/scrawn/README.md +++ b/packages/scrawn/README.md @@ -35,6 +35,6 @@ const biller = scrawn({ // Track a billable event await biller.sdkCallEventConsumer({ userId: "user-123", - debitAmount: 100, + debit: 100, }); ``` diff --git a/packages/scrawn/src/core/ai/track.ts b/packages/scrawn/src/core/ai/track.ts index 90cdf7c..c93fd13 100644 --- a/packages/scrawn/src/core/ai/track.ts +++ b/packages/scrawn/src/core/ai/track.ts @@ -1,4 +1,4 @@ -import type { AITokenUsagePayload, DebitField } from "../types/event.js"; +import type { AITokenUsagePayload, Debit } from "../types/event.js"; import type { BillableCallParams, LanguageModelUsage, @@ -15,10 +15,10 @@ export function buildAIPayload( usage: LanguageModelUsage, overrides: BillableCallParams, defaults: { - inputDebit: DebitField; - outputDebit: DebitField; - inputCacheDebit: DebitField; - outputCacheDebit: DebitField; + inputDebit: Debit; + outputDebit: Debit; + inputCacheDebit: Debit; + outputCacheDebit: Debit; provider?: string; } ): AITokenUsagePayload { diff --git a/packages/scrawn/src/core/ai/types.ts b/packages/scrawn/src/core/ai/types.ts index f07621e..70b5fff 100644 --- a/packages/scrawn/src/core/ai/types.ts +++ b/packages/scrawn/src/core/ai/types.ts @@ -1,4 +1,4 @@ -import type { DebitField } from "../types/event.js"; +import type { Debit } from "../types/event.js"; /** * Configuration for the biller.ai() wrapper. @@ -6,13 +6,13 @@ import type { DebitField } from "../types/event.js"; */ export interface BillableAIOptions { /** Default billing for input tokens (required). */ - inputDebit: DebitField; + inputDebit: Debit; /** Default billing for output tokens (required). */ - outputDebit: DebitField; + outputDebit: Debit; /** Default billing for cached input tokens. Falls back to inputDebit if not set. */ - inputCacheDebit?: DebitField; + inputCacheDebit?: Debit; /** Default billing for cached output tokens. Falls back to outputDebit if not set. */ - outputCacheDebit?: DebitField; + outputCacheDebit?: Debit; /** Default provider override. If not set, auto-detected from the model's provider. */ provider?: string; } @@ -25,13 +25,13 @@ export interface BillableCallParams { /** The user ID to bill against. If omitted, billing is skipped. */ userId?: string; /** Override input token billing for this specific call. */ - inputDebit?: DebitField; + inputDebit?: Debit; /** Override output token billing for this specific call. */ - outputDebit?: DebitField; + outputDebit?: Debit; /** Override cached input token billing for this specific call. */ - inputCacheDebit?: DebitField; + inputCacheDebit?: Debit; /** Override cached output token billing for this specific call. */ - outputCacheDebit?: DebitField; + outputCacheDebit?: Debit; /** Override provider for this specific call. */ provider?: string; } diff --git a/packages/scrawn/src/core/pricing/builders.ts b/packages/scrawn/src/core/pricing/builders.ts index d4c3606..8c142ab 100644 --- a/packages/scrawn/src/core/pricing/builders.ts +++ b/packages/scrawn/src/core/pricing/builders.ts @@ -23,7 +23,6 @@ import type { PriceExpr, ExprInput, ExprRef, - ScrawnExpr, } from "./types.js"; import { validateExpr } from "./validate.js"; @@ -37,10 +36,7 @@ function toExpr( if (typeof input === "number") { return { kind: "amount", value: input } as const; } - if ("_expr" in (input as unknown as Record)) { - return (input as ScrawnExpr)._expr as PriceExpr; - } - return input as PriceExpr; + return input; } /** diff --git a/packages/scrawn/src/core/pricing/index.ts b/packages/scrawn/src/core/pricing/index.ts index 0c2bf22..211e245 100644 --- a/packages/scrawn/src/core/pricing/index.ts +++ b/packages/scrawn/src/core/pricing/index.ts @@ -31,7 +31,6 @@ export type { InputTokensExpr, OutputTokensExpr, ExprRef, - ScrawnExpr, PriceExpr, ExprInput, } from "./types.js"; diff --git a/packages/scrawn/src/core/pricing/types.ts b/packages/scrawn/src/core/pricing/types.ts index 4f7427c..af1e2bd 100644 --- a/packages/scrawn/src/core/pricing/types.ts +++ b/packages/scrawn/src/core/pricing/types.ts @@ -82,28 +82,6 @@ export interface ExprRef { readonly name: string; } -/** - * A wrapped pricing expression — the only type accepted by `debitExpr` fields. - * - * Created exclusively via `biller.expr()`. This wrapper ensures all expressions - * flow through a consistent entry point that provides type-safety for both - * inline expressions and persisted expression references. - * - * @typeParam TTag - The tag name type flowing through the expression tree - * - * @example - * ```typescript - * // inline expression - * const expr = biller.expr(mul(biller.tag("PREMIUM_CALL"), 3)); - * - * // persisted expression reference - * const expr = biller.expr("MY_EXPR"); - * ``` - */ -export interface ScrawnExpr { - readonly _expr: PriceExpr | ExprRef; -} - /** * A pricing expression - can be a literal amount, a tag reference, an operation, * a token placeholder (inputTokens/outputTokens), or a persisted expression reference. @@ -124,7 +102,4 @@ export type PriceExpr = * * @typeParam TTag - The tag name type flowing through the expression tree */ -export type ExprInput = - | PriceExpr - | ScrawnExpr - | number; +export type ExprInput = PriceExpr | number; diff --git a/packages/scrawn/src/core/scrawn.ts b/packages/scrawn/src/core/scrawn.ts index 707e4ec..b6c5bc6 100644 --- a/packages/scrawn/src/core/scrawn.ts +++ b/packages/scrawn/src/core/scrawn.ts @@ -8,19 +8,14 @@ import type { AITokenUsagePayload, EventConsumerErrorCallback, RetryContext, - DebitField, + Debit, } from "./types/event.js"; import type { AuthRegistry, AuthMethodName, AllCredentials, } from "./types/auth.js"; -import type { - TagExpr, - PriceExpr, - ExprRef, - ScrawnExpr, -} from "./pricing/types.js"; +import type { TagExpr, PriceExpr, ExprRef } from "./pricing/types.js"; import { ApiKeyAuth } from "./auth/apiKeyAuth.js"; import { ScrawnLogger } from "../utils/logger.js"; import { matchPath } from "../utils/pathMatcher.js"; @@ -71,6 +66,29 @@ import type { } from "./ai/types.js"; import { ScrawnConfig } from "../config.js"; import { randomUUID } from "node:crypto"; +import type { TokenContext } from "./pricing/index.js"; + +export type NormalizedDebit = + | { case: "amount"; value: number } + | { case: "expr"; value: string }; + +function normalizeDebit(debit: number | PriceExpr): NormalizedDebit { + if (typeof debit === "number") { + return { case: "amount", value: debit }; + } + return { case: "expr", value: serializeExpr(debit) }; +} + +function normalizeAIDebit( + debit: number | PriceExpr, + tokenContext: TokenContext +): NormalizedDebit { + if (typeof debit === "number") { + return { case: "amount", value: debit }; + } + const resolved = resolveTokens(debit, tokenContext); + return { case: "expr", value: serializeExpr(resolved) }; +} const log = new ScrawnLogger("Scrawn"); @@ -286,15 +304,13 @@ export class Scrawn< * }); * ``` */ - expr(name: T): ScrawnExpr; - expr(expr: PriceExpr): ScrawnExpr; - expr(value: string | PriceExpr): ScrawnExpr { - return { - _expr: - typeof value === "string" - ? ({ kind: "exprRef", name: value } as const) - : value, - }; + expr(name: T): PriceExpr; + expr(expr: PriceExpr): PriceExpr; + expr(value: string | PriceExpr): PriceExpr { + if (typeof value === "string") { + return { kind: "exprRef", name: value } as PriceExpr; + } + return value; } /** @@ -405,9 +421,7 @@ export class Scrawn< ): Promise { const rawPayload = { userId: payload.userId, - debitAmount: payload.debitAmount, - debitTag: payload.debitTag, - debitExpr: payload.debitExpr?._expr, + debit: payload.debit, metadata: payload.metadata, }; const validationResult = EventPayloadSchema.safeParse(rawPayload); @@ -428,9 +442,16 @@ export class Scrawn< const eventId = options?.eventId ?? randomUUID(); const idempotencyKey = randomUUID(); + const debit = normalizeDebit(validationResult.data.debit); + const normalizedPayload = { + userId: validationResult.data.userId, + debit, + metadata: validationResult.data.metadata, + }; + const attempt = () => this.consumeEvent( - validationResult.data, + normalizedPayload, "api", "RAW", eventId, @@ -566,9 +587,7 @@ export class Scrawn< const rawPayload = { userId: extractedPayload.userId, - debitAmount: extractedPayload.debitAmount, - debitTag: extractedPayload.debitTag, - debitExpr: extractedPayload.debitExpr?._expr, + debit: extractedPayload.debit, metadata: extractedPayload.metadata, }; const validationResult = EventPayloadSchema.safeParse(rawPayload); @@ -590,8 +609,15 @@ export class Scrawn< const eventId = randomUUID(); const idempotencyKey = randomUUID(); + const debit = normalizeDebit(validationResult.data.debit); + const normalizedPayload = { + userId: validationResult.data.userId, + debit, + metadata: validationResult.data.metadata, + }; + this.consumeEvent( - validationResult.data, + normalizedPayload, "api", "MIDDLEWARE_CALL", eventId, @@ -688,9 +714,7 @@ export class Scrawn< private async consumeEvent( payload: { userId: string; - debitAmount?: number; - debitTag?: string; - debitExpr?: PriceExpr; + debit: NormalizedDebit; metadata?: Record; }, authMethodName: K, @@ -718,28 +742,8 @@ export class Scrawn< const basicUsageType = eventType === "RAW" ? BasicUsageType.RAW : BasicUsageType.MIDDLEWARE_CALL; - // Build debit field based on which debit option is provided - let debitField: - | { case: "amount"; value: number } - | { case: "tag"; value: string } - | { case: "expr"; value: string }; - - if (payload.debitAmount !== undefined) { - debitField = { case: "amount" as const, value: payload.debitAmount }; - } else if (payload.debitTag !== undefined) { - debitField = { case: "tag" as const, value: payload.debitTag }; - } else { - const serialized = serializeExpr(payload.debitExpr!); - log.debug( - `Serialized pricing expression: ${serialized}\n${prettyPrintExpr( - payload.debitExpr! - )}` - ); - debitField = { - case: "expr" as const, - value: serialized, - }; - } + // Build debit field — already normalized by caller + const debitField = payload.debit; // Retry loop for retryable failures for (let attempt = 0; ; attempt++) { @@ -751,7 +755,6 @@ export class Scrawn< const basicUsage = { basicUsageType, amount: debitField.case === "amount" ? debitField.value : undefined, - tag: debitField.case === "tag" ? debitField.value : undefined, expr: debitField.case === "expr" ? debitField.value : undefined, metadata: payload.metadata ? JSON.stringify(payload.metadata) @@ -1015,40 +1018,19 @@ export class Scrawn< onError?: EventConsumerErrorCallback ) { for await (const payload of stream) { - // Unwrap ScrawnExpr before Zod validation const rawPayload = { userId: payload.userId, model: payload.model, inputTokens: payload.inputTokens, outputTokens: payload.outputTokens, - inputDebit: { - amount: payload.inputDebit.amount, - tag: payload.inputDebit.tag, - expr: payload.inputDebit.expr?._expr, - }, - outputDebit: { - amount: payload.outputDebit.amount, - tag: payload.outputDebit.tag, - expr: payload.outputDebit.expr?._expr, - }, + inputDebit: payload.inputDebit, + outputDebit: payload.outputDebit, metadata: payload.metadata, provider: payload.provider, inputCacheTokens: payload.inputCacheTokens, - inputCacheDebit: payload.inputCacheDebit - ? { - amount: payload.inputCacheDebit.amount, - tag: payload.inputCacheDebit.tag, - expr: payload.inputCacheDebit.expr?._expr, - } - : undefined, + inputCacheDebit: payload.inputCacheDebit, outputCacheTokens: payload.outputCacheTokens, - outputCacheDebit: payload.outputCacheDebit - ? { - amount: payload.outputCacheDebit.amount, - tag: payload.outputCacheDebit.tag, - expr: payload.outputCacheDebit.expr?._expr, - } - : undefined, + outputCacheDebit: payload.outputCacheDebit, }; // Validate each payload @@ -1074,105 +1056,48 @@ export class Scrawn< outputTokens: validated.outputTokens, }; - // Build input debit field (amount, tag, or expr) - let inputDebit: - | { case: "inputAmount"; value: number } - | { case: "inputTag"; value: string } - | { case: "inputExpr"; value: string }; - if (validated.inputDebit.amount !== undefined) { - inputDebit = { - case: "inputAmount" as const, - value: validated.inputDebit.amount, - }; - } else if (validated.inputDebit.tag !== undefined) { - inputDebit = { - case: "inputTag" as const, - value: validated.inputDebit.tag, - }; - } else { - const resolved = resolveTokens( - validated.inputDebit.expr!, - tokenContext - ); - const serialized = serializeExpr(resolved); - log.debug( - `Resolved input debit expression (inputTokens=${ - validated.inputTokens - }): ${serialized}\n${prettyPrintExpr(resolved)}` - ); - inputDebit = { - case: "inputExpr" as const, - value: serialized, - }; - } - - // Build output debit field (amount, tag, or expr) - let outputDebit: - | { case: "outputAmount"; value: number } - | { case: "outputTag"; value: string } - | { case: "outputExpr"; value: string }; - if (validated.outputDebit.amount !== undefined) { - outputDebit = { - case: "outputAmount" as const, - value: validated.outputDebit.amount, - }; - } else if (validated.outputDebit.tag !== undefined) { - outputDebit = { - case: "outputTag" as const, - value: validated.outputDebit.tag, - }; - } else { - const resolved = resolveTokens( - validated.outputDebit.expr!, - tokenContext - ); - const serialized = serializeExpr(resolved); - log.debug( - `Resolved output debit expression (outputTokens=${ - validated.outputTokens - }): ${serialized}\n${prettyPrintExpr(resolved)}` - ); - outputDebit = { - case: "outputExpr" as const, - value: serialized, - }; - } + // Normalize each debit with token resolution + const inputDebit = normalizeAIDebit(validated.inputDebit, tokenContext); + const outputDebit = normalizeAIDebit(validated.outputDebit, tokenContext); + const inputCacheDebit = + validated.inputCacheDebit !== undefined + ? normalizeAIDebit(validated.inputCacheDebit, tokenContext) + : undefined; + const outputCacheDebit = + validated.outputCacheDebit !== undefined + ? normalizeAIDebit(validated.outputCacheDebit, tokenContext) + : undefined; const aiTokenUsage = { model: validated.model, inputTokens: validated.inputTokens, outputTokens: validated.outputTokens, inputAmount: - inputDebit.case === "inputAmount" ? inputDebit.value : undefined, - inputTag: inputDebit.case === "inputTag" ? inputDebit.value : undefined, - inputExpr: - inputDebit.case === "inputExpr" ? inputDebit.value : undefined, + inputDebit.case === "amount" ? inputDebit.value : undefined, + inputExpr: inputDebit.case === "expr" ? inputDebit.value : undefined, outputAmount: - outputDebit.case === "outputAmount" ? outputDebit.value : undefined, - outputTag: - outputDebit.case === "outputTag" ? outputDebit.value : undefined, - outputExpr: - outputDebit.case === "outputExpr" ? outputDebit.value : undefined, + outputDebit.case === "amount" ? outputDebit.value : undefined, + outputExpr: outputDebit.case === "expr" ? outputDebit.value : undefined, metadata: validated.metadata ? JSON.stringify(validated.metadata) : undefined, provider: validated.provider ?? undefined, inputCacheTokens: validated.inputCacheTokens ?? 0, - inputCacheAmount: validated.inputCacheDebit?.amount ?? undefined, - inputCacheTag: validated.inputCacheDebit?.tag ?? undefined, - inputCacheExpr: validated.inputCacheDebit?.expr - ? serializeExpr( - resolveTokens(validated.inputCacheDebit.expr, tokenContext) - ) - : undefined, + inputCacheAmount: + inputCacheDebit?.case === "amount" + ? inputCacheDebit.value + : undefined, + inputCacheExpr: + inputCacheDebit?.case === "expr" ? inputCacheDebit.value : undefined, outputCacheTokens: validated.outputCacheTokens ?? 0, - outputCacheAmount: validated.outputCacheDebit?.amount ?? undefined, - outputCacheTag: validated.outputCacheDebit?.tag ?? undefined, - outputCacheExpr: validated.outputCacheDebit?.expr - ? serializeExpr( - resolveTokens(validated.outputCacheDebit.expr, tokenContext) - ) - : undefined, + outputCacheAmount: + outputCacheDebit?.case === "amount" + ? outputCacheDebit.value + : undefined, + outputCacheExpr: + outputCacheDebit?.case === "expr" + ? outputCacheDebit.value + : undefined, } as AITokenUsage; const eventId = randomUUID(); @@ -1263,10 +1188,10 @@ export class Scrawn< usage: LanguageModelUsage, overrides: BillableCallParams, defaults: { - inputDebit: DebitField; - outputDebit: DebitField; - inputCacheDebit: DebitField; - outputCacheDebit: DebitField; + inputDebit: Debit; + outputDebit: Debit; + inputCacheDebit: Debit; + outputCacheDebit: Debit; provider?: string; } ): void { diff --git a/packages/scrawn/src/core/types/event.ts b/packages/scrawn/src/core/types/event.ts index 50e93c8..7c8a9ce 100644 --- a/packages/scrawn/src/core/types/event.ts +++ b/packages/scrawn/src/core/types/event.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { PriceExpr, ScrawnExpr } from "../pricing/types.js"; +import type { PriceExpr } from "../pricing/types.js"; import type { ScrawnError } from "../errors/index.js"; import { isValidExpr, containsTokenExpr } from "../pricing/validate.js"; @@ -67,6 +67,24 @@ const PriceExprNoTokensSchema = z.custom>( */ const TAG_NAME_REGEX = /^[A-Z_]+$/; +/** + * Debit zod schema — validates a number or a pricing expression (rejects token placeholders). + * Used for basic usage / middleware events where token placeholders are invalid. + */ +const DebitSchemaNoTokens = z.union([ + z.number().nonnegative("debit amount must be non-negative"), + PriceExprNoTokensSchema, +]); + +/** + * Debit zod schema — validates a number or a pricing expression (allows token placeholders). + * Used for AI token usage payloads where inputTokens()/outputTokens() are valid. + */ +const DebitSchemaWithTokens = z.union([ + z.number().nonnegative("debit amount must be non-negative"), + PriceExprSchema, +]); + /** * Zod schema for event payload validation. * @@ -74,50 +92,36 @@ const TAG_NAME_REGEX = /^[A-Z_]+$/; * * Validates: * - userId: non-empty string - * - Exactly one of: debitAmount (number), debitTag (string), or debitExpr (PriceExpr) + * - debit: a non-negative number or a valid pricing expression (no token placeholders) */ -export const EventPayloadSchema = z - .object({ - userId: z.string().min(1, "userId must be a non-empty string"), - debitAmount: z - .number() - .positive("debitAmount must be a positive number") - .optional(), - debitTag: z - .string() - .min(1, "debitTag must be a non-empty string") - .regex( - TAG_NAME_REGEX, - "debitTag must be ALL CAPS with underscores only (e.g., PREMIUM_CALL, FEE). No lowercase, digits, or hyphens allowed." - ) - .optional(), - debitExpr: PriceExprNoTokensSchema.optional(), - metadata: z.record(z.string(), z.unknown()).optional(), - }) - .refine( - (data) => { - const defined = [ - data.debitAmount !== undefined, - data.debitTag !== undefined, - data.debitExpr !== undefined, - ].filter(Boolean).length; - return defined === 1; - }, - { - message: - "Exactly one of debitAmount, debitTag, or debitExpr must be provided", - } - ); +export const EventPayloadSchema = z.object({ + userId: z.string().min(1, "userId must be a non-empty string"), + debit: DebitSchemaNoTokens, + metadata: z.record(z.string(), z.unknown()).optional(), +}); /** - * Debit field for pricing — exactly one of amount, tag, or expr. + * A debit amount — a raw number of cents, a named price tag via `biller.tag()`, + * or a pricing expression via the DSL functions (`add`, `mul`, etc.). + * + * @typeParam TTag - The valid tag name union (defaults to `string` for untyped usage) * - * @typeParam TTag - The specific tag name literal type (defaults to `string`) + * @example + * ```typescript + * // Direct amount (500 cents = $5.00) + * debit: 500 + * + * // Named price tag (compile-time checked) + * debit: biller.tag("PREMIUM_FEATURE") + * + * // Pricing expression + * debit: mul(biller.tag("PER_TOKEN"), 100) + * + * // Persisted expression reference + * debit: biller.expr("SAVED_RATE") + * ``` */ -export type DebitField = - | { amount: number; tag?: never; expr?: never } - | { amount?: never; tag: TTag; expr?: never } - | { amount?: never; tag?: never; expr: ScrawnExpr }; +export type Debit = number | PriceExpr; /** * Payload structure for event tracking. @@ -127,41 +131,34 @@ export type DebitField = * @typeParam TTag - The valid tag name union (defaults to `string` for untyped usage) * * @property userId - The user ID associated with this event - * @property debitAmount - (Optional) Direct amount to debit in cents - * @property debitTag - (Optional) Named price tag to look up amount from backend - * @property debitExpr - (Optional) Pricing expression for complex calculations - * - * Note: Exactly one of debitAmount, debitTag, or debitExpr must be provided. + * @property debit - The debit amount (number, tag, or expression) * * @example * ```typescript - * import { add, mul } from '@scrawn/core'; * import { biller } from './scrawn/biller'; * * // Using direct amount * const payload1: EventPayload = { * userId: 'u123', - * debitAmount: 500 // 500 cents = $5.00 + * debit: 500 // 500 cents = $5.00 * }; * * // Using price tag (compile-time checked) * const payload2: EventPayload = { * userId: 'u123', - * debitTag: 'PREMIUM_FEATURE' + * debit: biller.tag('PREMIUM_FEATURE') * }; * * // Using pricing expression * const payload3: EventPayload = { * userId: 'u123', - * debitExpr: add(mul(biller.tag('PREMIUM_CALL'), 3), biller.tag('EXTRA_FEE'), 250) + * debit: mul(biller.tag('PREMIUM_CALL'), 3) * }; * ``` */ export type EventPayload = { userId: string; - debitAmount?: number; - debitTag?: TTag; - debitExpr?: ScrawnExpr; + debit: Debit; metadata?: Record; }; @@ -304,38 +301,6 @@ export interface MiddlewareEventConfig { onError?: EventConsumerErrorCallback; } -/** - * Debit field schema for AI token usage. - * - * Represents a direct amount, a named price tag, or a pricing expression for billing. - * Exactly one of amount, tag, or expr must be provided. - * Tag names must be ALL CAPS with underscores only (e.g., CLAUDE_INPUT, GPT4_OUTPUT_RATE). - */ -const DebitFieldSchema = z - .object({ - amount: z.number().nonnegative("amount must be non-negative").optional(), - tag: z - .string() - .min(1, "tag must be a non-empty string") - .regex( - TAG_NAME_REGEX, - "tag must be ALL CAPS with underscores only (e.g., CLAUDE_INPUT, FEE). No lowercase, digits, or hyphens allowed." - ) - .optional(), - expr: PriceExprSchema.optional(), - }) - .refine( - (data) => { - const defined = [ - data.amount !== undefined, - data.tag !== undefined, - data.expr !== undefined, - ].filter(Boolean).length; - return defined === 1; - }, - { message: "Exactly one of amount, tag, or expr must be provided" } - ); - /** * Zod schema for AI token usage payload validation. * @@ -346,13 +311,13 @@ const DebitFieldSchema = z * - model: non-empty string (e.g., 'gpt-4', 'claude-3') * - inputTokens: non-negative integer * - outputTokens: non-negative integer - * - inputDebit: exactly one of amount (number), tag (string), or expr (PriceExpr) - * - outputDebit: exactly one of amount (number), tag (string), or expr (PriceExpr) + * - inputDebit: a non-negative number or a valid pricing expression (token placeholders allowed) + * - outputDebit: a non-negative number or a valid pricing expression (token placeholders allowed) * - provider: optional non-empty string * - inputCacheTokens: optional non-negative integer - * - inputCacheDebit: optional one of amount, tag, or expr + * - inputCacheDebit: optional debit (token placeholders allowed) * - outputCacheTokens: optional non-negative integer - * - outputCacheDebit: optional one of amount, tag, or expr + * - outputCacheDebit: optional debit (token placeholders allowed) */ export const AITokenUsagePayloadSchema = z.object({ userId: z.string().min(1, "userId must be a non-empty string"), @@ -365,8 +330,8 @@ export const AITokenUsagePayloadSchema = z.object({ .number() .int("outputTokens must be an integer") .nonnegative("outputTokens must be non-negative"), - inputDebit: DebitFieldSchema, - outputDebit: DebitFieldSchema, + inputDebit: DebitSchemaWithTokens, + outputDebit: DebitSchemaWithTokens, metadata: z.record(z.string(), z.unknown()).optional(), provider: z.string().min(1, "provider must be a non-empty string").optional(), inputCacheTokens: z @@ -374,13 +339,13 @@ export const AITokenUsagePayloadSchema = z.object({ .int("inputCacheTokens must be an integer") .nonnegative("inputCacheTokens must be non-negative") .optional(), - inputCacheDebit: DebitFieldSchema.optional(), + inputCacheDebit: DebitSchemaWithTokens.optional(), outputCacheTokens: z .number() .int("outputCacheTokens must be an integer") .nonnegative("outputCacheTokens must be non-negative") .optional(), - outputCacheDebit: DebitFieldSchema.optional(), + outputCacheDebit: DebitSchemaWithTokens.optional(), }); /** @@ -415,8 +380,8 @@ export const AITokenUsagePayloadSchema = z.object({ * model: 'gpt-4', * inputTokens: 100, * outputTokens: 50, - * inputDebit: { amount: 3 }, // 3 cents - * outputDebit: { amount: 6 } // 6 cents + * inputDebit: 3, // 3 cents per input token + * outputDebit: 6 // 6 cents per output token * }; * * // Using price tags (compile-time checked) @@ -425,8 +390,8 @@ export const AITokenUsagePayloadSchema = z.object({ * model: 'claude-3-opus', * inputTokens: 200, * outputTokens: 100, - * inputDebit: { tag: 'CLAUDE_INPUT' }, - * outputDebit: { tag: 'CLAUDE_OUTPUT' } + * inputDebit: biller.tag('CLAUDE_INPUT'), + * outputDebit: biller.tag('CLAUDE_OUTPUT') * }; * * // Using pricing expressions (e.g., per-token pricing) @@ -435,8 +400,8 @@ export const AITokenUsagePayloadSchema = z.object({ * model: 'gpt-4', * inputTokens: 100, * outputTokens: 50, - * inputDebit: { expr: mul(biller.tag('GPT_INPUT_RATE'), inputTokens()) }, - * outputDebit: { expr: mul(biller.tag('GPT_OUTPUT_RATE'), outputTokens()) } + * inputDebit: mul(biller.tag('GPT_INPUT_RATE'), inputTokens()), + * outputDebit: mul(biller.tag('GPT_OUTPUT_RATE'), outputTokens()) * }; * ``` */ @@ -445,17 +410,17 @@ export type AITokenUsagePayload = { model: string; inputTokens: number; outputTokens: number; - inputDebit: DebitField; - outputDebit: DebitField; + inputDebit: Debit; + outputDebit: Debit; metadata?: Record; /** Optional LLM provider identifier (e.g. 'openai', 'anthropic'). */ provider?: string; /** Number of cached input tokens used (typically cheaper). */ inputCacheTokens?: number; - /** Debit pricing for cached input tokens (oneof amount, tag, or expr). */ - inputCacheDebit?: DebitField; + /** Debit pricing for cached input tokens. */ + inputCacheDebit?: Debit; /** Number of cached output tokens used (typically cheaper). */ outputCacheTokens?: number; - /** Debit pricing for cached output tokens (oneof amount, tag, or expr). */ - outputCacheDebit?: DebitField; + /** Debit pricing for cached output tokens. */ + outputCacheDebit?: Debit; }; diff --git a/packages/scrawn/tests/unit/scrawn/middleware.test.ts b/packages/scrawn/tests/unit/scrawn/middleware.test.ts index 6ef5637..6207201 100644 --- a/packages/scrawn/tests/unit/scrawn/middleware.test.ts +++ b/packages/scrawn/tests/unit/scrawn/middleware.test.ts @@ -57,7 +57,7 @@ describe("middlewareEventConsumer", () => { }); attachMockClient(biller); const middleware = biller.middlewareEventConsumer({ - extractor: () => ({ userId: "user_1", debitAmount: 2 }), + extractor: () => ({ userId: "user_1", debit: 2 }), whitelist: ["/api/**"], }); @@ -76,7 +76,7 @@ describe("middlewareEventConsumer", () => { }); attachMockClient(biller); const middleware = biller.middlewareEventConsumer({ - extractor: () => ({ userId: "user_1", debitAmount: 2 }), + extractor: () => ({ userId: "user_1", debit: 2 }), whitelist: ["/billing/**"], }); @@ -117,7 +117,7 @@ describe("middlewareEventConsumer", () => { requestError = new Error("grpc down"); const middleware = biller.middlewareEventConsumer({ - extractor: () => ({ userId: "user_1", debitAmount: 2 }), + extractor: () => ({ userId: "user_1", debit: 2 }), onError, }); @@ -140,7 +140,7 @@ describe("middlewareEventConsumer", () => { const onError = vi.fn(); const middleware = biller.middlewareEventConsumer({ - extractor: () => ({ userId: "", debitAmount: 2 }), + extractor: () => ({ userId: "", debit: 2 }), onError, }); diff --git a/packages/scrawn/tests/unit/scrawn/scrawn.test.ts b/packages/scrawn/tests/unit/scrawn/scrawn.test.ts index 93004d9..690703d 100644 --- a/packages/scrawn/tests/unit/scrawn/scrawn.test.ts +++ b/packages/scrawn/tests/unit/scrawn/scrawn.test.ts @@ -62,7 +62,7 @@ describe("Scrawn", () => { }); attachMockClient(biller); - await biller.basicUsageEventConsumer({ userId: "user_1", debitAmount: 5 }); + await biller.basicUsageEventConsumer({ userId: "user_1", debit: 5 }); const request = requestMock.mock.calls[0][0] as any; expect(request.userId).toBe("user_1"); @@ -82,10 +82,7 @@ describe("Scrawn", () => { const onError = vi.fn(); - await biller.basicUsageEventConsumer( - { userId: "", debitAmount: 5 }, - { onError } - ); + await biller.basicUsageEventConsumer({ userId: "", debit: 5 }, { onError }); expect(onError).toHaveBeenCalledTimes(1); const error = onError.mock.calls[0][0]; @@ -134,7 +131,7 @@ describe("Scrawn", () => { attachMockClient(biller); await biller.basicUsageEventConsumer( - { userId: "user_1", debitAmount: 5 }, + { userId: "user_1", debit: 5 }, { onError } ); diff --git a/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts b/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts index cfda619..d856832 100644 --- a/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts +++ b/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts @@ -16,8 +16,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { amount: 0.003 }, - outputDebit: { amount: 0.006 }, + inputDebit: 3, + outputDebit: 6, }); expect(result.success).toBe(true); @@ -29,8 +29,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "claude-3-opus", inputTokens: 200, outputTokens: 100, - inputDebit: { tag: "CLAUDE_INPUT" }, - outputDebit: { tag: "CLAUDE_OUTPUT" }, + inputDebit: tag("CLAUDE_INPUT"), + outputDebit: tag("CLAUDE_OUTPUT"), }); expect(result.success).toBe(true); @@ -42,8 +42,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { expr: mul(tag("GPT_INPUT_RATE"), 100) }, - outputDebit: { expr: mul(tag("GPT_OUTPUT_RATE"), 50) }, + inputDebit: mul(tag("GPT_INPUT_RATE"), 100), + outputDebit: mul(tag("GPT_OUTPUT_RATE"), 50), }); expect(result.success).toBe(true); @@ -55,10 +55,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { - expr: add(mul(tag("BASE_RATE"), 100), tag("PREMIUM_FEE")), - }, - outputDebit: { expr: mul(tag("OUTPUT_RATE"), 50) }, + inputDebit: add(mul(tag("BASE_RATE"), 100), tag("PREMIUM_FEE")), + outputDebit: mul(tag("OUTPUT_RATE"), 50), }); expect(result.success).toBe(true); @@ -70,34 +68,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { amount: 0.003 }, - outputDebit: { tag: "OUTPUT_TAG" }, - }); - - expect(result.success).toBe(true); - }); - - it("accepts payloads mixing expr with amount", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { expr: mul(tag("INPUT_RATE"), 100) }, - outputDebit: { amount: 6 }, - }); - - expect(result.success).toBe(true); - }); - - it("accepts payloads mixing expr with tag", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { tag: "INPUT_TAG" }, - outputDebit: { expr: mul(tag("OUTPUT_RATE"), 50) }, + inputDebit: 3, + outputDebit: tag("OUTPUT_TAG"), }); expect(result.success).toBe(true); @@ -109,8 +81,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 0, outputTokens: 0, - inputDebit: { amount: 0 }, - outputDebit: { amount: 0 }, + inputDebit: 0, + outputDebit: 0, }); expect(result.success).toBe(true); @@ -124,8 +96,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { amount: 0.003 }, - outputDebit: { amount: 0.006 }, + inputDebit: 3, + outputDebit: 6, }); expect(result.success).toBe(false); @@ -137,8 +109,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "", inputTokens: 100, outputTokens: 50, - inputDebit: { amount: 0.003 }, - outputDebit: { amount: 0.006 }, + inputDebit: 3, + outputDebit: 6, }); expect(result.success).toBe(false); @@ -150,8 +122,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: -10, outputTokens: 50, - inputDebit: { amount: 0.003 }, - outputDebit: { amount: 0.006 }, + inputDebit: 3, + outputDebit: 6, }); expect(result.success).toBe(false); @@ -163,8 +135,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: -5, - inputDebit: { amount: 0.003 }, - outputDebit: { amount: 0.006 }, + inputDebit: 3, + outputDebit: 6, }); expect(result.success).toBe(false); @@ -176,213 +148,59 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100.5, outputTokens: 50, - inputDebit: { amount: 0.003 }, - outputDebit: { amount: 0.006 }, + inputDebit: 3, + outputDebit: 6, }); expect(result.success).toBe(false); }); - it("rejects payloads with both amount and tag in inputDebit", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { amount: 0.003, tag: "INPUT_TAG" }, - outputDebit: { amount: 0.006 }, - }); - - expect(result.success).toBe(false); - }); - - it("rejects payloads with both amount and tag in outputDebit", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { amount: 0.003 }, - outputDebit: { amount: 0.006, tag: "OUTPUT_TAG" }, - }); - - expect(result.success).toBe(false); - }); - - it("rejects payloads with both amount and expr in inputDebit", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { amount: 3, expr: tag("INPUT") }, - outputDebit: { amount: 6 }, - }); - - expect(result.success).toBe(false); - }); - - it("rejects payloads with both tag and expr in outputDebit", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { amount: 3 }, - outputDebit: { tag: "OUTPUT", expr: tag("OUTPUT_EXPR") }, - }); - - expect(result.success).toBe(false); - }); - - it("rejects payloads with all three in debit", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { amount: 3, tag: "TAG", expr: tag("EXPR") }, - outputDebit: { amount: 6 }, - }); - - expect(result.success).toBe(false); - }); - - it("rejects payloads with empty inputDebit", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: {}, - outputDebit: { amount: 0.006 }, - }); - - expect(result.success).toBe(false); - }); - - it("rejects payloads with empty outputDebit", () => { + it("rejects payloads with invalid expr", () => { const result = AITokenUsagePayloadSchema.safeParse({ userId: "user_1", model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { amount: 0.003 }, - outputDebit: {}, + inputDebit: { invalid: "expression" }, + outputDebit: 6, }); expect(result.success).toBe(false); }); - it("rejects payloads with negative debit amount", () => { + it("rejects payloads missing required fields", () => { const result = AITokenUsagePayloadSchema.safeParse({ userId: "user_1", model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { amount: -0.003 }, - outputDebit: { amount: 0.006 }, }); expect(result.success).toBe(false); }); - it("rejects payloads with empty debit tag", () => { + it("rejects payloads with missing debit", () => { const result = AITokenUsagePayloadSchema.safeParse({ userId: "user_1", model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { tag: "" }, - outputDebit: { amount: 0.006 }, + inputDebit: 3, }); expect(result.success).toBe(false); }); - it("rejects payloads with invalid expr", () => { + it("rejects negative debit", () => { const result = AITokenUsagePayloadSchema.safeParse({ userId: "user_1", model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { expr: { invalid: "expression" } }, - outputDebit: { amount: 6 }, - }); - - expect(result.success).toBe(false); - }); - - it("rejects payloads missing required fields", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", + inputDebit: -3, + outputDebit: 6, }); expect(result.success).toBe(false); }); - - describe("tag format validation", () => { - it("rejects lowercase tag in inputDebit", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { tag: "claude_input" }, - outputDebit: { tag: "CLAUDE_OUTPUT" }, - }); - expect(result.success).toBe(false); - }); - - it("rejects lowercase tag in outputDebit", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { tag: "CLAUDE_INPUT" }, - outputDebit: { tag: "claude_output" }, - }); - expect(result.success).toBe(false); - }); - - it("rejects tag with digits", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { tag: "GPT4_INPUT" }, - outputDebit: { tag: "CLAUDE_OUTPUT" }, - }); - expect(result.success).toBe(false); - }); - - it("rejects tag with hyphens", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { tag: "CLAUDE-INPUT" }, - outputDebit: { tag: "CLAUDE_OUTPUT" }, - }); - expect(result.success).toBe(false); - }); - - it("rejects mixed case tag", () => { - const result = AITokenUsagePayloadSchema.safeParse({ - userId: "user_1", - model: "gpt-4", - inputTokens: 100, - outputTokens: 50, - inputDebit: { tag: "Claude_Input" }, - outputDebit: { tag: "CLAUDE_OUTPUT" }, - }); - expect(result.success).toBe(false); - }); - }); }); describe("token placeholder expressions", () => { @@ -392,8 +210,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { expr: mul(tag("INPUT_RATE"), inputTokens()) }, - outputDebit: { amount: 6 }, + inputDebit: mul(tag("INPUT_RATE"), inputTokens()), + outputDebit: 6, }); expect(result.success).toBe(true); }); @@ -404,8 +222,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { amount: 3 }, - outputDebit: { expr: mul(tag("OUTPUT_RATE"), outputTokens()) }, + inputDebit: 3, + outputDebit: mul(tag("OUTPUT_RATE"), outputTokens()), }); expect(result.success).toBe(true); }); @@ -416,8 +234,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { expr: mul(tag("INPUT_RATE"), inputTokens()) }, - outputDebit: { expr: mul(tag("OUTPUT_RATE"), outputTokens()) }, + inputDebit: mul(tag("INPUT_RATE"), inputTokens()), + outputDebit: mul(tag("OUTPUT_RATE"), outputTokens()), }); expect(result.success).toBe(true); }); @@ -428,34 +246,35 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { - expr: add(mul(tag("BASE_RATE"), inputTokens()), tag("PREMIUM_FEE")), - }, - outputDebit: { expr: mul(tag("OUTPUT_RATE"), outputTokens()) }, + inputDebit: add( + mul(tag("BASE_RATE"), inputTokens()), + tag("PREMIUM_FEE") + ), + outputDebit: mul(tag("OUTPUT_RATE"), outputTokens()), }); expect(result.success).toBe(true); }); - it("accepts standalone inputTokens() as expr", () => { + it("accepts standalone inputTokens() as debit", () => { const result = AITokenUsagePayloadSchema.safeParse({ userId: "user_1", model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { expr: inputTokens() }, - outputDebit: { amount: 6 }, + inputDebit: inputTokens(), + outputDebit: 6, }); expect(result.success).toBe(true); }); - it("accepts standalone outputTokens() as expr", () => { + it("accepts standalone outputTokens() as debit", () => { const result = AITokenUsagePayloadSchema.safeParse({ userId: "user_1", model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { amount: 3 }, - outputDebit: { expr: outputTokens() }, + inputDebit: 3, + outputDebit: outputTokens(), }); expect(result.success).toBe(true); }); diff --git a/packages/scrawn/tests/unit/types/eventPayload.test.ts b/packages/scrawn/tests/unit/types/eventPayload.test.ts index 7b64a4e..fd7442b 100644 --- a/packages/scrawn/tests/unit/types/eventPayload.test.ts +++ b/packages/scrawn/tests/unit/types/eventPayload.test.ts @@ -9,214 +9,131 @@ import { } from "../../../src/core/pricing/index.js"; describe("EventPayloadSchema", () => { - it("accepts payloads with debitAmount", () => { + it("accepts payloads with debit as a number", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitAmount: 10, + debit: 10, }); expect(result.success).toBe(true); }); - it("accepts payloads with debitTag", () => { + it("accepts payloads with debit as a tag expression", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitTag: "PREMIUM", + debit: tag("PREMIUM"), }); expect(result.success).toBe(true); }); - it("accepts payloads with debitExpr (simple tag)", () => { + it("accepts payloads with debit as a simple expression", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitExpr: tag("PREMIUM_CALL"), + debit: tag("PREMIUM_CALL"), }); expect(result.success).toBe(true); }); - it("accepts payloads with debitExpr (complex expression)", () => { + it("accepts payloads with debit as a complex expression", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitExpr: add(mul(tag("PREMIUM_CALL"), 3), tag("EXTRA_FEE"), 250), + debit: add(mul(tag("PREMIUM_CALL"), 3), tag("EXTRA_FEE"), 250), }); expect(result.success).toBe(true); }); - it("rejects payloads with both debitAmount and debitTag", () => { + it("rejects payloads without debit", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitAmount: 5, - debitTag: "PREMIUM", }); expect(result.success).toBe(false); }); - it("rejects payloads with both debitAmount and debitExpr", () => { - const result = EventPayloadSchema.safeParse({ - userId: "user_1", - debitAmount: 5, - debitExpr: tag("PREMIUM"), - }); - - expect(result.success).toBe(false); - }); - - it("rejects payloads with both debitTag and debitExpr", () => { - const result = EventPayloadSchema.safeParse({ - userId: "user_1", - debitTag: "PREMIUM", - debitExpr: tag("OTHER"), - }); - - expect(result.success).toBe(false); - }); - - it("rejects payloads with all three debit fields", () => { + it("rejects invalid userId values", () => { const result = EventPayloadSchema.safeParse({ - userId: "user_1", - debitAmount: 5, - debitTag: "PREMIUM", - debitExpr: tag("OTHER"), + userId: "", + debit: 2, }); expect(result.success).toBe(false); }); - it("rejects payloads without debit info", () => { + it("rejects invalid debit (not a number or PriceExpr)", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", + debit: { invalid: "expression" }, }); expect(result.success).toBe(false); }); - it("rejects invalid userId values", () => { - const result = EventPayloadSchema.safeParse({ - userId: "", - debitAmount: 2, - }); - - expect(result.success).toBe(false); - }); - - it("rejects invalid debitExpr (not a valid PriceExpr)", () => { + it("rejects debit with invalid nested expression", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitExpr: { invalid: "expression" }, + debit: { kind: "amount", value: 2.5 }, }); expect(result.success).toBe(false); }); - it("rejects debitExpr with invalid nested expression", () => { - // Manually construct an invalid expression (non-integer amount) + it("rejects negative debit amount", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitExpr: { kind: "amount", value: 2.5 }, + debit: -5, }); expect(result.success).toBe(false); }); - describe("debitTag format validation", () => { - it("accepts ALL_CAPS debitTag", () => { - const result = EventPayloadSchema.safeParse({ - userId: "user_1", - debitTag: "PREMIUM_FEATURE", - }); - expect(result.success).toBe(true); - }); - - it("rejects lowercase debitTag", () => { - const result = EventPayloadSchema.safeParse({ - userId: "user_1", - debitTag: "premium", - }); - expect(result.success).toBe(false); - }); - - it("rejects mixed case debitTag", () => { - const result = EventPayloadSchema.safeParse({ - userId: "user_1", - debitTag: "Premium_Feature", - }); - expect(result.success).toBe(false); - }); - - it("rejects debitTag with digits", () => { - const result = EventPayloadSchema.safeParse({ - userId: "user_1", - debitTag: "GPT4_CALL", - }); - expect(result.success).toBe(false); - }); - - it("rejects debitTag with hyphens", () => { - const result = EventPayloadSchema.safeParse({ - userId: "user_1", - debitTag: "PREMIUM-CALL", - }); - expect(result.success).toBe(false); - }); - - it("rejects debitTag with special characters", () => { - const result = EventPayloadSchema.safeParse({ - userId: "user_1", - debitTag: "PREMIUM.CALL", - }); - expect(result.success).toBe(false); - }); - }); - describe("token placeholder rejection", () => { - it("rejects debitExpr containing inputTokens()", () => { + it("rejects debit containing inputTokens()", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitExpr: mul(tag("RATE"), inputTokens()), + debit: mul(tag("RATE"), inputTokens()), }); expect(result.success).toBe(false); }); - it("rejects debitExpr containing outputTokens()", () => { + it("rejects debit containing outputTokens()", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitExpr: mul(tag("RATE"), outputTokens()), + debit: mul(tag("RATE"), outputTokens()), }); expect(result.success).toBe(false); }); - it("rejects debitExpr with standalone inputTokens()", () => { + it("rejects debit with standalone inputTokens()", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitExpr: inputTokens(), + debit: inputTokens(), }); expect(result.success).toBe(false); }); - it("rejects debitExpr with standalone outputTokens()", () => { + it("rejects debit with standalone outputTokens()", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitExpr: outputTokens(), + debit: outputTokens(), }); expect(result.success).toBe(false); }); - it("rejects debitExpr with deeply nested inputTokens()", () => { + it("rejects debit with deeply nested inputTokens()", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitExpr: add(100, mul(tag("RATE"), inputTokens())), + debit: add(100, mul(tag("RATE"), inputTokens())), }); expect(result.success).toBe(false); }); - it("rejects debitExpr with both token placeholders", () => { + it("rejects debit with both token placeholders", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitExpr: add( + debit: add( mul(tag("INPUT_RATE"), inputTokens()), mul(tag("OUTPUT_RATE"), outputTokens()) ), @@ -224,10 +141,10 @@ describe("EventPayloadSchema", () => { expect(result.success).toBe(false); }); - it("still accepts debitExpr without token placeholders", () => { + it("still accepts debit without token placeholders", () => { const result = EventPayloadSchema.safeParse({ userId: "user_1", - debitExpr: add(mul(tag("PREMIUM_CALL"), 3), tag("EXTRA_FEE"), 250), + debit: add(mul(tag("PREMIUM_CALL"), 3), tag("EXTRA_FEE"), 250), }); expect(result.success).toBe(true); }); From cf88d652212d546c859c7ca20650fffc10aee4de Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Thu, 28 May 2026 12:08:26 +0530 Subject: [PATCH 2/2] refactor: lil example change Signed-off-by: Devyash Saini --- examples/ai-sdk-wrapper-usage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ai-sdk-wrapper-usage.ts b/examples/ai-sdk-wrapper-usage.ts index c400986..2b74f72 100644 --- a/examples/ai-sdk-wrapper-usage.ts +++ b/examples/ai-sdk-wrapper-usage.ts @@ -7,7 +7,7 @@ config({ path: ".env.local" }); async function main() { const aii = biller.ai(ai, { inputDebit: biller.tag("PREMIUM_CALL"), - outputDebit: biller.tag("EXTRA_FEE"), + outputDebit: biller.expr("COMPLEX_FEE"), }); const result = await aii.streamText({