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
9 changes: 3 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,8 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Format check (prettier)
run: bunx prettier --check .
# - name: Format check (prettier)
# run: bunx prettier --check .

- name: Type check (tsc)
run: bunx tsgo --noEmit

- name: Run tests
run: bun run test
run: bunx tsc --noEmit
56 changes: 56 additions & 0 deletions src/routes/http/api/expressions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { FastifyRequest, FastifyReply } from "fastify";
import * as Sentry from "@sentry/bun";
import { createWideEventBuilder, generateRequestId } from "../../../context/requestContext.ts";
import { logger } from "../../../errors/logger.ts";
import { AuthError } from "../../../errors/auth.ts";
import { authenticateHttpApiKey } from "../../../utils/authenticateHttpApiKey.ts";
import { listExpressions } from "../../../storage/db/postgres/helpers/expressions.ts";

interface ListExpressionsResponse {
expressions: string[];
}

export async function handleListExpressions(
request: FastifyRequest,
reply: FastifyReply
): Promise<ListExpressionsResponse> {
const builder = createWideEventBuilder(
generateRequestId(),
request.method,
request.url
);

try {
const authHeader = request.headers.authorization;
await authenticateHttpApiKey(authHeader);

const expressions = await listExpressions();

builder.setSuccess(200).addContext({ expressionCount: expressions.length });
reply.code(200);
return { expressions };
} catch (error) {
Sentry.captureException(error, {
extra: { context: "list expressions route handler" },
});

if (error instanceof AuthError) {
builder.setError(401, {
type: error.type,
message: error.message,
});
reply.code(401);
return { expressions: [] };
}

const err = error instanceof Error ? error : new Error(String(error));
builder.setError(500, {
type: "InternalError",
message: err.message,
});
reply.code(500);
return { expressions: [] };
} finally {
logger.emit(builder.build());
}
}
11 changes: 11 additions & 0 deletions src/routes/http/api/registerApiRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { FastifyRequest, FastifyReply } from "fastify";
import { handleOnboarding } from "./onboarding.ts";
import { handleListTags } from "./tags.ts";
import { handleListExpressions } from "./expressions.ts";

export async function registerApiRoutes(
server: ReturnType<typeof import("fastify")["fastify"]>
Expand All @@ -24,4 +25,14 @@ export async function registerApiRoutes(
return handleListTags(request, reply);
}
);

server.get(
"/api/v1/expressions",
async (
request: FastifyRequest,
reply: FastifyReply
) => {
return handleListExpressions(request, reply);
}
);
}
41 changes: 41 additions & 0 deletions src/storage/db/postgres/helpers/expressions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { getPostgresDB } from "../db";
import { expressionsTable } from "../schema";
import { StorageError } from "../../../../errors/storage";
import { eq } from "drizzle-orm";

export async function listExpressions(): Promise<string[]> {
const db = getPostgresDB();

try {
const rows = await db
.select({ key: expressionsTable.key })
.from(expressionsTable);
return rows.map((row) => row.key);
} catch (e) {
throw StorageError.queryFailed(
"Failed to list expressions",
e instanceof Error ? e : new Error(String(e))
);
}
}

export async function findExpressionByKey(
key: string
): Promise<string | null> {
const db = getPostgresDB();

try {
const [record] = await db
.select({ expr: expressionsTable.expr })
.from(expressionsTable)
.where(eq(expressionsTable.key, key))
.limit(1);

return record?.expr ?? null;
} catch (e) {
throw StorageError.queryFailed(
`Failed to look up expression '${key}'`,
e instanceof Error ? e : new Error(String(e))
);
}
}
6 changes: 6 additions & 0 deletions src/storage/db/postgres/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,9 @@ export const metadataTable = pgTable("metadata", {
payment_cron: text("payment_cron").notNull(),
payment_webhook: text("payment_webhook"),
});

export const expressionsTable = pgTable("expressions", {
id: uuid("id").primaryKey().defaultRandom(),
key: text("key").notNull().unique(),
expr: text("expr").notNull(),
});
151 changes: 141 additions & 10 deletions src/utils/parseExpr.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Parser } from "expr-eval";
import { EventError } from "../errors/event";
import { fetchTagAmount } from "./fetchTagAmount";
import { findExpressionByKey } from "../storage/db/postgres/helpers/expressions";

