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.
Files changed (171) hide show
  1. package/.claude/settings.local.json +32 -0
  2. package/STRATEGIC_PLAN_2025-12-01.md +934 -0
  3. package/costcanary/.claude/settings.local.json +9 -0
  4. package/costcanary/.env.production.template +38 -0
  5. package/costcanary/.eslintrc.json +22 -0
  6. package/costcanary/.nvmrc +1 -0
  7. package/costcanary/.prettierignore +11 -0
  8. package/costcanary/.prettierrc.json +12 -0
  9. package/costcanary/ADMIN_SETUP.md +68 -0
  10. package/costcanary/CLAUDE.md +228 -0
  11. package/costcanary/CLERK_SETUP.md +69 -0
  12. package/costcanary/DATABASE_SETUP.md +136 -0
  13. package/costcanary/DEMO_CHECKLIST.md +62 -0
  14. package/costcanary/DEPLOYMENT.md +31 -0
  15. package/costcanary/PRODUCTION_RECOVERY.md +109 -0
  16. package/costcanary/README.md +247 -0
  17. package/costcanary/STRIPE_SECURITY_AUDIT.md +123 -0
  18. package/costcanary/TESTING_ADMIN.md +92 -0
  19. package/costcanary/app/(auth)/sign-in/[[...sign-in]]/page.tsx +25 -0
  20. package/costcanary/app/(auth)/sign-up/[[...sign-up]]/page.tsx +25 -0
  21. package/costcanary/app/(dashboard)/dashboard/admin/page.tsx +260 -0
  22. package/costcanary/app/(dashboard)/dashboard/alerts/page.tsx +64 -0
  23. package/costcanary/app/(dashboard)/dashboard/api-keys/page.tsx +231 -0
  24. package/costcanary/app/(dashboard)/dashboard/billing/page.tsx +349 -0
  25. package/costcanary/app/(dashboard)/dashboard/layout.tsx +188 -0
  26. package/costcanary/app/(dashboard)/dashboard/page.tsx +13 -0
  27. package/costcanary/app/(dashboard)/dashboard/playground/page.tsx +605 -0
  28. package/costcanary/app/(dashboard)/dashboard/settings/page.tsx +86 -0
  29. package/costcanary/app/(dashboard)/dashboard/usage/page.tsx +354 -0
  30. package/costcanary/app/(dashboard)/dashboard/wrapped-keys/page.tsx +677 -0
  31. package/costcanary/app/(marketing)/page.tsx +90 -0
  32. package/costcanary/app/(marketing)/pricing/page.tsx +272 -0
  33. package/costcanary/app/admin/pricing-status/page.tsx +338 -0
  34. package/costcanary/app/api/admin/check-pricing/route.ts +127 -0
  35. package/costcanary/app/api/admin/debug/route.ts +44 -0
  36. package/costcanary/app/api/admin/fix-pricing/route.ts +216 -0
  37. package/costcanary/app/api/admin/pricing-jobs/[jobId]/route.ts +48 -0
  38. package/costcanary/app/api/admin/pricing-jobs/route.ts +45 -0
  39. package/costcanary/app/api/admin/trigger-pricing/route.ts +209 -0
  40. package/costcanary/app/api/admin/whoami/route.ts +44 -0
  41. package/costcanary/app/api/auth/clerk/[...nextjs]/route.ts +93 -0
  42. package/costcanary/app/api/debug/wrapped-key/route.ts +51 -0
  43. package/costcanary/app/api/debug-status/route.ts +9 -0
  44. package/costcanary/app/api/debug-version/route.ts +12 -0
  45. package/costcanary/app/api/health/route.ts +14 -0
  46. package/costcanary/app/api/health-simple/route.ts +18 -0
  47. package/costcanary/app/api/keys/route.ts +162 -0
  48. package/costcanary/app/api/keys/wrapped/[id]/revoke/route.ts +86 -0
  49. package/costcanary/app/api/keys/wrapped/[id]/rotate/route.ts +81 -0
  50. package/costcanary/app/api/keys/wrapped/route.ts +241 -0
  51. package/costcanary/app/api/optimizer/preview/route.ts +147 -0
  52. package/costcanary/app/api/optimizer/route.ts +118 -0
  53. package/costcanary/app/api/pricing/models/route.ts +102 -0
  54. package/costcanary/app/api/proxy/[...path]/route.ts +391 -0
  55. package/costcanary/app/api/proxy/anthropic/route.ts +539 -0
  56. package/costcanary/app/api/proxy/google/route.ts +395 -0
  57. package/costcanary/app/api/proxy/openai/route.ts +529 -0
  58. package/costcanary/app/api/simple-test/route.ts +7 -0
  59. package/costcanary/app/api/stripe/checkout/route.ts +201 -0
  60. package/costcanary/app/api/stripe/webhook/route.ts +392 -0
  61. package/costcanary/app/api/test-connection/route.ts +209 -0
  62. package/costcanary/app/api/test-proxy/route.ts +7 -0
  63. package/costcanary/app/api/test-simple/route.ts +20 -0
  64. package/costcanary/app/api/usage/current/route.ts +112 -0
  65. package/costcanary/app/api/usage/stats/route.ts +129 -0
  66. package/costcanary/app/api/usage/stream/route.ts +113 -0
  67. package/costcanary/app/api/usage/summary/route.ts +67 -0
  68. package/costcanary/app/api/usage/trend/route.ts +119 -0
  69. package/costcanary/app/api/ws/route.ts +23 -0
  70. package/costcanary/app/globals.css +280 -0
  71. package/costcanary/app/layout.tsx +87 -0
  72. package/costcanary/components/Header.tsx +85 -0
  73. package/costcanary/components/dashboard/AddApiKeyModal.tsx +264 -0
  74. package/costcanary/components/dashboard/dashboard-content.tsx +329 -0
  75. package/costcanary/components/landing/DashboardPreview.tsx +222 -0
  76. package/costcanary/components/landing/Features.tsx +238 -0
  77. package/costcanary/components/landing/Footer.tsx +83 -0
  78. package/costcanary/components/landing/Hero.tsx +193 -0
  79. package/costcanary/components/landing/Pricing.tsx +250 -0
  80. package/costcanary/components/landing/Testimonials.tsx +248 -0
  81. package/costcanary/components/theme-provider.tsx +8 -0
  82. package/costcanary/components/ui/alert.tsx +59 -0
  83. package/costcanary/components/ui/badge.tsx +36 -0
  84. package/costcanary/components/ui/button.tsx +56 -0
  85. package/costcanary/components/ui/card.tsx +79 -0
  86. package/costcanary/components/ui/dialog.tsx +122 -0
  87. package/costcanary/components/ui/input.tsx +22 -0
  88. package/costcanary/components/ui/label.tsx +26 -0
  89. package/costcanary/components/ui/progress.tsx +28 -0
  90. package/costcanary/components/ui/select.tsx +160 -0
  91. package/costcanary/components/ui/separator.tsx +31 -0
  92. package/costcanary/components/ui/switch.tsx +29 -0
  93. package/costcanary/components/ui/tabs.tsx +55 -0
  94. package/costcanary/components/ui/toast.tsx +127 -0
  95. package/costcanary/components/ui/toaster.tsx +35 -0
  96. package/costcanary/components/ui/use-toast.ts +189 -0
  97. package/costcanary/components.json +17 -0
  98. package/costcanary/debug-wrapped-keys.md +117 -0
  99. package/costcanary/fix-console.sh +30 -0
  100. package/costcanary/lib/admin-auth.ts +226 -0
  101. package/costcanary/lib/admin-security.ts +124 -0
  102. package/costcanary/lib/audit-events.ts +62 -0
  103. package/costcanary/lib/audit.ts +158 -0
  104. package/costcanary/lib/chart-colors.ts +152 -0
  105. package/costcanary/lib/cost-calculator.ts +212 -0
  106. package/costcanary/lib/db-utils.ts +325 -0
  107. package/costcanary/lib/db.ts +14 -0
  108. package/costcanary/lib/encryption.ts +120 -0
  109. package/costcanary/lib/kms.ts +358 -0
  110. package/costcanary/lib/model-alias.ts +180 -0
  111. package/costcanary/lib/pricing.ts +292 -0
  112. package/costcanary/lib/prisma.ts +52 -0
  113. package/costcanary/lib/railway-db.ts +157 -0
  114. package/costcanary/lib/sse-parser.ts +283 -0
  115. package/costcanary/lib/stripe-client.ts +81 -0
  116. package/costcanary/lib/stripe-server.ts +52 -0
  117. package/costcanary/lib/tokens.ts +396 -0
  118. package/costcanary/lib/usage-limits.ts +164 -0
  119. package/costcanary/lib/utils.ts +6 -0
  120. package/costcanary/lib/websocket.ts +153 -0
  121. package/costcanary/lib/wrapped-keys.ts +531 -0
  122. package/costcanary/market-research.md +443 -0
  123. package/costcanary/middleware.ts +48 -0
  124. package/costcanary/next.config.js +43 -0
  125. package/costcanary/nia-sources.md +151 -0
  126. package/costcanary/package-lock.json +12162 -0
  127. package/costcanary/package.json +92 -0
  128. package/costcanary/package.json.backup +89 -0
  129. package/costcanary/postcss.config.js +6 -0
  130. package/costcanary/pricing-worker/.env.example +8 -0
  131. package/costcanary/pricing-worker/README.md +81 -0
  132. package/costcanary/pricing-worker/package-lock.json +1109 -0
  133. package/costcanary/pricing-worker/package.json +26 -0
  134. package/costcanary/pricing-worker/railway.json +13 -0
  135. package/costcanary/pricing-worker/schema.prisma +326 -0
  136. package/costcanary/pricing-worker/src/index.ts +115 -0
  137. package/costcanary/pricing-worker/src/services/pricing-updater.ts +79 -0
  138. package/costcanary/pricing-worker/src/services/tavily-client.ts +474 -0
  139. package/costcanary/pricing-worker/test-tavily.ts +47 -0
  140. package/costcanary/pricing-worker/tsconfig.json +24 -0
  141. package/costcanary/prisma/migrations/001_add_stripe_fields.sql +26 -0
  142. package/costcanary/prisma/schema.prisma +326 -0
  143. package/costcanary/prisma/seed-pricing.ts +133 -0
  144. package/costcanary/public/costhawk-logo.png +0 -0
  145. package/costcanary/railway.json +30 -0
  146. package/costcanary/railway.toml +16 -0
  147. package/costcanary/research-nia.md +298 -0
  148. package/costcanary/research.md +411 -0
  149. package/costcanary/scripts/build-production.js +65 -0
  150. package/costcanary/scripts/check-current-pricing.ts +51 -0
  151. package/costcanary/scripts/check-pricing-data.ts +174 -0
  152. package/costcanary/scripts/create-stripe-prices.js +49 -0
  153. package/costcanary/scripts/fix-pricing-data.ts +135 -0
  154. package/costcanary/scripts/fix-pricing-db.ts +148 -0
  155. package/costcanary/scripts/postinstall.js +58 -0
  156. package/costcanary/scripts/railway-deploy.sh +52 -0
  157. package/costcanary/scripts/run-migration.js +61 -0
  158. package/costcanary/scripts/start-production.js +175 -0
  159. package/costcanary/scripts/test-wrapped-key.ts +85 -0
  160. package/costcanary/scripts/validate-deployment.js +176 -0
  161. package/costcanary/scripts/validate-production.js +119 -0
  162. package/costcanary/server.js.backup +38 -0
  163. package/costcanary/tailwind.config.ts +216 -0
  164. package/costcanary/test-pricing-status.sh +27 -0
  165. package/costcanary/tsconfig.json +42 -0
  166. package/docs/sessions/session-2025-12-01.md +570 -0
  167. package/executive-summary.md +302 -0
  168. package/index.js +1 -0
  169. package/nia-sources.md +163 -0
  170. package/package.json +16 -0
  171. package/research.md +750 -0
