Skip to content
Merged
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
2 changes: 1 addition & 1 deletion examples/ai-sdk-wrapper-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ async function main() {
userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f",
event,
inputDebit: biller.tag("PREMIUM_CALL"),
outputDebit: mul(outputTokens(), 0.0001),
outputDebit: biller.expr(mul(outputTokens(), 0.0001)),
});
},
});
Expand Down
6 changes: 3 additions & 3 deletions examples/ai-token-stream-expr-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ async function* tokenUsageFromAIStream(): AsyncGenerator<
inputTokens: 150,
outputTokens: 0,
inputDebit: biller.expr("PER_TOKEN_INPUT"),
outputDebit: mul(biller.tag("EXTRA_FEE"), outputTokens()),
outputDebit: biller.expr(mul(biller.tag("EXTRA_FEE"), outputTokens())),
};

yield {
userId,
model: "gpt-4",
inputTokens: 0,
outputTokens: 75,
inputDebit: mul(biller.tag("PREMIUM_CALL"), inputTokens()),
outputDebit: mul(biller.tag("EXTRA_FEE"), outputTokens()),
inputDebit: biller.expr(mul(biller.tag("PREMIUM_CALL"), inputTokens())),
outputDebit: biller.expr(mul(biller.tag("EXTRA_FEE"), outputTokens())),
};
}

