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
11 changes: 11 additions & 0 deletions deco-ai-gateway/server/db/mcps.code-workspace
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../../.."
},
{
"path": "../../../../mesh"
}
],
"settings": {}
}
28 changes: 27 additions & 1 deletion deco-ai-gateway/server/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ CREATE TABLE IF NOT EXISTS llm_gateway_connections (
encryption_iv TEXT, -- 12-byte Initialization Vector (hex)
encryption_tag TEXT, -- 16-byte auth tag for integrity verification (hex)
billing_mode TEXT NOT NULL DEFAULT 'prepaid', -- 'prepaid' (buy credits) or 'postpaid' (pay per use)
is_subscription BOOLEAN NOT NULL DEFAULT FALSE, -- FALSE = wallet (credit never resets), TRUE = subscription (resets monthly)
is_subscription BOOLEAN NOT NULL DEFAULT FALSE, -- Kept for backwards compat; use limit_period instead
limit_period TEXT CHECK (limit_period IN ('daily', 'weekly', 'monthly')), -- NULL = no reset, 'monthly' etc = OpenRouter auto-reset
usage_markup_pct NUMERIC(5,2) NOT NULL DEFAULT 15, -- Surcharge % on top of OpenRouter cost (e.g. 30 = 30%)
max_limit_usd NUMERIC(10,4) DEFAULT NULL, -- Maximum spending limit cap (NULL = no cap)
alert_enabled BOOLEAN NOT NULL DEFAULT FALSE, -- Whether low-balance email alerts are on
Expand Down Expand Up @@ -324,6 +325,31 @@ BEGIN
END IF;
END $$;

-- Migration: Add limit_period column (replaces is_subscription logic)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'llm_gateway_connections'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'llm_gateway_connections'
AND column_name = 'limit_period'
) THEN
ALTER TABLE llm_gateway_connections
ADD COLUMN limit_period TEXT CHECK (limit_period IN ('daily', 'weekly', 'monthly'));

-- Migrate existing is_subscription=true rows to limit_period='monthly'
UPDATE llm_gateway_connections
SET limit_period = 'monthly'
WHERE is_subscription = TRUE AND limit_period IS NULL;

RAISE NOTICE 'Migration: Added column limit_period and migrated is_subscription data';
END IF;
END $$;

