@sudobility/shapeshyft_service 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CLAUDE.md +35 -0
  2. package/README.md +17 -0
  3. package/dist/context.d.ts +62 -0
  4. package/dist/context.d.ts.map +1 -0
  5. package/dist/context.js +55 -0
  6. package/dist/context.js.map +1 -0
  7. package/dist/contracts.d.ts +110 -0
  8. package/dist/contracts.d.ts.map +1 -0
  9. package/dist/contracts.js +7 -0
  10. package/dist/contracts.js.map +1 -0
  11. package/dist/index.d.ts +17 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +16 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/lib/api-key.d.ts +22 -0
  16. package/dist/lib/api-key.d.ts.map +1 -0
  17. package/dist/lib/api-key.js +87 -0
  18. package/dist/lib/api-key.js.map +1 -0
  19. package/dist/lib/encryption.d.ts +22 -0
  20. package/dist/lib/encryption.d.ts.map +1 -0
  21. package/dist/lib/encryption.js +41 -0
  22. package/dist/lib/encryption.js.map +1 -0
  23. package/dist/lib/entity-api-key.d.ts +21 -0
  24. package/dist/lib/entity-api-key.d.ts.map +1 -0
  25. package/dist/lib/entity-api-key.js +54 -0
  26. package/dist/lib/entity-api-key.js.map +1 -0
  27. package/dist/lib/entity-helpers.d.ts +90 -0
  28. package/dist/lib/entity-helpers.d.ts.map +1 -0
  29. package/dist/lib/entity-helpers.js +136 -0
  30. package/dist/lib/entity-helpers.js.map +1 -0
  31. package/dist/lib/money.d.ts +7 -0
  32. package/dist/lib/money.d.ts.map +1 -0
  33. package/dist/lib/money.js +9 -0
  34. package/dist/lib/money.js.map +1 -0
  35. package/dist/lib/public-project.d.ts +14 -0
  36. package/dist/lib/public-project.d.ts.map +1 -0
  37. package/dist/lib/public-project.js +17 -0
  38. package/dist/lib/public-project.js.map +1 -0
  39. package/dist/lib/user-api-key-cache.d.ts +35 -0
  40. package/dist/lib/user-api-key-cache.d.ts.map +1 -0
  41. package/dist/lib/user-api-key-cache.js +99 -0
  42. package/dist/lib/user-api-key-cache.js.map +1 -0
  43. package/dist/lib/user-api-key.d.ts +46 -0
  44. package/dist/lib/user-api-key.d.ts.map +1 -0
  45. package/dist/lib/user-api-key.js +102 -0
  46. package/dist/lib/user-api-key.js.map +1 -0
  47. package/dist/middleware/firebaseAuth.d.ts +34 -0
  48. package/dist/middleware/firebaseAuth.d.ts.map +1 -0
  49. package/dist/middleware/firebaseAuth.js +133 -0
  50. package/dist/middleware/firebaseAuth.js.map +1 -0
  51. package/dist/middleware/rateLimit.d.ts +29 -0
  52. package/dist/middleware/rateLimit.d.ts.map +1 -0
  53. package/dist/middleware/rateLimit.js +85 -0
  54. package/dist/middleware/rateLimit.js.map +1 -0
  55. package/dist/middleware/subscription.d.ts +8 -0
  56. package/dist/middleware/subscription.d.ts.map +1 -0
  57. package/dist/middleware/subscription.js +19 -0
  58. package/dist/middleware/subscription.js.map +1 -0
  59. package/dist/routes/ai.d.ts +10 -0
  60. package/dist/routes/ai.d.ts.map +1 -0
  61. package/dist/routes/ai.js +698 -0
  62. package/dist/routes/ai.js.map +1 -0
  63. package/dist/routes/analytics.d.ts +9 -0
  64. package/dist/routes/analytics.d.ts.map +1 -0
  65. package/dist/routes/analytics.js +192 -0
  66. package/dist/routes/analytics.js.map +1 -0
  67. package/dist/routes/endpoints.d.ts +10 -0
  68. package/dist/routes/endpoints.d.ts.map +1 -0
  69. package/dist/routes/endpoints.js +255 -0
  70. package/dist/routes/endpoints.js.map +1 -0
  71. package/dist/routes/entities.d.ts +15 -0
  72. package/dist/routes/entities.d.ts.map +1 -0
  73. package/dist/routes/entities.js +332 -0
  74. package/dist/routes/entities.js.map +1 -0
  75. package/dist/routes/entity-api-keys.d.ts +17 -0
  76. package/dist/routes/entity-api-keys.d.ts.map +1 -0
  77. package/dist/routes/entity-api-keys.js +142 -0
  78. package/dist/routes/entity-api-keys.js.map +1 -0
  79. package/dist/routes/invitations.d.ts +15 -0
  80. package/dist/routes/invitations.d.ts.map +1 -0
  81. package/dist/routes/invitations.js +58 -0
  82. package/dist/routes/invitations.js.map +1 -0
  83. package/dist/routes/projects.d.ts +9 -0
  84. package/dist/routes/projects.d.ts.map +1 -0
  85. package/dist/routes/projects.js +241 -0
  86. package/dist/routes/projects.js.map +1 -0
  87. package/dist/routes/providers.d.ts +9 -0
  88. package/dist/routes/providers.d.ts.map +1 -0
  89. package/dist/routes/providers.js +89 -0
  90. package/dist/routes/providers.js.map +1 -0
  91. package/dist/routes/ratelimits.d.ts +9 -0
  92. package/dist/routes/ratelimits.d.ts.map +1 -0
  93. package/dist/routes/ratelimits.js +171 -0
  94. package/dist/routes/ratelimits.js.map +1 -0
  95. package/dist/routes/settings.d.ts +9 -0
  96. package/dist/routes/settings.d.ts.map +1 -0
  97. package/dist/routes/settings.js +138 -0
  98. package/dist/routes/settings.js.map +1 -0
  99. package/dist/routes/storage.d.ts +9 -0
  100. package/dist/routes/storage.d.ts.map +1 -0
  101. package/dist/routes/storage.js +180 -0
  102. package/dist/routes/storage.js.map +1 -0
  103. package/dist/routes/user-api-keys.d.ts +14 -0
  104. package/dist/routes/user-api-keys.d.ts.map +1 -0
  105. package/dist/routes/user-api-keys.js +237 -0
  106. package/dist/routes/user-api-keys.js.map +1 -0
  107. package/dist/routes/users.d.ts +7 -0
  108. package/dist/routes/users.d.ts.map +1 -0
  109. package/dist/routes/users.js +102 -0
  110. package/dist/routes/users.js.map +1 -0
  111. package/dist/schema/init.d.ts +12 -0
  112. package/dist/schema/init.d.ts.map +1 -0
  113. package/dist/schema/init.js +404 -0
  114. package/dist/schema/init.js.map +1 -0
  115. package/dist/schema/tables.d.ts +2110 -0
  116. package/dist/schema/tables.d.ts.map +1 -0
  117. package/dist/schema/tables.js +251 -0
  118. package/dist/schema/tables.js.map +1 -0
  119. package/dist/schemas/index.d.ts +671 -0
  120. package/dist/schemas/index.d.ts.map +1 -0
  121. package/dist/schemas/index.js +299 -0
  122. package/dist/schemas/index.js.map +1 -0
  123. package/dist/service.d.ts +41 -0
  124. package/dist/service.d.ts.map +1 -0
  125. package/dist/service.js +70 -0
  126. package/dist/service.js.map +1 -0
  127. package/package.json +85 -0