/**
* Expression Parser for Pricing DSL
Expand All @@ -14,15 +15,30 @@ import { fetchTagAmount } from "./fetchTagAmount";
* - mul(...args): Product of all arguments
* - div(a, b): a / b (floors result)
* - tag(NAME): Resolves to the tag's value from database
* - expr(NAME): Resolves to a stored expression, recursively evaluated
*
* Token placeholders (inputTokens(), outputTokens()) may appear in
* persisted expressions; they are resolved from the AI token usage context.
*
* Numbers are treated as cents (integers).
*/

// Regex to match tag(NAME) patterns - tag names must be UPPER_SNAKE_CASE
const TAG_PATTERN = /tag\(([A-Z_][A-Z0-9_]*)\)/g;

// Regex to match expr(NAME) patterns - same format as tags
const EXPR_PATTERN = /expr\(([A-Z_][A-Z0-9_]*)\)/g;

// Allowed function names in expressions
const ALLOWED_FUNCTIONS = new Set(["add", "sub", "mul", "div", "tag"]);
const ALLOWED_FUNCTIONS = new Set(["add", "sub", "mul", "div", "tag", "expr"]);

/**
* Token context passed from AI token usage event handlers.
*/
export interface EvalTokenContext {
inputTokens?: number;
outputTokens?: number;
}

/**
* Creates a configured expr-eval parser with custom functions.
Expand Down Expand Up @@ -129,6 +145,84 @@ function validateExprSyntax(exprString: string): void {
);
}
}

// Validate expr name format (same UPPER_SNAKE_CASE as tags)
const exprNamePattern = /expr\(([^)]*)\)/gi;
while ((match = exprNamePattern.exec(exprString)) !== null) {
const exprName = match[1];
if (!exprName || !/^[A-Z_][A-Z0-9_]*$/.test(exprName)) {
throw EventError.validationFailed(
`Invalid expression name format: ${exprName}. Names must be UPPER_SNAKE_CASE`
);
}
}
}

/**
* Resolves all expr(NAME) references in an expression by fetching
* their stored expression strings from the database and expanding them.
*
* Handles recursion: if a stored expression itself contains expr() refs,
* those are resolved recursively. Cycle detection prevents infinite loops.
*
* @param exprString - The expression string with expr(NAME) references
* @returns The expression string with all expr() refs expanded
* @throws EventError if an expression is not found or a cycle is detected
*/
async function resolveExprRefsInExpression(
exprString: string,
resolving: Set<string> = new Set()
): Promise<string> {
const refs = extractExprRefs(exprString);

if (refs.length === 0) {
return exprString;
}

let resolved = exprString;

for (const refName of refs) {
if (resolving.has(refName)) {
throw EventError.validationFailed(
`Circular expression reference detected: ${refName}`
);
}

const storedExpr = await findExpressionByKey(refName);
if (!storedExpr) {
throw EventError.validationFailed(
`Expression not found: ${refName}`
);
}

resolving.add(refName);

// Recursively resolve any expr() refs within the stored expression
const expanded = await resolveExprRefsInExpression(storedExpr, resolving);

// Replace this expr(NAME) with the expanded stored expression
const refPattern = new RegExp(`expr\\(${refName}\\)`, "g");
resolved = resolved.replace(refPattern, `(${expanded})`);

resolving.delete(refName);
}

return resolved;
}

function extractExprRefs(exprString: string): string[] {
const refs = new Set<string>();
let match: RegExpExecArray | null;

EXPR_PATTERN.lastIndex = 0;

while ((match = EXPR_PATTERN.exec(exprString)) !== null) {
if (match[1]) {
refs.add(match[1]);
}
}

return Array.from(refs);
}

/**
Expand Down Expand Up @@ -165,17 +259,36 @@ async function resolveTagsInExpression(exprString: string): Promise<string> {
return resolvedExpr;
}

/**
* Replaces inputTokens() and outputTokens() placeholders with concrete
* values from the AI token usage event context.
*
* This handles persisted expressions that contain token placeholders,
* since the SDK cannot resolve them for expressions it doesn't know about.
*/
function resolveTokenPlaceholders(
exprString: string,
context: EvalTokenContext
): string {
return exprString
.replace(/inputTokens\(\)/g, String(context.inputTokens ?? 0))
.replace(/outputTokens\(\)/g, String(context.outputTokens ?? 0));
}