-- Migration: Add organization_id index to payments (if not exists)
CREATE INDEX IF NOT EXISTS idx_llm_gw_pay_org
ON llm_gateway_payments(organization_id);
Expand Down
7 changes: 5 additions & 2 deletions deco-ai-gateway/server/lib/config-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isSupabaseConfigured,
type LlmGatewayConnectionRow,
type BillingMode,
type LimitPeriod,
} from "./supabase-client.ts";
import { logger } from "./logger.ts";
import { DEFAULT_MARKUP_PCT } from "./constants.ts";
Expand Down Expand Up @@ -80,12 +81,13 @@ export async function saveApiKey(params: {
openrouterKeyName: string;
openrouterKeyHash: string;
billingMode?: BillingMode;
isSubscription?: boolean;
limitPeriod?: LimitPeriod | null;
usageMarkupPct?: number;
maxLimitUsd?: number | null;
}): Promise<void> {
const { ciphertext, iv, tag } = encrypt(params.apiKey);
const now = new Date().toISOString();
const limitPeriod = params.limitPeriod ?? null;

const row: LlmGatewayConnectionRow = {
connection_id: params.connectionId,
Expand All @@ -97,7 +99,8 @@ export async function saveApiKey(params: {
encryption_iv: iv,
encryption_tag: tag,
billing_mode: params.billingMode ?? "prepaid",
is_subscription: params.isSubscription ?? false,
is_subscription: limitPeriod === "monthly",
limit_period: limitPeriod,
usage_markup_pct: params.usageMarkupPct ?? DEFAULT_MARKUP_PCT,
max_limit_usd: params.maxLimitUsd ?? null,
alert_enabled: false,
Expand Down
4 changes: 2 additions & 2 deletions deco-ai-gateway/server/lib/confirm-payment-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ export async function confirmPaymentForConnection(
};
}

const isSubscription = row.is_subscription ?? false;
const limitPeriod = row.limit_period ?? null;
await updateKeyLimit(
row.openrouter_key_hash,
payment.new_limit_usd,
isSubscription ? "monthly" : null,
limitPeriod,
false,
);
await markPaymentCompleted(payment.id);
Expand Down
23 changes: 12 additions & 11 deletions deco-ai-gateway/server/lib/provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
isSupabaseConfigured,
findExistingOrgConnection,
type BillingMode,
type LimitPeriod,
} from "./supabase-client.ts";
import { decrypt } from "./encryption.ts";
import { updateKeyLimit, type LimitReset } from "./openrouter-keys.ts";
Expand Down Expand Up @@ -61,7 +62,7 @@ async function createOpenRouterKey(
organizationId: string,
organizationName: string | undefined,
billingMode: BillingMode,
isSubscription: boolean,
limitPeriod: LimitPeriod | null,
): Promise<{ key: string; hash: string; name: string }> {
const managementKey = process.env.OPENROUTER_MANAGEMENT_KEY;
if (!managementKey) {
Expand Down Expand Up @@ -90,7 +91,7 @@ async function createOpenRouterKey(
billingMode === "prepaid"
? DEFAULT_LIMIT_USD
: DEFAULT_POSTPAID_LIMIT_USD,
limit_reset: isSubscription ? "monthly" : undefined,
limit_reset: limitPeriod ?? undefined,
}),
});

Expand Down Expand Up @@ -140,7 +141,7 @@ async function provisionOrReuseKey(
meshUrl: string,
organizationName: string | undefined,
billingMode: BillingMode,
isSubscription: boolean,
limitPeriod: LimitPeriod | null,
): Promise<string | null> {
const existingOrgLock = orgProvisioningLocks.get(organizationId);
if (existingOrgLock) {
Expand All @@ -157,7 +158,7 @@ async function provisionOrReuseKey(
meshUrl,
organizationName,
billingMode,
isSubscription,
limitPeriod,
);
}

Expand Down Expand Up @@ -195,7 +196,7 @@ async function provisionOrReuseKey(
openrouterKeyName: existingOrgRow.openrouter_key_name ?? "",
openrouterKeyHash: existingOrgRow.openrouter_key_hash ?? "",
billingMode: existingOrgRow.billing_mode,
isSubscription: existingOrgRow.is_subscription,
limitPeriod: existingOrgRow.limit_period ?? null,
usageMarkupPct: existingOrgRow.usage_markup_pct,
maxLimitUsd: existingOrgRow.max_limit_usd,
});
Expand All @@ -215,10 +216,10 @@ async function provisionOrReuseKey(
organizationId,
organizationName,
billingMode,
isSubscription,
limitPeriod,
);

const limitReset: LimitReset | null = isSubscription ? "monthly" : null;
const limitReset: LimitReset | null = limitPeriod ?? null;
const defaultLimit =
billingMode === "prepaid"
? DEFAULT_LIMIT_USD
Expand All @@ -229,7 +230,7 @@ async function provisionOrReuseKey(
keyHash: hash,
billingMode,
limitUsd: defaultLimit,
isSubscription,
limitPeriod,
});
const limitResult = await updateKeyLimit(
hash,
Expand All @@ -255,7 +256,7 @@ async function provisionOrReuseKey(
openrouterKeyName: name,
openrouterKeyHash: hash,
billingMode,
isSubscription,
limitPeriod,
});