Expand Down
19 changes: 16 additions & 3 deletions examples/basic-usage-expr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,30 @@ config({ path: ".env.local" });
async function main() {
await biller.basicUsageEventConsumer({
userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f",
debit: mul(biller.tag("PREMIUM_CALL"), 3),
debit: biller.expr(mul(biller.tag("PREMIUM_CALL"), 3)),
});

await biller.basicUsageEventConsumer({
userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f",
debit: mul(biller.tag("EXTRA_FEE"), 3),
debit: biller.expr(mul(biller.tag("EXTRA_FEE"), 3)),
});

await biller.basicUsageEventConsumer({
userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f",
debit: add(biller.expr("COMPLEX_FEE"), mul(biller.tag("PREMIUM_CALL"), 5)),
debit: biller.expr(
add(biller.expr("COMPLEX_FEE"), mul(biller.tag("PREMIUM_CALL"), 5))
),
});

// biller.expr() also accepts raw amounts and tags
await biller.basicUsageEventConsumer({
userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f",
debit: biller.expr(250),
});

await biller.basicUsageEventConsumer({
userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f",
debit: biller.expr(biller.tag("EXTRA_FEE")),
});

console.log("Basic usage expression events consumed successfully");
Expand Down
1 change: 1 addition & 0 deletions packages/scrawn/src/core/pricing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type {
OutputTokensExpr,
ExprRef,
PriceExpr,
ExprValue,
ExprInput,
} from "./types.js";

Expand Down
18 changes: 18 additions & 0 deletions packages/scrawn/src/core/pricing/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,24 @@ export type PriceExpr<TTag extends string = string> =
| OutputTokensExpr
| ExprRef;

/**
* A branded PriceExpr returned by biller.expr().
* Use biller.expr() to wrap inline expressions, tags, or amounts.
* Raw OpExpr values (e.g., mul(tag("X"), 3)) are not accepted by debit
* — they must be wrapped in biller.expr().
*/
declare const EXPR_BRAND: unique symbol;
export type ExprValue<TTag extends string = string> = PriceExpr<TTag> & {
readonly [EXPR_BRAND]: true;
};

/** @internal — casts a PriceExpr to ExprValue at compile time, no-op at runtime. */
export function toExprValue<TTag extends string>(
expr: PriceExpr<TTag>
): ExprValue<TTag> {
return expr as ExprValue<TTag>;
}

/**
* Input type for DSL builder functions.
* Accepts either a PriceExpr or a raw number (interpreted as cents).
Expand Down
51 changes: 31 additions & 20 deletions packages/scrawn/src/core/scrawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ import type {
AuthMethodName,
AllCredentials,
} from "./types/auth.js";
import type { TagExpr, PriceExpr, ExprRef } from "./pricing/types.js";
import type {
TagExpr,
PriceExpr,
ExprRef,
ExprValue,
} from "./pricing/types.js";
import { toExprValue } from "./pricing/types.js";
import { ApiKeyAuth } from "./auth/apiKeyAuth.js";
import { ScrawnLogger } from "../utils/logger.js";
import { matchPath } from "../utils/pathMatcher.js";
Expand Down Expand Up @@ -305,40 +311,45 @@ export class Scrawn<
}

/**
* Create a type-safe reference to a persisted expression.
* Create a type-safe reference to a persisted expression, inline expression,
* tag, or raw amount. All expressions should go through `biller.expr()`.
*
* Expression names are compile-time checked against known expressions
* synced from the Scrawn server. The backend resolves the stored
* expression string and evaluates it at runtime.
*
* Also accepts inline PriceExpr as a passthrough for a consistent
* `biller.expr()` entry point for all expressions.
* Also accepts inline PriceExpr, TagExpr, and raw numbers as a unified
* `biller.expr()` entry point.
*
* @param nameOrExpr - The persisted expression name or an inline PriceExpr
* @returns An ExprRef (if name) or the original PriceExpr (passthrough)
* @param value - A persisted expression name, inline PriceExpr, TagExpr, or raw number
* @returns A PriceExpr representing the expression
*
* @example
* ```typescript
* // Reference a persisted expression
* biller.basicUsageEventConsumer({
* userId: 'u123',
* debitExpr: biller.expr("MY_EXPR"),
* });
* biller.expr("MY_EXPR")
*
* // Inline expression passthrough
* biller.basicUsageEventConsumer({
* userId: 'u123',
* debitExpr: biller.expr(mul(biller.tag("PREMIUM_CALL"), 3)),
* });
* // Inline expression
* biller.expr(mul(biller.tag("PREMIUM_CALL"), 3))
*
* // Wrap a tag
* biller.expr(biller.tag("EXTRA_FEE"))
*
* // Wrap a raw amount
* biller.expr(250)
* ```
*/
expr<T extends TExprs>(name: T): PriceExpr<TTags>;
expr(expr: PriceExpr<TTags>): PriceExpr<TTags>;
expr(value: string | PriceExpr<TTags>): PriceExpr<TTags> {
expr(amount: number): ExprValue<TTags>;
expr<T extends TExprs>(name: T): ExprValue<TTags>;
expr(expr: PriceExpr<TTags>): ExprValue<TTags>;
expr(value: string | number | PriceExpr<TTags>): ExprValue<TTags> {
if (typeof value === "string") {
return { kind: "exprRef", name: value } as PriceExpr<TTags>;
return toExprValue({ kind: "exprRef", name: value } as PriceExpr<TTags>);
}
if (typeof value === "number") {
return toExprValue({ kind: "amount", value } as PriceExpr<TTags>);
}
Comment on lines +349 to 351

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Negative amounts bypass the nonnegative debit guard

biller.expr(-100) produces { kind: "amount", value: -100 }, an ExprValue that passes isValidExpr() because validateAmount() only rejects non-finite and non-integer values — negative numbers are explicitly left to "the backend". Meanwhile, passing -100 directly as debit: -100 is rejected immediately by the Zod union's z.number().nonnegative() branch. The two paths that reach the same semantic result are validated inconsistently, so a negative raw-amount expression silently escapes the client-side guard that exists for the direct-number case.

return value;
return toExprValue(value);
}

/**
Expand Down
7 changes: 5 additions & 2 deletions packages/scrawn/src/core/types/event.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from "zod";
import type { PriceExpr } from "../pricing/types.js";
import type { PriceExpr, TagExpr, ExprValue } from "../pricing/types.js";
import type { ScrawnError } from "../errors/index.js";
import { isValidExpr, containsTokenExpr } from "../pricing/validate.js";

Expand Down Expand Up @@ -122,7 +122,10 @@ export const EventPayloadSchema = z.object({
* debit: biller.expr("SAVED_RATE")
* ```
*/
export type Debit<TTag extends string = string> = number | PriceExpr<TTag>;
export type Debit<TTag extends string = string> =
| number
| TagExpr<TTag>
| ExprValue<TTag>;

/**
* Payload structure for event tracking.
Expand Down
Loading