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
7 changes: 4 additions & 3 deletions packages/scrawn/src/core/pricing/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
OpExpr,
PriceExpr,
ExprInput,
TagName,
} from "./types.js";
import { validateExpr } from "./validate.js";

Expand All @@ -38,16 +39,16 @@ function toExpr(input: ExprInput): PriceExpr {
* Create a tag reference expression.
* Tags are resolved to their cent values by the backend.
*
* @param name - The name of the price tag (must be non-empty)
* @param name - The name of the price tag (must be ALL CAPS with underscores only, e.g., PREMIUM_CALL, FEE)
* @returns A TagExpr referencing the named tag
* @throws Error if name is empty or whitespace-only
* @throws Error if name is empty, whitespace-only, or not ALL_CAPS format
*
* @example
* ```typescript
* const premiumTag = tag('PREMIUM_CALL');
* ```
*/
export function tag(name: string): TagExpr {
export function tag(name: TagName): TagExpr {
const expr: TagExpr = { kind: "tag", name } as const;
validateExpr(expr); // Will throw if invalid
return expr;
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 @@ -30,6 +30,7 @@ export type {
OpExpr,
PriceExpr,
ExprInput,
TagName,
} from "./types.js";

// Export builder functions
Expand Down
12 changes: 11 additions & 1 deletion packages/scrawn/src/core/pricing/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
*/
export type OpType = "ADD" | "SUB" | "MUL" | "DIV";

/**
* Intellisense hint type for tag names.
* Tag names must be ALL CAPS with underscores only (e.g., PREMIUM_CALL, FEE, INPUT_RATE).
* No lowercase, digits, or hyphens allowed.
*
* This is a branded type that provides IDE hints while remaining compatible with `string`.
*/
export type TagName = Uppercase<string> & { readonly __brand?: "TagName" };

/**
* A literal amount in cents (must be an integer).
*/
Expand All @@ -29,10 +38,11 @@ export interface AmountExpr {

/**
* A reference to a named price tag (resolved by the backend).
* Tag names must be ALL CAPS with underscores only (e.g., PREMIUM_CALL, FEE).
*/
export interface TagExpr {
readonly kind: "tag";
readonly name: string;
readonly name: TagName;
}

/**
Expand Down
12 changes: 7 additions & 5 deletions packages/scrawn/src/core/pricing/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* - Non-finite numbers (NaN, Infinity)
* - Empty operation arguments (ops need at least 2 args)
* - Empty/whitespace tag names
* - Tag name format (must be ALL_CAPS with underscores only)
*
* SDK does NOT validate:
* - Tag existence (backend resolves tags)
Expand Down Expand Up @@ -72,7 +73,8 @@ function validateAmount(value: number): void {

/**
* Validate a tag name.
* Must be a non-empty string with no leading/trailing whitespace.
* Must be ALL CAPS with underscores only (e.g., PREMIUM_CALL, FEE, INPUT_RATE).
* No lowercase, digits, or hyphens allowed.
*/
function validateTagName(name: string): void {
if (typeof name !== "string") {
Expand All @@ -91,11 +93,11 @@ function validateTagName(name: string): void {
if (name.trim().length === 0) {
throw new PricingExpressionError("Tag name cannot be only whitespace");
}
// Validate tag name format: alphanumeric, underscores, hyphens
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)) {
// Validate tag name format: ALL CAPS with underscores only
if (!/^[A-Z_]+$/.test(name)) {
throw new PricingExpressionError(
`Tag name must start with a letter or underscore and contain only ` +
`alphanumeric characters, underscores, or hyphens: "${name}"`
`Tag name must be ALL CAPS with underscores only (e.g., PREMIUM_CALL, FEE). ` +
`No lowercase, digits, or hyphens allowed. Got: "${name}"`
);
}
}
Expand Down
24 changes: 21 additions & 3 deletions packages/scrawn/src/core/types/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ const PriceExprSchema = z.custom<PriceExpr>(
}
);

/**
* Regex for validating tag names: ALL CAPS with underscores only.
* No lowercase, digits, or hyphens allowed.
*/
const TAG_NAME_REGEX = /^[A-Z_]+$/;

/**
* Zod schema for event payload validation.
*
Expand All @@ -44,6 +50,10 @@ export const EventPayloadSchema = z
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: PriceExprSchema.optional(),
})
Expand Down Expand Up @@ -219,11 +229,19 @@ export interface MiddlewareEventConfig {
*
* 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").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(
Expand Down Expand Up @@ -309,8 +327,8 @@ export const AITokenUsagePayloadSchema = z.object({
* model: 'gpt-4',
* inputTokens: 100,
* outputTokens: 50,
* inputDebit: { expr: mul(tag('GPT4_INPUT_RATE'), 100) }, // rate * tokens
* outputDebit: { expr: mul(tag('GPT4_OUTPUT_RATE'), 50) } // rate * tokens
* inputDebit: { expr: mul(tag('GPT_INPUT_RATE'), 100) }, // rate * tokens
* outputDebit: { expr: mul(tag('GPT_OUTPUT_RATE'), 50) } // rate * tokens
* };
* ```
*/
Expand Down
35 changes: 29 additions & 6 deletions packages/scrawn/tests/unit/pricing/pricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,14 @@ describe("Pricing DSL Builders", () => {
expect(expr).toEqual({ kind: "tag", name: "PREMIUM_CALL" });
});

it("accepts underscores and hyphens", () => {
it("accepts ALL_CAPS names with underscores", () => {
expect(tag("PREMIUM_CALL")).toEqual({
kind: "tag",
name: "PREMIUM_CALL",
});
expect(tag("premium-call")).toEqual({
kind: "tag",
name: "premium-call",
});
expect(tag("_private")).toEqual({ kind: "tag", name: "_private" });
expect(tag("FEE")).toEqual({ kind: "tag", name: "FEE" });
expect(tag("_PRIVATE")).toEqual({ kind: "tag", name: "_PRIVATE" });
expect(tag("__DOUBLE")).toEqual({ kind: "tag", name: "__DOUBLE" });
});

it("throws on empty tag name", () => {
Expand All @@ -55,6 +53,31 @@ describe("Pricing DSL Builders", () => {
expect(() => tag("has spaces")).toThrow(PricingExpressionError);
expect(() => tag("has.dots")).toThrow(PricingExpressionError);
});

it("throws on lowercase tag names", () => {
expect(() => tag("premium")).toThrow(PricingExpressionError);
expect(() => tag("premium")).toThrow("ALL CAPS");
expect(() => tag("premium_call")).toThrow(PricingExpressionError);
});

it("throws on mixed case tag names", () => {
expect(() => tag("Premium")).toThrow(PricingExpressionError);
expect(() => tag("Premium")).toThrow("ALL CAPS");
expect(() => tag("PremiumCall")).toThrow(PricingExpressionError);
});

it("throws on tag names with digits", () => {
expect(() => tag("GPT4")).toThrow(PricingExpressionError);
expect(() => tag("GPT4")).toThrow("ALL CAPS");
expect(() => tag("RATE2")).toThrow(PricingExpressionError);
expect(() => tag("123")).toThrow(PricingExpressionError);
});

it("throws on tag names with hyphens", () => {
expect(() => tag("PREMIUM-CALL")).toThrow(PricingExpressionError);
expect(() => tag("PREMIUM-CALL")).toThrow("ALL CAPS");
expect(() => tag("my-tag")).toThrow(PricingExpressionError);
});
});

describe("amount()", () => {
Expand Down
66 changes: 64 additions & 2 deletions packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ describe("AITokenUsagePayloadSchema", () => {
model: "gpt-4",
inputTokens: 100,
outputTokens: 50,
inputDebit: { expr: mul(tag("GPT4_INPUT_RATE"), 100) },
outputDebit: { expr: mul(tag("GPT4_OUTPUT_RATE"), 50) },
inputDebit: { expr: mul(tag("GPT_INPUT_RATE"), 100) },
outputDebit: { expr: mul(tag("GPT_OUTPUT_RATE"), 50) },
});

expect(result.success).toBe(true);
Expand Down Expand Up @@ -315,5 +315,67 @@ describe("AITokenUsagePayloadSchema", () => {

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);
});
});
});
});
50 changes: 50 additions & 0 deletions packages/scrawn/tests/unit/types/eventPayload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,54 @@ describe("EventPayloadSchema", () => {

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);
});
});
});
Loading