logger.info("Key provisioned and persisted", {
Expand Down Expand Up @@ -289,7 +290,7 @@ export async function ensureApiKey(
meshUrl: string,
organizationName?: string,
billingMode: BillingMode = "prepaid",
isSubscription = false,
limitPeriod: LimitPeriod | null = null,
): Promise<string | null> {
logger.debug("ensureApiKey called", { connectionId, organizationId });

Expand Down Expand Up @@ -337,7 +338,7 @@ export async function ensureApiKey(
meshUrl,
organizationName,
billingMode,
isSubscription,
limitPeriod,
);
} catch (error) {
logger.error("Failed to provision OpenRouter API key", {
Expand Down
8 changes: 8 additions & 0 deletions deco-ai-gateway/server/lib/supabase-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { logger } from "./logger.ts";

export type BillingMode = "prepaid" | "postpaid";
export type LimitPeriod = "daily" | "weekly" | "monthly";

export interface LlmGatewayConnectionRow {
connection_id: string;
Expand All @@ -14,6 +15,7 @@ export interface LlmGatewayConnectionRow {
encryption_tag: string | null;
billing_mode: BillingMode;
is_subscription: boolean;
limit_period: LimitPeriod | null;
usage_markup_pct: number;
max_limit_usd: number | null;
alert_enabled: boolean;
Expand Down Expand Up @@ -177,6 +179,7 @@ export async function deleteConnectionConfig(
export interface BillingConfig {
billingMode: BillingMode;
isSubscription: boolean;
limitPeriod: LimitPeriod | null;
usageMarkupPct: number;
maxLimitUsd: number | null;
}
Expand All @@ -199,6 +202,11 @@ export async function updateBillingConfig(
if (config.isSubscription !== undefined) {
updates.is_subscription = config.isSubscription;
}
if ("limitPeriod" in config) {
updates.limit_period = config.limitPeriod ?? null;
// Keep is_subscription in sync for backwards compat
updates.is_subscription = config.limitPeriod === "monthly";
}
if (config.usageMarkupPct !== undefined) {
updates.usage_markup_pct = config.usageMarkupPct;
}
Expand Down
35 changes: 31 additions & 4 deletions deco-ai-gateway/server/tools/credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,33 @@ export const createGatewayCreditsTool = (env: Env) =>
createTool({
id: "GATEWAY_CREDITS",
description:
"Returns the available credit balance for this organization's AI Gateway. " +
"Use this to check how much credit remains before making LLM calls.",
"Returns the current balance or usage for this organization's AI Gateway. " +
"For prepaid mode: shows available credit. " +
"For postpaid mode: shows usage vs limit (if set) or raw usage.",
inputSchema: z.object({}).strict(),
outputSchema: z
.object({
available: z
.number()
.nullable()
.describe("Remaining credit balance in USD (null = unlimited)"),
.describe(
"Remaining credit in USD (prepaid) or remaining limit headroom (postpaid with limit). Null = unlimited.",
),
total: z
.number()
.nullable()
.describe("Total credit limit in USD (null = unlimited)"),
.describe("Total credit/limit in USD (null = unlimited)"),
used: z.number().describe("Total amount spent in USD"),
percentUsed: z
.number()
.nullable()
.describe(
"Percentage of limit used (0-100). Null when no limit is set.",
),
limitPeriod: z
.enum(["daily", "weekly", "monthly"])
.nullable()
.describe("Reset period for the limit (null = no reset / wallet)"),
billingMode: z
.enum(["prepaid", "postpaid"])
.describe("Billing mode for this organization"),
Expand All @@ -48,6 +61,8 @@ export const createGatewayCreditsTool = (env: Env) =>
available: DEFAULT_LIMIT_USD,
total: DEFAULT_LIMIT_USD,
used: 0,
percentUsed: 0,
limitPeriod: null,
billingMode: (row?.billing_mode ?? "prepaid") as
| "prepaid"
| "postpaid",
Expand All @@ -59,11 +74,23 @@ export const createGatewayCreditsTool = (env: Env) =>
const billingMode = (row.billing_mode ?? "prepaid") as
| "prepaid"
| "postpaid";
const limitPeriod = (row.limit_period ?? null) as
| "daily"
| "weekly"
| "monthly"
| null;

const percentUsed =
d.limit != null && d.limit > 0
? Math.min(100, Math.round((d.usage / d.limit) * 100 * 10) / 10)
: null;

return {
available: d.limit_remaining,
total: d.limit,
used: d.usage,
percentUsed,
limitPeriod,
billingMode,
keyDisabled: d.disabled,
};
Expand Down
6 changes: 3 additions & 3 deletions deco-ai-gateway/server/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ async function ensureKeyLimitMatchesBillingMode(

if (!verifiedConnections.has(connectionId)) {
const billingMode = row.billing_mode ?? "prepaid";
const isSubscription = row.is_subscription ?? false;
const limitPeriod = row.limit_period ?? null;
const details = await getKeyDetails(row.openrouter_key_hash);

const expectedDefault =
Expand All @@ -41,12 +41,12 @@ async function ensureKeyLimitMatchesBillingMode(
connectionId,
billingMode,
defaultLimit: expectedDefault,
isSubscription,
limitPeriod,
});
await updateKeyLimit(
row.openrouter_key_hash,
expectedDefault,
isSubscription ? "monthly" : null,
limitPeriod,
false,
);
}
Expand Down
Loading