@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.
- package/CLAUDE.md +35 -0
- package/README.md +17 -0
- package/dist/context.d.ts +62 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +55 -0
- package/dist/context.js.map +1 -0
- package/dist/contracts.d.ts +110 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +7 -0
- package/dist/contracts.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api-key.d.ts +22 -0
- package/dist/lib/api-key.d.ts.map +1 -0
- package/dist/lib/api-key.js +87 -0
- package/dist/lib/api-key.js.map +1 -0
- package/dist/lib/encryption.d.ts +22 -0
- package/dist/lib/encryption.d.ts.map +1 -0
- package/dist/lib/encryption.js +41 -0
- package/dist/lib/encryption.js.map +1 -0
- package/dist/lib/entity-api-key.d.ts +21 -0
- package/dist/lib/entity-api-key.d.ts.map +1 -0
- package/dist/lib/entity-api-key.js +54 -0
- package/dist/lib/entity-api-key.js.map +1 -0
- package/dist/lib/entity-helpers.d.ts +90 -0
- package/dist/lib/entity-helpers.d.ts.map +1 -0
- package/dist/lib/entity-helpers.js +136 -0
- package/dist/lib/entity-helpers.js.map +1 -0
- package/dist/lib/money.d.ts +7 -0
- package/dist/lib/money.d.ts.map +1 -0
- package/dist/lib/money.js +9 -0
- package/dist/lib/money.js.map +1 -0
- package/dist/lib/public-project.d.ts +14 -0
- package/dist/lib/public-project.d.ts.map +1 -0
- package/dist/lib/public-project.js +17 -0
- package/dist/lib/public-project.js.map +1 -0
- package/dist/lib/user-api-key-cache.d.ts +35 -0
- package/dist/lib/user-api-key-cache.d.ts.map +1 -0
- package/dist/lib/user-api-key-cache.js +99 -0
- package/dist/lib/user-api-key-cache.js.map +1 -0
- package/dist/lib/user-api-key.d.ts +46 -0
- package/dist/lib/user-api-key.d.ts.map +1 -0
- package/dist/lib/user-api-key.js +102 -0
- package/dist/lib/user-api-key.js.map +1 -0
- package/dist/middleware/firebaseAuth.d.ts +34 -0
- package/dist/middleware/firebaseAuth.d.ts.map +1 -0
- package/dist/middleware/firebaseAuth.js +133 -0
- package/dist/middleware/firebaseAuth.js.map +1 -0
- package/dist/middleware/rateLimit.d.ts +29 -0
- package/dist/middleware/rateLimit.d.ts.map +1 -0
- package/dist/middleware/rateLimit.js +85 -0
- package/dist/middleware/rateLimit.js.map +1 -0
- package/dist/middleware/subscription.d.ts +8 -0
- package/dist/middleware/subscription.d.ts.map +1 -0
- package/dist/middleware/subscription.js +19 -0
- package/dist/middleware/subscription.js.map +1 -0
- package/dist/routes/ai.d.ts +10 -0
- package/dist/routes/ai.d.ts.map +1 -0
- package/dist/routes/ai.js +698 -0
- package/dist/routes/ai.js.map +1 -0
- package/dist/routes/analytics.d.ts +9 -0
- package/dist/routes/analytics.d.ts.map +1 -0
- package/dist/routes/analytics.js +192 -0
- package/dist/routes/analytics.js.map +1 -0
- package/dist/routes/endpoints.d.ts +10 -0
- package/dist/routes/endpoints.d.ts.map +1 -0
- package/dist/routes/endpoints.js +255 -0
- package/dist/routes/endpoints.js.map +1 -0
- package/dist/routes/entities.d.ts +15 -0
- package/dist/routes/entities.d.ts.map +1 -0
- package/dist/routes/entities.js +332 -0
- package/dist/routes/entities.js.map +1 -0
- package/dist/routes/entity-api-keys.d.ts +17 -0
- package/dist/routes/entity-api-keys.d.ts.map +1 -0
- package/dist/routes/entity-api-keys.js +142 -0
- package/dist/routes/entity-api-keys.js.map +1 -0
- package/dist/routes/invitations.d.ts +15 -0
- package/dist/routes/invitations.d.ts.map +1 -0
- package/dist/routes/invitations.js +58 -0
- package/dist/routes/invitations.js.map +1 -0
- package/dist/routes/projects.d.ts +9 -0
- package/dist/routes/projects.d.ts.map +1 -0
- package/dist/routes/projects.js +241 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/providers.d.ts +9 -0
- package/dist/routes/providers.d.ts.map +1 -0
- package/dist/routes/providers.js +89 -0
- package/dist/routes/providers.js.map +1 -0
- package/dist/routes/ratelimits.d.ts +9 -0
- package/dist/routes/ratelimits.d.ts.map +1 -0
- package/dist/routes/ratelimits.js +171 -0
- package/dist/routes/ratelimits.js.map +1 -0
- package/dist/routes/settings.d.ts +9 -0
- package/dist/routes/settings.d.ts.map +1 -0
- package/dist/routes/settings.js +138 -0
- package/dist/routes/settings.js.map +1 -0
- package/dist/routes/storage.d.ts +9 -0
- package/dist/routes/storage.d.ts.map +1 -0
- package/dist/routes/storage.js +180 -0
- package/dist/routes/storage.js.map +1 -0
- package/dist/routes/user-api-keys.d.ts +14 -0
- package/dist/routes/user-api-keys.d.ts.map +1 -0
- package/dist/routes/user-api-keys.js +237 -0
- package/dist/routes/user-api-keys.js.map +1 -0
- package/dist/routes/users.d.ts +7 -0
- package/dist/routes/users.d.ts.map +1 -0
- package/dist/routes/users.js +102 -0
- package/dist/routes/users.js.map +1 -0
- package/dist/schema/init.d.ts +12 -0
- package/dist/schema/init.d.ts.map +1 -0
- package/dist/schema/init.js +404 -0
- package/dist/schema/init.js.map +1 -0
- package/dist/schema/tables.d.ts +2110 -0
- package/dist/schema/tables.d.ts.map +1 -0
- package/dist/schema/tables.js +251 -0
- package/dist/schema/tables.js.map +1 -0
- package/dist/schemas/index.d.ts +671 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +299 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/service.d.ts +41 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +70 -0
- package/dist/service.js.map +1 -0
- 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
|