@littlebearapps/platform-consumer-sdk 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/README.md +306 -0
- package/package.json +53 -0
- package/src/ai-gateway.ts +305 -0
- package/src/constants.ts +147 -0
- package/src/costs.ts +590 -0
- package/src/do-heartbeat.ts +249 -0
- package/src/dynamic-patterns.ts +273 -0
- package/src/errors.ts +285 -0
- package/src/features.ts +149 -0
- package/src/heartbeat.ts +27 -0
- package/src/index.ts +950 -0
- package/src/logging.ts +543 -0
- package/src/middleware.ts +447 -0
- package/src/patterns.ts +156 -0
- package/src/proxy.ts +732 -0
- package/src/retry.ts +19 -0
- package/src/service-client.ts +291 -0
- package/src/telemetry.ts +342 -0
- package/src/timeout.ts +212 -0
- package/src/tracing.ts +403 -0
- package/src/types.ts +465 -0
package/README.md
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# Platform Consumer SDK
|
|
2
|
+
|
|
3
|
+
**`@littlebearapps/platform-consumer-sdk`** — Automatic cost protection, circuit breaking, and telemetry for Cloudflare Workers.
|
|
4
|
+
|
|
5
|
+
Install in each Worker project. Zero production dependencies.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @littlebearapps/platform-consumer-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
### Wrap fetch handlers
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { withFeatureBudget, CircuitBreakerError, completeTracking } from '@littlebearapps/platform-consumer-sdk';
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
|
22
|
+
const tracked = withFeatureBudget(env, 'myapp:api:main', { ctx });
|
|
23
|
+
try {
|
|
24
|
+
const result = await tracked.DB.prepare('SELECT * FROM users LIMIT 100').all();
|
|
25
|
+
return Response.json(result);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e instanceof CircuitBreakerError) {
|
|
28
|
+
return Response.json({ error: 'Feature temporarily disabled' }, { status: 503 });
|
|
29
|
+
}
|
|
30
|
+
throw e;
|
|
31
|
+
} finally {
|
|
32
|
+
ctx.waitUntil(completeTracking(tracked));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Wrap cron handlers
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { withCronBudget, completeTracking } from '@littlebearapps/platform-consumer-sdk';
|
|
42
|
+
|
|
43
|
+
export default {
|
|
44
|
+
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
|
|
45
|
+
const tracked = withCronBudget(env, 'myapp:cron:daily-sync', { ctx, cronExpression: event.cron });
|
|
46
|
+
try {
|
|
47
|
+
// Your cron logic using tracked.DB, tracked.KV, etc.
|
|
48
|
+
} finally {
|
|
49
|
+
ctx.waitUntil(completeTracking(tracked));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Wrap queue handlers
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { withQueueBudget, completeTracking } from '@littlebearapps/platform-consumer-sdk';
|
|
59
|
+
|
|
60
|
+
export default {
|
|
61
|
+
async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) {
|
|
62
|
+
for (const msg of batch.messages) {
|
|
63
|
+
const tracked = withQueueBudget(env, 'myapp:queue:processor', {
|
|
64
|
+
message: msg.body, queueName: 'my-queue',
|
|
65
|
+
});
|
|
66
|
+
try {
|
|
67
|
+
// Process using tracked.DB, tracked.KV, etc.
|
|
68
|
+
msg.ack();
|
|
69
|
+
} finally {
|
|
70
|
+
ctx.waitUntil(completeTracking(tracked));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Required Bindings
|
|
78
|
+
|
|
79
|
+
Add to your `wrangler.jsonc`:
|
|
80
|
+
|
|
81
|
+
```jsonc
|
|
82
|
+
{
|
|
83
|
+
"kv_namespaces": [
|
|
84
|
+
{ "binding": "PLATFORM_CACHE", "id": "YOUR_KV_NAMESPACE_ID" }
|
|
85
|
+
],
|
|
86
|
+
"queues": {
|
|
87
|
+
"producers": [
|
|
88
|
+
{ "binding": "TELEMETRY_QUEUE", "queue": "your-telemetry-queue" }
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Exports
|
|
95
|
+
|
|
96
|
+
### Main (`@littlebearapps/platform-consumer-sdk`)
|
|
97
|
+
|
|
98
|
+
#### Core Wrappers
|
|
99
|
+
|
|
100
|
+
| Export | Description |
|
|
101
|
+
|--------|------------|
|
|
102
|
+
| `withFeatureBudget(env, featureId, opts?)` | Wrap `fetch` handlers — proxies bindings with automatic tracking |
|
|
103
|
+
| `withCronBudget(env, featureId, opts)` | Wrap `scheduled` handlers (deterministic correlation ID from cron expression) |
|
|
104
|
+
| `withQueueBudget(env, featureId, opts?)` | Wrap `queue` handlers (extracts correlation ID from message body) |
|
|
105
|
+
| `completeTracking(env)` | Flush pending metrics — call in `finally` or `ctx.waitUntil()` |
|
|
106
|
+
| `CircuitBreakerError` | Thrown when a feature's budget is exhausted (has `featureId`, `level`, `reason`) |
|
|
107
|
+
| `health(featureId, kv, queue?, ctx?)` | Dual-plane health check (KV connectivity + queue delivery) |
|
|
108
|
+
|
|
109
|
+
#### Circuit Breaker Management
|
|
110
|
+
|
|
111
|
+
| Export | Description |
|
|
112
|
+
|--------|------------|
|
|
113
|
+
| `isFeatureEnabled(featureId, kv)` | Check if a feature is enabled (returns boolean) |
|
|
114
|
+
| `setCircuitBreakerStatus(featureId, status, kv, reason?)` | Set GO/STOP status for a feature |
|
|
115
|
+
| `clearCircuitBreakerCache()` | Clear per-request circuit breaker cache |
|
|
116
|
+
|
|
117
|
+
#### Logging and Correlation
|
|
118
|
+
|
|
119
|
+
| Export | Description |
|
|
120
|
+
|--------|------------|
|
|
121
|
+
| `createLogger(opts)` | Structured logger with correlation IDs |
|
|
122
|
+
| `createLoggerFromEnv(env, opts)` | Logger auto-configured from environment |
|
|
123
|
+
| `createLoggerFromRequest(request, env, opts)` | Logger from incoming request context |
|
|
124
|
+
| `generateCorrelationId()` | Generate a new correlation ID |
|
|
125
|
+
| `getCorrelationId(env)` / `setCorrelationId(env, id)` | Get/set correlation ID on environment |
|
|
126
|
+
|
|
127
|
+
#### Error Tracking
|
|
128
|
+
|
|
129
|
+
| Export | Description |
|
|
130
|
+
|--------|------------|
|
|
131
|
+
| `categoriseError(error)` | Categorise error as `transient`, `client`, `server`, `unknown` |
|
|
132
|
+
| `reportError(env, error)` | Report error to telemetry context |
|
|
133
|
+
| `reportErrorExplicit(env, code, message)` | Report with explicit code and message |
|
|
134
|
+
| `withErrorTracking(env, fn)` | Wrapper that automatically reports errors |
|
|
135
|
+
| `trackError(env, error)` | Track error count without reporting |
|
|
136
|
+
|
|
137
|
+
#### Distributed Tracing (W3C Traceparent)
|
|
138
|
+
|
|
139
|
+
| Export | Description |
|
|
140
|
+
|--------|------------|
|
|
141
|
+
| `createTraceContext(request?)` | Create trace context from request headers |
|
|
142
|
+
| `extractTraceContext(request)` / `getTraceContext(env)` | Extract/get current trace context |
|
|
143
|
+
| `parseTraceparent(header)` / `formatTraceparent(ctx)` | Parse/format W3C traceparent |
|
|
144
|
+
| `propagateTraceContext(headers)` | Add trace headers to outgoing requests |
|
|
145
|
+
| `createTracedFetch(env)` | Wrapped fetch that auto-propagates trace context |
|
|
146
|
+
| `startSpan(name)` / `endSpan(span)` / `failSpan(span, error)` | Span lifecycle management |
|
|
147
|
+
|
|
148
|
+
#### Timeout Utilities
|
|
149
|
+
|
|
150
|
+
| Export | Description |
|
|
151
|
+
|--------|------------|
|
|
152
|
+
| `withTimeout(promise, ms)` | Race a promise against a timeout |
|
|
153
|
+
| `withTrackedTimeout(env, promise, ms, feature)` | Timeout with metrics tracking |
|
|
154
|
+
| `withRequestTimeout(request, promise)` | Timeout based on request deadline header |
|
|
155
|
+
| `TimeoutError` | Error class thrown on timeout |
|
|
156
|
+
| `DEFAULT_TIMEOUTS` | Preset timeout values (short: 5s, medium: 15s, long: 30s) |
|
|
157
|
+
|
|
158
|
+
#### Service Client (Cross-Worker Correlation)
|
|
159
|
+
|
|
160
|
+
| Export | Description |
|
|
161
|
+
|--------|------------|
|
|
162
|
+
| `createServiceClient(binding, opts)` | Service binding wrapper with correlation propagation |
|
|
163
|
+
| `wrapServiceBinding(binding, opts)` | Lightweight service binding wrapper |
|
|
164
|
+
| `createServiceBindingHeaders(env)` | Generate correlation chain headers |
|
|
165
|
+
| `extractCorrelationChain(request)` | Extract chain from incoming request |
|
|
166
|
+
|
|
167
|
+
#### AI Gateway
|
|
168
|
+
|
|
169
|
+
| Export | Description |
|
|
170
|
+
|--------|------------|
|
|
171
|
+
| `createAIGatewayFetch(env, gateway, provider)` | AI Gateway request wrapper |
|
|
172
|
+
| `createAIGatewayFetchWithBodyParsing(env, gateway, provider)` | With response body parsing |
|
|
173
|
+
| `parseAIGatewayUrl(url)` | Extract provider/model from AI Gateway URL |
|
|
174
|
+
| `reportAIGatewayUsage(env, provider, model)` | Track AI call metrics |
|
|
175
|
+
|
|
176
|
+
#### Proxy Utilities
|
|
177
|
+
|
|
178
|
+
| Export | Description |
|
|
179
|
+
|--------|------------|
|
|
180
|
+
| `createD1Proxy(db, metrics)` | D1 binding proxy with metrics |
|
|
181
|
+
| `createKVProxy(kv, metrics)` | KV binding proxy with metrics |
|
|
182
|
+
| `createAIProxy(ai, metrics)` | Workers AI proxy with metrics |
|
|
183
|
+
| `createR2Proxy(r2, metrics)` | R2 binding proxy with metrics |
|
|
184
|
+
| `createQueueProxy(queue, metrics)` | Queue binding proxy with metrics |
|
|
185
|
+
| `createVectorizeProxy(index, metrics)` | Vectorize binding proxy with metrics |
|
|
186
|
+
| `getMetrics(proxy)` | Extract accumulated metrics from any proxy |
|
|
187
|
+
|
|
188
|
+
#### Other Utilities
|
|
189
|
+
|
|
190
|
+
| Export | Description |
|
|
191
|
+
|--------|------------|
|
|
192
|
+
| `pingHeartbeat(url, token?)` | Gatus/uptime heartbeat ping |
|
|
193
|
+
| `withExponentialBackoff(fn, opts?)` | Retry with exponential backoff (3 attempts) |
|
|
194
|
+
| `withHeartbeat(DOClass, config)` | Durable Object class wrapper with heartbeat |
|
|
195
|
+
| `PRICING_TIERS` / `PAID_ALLOWANCES` | Cloudflare pricing constants |
|
|
196
|
+
| `calculateHourlyCosts(metrics)` / `calculateDailyBillableCosts(usage)` | Cost calculation helpers |
|
|
197
|
+
| `KV_KEYS` / `CIRCUIT_STATUS` / `METRIC_FIELDS` / `BINDING_NAMES` | SDK constants |
|
|
198
|
+
| `PLATFORM_FEATURES` / `getAllPlatformFeatures()` | Platform worker feature IDs |
|
|
199
|
+
|
|
200
|
+
### Sub-path Exports (v0.2.0+)
|
|
201
|
+
|
|
202
|
+
#### `@littlebearapps/platform-consumer-sdk/middleware`
|
|
203
|
+
|
|
204
|
+
Project-level circuit breaker middleware. Two-tier system: feature-level (SDK core) + project-level (this module).
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
import { createCircuitBreakerMiddleware, CB_PROJECT_KEYS } from '@littlebearapps/platform-consumer-sdk/middleware';
|
|
208
|
+
|
|
209
|
+
// Hono middleware
|
|
210
|
+
const app = new Hono<{ Bindings: Env }>();
|
|
211
|
+
app.use('*', createCircuitBreakerMiddleware(CB_PROJECT_KEYS.SCOUT, {
|
|
212
|
+
skipPaths: ['/health', '/healthz'],
|
|
213
|
+
}));
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
| Export | Description |
|
|
217
|
+
|--------|------------|
|
|
218
|
+
| `checkProjectCircuitBreaker(key, kv)` | Simple check — returns Response or null |
|
|
219
|
+
| `checkProjectCircuitBreakerDetailed(key, kv)` | Detailed check with status/reason |
|
|
220
|
+
| `createCircuitBreakerMiddleware(key, opts?)` | Hono middleware factory |
|
|
221
|
+
| `getCircuitBreakerStates(keys, kv)` | Query multiple projects at once |
|
|
222
|
+
| `getProjectStatus(key, kv)` / `setProjectStatus(key, status, kv)` | Read/write project CB |
|
|
223
|
+
| `isGlobalStopActive(kv)` / `setGlobalStop(active, kv)` | Global kill switch |
|
|
224
|
+
| `CB_PROJECT_KEYS` | Pre-defined keys for Scout, Brand Copilot, etc. |
|
|
225
|
+
| `PROJECT_CB_STATUS` | Status values: `active`, `warning`, `paused` |
|
|
226
|
+
|
|
227
|
+
#### `@littlebearapps/platform-consumer-sdk/patterns`
|
|
228
|
+
|
|
229
|
+
125 static regex patterns for classifying transient (expected operational) errors. Zero I/O.
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
import { classifyErrorAsTransient } from '@littlebearapps/platform-consumer-sdk/patterns';
|
|
233
|
+
|
|
234
|
+
const result = classifyErrorAsTransient('quotaExceeded: Daily limit reached');
|
|
235
|
+
// { isTransient: true, category: 'quota-exhausted' }
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
| Export | Description |
|
|
239
|
+
|--------|------------|
|
|
240
|
+
| `TRANSIENT_ERROR_PATTERNS` | Array of 125 patterns with regex + category |
|
|
241
|
+
| `classifyErrorAsTransient(message)` | Classify a message — returns `{ isTransient, category? }` |
|
|
242
|
+
|
|
243
|
+
#### `@littlebearapps/platform-consumer-sdk/dynamic-patterns`
|
|
244
|
+
|
|
245
|
+
AI-discovered patterns loaded from KV at runtime. Constrained DSL with ReDoS protection.
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
import { loadDynamicPatterns, classifyWithDynamicPatterns } from '@littlebearapps/platform-consumer-sdk/dynamic-patterns';
|
|
249
|
+
|
|
250
|
+
const patterns = await loadDynamicPatterns(env.PLATFORM_CACHE);
|
|
251
|
+
const result = classifyWithDynamicPatterns('Custom error message', patterns);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
| Export | Description |
|
|
255
|
+
|--------|------------|
|
|
256
|
+
| `loadDynamicPatterns(kv)` | Load approved patterns from KV (5-min cache) |
|
|
257
|
+
| `compileDynamicPatterns(rules)` | Compile and validate pattern rules |
|
|
258
|
+
| `classifyWithDynamicPatterns(message, patterns)` | Classify against dynamic patterns |
|
|
259
|
+
| `exportDynamicPatterns(kv)` | Export patterns for cross-account sync |
|
|
260
|
+
| `importDynamicPatterns(kv, rules)` | Import with validation gate |
|
|
261
|
+
|
|
262
|
+
#### `@littlebearapps/platform-consumer-sdk/heartbeat`
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
import { pingHeartbeat } from '@littlebearapps/platform-consumer-sdk/heartbeat';
|
|
266
|
+
await pingHeartbeat('https://status.example.com/api/v1/endpoints/heartbeats_myworker/external');
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
#### `@littlebearapps/platform-consumer-sdk/retry`
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
import { withExponentialBackoff } from '@littlebearapps/platform-consumer-sdk/retry';
|
|
273
|
+
const result = await withExponentialBackoff(() => fetchWithRetry(url));
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
#### `@littlebearapps/platform-consumer-sdk/costs`
|
|
277
|
+
|
|
278
|
+
Cloudflare pricing tiers and cost calculation helpers. Updated for January 2025 pricing.
|
|
279
|
+
|
|
280
|
+
## Error Handling
|
|
281
|
+
|
|
282
|
+
`CircuitBreakerError` is thrown when any level of the circuit breaker hierarchy is STOP:
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
try {
|
|
286
|
+
const tracked = withFeatureBudget(env, 'myapp:api:main', { ctx });
|
|
287
|
+
await tracked.DB.prepare('SELECT 1').all();
|
|
288
|
+
} catch (e) {
|
|
289
|
+
if (e instanceof CircuitBreakerError) {
|
|
290
|
+
console.log(e.featureId); // 'myapp:api:main'
|
|
291
|
+
console.log(e.level); // 'feature' | 'project' | 'global'
|
|
292
|
+
console.log(e.reason); // Optional reason string
|
|
293
|
+
return new Response('Service unavailable', { status: 503 });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The hierarchy checks in order: **global** (kill switch) > **project** > **feature**.
|
|
299
|
+
|
|
300
|
+
## Configuration
|
|
301
|
+
|
|
302
|
+
Budget limits and circuit breaker thresholds are stored in KV (`PLATFORM_CACHE`) under the `CONFIG:FEATURE:` prefix. They're synced from `budgets.yaml` via the Admin SDK's sync script.
|
|
303
|
+
|
|
304
|
+
## License
|
|
305
|
+
|
|
306
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@littlebearapps/platform-consumer-sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Platform Consumer SDK — automatic metric collection, circuit breaking, and cost protection for Cloudflare Workers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./heartbeat": "./src/heartbeat.ts",
|
|
10
|
+
"./retry": "./src/retry.ts",
|
|
11
|
+
"./costs": "./src/costs.ts",
|
|
12
|
+
"./middleware": "./src/middleware.ts",
|
|
13
|
+
"./patterns": "./src/patterns.ts",
|
|
14
|
+
"./dynamic-patterns": "./src/dynamic-patterns.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/**/*.ts"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:coverage": "vitest run --coverage",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/littlebearapps/platform-sdks.git",
|
|
30
|
+
"directory": "packages/consumer-sdk"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/littlebearapps/platform-sdks#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/littlebearapps/platform-sdks/issues"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"cloudflare-workers",
|
|
38
|
+
"platform-consumer-sdk",
|
|
39
|
+
"circuit-breaker",
|
|
40
|
+
"cost-protection",
|
|
41
|
+
"telemetry",
|
|
42
|
+
"feature-budget",
|
|
43
|
+
"billing-safety"
|
|
44
|
+
],
|
|
45
|
+
"author": "Little Bear Apps",
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@cloudflare/workers-types": "^4.20250214.0",
|
|
49
|
+
"@vitest/coverage-v8": "^3.0.5",
|
|
50
|
+
"typescript": "^5.7.3",
|
|
51
|
+
"vitest": "^3.0.5"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Platform SDK AI Gateway Tracking
|
|
5
|
+
*
|
|
6
|
+
* Provides a drop-in replacement fetch wrapper for AI Gateway calls.
|
|
7
|
+
* Automatically parses provider/model from URLs and reports usage to telemetry.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* const trackedEnv = withFeatureBudget(env, 'scout:ai:scoring', { ctx });
|
|
12
|
+
* const trackedFetch = createAIGatewayFetch(trackedEnv);
|
|
13
|
+
*
|
|
14
|
+
* const response = await trackedFetch(
|
|
15
|
+
* `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/google-ai-studio/v1beta/models/gemini-pro:generateContent`,
|
|
16
|
+
* { method: 'POST', body: JSON.stringify(payload) }
|
|
17
|
+
* );
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getTelemetryContext } from './telemetry';
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// TYPES
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parsed AI Gateway URL components.
|
|
29
|
+
*/
|
|
30
|
+
export interface AIGatewayUrlInfo {
|
|
31
|
+
/** Provider name (google-ai-studio, openai, deepseek, anthropic, etc.) */
|
|
32
|
+
provider: string;
|
|
33
|
+
/** Model name extracted from URL path */
|
|
34
|
+
model: string;
|
|
35
|
+
/** Account ID from the URL */
|
|
36
|
+
accountId: string;
|
|
37
|
+
/** Gateway ID from the URL */
|
|
38
|
+
gatewayId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* AI Gateway provider types.
|
|
43
|
+
*/
|
|
44
|
+
export type AIGatewayProvider =
|
|
45
|
+
| 'google-ai-studio'
|
|
46
|
+
| 'openai'
|
|
47
|
+
| 'deepseek'
|
|
48
|
+
| 'anthropic'
|
|
49
|
+
| 'workers-ai'
|
|
50
|
+
| 'azure-openai'
|
|
51
|
+
| 'bedrock'
|
|
52
|
+
| 'groq'
|
|
53
|
+
| 'mistral'
|
|
54
|
+
| 'perplexity'
|
|
55
|
+
| string; // Allow custom providers
|
|
56
|
+
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// URL PARSING
|
|
59
|
+
// =============================================================================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* AI Gateway URL pattern.
|
|
63
|
+
* Format: gateway.ai.cloudflare.com/v1/{accountId}/{gatewayId}/{provider}/...
|
|
64
|
+
*/
|
|
65
|
+
const AI_GATEWAY_PATTERN = /gateway\.ai\.cloudflare\.com\/v1\/([^/]+)\/([^/]+)\/([^/]+)\/(.*)/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse an AI Gateway URL to extract provider and model information.
|
|
69
|
+
* Supports all major providers: Google AI Studio, OpenAI, DeepSeek, Anthropic, etc.
|
|
70
|
+
*
|
|
71
|
+
* @param url - The AI Gateway URL to parse
|
|
72
|
+
* @returns Parsed URL info or null if not an AI Gateway URL
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```typescript
|
|
76
|
+
* const info = parseAIGatewayUrl(
|
|
77
|
+
* 'https://gateway.ai.cloudflare.com/v1/abc123/my-gateway/google-ai-studio/v1beta/models/gemini-pro:generateContent'
|
|
78
|
+
* );
|
|
79
|
+
* // { provider: 'google-ai-studio', model: 'gemini-pro', accountId: 'abc123', gatewayId: 'my-gateway' }
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function parseAIGatewayUrl(url: string): AIGatewayUrlInfo | null {
|
|
83
|
+
const match = url.match(AI_GATEWAY_PATTERN);
|
|
84
|
+
if (!match) return null;
|
|
85
|
+
|
|
86
|
+
const accountId = match[1];
|
|
87
|
+
const gatewayId = match[2];
|
|
88
|
+
const provider = match[3];
|
|
89
|
+
const path = match[4];
|
|
90
|
+
|
|
91
|
+
const model = extractModelFromPath(provider, path);
|
|
92
|
+
|
|
93
|
+
return { provider, model, accountId, gatewayId };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract model name from the URL path based on provider-specific patterns.
|
|
98
|
+
*/
|
|
99
|
+
function extractModelFromPath(provider: string, path: string): string {
|
|
100
|
+
switch (provider) {
|
|
101
|
+
case 'google-ai-studio': {
|
|
102
|
+
// Pattern: v1beta/models/{model}:generateContent or v1beta/models/{model}:streamGenerateContent
|
|
103
|
+
const match = path.match(/models\/([^/:]+)/);
|
|
104
|
+
return match?.[1] ?? 'gemini-unknown';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'openai': {
|
|
108
|
+
// Model is typically in request body, not URL
|
|
109
|
+
// Path patterns: v1/chat/completions, v1/embeddings, v1/images/generations
|
|
110
|
+
if (path.includes('embeddings')) {
|
|
111
|
+
return 'text-embedding-3-small';
|
|
112
|
+
}
|
|
113
|
+
if (path.includes('images')) {
|
|
114
|
+
return 'dall-e-3';
|
|
115
|
+
}
|
|
116
|
+
// Default to gpt-4o for chat completions - actual model in body
|
|
117
|
+
return 'gpt-4o';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case 'deepseek': {
|
|
121
|
+
// DeepSeek follows OpenAI format
|
|
122
|
+
if (path.includes('embeddings')) {
|
|
123
|
+
return 'deepseek-embedding';
|
|
124
|
+
}
|
|
125
|
+
return 'deepseek-chat';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case 'anthropic': {
|
|
129
|
+
// Anthropic: v1/messages
|
|
130
|
+
// Model is in request body, default to claude-sonnet
|
|
131
|
+
return 'claude-3-5-sonnet';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case 'workers-ai': {
|
|
135
|
+
// Pattern: {model} directly in path
|
|
136
|
+
const segments = path.split('/').filter(Boolean);
|
|
137
|
+
return segments[0] ?? 'workers-ai-unknown';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
case 'groq': {
|
|
141
|
+
// Groq follows OpenAI format
|
|
142
|
+
return 'llama-3.1-70b';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case 'mistral': {
|
|
146
|
+
// Mistral: v1/chat/completions
|
|
147
|
+
return 'mistral-large';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'perplexity': {
|
|
151
|
+
// Perplexity: chat/completions
|
|
152
|
+
return 'llama-3.1-sonar';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
default: {
|
|
156
|
+
// For unknown providers, try to extract model from common patterns
|
|
157
|
+
// Try OpenAI-style /models/{model} pattern
|
|
158
|
+
const modelMatch = path.match(/models\/([^/]+)/);
|
|
159
|
+
if (modelMatch) {
|
|
160
|
+
return modelMatch[1];
|
|
161
|
+
}
|
|
162
|
+
// Return provider as model identifier
|
|
163
|
+
return `${provider}-unknown`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// =============================================================================
|
|
169
|
+
// USAGE REPORTING
|
|
170
|
+
// =============================================================================
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Report AI Gateway usage to telemetry context.
|
|
174
|
+
* Increments aiRequests counter and tracks per-model breakdown.
|
|
175
|
+
*
|
|
176
|
+
* @param env - The tracked environment from withFeatureBudget
|
|
177
|
+
* @param provider - AI provider name (google-ai-studio, openai, etc.)
|
|
178
|
+
* @param model - Model name
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* ```typescript
|
|
182
|
+
* const trackedEnv = withFeatureBudget(env, 'scout:ai:scoring', { ctx });
|
|
183
|
+
* reportAIGatewayUsage(trackedEnv, 'google-ai-studio', 'gemini-pro');
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
export function reportAIGatewayUsage(env: object, provider: string, model: string): void {
|
|
187
|
+
const context = getTelemetryContext(env);
|
|
188
|
+
if (!context) {
|
|
189
|
+
// No telemetry context - silently skip
|
|
190
|
+
// This happens when called outside of withFeatureBudget
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Increment total AI requests
|
|
195
|
+
context.metrics.aiRequests += 1;
|
|
196
|
+
|
|
197
|
+
// Track per-model breakdown using provider/model format
|
|
198
|
+
const fullModelName = `${provider}/${model}`;
|
|
199
|
+
const currentCount = context.metrics.aiModelCounts.get(fullModelName) ?? 0;
|
|
200
|
+
context.metrics.aiModelCounts.set(fullModelName, currentCount + 1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// =============================================================================
|
|
204
|
+
// TRACKED FETCH WRAPPER
|
|
205
|
+
// =============================================================================
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Create a tracked fetch wrapper for AI Gateway calls.
|
|
209
|
+
* Automatically extracts provider and model from URL and reports usage.
|
|
210
|
+
*
|
|
211
|
+
* This is a drop-in replacement for fetch() when calling AI Gateway endpoints.
|
|
212
|
+
* Non-AI Gateway URLs are passed through without tracking.
|
|
213
|
+
*
|
|
214
|
+
* @param env - The tracked environment from withFeatureBudget
|
|
215
|
+
* @returns A fetch function that tracks AI Gateway usage
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```typescript
|
|
219
|
+
* const trackedEnv = withFeatureBudget(env, 'scout:ai:scoring', { ctx });
|
|
220
|
+
* const trackedFetch = createAIGatewayFetch(trackedEnv);
|
|
221
|
+
*
|
|
222
|
+
* // This call is automatically tracked
|
|
223
|
+
* const response = await trackedFetch(
|
|
224
|
+
* `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/google-ai-studio/v1beta/models/gemini-pro:generateContent`,
|
|
225
|
+
* { method: 'POST', body: JSON.stringify(payload) }
|
|
226
|
+
* );
|
|
227
|
+
*
|
|
228
|
+
* // Non-AI Gateway calls pass through unchanged
|
|
229
|
+
* const other = await trackedFetch('https://api.example.com/data');
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
export function createAIGatewayFetch(env: object): typeof fetch {
|
|
233
|
+
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
234
|
+
// Extract URL string from various input types
|
|
235
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
236
|
+
|
|
237
|
+
// Parse AI Gateway URL
|
|
238
|
+
const parsed = parseAIGatewayUrl(url);
|
|
239
|
+
|
|
240
|
+
// Make the actual fetch call
|
|
241
|
+
const response = await fetch(input, init);
|
|
242
|
+
|
|
243
|
+
// Report usage if this was an AI Gateway call
|
|
244
|
+
if (parsed) {
|
|
245
|
+
reportAIGatewayUsage(env, parsed.provider, parsed.model);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return response;
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Create a tracked fetch wrapper that also extracts the model from the request body.
|
|
254
|
+
* Use this when you need accurate model tracking for providers where the model
|
|
255
|
+
* is specified in the request body (OpenAI, Anthropic, DeepSeek).
|
|
256
|
+
*
|
|
257
|
+
* @param env - The tracked environment from withFeatureBudget
|
|
258
|
+
* @returns A fetch function that tracks AI Gateway usage with body parsing
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* ```typescript
|
|
262
|
+
* const trackedEnv = withFeatureBudget(env, 'brand-copilot:content:generate', { ctx });
|
|
263
|
+
* const trackedFetch = createAIGatewayFetchWithBodyParsing(trackedEnv);
|
|
264
|
+
*
|
|
265
|
+
* const response = await trackedFetch(
|
|
266
|
+
* `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/openai/v1/chat/completions`,
|
|
267
|
+
* {
|
|
268
|
+
* method: 'POST',
|
|
269
|
+
* body: JSON.stringify({ model: 'gpt-4o-mini', messages: [...] })
|
|
270
|
+
* }
|
|
271
|
+
* );
|
|
272
|
+
* // Tracks as 'openai/gpt-4o-mini' instead of default 'openai/gpt-4o'
|
|
273
|
+
* ```
|
|
274
|
+
*/
|
|
275
|
+
export function createAIGatewayFetchWithBodyParsing(env: object): typeof fetch {
|
|
276
|
+
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
277
|
+
// Extract URL string
|
|
278
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
279
|
+
|
|
280
|
+
// Parse AI Gateway URL
|
|
281
|
+
const parsed = parseAIGatewayUrl(url);
|
|
282
|
+
|
|
283
|
+
// Make the actual fetch call
|
|
284
|
+
const response = await fetch(input, init);
|
|
285
|
+
|
|
286
|
+
// Report usage if this was an AI Gateway call
|
|
287
|
+
if (parsed) {
|
|
288
|
+
// Try to extract model from request body
|
|
289
|
+
let model = parsed.model;
|
|
290
|
+
if (init?.body && typeof init.body === 'string') {
|
|
291
|
+
try {
|
|
292
|
+
const body = JSON.parse(init.body) as { model?: string };
|
|
293
|
+
if (body.model && typeof body.model === 'string') {
|
|
294
|
+
model = body.model;
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// Body is not JSON or doesn't have model field - use URL-derived model
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
reportAIGatewayUsage(env, parsed.provider, model);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return response;
|
|
304
|
+
};
|
|
305
|
+
}
|