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,539 @@
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 { estimateAnthropicTokens, 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: 'anthropic-proxy', timestamp: new Date().toISOString() },
41
+ { headers: getCorsHeaders() }
42
+ );
43
+ }
44
+
45
+ // This is the Anthropic API proxy that handles wrapped keys and 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-anthropic-${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-anthropic-${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-anthropic-${requestId}] Wrapped key not found:`, wrappedKeyId);
83
+ return NextResponse.json(
84
+ { error: 'Invalid or expired wrapped key' },
85
+ { status: 401 }
86
+ );
87
+ }
88
+
89
+ // Verify it's an Anthropic key
90
+ if (resolvedKey.provider !== 'ANTHROPIC') {
91
+ return NextResponse.json(
92
+ { error: `This wrapped key is for ${resolvedKey.provider}, not Anthropic` },
93
+ { status: 400 }
94
+ );
95
+ }
96
+
97
+ // DEBUG: console.log(`[proxy-anthropic-${requestId}] Wrapped key resolved:`, {
98
+ // userId: resolvedKey.userId,
99
+ // orgId: resolvedKey.orgId,
100
+ // provider: resolvedKey.provider,
101
+ // hasPolicy: !!resolvedKey.policy,
102
+ // });
103
+
104
+ } catch (keyError) {
105
+ // DEBUG: console.error(`[proxy-anthropic-${requestId}] Wrapped key resolution error:`, keyError);
106
+ return NextResponse.json(
107
+ { error: 'Failed to resolve wrapped key', details: keyError instanceof Error ? keyError.message : 'Unknown error' },
108
+ { status: 500 }
109
+ );
110
+ }
111
+
112
+ try {
113
+ // Validate policy
114
+ const headersList = await headers();
115
+ const ipAddress = headersList.get('x-forwarded-for')?.split(',')[0] ||
116
+ headersList.get('x-real-ip') || undefined;
117
+
118
+ const policyValidation = await validateKeyPolicy(resolvedKey.policy, {
119
+ model: body.model,
120
+ orgId: resolvedKey.orgId,
121
+ ipAddress
122
+ });
123
+
124
+ if (!policyValidation.allowed) {
125
+ return NextResponse.json(
126
+ { error: policyValidation.reason || 'Request blocked by policy' },
127
+ { status: 403 }
128
+ );
129
+ }
130
+
131
+ } catch (policyError) {
132
+ return NextResponse.json(
133
+ { error: 'Policy validation failed', details: policyError instanceof Error ? policyError.message : 'Unknown error' },
134
+ { status: 500 }
135
+ );
136
+ }
137
+ }
138
+
139
+ // 1. Authenticate user (for non-wrapped key requests)
140
+ let user;
141
+ if (isWrappedKey && resolvedKey) {
142
+ user = await prisma.user.findUnique({
143
+ where: { id: resolvedKey.userId }
144
+ });
145
+ } else {
146
+ const { userId: clerkId } = await auth();
147
+ if (!clerkId) {
148
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
149
+ }
150
+
151
+ user = await prisma.user.findUnique({
152
+ where: { clerkId }
153
+ });
154
+ }
155
+
156
+ if (!user) {
157
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
158
+ }
159
+
160
+ // 3. CHECK USAGE LIMITS (CRITICAL!)
161
+ const usageCheck = await checkUsageLimit(user.id);
162
+
163
+ if (!usageCheck.allowed) {
164
+ // Log the rejection
165
+ await logAuditEvent({
166
+ userId: user.id,
167
+ action: 'api_call_rejected',
168
+ details: {
169
+ reason: 'usage_limit_exceeded',
170
+ currentUsage: usageCheck.currentUsage,
171
+ limit: usageCheck.limit,
172
+ tier: usageCheck.tier,
173
+ },
174
+ });
175
+
176
+ // Return 402 Payment Required
177
+ return NextResponse.json(
178
+ {
179
+ error: 'Usage limit exceeded',
180
+ message: usageCheck.message,
181
+ upgradeUrl: usageCheck.upgradeUrl,
182
+ usage: {
183
+ current: usageCheck.currentUsage,
184
+ limit: usageCheck.limit,
185
+ tier: usageCheck.tier,
186
+ },
187
+ },
188
+ { status: 402 } // Payment Required
189
+ );
190
+ }
191
+
192
+ // 4. Get API key
193
+ let decryptedKey: string;
194
+ let apiKeyId: string | undefined;
195
+
196
+ if (isWrappedKey && resolvedKey) {
197
+ // Use the wrapped key's real API key
198
+ decryptedKey = resolvedKey.realKey;
199
+ // No apiKeyId for wrapped keys
200
+ } else {
201
+ // Use regular API key - need to fetch it
202
+ const apiKey = await prisma.apiKey.findFirst({
203
+ where: {
204
+ userId: user.id,
205
+ provider: 'ANTHROPIC',
206
+ isActive: true
207
+ }
208
+ });
209
+
210
+ if (!apiKey) {
211
+ return NextResponse.json(
212
+ { error: 'No Anthropic API key configured. Please add one in settings.' },
213
+ { status: 400 }
214
+ );
215
+ }
216
+
217
+ apiKeyId = apiKey.id;
218
+ decryptedKey = decrypt(apiKey.encryptedKey);
219
+ }
220
+
221
+ // 5. Forward request to Anthropic
222
+ const startTime = Date.now();
223
+
224
+ const anthropicResponse = await fetch('https://api.anthropic.com/v1/messages', {
225
+ method: 'POST',
226
+ headers: {
227
+ 'Content-Type': 'application/json',
228
+ 'x-api-key': decryptedKey,
229
+ 'anthropic-version': '2023-06-01',
230
+ },
231
+ body: JSON.stringify(body),
232
+ // @ts-expect-error Node 18 streaming support
233
+ duplex: 'half',
234
+ });
235
+
236
+ const contentType = anthropicResponse.headers.get('content-type') || '';
237
+ const latency = Date.now() - startTime;
238
+
239
+ // Check if this is a streaming response (SSE)
240
+ const isStreaming = contentType.toLowerCase().includes('text/event-stream');
241
+
242
+ if (isStreaming) {
243
+ // For streaming responses, use the new SSE parser
244
+ // DEBUG: console.log(`[proxy-anthropic-${requestId}] Streaming response detected, parsing with tee`);
245
+
246
+ if (anthropicResponse.ok && anthropicResponse.body) {
247
+ // Parse SSE stream to extract usage
248
+ const { clientStream, parsePromise } = await parseSSEStream(
249
+ anthropicResponse.body,
250
+ 'ANTHROPIC'
251
+ );
252
+
253
+ // Create response with client stream
254
+ const response = new Response(clientStream, {
255
+ status: anthropicResponse.status,
256
+ headers: {
257
+ 'Content-Type': 'text/event-stream',
258
+ 'Cache-Control': 'no-cache, no-transform',
259
+ 'X-Accel-Buffering': 'no',
260
+ ...getCorsHeaders(),
261
+ },
262
+ });
263
+
264
+ // Process usage data asynchronously
265
+ parsePromise.then(async (parseResult) => {
266
+ await incrementUsage(user.id);
267
+
268
+ // Resolve canonical model
269
+ const canonicalModel = await resolveCanonicalModel(
270
+ Provider.ANTHROPIC,
271
+ body.model,
272
+ parseResult.model
273
+ );
274
+
275
+ // Get pricing and compute cost
276
+ let cost = 0;
277
+ let costIn = 0;
278
+ let costOut = 0;
279
+ let estimated = false;
280
+ let pricingVersionId: string | undefined;
281
+
282
+ if (canonicalModel) {
283
+ const price = await getActivePrice(Provider.ANTHROPIC, canonicalModel) ||
284
+ getFallbackPrice(Provider.ANTHROPIC, canonicalModel);
285
+
286
+ if (price) {
287
+ pricingVersionId = price.versionId !== 'fallback' ? price.versionId : undefined;
288
+
289
+ if (parseResult.usage) {
290
+ const breakdown = computeCost(parseResult.usage, price);
291
+ cost = breakdown.total;
292
+ costIn = breakdown.costIn;
293
+ costOut = breakdown.costOut;
294
+ } else if (parseResult.completionText) {
295
+ // Estimate if no usage provided
296
+ const inputTokens = estimateAnthropicTokens(body.messages || []).inputTokens;
297
+ const completionTokens = estimateCompletionTokens(parseResult.completionText);
298
+ const breakdown = computeCost(
299
+ { input: inputTokens, output: completionTokens },
300
+ price
301
+ );
302
+ cost = breakdown.total;
303
+ costIn = breakdown.costIn;
304
+ costOut = breakdown.costOut;
305
+ estimated = true;
306
+ }
307
+ }
308
+ }
309
+
310
+ // Log usage
311
+ await prisma.usageLog.create({
312
+ data: {
313
+ userId: user.id,
314
+ apiKeyId: apiKeyId,
315
+ wrappedKeyId: isWrappedKey && resolvedKey ? resolvedKey.wrappedKey.id : undefined,
316
+ provider: Provider.ANTHROPIC,
317
+ model: body.model || 'unknown',
318
+ responseModel: parseResult.model,
319
+ canonicalModel,
320
+ pricingVersionId,
321
+ endpoint: '/v1/messages',
322
+ inputTokens: (typeof parseResult.usage?.input_tokens === 'number' ? parseResult.usage.input_tokens :
323
+ typeof parseResult.usage?.input === 'number' ? parseResult.usage.input : 0),
324
+ outputTokens: (typeof parseResult.usage?.output_tokens === 'number' ? parseResult.usage.output_tokens :
325
+ typeof parseResult.usage?.output === 'number' ? parseResult.usage.output : 0),
326
+ totalTokens: ((typeof parseResult.usage?.input_tokens === 'number' ? parseResult.usage.input_tokens : 0) +
327
+ (typeof parseResult.usage?.output_tokens === 'number' ? parseResult.usage.output_tokens : 0)),
328
+ cost,
329
+ costInputUsd: costIn,
330
+ costOutputUsd: costOut,
331
+ latency,
332
+ statusCode: anthropicResponse.status,
333
+ metadata: { streaming: true, estimated },
334
+ estimated,
335
+ },
336
+ });
337
+ }).catch(async error => {
338
+ // DEBUG: console.error(`[proxy-anthropic-${requestId}] SSE parse error:`, error);
339
+ // Still log usage but mark as failed parsing
340
+ try {
341
+ await incrementUsage(user.id);
342
+ await prisma.usageLog.create({
343
+ data: {
344
+ userId: user.id,
345
+ apiKeyId: apiKeyId,
346
+ wrappedKeyId: isWrappedKey && resolvedKey ? resolvedKey.wrappedKey.id : undefined,
347
+ provider: Provider.ANTHROPIC,
348
+ model: body.model || 'unknown',
349
+ endpoint: '/v1/messages',
350
+ inputTokens: 0,
351
+ outputTokens: 0,
352
+ totalTokens: 0,
353
+ cost: 0,
354
+ latency,
355
+ statusCode: anthropicResponse.status,
356
+ metadata: { streaming: true, parseError: true },
357
+ error: error instanceof Error ? error.message : 'SSE parse failed',
358
+ },
359
+ });
360
+ } catch (logError) {
361
+ // DEBUG: console.error(`[proxy-anthropic-${requestId}] Failed to log parse error:`, logError);
362
+ }
363
+ });
364
+
365
+ return response;
366
+ } else {
367
+ // Non-OK response, pass through
368
+ return new Response(anthropicResponse.body, {
369
+ status: anthropicResponse.status,
370
+ headers: {
371
+ 'Content-Type': 'text/event-stream',
372
+ 'Cache-Control': 'no-cache, no-transform',
373
+ 'X-Accel-Buffering': 'no',
374
+ ...getCorsHeaders(),
375
+ },
376
+ });
377
+ }
378
+ }
379
+
380
+ // For non-streaming responses, parse JSON
381
+ let responseData;
382
+ try {
383
+ const responseText = await anthropicResponse.text();
384
+ responseData = JSON.parse(responseText);
385
+ } catch (parseError) {
386
+ // DEBUG: console.error(`[proxy-anthropic-${requestId}] Failed to parse response:`, parseError);
387
+ // Return the raw response if we can't parse it
388
+ return new Response(anthropicResponse.body, {
389
+ status: anthropicResponse.status,
390
+ headers: {
391
+ ...Object.fromEntries(anthropicResponse.headers.entries()),
392
+ ...getCorsHeaders(),
393
+ },
394
+ });
395
+ }
396
+
397
+ // Variables for cost tracking (declared outside for header access)
398
+ let cost = 0;
399
+ let costIn = 0;
400
+ let costOut = 0;
401
+ let estimated = false;
402
+
403
+ // 6. Track usage (AFTER successful call)
404
+ if (anthropicResponse.ok) {
405
+ await incrementUsage(user.id);
406
+
407
+ // Extract model from response
408
+ const responseModel = responseData.model;
409
+
410
+ // Resolve canonical model
411
+ const canonicalModel = await resolveCanonicalModel(
412
+ Provider.ANTHROPIC,
413
+ body.model,
414
+ responseModel
415
+ );
416
+
417
+ // Get pricing and compute cost
418
+ const usage = responseData.usage || {};
419
+ let pricingVersionId: string | undefined;
420
+
421
+ if (canonicalModel) {
422
+ const price = await getActivePrice(Provider.ANTHROPIC, canonicalModel) ||
423
+ getFallbackPrice(Provider.ANTHROPIC, canonicalModel);
424
+
425
+ if (price) {
426
+ pricingVersionId = price.versionId !== 'fallback' ? price.versionId : undefined;
427
+
428
+ if (usage.input_tokens || usage.output_tokens) {
429
+ const breakdown = computeCost({
430
+ input: usage.input_tokens,
431
+ output: usage.output_tokens
432
+ }, price);
433
+ cost = breakdown.total;
434
+ costIn = breakdown.costIn;
435
+ costOut = breakdown.costOut;
436
+ } else {
437
+ // Estimate tokens if not provided
438
+ const inputTokens = estimateAnthropicTokens(body.messages || []).inputTokens;
439
+ const completionText = responseData.content?.[0]?.text;
440
+ const completionTokens = completionText ?
441
+ estimateCompletionTokens(completionText) : 0;
442
+ const breakdown = computeCost(
443
+ { input: inputTokens, output: completionTokens },
444
+ price
445
+ );
446
+ cost = breakdown.total;
447
+ costIn = breakdown.costIn;
448
+ costOut = breakdown.costOut;
449
+ estimated = true;
450
+ }
451
+ }
452
+ }
453
+
454
+ // Log usage details
455
+ await prisma.usageLog.create({
456
+ data: {
457
+ userId: user.id,
458
+ apiKeyId: apiKeyId,
459
+ wrappedKeyId: isWrappedKey && resolvedKey ? resolvedKey.wrappedKey.id : undefined,
460
+ provider: Provider.ANTHROPIC,
461
+ model: body.model || 'unknown',
462
+ responseModel,
463
+ canonicalModel,
464
+ pricingVersionId,
465
+ endpoint: '/v1/messages',
466
+ inputTokens: usage.input_tokens || 0,
467
+ outputTokens: usage.output_tokens || 0,
468
+ totalTokens: (usage.input_tokens || 0) + (usage.output_tokens || 0),
469
+ cost,
470
+ costInputUsd: costIn,
471
+ costOutputUsd: costOut,
472
+ latency,
473
+ statusCode: anthropicResponse.status,
474
+ estimated,
475
+ },
476
+ });
477
+
478
+ // Update last used timestamp
479
+ if (apiKeyId) {
480
+ await prisma.apiKey.update({
481
+ where: { id: apiKeyId },
482
+ data: { lastUsed: new Date() },
483
+ });
484
+ }
485
+ }
486
+
487
+ // 7. Add usage warning headers if needed
488
+ const responseHeaders: Record<string, string> = {
489
+ ...getCorsHeaders(),
490
+ 'X-CH-Proxy': 'anthropic',
491
+ 'X-CH-Request-Id': requestId,
492
+ };
493
+
494
+ if (usageCheck.message) {
495
+ responseHeaders['X-Usage-Warning'] = usageCheck.message;
496
+ responseHeaders['X-Usage-Current'] = usageCheck.currentUsage.toString();
497
+ responseHeaders['X-Usage-Limit'] = usageCheck.limit?.toString() || 'unlimited';
498
+ }
499
+
500
+ // Add usage and cost information to headers
501
+ if (anthropicResponse.ok && responseData.usage) {
502
+ const usage = responseData.usage;
503
+ responseHeaders['X-CH-Input-Tokens'] = (usage.input_tokens || 0).toString();
504
+ responseHeaders['X-CH-Output-Tokens'] = (usage.output_tokens || 0).toString();
505
+ responseHeaders['X-CH-Total-Tokens'] = ((usage.input_tokens || 0) + (usage.output_tokens || 0)).toString();
506
+ // Add cost information if it was calculated above
507
+ if (cost > 0 || costIn > 0 || costOut > 0) {
508
+ responseHeaders['X-CH-Cost-USD'] = cost.toFixed(6);
509
+ responseHeaders['X-CH-Cost-Input-USD'] = costIn.toFixed(6);
510
+ responseHeaders['X-CH-Cost-Output-USD'] = costOut.toFixed(6);
511
+ if (estimated) {
512
+ responseHeaders['X-CH-Cost-Estimated'] = 'true';
513
+ }
514
+ }
515
+ }
516
+
517
+ return NextResponse.json(responseData, {
518
+ status: anthropicResponse.status,
519
+ headers: responseHeaders
520
+ });
521
+ } catch (error) {
522
+ // DEBUG: console.error(`[proxy-anthropic-${requestId}] Critical proxy error:`, error);
523
+
524
+ // Provide more detailed error information in development
525
+ const isDevelopment = process.env.NODE_ENV === 'development';
526
+
527
+ return NextResponse.json(
528
+ {
529
+ error: 'Internal proxy error',
530
+ requestId,
531
+ ...(isDevelopment && {
532
+ details: error instanceof Error ? error.message : String(error),
533
+ stack: error instanceof Error ? error.stack : undefined
534
+ })
535
+ },
536
+ { status: 500 }
537
+ );
538
+ }
539
+ }