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
110 changes: 110 additions & 0 deletions cloud/api/router/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getRouterConfig, validateRouterConfig } from "./config";

describe("RouterConfig", () => {
// Store original env vars
const originalEnv = { ...process.env };

beforeEach(() => {
// Reset to clean state
process.env = { ...originalEnv };
});

afterEach(() => {
// Restore original env
process.env = originalEnv;
});

describe("getRouterConfig", () => {
it("should return config when all env vars are set", () => {
process.env.DATABASE_URL = "postgresql://test";
process.env.STRIPE_SECRET_KEY = "sk_test_123";
process.env.STRIPE_ROUTER_PRICE_ID = "price_123";
process.env.STRIPE_ROUTER_METER_ID = "meter_123";

const config = getRouterConfig();

expect(config).toEqual({
databaseUrl: "postgresql://test",
stripe: {
apiKey: "sk_test_123",
routerPriceId: "price_123",
routerMeterId: "meter_123",
},
});
});

it("should throw when DATABASE_URL is missing", () => {
delete process.env.DATABASE_URL;
process.env.STRIPE_SECRET_KEY = "sk_test_123";
process.env.STRIPE_ROUTER_PRICE_ID = "price_123";
process.env.STRIPE_ROUTER_METER_ID = "meter_123";

expect(() => getRouterConfig()).toThrow(
"DATABASE_URL environment variable is required",
);
});

it("should throw when STRIPE_SECRET_KEY is missing", () => {
process.env.DATABASE_URL = "postgresql://test";
delete process.env.STRIPE_SECRET_KEY;
process.env.STRIPE_ROUTER_PRICE_ID = "price_123";
process.env.STRIPE_ROUTER_METER_ID = "meter_123";

expect(() => getRouterConfig()).toThrow(
"Stripe environment variables (STRIPE_SECRET_KEY, STRIPE_ROUTER_PRICE_ID, STRIPE_ROUTER_METER_ID) are required",
);
});

it("should throw when STRIPE_ROUTER_PRICE_ID is missing", () => {
process.env.DATABASE_URL = "postgresql://test";
process.env.STRIPE_SECRET_KEY = "sk_test_123";
delete process.env.STRIPE_ROUTER_PRICE_ID;
process.env.STRIPE_ROUTER_METER_ID = "meter_123";

expect(() => getRouterConfig()).toThrow(
"Stripe environment variables (STRIPE_SECRET_KEY, STRIPE_ROUTER_PRICE_ID, STRIPE_ROUTER_METER_ID) are required",
);
});

it("should throw when STRIPE_ROUTER_METER_ID is missing", () => {
process.env.DATABASE_URL = "postgresql://test";
process.env.STRIPE_SECRET_KEY = "sk_test_123";
process.env.STRIPE_ROUTER_PRICE_ID = "price_123";
delete process.env.STRIPE_ROUTER_METER_ID;

expect(() => getRouterConfig()).toThrow(
"Stripe environment variables (STRIPE_SECRET_KEY, STRIPE_ROUTER_PRICE_ID, STRIPE_ROUTER_METER_ID) are required",
);
});
});

describe("validateRouterConfig", () => {
it("should return null when all env vars are set", () => {
process.env.DATABASE_URL = "postgresql://test";
process.env.STRIPE_SECRET_KEY = "sk_test_123";
process.env.STRIPE_ROUTER_PRICE_ID = "price_123";
process.env.STRIPE_ROUTER_METER_ID = "meter_123";

expect(validateRouterConfig()).toBeNull();
});

it("should return error message when DATABASE_URL is missing", () => {
delete process.env.DATABASE_URL;
process.env.STRIPE_SECRET_KEY = "sk_test_123";
process.env.STRIPE_ROUTER_PRICE_ID = "price_123";
process.env.STRIPE_ROUTER_METER_ID = "meter_123";

expect(validateRouterConfig()).toBe("Database not configured");
});

it("should return error message when Stripe config is incomplete", () => {
process.env.DATABASE_URL = "postgresql://test";
delete process.env.STRIPE_SECRET_KEY;
process.env.STRIPE_ROUTER_PRICE_ID = "price_123";
process.env.STRIPE_ROUTER_METER_ID = "meter_123";

expect(validateRouterConfig()).toBe("Stripe configuration incomplete");
});
});
});
79 changes: 79 additions & 0 deletions cloud/api/router/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* @fileoverview Centralized configuration for router environment variables.
*
* Provides type-safe access to environment variables used across the router
* implementation, including database, Stripe, and provider API keys.
*/

/**
* Router configuration interface.
*/
export interface RouterConfig {
/** Database connection string */
databaseUrl: string;
/** Stripe configuration */
stripe: {
/** Stripe API secret key */
apiKey: string;
/** Stripe router price ID */
routerPriceId: string;
/** Stripe router meter ID */
routerMeterId: string;
};
}

/**
* Gets the router configuration from environment variables.
*
* @throws Error if required environment variables are not set
* @returns Router configuration
*/
export function getRouterConfig(): RouterConfig {
const databaseUrl = process.env.DATABASE_URL;
const stripeApiKey = process.env.STRIPE_SECRET_KEY;
const routerPriceId = process.env.STRIPE_ROUTER_PRICE_ID;
const routerMeterId = process.env.STRIPE_ROUTER_METER_ID;

if (!databaseUrl) {
throw new Error("DATABASE_URL environment variable is required");
}

if (!stripeApiKey || !routerPriceId || !routerMeterId) {
throw new Error(
"Stripe environment variables (STRIPE_SECRET_KEY, STRIPE_ROUTER_PRICE_ID, STRIPE_ROUTER_METER_ID) are required",
);
}

return {
databaseUrl,
stripe: {
apiKey: stripeApiKey,
routerPriceId,
routerMeterId,
},
};
}