@@ -0,0 +1,698 @@
1
+ /**
2
+ * @fileoverview Public AI inference routes
3
+ * @description Handles AI endpoint invocation and prompt generation.
4
+ * These routes use project API key authentication (not Firebase),
5
+ * with optional IP allowlisting and rate limiting per entity.
6
+ */
7
+ import { Hono } from "hono";
8
+ import { zValidator } from "@hono/zod-validator";
9
+ import { eq, and, sql } from "drizzle-orm";
10
+ import { aiParamSchema } from "../schemas/index.js";
11
+ import { successResponse, errorResponse, } from "@sudobility/shapeshyft_engine/types";
12
+ import { ApiHelper, estimateCost, getModelPricing, extractMediaFromInput, convertAllMediaIfNeeded, validateMediaCapabilities, validateWhisperRequest, isTranscriptionModel, extractReservedFields, resolveMaxOutputTokens, } from "@sudobility/shapeshyft_engine";
13
+ import { EntitlementHelper, RateLimitChecker, } from "@sudobility/ratelimit_service";
14
+ import { SubscriptionHelper } from "@sudobility/subscription_service";
15
+ import { toMicroCents } from "../lib/money.js";
16
+ export function createAiRouter(ctx) {
17
+ const { db } = ctx;
18
+ const { projects, endpoints, usageAnalytics, entities, entityMembers, users, rateLimitCounters, } = ctx.tables;
19
+ const { validateProjectApiKey, isValidApiKeyFormat } = ctx.projectApiKeys;
20
+ const aiRouter = new Hono();
21
+ /**
22
+ * Record one invocation against the endpoint's lifetime counter.
23
+ *
24
+ * Incremented in SQL rather than read-modify-write so concurrent calls to the
25
+ * same endpoint cannot lose counts. Best-effort: a failure here is logged and
26
+ * swallowed, because losing a tally must never fail a request the caller
27
+ * already paid for.
28
+ *
29
+ * @param endpointId - UUID of the endpoint that was called
30
+ */
31
+ async function incrementCallCount(endpointId) {
32
+ try {
33
+ await db
34
+ .update(endpoints)
35
+ .set({ call_count: sql `${endpoints.call_count} + 1` })
36
+ .where(eq(endpoints.uuid, endpointId));
37
+ }
38
+ catch (error) {
39
+ ctx.logger.error("Failed to increment endpoint call count:", error);
40
+ }
41
+ }
42
+ // =============================================================================
43
+ // Security Helpers
44
+ // =============================================================================
45
+ /**
46
+ * Extract API key from request (query param or Authorization header)
47
+ */
48
+ function extractApiKey(c) {
49
+ // 1. Check query parameter
50
+ const url = new URL(c.req.url);
51
+ const queryKey = url.searchParams.get("api_key");
52
+ if (queryKey) {
53
+ return queryKey;
54
+ }
55
+ // 2. Check Authorization header (Bearer token)
56
+ const authHeader = c.req.header("Authorization");
57
+ if (authHeader && authHeader.startsWith("Bearer ")) {
58
+ return authHeader.substring(7);
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * Get client IP address from request
64
+ */
65
+ function getClientIp(c) {
66
+ // Check common proxy headers first
67
+ const xForwardedFor = c.req.header("X-Forwarded-For");
68
+ if (xForwardedFor) {
69
+ // X-Forwarded-For can be comma-separated list; take the first (client) IP
70
+ return xForwardedFor.split(",")[0]?.trim() ?? null;
71
+ }
72
+ const xRealIp = c.req.header("X-Real-IP");
73
+ if (xRealIp) {
74
+ return xRealIp;
75
+ }
76
+ // Fallback to connection info if available
77
+ // Note: This may not work in all environments
78
+ return c.req.raw?.socket?.remoteAddress ?? null;
79
+ }
80
+ /**
81
+ * Check if IP is in the allowlist
82
+ */
83
+ function isIpAllowed(clientIp, allowlist) {
84
+ // If no allowlist is set, allow all
85
+ if (!allowlist || allowlist.length === 0) {
86
+ return true;
87
+ }
88
+ // If allowlist is set but no client IP, deny
89
+ if (!clientIp) {
90
+ return false;
91
+ }
92
+ return allowlist.includes(clientIp);
93
+ }
94
+ // =============================================================================
95
+ // Input Processing Helpers
96
+ // =============================================================================
97
+ // Reserved input fields (context, web_search, max_output_tokens) are pulled out
98
+ // by extractReservedFields in ../lib/reserved-fields.
99
+ // =============================================================================
100
+ // Shared Validation Logic
101
+ // =============================================================================
102
+ /**
103
+ * Find entity by slug (organization path).
104
+ * The organization path in the public API URL is now the entity slug.
105
+ */
106
+ async function findEntityBySlug(entitySlug) {
107
+ const entityRows = await db
108
+ .select()
109
+ .from(entities)
110
+ .where(eq(entities.entity_slug, entitySlug));
111
+ return entityRows[0] ?? null;
112
+ }
113
+ // Lazy-initialized rate limit helpers
114
+ let _subscriptionHelper = null;
115
+ let _entitlementHelper = null;
116
+ let _rateLimitChecker = null;
117
+ /**
118
+ * Get subscription helper (singleton, lazily initialized).
119
+ * Uses single API key - testMode is passed to getSubscriptionInfo to filter sandbox purchases.
120
+ */
121
+ function getSubscriptionHelper() {
122
+ const apiKey = ctx.revenueCatApiKey;
123
+ if (!apiKey)
124
+ return null;
125
+ if (!_subscriptionHelper) {
126
+ _subscriptionHelper = new SubscriptionHelper({
127
+ revenueCatApiKey: apiKey,
128
+ });
129
+ }
130
+ return _subscriptionHelper;
131
+ }
132
+ function getEntitlementHelper() {
133
+ if (!_entitlementHelper) {
134
+ _entitlementHelper = new EntitlementHelper(ctx.rateLimiting.rateLimitsConfig);
135
+ }
136
+ return _entitlementHelper;
137
+ }
138
+ function getRateLimitChecker() {
139
+ if (!_rateLimitChecker) {
140
+ _rateLimitChecker = new RateLimitChecker({
141
+ db: db,
142
+ table: rateLimitCounters,
143
+ });
144
+ }
145
+ return _rateLimitChecker;
146
+ }
147
+ /**
148
+ * Extract testMode from URL query parameter
149
+ */
150
+ function getTestMode(c) {
151
+ const url = new URL(c.req.url);
152
+ const testMode = url.searchParams.get("testMode");
153
+ return testMode === "true";
154
+ }
155
+ /**
156
+ * Resolve whether web search runs for this call.
157
+ *
158
+ * The endpoint config is the gate: a caller can only ever turn search *off*,
159
+ * never on for an endpoint that does not have it enabled.
160
+ *
161
+ * @param endpointDefault - The endpoint's `web_search` setting
162
+ * @param callerPreference - The caller's preference, already extracted from the
163
+ * input by `extractReservedFields`; undefined when they expressed none
164
+ */
165
+ function resolveWebSearch(endpointDefault, callerPreference) {
166
+ if (!endpointDefault)
167
+ return false;
168
+ return callerPreference ?? true;
169
+ }
170
+ /**
171
+ * Check if an entity is owned by a site admin.
172
+ * Entities owned by site admins are exempt from rate limiting.
173
+ * This applies to both personal and organization entities.
174
+ */
175
+ async function isEntityOwnedBySiteAdmin(entity) {
176
+ // Find the owner of the entity
177
+ const ownerMember = await db
178
+ .select()
179
+ .from(entityMembers)
180
+ .where(and(eq(entityMembers.entity_id, entity.id), eq(entityMembers.role, "owner"), eq(entityMembers.is_active, true)))
181
+ .limit(1);
182
+ if (ownerMember.length === 0) {
183
+ return false;
184
+ }
185
+ // Get the owner's email from the users table
186
+ const ownerUser = await db
187
+ .select()
188
+ .from(users)
189
+ .where(eq(users.firebase_uid, ownerMember[0].user_id))
190
+ .limit(1);
191
+ if (ownerUser.length === 0 || !ownerUser[0].email) {
192
+ return false;
193
+ }
194
+ // Check if the owner's email is a site admin
195
+ return ctx.auth.isSiteAdmin(ownerUser[0].email);
196
+ }
197
+ /**
198
+ * Check and increment rate limits for an AI request.
199
+ * Rate limits are per entity (personal or organizational).
200
+ * Entities owned by site admins are exempt from rate limiting.
201
+ * Returns null if allowed, or an error response if rate limited.
202
+ */
203
+ async function checkRateLimit(c, entity) {
204
+ // Check if entity is owned by a site admin - skip rate limiting
205
+ if (await isEntityOwnedBySiteAdmin(entity)) {
206
+ return null;
207
+ }
208
+ const testMode = getTestMode(c);
209
+ const subHelper = getSubscriptionHelper();
210
+ if (!subHelper) {
211
+ // RevenueCat not configured - skip rate limiting
212
+ return null;
213
+ }
214
+ try {
215
+ // Use entityId as RevenueCat subscriber ID
216
+ // testMode is passed to filter sandbox purchases in production mode
217
+ const subscriptionInfo = await subHelper.getSubscriptionInfo(entity.id, testMode);
218
+ const limits = getEntitlementHelper().getRateLimits(subscriptionInfo.entitlements);
219
+ const result = await getRateLimitChecker().checkAndIncrement(entity.id, limits, subscriptionInfo.subscriptionStartedAt);
220
+ if (!result.allowed) {
221
+ return c.json(errorResponse(`Rate limit exceeded (${result.exceededLimit ?? "unknown"} limit). ` +
222
+ `Remaining: hourly=${result.remaining.hourly ?? "∞"}, ` +
223
+ `daily=${result.remaining.daily ?? "∞"}, ` +
224
+ `monthly=${result.remaining.monthly ?? "∞"}`), 429);
225
+ }
226
+ return null; // Allowed
227
+ }
228
+ catch (error) {
229
+ // Log error but don't block request on rate limit check failure
230
+ ctx.logger.error("Rate limit check failed:", error);
231
+ return null;
232
+ }
233
+ }
234
+ /**
235
+ * Validate request and get all required context data.
236
+ * This is shared between /prompt and main endpoints.
237
+ */
238
+ async function validateAndGetContext(c) {
239
+ const { organizationPath, projectName, endpointName } = c.req.valid("param");
240
+ // 1. Find entity by slug (organization path is now entity slug)
241
+ const entity = await findEntityBySlug(organizationPath);
242
+ if (!entity) {
243
+ return {
244
+ success: false,
245
+ response: c.json(errorResponse("Organization not found"), 404),
246
+ };
247
+ }
248
+ // 2. Find project by name AND entity_id
249
+ const projectRows = await db
250
+ .select()
251
+ .from(projects)
252
+ .where(and(eq(projects.entity_id, entity.id), eq(projects.project_name, projectName), eq(projects.is_active, true)));
253
+ if (projectRows.length === 0) {
254
+ return {
255
+ success: false,
256
+ response: c.json(errorResponse("Project not found"), 404),
257
+ };
258
+ }
259
+ const project = projectRows[0];
260
+ // 3. Validate API key
261
+ const providedApiKey = extractApiKey(c);
262
+ if (!providedApiKey) {
263
+ return {
264
+ success: false,
265
+ response: c.json(errorResponse("API key required. Provide via api_key query parameter or Authorization header"), 401),
266
+ };
267
+ }
268
+ if (!isValidApiKeyFormat(providedApiKey)) {
269
+ return {
270
+ success: false,
271
+ response: c.json(errorResponse("Invalid API key format"), 401),
272
+ };
273
+ }
274
+ // Check if project has an API key configured
275
+ if (!project.encrypted_api_key || !project.api_key_iv) {
276
+ return {
277
+ success: false,
278
+ response: c.json(errorResponse("Project API key not configured"), 500),
279
+ };
280
+ }
281
+ // Validate the provided API key against stored encrypted key
282
+ const isValidKey = validateProjectApiKey(providedApiKey, project.encrypted_api_key, project.api_key_iv);
283
+ if (!isValidKey) {
284
+ return {
285
+ success: false,
286
+ response: c.json(errorResponse("Invalid API key"), 401),
287
+ };
288
+ }
289
+ // 4. Find endpoint by name within project
290
+ const endpointRows = await db
291
+ .select()
292
+ .from(endpoints)
293
+ .where(and(eq(endpoints.project_id, project.uuid), eq(endpoints.endpoint_name, endpointName), eq(endpoints.is_active, true)));
294
+ if (endpointRows.length === 0) {
295
+ return {
296
+ success: false,
297
+ response: c.json(errorResponse("Endpoint not found"), 404),
298
+ };
299
+ }
300
+ const endpoint = endpointRows[0];
301
+ // 5. Validate IP allowlist (if configured on endpoint)
302
+ const ipAllowlist = endpoint.ip_allowlist;
303
+ if (ipAllowlist && ipAllowlist.length > 0) {
304
+ const clientIp = getClientIp(c);
305
+ if (!isIpAllowed(clientIp, ipAllowlist)) {
306
+ return {
307
+ success: false,
308
+ response: c.json(errorResponse(`IP address ${clientIp ?? "unknown"} is not allowed to access this endpoint`), 403),
309
+ };
310
+ }
311
+ }
312
+ // 6. Validate HTTP method matches endpoint definition
313
+ const requestMethod = c.req.method;
314
+ if (endpoint.http_method !== requestMethod) {
315
+ return {
316
+ success: false,
317
+ response: c.json(errorResponse(`Method ${requestMethod} not allowed. Use ${endpoint.http_method}`), 405),
318
+ };
319
+ }
320
+ // 7. Get input data based on method
321
+ let inputData;
322
+ try {
323
+ if (requestMethod === "GET") {
324
+ // Parse query parameters
325
+ const url = new URL(c.req.url);
326
+ inputData = Object.fromEntries(url.searchParams);
327
+ }
328
+ else {
329
+ // Parse JSON body
330
+ inputData = await c.req.json();
331
+ }
332
+ }
333
+ catch {
334
+ return {
335
+ success: false,
336
+ response: c.json(errorResponse("Invalid request body"), 400),
337
+ };
338
+ }
339
+ // 8. Resolve the provider credential. Where it comes from is the app's
340
+ // business (an entity's LLM key, a site-owned provider key). Kept here,
341
+ // before rate limiting, so a missing credential fails without consuming a
342
+ // rate-limit count -- the order this lookup always had.
343
+ let credential;
344
+ try {
345
+ const resolved = await ctx.credentials.resolve({
346
+ entityId: entity.id,
347
+ endpoint,
348
+ });
349
+ if (!resolved.ok) {
350
+ return {
351
+ success: false,
352
+ response: c.json(errorResponse(resolved.message), resolved.status),
353
+ };
354
+ }
355
+ credential = resolved;
356
+ }
357
+ catch (error) {
358
+ ctx.logger.error("Credential resolution failed:", error);
359
+ return {
360
+ success: false,
361
+ response: c.json(errorResponse("Failed to resolve provider credential"), 500),
362
+ };
363
+ }
364
+ return {
365
+ success: true,
366
+ entity,
367
+ project,
368
+ endpoint,
369
+ credential,
370
+ inputData,
371
+ };
372
+ }
373
+ // =============================================================================
374
+ // Prompt Endpoint Handler
375
+ // =============================================================================
376
+ /**
377
+ * Handle prompt generation request - returns just the prompt without calling LLM
378
+ */
379
+ async function handlePromptRequest(c) {
380
+ const validationResult = await validateAndGetContext(c);
381
+ if (!validationResult.success) {
382
+ return validationResult.response;
383
+ }
384
+ const { entity, endpoint, credential, inputData } = validationResult;
385
+ // Check rate limits using entity's subscription
386
+ const rateLimitResponse = await checkRateLimit(c, entity);
387
+ if (rateLimitResponse) {
388
+ return rateLimitResponse;
389
+ }
390
+ // Strip reserved fields so the preview matches what an invocation would send
391
+ const { context: contextOverride, cleanedInput } = extractReservedFields(inputData);
392
+ // Generate the combined prompt using ApiHelper
393
+ // Use context override if provided, otherwise use endpoint's configured context
394
+ const prompt = ApiHelper.prompt({
395
+ inputData: cleanedInput,
396
+ outputSchema: endpoint.output_schema,
397
+ instructions: endpoint.instructions,
398
+ context: contextOverride ?? endpoint.context,
399
+ provider: credential.provider,
400
+ });
401
+ const promptResponse = { prompt };
402
+ return c.json(successResponse(promptResponse));
403
+ }
404
+ // =============================================================================
405
+ // Main AI Endpoint Handler
406
+ // =============================================================================
407
+ /**
408
+ * Handle AI endpoint execution - generates prompt, calls LLM, returns response
409
+ */
410
+ async function handleAIRequest(c) {
411
+ const startTime = Date.now();
412
+ const validationResult = await validateAndGetContext(c);
413
+ if (!validationResult.success) {
414
+ return validationResult.response;
415
+ }
416
+ const { entity, project, endpoint, credential, inputData } = validationResult;
417
+ // Check rate limits using entity's subscription
418
+ const rateLimitResponse = await checkRateLimit(c, entity);
419
+ if (rateLimitResponse) {
420
+ return rateLimitResponse;
421
+ }
422
+ // App gate after rate limiting (e.g. ShapeRouter's credit balance)
423
+ if (ctx.hooks.beforeInvoke) {
424
+ const stop = await ctx.hooks.beforeInvoke({
425
+ c,
426
+ entity,
427
+ project,
428
+ endpoint,
429
+ provider: credential.provider,
430
+ });
431
+ if (stop)
432
+ return stop;
433
+ }
434
+ // Pull out every reserved field in one pass, before anything builds a prompt
435
+ const { context: contextOverride, webSearch: webSearchPreference, maxOutputTokens: requestedMaxOutputTokens, cleanedInput: inputWithoutReserved, } = extractReservedFields(inputData);
436
+ // Resolve the output ceiling: the caller may lower the endpoint's limit but
437
+ // never raise it. A malformed value fails the request rather than silently
438
+ // leaving the caller unprotected.
439
+ const ceiling = resolveMaxOutputTokens(endpoint.max_output_tokens, requestedMaxOutputTokens);
440
+ if (!ceiling.ok) {
441
+ return c.json(errorResponse(ceiling.error), 400);
442
+ }
443
+ // Extract media from input data (after removing reserved fields)
444
+ const extractionResult = extractMediaFromInput(inputWithoutReserved);
445
+ if (extractionResult.error) {
446
+ return c.json(errorResponse(extractionResult.error), 400);
447
+ }
448
+ const { cleanedInput, media: extractedMedia } = extractionResult.result;
449
+ // Convert unsupported image formats (SVG, TIFF, etc.) to PNG
450
+ let media = extractedMedia;
451
+ if (extractedMedia.length > 0) {
452
+ try {
453
+ media = await convertAllMediaIfNeeded(extractedMedia);
454
+ }
455
+ catch (conversionError) {
456
+ const errorMessage = conversionError instanceof Error
457
+ ? conversionError.message
458
+ : "Failed to convert image format";
459
+ return c.json(errorResponse(errorMessage), 400);
460
+ }
461
+ }
462
+ // Determine model (from endpoint config)
463
+ const model = endpoint.model ?? undefined;
464
+ // Validate media capabilities if media was extracted
465
+ if (media.length > 0 && model) {
466
+ const validation = validateMediaCapabilities({
467
+ model,
468
+ provider: credential.provider,
469
+ inputMedia: media,
470
+ expectsOutput: {
471
+ // For now, we don't have explicit output config in endpoints
472
+ // This can be extended when we add output media support
473
+ },
474
+ });
475
+ if (!validation.valid) {
476
+ return c.json(errorResponse(validation.errors.join("; ")), 400);
477
+ }
478
+ // Additional Whisper validation
479
+ if (isTranscriptionModel(model)) {
480
+ const whisperValidation = validateWhisperRequest(model, media);
481
+ if (!whisperValidation.valid) {
482
+ return c.json(errorResponse(whisperValidation.errors.join("; ")), 400);
483
+ }
484
+ }
485
+ }
486
+ // Build the prompts for LLM call (providers expect system/user format)
487
+ // Use cleaned input (media replaced with placeholders)
488
+ // Use context override if provided, otherwise use endpoint's configured context
489
+ const prompts = ApiHelper.buildLegacyPrompts({
490
+ inputData: cleanedInput,
491
+ outputSchema: endpoint.output_schema,
492
+ instructions: endpoint.instructions,
493
+ context: contextOverride ?? endpoint.context,
494
+ provider: credential.provider,
495
+ });
496
+ // Parse media output configuration from endpoint
497
+ const expectsMediaOutput = endpoint.expects_media_output;
498
+ // Create LLM request with media
499
+ // Use proper discriminated union based on output format
500
+ const baseRequest = {
501
+ prompt: prompts.user,
502
+ systemPrompt: prompts.system,
503
+ outputSchema: endpoint.output_schema ?? {
504
+ type: "object",
505
+ },
506
+ model,
507
+ media: media.length > 0 ? media : undefined,
508
+ expectsMediaOutput: expectsMediaOutput ?? undefined,
509
+ webSearch: resolveWebSearch(endpoint.web_search ?? false, webSearchPreference),
510
+ // null means the endpoint opted out of runaway protection; the providers
511
+ // treat undefined as "no limit".
512
+ maxTokens: ceiling.value ?? undefined,
513
+ /*
514
+ NULL becomes undefined rather than 0, and the two are different answers.
515
+
516
+ Undefined leaves every adapter exactly as it was: OpenAI, Gemini, Groq
517
+ and the custom provider apply their own `?? 0`, while Anthropic omits the
518
+ field entirely because Opus 4.7+ and Sonnet 5 reject it with a 400.
519
+ Passing 0 here would send a value to the models that refuse one, and
520
+ would do it to every endpoint that predates this column.
521
+ */
522
+ temperature: endpoint.temperature ?? undefined,
523
+ };
524
+ const llmRequest = endpoint.output_media_format === "url"
525
+ ? {
526
+ ...baseRequest,
527
+ outputMediaFormat: "url",
528
+ entityId: endpoint.uuid,
529
+ }
530
+ : { ...baseRequest, outputMediaFormat: "base64" };
531
+ // 4. Call LLM and return response
532
+ const provider = ctx.createProvider(credential.provider, {
533
+ apiKey: credential.apiKey,
534
+ endpointUrl: credential.endpointUrl,
535
+ timeoutMs: credential.timeoutMs,
536
+ });
537
+ // Debug info for troubleshooting (get actual URL from provider if available)
538
+ const actualEndpointUrl = "getEndpointUrl" in provider
539
+ ? provider.getEndpointUrl()
540
+ : credential.endpointUrl;
541
+ const debugInfo = {
542
+ provider: credential.provider,
543
+ endpointUrl: actualEndpointUrl,
544
+ request: llmRequest,
545
+ };
546
+ ctx.logger.log("[AI] Prompt sent to LLM:", {
547
+ provider: credential.provider,
548
+ model,
549
+ instructions: endpoint.instructions,
550
+ systemPrompt: prompts.system,
551
+ userPrompt: prompts.user,
552
+ });
553
+ try {
554
+ const llmResponse = await provider.generate(llmRequest);
555
+ ctx.logger.log("[AI] LLM response:", {
556
+ provider: llmResponse.provider,
557
+ model: llmResponse.model,
558
+ content: llmResponse.content,
559
+ usage: llmResponse.usage,
560
+ latencyMs: llmResponse.latencyMs,
561
+ });
562
+ // 5. Calculate cost
563
+ const pricing = getModelPricing(llmResponse.model);
564
+ const costCents = estimateCost(pricing, llmResponse.usage.promptTokens, llmResponse.usage.completionTokens);
565
+ // 6. Log analytics and count the call
566
+ await incrementCallCount(endpoint.uuid);
567
+ const analyticsValues = {
568
+ endpoint_id: endpoint.uuid,
569
+ success: true,
570
+ tokens_input: llmResponse.usage.promptTokens,
571
+ tokens_output: llmResponse.usage.completionTokens,
572
+ latency_ms: llmResponse.latencyMs,
573
+ estimated_cost_cents: Math.round(costCents),
574
+ request_metadata: {
575
+ model: llmResponse.model,
576
+ provider: llmResponse.provider,
577
+ ...(llmResponse.finishReason
578
+ ? { finish_reason: llmResponse.finishReason }
579
+ : {}),
580
+ ...(ceiling.value !== null
581
+ ? { max_output_tokens: ceiling.value }
582
+ : {}),
583
+ },
584
+ };
585
+ const afterInvoke = ctx.hooks.afterInvoke;
586
+ if (afterInvoke) {
587
+ // Settlement and the analytics row commit together or not at all. A
588
+ // failure here fails the request even though the provider answered:
589
+ // an unrecorded charge is worse than a retry.
590
+ try {
591
+ await db.transaction(async (tx) => {
592
+ const [row] = await tx
593
+ .insert(usageAnalytics)
594
+ .values(analyticsValues)
595
+ .returning({ uuid: usageAnalytics.uuid });
596
+ await afterInvoke({
597
+ tx,
598
+ entity,
599
+ endpoint,
600
+ usageAnalyticsId: row.uuid,
601
+ provider: credential.provider,
602
+ model: llmResponse.model,
603
+ usage: {
604
+ promptTokens: llmResponse.usage.promptTokens,
605
+ completionTokens: llmResponse.usage.completionTokens,
606
+ },
607
+ providerCostMicroCents: toMicroCents(costCents),
608
+ });
609
+ });
610
+ }
611
+ catch (settleError) {
612
+ ctx.logger.error("Usage settlement failed:", settleError);
613
+ return c.json(errorResponse("Failed to record usage for this call"), 500);
614
+ }
615
+ }
616
+ else {
617
+ await db.insert(usageAnalytics).values(analyticsValues);
618
+ }
619
+ // 7. Return response with generated media if present
620
+ const response = {
621
+ output: llmResponse.content,
622
+ usage: {
623
+ tokens_input: llmResponse.usage.promptTokens,
624
+ tokens_output: llmResponse.usage.completionTokens,
625
+ latency_ms: llmResponse.latencyMs,
626
+ estimated_cost_cents: costCents,
627
+ ...(llmResponse.finishReason
628
+ ? { finish_reason: llmResponse.finishReason }
629
+ : {}),
630
+ },
631
+ };
632
+ // Hitting the ceiling means `output` is a truncated answer that will
633
+ // usually fail the caller's schema validation. Saying so explicitly is what
634
+ // lets a caller tell "the model ran away" from "the model returned
635
+ // something unparseable" -- different faults, different correct responses.
636
+ if (llmResponse.finishReason === "length") {
637
+ response.truncated = true;
638
+ ctx.logger.warn("[AI] Output truncated at token ceiling:", {
639
+ endpoint: endpoint.endpoint_name,
640
+ maxOutputTokens: ceiling.value,
641
+ tokensOutput: llmResponse.usage.completionTokens,
642
+ });
643
+ }
644
+ // Include generated media if present
645
+ if (llmResponse.generatedMedia && llmResponse.generatedMedia.length > 0) {
646
+ response.generated_media = llmResponse.generatedMedia;
647
+ }
648
+ return c.json(successResponse(response));
649
+ }
650
+ catch (error) {
651
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
652
+ const latencyMs = Date.now() - startTime;
653
+ const errorDetails = error instanceof Error &&
654
+ "details" in error &&
655
+ error.details &&
656
+ typeof error.details === "object"
657
+ ? error.details
658
+ : undefined;
659
+ // Log failed analytics and count the call: the endpoint was invoked, so it
660
+ // counts whether or not the provider answered.
661
+ await incrementCallCount(endpoint.uuid);
662
+ await db.insert(usageAnalytics).values({
663
+ endpoint_id: endpoint.uuid,
664
+ success: false,
665
+ error_message: errorMessage,
666
+ latency_ms: latencyMs,
667
+ request_metadata: errorDetails
668
+ ? {
669
+ model: debugInfo.request.model,
670
+ provider: debugInfo.provider,
671
+ error_details: errorDetails,
672
+ }
673
+ : undefined,
674
+ });
675
+ ctx.logger.error("LLM processing failed:", errorMessage, {
676
+ ...debugInfo,
677
+ errorDetails,
678
+ });
679
+ return c.json({
680
+ ...errorResponse(`LLM processing failed: ${errorMessage}`),
681
+ details: errorDetails,
682
+ }, 500);
683
+ }
684
+ }
685
+ // =============================================================================
686
+ // Route Registration
687
+ // =============================================================================
688
+ // IMPORTANT: Register /prompt routes BEFORE the main routes
689
+ // Otherwise ":endpointName" will match "prompt" as the endpoint name
690
+ // Prompt-only endpoints (new)
691
+ aiRouter.get("/:organizationPath/:projectName/:endpointName/prompt", zValidator("param", aiParamSchema), handlePromptRequest);
692
+ aiRouter.post("/:organizationPath/:projectName/:endpointName/prompt", zValidator("param", aiParamSchema), handlePromptRequest);
693
+ // Main AI execution endpoints
694
+ aiRouter.get("/:organizationPath/:projectName/:endpointName", zValidator("param", aiParamSchema), handleAIRequest);
695
+ aiRouter.post("/:organizationPath/:projectName/:endpointName", zValidator("param", aiParamSchema), handleAIRequest);
696
+ return aiRouter;
697
+ }
698
+ //# sourceMappingURL=ai.js.map