/**
* Parses and evaluates a pricing expression string.
*
* This is the main entry point for expression evaluation.
* It handles the full pipeline:
* 1. Validates expression syntax
* 2. Resolves all tag references from the database
* 3. Evaluates the expression using expr-eval
* 4. Returns the floored integer result (cents)
* 2. Resolves all expr(NAME) references from the database (recursive, with cycle detection)
* 3. Resolves all tag references from the database
* 4. Resolves token placeholders (if tokenContext provided)
* 5. Evaluates the expression using expr-eval
* 6. Returns the floored integer result (cents)
*
* @param exprString - The expression string to evaluate
* @param tokenContext - Optional AI token usage context for resolving placeholders
* @returns The evaluated result as an integer (cents)
* @throws EventError for syntax errors, unknown tags, or evaluation errors
*
Expand All @@ -187,24 +300,42 @@ async function resolveTagsInExpression(exprString: string): Promise<string> {
* // With tag (assumes PREMIUM_CALL = 100 in DB)
* await parseAndEvaluateExpr("add(mul(tag(PREMIUM_CALL),3),250)")
* // Returns: 550 (100*3 + 250)
*
* @example
* // With persisted expression + token placeholders
* await parseAndEvaluateExpr("expr(PER_TOKEN_INPUT)", {
* inputTokens: 150,
* outputTokens: 0,
* })
* // Fetches PER_TOKEN_INPUT from DB → "mul(tag(RATE),inputTokens())"
* // Resolves tag(RATE) and inputTokens()=150 → evaluates
*/
export async function parseAndEvaluateExpr(
exprString: string
exprString: string,
tokenContext?: EvalTokenContext
): Promise<number> {
// Step 1: Validate syntax
validateExprSyntax(exprString);

// Step 2: Resolve all tags to their values
const resolvedExpr = await resolveTagsInExpression(exprString);
// Step 2: Resolve all expr(NAME) references (recursive, from DB)
const expandedExpr = await resolveExprRefsInExpression(exprString);

// Step 3: Resolve all tags to their values
const tagResolvedExpr = await resolveTagsInExpression(expandedExpr);

// Step 4: Resolve token placeholders if context provided
const finalExpr = tokenContext
? resolveTokenPlaceholders(tagResolvedExpr, tokenContext)
: tagResolvedExpr;

// Step 3: Parse and evaluate
// Step 5: Parse and evaluate
const parser = createParser();

try {
const expression = parser.parse(resolvedExpr);
const expression = parser.parse(finalExpr);
const result = expression.evaluate();

// Step 4: Validate and return result
// Step 6: Validate and return result
if (typeof result !== "number" || !Number.isFinite(result)) {
throw EventError.validationFailed(
`Expression evaluation produced invalid result: ${result}`
Expand Down
9 changes: 7 additions & 2 deletions src/zod/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,19 @@ const AITokenUsageDataSchema: z.ZodType<AITokenUsageEventData> = z
outputexpr: z.string(),
})
.transform(async (v): Promise<AITokenUsageEventData> => {
const tokenContext = {
inputTokens: v.inputtokens,
outputTokens: v.outputtokens,
};

let inputDebitAmount: number;
if (v.inputtag) {
inputDebitAmount = await fetchTagAmount(
v.inputtag,
`Input tag not found: ${v.inputtag}`
);
} else if (v.inputexpr) {
inputDebitAmount = await parseAndEvaluateExpr(v.inputexpr);
inputDebitAmount = await parseAndEvaluateExpr(v.inputexpr, tokenContext);
} else {
inputDebitAmount = v.inputamount;
}
Expand All @@ -72,7 +77,7 @@ const AITokenUsageDataSchema: z.ZodType<AITokenUsageEventData> = z
`Output tag not found: ${v.outputtag}`
);
} else if (v.outputexpr) {
outputDebitAmount = await parseAndEvaluateExpr(v.outputexpr);
outputDebitAmount = await parseAndEvaluateExpr(v.outputexpr, tokenContext);
} else {
outputDebitAmount = v.outputamount;
}
Expand Down
Loading