Skip to content

Commit f1cccb7

Browse files
ersinkocclaude
andcommitted
✨ feat(ui): add billing type editor to provider settings
Provider settings modal now includes: - Billing Type dropdown: Pay-per-use / Subscription / Free - Subscription fields: Monthly Cost (USD) + Plan Name (conditional) - Billing badges on provider cards: violet "Subscription $20/mo", green "Free" Backend: - user_provider_configs table gains billing columns (migration 024) - Provider routes return billingType in list + detail responses - Validation schema accepts billingType, subscriptionCostUsd, subscriptionPlan - Local + CLI providers default to 'free' Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2a6bd66 commit f1cccb7

7 files changed

Lines changed: 139 additions & 0 deletions

File tree

packages/gateway/src/db/migrations/postgres/001_initial_schema.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,9 @@ CREATE TABLE IF NOT EXISTS user_provider_configs (
633633
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
634634
api_key_env TEXT,
635635
notes TEXT,
636+
billing_type TEXT NOT NULL DEFAULT 'pay-per-use',
637+
subscription_cost_usd REAL,
638+
subscription_plan TEXT,
636639
config JSONB DEFAULT '{}',
637640
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
638641
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),

packages/gateway/src/db/migrations/postgres/024_provider_billing.sql

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,16 @@ ALTER TABLE custom_providers ADD COLUMN IF NOT EXISTS subscription_cost_usd REAL
77
ALTER TABLE custom_providers ADD COLUMN IF NOT EXISTS subscription_plan TEXT;
88
ALTER TABLE custom_providers ADD COLUMN IF NOT EXISTS billing_notes TEXT;
99

10+
-- Add billing columns to user_provider_configs (built-in provider overrides)
11+
ALTER TABLE user_provider_configs ADD COLUMN IF NOT EXISTS billing_type TEXT NOT NULL DEFAULT 'pay-per-use';
12+
ALTER TABLE user_provider_configs ADD COLUMN IF NOT EXISTS subscription_cost_usd REAL;
13+
ALTER TABLE user_provider_configs ADD COLUMN IF NOT EXISTS subscription_plan TEXT;
14+
1015
-- Set known free providers
1116
UPDATE custom_providers SET billing_type = 'free'
1217
WHERE provider_id IN ('local', 'ollama', 'lmstudio', 'localai', 'vllm')
1318
AND billing_type = 'pay-per-use';
19+
20+
UPDATE user_provider_configs SET billing_type = 'free'
21+
WHERE provider_id IN ('local', 'ollama', 'lmstudio', 'localai', 'vllm')
22+
AND billing_type = 'pay-per-use';