@@ -0,0 +1,529 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { auth } from '@clerk/nextjs/server';
3
+ import { prisma } from '@/lib/prisma';
4
+ import { checkUsageLimit, incrementUsage } from '@/lib/usage-limits';
5
+ import { logAuditEvent } from '@/lib/audit-events';
6
+ import { decrypt } from '@/lib/encryption';
7
+ import { resolveWrappedKey, validateKeyPolicy } from '@/lib/wrapped-keys';
8
+ import { headers } from 'next/headers';
9
+ import { Provider } from '@prisma/client';
10
+ import { resolveCanonicalModel } from '@/lib/model-alias';
11
+ import { getActivePrice, computeCost, getFallbackPrice } from '@/lib/pricing';
12
+ import { parseSSEStream } from '@/lib/sse-parser';
13
+ import { estimateOpenAITokens, estimateCompletionTokens } from '@/lib/tokens';
14
+
15
+ // Force Node.js runtime and disable static optimization
16
+ export const runtime = 'nodejs';
17
+ export const dynamic = 'force-dynamic';
18
+ export const revalidate = 0;
19
+
20
+ // CORS headers for public API access
21
+ function getCorsHeaders() {
22
+ return {
23
+ 'Access-Control-Allow-Origin': process.env.NEXT_PUBLIC_APP_ORIGIN || '*',
24
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
25
+ 'Access-Control-Allow-Headers': 'Content-Type,Authorization',
26
+ };
27
+ }
28
+
29
+ // Handle preflight requests
30
+ export async function OPTIONS() {
31
+ return new NextResponse(null, {
32
+ status: 204,
33
+ headers: getCorsHeaders()
34
+ });
35
+ }
36
+
37
+ // Simple GET for health checks
38
+ export async function GET() {
39
+ return NextResponse.json(
40
+ { ok: true, service: 'openai-proxy', timestamp: new Date().toISOString() },
41
+ { headers: getCorsHeaders() }
42
+ );
43
+ }
44
+
45
+ // This is the critical API proxy that enforces usage limits
46
+ export async function POST(req: NextRequest) {
47
+ const requestId = Math.random().toString(36).substring(7);
48
+
49
+ // Critical debug logging for production issues
50
+ // DEBUG: console.log(`[proxy-openai-${requestId}] Request received:`, {
51
+ // url: req.url,
52
+ // method: req.method,
53
+ // hasAuth: !!req.headers.get('authorization'),
54
+ // authPrefix: req.headers.get('authorization')?.substring(0, 15),
55
+ // });
56
+
57
+ try {
58
+ // Parse body once at the beginning
59
+ const body = await req.json();
60
+
61
+ // Check if this is a wrapped key request
62
+ const authHeader = req.headers.get('authorization');
63
+ let isWrappedKey = false;
64
+ let wrappedKeyId: string | null = null;
65
+ let resolvedKey: Awaited<ReturnType<typeof resolveWrappedKey>> = null;
66
+
67
+ if (authHeader?.startsWith('Bearer ch_sk_')) {
68
+ // This is a wrapped key
69
+ isWrappedKey = true;
70
+ wrappedKeyId = authHeader.substring(7); // Remove "Bearer "
71
+
72
+ // DEBUG: console.log(`[proxy-openai-${requestId}] Wrapped key detected:`, {
73
+ // wrappedKeyId: wrappedKeyId.substring(0, 20) + '...',
74
+ // fullKeyLength: wrappedKeyId.length,
75
+ // });
76
+
77
+ try {
78
+ // Resolve the wrapped key
79
+ resolvedKey = await resolveWrappedKey(wrappedKeyId);
80
+
81
+ if (!resolvedKey) {
82
+ // DEBUG: console.error(`[proxy-openai-${requestId}] Wrapped key not found:`, wrappedKeyId);
83
+ return NextResponse.json(
84
+ { error: 'Invalid or expired wrapped key' },
85
+ { status: 401 }
86
+ );
87
+ }
88
+
89
+ // DEBUG: console.log(`[proxy-openai-${requestId}] Wrapped key resolved:`, {
90
+ // userId: resolvedKey.userId,
91
+ // orgId: resolvedKey.orgId,
92
+ // provider: resolvedKey.provider,
93
+ // hasPolicy: !!resolvedKey.policy,
94
+ // });
95
+
96
+ } catch (keyError) {
97
+ // DEBUG: console.error(`[proxy-openai-${requestId}] Wrapped key resolution error:`, keyError);
98
+ return NextResponse.json(
99
+ { error: 'Failed to resolve wrapped key', details: keyError instanceof Error ? keyError.message : 'Unknown error' },
100
+ { status: 500 }
101
+ );
102
+ }
103
+
104
+ try {
105
+ // Validate policy
106
+ const headersList = await headers();
107
+ const ipAddress = headersList.get('x-forwarded-for')?.split(',')[0] ||
108
+ headersList.get('x-real-ip') || undefined;
109
+
110
+ const policyValidation = await validateKeyPolicy(resolvedKey.policy, {
111
+ model: body.model,
112
+ orgId: resolvedKey.orgId,
113
+ ipAddress
114
+ });
115
+
116
+ if (!policyValidation.allowed) {
117
+ return NextResponse.json(
118
+ { error: policyValidation.reason || 'Request blocked by policy' },
119
+ { status: 403 }
120
+ );
121
+ }
122
+
123
+ } catch (policyError) {
124
+ return NextResponse.json(
125
+ { error: 'Policy validation failed', details: policyError instanceof Error ? policyError.message : 'Unknown error' },
126
+ { status: 500 }
127
+ );
128
+ }
129
+ } else {
130
+ }
131
+
132
+ // 1. Authenticate user (for non-wrapped key requests)
133
+ let user;
134
+ if (isWrappedKey && resolvedKey) {
135
+ user = await prisma.user.findUnique({
136
+ where: { id: resolvedKey.userId }
137
+ });
138
+ } else {
139
+ const { userId: clerkId } = await auth();
140
+ if (!clerkId) {
141
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
142
+ }
143
+
144
+ user = await prisma.user.findUnique({
145
+ where: { clerkId }
146
+ });
147
+ }
148
+
149
+ if (!user) {
150
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
151
+ }
152
+
153
+ // 3. CHECK USAGE LIMITS (CRITICAL!)
154
+ const usageCheck = await checkUsageLimit(user.id);
155
+
156
+ if (!usageCheck.allowed) {
157
+ // Log the rejection
158
+ await logAuditEvent({
159
+ userId: user.id,
160
+ action: 'api_call_rejected',
161
+ details: {
162
+ reason: 'usage_limit_exceeded',
163
+ currentUsage: usageCheck.currentUsage,
164
+ limit: usageCheck.limit,
165
+ tier: usageCheck.tier,
166
+ },
167
+ });
168
+
169
+ // Return 402 Payment Required
170
+ return NextResponse.json(
171
+ {
172
+ error: 'Usage limit exceeded',
173
+ message: usageCheck.message,
174
+ upgradeUrl: usageCheck.upgradeUrl,
175
+ usage: {
176
+ current: usageCheck.currentUsage,
177
+ limit: usageCheck.limit,
178
+ tier: usageCheck.tier,
179
+ },
180
+ },
181
+ { status: 402 } // Payment Required
182
+ );
183
+ }
184
+
185
+ // 4. Get API key
186
+ let decryptedKey: string;
187
+ let apiKeyId: string | undefined;
188
+
189
+ if (isWrappedKey && resolvedKey) {
190
+ // Use the wrapped key's real API key
191
+ decryptedKey = resolvedKey.realKey;
192
+ // No apiKeyId for wrapped keys
193
+ } else {
194
+ // Use regular API key - need to fetch it
195
+ const apiKey = await prisma.apiKey.findFirst({
196
+ where: {
197
+ userId: user.id,
198
+ provider: 'OPENAI',
199
+ isActive: true
200
+ }
201
+ });
202
+
203
+ if (!apiKey) {
204
+ return NextResponse.json(
205
+ { error: 'No OpenAI API key configured. Please add one in settings.' },
206
+ { status: 400 }
207
+ );
208
+ }
209
+
210
+ apiKeyId = apiKey.id;
211
+ decryptedKey = decrypt(apiKey.encryptedKey);
212
+ }
213
+
214
+ // 5. Forward request to OpenAI
215
+ const startTime = Date.now();
216
+
217
+ const openaiResponse = await fetch('https://api.openai.com/v1/chat/completions', {
218
+ method: 'POST',
219
+ headers: {
220
+ 'Content-Type': 'application/json',
221
+ 'Authorization': `Bearer ${decryptedKey}`,
222
+ },
223
+ body: JSON.stringify(body),
224
+ // @ts-expect-error Node 18 streaming support
225
+ duplex: 'half',
226
+ });
227
+
228
+ const contentType = openaiResponse.headers.get('content-type') || '';
229
+ const latency = Date.now() - startTime;
230
+
231
+ // Check if this is a streaming response (SSE)
232
+ const isStreaming = contentType.toLowerCase().includes('text/event-stream');
233
+
234
+ if (isStreaming) {
235
+ // For streaming responses, use the new SSE parser
236
+ // DEBUG: console.log(`[proxy-openai-${requestId}] Streaming response detected, parsing with tee`);
237
+
238
+ if (openaiResponse.ok && openaiResponse.body) {
239
+ // Parse SSE stream to extract usage
240
+ const { clientStream, parsePromise } = await parseSSEStream(
241
+ openaiResponse.body,
242
+ 'OPENAI'
243
+ );
244
+
245
+ // Create response with client stream
246
+ const response = new Response(clientStream, {
247
+ status: openaiResponse.status,
248
+ headers: {
249
+ 'Content-Type': 'text/event-stream',
250
+ 'Cache-Control': 'no-cache, no-transform',
251
+ 'X-Accel-Buffering': 'no',
252
+ ...getCorsHeaders(),
253
+ },
254
+ });
255
+
256
+ // Process usage data asynchronously
257
+ parsePromise.then(async (parseResult) => {
258
+ await incrementUsage(user.id);
259
+
260
+ // Resolve canonical model
261
+ const canonicalModel = await resolveCanonicalModel(
262
+ Provider.OPENAI,
263
+ body.model,
264
+ parseResult.model
265
+ );
266
+
267
+ // Get pricing and compute cost
268
+ let cost = 0;
269
+ let costIn = 0;
270
+ let costOut = 0;
271
+ let estimated = false;
272
+
273
+ let pricingVersionId: string | undefined;
274
+
275
+ if (canonicalModel) {
276
+ const price = await getActivePrice(Provider.OPENAI, canonicalModel) ||
277
+ getFallbackPrice(Provider.OPENAI, canonicalModel);
278
+
279
+ if (price) {
280
+ pricingVersionId = price.versionId !== 'fallback' ? price.versionId : undefined;
281
+
282
+ if (parseResult.usage) {
283
+ const breakdown = computeCost(parseResult.usage, price);
284
+ cost = breakdown.total;
285
+ costIn = breakdown.costIn;
286
+ costOut = breakdown.costOut;
287
+ } else if (parseResult.completionText) {
288
+ // Estimate if no usage provided
289
+ const promptTokens = estimateOpenAITokens(body.model || 'gpt-4', body.messages || []).promptTokens;
290
+ const completionTokens = estimateCompletionTokens(parseResult.completionText);
291
+ const breakdown = computeCost(
292
+ { prompt: promptTokens, completion: completionTokens },
293
+ price
294
+ );
295
+ cost = breakdown.total;
296
+ costIn = breakdown.costIn;
297
+ costOut = breakdown.costOut;
298
+ estimated = true;
299
+ }
300
+ }
301
+ }
302
+
303
+ // Log usage
304
+ await prisma.usageLog.create({
305
+ data: {
306
+ userId: user.id,
307
+ apiKeyId: apiKeyId,
308
+ wrappedKeyId: isWrappedKey && resolvedKey ? resolvedKey.wrappedKey.id : undefined,
309
+ provider: Provider.OPENAI,
310
+ model: body.model || 'unknown',
311
+ responseModel: parseResult.model,
312
+ canonicalModel,
313
+ pricingVersionId,
314
+ endpoint: '/v1/chat/completions',
315
+ inputTokens: (typeof parseResult.usage?.prompt_tokens === 'number' ? parseResult.usage.prompt_tokens :
316
+ typeof parseResult.usage?.prompt === 'number' ? parseResult.usage.prompt : 0),
317
+ outputTokens: (typeof parseResult.usage?.completion_tokens === 'number' ? parseResult.usage.completion_tokens :
318
+ typeof parseResult.usage?.completion === 'number' ? parseResult.usage.completion : 0),
319
+ totalTokens: (typeof parseResult.usage?.total_tokens === 'number' ? parseResult.usage.total_tokens :
320
+ typeof parseResult.usage?.total === 'number' ? parseResult.usage.total : 0),
321
+ cost,
322
+ costInputUsd: costIn,
323
+ costOutputUsd: costOut,
324
+ latency,
325
+ statusCode: openaiResponse.status,
326
+ metadata: { streaming: true, estimated },
327
+ estimated,
328
+ },
329
+ });
330
+ }).catch(async error => {
331
+ // DEBUG: console.error(`[proxy-openai-${requestId}] SSE parse error:`, error);
332
+ // Still log usage but mark as failed parsing
333
+ try {
334
+ await incrementUsage(user.id);
335
+ await prisma.usageLog.create({
336
+ data: {
337
+ userId: user.id,
338
+ apiKeyId: apiKeyId,
339
+ wrappedKeyId: isWrappedKey && resolvedKey ? resolvedKey.wrappedKey.id : undefined,
340
+ provider: Provider.OPENAI,
341
+ model: body.model || 'unknown',
342
+ endpoint: '/v1/chat/completions',
343
+ inputTokens: 0,
344
+ outputTokens: 0,
345
+ totalTokens: 0,
346
+ cost: 0,
347
+ latency,
348
+ statusCode: openaiResponse.status,
349
+ metadata: { streaming: true, parseError: true },
350
+ error: error instanceof Error ? error.message : 'SSE parse failed',
351
+ },
352
+ });
353
+ } catch (logError) {
354
+ // DEBUG: console.error(`[proxy-openai-${requestId}] Failed to log parse error:`, logError);
355
+ }
356
+ });
357
+
358
+ return response;
359
+ } else {
360
+ // Non-OK response, pass through
361
+ return new Response(openaiResponse.body, {
362
+ status: openaiResponse.status,
363
+ headers: {
364
+ 'Content-Type': 'text/event-stream',
365
+ 'Cache-Control': 'no-cache, no-transform',
366
+ 'X-Accel-Buffering': 'no',
367
+ ...getCorsHeaders(),
368
+ },
369
+ });
370
+ }
371
+ }
372
+
373
+ // For non-streaming responses, parse JSON
374
+ let responseData;
375
+ try {
376
+ const responseText = await openaiResponse.text();
377
+ responseData = JSON.parse(responseText);
378
+ } catch (parseError) {
379
+ // DEBUG: console.error(`[proxy-openai-${requestId}] Failed to parse response:`, parseError);
380
+ // Return the raw response if we can't parse it
381
+ return new Response(openaiResponse.body, {
382
+ status: openaiResponse.status,
383
+ headers: {
384
+ ...Object.fromEntries(openaiResponse.headers.entries()),
385
+ ...getCorsHeaders(),
386
+ },
387
+ });
388
+ }
389
+
390
+ // Variables for cost tracking (declared outside for header access)
391
+ let cost = 0;
392
+ let costIn = 0;
393
+ let costOut = 0;
394
+ let estimated = false;
395
+
396
+ // 6. Track usage (AFTER successful call)
397
+ if (openaiResponse.ok) {
398
+ await incrementUsage(user.id);
399
+
400
+ // Extract model from response
401
+ const responseModel = responseData.model;
402
+
403
+ // Resolve canonical model
404
+ const canonicalModel = await resolveCanonicalModel(
405
+ Provider.OPENAI,
406
+ body.model,
407
+ responseModel
408
+ );
409
+
410
+ // Get pricing and compute cost
411
+ const usage = responseData.usage || {};
412
+ let pricingVersionId: string | undefined;
413
+
414
+ if (canonicalModel) {
415
+ const price = await getActivePrice(Provider.OPENAI, canonicalModel) ||
416
+ getFallbackPrice(Provider.OPENAI, canonicalModel);
417
+
418
+ if (price) {
419
+ pricingVersionId = price.versionId !== 'fallback' ? price.versionId : undefined;
420
+
421
+ if (usage.prompt_tokens || usage.completion_tokens) {
422
+ const breakdown = computeCost(usage, price);
423
+ cost = breakdown.total;
424
+ costIn = breakdown.costIn;
425
+ costOut = breakdown.costOut;
426
+ } else {
427
+ // Estimate tokens if not provided
428
+ const promptTokens = estimateOpenAITokens(body.model || 'gpt-4', body.messages || []).promptTokens;
429
+ const completionContent = responseData.choices?.[0]?.message?.content;
430
+ const completionTokens = completionContent ?
431
+ estimateCompletionTokens(completionContent) : 0;
432
+ const breakdown = computeCost(
433
+ { prompt: promptTokens, completion: completionTokens },
434
+ price
435
+ );
436
+ cost = breakdown.total;
437
+ costIn = breakdown.costIn;
438
+ costOut = breakdown.costOut;
439
+ estimated = true;
440
+ }
441
+ }
442
+ }
443
+
444
+ // Log usage details
445
+ await prisma.usageLog.create({
446
+ data: {
447
+ userId: user.id,
448
+ apiKeyId: apiKeyId,
449
+ wrappedKeyId: isWrappedKey && resolvedKey ? resolvedKey.wrappedKey.id : undefined,
450
+ provider: Provider.OPENAI,
451
+ model: body.model || 'unknown',
452
+ responseModel,
453
+ canonicalModel,
454
+ pricingVersionId,
455
+ endpoint: '/v1/chat/completions',
456
+ inputTokens: usage.prompt_tokens || 0,
457
+ outputTokens: usage.completion_tokens || 0,
458
+ totalTokens: usage.total_tokens || 0,
459
+ cost,
460
+ costInputUsd: costIn,
461
+ costOutputUsd: costOut,
462
+ latency,
463
+ statusCode: openaiResponse.status,
464
+ estimated,
465
+ },
466
+ });
467
+
468
+ // Update last used timestamp
469
+ if (apiKeyId) {
470
+ await prisma.apiKey.update({
471
+ where: { id: apiKeyId },
472
+ data: { lastUsed: new Date() },
473
+ });
474
+ }
475
+ }
476
+
477
+ // 7. Add usage warning headers if needed
478
+ const responseHeaders: Record<string, string> = {
479
+ ...getCorsHeaders(),
480
+ 'X-CH-Proxy': 'openai',
481
+ 'X-CH-Request-Id': requestId,
482
+ };
483
+
484
+ if (usageCheck.message) {
485
+ responseHeaders['X-Usage-Warning'] = usageCheck.message;
486
+ responseHeaders['X-Usage-Current'] = usageCheck.currentUsage.toString();
487
+ responseHeaders['X-Usage-Limit'] = usageCheck.limit?.toString() || 'unlimited';
488
+ }
489
+
490
+ // Add usage and cost information to headers
491
+ if (openaiResponse.ok && responseData.usage) {
492
+ const usage = responseData.usage;
493
+ responseHeaders['X-CH-Input-Tokens'] = (usage.prompt_tokens || 0).toString();
494
+ responseHeaders['X-CH-Output-Tokens'] = (usage.completion_tokens || 0).toString();
495
+ responseHeaders['X-CH-Total-Tokens'] = (usage.total_tokens || 0).toString();
496
+ // Add cost information if it was calculated above
497
+ if (cost > 0 || costIn > 0 || costOut > 0) {
498
+ responseHeaders['X-CH-Cost-USD'] = cost.toFixed(6);
499
+ responseHeaders['X-CH-Cost-Input-USD'] = costIn.toFixed(6);
500
+ responseHeaders['X-CH-Cost-Output-USD'] = costOut.toFixed(6);
501
+ if (estimated) {
502
+ responseHeaders['X-CH-Cost-Estimated'] = 'true';
503
+ }
504
+ }
505
+ }
506
+
507
+ return NextResponse.json(responseData, {
508
+ status: openaiResponse.status,
509
+ headers: responseHeaders
510
+ });
511
+ } catch (error) {
512
+ // DEBUG: console.error(`[proxy-${requestId}] Critical proxy error:`, error);
513
+
514
+ // Provide more detailed error information in development
515
+ const isDevelopment = process.env.NODE_ENV === 'development';
516
+
517
+ return NextResponse.json(
518
+ {
519
+ error: 'Internal proxy error',
520
+ requestId,
521
+ ...(isDevelopment && {
522
+ details: error instanceof Error ? error.message : String(error),
523
+ stack: error instanceof Error ? error.stack : undefined
524
+ })
525
+ },
526
+ { status: 500 }
527
+ );
528
+ }
529
+ }
@@ -0,0 +1,7 @@
1
+ export function GET() {
2
+ return new Response('Simple test works!', { status: 200 })
3
+ }
4
+
5
+ export function POST() {
6
+ return new Response('Simple POST works!', { status: 200 })
7
+ }