@inferencesh/sdk 0.21.0 → 0.22.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/CHANGELOG.md +47 -0
- package/dist/agent/api.d.ts +35 -1
- package/dist/agent/api.js +35 -1
- package/dist/agent/reducer.js +23 -7
- package/dist/agent/types.d.ts +29 -4
- package/dist/api/agents.d.ts +9 -3
- package/dist/api/agents.js +87 -63
- package/dist/api/api-keys.d.ts +2 -5
- package/dist/api/apps.d.ts +9 -2
- package/dist/api/apps.js +9 -1
- package/dist/api/chats.d.ts +13 -3
- package/dist/api/chats.js +13 -2
- package/dist/api/engines.d.ts +4 -2
- package/dist/api/engines.js +3 -2
- package/dist/api/flow-runs.d.ts +7 -2
- package/dist/api/flow-runs.js +7 -2
- package/dist/api/flows.d.ts +13 -2
- package/dist/api/flows.js +15 -1
- package/dist/api/knowledge.d.ts +7 -4
- package/dist/api/knowledge.js +6 -4
- package/dist/api/mcp-servers.d.ts +2 -1
- package/dist/api/projects.d.ts +5 -3
- package/dist/api/projects.js +3 -2
- package/dist/api/secrets.d.ts +2 -2
- package/dist/api/tasks.js +38 -56
- package/dist/api/teams.d.ts +4 -2
- package/dist/api/teams.js +3 -2
- package/dist/api/write-bodies.d.ts +110 -0
- package/dist/api/write-bodies.js +1 -0
- package/dist/http/client.d.ts +1 -1
- package/dist/http/client.js +2 -1
- package/dist/http/loopback.d.ts +8 -0
- package/dist/http/loopback.js +18 -0
- package/dist/http/stream-until-terminal.d.ts +55 -0
- package/dist/http/stream-until-terminal.js +88 -0
- package/dist/http/stream.d.ts +1 -0
- package/dist/http/stream.js +7 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/proxy/endpoints.json +156 -0
- package/dist/proxy/express.d.ts +5 -6
- package/dist/proxy/express.js +18 -2
- package/dist/proxy/hono.d.ts +5 -3
- package/dist/proxy/hono.js +4 -3
- package/dist/proxy/index.d.ts +165 -6
- package/dist/proxy/index.js +409 -25
- package/dist/proxy/nextjs.d.ts +29 -17
- package/dist/proxy/nextjs.js +72 -62
- package/dist/proxy/remix.d.ts +5 -3
- package/dist/proxy/remix.js +4 -3
- package/dist/proxy/svelte.d.ts +5 -4
- package/dist/proxy/svelte.js +4 -4
- package/dist/types.d.ts +423 -6
- package/dist/types.js +111 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/package.json +2 -1
package/dist/proxy/index.js
CHANGED
|
@@ -4,13 +4,26 @@
|
|
|
4
4
|
* Protects API keys by proxying requests from frontend apps through your server.
|
|
5
5
|
* Supports Next.js (App & Page Router), Express, Hono, Remix, and SvelteKit.
|
|
6
6
|
*
|
|
7
|
+
* The proxy forwards only what a frontend needs to run things (app runs,
|
|
8
|
+
* agent chats, and reading or streaming their task or chat; see
|
|
9
|
+
* PROXY_ENDPOINTS) and refuses every other API call with 403.
|
|
10
|
+
*
|
|
7
11
|
* @example Next.js App Router
|
|
8
12
|
* ```typescript
|
|
9
13
|
* // app/api/inference/proxy/route.ts
|
|
10
14
|
* import { createHandler } from "@inferencesh/sdk/proxy/nextjs";
|
|
11
|
-
* export const { GET, POST
|
|
15
|
+
* export const { GET, POST } = createHandler({
|
|
16
|
+
* allowedEndpoints: ["my-team/support-agent"],
|
|
17
|
+
* isAuthenticated: async (req) => Boolean(req.cookies.get("session")),
|
|
18
|
+
* });
|
|
12
19
|
* ```
|
|
13
20
|
*/
|
|
21
|
+
// With the .js extension: dist/proxy/index.js is the one proxy entry that
|
|
22
|
+
// loads in plain Node ESM (no bundler), and Node does not add extensions.
|
|
23
|
+
import { isHttpsOrLoopback } from "../http/loopback.js";
|
|
24
|
+
// The calls the proxy forwards, generated by go/api from its route table
|
|
25
|
+
// (`make proxy-endpoints`, copied here by `make types`). Do not edit it here.
|
|
26
|
+
import manifest from "./endpoints.json" with { type: "json" };
|
|
14
27
|
// ============================================================================
|
|
15
28
|
// Constants
|
|
16
29
|
// ============================================================================
|
|
@@ -20,11 +33,330 @@ export const INF_TARGET_HEADER = "x-inf-target-url";
|
|
|
20
33
|
export const INF_TARGET_PARAM = "__inf_target";
|
|
21
34
|
/** Default proxy route path */
|
|
22
35
|
export const PROXY_PATH = "/api/inference/proxy";
|
|
23
|
-
/**
|
|
24
|
-
const
|
|
36
|
+
/** The API the proxy forwards to unless `apiUrl` or `apiBaseUrl` says otherwise. */
|
|
37
|
+
export const DEFAULT_API_URL = "https://api.inference.sh";
|
|
38
|
+
/**
|
|
39
|
+
* Default `maxUploadBytes`: 100 MiB, well under the API's own per-file limit
|
|
40
|
+
* (storage.max_upload_bytes, 5 GiB by default).
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
25
43
|
/** Headers stripped from response (fetch auto-decompresses) */
|
|
26
44
|
const STRIP_RESPONSE_HEADERS = ["content-length", "content-encoding"];
|
|
27
45
|
// ============================================================================
|
|
46
|
+
// Endpoint policy
|
|
47
|
+
// ============================================================================
|
|
48
|
+
/**
|
|
49
|
+
* What the proxy forwards with the site's API key.
|
|
50
|
+
*
|
|
51
|
+
* A frontend needs the proxy to run things: run an app, start or continue an
|
|
52
|
+
* agent chat, and read or stream the resulting task or chat. PROXY_ENDPOINTS
|
|
53
|
+
* lists exactly the calls the SDK's own client makes for that (`run`,
|
|
54
|
+
* `agent().sendMessage`, the agent chat provider) and nothing else, so
|
|
55
|
+
* secrets, API keys, billing, account, file listings and app/agent management
|
|
56
|
+
* are never reachable through it.
|
|
57
|
+
*
|
|
58
|
+
* `allowedEndpoints` then limits which apps and agents may be run. It is
|
|
59
|
+
* checked where a request names one: a new run or chat, a chat handed to
|
|
60
|
+
* another agent, an agent's info. A request about an existing task or chat
|
|
61
|
+
* names it by id, and the proxy cannot tell which app or agent that id
|
|
62
|
+
* belongs to: the id holder may use every id call below on it.
|
|
63
|
+
*
|
|
64
|
+
* Tool approvals are not forwarded. The API takes an approval (approve,
|
|
65
|
+
* reject, always allow, resolve an interrupt, answer a question awaiting a
|
|
66
|
+
* person) only from the account holder's own sign-in, never from an API key,
|
|
67
|
+
* and the proxy signs with a key. Agents behind a proxy should not use
|
|
68
|
+
* approval-gated tools.
|
|
69
|
+
*/
|
|
70
|
+
/**
|
|
71
|
+
* Ids of tasks, chats, messages, tool calls and the like, in the shape the
|
|
72
|
+
* API mints them: a lowercase 26-character ULID (Crockford base32, no i, l,
|
|
73
|
+
* o or u). Literal routes beside an id (`/tasks/stream`, `/chats/list`, ...)
|
|
74
|
+
* never match.
|
|
75
|
+
*/
|
|
76
|
+
const ID = "[0-9a-hjkmnp-tv-z]{26}";
|
|
77
|
+
/** One segment of an agent ref in a path (namespace, name, name@version). */
|
|
78
|
+
const SEGMENT = "[A-Za-z0-9._@:-]+";
|
|
79
|
+
/** API routes under /agents/{id}/ that are not an agent's namespace/name. */
|
|
80
|
+
const AGENT_SUBROUTES = "(?!(?:card|versions|rules)$)";
|
|
81
|
+
/**
|
|
82
|
+
* The proxy's own checks, by manifest route. A route here that the manifest
|
|
83
|
+
* does not list is never forwarded (a test keeps the two in step).
|
|
84
|
+
*/
|
|
85
|
+
export const PROXY_ENDPOINT_RULES = {
|
|
86
|
+
// Apps: only the app, its input and how to follow the run: no workers,
|
|
87
|
+
// infra, session, webhook, schedule, setup or metadata from a visitor.
|
|
88
|
+
"POST /apps/run": {
|
|
89
|
+
ref: { body: "app", refuse: ["app_id", "version_id"] },
|
|
90
|
+
onlyFields: ["app", "app_id", "version_id", "function", "input", "stream", "wait"],
|
|
91
|
+
},
|
|
92
|
+
"POST /files": { onlyFields: ["files"], validate: checkUpload },
|
|
93
|
+
// Agents: the agent a chat starts with or is handed to.
|
|
94
|
+
"POST /agents/run": { ref: { body: "agent", refuse: ["agent_config"] } },
|
|
95
|
+
"POST /chats": { ref: { body: "agent", refuse: [] } },
|
|
96
|
+
"POST /chats/{id}/agent": { ref: { body: "agent", refuse: [] } },
|
|
97
|
+
"GET /agents/{namespace}/{name}": { ref: { path: true } },
|
|
98
|
+
};
|
|
99
|
+
/** A route with its placeholders unnamed ("POST /chats/{}/messages"), to compare routes. */
|
|
100
|
+
const routeKey = (route) => route.replace(/\{\w+\}/g, "{}");
|
|
101
|
+
const RULES_BY_KEY = new Map(Object.entries(PROXY_ENDPOINT_RULES).map(([route, rules]) => [routeKey(route), rules]));
|
|
102
|
+
/** The regexp for a manifest path: an agent ref's segments, ids everywhere else. */
|
|
103
|
+
function pathPattern(template) {
|
|
104
|
+
const source = template
|
|
105
|
+
.split(/(\{\w+\})/)
|
|
106
|
+
.map((part) => {
|
|
107
|
+
if (part === "{namespace}")
|
|
108
|
+
return SEGMENT;
|
|
109
|
+
if (part === "{name}")
|
|
110
|
+
return `${AGENT_SUBROUTES}${SEGMENT}`;
|
|
111
|
+
if (/^\{\w+\}$/.test(part))
|
|
112
|
+
return ID;
|
|
113
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
114
|
+
})
|
|
115
|
+
.join("");
|
|
116
|
+
return new RegExp(`^${source}$`);
|
|
117
|
+
}
|
|
118
|
+
function fromManifest(e) {
|
|
119
|
+
if (e.method !== "GET" && e.method !== "POST")
|
|
120
|
+
throw new Error(`proxy manifest: unsupported method ${e.method} ${e.path}`);
|
|
121
|
+
const route = `${e.method} ${e.path}`;
|
|
122
|
+
return {
|
|
123
|
+
method: e.method,
|
|
124
|
+
route,
|
|
125
|
+
path: pathPattern(e.path),
|
|
126
|
+
purpose: e.purpose,
|
|
127
|
+
scopes: e.scopes,
|
|
128
|
+
...RULES_BY_KEY.get(routeKey(route)),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** The API's proxy manifest as shipped in this package. */
|
|
132
|
+
export const PROXY_MANIFEST = manifest;
|
|
133
|
+
/**
|
|
134
|
+
* The calls the proxy forwards: exactly the manifest's, with the proxy's
|
|
135
|
+
* request checks. Everything else is refused with 403.
|
|
136
|
+
*/
|
|
137
|
+
export const PROXY_ENDPOINTS = PROXY_MANIFEST.endpoints.map(fromManifest);
|
|
138
|
+
/** PROXY_ENDPOINTS and what pickProxyEndpoint derived from them: the only entries `endpoints` takes. */
|
|
139
|
+
const SANCTIONED = new WeakSet(PROXY_ENDPOINTS);
|
|
140
|
+
/** The PROXY_ENDPOINTS entry for a request, or undefined when the proxy does not forward it. */
|
|
141
|
+
export function matchProxyEndpoint(method, pathname, endpoints = PROXY_ENDPOINTS) {
|
|
142
|
+
const m = method.toUpperCase();
|
|
143
|
+
return endpoints.find((e) => e.method === m && e.path.test(pathname));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The PROXY_ENDPOINTS entry for `route` ("METHOD /path", with `{id}`,
|
|
147
|
+
* `{namespace}` and `{name}` placeholders, e.g. "POST /chats/{id}/messages"),
|
|
148
|
+
* optionally narrowed: `onlyFields` must be a subset of the entry's own,
|
|
149
|
+
* and `validate` runs after the entry's own check. For the `endpoints`
|
|
150
|
+
* option. Throws when the proxy does not forward the route.
|
|
151
|
+
*/
|
|
152
|
+
export function pickProxyEndpoint(route, narrow = {}) {
|
|
153
|
+
const [method = "", template = ""] = route.split(" ");
|
|
154
|
+
const key = routeKey(`${method.toUpperCase()} ${template}`);
|
|
155
|
+
const base = PROXY_ENDPOINTS.find((e) => routeKey(e.route) === key);
|
|
156
|
+
if (!base)
|
|
157
|
+
throw new Error(`pickProxyEndpoint: "${route}" is not in PROXY_ENDPOINTS`);
|
|
158
|
+
const { onlyFields, validate } = narrow;
|
|
159
|
+
const wider = onlyFields?.find((field) => base.onlyFields && !base.onlyFields.includes(field));
|
|
160
|
+
if (wider !== undefined)
|
|
161
|
+
throw new Error(`pickProxyEndpoint: "${route}" does not take "${wider}"`);
|
|
162
|
+
const picked = {
|
|
163
|
+
...base,
|
|
164
|
+
onlyFields: onlyFields ?? base.onlyFields,
|
|
165
|
+
validate: !validate ? base.validate : (body, limits) => base.validate?.(body, limits) ?? validate(body, limits),
|
|
166
|
+
};
|
|
167
|
+
SANCTIONED.add(picked);
|
|
168
|
+
return picked;
|
|
169
|
+
}
|
|
170
|
+
/** File fields `POST /files` takes (models.PartialFile). */
|
|
171
|
+
const UPLOAD_FILE_FIELDS = ["uri", "path", "content_type", "size", "filename"];
|
|
172
|
+
/**
|
|
173
|
+
* `POST /files` through the proxy: one file, its declared size within
|
|
174
|
+
* maxUploadBytes, no category (uploads land in the default "files" category).
|
|
175
|
+
*/
|
|
176
|
+
function checkUpload(body, limits) {
|
|
177
|
+
for (const files of fieldValues(body, "files")) {
|
|
178
|
+
if (!Array.isArray(files) || files.length !== 1) {
|
|
179
|
+
return { status: 403, error: "the inference.sh proxy uploads one file per request" };
|
|
180
|
+
}
|
|
181
|
+
const file = files[0];
|
|
182
|
+
if (file === null || typeof file !== "object" || Array.isArray(file)) {
|
|
183
|
+
return { status: 400, error: "files[0] must be a JSON object" };
|
|
184
|
+
}
|
|
185
|
+
const fields = file;
|
|
186
|
+
const extra = Object.keys(fields).find((key) => !UPLOAD_FILE_FIELDS.includes(foldKey(key)));
|
|
187
|
+
if (extra !== undefined) {
|
|
188
|
+
return { status: 403, error: `"files[0].${extra}" cannot be set through the inference.sh proxy` };
|
|
189
|
+
}
|
|
190
|
+
const sizes = fieldValues(fields, "size");
|
|
191
|
+
if (sizes.length === 0)
|
|
192
|
+
return { status: 400, error: "files[0].size is required" };
|
|
193
|
+
for (const size of sizes) {
|
|
194
|
+
if (typeof size !== "number" || !Number.isInteger(size) || size <= 0) {
|
|
195
|
+
return { status: 400, error: "files[0].size must be a positive integer" };
|
|
196
|
+
}
|
|
197
|
+
if (size > limits.maxUploadBytes) {
|
|
198
|
+
return { status: 413, error: `file is larger than this proxy's upload limit (${limits.maxUploadBytes} bytes)` };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* `namespace/name` of an app or agent ref, parsed the way the API parses it
|
|
206
|
+
* (`[type/]namespace/name[@version][:function]`), or undefined when the ref
|
|
207
|
+
* names no namespace (an id, or a bare name).
|
|
208
|
+
*/
|
|
209
|
+
function refName(ref) {
|
|
210
|
+
let full = ref;
|
|
211
|
+
const colon = ref.lastIndexOf(":");
|
|
212
|
+
const at = ref.lastIndexOf("@");
|
|
213
|
+
if (colon !== -1 && (at === -1 || colon > at))
|
|
214
|
+
full = ref.slice(0, colon);
|
|
215
|
+
const versionAt = full.lastIndexOf("@");
|
|
216
|
+
const fullName = versionAt === -1 ? full : full.slice(0, versionAt);
|
|
217
|
+
const parts = fullName.split("/");
|
|
218
|
+
if (parts.length < 2)
|
|
219
|
+
return undefined;
|
|
220
|
+
let namespace = parts[0];
|
|
221
|
+
let name = parts.slice(1).join("/");
|
|
222
|
+
if (parts.length >= 3 && ["knowledge", "skill", "app", "agent"].includes(parts[0])) {
|
|
223
|
+
namespace = parts[1];
|
|
224
|
+
name = parts.slice(2).join("/");
|
|
225
|
+
}
|
|
226
|
+
if (!namespace || !name)
|
|
227
|
+
return undefined;
|
|
228
|
+
return `${namespace}/${name}`;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Compile allowedEndpoints. Globs: `*` matches within one path segment, so
|
|
232
|
+
* "ana/*" is every app and agent of the namespace "ana", at any version.
|
|
233
|
+
* With `exact`, each entry is a whole ref.
|
|
234
|
+
*/
|
|
235
|
+
function compileEndpointPatterns(patterns, exact) {
|
|
236
|
+
return patterns.map((pattern) => {
|
|
237
|
+
if (exact) {
|
|
238
|
+
if (typeof pattern !== "string" || pattern.includes("*") || refName(pattern) === undefined) {
|
|
239
|
+
throw new Error(`allowedEndpoints: "${pattern}" is not an app or agent ref (e.g. "ana/helper@v2")`);
|
|
240
|
+
}
|
|
241
|
+
return (ref) => ref === pattern;
|
|
242
|
+
}
|
|
243
|
+
if (typeof pattern !== "string" || refName(pattern) !== pattern) {
|
|
244
|
+
throw new Error(`allowedEndpoints: "${pattern}" is not a namespace/name pattern (e.g. "ana/helper" or "ana/*")`);
|
|
245
|
+
}
|
|
246
|
+
const source = pattern.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*");
|
|
247
|
+
const glob = new RegExp(`^${source}$`);
|
|
248
|
+
return (ref) => {
|
|
249
|
+
const name = refName(ref);
|
|
250
|
+
return name !== undefined && glob.test(name);
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* allowedEndpoints compiled once per options array (adapters pass the same
|
|
256
|
+
* array on every request), with a bad entry's error kept so every request
|
|
257
|
+
* answers it.
|
|
258
|
+
*/
|
|
259
|
+
const compiledPatterns = new WeakMap();
|
|
260
|
+
function endpointPatterns(patterns, exact) {
|
|
261
|
+
if (!patterns || patterns.length === 0)
|
|
262
|
+
return [];
|
|
263
|
+
let byMode = compiledPatterns.get(patterns);
|
|
264
|
+
if (!byMode)
|
|
265
|
+
compiledPatterns.set(patterns, (byMode = new Map()));
|
|
266
|
+
let compiled = byMode.get(exact);
|
|
267
|
+
if (!compiled) {
|
|
268
|
+
try {
|
|
269
|
+
compiled = compileEndpointPatterns(patterns, exact);
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
compiled = e;
|
|
273
|
+
}
|
|
274
|
+
byMode.set(exact, compiled);
|
|
275
|
+
}
|
|
276
|
+
return compiled;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Go's encoding/json matches object keys to struct fields case-insensitively
|
|
280
|
+
* (with Unicode folding: "ſ" is "s", "K" is "k"), so the API reads "Agent" or
|
|
281
|
+
* "AGENT" as "agent". Every key that folds to a field is that field.
|
|
282
|
+
*/
|
|
283
|
+
function foldKey(key) {
|
|
284
|
+
return key.toUpperCase().toLowerCase();
|
|
285
|
+
}
|
|
286
|
+
function fieldValues(body, field) {
|
|
287
|
+
return Object.keys(body).filter((key) => foldKey(key) === field).map((key) => body[key]);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Check a request against the endpoint list and allowedEndpoints. Returns a
|
|
291
|
+
* refusal, or undefined when the request may be forwarded.
|
|
292
|
+
*
|
|
293
|
+
* @param patterns compiled allowedEndpoints; empty means every app and agent.
|
|
294
|
+
* @param endpoints the calls forwarded (PROXY_ENDPOINTS or a subset).
|
|
295
|
+
*/
|
|
296
|
+
function checkProxyRequest(method, pathname, body, patterns, limits, endpoints) {
|
|
297
|
+
const m = method.toUpperCase();
|
|
298
|
+
const match = matchProxyEndpoint(m, pathname, endpoints);
|
|
299
|
+
if (!match) {
|
|
300
|
+
return {
|
|
301
|
+
status: 403,
|
|
302
|
+
error: `${m} ${pathname} is not available through the inference.sh proxy: it forwards app runs and agent chats only`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const checkRef = match.ref !== undefined && patterns.length > 0;
|
|
306
|
+
if (!checkRef && !match.onlyFields && !match.validate)
|
|
307
|
+
return undefined;
|
|
308
|
+
if (match.ref && "path" in match.ref) {
|
|
309
|
+
const ref = pathname.slice("/agents/".length);
|
|
310
|
+
return allowedRef(ref, patterns) ? undefined : notAllowed(`agent "${ref}"`);
|
|
311
|
+
}
|
|
312
|
+
let json;
|
|
313
|
+
try {
|
|
314
|
+
json = body ? JSON.parse(body) : undefined;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return { status: 400, error: "request body must be JSON" };
|
|
318
|
+
}
|
|
319
|
+
if (json === null || typeof json !== "object" || Array.isArray(json)) {
|
|
320
|
+
return { status: 400, error: "request body must be a JSON object" };
|
|
321
|
+
}
|
|
322
|
+
const fields = json;
|
|
323
|
+
if (match.onlyFields) {
|
|
324
|
+
const extra = Object.keys(fields).find((key) => !match.onlyFields.includes(foldKey(key)));
|
|
325
|
+
if (extra !== undefined) {
|
|
326
|
+
return { status: 403, error: `"${extra}" cannot be set through the inference.sh proxy` };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (match.validate) {
|
|
330
|
+
const refusal = match.validate(fields, limits);
|
|
331
|
+
if (refusal)
|
|
332
|
+
return refusal;
|
|
333
|
+
}
|
|
334
|
+
if (checkRef && match.ref && "body" in match.ref) {
|
|
335
|
+
for (const field of match.ref.refuse) {
|
|
336
|
+
if (fieldValues(fields, field).some((v) => v !== null && v !== undefined && v !== "")) {
|
|
337
|
+
return {
|
|
338
|
+
status: 403,
|
|
339
|
+
error: `"${field}" is not accepted when allowedEndpoints is set: name the app or agent by its namespace/name ref`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const refs = fieldValues(fields, match.ref.body);
|
|
344
|
+
if (refs.length === 0) {
|
|
345
|
+
return { status: 403, error: `"${match.ref.body}" is required when allowedEndpoints is set` };
|
|
346
|
+
}
|
|
347
|
+
const refused = refs.find((ref) => !allowedRef(ref, patterns));
|
|
348
|
+
if (refused !== undefined)
|
|
349
|
+
return notAllowed(`${match.ref.body} ${JSON.stringify(refused)}`);
|
|
350
|
+
}
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
function allowedRef(ref, patterns) {
|
|
354
|
+
return typeof ref === "string" && patterns.some((matches) => matches(ref));
|
|
355
|
+
}
|
|
356
|
+
function notAllowed(what) {
|
|
357
|
+
return { status: 403, error: `${what} is not in this proxy's allowedEndpoints` };
|
|
358
|
+
}
|
|
359
|
+
// ============================================================================
|
|
28
360
|
// Utilities
|
|
29
361
|
// ============================================================================
|
|
30
362
|
/** Get first value from header (may be array) */
|
|
@@ -37,14 +369,20 @@ function firstValue(value) {
|
|
|
37
369
|
function envApiKey() {
|
|
38
370
|
return process.env.INFERENCE_API_KEY;
|
|
39
371
|
}
|
|
40
|
-
/**
|
|
41
|
-
function
|
|
42
|
-
|
|
372
|
+
/** Whether a pattern matches the whole host (no partial or suffix match). */
|
|
373
|
+
function matchesWholeHost(pattern, host) {
|
|
374
|
+
const whole = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
|
|
375
|
+
const m = whole.exec(host);
|
|
376
|
+
return m !== null && m.index === 0 && m[0] === host;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The target must be the configured API's origin or a host the site listed
|
|
380
|
+
* in allowedDomains: the API key goes nowhere else.
|
|
381
|
+
*/
|
|
382
|
+
function isAllowedTarget(target, apiOrigin, extraDomains) {
|
|
383
|
+
if (target.origin === apiOrigin)
|
|
43
384
|
return true;
|
|
44
|
-
|
|
45
|
-
return extraDomains.some((pattern) => pattern.test(host));
|
|
46
|
-
}
|
|
47
|
-
return false;
|
|
385
|
+
return (extraDomains ?? []).some((pattern) => matchesWholeHost(pattern, target.host));
|
|
48
386
|
}
|
|
49
387
|
// ============================================================================
|
|
50
388
|
// Core Handler
|
|
@@ -60,6 +398,26 @@ function isAllowedDomain(host, extraDomains) {
|
|
|
60
398
|
* @returns Promise resolving to the framework's response type
|
|
61
399
|
*/
|
|
62
400
|
export async function processProxyRequest(adapter, options) {
|
|
401
|
+
// 0. Who may use the key: isAuthenticated when given, else
|
|
402
|
+
// allowUnauthorizedRequests (default true).
|
|
403
|
+
if (options?.isAuthenticated) {
|
|
404
|
+
if (!(await options.isAuthenticated(adapter.request))) {
|
|
405
|
+
return adapter.error(401, { error: "Unauthorized" });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else if (options?.allowUnauthorizedRequests === false) {
|
|
409
|
+
return adapter.error(401, {
|
|
410
|
+
error: "Unauthorized: this proxy sets allowUnauthorizedRequests: false without isAuthenticated",
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
const patterns = endpointPatterns(options?.allowedEndpoints, options?.exactRefs === true);
|
|
414
|
+
if (patterns instanceof Error) {
|
|
415
|
+
return adapter.error(500, { error: patterns.message });
|
|
416
|
+
}
|
|
417
|
+
const endpoints = options?.endpoints ?? PROXY_ENDPOINTS;
|
|
418
|
+
if (!endpoints.every((e) => SANCTIONED.has(e))) {
|
|
419
|
+
return adapter.error(500, { error: "endpoints: entries must come from PROXY_ENDPOINTS or pickProxyEndpoint" });
|
|
420
|
+
}
|
|
63
421
|
// 1. Extract target URL (header first, query param fallback for SSE)
|
|
64
422
|
let targetUrl = firstValue(adapter.header(INF_TARGET_HEADER));
|
|
65
423
|
if (!targetUrl && adapter.query) {
|
|
@@ -73,28 +431,53 @@ export async function processProxyRequest(adapter, options) {
|
|
|
73
431
|
error: `Missing ${INF_TARGET_HEADER} header or ${INF_TARGET_PARAM} query param`,
|
|
74
432
|
});
|
|
75
433
|
}
|
|
76
|
-
// 1b.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const parsed = new URL(targetUrl);
|
|
80
|
-
const override = new URL(overrideBase);
|
|
81
|
-
parsed.protocol = override.protocol;
|
|
82
|
-
parsed.host = override.host;
|
|
83
|
-
targetUrl = parsed.toString();
|
|
84
|
-
}
|
|
85
|
-
// 2. Validate target domain
|
|
86
|
-
let host;
|
|
434
|
+
// 1b. Parse, and rewrite the base URL if apiBaseUrl / INFERENCE_API_BASE_URL is set
|
|
435
|
+
let target;
|
|
436
|
+
let apiOrigin;
|
|
87
437
|
try {
|
|
88
|
-
|
|
438
|
+
target = new URL(targetUrl);
|
|
439
|
+
const overrideBase = options?.apiBaseUrl || process.env.INFERENCE_API_BASE_URL;
|
|
440
|
+
if (overrideBase) {
|
|
441
|
+
const override = new URL(overrideBase);
|
|
442
|
+
target.protocol = override.protocol;
|
|
443
|
+
target.host = override.host;
|
|
444
|
+
apiOrigin = override.origin;
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
apiOrigin = new URL(options?.apiUrl || DEFAULT_API_URL).origin;
|
|
448
|
+
}
|
|
89
449
|
}
|
|
90
450
|
catch {
|
|
91
451
|
return adapter.error(400, { error: "Invalid target URL" });
|
|
92
452
|
}
|
|
93
|
-
if (
|
|
453
|
+
if (target.username || target.password) {
|
|
454
|
+
return adapter.error(400, { error: "Target URL must not carry credentials" });
|
|
455
|
+
}
|
|
456
|
+
targetUrl = target.toString();
|
|
457
|
+
// 2. Validate the target: https only (the API key rides on this request),
|
|
458
|
+
// and the configured API's origin or an explicitly allowed host.
|
|
459
|
+
if (!isHttpsOrLoopback(target)) {
|
|
94
460
|
return adapter.error(412, {
|
|
95
|
-
error: `Target must
|
|
461
|
+
error: `Target must use https, got: ${target.protocol}`,
|
|
96
462
|
});
|
|
97
463
|
}
|
|
464
|
+
if (!isAllowedTarget(target, apiOrigin, options?.allowedDomains)) {
|
|
465
|
+
return adapter.error(412, {
|
|
466
|
+
error: `Target must be the API at ${apiOrigin}, got: ${target.host}`,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
const maxUploadBytes = options?.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
470
|
+
if (!(Number.isInteger(maxUploadBytes) && maxUploadBytes > 0)) {
|
|
471
|
+
return adapter.error(500, { error: "maxUploadBytes must be a positive integer" });
|
|
472
|
+
}
|
|
473
|
+
// 2b. Only the calls a frontend needs to run apps and agents, and only
|
|
474
|
+
// the allowed apps and agents. The body is read once, here, and the same
|
|
475
|
+
// string is what goes upstream.
|
|
476
|
+
const body = adapter.method.toUpperCase() === "GET" ? undefined : await adapter.body();
|
|
477
|
+
const refusal = checkProxyRequest(adapter.method, target.pathname, body, patterns, { maxUploadBytes }, endpoints);
|
|
478
|
+
if (refusal) {
|
|
479
|
+
return adapter.error(refusal.status, { error: refusal.error });
|
|
480
|
+
}
|
|
98
481
|
// 3. Resolve API key
|
|
99
482
|
const apiKey = options?.apiKey
|
|
100
483
|
?? (adapter.apiKey ? await adapter.apiKey() : undefined)
|
|
@@ -138,7 +521,8 @@ export async function processProxyRequest(adapter, options) {
|
|
|
138
521
|
"user-agent": userAgent || proxyId,
|
|
139
522
|
"x-inf-proxy": proxyId,
|
|
140
523
|
},
|
|
141
|
-
body
|
|
524
|
+
body,
|
|
525
|
+
signal: adapter.signal,
|
|
142
526
|
});
|
|
143
527
|
// 7. Forward response headers (strip compression headers since fetch decompresses)
|
|
144
528
|
response.headers.forEach((value, key) => {
|
package/dist/proxy/nextjs.d.ts
CHANGED
|
@@ -5,17 +5,19 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @example App Router (app/api/inference/proxy/route.ts)
|
|
7
7
|
* ```typescript
|
|
8
|
-
* import {
|
|
9
|
-
* export const { GET, POST
|
|
8
|
+
* import { createHandler } from "@inferencesh/sdk/proxy/nextjs";
|
|
9
|
+
* export const { GET, POST } = createHandler({ allowedEndpoints: ["my-team/*"] });
|
|
10
10
|
* ```
|
|
11
11
|
*
|
|
12
12
|
* @example Page Router (pages/api/inference/proxy.ts)
|
|
13
13
|
* ```typescript
|
|
14
|
-
*
|
|
14
|
+
* import { createPageHandler } from "@inferencesh/sdk/proxy/nextjs";
|
|
15
|
+
* export default createPageHandler({ allowedEndpoints: ["my-team/*"] });
|
|
15
16
|
* ```
|
|
16
17
|
*/
|
|
17
18
|
import { type NextRequest } from "next/server";
|
|
18
|
-
import type { NextApiHandler } from "next/types";
|
|
19
|
+
import type { NextApiHandler, NextApiRequest } from "next/types";
|
|
20
|
+
import { type ProxyOptions } from "./index";
|
|
19
21
|
/** Default proxy route path */
|
|
20
22
|
export declare const PROXY_ROUTE = "/api/inference/proxy";
|
|
21
23
|
/**
|
|
@@ -27,37 +29,47 @@ export declare const PROXY_ROUTE = "/api/inference/proxy";
|
|
|
27
29
|
* @example
|
|
28
30
|
* ```typescript
|
|
29
31
|
* // pages/api/inference/proxy.ts
|
|
30
|
-
*
|
|
32
|
+
* import { createPageHandler } from "@inferencesh/sdk/proxy/nextjs";
|
|
33
|
+
* export default createPageHandler({ allowedEndpoints: ["my-team/*"] });
|
|
31
34
|
* ```
|
|
32
35
|
*/
|
|
36
|
+
export declare function createPageHandler(options?: ProxyOptions<NextApiRequest>): NextApiHandler;
|
|
37
|
+
/** Page Router handler with the default options. */
|
|
33
38
|
export declare const pageHandler: NextApiHandler;
|
|
34
39
|
/**
|
|
35
|
-
* Create
|
|
40
|
+
* Create App Router route handlers for the Inference.sh proxy.
|
|
36
41
|
* Supports full streaming passthrough for SSE responses.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* // app/api/inference/proxy/route.ts
|
|
46
|
+
* import { createHandler } from "@inferencesh/sdk/proxy/nextjs";
|
|
47
|
+
* export const { GET, POST } = createHandler({ allowedEndpoints: ["my-team/*"] });
|
|
48
|
+
* ```
|
|
37
49
|
*/
|
|
38
|
-
declare function
|
|
50
|
+
export declare function createHandler(options?: ProxyOptions<NextRequest>): {
|
|
51
|
+
GET: (request: NextRequest) => Promise<Response>;
|
|
52
|
+
POST: (request: NextRequest) => Promise<Response>;
|
|
53
|
+
};
|
|
39
54
|
/**
|
|
40
|
-
* App Router route handlers.
|
|
55
|
+
* App Router route handlers with the default options.
|
|
41
56
|
*
|
|
42
57
|
* @example
|
|
43
58
|
* ```typescript
|
|
44
59
|
* // app/api/inference/proxy/route.ts
|
|
45
60
|
* import { handlers } from "@inferencesh/sdk/proxy/nextjs";
|
|
46
|
-
* export const { GET, POST
|
|
61
|
+
* export const { GET, POST } = handlers;
|
|
47
62
|
* ```
|
|
48
63
|
*/
|
|
49
64
|
export declare const handlers: {
|
|
50
|
-
GET:
|
|
51
|
-
POST:
|
|
52
|
-
PUT: typeof appHandler;
|
|
65
|
+
GET: (request: NextRequest) => Promise<Response>;
|
|
66
|
+
POST: (request: NextRequest) => Promise<Response>;
|
|
53
67
|
};
|
|
54
68
|
/** @deprecated Use pageHandler */
|
|
55
69
|
export declare const handler: NextApiHandler;
|
|
56
70
|
/** @deprecated Use handlers */
|
|
57
71
|
export declare const route: {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
PUT: typeof appHandler;
|
|
72
|
+
GET: (request: NextRequest) => Promise<Response>;
|
|
73
|
+
POST: (request: NextRequest) => Promise<Response>;
|
|
74
|
+
handler: (request: NextRequest) => Promise<Response>;
|
|
62
75
|
};
|
|
63
|
-
export {};
|