costhawk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +32 -0
- package/STRATEGIC_PLAN_2025-12-01.md +934 -0
- package/costcanary/.claude/settings.local.json +9 -0
- package/costcanary/.env.production.template +38 -0
- package/costcanary/.eslintrc.json +22 -0
- package/costcanary/.nvmrc +1 -0
- package/costcanary/.prettierignore +11 -0
- package/costcanary/.prettierrc.json +12 -0
- package/costcanary/ADMIN_SETUP.md +68 -0
- package/costcanary/CLAUDE.md +228 -0
- package/costcanary/CLERK_SETUP.md +69 -0
- package/costcanary/DATABASE_SETUP.md +136 -0
- package/costcanary/DEMO_CHECKLIST.md +62 -0
- package/costcanary/DEPLOYMENT.md +31 -0
- package/costcanary/PRODUCTION_RECOVERY.md +109 -0
- package/costcanary/README.md +247 -0
- package/costcanary/STRIPE_SECURITY_AUDIT.md +123 -0
- package/costcanary/TESTING_ADMIN.md +92 -0
- package/costcanary/app/(auth)/sign-in/[[...sign-in]]/page.tsx +25 -0
- package/costcanary/app/(auth)/sign-up/[[...sign-up]]/page.tsx +25 -0
- package/costcanary/app/(dashboard)/dashboard/admin/page.tsx +260 -0
- package/costcanary/app/(dashboard)/dashboard/alerts/page.tsx +64 -0
- package/costcanary/app/(dashboard)/dashboard/api-keys/page.tsx +231 -0
- package/costcanary/app/(dashboard)/dashboard/billing/page.tsx +349 -0
- package/costcanary/app/(dashboard)/dashboard/layout.tsx +188 -0
- package/costcanary/app/(dashboard)/dashboard/page.tsx +13 -0
- package/costcanary/app/(dashboard)/dashboard/playground/page.tsx +605 -0
- package/costcanary/app/(dashboard)/dashboard/settings/page.tsx +86 -0
- package/costcanary/app/(dashboard)/dashboard/usage/page.tsx +354 -0
- package/costcanary/app/(dashboard)/dashboard/wrapped-keys/page.tsx +677 -0
- package/costcanary/app/(marketing)/page.tsx +90 -0
- package/costcanary/app/(marketing)/pricing/page.tsx +272 -0
- package/costcanary/app/admin/pricing-status/page.tsx +338 -0
- package/costcanary/app/api/admin/check-pricing/route.ts +127 -0
- package/costcanary/app/api/admin/debug/route.ts +44 -0
- package/costcanary/app/api/admin/fix-pricing/route.ts +216 -0
- package/costcanary/app/api/admin/pricing-jobs/[jobId]/route.ts +48 -0
- package/costcanary/app/api/admin/pricing-jobs/route.ts +45 -0
- package/costcanary/app/api/admin/trigger-pricing/route.ts +209 -0
- package/costcanary/app/api/admin/whoami/route.ts +44 -0
- package/costcanary/app/api/auth/clerk/[...nextjs]/route.ts +93 -0
- package/costcanary/app/api/debug/wrapped-key/route.ts +51 -0
- package/costcanary/app/api/debug-status/route.ts +9 -0
- package/costcanary/app/api/debug-version/route.ts +12 -0
- package/costcanary/app/api/health/route.ts +14 -0
- package/costcanary/app/api/health-simple/route.ts +18 -0
- package/costcanary/app/api/keys/route.ts +162 -0
- package/costcanary/app/api/keys/wrapped/[id]/revoke/route.ts +86 -0
- package/costcanary/app/api/keys/wrapped/[id]/rotate/route.ts +81 -0
- package/costcanary/app/api/keys/wrapped/route.ts +241 -0
- package/costcanary/app/api/optimizer/preview/route.ts +147 -0
- package/costcanary/app/api/optimizer/route.ts +118 -0
- package/costcanary/app/api/pricing/models/route.ts +102 -0
- package/costcanary/app/api/proxy/[...path]/route.ts +391 -0
- package/costcanary/app/api/proxy/anthropic/route.ts +539 -0
- package/costcanary/app/api/proxy/google/route.ts +395 -0
- package/costcanary/app/api/proxy/openai/route.ts +529 -0
- package/costcanary/app/api/simple-test/route.ts +7 -0
- package/costcanary/app/api/stripe/checkout/route.ts +201 -0
- package/costcanary/app/api/stripe/webhook/route.ts +392 -0
- package/costcanary/app/api/test-connection/route.ts +209 -0
- package/costcanary/app/api/test-proxy/route.ts +7 -0
- package/costcanary/app/api/test-simple/route.ts +20 -0
- package/costcanary/app/api/usage/current/route.ts +112 -0
- package/costcanary/app/api/usage/stats/route.ts +129 -0
- package/costcanary/app/api/usage/stream/route.ts +113 -0
- package/costcanary/app/api/usage/summary/route.ts +67 -0
- package/costcanary/app/api/usage/trend/route.ts +119 -0
- package/costcanary/app/api/ws/route.ts +23 -0
- package/costcanary/app/globals.css +280 -0
- package/costcanary/app/layout.tsx +87 -0
- package/costcanary/components/Header.tsx +85 -0
- package/costcanary/components/dashboard/AddApiKeyModal.tsx +264 -0
- package/costcanary/components/dashboard/dashboard-content.tsx +329 -0
- package/costcanary/components/landing/DashboardPreview.tsx +222 -0
- package/costcanary/components/landing/Features.tsx +238 -0
- package/costcanary/components/landing/Footer.tsx +83 -0
- package/costcanary/components/landing/Hero.tsx +193 -0
- package/costcanary/components/landing/Pricing.tsx +250 -0
- package/costcanary/components/landing/Testimonials.tsx +248 -0
- package/costcanary/components/theme-provider.tsx +8 -0
- package/costcanary/components/ui/alert.tsx +59 -0
- package/costcanary/components/ui/badge.tsx +36 -0
- package/costcanary/components/ui/button.tsx +56 -0
- package/costcanary/components/ui/card.tsx +79 -0
- package/costcanary/components/ui/dialog.tsx +122 -0
- package/costcanary/components/ui/input.tsx +22 -0
- package/costcanary/components/ui/label.tsx +26 -0
- package/costcanary/components/ui/progress.tsx +28 -0
- package/costcanary/components/ui/select.tsx +160 -0
- package/costcanary/components/ui/separator.tsx +31 -0
- package/costcanary/components/ui/switch.tsx +29 -0
- package/costcanary/components/ui/tabs.tsx +55 -0
- package/costcanary/components/ui/toast.tsx +127 -0
- package/costcanary/components/ui/toaster.tsx +35 -0
- package/costcanary/components/ui/use-toast.ts +189 -0
- package/costcanary/components.json +17 -0
- package/costcanary/debug-wrapped-keys.md +117 -0
- package/costcanary/fix-console.sh +30 -0
- package/costcanary/lib/admin-auth.ts +226 -0
- package/costcanary/lib/admin-security.ts +124 -0
- package/costcanary/lib/audit-events.ts +62 -0
- package/costcanary/lib/audit.ts +158 -0
- package/costcanary/lib/chart-colors.ts +152 -0
- package/costcanary/lib/cost-calculator.ts +212 -0
- package/costcanary/lib/db-utils.ts +325 -0
- package/costcanary/lib/db.ts +14 -0
- package/costcanary/lib/encryption.ts +120 -0
- package/costcanary/lib/kms.ts +358 -0
- package/costcanary/lib/model-alias.ts +180 -0
- package/costcanary/lib/pricing.ts +292 -0
- package/costcanary/lib/prisma.ts +52 -0
- package/costcanary/lib/railway-db.ts +157 -0
- package/costcanary/lib/sse-parser.ts +283 -0
- package/costcanary/lib/stripe-client.ts +81 -0
- package/costcanary/lib/stripe-server.ts +52 -0
- package/costcanary/lib/tokens.ts +396 -0
- package/costcanary/lib/usage-limits.ts +164 -0
- package/costcanary/lib/utils.ts +6 -0
- package/costcanary/lib/websocket.ts +153 -0
- package/costcanary/lib/wrapped-keys.ts +531 -0
- package/costcanary/market-research.md +443 -0
- package/costcanary/middleware.ts +48 -0
- package/costcanary/next.config.js +43 -0
- package/costcanary/nia-sources.md +151 -0
- package/costcanary/package-lock.json +12162 -0
- package/costcanary/package.json +92 -0
- package/costcanary/package.json.backup +89 -0
- package/costcanary/postcss.config.js +6 -0
- package/costcanary/pricing-worker/.env.example +8 -0
- package/costcanary/pricing-worker/README.md +81 -0
- package/costcanary/pricing-worker/package-lock.json +1109 -0
- package/costcanary/pricing-worker/package.json +26 -0
- package/costcanary/pricing-worker/railway.json +13 -0
- package/costcanary/pricing-worker/schema.prisma +326 -0
- package/costcanary/pricing-worker/src/index.ts +115 -0
- package/costcanary/pricing-worker/src/services/pricing-updater.ts +79 -0
- package/costcanary/pricing-worker/src/services/tavily-client.ts +474 -0
- package/costcanary/pricing-worker/test-tavily.ts +47 -0
- package/costcanary/pricing-worker/tsconfig.json +24 -0
- package/costcanary/prisma/migrations/001_add_stripe_fields.sql +26 -0
- package/costcanary/prisma/schema.prisma +326 -0
- package/costcanary/prisma/seed-pricing.ts +133 -0
- package/costcanary/public/costhawk-logo.png +0 -0
- package/costcanary/railway.json +30 -0
- package/costcanary/railway.toml +16 -0
- package/costcanary/research-nia.md +298 -0
- package/costcanary/research.md +411 -0
- package/costcanary/scripts/build-production.js +65 -0
- package/costcanary/scripts/check-current-pricing.ts +51 -0
- package/costcanary/scripts/check-pricing-data.ts +174 -0
- package/costcanary/scripts/create-stripe-prices.js +49 -0
- package/costcanary/scripts/fix-pricing-data.ts +135 -0
- package/costcanary/scripts/fix-pricing-db.ts +148 -0
- package/costcanary/scripts/postinstall.js +58 -0
- package/costcanary/scripts/railway-deploy.sh +52 -0
- package/costcanary/scripts/run-migration.js +61 -0
- package/costcanary/scripts/start-production.js +175 -0
- package/costcanary/scripts/test-wrapped-key.ts +85 -0
- package/costcanary/scripts/validate-deployment.js +176 -0
- package/costcanary/scripts/validate-production.js +119 -0
- package/costcanary/server.js.backup +38 -0
- package/costcanary/tailwind.config.ts +216 -0
- package/costcanary/test-pricing-status.sh +27 -0
- package/costcanary/tsconfig.json +42 -0
- package/docs/sessions/session-2025-12-01.md +570 -0
- package/executive-summary.md +302 -0
- package/index.js +1 -0
- package/nia-sources.md +163 -0
- package/package.json +16 -0
- package/research.md +750 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { prisma } from './prisma';
|
|
2
|
+
import { Provider } from '@prisma/client';
|
|
3
|
+
|
|
4
|
+
// Initialize Redis if available (optional dependency)
|
|
5
|
+
let redis: { get: (key: string) => Promise<string | null>; setex: (key: string, ttl: number, value: string) => Promise<void>; del: (...keys: string[]) => Promise<void>; keys: (pattern: string) => Promise<string[]> } | null = null;
|
|
6
|
+
try {
|
|
7
|
+
if (process.env.REDIS_URL) {
|
|
8
|
+
const Redis = require('ioredis');
|
|
9
|
+
redis = new Redis(process.env.REDIS_URL);
|
|
10
|
+
}
|
|
11
|
+
} catch (error) {
|
|
12
|
+
// DEBUG: console.log('[pricing] Redis not available, using database only');
|
|
13
|
+
}
|
|
14
|
+
const CACHE_TTL = 60 * 10; // 10 minutes
|
|
15
|
+
|
|
16
|
+
export interface PriceRow {
|
|
17
|
+
provider: Provider;
|
|
18
|
+
canonicalModel: string;
|
|
19
|
+
versionId: string;
|
|
20
|
+
unitInputPer1k: number;
|
|
21
|
+
unitOutputPer1k: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UsageData {
|
|
25
|
+
input?: number;
|
|
26
|
+
output?: number;
|
|
27
|
+
prompt?: number;
|
|
28
|
+
completion?: number;
|
|
29
|
+
total?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CostBreakdown {
|
|
33
|
+
input: number;
|
|
34
|
+
output: number;
|
|
35
|
+
costIn: number;
|
|
36
|
+
costOut: number;
|
|
37
|
+
total: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the active pricing for a specific provider and model
|
|
42
|
+
* Uses Redis caching if available
|
|
43
|
+
*/
|
|
44
|
+
export async function getActivePrice(
|
|
45
|
+
provider: Provider,
|
|
46
|
+
canonicalModel: string
|
|
47
|
+
): Promise<PriceRow | null> {
|
|
48
|
+
if (!canonicalModel) return null;
|
|
49
|
+
|
|
50
|
+
const cacheKey = `price:${provider}:${canonicalModel}`;
|
|
51
|
+
|
|
52
|
+
// Try Redis cache first
|
|
53
|
+
if (redis) {
|
|
54
|
+
try {
|
|
55
|
+
const cached = await redis.get(cacheKey);
|
|
56
|
+
if (cached) {
|
|
57
|
+
// DEBUG: console.log(`[pricing] Cache hit for ${provider}:${canonicalModel}`);
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(cached);
|
|
60
|
+
} catch (parseError) {
|
|
61
|
+
// DEBUG: console.error('[pricing] Failed to parse cached value:', parseError);
|
|
62
|
+
// Continue to database query
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// DEBUG: console.error('[pricing] Redis error:', error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Query database for active pricing
|
|
71
|
+
try {
|
|
72
|
+
const version = await prisma.pricingVersion.findFirst({
|
|
73
|
+
where: {
|
|
74
|
+
provider,
|
|
75
|
+
isActive: true
|
|
76
|
+
},
|
|
77
|
+
orderBy: { createdAt: 'desc' },
|
|
78
|
+
include: {
|
|
79
|
+
models: {
|
|
80
|
+
where: {
|
|
81
|
+
provider,
|
|
82
|
+
canonicalModel
|
|
83
|
+
},
|
|
84
|
+
take: 1
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const priceModel = version?.models[0];
|
|
90
|
+
if (!priceModel) {
|
|
91
|
+
// DEBUG: console.warn(`[pricing] No pricing found for ${provider}:${canonicalModel}`);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const priceRow: PriceRow = {
|
|
96
|
+
provider,
|
|
97
|
+
canonicalModel,
|
|
98
|
+
versionId: version.id,
|
|
99
|
+
unitInputPer1k: Number(priceModel.unitInputPer1k),
|
|
100
|
+
unitOutputPer1k: Number(priceModel.unitOutputPer1k),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Cache in Redis
|
|
104
|
+
if (redis) {
|
|
105
|
+
try {
|
|
106
|
+
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(priceRow));
|
|
107
|
+
// DEBUG: console.log(`[pricing] Cached ${provider}:${canonicalModel}`);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// DEBUG: console.error('[pricing] Redis cache error:', error);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return priceRow;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// DEBUG: console.error('[pricing] Database error:', error);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Compute cost based on token usage and pricing
|
|
122
|
+
*/
|
|
123
|
+
export function computeCost(
|
|
124
|
+
usage: UsageData,
|
|
125
|
+
price: PriceRow
|
|
126
|
+
): CostBreakdown {
|
|
127
|
+
// Normalize token counts (handle OpenAI, Anthropic, and common formats)
|
|
128
|
+
// Anthropic uses input_tokens/output_tokens, OpenAI uses prompt_tokens/completion_tokens
|
|
129
|
+
// We also accept input/output for normalized formats
|
|
130
|
+
const usageAny = usage as Record<string, unknown>;
|
|
131
|
+
const inputTokens =
|
|
132
|
+
(typeof usageAny.input_tokens === 'number' ? usageAny.input_tokens : 0) ||
|
|
133
|
+
(typeof usage.input === 'number' ? usage.input : 0) ||
|
|
134
|
+
(typeof usage.prompt === 'number' ? usage.prompt : 0) ||
|
|
135
|
+
(typeof usageAny.prompt_tokens === 'number' ? usageAny.prompt_tokens : 0) ||
|
|
136
|
+
0;
|
|
137
|
+
const outputTokens =
|
|
138
|
+
(typeof usageAny.output_tokens === 'number' ? usageAny.output_tokens : 0) ||
|
|
139
|
+
(typeof usage.output === 'number' ? usage.output : 0) ||
|
|
140
|
+
(typeof usage.completion === 'number' ? usage.completion : 0) ||
|
|
141
|
+
(typeof usageAny.completion_tokens === 'number' ? usageAny.completion_tokens : 0) ||
|
|
142
|
+
0;
|
|
143
|
+
|
|
144
|
+
// Calculate costs (prices are per 1000 tokens)
|
|
145
|
+
const costIn = (inputTokens / 1000) * price.unitInputPer1k;
|
|
146
|
+
const costOut = (outputTokens / 1000) * price.unitOutputPer1k;
|
|
147
|
+
const total = +(costIn + costOut).toFixed(6);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
input: inputTokens,
|
|
151
|
+
output: outputTokens,
|
|
152
|
+
costIn: +costIn.toFixed(6),
|
|
153
|
+
costOut: +costOut.toFixed(6),
|
|
154
|
+
total,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Clear the pricing cache (useful after updating prices)
|
|
160
|
+
*/
|
|
161
|
+
export async function clearPricingCache(
|
|
162
|
+
provider?: Provider,
|
|
163
|
+
model?: string
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
if (!redis) return;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
if (provider && model) {
|
|
169
|
+
// Clear specific model
|
|
170
|
+
await redis.del(`price:${provider}:${model}`);
|
|
171
|
+
// DEBUG: console.log(`[pricing] Cleared cache for ${provider}:${model}`);
|
|
172
|
+
} else if (provider) {
|
|
173
|
+
// Clear all models for provider
|
|
174
|
+
const keys = await redis.keys(`price:${provider}:*`);
|
|
175
|
+
if (keys.length > 0) {
|
|
176
|
+
await redis.del(...keys);
|
|
177
|
+
// DEBUG: console.log(`[pricing] Cleared ${keys.length} cache entries for ${provider}`);
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
// Clear all pricing cache
|
|
181
|
+
const keys = await redis.keys('price:*');
|
|
182
|
+
if (keys.length > 0) {
|
|
183
|
+
await redis.del(...keys);
|
|
184
|
+
// DEBUG: console.log(`[pricing] Cleared all ${keys.length} pricing cache entries`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// DEBUG: console.error('[pricing] Error clearing cache:', error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get fallback pricing for a model (hardcoded as backup)
|
|
194
|
+
* This ensures the system works even if database pricing isn't set up
|
|
195
|
+
*
|
|
196
|
+
* Prices are per 1K tokens (divide by 1000 from per-1M pricing)
|
|
197
|
+
* Last updated: December 2025
|
|
198
|
+
*/
|
|
199
|
+
export function getFallbackPrice(
|
|
200
|
+
provider: Provider,
|
|
201
|
+
model: string
|
|
202
|
+
): PriceRow | null {
|
|
203
|
+
const fallbackPrices: Partial<Record<Provider, Record<string, { input: number; output: number }>>> = {
|
|
204
|
+
[Provider.OPENAI]: {
|
|
205
|
+
// GPT-5 (December 2025) - $1.25/$10 per 1M
|
|
206
|
+
'gpt-5': { input: 0.00125, output: 0.01 },
|
|
207
|
+
'gpt-5-mini': { input: 0.00025, output: 0.002 },
|
|
208
|
+
// GPT-4o series (still available)
|
|
209
|
+
'gpt-4o': { input: 0.0025, output: 0.01 },
|
|
210
|
+
'gpt-4o-mini': { input: 0.00015, output: 0.0006 },
|
|
211
|
+
'gpt-4o-audio-preview': { input: 0.0025, output: 0.01 },
|
|
212
|
+
// o1/o3 reasoning models
|
|
213
|
+
'o1': { input: 0.015, output: 0.06 },
|
|
214
|
+
'o1-mini': { input: 0.0011, output: 0.0044 },
|
|
215
|
+
'o1-preview': { input: 0.015, output: 0.06 },
|
|
216
|
+
'o3-mini': { input: 0.0011, output: 0.0044 },
|
|
217
|
+
// Legacy models
|
|
218
|
+
'gpt-4-turbo': { input: 0.01, output: 0.03 },
|
|
219
|
+
'gpt-4': { input: 0.03, output: 0.06 },
|
|
220
|
+
'gpt-3.5-turbo': { input: 0.0005, output: 0.0015 },
|
|
221
|
+
},
|
|
222
|
+
[Provider.ANTHROPIC]: {
|
|
223
|
+
// Claude 4.5 (December 2025) - correct API model IDs
|
|
224
|
+
'claude-opus-4-5': { input: 0.005, output: 0.025 },
|
|
225
|
+
'claude-opus-4-5-20251101': { input: 0.005, output: 0.025 },
|
|
226
|
+
'claude-sonnet-4-5': { input: 0.003, output: 0.015 },
|
|
227
|
+
'claude-sonnet-4-5-20250929': { input: 0.003, output: 0.015 },
|
|
228
|
+
// Claude 3.5 - widely used
|
|
229
|
+
'claude-3-5-sonnet-20241022': { input: 0.003, output: 0.015 },
|
|
230
|
+
'claude-3-5-sonnet-20240620': { input: 0.003, output: 0.015 },
|
|
231
|
+
'claude-3-5-sonnet-latest': { input: 0.003, output: 0.015 },
|
|
232
|
+
'claude-3-5-haiku-20241022': { input: 0.0008, output: 0.004 },
|
|
233
|
+
'claude-3-5-haiku-latest': { input: 0.0008, output: 0.004 },
|
|
234
|
+
// Legacy Claude 3
|
|
235
|
+
'claude-3-opus-20240229': { input: 0.015, output: 0.075 },
|
|
236
|
+
'claude-3-opus-latest': { input: 0.015, output: 0.075 },
|
|
237
|
+
'claude-3-sonnet-20240229': { input: 0.003, output: 0.015 },
|
|
238
|
+
'claude-3-haiku-20240307': { input: 0.00025, output: 0.00125 },
|
|
239
|
+
},
|
|
240
|
+
[Provider.GOOGLE]: {
|
|
241
|
+
// Gemini 2.0 (December 2025)
|
|
242
|
+
'gemini-2.0-flash': { input: 0.0001, output: 0.0004 },
|
|
243
|
+
'gemini-2.0-flash-exp': { input: 0.0001, output: 0.0004 },
|
|
244
|
+
'gemini-2.0-pro': { input: 0.00035, output: 0.0015 },
|
|
245
|
+
// Gemini 1.5 (still available)
|
|
246
|
+
'gemini-1.5-pro': { input: 0.00125, output: 0.005 },
|
|
247
|
+
'gemini-1.5-pro-latest': { input: 0.00125, output: 0.005 },
|
|
248
|
+
'gemini-1.5-flash': { input: 0.0006, output: 0.0024 },
|
|
249
|
+
'gemini-1.5-flash-latest': { input: 0.0006, output: 0.0024 },
|
|
250
|
+
'gemini-1.5-flash-8b': { input: 0.0000375, output: 0.00015 },
|
|
251
|
+
// Legacy
|
|
252
|
+
'gemini-pro': { input: 0.0005, output: 0.0015 },
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const providerPrices = fallbackPrices[provider];
|
|
257
|
+
if (!providerPrices) return null;
|
|
258
|
+
|
|
259
|
+
// Try exact match first
|
|
260
|
+
let pricing = providerPrices[model];
|
|
261
|
+
|
|
262
|
+
// If not found, try partial match
|
|
263
|
+
if (!pricing) {
|
|
264
|
+
const modelKey = Object.keys(providerPrices).find(key =>
|
|
265
|
+
model.toLowerCase().includes(key.toLowerCase()) ||
|
|
266
|
+
key.toLowerCase().includes(model.toLowerCase())
|
|
267
|
+
);
|
|
268
|
+
if (modelKey) {
|
|
269
|
+
pricing = providerPrices[modelKey];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!pricing) {
|
|
274
|
+
// Use first available as default
|
|
275
|
+
const values = Object.values(providerPrices);
|
|
276
|
+
if (values.length > 0) {
|
|
277
|
+
pricing = values[0];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!pricing) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
provider,
|
|
287
|
+
canonicalModel: model,
|
|
288
|
+
versionId: 'fallback',
|
|
289
|
+
unitInputPer1k: pricing.input,
|
|
290
|
+
unitOutputPer1k: pricing.output,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { PrismaClient } from '@prisma/client'
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
// eslint-disable-next-line no-var
|
|
5
|
+
var prisma: PrismaClient | undefined
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// PrismaClient is attached to the `global` object in development to prevent
|
|
9
|
+
// exhausting your database connection limit.
|
|
10
|
+
//
|
|
11
|
+
// Learn more:
|
|
12
|
+
// https://pris.ly/d/help/next-js-best-practices
|
|
13
|
+
|
|
14
|
+
const prismaClientSingleton = () => {
|
|
15
|
+
// Railway-specific: Use public URL if internal fails
|
|
16
|
+
const databaseUrl = process.env.DATABASE_PUBLIC_URL || process.env.DATABASE_URL;
|
|
17
|
+
|
|
18
|
+
if (!databaseUrl) {
|
|
19
|
+
console.error('[Prisma] No database URL configured!');
|
|
20
|
+
console.error('[Prisma] DATABASE_URL:', !!process.env.DATABASE_URL);
|
|
21
|
+
console.error('[Prisma] DATABASE_PUBLIC_URL:', !!process.env.DATABASE_PUBLIC_URL);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return new PrismaClient({
|
|
25
|
+
datasources: {
|
|
26
|
+
db: {
|
|
27
|
+
url: databaseUrl || 'postgresql://placeholder:placeholder@localhost:5432/placeholder'
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
log: process.env.NODE_ENV === 'development'
|
|
31
|
+
? ['query', 'error', 'warn']
|
|
32
|
+
: ['error'],
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const prisma = globalThis.prisma ?? prismaClientSingleton()
|
|
37
|
+
|
|
38
|
+
if (process.env.NODE_ENV !== 'production') globalThis.prisma = prisma
|
|
39
|
+
|
|
40
|
+
// Export types for use in the application
|
|
41
|
+
export type {
|
|
42
|
+
User,
|
|
43
|
+
ApiKey,
|
|
44
|
+
UsageLog,
|
|
45
|
+
Budget,
|
|
46
|
+
Alert,
|
|
47
|
+
AuditLog,
|
|
48
|
+
Provider,
|
|
49
|
+
Period,
|
|
50
|
+
AlertType,
|
|
51
|
+
AuditAction
|
|
52
|
+
} from '@prisma/client'
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Railway-Specific Database Connection Utilities
|
|
3
|
+
*
|
|
4
|
+
* Handles Railway's internal DNS resolution timing issues
|
|
5
|
+
* and provides fallback strategies for reliable database connectivity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { PrismaClient } from '@prisma/client';
|
|
9
|
+
|
|
10
|
+
interface DatabaseConnectionResult {
|
|
11
|
+
success: boolean;
|
|
12
|
+
client?: PrismaClient;
|
|
13
|
+
error?: string;
|
|
14
|
+
urlUsed?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a database connection with Railway-specific fallback logic
|
|
19
|
+
*/
|
|
20
|
+
export async function createRailwayDatabaseConnection(
|
|
21
|
+
retries = 3,
|
|
22
|
+
timeoutMs = 5000
|
|
23
|
+
): Promise<DatabaseConnectionResult> {
|
|
24
|
+
const connectionStrategies = [
|
|
25
|
+
{
|
|
26
|
+
name: 'internal',
|
|
27
|
+
url: process.env.DATABASE_URL,
|
|
28
|
+
description: 'Railway internal URL'
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'public',
|
|
32
|
+
url: process.env.DATABASE_PUBLIC_URL,
|
|
33
|
+
description: 'Railway public URL'
|
|
34
|
+
}
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
for (const strategy of connectionStrategies) {
|
|
38
|
+
if (!strategy.url) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
43
|
+
try {
|
|
44
|
+
// Railway deployment logging
|
|
45
|
+
// eslint-disable-next-line no-console
|
|
46
|
+
console.log(`[Railway DB] Attempting ${strategy.description} (attempt ${attempt}/${retries})`)
|
|
47
|
+
|
|
48
|
+
const client = new PrismaClient({
|
|
49
|
+
datasources: {
|
|
50
|
+
db: { url: strategy.url }
|
|
51
|
+
},
|
|
52
|
+
log: ['error']
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Test connection with timeout
|
|
56
|
+
const connectionTest = client.$queryRaw`SELECT 1 as test`;
|
|
57
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
58
|
+
setTimeout(() => reject(new Error('Connection timeout')), timeoutMs)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
await Promise.race([connectionTest, timeoutPromise]);
|
|
62
|
+
|
|
63
|
+
// eslint-disable-next-line no-console
|
|
64
|
+
console.log(`[Railway DB] ✅ Connected using ${strategy.description}`);
|
|
65
|
+
return {
|
|
66
|
+
success: true,
|
|
67
|
+
client,
|
|
68
|
+
urlUsed: strategy.name
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// eslint-disable-next-line no-console
|
|
73
|
+
console.log(`[Railway DB] ❌ ${strategy.description} attempt ${attempt} failed:`,
|
|
74
|
+
error instanceof Error ? error.message : 'Unknown error'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (attempt === retries) {
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.log(`[Railway DB] All attempts failed for ${strategy.description}`);
|
|
80
|
+
} else {
|
|
81
|
+
// Wait before retry
|
|
82
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
success: false,
|
|
90
|
+
error: 'All database connection strategies failed'
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Tests database connectivity with multiple strategies
|
|
96
|
+
*/
|
|
97
|
+
export async function testDatabaseConnectivity(): Promise<{
|
|
98
|
+
healthy: boolean;
|
|
99
|
+
details: {
|
|
100
|
+
internal?: { success: boolean; latency?: number; error?: string };
|
|
101
|
+
public?: { success: boolean; latency?: number; error?: string };
|
|
102
|
+
};
|
|
103
|
+
}> {
|
|
104
|
+
const details: {
|
|
105
|
+
internal?: { success: boolean; latency?: number; error?: string };
|
|
106
|
+
public?: { success: boolean; latency?: number; error?: string };
|
|
107
|
+
} = {};
|
|
108
|
+
|
|
109
|
+
const strategies = [
|
|
110
|
+
{ name: 'internal' as const, url: process.env.DATABASE_URL },
|
|
111
|
+
{ name: 'public' as const, url: process.env.DATABASE_PUBLIC_URL }
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
let anySuccessful = false;
|
|
115
|
+
|
|
116
|
+
for (const strategy of strategies) {
|
|
117
|
+
if (!strategy.url) {
|
|
118
|
+
details[strategy.name] = { success: false, error: 'URL not configured' };
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const startTime = Date.now();
|
|
124
|
+
|
|
125
|
+
const client = new PrismaClient({
|
|
126
|
+
datasources: { db: { url: strategy.url } },
|
|
127
|
+
log: []
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
131
|
+
setTimeout(() => reject(new Error('Timeout')), 3000)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
await Promise.race([
|
|
135
|
+
client.$queryRaw`SELECT 1 as test`,
|
|
136
|
+
timeoutPromise
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
await client.$disconnect();
|
|
140
|
+
|
|
141
|
+
const latency = Date.now() - startTime;
|
|
142
|
+
details[strategy.name] = { success: true, latency };
|
|
143
|
+
anySuccessful = true;
|
|
144
|
+
|
|
145
|
+
} catch (error) {
|
|
146
|
+
details[strategy.name] = {
|
|
147
|
+
success: false,
|
|
148
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
healthy: anySuccessful,
|
|
155
|
+
details
|
|
156
|
+
};
|
|
157
|
+
}
|