/**
* Validates that router configuration is available.
*
* Returns null if configuration is valid, or an error message if invalid.
* Useful for early validation without throwing exceptions.
*
* @returns Error message if invalid, null if valid
*/
export function validateRouterConfig(): string | null {
if (!process.env.DATABASE_URL) {
return "Database not configured";
}

if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ROUTER_PRICE_ID ||
!process.env.STRIPE_ROUTER_METER_ID
) {
return "Stripe configuration incomplete";
}

return null;
}
45 changes: 44 additions & 1 deletion cloud/api/router/cost-calculator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, assert, vi, beforeEach } from "@effect/vitest";
import { describe, it, expect, vi, beforeEach } from "@effect/vitest";
import { Effect } from "effect";
import assert from "node:assert";
import {
OpenAICostCalculator,
AnthropicCostCalculator,
Expand Down Expand Up @@ -316,6 +317,18 @@ describe("CostCalculator", () => {
const usage = calculator.extractUsageFromStreamChunk(chunk);
expect(usage).toBeNull();
});

it("should return null for null chunk", () => {
const calculator = new OpenAICostCalculator();
const usage = calculator.extractUsageFromStreamChunk(null);
expect(usage).toBeNull();
});

it("should return null for non-object chunk", () => {
const calculator = new OpenAICostCalculator();
const usage = calculator.extractUsageFromStreamChunk("not an object");
expect(usage).toBeNull();
});
});

describe("AnthropicCostCalculator", () => {
Expand Down Expand Up @@ -831,5 +844,35 @@ describe("CostCalculator", () => {
expect(result).toBeNull();
}),
);

it.effect("should handle decimal token values gracefully", () =>
Effect.gen(function* () {
const calculator = new OpenAICostCalculator();

// Test with decimal tokens (BigInt constructor throws on decimals)
const invalidUsage = {
inputTokens: 100.5,
outputTokens: 500,
};

const result = yield* calculator.calculate("gpt-4o-mini", invalidUsage);
expect(result).toBeNull();
}),
);

it.effect("should handle Infinity token values gracefully", () =>
Effect.gen(function* () {
const calculator = new OpenAICostCalculator();

// Test with Infinity tokens (BigInt constructor throws on Infinity)
const invalidUsage = {
inputTokens: Infinity,
outputTokens: 500,
};

const result = yield* calculator.calculate("gpt-4o-mini", invalidUsage);
expect(result).toBeNull();
}),
);
});
});
62 changes: 33 additions & 29 deletions cloud/api/router/cost-calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export abstract class BaseCostCalculator {
public calculate(
modelId: string,
usage: TokenUsage,
): Effect.Effect<CostBreakdown | null, Error> {
): Effect.Effect<CostBreakdown | null, never> {
return Effect.gen(this, function* () {
// Get pricing data (null if unavailable)
const pricing = yield* getModelPricing(this.provider, modelId).pipe(
Expand All @@ -96,34 +96,38 @@ export abstract class BaseCostCalculator {
return null;
}

// Calculate costs in centi-cents using BIGINT (pricing is centi-cents per million tokens)
const inputCost =
(BigInt(usage.inputTokens) * pricing.input) / 1_000_000n;
const outputCost =
(BigInt(usage.outputTokens) * pricing.output) / 1_000_000n;

const cacheReadCost =
usage.cacheReadTokens && pricing.cache_read
? (BigInt(usage.cacheReadTokens) * pricing.cache_read) / 1_000_000n
: undefined;

// Use provider-specific cache write cost calculation
const cacheWriteCost = this.calculateCacheWriteCost(
usage.cacheWriteTokens,
usage.cacheWriteBreakdown,
pricing.cache_write || 0n,
);

const totalCost =
inputCost + outputCost + (cacheReadCost || 0n) + (cacheWriteCost || 0n);

return {
inputCost,
outputCost,
cacheReadCost,
cacheWriteCost,
totalCost,
};
return yield* Effect.try(() => {
const inputCost =
(BigInt(usage.inputTokens) * pricing.input) / 1_000_000n;
const outputCost =
(BigInt(usage.outputTokens) * pricing.output) / 1_000_000n;

const cacheReadCost =
usage.cacheReadTokens && pricing.cache_read
? (BigInt(usage.cacheReadTokens) * pricing.cache_read) / 1_000_000n
: undefined;

// Use provider-specific cache write cost calculation
const cacheWriteCost = this.calculateCacheWriteCost(
usage.cacheWriteTokens,
usage.cacheWriteBreakdown,
pricing.cache_write || 0n,
);

const totalCost =
inputCost +
outputCost +
(cacheReadCost || 0n) +
(cacheWriteCost || 0n);

return {
inputCost,
outputCost,
cacheReadCost,
cacheWriteCost,
totalCost,
};
}).pipe(Effect.orElseSucceed(() => null));
});
}
}
Expand Down
7 changes: 7 additions & 0 deletions cloud/api/router/cost-estimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,13 @@ function getCostEstimator(provider: ProviderName): BaseCostEstimator {
* @returns Estimated cost
* @throws PricingUnavailableError if pricing data cannot be fetched
*/
/**
* Estimates the cost of an AI provider request.
*
* @param params - Estimation parameters
* @returns Effect that resolves to estimated cost
* @throws PricingUnavailableError if pricing data cannot be fetched
*/
export function estimateCost({
provider,
modelId,
Expand Down
Loading
Loading