@jonsoc/console-app 1.1.34
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/.opencode/agent/css.md +149 -0
- package/README.md +32 -0
- package/package.json +49 -0
- package/public/apple-touch-icon-v3.png +1 -0
- package/public/apple-touch-icon.png +1 -0
- package/public/email +1 -0
- package/public/favicon-96x96-v3.png +1 -0
- package/public/favicon-96x96.png +1 -0
- package/public/favicon-v3.ico +1 -0
- package/public/favicon-v3.svg +1 -0
- package/public/favicon.ico +1 -0
- package/public/favicon.svg +1 -0
- package/public/opencode-brand-assets.zip +0 -0
- package/public/robots.txt +6 -0
- package/public/site.webmanifest +1 -0
- package/public/social-share-black.png +1 -0
- package/public/social-share-zen.png +1 -0
- package/public/social-share.png +1 -0
- package/public/theme.json +182 -0
- package/public/web-app-manifest-192x192.png +1 -0
- package/public/web-app-manifest-512x512.png +1 -0
- package/script/generate-sitemap.ts +103 -0
- package/src/app.css +1 -0
- package/src/app.tsx +27 -0
- package/src/asset/black/hero.png +0 -0
- package/src/asset/brand/opencode-brand-assets.zip +0 -0
- package/src/asset/brand/opencode-logo-dark.png +0 -0
- package/src/asset/brand/opencode-logo-dark.svg +16 -0
- package/src/asset/brand/opencode-logo-light.png +0 -0
- package/src/asset/brand/opencode-logo-light.svg +16 -0
- package/src/asset/brand/opencode-wordmark-dark.png +0 -0
- package/src/asset/brand/opencode-wordmark-dark.svg +30 -0
- package/src/asset/brand/opencode-wordmark-light.png +0 -0
- package/src/asset/brand/opencode-wordmark-light.svg +30 -0
- package/src/asset/brand/opencode-wordmark-simple-dark.png +0 -0
- package/src/asset/brand/opencode-wordmark-simple-dark.svg +22 -0
- package/src/asset/brand/opencode-wordmark-simple-light.png +0 -0
- package/src/asset/brand/opencode-wordmark-simple-light.svg +22 -0
- package/src/asset/brand/preview-opencode-dark.png +0 -0
- package/src/asset/brand/preview-opencode-logo-dark.png +0 -0
- package/src/asset/brand/preview-opencode-logo-light.png +0 -0
- package/src/asset/brand/preview-opencode-wordmark-dark.png +0 -0
- package/src/asset/brand/preview-opencode-wordmark-light.png +0 -0
- package/src/asset/brand/preview-opencode-wordmark-simple-dark.png +0 -0
- package/src/asset/brand/preview-opencode-wordmark-simple-light.png +0 -0
- package/src/asset/lander/avatar-adam.png +0 -0
- package/src/asset/lander/avatar-david.png +0 -0
- package/src/asset/lander/avatar-dax.png +0 -0
- package/src/asset/lander/avatar-frank.png +0 -0
- package/src/asset/lander/avatar-jay.png +0 -0
- package/src/asset/lander/brand-assets-dark.svg +10 -0
- package/src/asset/lander/brand-assets-light.svg +10 -0
- package/src/asset/lander/brand.png +0 -0
- package/src/asset/lander/check.svg +3 -0
- package/src/asset/lander/copy.svg +3 -0
- package/src/asset/lander/desktop-app-icon.png +0 -0
- package/src/asset/lander/dock.png +0 -0
- package/src/asset/lander/logo-dark.svg +11 -0
- package/src/asset/lander/logo-light.svg +11 -0
- package/src/asset/lander/opencode-comparison-min.mp4 +0 -0
- package/src/asset/lander/opencode-comparison-poster.png +0 -0
- package/src/asset/lander/opencode-desktop-icon.png +0 -0
- package/src/asset/lander/opencode-logo-dark.svg +11 -0
- package/src/asset/lander/opencode-logo-light.svg +11 -0
- package/src/asset/lander/opencode-min.mp4 +0 -0
- package/src/asset/lander/opencode-poster.png +0 -0
- package/src/asset/lander/opencode-wordmark-dark.svg +25 -0
- package/src/asset/lander/opencode-wordmark-light.svg +25 -0
- package/src/asset/lander/screenshot-github.png +0 -0
- package/src/asset/lander/screenshot-splash.png +0 -0
- package/src/asset/lander/screenshot-vscode.png +0 -0
- package/src/asset/lander/screenshot.png +0 -0
- package/src/asset/lander/wordmark-dark.svg +3 -0
- package/src/asset/lander/wordmark-light.svg +3 -0
- package/src/asset/logo-ornate-dark.svg +18 -0
- package/src/asset/logo-ornate-light.svg +18 -0
- package/src/asset/logo.svg +18 -0
- package/src/asset/zen-ornate-dark.svg +8 -0
- package/src/asset/zen-ornate-light.svg +8 -0
- package/src/component/dropdown.css +80 -0
- package/src/component/dropdown.tsx +79 -0
- package/src/component/email-signup.tsx +48 -0
- package/src/component/faq.tsx +33 -0
- package/src/component/footer.tsx +38 -0
- package/src/component/header-context-menu.css +63 -0
- package/src/component/header.tsx +279 -0
- package/src/component/icon.tsx +257 -0
- package/src/component/legal.tsx +20 -0
- package/src/component/modal.css +66 -0
- package/src/component/modal.tsx +24 -0
- package/src/component/spotlight.css +15 -0
- package/src/component/spotlight.tsx +820 -0
- package/src/config.ts +29 -0
- package/src/context/auth.session.ts +0 -0
- package/src/context/auth.ts +116 -0
- package/src/context/auth.withActor.ts +7 -0
- package/src/entry-client.tsx +4 -0
- package/src/entry-server.tsx +30 -0
- package/src/global.d.ts +5 -0
- package/src/lib/github.ts +38 -0
- package/src/middleware.ts +5 -0
- package/src/routes/[...404].css +130 -0
- package/src/routes/[...404].tsx +38 -0
- package/src/routes/api/enterprise.ts +47 -0
- package/src/routes/auth/[...callback].ts +41 -0
- package/src/routes/auth/authorize.ts +10 -0
- package/src/routes/auth/index.ts +12 -0
- package/src/routes/auth/logout.ts +17 -0
- package/src/routes/auth/status.ts +7 -0
- package/src/routes/bench/[id].tsx +365 -0
- package/src/routes/bench/index.tsx +86 -0
- package/src/routes/bench/submission.ts +29 -0
- package/src/routes/black/common.tsx +62 -0
- package/src/routes/black/index.tsx +108 -0
- package/src/routes/black/subscribe/[plan].tsx +449 -0
- package/src/routes/black/workspace.css +214 -0
- package/src/routes/black/workspace.tsx +229 -0
- package/src/routes/black.css +828 -0
- package/src/routes/black.tsx +285 -0
- package/src/routes/brand/index.css +555 -0
- package/src/routes/brand/index.tsx +252 -0
- package/src/routes/changelog/index.css +477 -0
- package/src/routes/changelog/index.tsx +147 -0
- package/src/routes/debug/index.ts +13 -0
- package/src/routes/desktop-feedback.ts +5 -0
- package/src/routes/discord.ts +5 -0
- package/src/routes/docs/[...path].ts +20 -0
- package/src/routes/docs/index.ts +20 -0
- package/src/routes/download/[platform].ts +38 -0
- package/src/routes/download/index.css +750 -0
- package/src/routes/download/index.tsx +482 -0
- package/src/routes/download/types.ts +4 -0
- package/src/routes/enterprise/index.css +578 -0
- package/src/routes/enterprise/index.tsx +251 -0
- package/src/routes/index.css +1251 -0
- package/src/routes/index.tsx +840 -0
- package/src/routes/legal/privacy-policy/index.css +343 -0
- package/src/routes/legal/privacy-policy/index.tsx +1512 -0
- package/src/routes/legal/terms-of-service/index.css +254 -0
- package/src/routes/legal/terms-of-service/index.tsx +512 -0
- package/src/routes/openapi.json.ts +7 -0
- package/src/routes/s/[id].ts +20 -0
- package/src/routes/stripe/webhook.ts +532 -0
- package/src/routes/t/[...path].tsx +20 -0
- package/src/routes/temp.tsx +172 -0
- package/src/routes/user-menu.css +18 -0
- package/src/routes/user-menu.tsx +32 -0
- package/src/routes/workspace/[id]/billing/billing-section.module.css +185 -0
- package/src/routes/workspace/[id]/billing/billing-section.tsx +240 -0
- package/src/routes/workspace/[id]/billing/black-section.module.css +142 -0
- package/src/routes/workspace/[id]/billing/black-section.tsx +269 -0
- package/src/routes/workspace/[id]/billing/black-waitlist-section.module.css +23 -0
- package/src/routes/workspace/[id]/billing/index.tsx +32 -0
- package/src/routes/workspace/[id]/billing/monthly-limit-section.module.css +96 -0
- package/src/routes/workspace/[id]/billing/monthly-limit-section.tsx +133 -0
- package/src/routes/workspace/[id]/billing/payment-section.module.css +93 -0
- package/src/routes/workspace/[id]/billing/payment-section.tsx +122 -0
- package/src/routes/workspace/[id]/billing/reload-section.module.css +261 -0
- package/src/routes/workspace/[id]/billing/reload-section.tsx +213 -0
- package/src/routes/workspace/[id]/graph-section.module.css +145 -0
- package/src/routes/workspace/[id]/graph-section.tsx +475 -0
- package/src/routes/workspace/[id]/index.tsx +81 -0
- package/src/routes/workspace/[id]/keys/index.tsx +11 -0
- package/src/routes/workspace/[id]/keys/key-section.module.css +197 -0
- package/src/routes/workspace/[id]/keys/key-section.tsx +176 -0
- package/src/routes/workspace/[id]/members/index.tsx +11 -0
- package/src/routes/workspace/[id]/members/member-section.module.css +249 -0
- package/src/routes/workspace/[id]/members/member-section.tsx +343 -0
- package/src/routes/workspace/[id]/members/role-dropdown.css +72 -0
- package/src/routes/workspace/[id]/members/role-dropdown.tsx +43 -0
- package/src/routes/workspace/[id]/model-section.module.css +173 -0
- package/src/routes/workspace/[id]/model-section.tsx +174 -0
- package/src/routes/workspace/[id]/new-user-section.module.css +143 -0
- package/src/routes/workspace/[id]/new-user-section.tsx +104 -0
- package/src/routes/workspace/[id]/provider-section.module.css +138 -0
- package/src/routes/workspace/[id]/provider-section.tsx +188 -0
- package/src/routes/workspace/[id]/settings/index.tsx +11 -0
- package/src/routes/workspace/[id]/settings/settings-section.module.css +94 -0
- package/src/routes/workspace/[id]/settings/settings-section.tsx +122 -0
- package/src/routes/workspace/[id]/usage-section.module.css +185 -0
- package/src/routes/workspace/[id]/usage-section.tsx +200 -0
- package/src/routes/workspace/[id].css +308 -0
- package/src/routes/workspace/[id].tsx +62 -0
- package/src/routes/workspace/common.tsx +120 -0
- package/src/routes/workspace-picker.css +74 -0
- package/src/routes/workspace-picker.tsx +122 -0
- package/src/routes/workspace.css +107 -0
- package/src/routes/workspace.tsx +38 -0
- package/src/routes/zen/index.css +866 -0
- package/src/routes/zen/index.tsx +343 -0
- package/src/routes/zen/util/dataDumper.ts +44 -0
- package/src/routes/zen/util/error.ts +13 -0
- package/src/routes/zen/util/handler.ts +784 -0
- package/src/routes/zen/util/logger.ts +12 -0
- package/src/routes/zen/util/provider/anthropic.ts +752 -0
- package/src/routes/zen/util/provider/google.ts +75 -0
- package/src/routes/zen/util/provider/openai-compatible.ts +546 -0
- package/src/routes/zen/util/provider/openai.ts +630 -0
- package/src/routes/zen/util/provider/provider.ts +210 -0
- package/src/routes/zen/util/rateLimiter.ts +41 -0
- package/src/routes/zen/util/stickyProviderTracker.ts +16 -0
- package/src/routes/zen/util/trialLimiter.ts +49 -0
- package/src/routes/zen/v1/chat/completions.ts +11 -0
- package/src/routes/zen/v1/messages.ts +11 -0
- package/src/routes/zen/v1/models/[model].ts +13 -0
- package/src/routes/zen/v1/models.ts +60 -0
- package/src/routes/zen/v1/responses.ts +11 -0
- package/src/style/base.css +21 -0
- package/src/style/component/button.css +102 -0
- package/src/style/index.css +8 -0
- package/src/style/reset.css +76 -0
- package/src/style/token/color.css +91 -0
- package/src/style/token/font.css +21 -0
- package/src/style/token/space.css +46 -0
- package/sst-env.d.ts +9 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +25 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import type { APIEvent } from "@solidjs/start/server"
|
|
2
|
+
import { and, Database, eq, isNull, lt, or, sql } from "@jonsoc/console-core/drizzle/index.js"
|
|
3
|
+
import { KeyTable } from "@jonsoc/console-core/schema/key.sql.js"
|
|
4
|
+
import { BillingTable, SubscriptionTable, UsageTable } from "@jonsoc/console-core/schema/billing.sql.js"
|
|
5
|
+
import { centsToMicroCents } from "@jonsoc/console-core/util/price.js"
|
|
6
|
+
import { getWeekBounds } from "@jonsoc/console-core/util/date.js"
|
|
7
|
+
import { Identifier } from "@jonsoc/console-core/identifier.js"
|
|
8
|
+
import { Billing } from "@jonsoc/console-core/billing.js"
|
|
9
|
+
import { Actor } from "@jonsoc/console-core/actor.js"
|
|
10
|
+
import { WorkspaceTable } from "@jonsoc/console-core/schema/workspace.sql.js"
|
|
11
|
+
import { ZenData } from "@jonsoc/console-core/model.js"
|
|
12
|
+
import { Black, BlackData } from "@jonsoc/console-core/black.js"
|
|
13
|
+
import { UserTable } from "@jonsoc/console-core/schema/user.sql.js"
|
|
14
|
+
import { ModelTable } from "@jonsoc/console-core/schema/model.sql.js"
|
|
15
|
+
import { ProviderTable } from "@jonsoc/console-core/schema/provider.sql.js"
|
|
16
|
+
import { logger } from "./logger"
|
|
17
|
+
import {
|
|
18
|
+
AuthError,
|
|
19
|
+
CreditsError,
|
|
20
|
+
MonthlyLimitError,
|
|
21
|
+
SubscriptionError,
|
|
22
|
+
UserLimitError,
|
|
23
|
+
ModelError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
} from "./error"
|
|
26
|
+
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
|
27
|
+
import { anthropicHelper } from "./provider/anthropic"
|
|
28
|
+
import { googleHelper } from "./provider/google"
|
|
29
|
+
import { openaiHelper } from "./provider/openai"
|
|
30
|
+
import { oaCompatHelper } from "./provider/openai-compatible"
|
|
31
|
+
import { createRateLimiter } from "./rateLimiter"
|
|
32
|
+
import { createDataDumper } from "./dataDumper"
|
|
33
|
+
import { createTrialLimiter } from "./trialLimiter"
|
|
34
|
+
import { createStickyTracker } from "./stickyProviderTracker"
|
|
35
|
+
|
|
36
|
+
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
|
37
|
+
type RetryOptions = {
|
|
38
|
+
excludeProviders: string[]
|
|
39
|
+
retryCount: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function handler(
|
|
43
|
+
input: APIEvent,
|
|
44
|
+
opts: {
|
|
45
|
+
format: ZenData.Format
|
|
46
|
+
parseApiKey: (headers: Headers) => string | undefined
|
|
47
|
+
parseModel: (url: string, body: any) => string
|
|
48
|
+
parseIsStream: (url: string, body: any) => boolean
|
|
49
|
+
},
|
|
50
|
+
) {
|
|
51
|
+
type AuthInfo = Awaited<ReturnType<typeof authenticate>>
|
|
52
|
+
type ModelInfo = Awaited<ReturnType<typeof validateModel>>
|
|
53
|
+
type ProviderInfo = Awaited<ReturnType<typeof selectProvider>>
|
|
54
|
+
|
|
55
|
+
const MAX_RETRIES = 3
|
|
56
|
+
const FREE_WORKSPACES = [
|
|
57
|
+
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
|
58
|
+
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // jonsoc bench
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const url = input.request.url
|
|
63
|
+
const body = await input.request.json()
|
|
64
|
+
const model = opts.parseModel(url, body)
|
|
65
|
+
const isStream = opts.parseIsStream(url, body)
|
|
66
|
+
const ip = input.request.headers.get("x-real-ip") ?? ""
|
|
67
|
+
const sessionId = input.request.headers.get("x-jonsoc-session") ?? ""
|
|
68
|
+
const requestId = input.request.headers.get("x-jonsoc-request") ?? ""
|
|
69
|
+
const projectId = input.request.headers.get("x-jonsoc-project") ?? ""
|
|
70
|
+
const ocClient = input.request.headers.get("x-jonsoc-client") ?? ""
|
|
71
|
+
logger.metric({
|
|
72
|
+
is_tream: isStream,
|
|
73
|
+
session: sessionId,
|
|
74
|
+
request: requestId,
|
|
75
|
+
client: ocClient,
|
|
76
|
+
})
|
|
77
|
+
const zenData = ZenData.list()
|
|
78
|
+
const modelInfo = validateModel(zenData, model)
|
|
79
|
+
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
|
80
|
+
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
|
|
81
|
+
const isTrial = await trialLimiter?.isTrial()
|
|
82
|
+
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip)
|
|
83
|
+
await rateLimiter?.check()
|
|
84
|
+
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
|
85
|
+
const stickyProvider = await stickyTracker?.get()
|
|
86
|
+
const authInfo = await authenticate(modelInfo)
|
|
87
|
+
const billingSource = validateBilling(authInfo, modelInfo)
|
|
88
|
+
|
|
89
|
+
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
|
90
|
+
const providerInfo = selectProvider(
|
|
91
|
+
model,
|
|
92
|
+
zenData,
|
|
93
|
+
authInfo,
|
|
94
|
+
modelInfo,
|
|
95
|
+
sessionId,
|
|
96
|
+
isTrial ?? false,
|
|
97
|
+
retry,
|
|
98
|
+
stickyProvider,
|
|
99
|
+
)
|
|
100
|
+
validateModelSettings(authInfo)
|
|
101
|
+
updateProviderKey(authInfo, providerInfo)
|
|
102
|
+
logger.metric({ provider: providerInfo.id })
|
|
103
|
+
|
|
104
|
+
const startTimestamp = Date.now()
|
|
105
|
+
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
|
|
106
|
+
const reqBody = JSON.stringify(
|
|
107
|
+
providerInfo.modifyBody({
|
|
108
|
+
...createBodyConverter(opts.format, providerInfo.format)(body),
|
|
109
|
+
model: providerInfo.model,
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
logger.debug("REQUEST URL: " + reqUrl)
|
|
113
|
+
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
|
|
114
|
+
const res = await fetch(reqUrl, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: (() => {
|
|
117
|
+
const headers = new Headers(input.request.headers)
|
|
118
|
+
providerInfo.modifyHeaders(headers, body, providerInfo.apiKey)
|
|
119
|
+
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
|
120
|
+
headers.set(k, headers.get(v)!)
|
|
121
|
+
})
|
|
122
|
+
headers.delete("host")
|
|
123
|
+
headers.delete("content-length")
|
|
124
|
+
headers.delete("x-jonsoc-request")
|
|
125
|
+
headers.delete("x-jonsoc-session")
|
|
126
|
+
headers.delete("x-jonsoc-project")
|
|
127
|
+
headers.delete("x-jonsoc-client")
|
|
128
|
+
return headers
|
|
129
|
+
})(),
|
|
130
|
+
body: reqBody,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Try another provider => stop retrying if using fallback provider
|
|
134
|
+
if (
|
|
135
|
+
res.status !== 200 &&
|
|
136
|
+
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
|
|
137
|
+
res.status !== 404 &&
|
|
138
|
+
// ie. cannot change codex model providers mid-session
|
|
139
|
+
modelInfo.stickyProvider !== "strict" &&
|
|
140
|
+
modelInfo.fallbackProvider &&
|
|
141
|
+
providerInfo.id !== modelInfo.fallbackProvider
|
|
142
|
+
) {
|
|
143
|
+
return retriableRequest({
|
|
144
|
+
excludeProviders: [...retry.excludeProviders, providerInfo.id],
|
|
145
|
+
retryCount: retry.retryCount + 1,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { providerInfo, reqBody, res, startTimestamp }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const { providerInfo, reqBody, res, startTimestamp } = await retriableRequest()
|
|
153
|
+
|
|
154
|
+
// Store model request
|
|
155
|
+
dataDumper?.provideModel(providerInfo.storeModel)
|
|
156
|
+
dataDumper?.provideRequest(reqBody)
|
|
157
|
+
|
|
158
|
+
// Store sticky provider
|
|
159
|
+
await stickyTracker?.set(providerInfo.id)
|
|
160
|
+
|
|
161
|
+
// Temporarily change 404 to 400 status code b/c solid start automatically override 404 response
|
|
162
|
+
const resStatus = res.status === 404 ? 400 : res.status
|
|
163
|
+
|
|
164
|
+
// Scrub response headers
|
|
165
|
+
const resHeaders = new Headers()
|
|
166
|
+
const keepHeaders = ["content-type", "cache-control"]
|
|
167
|
+
for (const [k, v] of res.headers.entries()) {
|
|
168
|
+
if (keepHeaders.includes(k.toLowerCase())) {
|
|
169
|
+
resHeaders.set(k, v)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
logger.debug("STATUS: " + res.status + " " + res.statusText)
|
|
173
|
+
|
|
174
|
+
// Handle non-streaming response
|
|
175
|
+
if (!isStream) {
|
|
176
|
+
const responseConverter = createResponseConverter(providerInfo.format, opts.format)
|
|
177
|
+
const json = await res.json()
|
|
178
|
+
const body = JSON.stringify(responseConverter(json))
|
|
179
|
+
logger.metric({ response_length: body.length })
|
|
180
|
+
logger.debug("RESPONSE: " + body)
|
|
181
|
+
dataDumper?.provideResponse(body)
|
|
182
|
+
dataDumper?.flush()
|
|
183
|
+
const tokensInfo = providerInfo.normalizeUsage(json.usage)
|
|
184
|
+
await trialLimiter?.track(tokensInfo)
|
|
185
|
+
await rateLimiter?.track()
|
|
186
|
+
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, billingSource, tokensInfo)
|
|
187
|
+
await reload(authInfo, costInfo)
|
|
188
|
+
return new Response(body, {
|
|
189
|
+
status: resStatus,
|
|
190
|
+
statusText: res.statusText,
|
|
191
|
+
headers: resHeaders,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Handle streaming response
|
|
196
|
+
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
|
|
197
|
+
const usageParser = providerInfo.createUsageParser()
|
|
198
|
+
const binaryDecoder = providerInfo.createBinaryStreamDecoder()
|
|
199
|
+
const stream = new ReadableStream({
|
|
200
|
+
start(c) {
|
|
201
|
+
const reader = res.body?.getReader()
|
|
202
|
+
const decoder = new TextDecoder()
|
|
203
|
+
const encoder = new TextEncoder()
|
|
204
|
+
|
|
205
|
+
let buffer = ""
|
|
206
|
+
let responseLength = 0
|
|
207
|
+
|
|
208
|
+
function pump(): Promise<void> {
|
|
209
|
+
return (
|
|
210
|
+
reader?.read().then(async ({ done, value: rawValue }) => {
|
|
211
|
+
if (done) {
|
|
212
|
+
logger.metric({
|
|
213
|
+
response_length: responseLength,
|
|
214
|
+
"timestamp.last_byte": Date.now(),
|
|
215
|
+
})
|
|
216
|
+
dataDumper?.flush()
|
|
217
|
+
await rateLimiter?.track()
|
|
218
|
+
const usage = usageParser.retrieve()
|
|
219
|
+
if (usage) {
|
|
220
|
+
const tokensInfo = providerInfo.normalizeUsage(usage)
|
|
221
|
+
await trialLimiter?.track(tokensInfo)
|
|
222
|
+
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, billingSource, tokensInfo)
|
|
223
|
+
await reload(authInfo, costInfo)
|
|
224
|
+
}
|
|
225
|
+
c.close()
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (responseLength === 0) {
|
|
230
|
+
const now = Date.now()
|
|
231
|
+
logger.metric({
|
|
232
|
+
time_to_first_byte: now - startTimestamp,
|
|
233
|
+
"timestamp.first_byte": now,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const value = binaryDecoder ? binaryDecoder(rawValue) : rawValue
|
|
238
|
+
if (!value) return
|
|
239
|
+
|
|
240
|
+
responseLength += value.length
|
|
241
|
+
buffer += decoder.decode(value, { stream: true })
|
|
242
|
+
dataDumper?.provideStream(buffer)
|
|
243
|
+
|
|
244
|
+
const parts = buffer.split(providerInfo.streamSeparator)
|
|
245
|
+
buffer = parts.pop() ?? ""
|
|
246
|
+
|
|
247
|
+
for (let part of parts) {
|
|
248
|
+
logger.debug("PART: " + part)
|
|
249
|
+
|
|
250
|
+
part = part.trim()
|
|
251
|
+
usageParser.parse(part)
|
|
252
|
+
|
|
253
|
+
if (providerInfo.format !== opts.format) {
|
|
254
|
+
part = streamConverter(part)
|
|
255
|
+
c.enqueue(encoder.encode(part + "\n\n"))
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (providerInfo.format === opts.format) {
|
|
260
|
+
c.enqueue(value)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return pump()
|
|
264
|
+
}) || Promise.resolve()
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return pump()
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
return new Response(stream, {
|
|
273
|
+
status: resStatus,
|
|
274
|
+
statusText: res.statusText,
|
|
275
|
+
headers: resHeaders,
|
|
276
|
+
})
|
|
277
|
+
} catch (error: any) {
|
|
278
|
+
logger.metric({
|
|
279
|
+
"error.type": error.constructor.name,
|
|
280
|
+
"error.message": error.message,
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
|
284
|
+
if (
|
|
285
|
+
error instanceof AuthError ||
|
|
286
|
+
error instanceof CreditsError ||
|
|
287
|
+
error instanceof MonthlyLimitError ||
|
|
288
|
+
error instanceof UserLimitError ||
|
|
289
|
+
error instanceof ModelError
|
|
290
|
+
)
|
|
291
|
+
return new Response(
|
|
292
|
+
JSON.stringify({
|
|
293
|
+
type: "error",
|
|
294
|
+
error: { type: error.constructor.name, message: error.message },
|
|
295
|
+
}),
|
|
296
|
+
{ status: 401 },
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
if (error instanceof RateLimitError || error instanceof SubscriptionError) {
|
|
300
|
+
const headers = new Headers()
|
|
301
|
+
if (error instanceof SubscriptionError && error.retryAfter) {
|
|
302
|
+
headers.set("retry-after", String(error.retryAfter))
|
|
303
|
+
}
|
|
304
|
+
return new Response(
|
|
305
|
+
JSON.stringify({
|
|
306
|
+
type: "error",
|
|
307
|
+
error: { type: error.constructor.name, message: error.message },
|
|
308
|
+
}),
|
|
309
|
+
{ status: 429, headers },
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return new Response(
|
|
314
|
+
JSON.stringify({
|
|
315
|
+
type: "error",
|
|
316
|
+
error: {
|
|
317
|
+
type: "error",
|
|
318
|
+
message: error.message,
|
|
319
|
+
},
|
|
320
|
+
}),
|
|
321
|
+
{ status: 500 },
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function validateModel(zenData: ZenData, reqModel: string) {
|
|
326
|
+
if (!(reqModel in zenData.models)) throw new ModelError(`Model ${reqModel} not supported`)
|
|
327
|
+
|
|
328
|
+
const modelId = reqModel as keyof typeof zenData.models
|
|
329
|
+
const modelData = Array.isArray(zenData.models[modelId])
|
|
330
|
+
? zenData.models[modelId].find((model) => opts.format === model.formatFilter)
|
|
331
|
+
: zenData.models[modelId]
|
|
332
|
+
|
|
333
|
+
if (!modelData) throw new ModelError(`Model ${reqModel} not supported for format ${opts.format}`)
|
|
334
|
+
|
|
335
|
+
logger.metric({ model: modelId })
|
|
336
|
+
|
|
337
|
+
return { id: modelId, ...modelData }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function selectProvider(
|
|
341
|
+
reqModel: string,
|
|
342
|
+
zenData: ZenData,
|
|
343
|
+
authInfo: AuthInfo,
|
|
344
|
+
modelInfo: ModelInfo,
|
|
345
|
+
sessionId: string,
|
|
346
|
+
isTrial: boolean,
|
|
347
|
+
retry: RetryOptions,
|
|
348
|
+
stickyProvider: string | undefined,
|
|
349
|
+
) {
|
|
350
|
+
const modelProvider = (() => {
|
|
351
|
+
if (authInfo?.provider?.credentials) {
|
|
352
|
+
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (isTrial) {
|
|
356
|
+
return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (stickyProvider) {
|
|
360
|
+
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
|
|
361
|
+
if (provider) return provider
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (retry.retryCount === MAX_RETRIES) {
|
|
365
|
+
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const providers = modelInfo.providers
|
|
369
|
+
.filter((provider) => !provider.disabled)
|
|
370
|
+
.filter((provider) => !retry.excludeProviders.includes(provider.id))
|
|
371
|
+
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
|
372
|
+
|
|
373
|
+
// Use the last 4 characters of session ID to select a provider
|
|
374
|
+
let h = 0
|
|
375
|
+
const l = sessionId.length
|
|
376
|
+
for (let i = l - 4; i < l; i++) {
|
|
377
|
+
h = (h * 31 + sessionId.charCodeAt(i)) | 0 // 32-bit int
|
|
378
|
+
}
|
|
379
|
+
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
|
|
380
|
+
return providers[index || 0]
|
|
381
|
+
})()
|
|
382
|
+
|
|
383
|
+
if (!modelProvider) throw new ModelError("No provider available")
|
|
384
|
+
if (!(modelProvider.id in zenData.providers)) throw new ModelError(`Provider ${modelProvider.id} not supported`)
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
...modelProvider,
|
|
388
|
+
...zenData.providers[modelProvider.id],
|
|
389
|
+
...(() => {
|
|
390
|
+
const format = zenData.providers[modelProvider.id].format
|
|
391
|
+
const providerModel = modelProvider.model
|
|
392
|
+
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
|
|
393
|
+
if (format === "google") return googleHelper({ reqModel, providerModel })
|
|
394
|
+
if (format === "openai") return openaiHelper({ reqModel, providerModel })
|
|
395
|
+
return oaCompatHelper({ reqModel, providerModel })
|
|
396
|
+
})(),
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function authenticate(modelInfo: ModelInfo) {
|
|
401
|
+
const apiKey = opts.parseApiKey(input.request.headers)
|
|
402
|
+
if (!apiKey || apiKey === "public") {
|
|
403
|
+
if (modelInfo.allowAnonymous) return
|
|
404
|
+
throw new AuthError("Missing API key.")
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const data = await Database.use((tx) =>
|
|
408
|
+
tx
|
|
409
|
+
.select({
|
|
410
|
+
apiKey: KeyTable.id,
|
|
411
|
+
workspaceID: KeyTable.workspaceID,
|
|
412
|
+
billing: {
|
|
413
|
+
balance: BillingTable.balance,
|
|
414
|
+
paymentMethodID: BillingTable.paymentMethodID,
|
|
415
|
+
monthlyLimit: BillingTable.monthlyLimit,
|
|
416
|
+
monthlyUsage: BillingTable.monthlyUsage,
|
|
417
|
+
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
|
418
|
+
reloadTrigger: BillingTable.reloadTrigger,
|
|
419
|
+
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
|
|
420
|
+
subscription: BillingTable.subscription,
|
|
421
|
+
},
|
|
422
|
+
user: {
|
|
423
|
+
id: UserTable.id,
|
|
424
|
+
monthlyLimit: UserTable.monthlyLimit,
|
|
425
|
+
monthlyUsage: UserTable.monthlyUsage,
|
|
426
|
+
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
|
427
|
+
},
|
|
428
|
+
subscription: {
|
|
429
|
+
id: SubscriptionTable.id,
|
|
430
|
+
rollingUsage: SubscriptionTable.rollingUsage,
|
|
431
|
+
fixedUsage: SubscriptionTable.fixedUsage,
|
|
432
|
+
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
|
433
|
+
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
|
434
|
+
},
|
|
435
|
+
provider: {
|
|
436
|
+
credentials: ProviderTable.credentials,
|
|
437
|
+
},
|
|
438
|
+
timeDisabled: ModelTable.timeCreated,
|
|
439
|
+
})
|
|
440
|
+
.from(KeyTable)
|
|
441
|
+
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
|
442
|
+
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
|
|
443
|
+
.innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)))
|
|
444
|
+
.leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, modelInfo.id)))
|
|
445
|
+
.leftJoin(
|
|
446
|
+
ProviderTable,
|
|
447
|
+
modelInfo.byokProvider
|
|
448
|
+
? and(
|
|
449
|
+
eq(ProviderTable.workspaceID, KeyTable.workspaceID),
|
|
450
|
+
eq(ProviderTable.provider, modelInfo.byokProvider),
|
|
451
|
+
)
|
|
452
|
+
: sql`false`,
|
|
453
|
+
)
|
|
454
|
+
.leftJoin(
|
|
455
|
+
SubscriptionTable,
|
|
456
|
+
and(
|
|
457
|
+
eq(SubscriptionTable.workspaceID, KeyTable.workspaceID),
|
|
458
|
+
eq(SubscriptionTable.userID, KeyTable.userID),
|
|
459
|
+
isNull(SubscriptionTable.timeDeleted),
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
|
463
|
+
.then((rows) => rows[0]),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
if (!data) throw new AuthError("Invalid API key.")
|
|
467
|
+
logger.metric({
|
|
468
|
+
api_key: data.apiKey,
|
|
469
|
+
workspace: data.workspaceID,
|
|
470
|
+
isSubscription: data.subscription ? true : false,
|
|
471
|
+
subscription: data.billing.subscription?.plan,
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
apiKeyId: data.apiKey,
|
|
476
|
+
workspaceID: data.workspaceID,
|
|
477
|
+
billing: data.billing,
|
|
478
|
+
user: data.user,
|
|
479
|
+
subscription: data.subscription,
|
|
480
|
+
provider: data.provider,
|
|
481
|
+
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
|
482
|
+
isDisabled: !!data.timeDisabled,
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo) {
|
|
487
|
+
if (!authInfo) return "anonymous"
|
|
488
|
+
if (authInfo.provider?.credentials) return "free"
|
|
489
|
+
if (authInfo.isFree) return "free"
|
|
490
|
+
if (modelInfo.allowAnonymous) return "free"
|
|
491
|
+
|
|
492
|
+
// Validate subscription billing
|
|
493
|
+
if (authInfo.billing.subscription && authInfo.subscription) {
|
|
494
|
+
try {
|
|
495
|
+
const sub = authInfo.subscription
|
|
496
|
+
const plan = authInfo.billing.subscription.plan
|
|
497
|
+
|
|
498
|
+
const formatRetryTime = (seconds: number) => {
|
|
499
|
+
const days = Math.floor(seconds / 86400)
|
|
500
|
+
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
|
501
|
+
const hours = Math.floor(seconds / 3600)
|
|
502
|
+
const minutes = Math.ceil((seconds % 3600) / 60)
|
|
503
|
+
if (hours >= 1) return `${hours}hr ${minutes}min`
|
|
504
|
+
return `${minutes}min`
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Check weekly limit
|
|
508
|
+
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
|
509
|
+
const result = Black.analyzeWeeklyUsage({
|
|
510
|
+
plan,
|
|
511
|
+
usage: sub.fixedUsage,
|
|
512
|
+
timeUpdated: sub.timeFixedUpdated,
|
|
513
|
+
})
|
|
514
|
+
if (result.status === "rate-limited")
|
|
515
|
+
throw new SubscriptionError(
|
|
516
|
+
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
|
517
|
+
result.resetInSec,
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Check rolling limit
|
|
522
|
+
if (sub.rollingUsage && sub.timeRollingUpdated) {
|
|
523
|
+
const result = Black.analyzeRollingUsage({
|
|
524
|
+
plan,
|
|
525
|
+
usage: sub.rollingUsage,
|
|
526
|
+
timeUpdated: sub.timeRollingUpdated,
|
|
527
|
+
})
|
|
528
|
+
if (result.status === "rate-limited")
|
|
529
|
+
throw new SubscriptionError(
|
|
530
|
+
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
|
531
|
+
result.resetInSec,
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return "subscription"
|
|
536
|
+
} catch (e) {
|
|
537
|
+
if (!authInfo.billing.subscription.useBalance) throw e
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Validate pay as you go billing
|
|
542
|
+
const billing = authInfo.billing
|
|
543
|
+
if (!billing.paymentMethodID)
|
|
544
|
+
throw new CreditsError(
|
|
545
|
+
`No payment method. Add a payment method here: https://jonsoc.com/workspace/${authInfo.workspaceID}/billing`,
|
|
546
|
+
)
|
|
547
|
+
if (billing.balance <= 0)
|
|
548
|
+
throw new CreditsError(
|
|
549
|
+
`Insufficient balance. Manage your billing here: https://jonsoc.com/workspace/${authInfo.workspaceID}/billing`,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
const now = new Date()
|
|
553
|
+
const currentYear = now.getUTCFullYear()
|
|
554
|
+
const currentMonth = now.getUTCMonth()
|
|
555
|
+
if (
|
|
556
|
+
billing.monthlyLimit &&
|
|
557
|
+
billing.monthlyUsage &&
|
|
558
|
+
billing.timeMonthlyUsageUpdated &&
|
|
559
|
+
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) &&
|
|
560
|
+
currentYear === billing.timeMonthlyUsageUpdated.getUTCFullYear() &&
|
|
561
|
+
currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
|
|
562
|
+
)
|
|
563
|
+
throw new MonthlyLimitError(
|
|
564
|
+
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://jonsoc.com/workspace/${authInfo.workspaceID}/billing`,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
if (
|
|
568
|
+
authInfo.user.monthlyLimit &&
|
|
569
|
+
authInfo.user.monthlyUsage &&
|
|
570
|
+
authInfo.user.timeMonthlyUsageUpdated &&
|
|
571
|
+
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100) &&
|
|
572
|
+
currentYear === authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() &&
|
|
573
|
+
currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
|
|
574
|
+
)
|
|
575
|
+
throw new UserLimitError(
|
|
576
|
+
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://jonsoc.com/workspace/${authInfo.workspaceID}/members`,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
return "balance"
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function validateModelSettings(authInfo: AuthInfo) {
|
|
583
|
+
if (!authInfo) return
|
|
584
|
+
if (authInfo.isDisabled) throw new ModelError("Model is disabled")
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {
|
|
588
|
+
if (!authInfo?.provider?.credentials) return
|
|
589
|
+
providerInfo.apiKey = authInfo.provider.credentials
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function trackUsage(
|
|
593
|
+
authInfo: AuthInfo,
|
|
594
|
+
modelInfo: ModelInfo,
|
|
595
|
+
providerInfo: ProviderInfo,
|
|
596
|
+
billingSource: ReturnType<typeof validateBilling>,
|
|
597
|
+
usageInfo: UsageInfo,
|
|
598
|
+
) {
|
|
599
|
+
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
|
600
|
+
usageInfo
|
|
601
|
+
|
|
602
|
+
const modelCost =
|
|
603
|
+
modelInfo.cost200K &&
|
|
604
|
+
inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > 200_000
|
|
605
|
+
? modelInfo.cost200K
|
|
606
|
+
: modelInfo.cost
|
|
607
|
+
|
|
608
|
+
const inputCost = modelCost.input * inputTokens * 100
|
|
609
|
+
const outputCost = modelCost.output * outputTokens * 100
|
|
610
|
+
const reasoningCost = (() => {
|
|
611
|
+
if (!reasoningTokens) return undefined
|
|
612
|
+
return modelCost.output * reasoningTokens * 100
|
|
613
|
+
})()
|
|
614
|
+
const cacheReadCost = (() => {
|
|
615
|
+
if (!cacheReadTokens) return undefined
|
|
616
|
+
if (!modelCost.cacheRead) return undefined
|
|
617
|
+
return modelCost.cacheRead * cacheReadTokens * 100
|
|
618
|
+
})()
|
|
619
|
+
const cacheWrite5mCost = (() => {
|
|
620
|
+
if (!cacheWrite5mTokens) return undefined
|
|
621
|
+
if (!modelCost.cacheWrite5m) return undefined
|
|
622
|
+
return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
|
|
623
|
+
})()
|
|
624
|
+
const cacheWrite1hCost = (() => {
|
|
625
|
+
if (!cacheWrite1hTokens) return undefined
|
|
626
|
+
if (!modelCost.cacheWrite1h) return undefined
|
|
627
|
+
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
|
628
|
+
})()
|
|
629
|
+
const totalCostInCent =
|
|
630
|
+
inputCost +
|
|
631
|
+
outputCost +
|
|
632
|
+
(reasoningCost ?? 0) +
|
|
633
|
+
(cacheReadCost ?? 0) +
|
|
634
|
+
(cacheWrite5mCost ?? 0) +
|
|
635
|
+
(cacheWrite1hCost ?? 0)
|
|
636
|
+
|
|
637
|
+
logger.metric({
|
|
638
|
+
"tokens.input": inputTokens,
|
|
639
|
+
"tokens.output": outputTokens,
|
|
640
|
+
"tokens.reasoning": reasoningTokens,
|
|
641
|
+
"tokens.cache_read": cacheReadTokens,
|
|
642
|
+
"tokens.cache_write_5m": cacheWrite5mTokens,
|
|
643
|
+
"tokens.cache_write_1h": cacheWrite1hTokens,
|
|
644
|
+
"cost.input": Math.round(inputCost),
|
|
645
|
+
"cost.output": Math.round(outputCost),
|
|
646
|
+
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
|
647
|
+
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
|
648
|
+
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
|
649
|
+
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
|
650
|
+
"cost.total": Math.round(totalCostInCent),
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
if (billingSource === "anonymous") return
|
|
654
|
+
authInfo = authInfo!
|
|
655
|
+
|
|
656
|
+
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
|
657
|
+
await Database.use((db) =>
|
|
658
|
+
Promise.all([
|
|
659
|
+
db.insert(UsageTable).values({
|
|
660
|
+
workspaceID: authInfo.workspaceID,
|
|
661
|
+
id: Identifier.create("usage"),
|
|
662
|
+
model: modelInfo.id,
|
|
663
|
+
provider: providerInfo.id,
|
|
664
|
+
inputTokens,
|
|
665
|
+
outputTokens,
|
|
666
|
+
reasoningTokens,
|
|
667
|
+
cacheReadTokens,
|
|
668
|
+
cacheWrite5mTokens,
|
|
669
|
+
cacheWrite1hTokens,
|
|
670
|
+
cost,
|
|
671
|
+
keyID: authInfo.apiKeyId,
|
|
672
|
+
enrichment: billingSource === "subscription" ? { plan: "sub" } : undefined,
|
|
673
|
+
}),
|
|
674
|
+
db
|
|
675
|
+
.update(KeyTable)
|
|
676
|
+
.set({ timeUsed: sql`now()` })
|
|
677
|
+
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
|
678
|
+
...(billingSource === "subscription"
|
|
679
|
+
? (() => {
|
|
680
|
+
const plan = authInfo.billing.subscription!.plan
|
|
681
|
+
const black = BlackData.getLimits({ plan })
|
|
682
|
+
const week = getWeekBounds(new Date())
|
|
683
|
+
const rollingWindowSeconds = black.rollingWindow * 3600
|
|
684
|
+
return [
|
|
685
|
+
db
|
|
686
|
+
.update(SubscriptionTable)
|
|
687
|
+
.set({
|
|
688
|
+
fixedUsage: sql`
|
|
689
|
+
CASE
|
|
690
|
+
WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost}
|
|
691
|
+
ELSE ${cost}
|
|
692
|
+
END
|
|
693
|
+
`,
|
|
694
|
+
timeFixedUpdated: sql`now()`,
|
|
695
|
+
rollingUsage: sql`
|
|
696
|
+
CASE
|
|
697
|
+
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost}
|
|
698
|
+
ELSE ${cost}
|
|
699
|
+
END
|
|
700
|
+
`,
|
|
701
|
+
timeRollingUpdated: sql`
|
|
702
|
+
CASE
|
|
703
|
+
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.timeRollingUpdated}
|
|
704
|
+
ELSE now()
|
|
705
|
+
END
|
|
706
|
+
`,
|
|
707
|
+
})
|
|
708
|
+
.where(
|
|
709
|
+
and(
|
|
710
|
+
eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
|
|
711
|
+
eq(SubscriptionTable.userID, authInfo.user.id),
|
|
712
|
+
),
|
|
713
|
+
),
|
|
714
|
+
]
|
|
715
|
+
})()
|
|
716
|
+
: [
|
|
717
|
+
db
|
|
718
|
+
.update(BillingTable)
|
|
719
|
+
.set({
|
|
720
|
+
balance: authInfo.isFree
|
|
721
|
+
? sql`${BillingTable.balance} - ${0}`
|
|
722
|
+
: sql`${BillingTable.balance} - ${cost}`,
|
|
723
|
+
monthlyUsage: sql`
|
|
724
|
+
CASE
|
|
725
|
+
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
|
726
|
+
ELSE ${cost}
|
|
727
|
+
END
|
|
728
|
+
`,
|
|
729
|
+
timeMonthlyUsageUpdated: sql`now()`,
|
|
730
|
+
})
|
|
731
|
+
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
|
|
732
|
+
db
|
|
733
|
+
.update(UserTable)
|
|
734
|
+
.set({
|
|
735
|
+
monthlyUsage: sql`
|
|
736
|
+
CASE
|
|
737
|
+
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
|
|
738
|
+
ELSE ${cost}
|
|
739
|
+
END
|
|
740
|
+
`,
|
|
741
|
+
timeMonthlyUsageUpdated: sql`now()`,
|
|
742
|
+
})
|
|
743
|
+
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
|
744
|
+
]),
|
|
745
|
+
]),
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
return { costInMicroCents: cost }
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function reload(authInfo: AuthInfo, costInfo: Awaited<ReturnType<typeof trackUsage>>) {
|
|
752
|
+
if (!authInfo) return
|
|
753
|
+
if (authInfo.isFree) return
|
|
754
|
+
if (authInfo.provider?.credentials) return
|
|
755
|
+
if (authInfo.subscription) return
|
|
756
|
+
|
|
757
|
+
if (!costInfo) return
|
|
758
|
+
|
|
759
|
+
const reloadTrigger = centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100)
|
|
760
|
+
if (authInfo.billing.balance - costInfo.costInMicroCents >= reloadTrigger) return
|
|
761
|
+
if (authInfo.billing.timeReloadLockedTill && authInfo.billing.timeReloadLockedTill > new Date()) return
|
|
762
|
+
|
|
763
|
+
const lock = await Database.use((tx) =>
|
|
764
|
+
tx
|
|
765
|
+
.update(BillingTable)
|
|
766
|
+
.set({
|
|
767
|
+
timeReloadLockedTill: sql`now() + interval 1 minute`,
|
|
768
|
+
})
|
|
769
|
+
.where(
|
|
770
|
+
and(
|
|
771
|
+
eq(BillingTable.workspaceID, authInfo.workspaceID),
|
|
772
|
+
eq(BillingTable.reload, true),
|
|
773
|
+
lt(BillingTable.balance, reloadTrigger),
|
|
774
|
+
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
|
775
|
+
),
|
|
776
|
+
),
|
|
777
|
+
)
|
|
778
|
+
if (lock.rowsAffected === 0) return
|
|
779
|
+
|
|
780
|
+
await Actor.provide("system", { workspaceID: authInfo.workspaceID }, async () => {
|
|
781
|
+
await Billing.reload()
|
|
782
|
+
})
|
|
783
|
+
}
|
|
784
|
+
}
|