packages/gateway/src/db/repositories/model-configs.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ export interface UserProviderConfig {
100100
isEnabled: boolean;
101101
apiKeyEnv?: string;
102102
notes?: string;
103+
billingType: 'pay-per-use' | 'subscription' | 'free';
104+
subscriptionCostUsd?: number;
105+
subscriptionPlan?: string;
103106
config: Record<string, unknown>;
104107
createdAt: Date;
105108
updatedAt: Date;
@@ -196,6 +199,9 @@ interface UserProviderConfigRow {
196199
is_enabled: boolean;
197200
api_key_env: string | null;
198201
notes: string | null;
202+
billing_type: string | null;
203+
subscription_cost_usd: number | null;
204+
subscription_plan: string | null;
199205
config: string;
200206
created_at: string;
201207
updated_at: string;
@@ -247,6 +253,7 @@ function rowToProvider(row: CustomProviderRow): CustomProvider {
247253
}
248254

249255
function rowToUserProviderConfig(row: UserProviderConfigRow): UserProviderConfig {
256+
const billingType = row.billing_type as UserProviderConfig['billingType'] | null;
250257
return {
251258
id: row.id,
252259
userId: row.user_id,
@@ -256,6 +263,9 @@ function rowToUserProviderConfig(row: UserProviderConfigRow): UserProviderConfig
256263
isEnabled: row.is_enabled,
257264
apiKeyEnv: row.api_key_env || undefined,
258265
notes: row.notes || undefined,
266+
billingType: billingType ?? 'pay-per-use',
267+
subscriptionCostUsd: row.subscription_cost_usd ?? undefined,
268+
subscriptionPlan: row.subscription_plan ?? undefined,
259269
config: parseJsonField(row.config, {}),
260270
createdAt: new Date(row.created_at),
261271
updatedAt: new Date(row.updated_at),

packages/gateway/src/middleware/validation.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,9 @@ export const providerConfigSchema = z.object({
526526
isEnabled: z.boolean().optional(),
527527
apiKeyEnv: z.string().max(200).optional(),
528528
notes: z.string().max(2000).optional(),
529+
billingType: z.enum(['pay-per-use', 'subscription', 'free']).optional(),
530+
subscriptionCostUsd: z.number().min(0).max(10000).optional(),
531+
subscriptionPlan: z.string().max(200).optional(),
529532
});
530533

531534
// ─── Workspace File & Execute Schemas ───────────────────────────

packages/gateway/src/routes/providers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ app.get('/', async (c) => {
257257
isEnabled: override?.isEnabled !== false,
258258
// Has user override
259259
hasOverride: !!override,
260+
// Billing
261+
billingType: override?.billingType ?? 'pay-per-use',
262+
subscriptionCostUsd: override?.subscriptionCostUsd,
263+
subscriptionPlan: override?.subscriptionPlan,
260264
// Configuration source: 'database' = set via UI, 'environment' = set via env var
261265
configSource,
262266
// UI metadata
@@ -298,6 +302,9 @@ app.get('/', async (c) => {
298302
isConfigured: true,
299303
isEnabled: true,
300304
hasOverride: false,
305+
billingType: 'free' as const,
306+
subscriptionCostUsd: undefined,
307+
subscriptionPlan: undefined,
301308
configSource: 'database' as const,
302309
color: localProviderColors[lp.providerType] ?? '#10b981',
303310
apiKeyPlaceholder: undefined,
@@ -331,6 +338,9 @@ app.get('/', async (c) => {
331338
isConfigured: cli.authenticated,
332339
isEnabled: true,
333340
hasOverride: false,
341+
billingType: 'free' as const,
342+
subscriptionCostUsd: undefined,
343+
subscriptionPlan: undefined,
334344
configSource: 'database' as const, // CLI providers are auto-detected
335345
color: cliProviderColors[cli.id] ?? '#666666',
336346
apiKeyPlaceholder: undefined,
@@ -423,13 +433,19 @@ app.get('/:id', async (c) => {
423433
isEnabled: override?.isEnabled !== false,
424434
hasOverride: !!override,
425435
// Include user override details if present
436+
billingType: override?.billingType ?? 'pay-per-use',
437+
subscriptionCostUsd: override?.subscriptionCostUsd,
438+
subscriptionPlan: override?.subscriptionPlan,
426439
userOverride: override
427440
? {
428441
baseUrl: override.baseUrl,
429442
providerType: override.providerType,
430443
isEnabled: override.isEnabled,
431444
apiKeyEnv: override.apiKeyEnv,
432445
notes: override.notes,
446+
billingType: override.billingType,
447+
subscriptionCostUsd: override.subscriptionCostUsd,
448+
subscriptionPlan: override.subscriptionPlan,
433449
}
434450
: null,
435451
// UI metadata

packages/ui/src/components/ProvidersTab.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@ export function ProvidersTab() {
3737
providerType: string;
3838
isEnabled: boolean;
3939
notes: string;
40+
billingType?: 'pay-per-use' | 'subscription' | 'free';
41+
subscriptionCostUsd?: number;
42+
subscriptionPlan?: string;
4043
}>({
4144
baseUrl: '',
4245
providerType: '',
4346
isEnabled: true,
4447
notes: '',
48+
billingType: 'pay-per-use',
4549
});
4650
const [saving, setSaving] = useState(false);
4751

@@ -85,6 +89,9 @@ export function ProvidersTab() {
8589
providerType: override.providerType || (baseConfig as Record<string, string>).type || '',
8690
isEnabled: override.isEnabled !== false,
8791
notes: override.notes || '',
92+
billingType: override.billingType ?? 'pay-per-use',
93+
subscriptionCostUsd: override.subscriptionCostUsd,
94+
subscriptionPlan: override.subscriptionPlan,
8895
});
8996
setEditingProvider(providerId);
9097
} catch {
@@ -101,6 +108,11 @@ export function ProvidersTab() {
101108
providerType: editForm.providerType || undefined,
102109
isEnabled: editForm.isEnabled,
103110
notes: editForm.notes || undefined,
111+
billingType: editForm.billingType,
112+
subscriptionCostUsd:
113+
editForm.billingType === 'subscription' ? editForm.subscriptionCostUsd : undefined,
114+
subscriptionPlan:
115+
editForm.billingType === 'subscription' ? editForm.subscriptionPlan : undefined,
104116
});
105117
// Refresh providers
106118
await fetchProviders();
@@ -179,6 +191,17 @@ export function ProvidersTab() {
179191
Override
180192
</span>
181193
)}
194+
{provider.billingType === 'subscription' && (
195+
<span className="text-xs px-1.5 py-0.5 bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 rounded">
196+
Subscription
197+
{provider.subscriptionCostUsd ? ` $${provider.subscriptionCostUsd}/mo` : ''}
198+
</span>
199+
)}
200+
{provider.billingType === 'free' && (
201+
<span className="text-xs px-1.5 py-0.5 bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 rounded">
202+
Free
203+
</span>
204+
)}
182205
</div>
183206

184207
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
@@ -433,6 +456,72 @@ export function ProvidersTab() {
433456
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
434457
/>
435458
</div>
459+
460+
{/* Billing Type */}
461+
<div>
462+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
463+
Billing Type
464+
</label>
465+
<select
466+
value={editForm.billingType ?? 'pay-per-use'}
467+
onChange={(e) =>
468+
setEditForm((f) => ({ ...f, billingType: e.target.value as never }))
469+
}
470+
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
471+
>
472+
<option value="pay-per-use">Pay-per-use (API)</option>
473+
<option value="subscription">Subscription (flat monthly fee)</option>
474+
<option value="free">Free (local / free tier)</option>
475+
</select>
476+
<p className="mt-1 text-xs text-gray-500">
477+
Pay-per-use: billed per token. Subscription: fixed monthly fee, no per-token
478+
cost. Free: no cost.
479+
</p>
480+
</div>
481+
482+
{/* Subscription details (only if subscription) */}
483+
{editForm.billingType === 'subscription' && (
484+
<div className="grid grid-cols-2 gap-3">
485+
<div>
486+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
487+
Monthly Cost (USD)
488+
</label>
489+
<input
490+
type="number"
491+
step="0.01"
492+
min="0"
493+
value={editForm.subscriptionCostUsd ?? ''}
494+
onChange={(e) =>
495+
setEditForm((f) => ({
496+
...f,
497+
subscriptionCostUsd: e.target.value
498+
? parseFloat(e.target.value)
499+
: undefined,
500+
}))
501+
}
502+
placeholder="e.g. 20.00"
503+
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
504+
/>
505+
</div>
506+
<div>
507+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
508+
Plan Name
509+
</label>
510+
<input
511+
type="text"
512+
value={editForm.subscriptionPlan ?? ''}
513+
onChange={(e) =>
514+
setEditForm((f) => ({
515+
...f,
516+
subscriptionPlan: e.target.value || undefined,
517+
}))
518+
}
519+
placeholder="e.g. ChatGPT Plus"
520+
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
521+
/>
522+
</div>
523+
</div>
524+
)}
436525
</div>
437526

438527
{/* Actions */}

packages/ui/src/types/models.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export interface ModelInfo {
1717
recommended?: boolean;
1818
}
1919

20+
/** Billing type for AI providers */
21+
export type BillingType = 'pay-per-use' | 'subscription' | 'free';
22+
2023
/** Full provider info as returned by /api/v1/providers */
2124
export interface ProviderInfo {
2225
id: string;
@@ -28,6 +31,9 @@ export interface ProviderInfo {
2831
isConfigured: boolean;
2932
isEnabled: boolean;
3033
hasOverride: boolean;
34+
billingType?: BillingType;
35+
subscriptionCostUsd?: number;
36+
subscriptionPlan?: string;
3137
color?: string;
3238
modelCount: number;
3339
features: {
@@ -58,6 +64,9 @@ export interface UserOverride {
5864
isEnabled: boolean;
5965
apiKeyEnv?: string;
6066
notes?: string;
67+
billingType?: BillingType;
68+
subscriptionCostUsd?: number;
69+
subscriptionPlan?: string;
6170
}
6271

6372
/** Local provider (Ollama, LM Studio, etc.) */

0 commit comments

Comments
 (0)