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,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
|
+
}
|