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