@juspay/neurolink 13.8.10 → 13.8.12
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 +2 -3
- package/dist/browser/neurolink.min.js +412 -412
- package/dist/cli/commands/proxy.js +16 -1
- package/dist/factories/providerDescriptors.js +22 -10
- package/dist/models/manifests/cloudflareClef.d.ts +4 -2
- package/dist/models/manifests/cloudflareClef.js +5 -3
- package/dist/providers/cloudflareClef.d.ts +9 -0
- package/dist/providers/cloudflareClef.js +52 -1
- package/dist/providers/systemOneDecision.d.ts +6 -0
- package/dist/providers/systemOneDecision.js +9 -1
- package/dist/proxy/requestBodyLimit.d.ts +60 -0
- package/dist/proxy/requestBodyLimit.js +97 -0
- package/dist/server/adapters/honoAdapter.d.ts +13 -0
- package/dist/server/adapters/honoAdapter.js +73 -83
- package/dist/server/utils/rawBody.d.ts +17 -0
- package/dist/server/utils/rawBody.js +69 -4
- package/docs-site/static/search-index.json +10 -7
- package/package.json +11 -4
|
@@ -13,6 +13,8 @@ import { ensureTelemetry, getLangfuseHealthStatus, } from "../../services/server
|
|
|
13
13
|
import { logger } from "../../utils/logger.js";
|
|
14
14
|
import { applyAllClients, applyClientsOnProxyStart, restoreAllClients, } from "../proxy-clients/registry.js";
|
|
15
15
|
import { resolveProxyConfigPath } from "../../proxy/proxyConfig.js";
|
|
16
|
+
import { readProxyRequestText } from "../../proxy/requestBodyLimit.js";
|
|
17
|
+
import { RawBodyTooLargeError } from "../../server/utils/rawBody.js";
|
|
16
18
|
import { redactUrlsInText, sanitizeForLog, } from "../../utils/logSanitize.js";
|
|
17
19
|
import { raceWithAbort, withTimeout, } from "../../utils/async/withTimeout.js";
|
|
18
20
|
import { startProxyHttpTrace } from "../../proxy/proxyTracer.js";
|
|
@@ -1511,6 +1513,13 @@ export async function createProxyStartApp(params) {
|
|
|
1511
1513
|
}),
|
|
1512
1514
|
]);
|
|
1513
1515
|
};
|
|
1516
|
+
/** 413 in the wire format this proxy answers its other request errors in. */
|
|
1517
|
+
const respondRequestTooLarge = async (c, metadata, message) => {
|
|
1518
|
+
if (metadata) {
|
|
1519
|
+
await recordRuntimeError(metadata, 413, "request_too_large", message);
|
|
1520
|
+
}
|
|
1521
|
+
return c.json({ type: "error", error: { type: "request_too_large", message } }, 413);
|
|
1522
|
+
};
|
|
1514
1523
|
app.onError(async (err, c) => {
|
|
1515
1524
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1516
1525
|
logger.always(`[proxy] unhandled error: ${errMsg}`);
|
|
@@ -1703,7 +1712,13 @@ export async function createProxyStartApp(params) {
|
|
|
1703
1712
|
let body;
|
|
1704
1713
|
let rawBody;
|
|
1705
1714
|
if (method === "post") {
|
|
1706
|
-
|
|
1715
|
+
// Bounded before it is parsed, and so before the share gate below
|
|
1716
|
+
// (which needs the model), the way the server adapters bound theirs.
|
|
1717
|
+
const read = await readProxyRequestText(c.req.raw, route.path);
|
|
1718
|
+
if (read instanceof RawBodyTooLargeError) {
|
|
1719
|
+
return respondRequestTooLarge(c, metadata, read.message);
|
|
1720
|
+
}
|
|
1721
|
+
rawBody = read;
|
|
1707
1722
|
try {
|
|
1708
1723
|
body = rawBody ? JSON.parse(rawBody) : emptyBody;
|
|
1709
1724
|
}
|
|
@@ -619,16 +619,28 @@ const HAND_DESCRIPTORS = [
|
|
|
619
619
|
astralTokensPerChar: 3,
|
|
620
620
|
// Images only. Exactly four tiny images succeeded and five were refused
|
|
621
621
|
// on both models. image/jpg also succeeded and is normalized to JPEG.
|
|
622
|
-
//
|
|
623
|
-
// both models
|
|
624
|
-
// 413/code 5021
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
622
|
+
// The 256,000-byte cap on the whole encoded body is NeuroLink's own and is
|
|
623
|
+
// the same for both models. It is well below what the service takes with
|
|
624
|
+
// images, since the 413/code 5021 gate does not count them (2026-10-24:
|
|
625
|
+
// bodies of up to 1.17 MB, images included, passed it on clef-flash). It
|
|
626
|
+
// is not below clef-flash's edge for the text: the estimate is the
|
|
627
|
+
// characters of the JSON without `images`, over four, and the refusal is
|
|
628
|
+
// past twice the window the message prints (24,576 for clef-flash, so
|
|
629
|
+
// 196,608 characters; 65,536 for clef, so 524,288). That is checked on the
|
|
630
|
+
// text alone, per model, in cloudflareClef.ts, 1,024 characters short of
|
|
631
|
+
// the edge. A text that large is out of reach of the state limit below
|
|
632
|
+
// except through the questions or control characters, which JSON writes
|
|
633
|
+
// as up to six characters each, so the margin costs a caller nothing.
|
|
634
|
+
// History: on 2026-10-04 both models accepted 520,000 text characters and
|
|
635
|
+
// refused 525,000 with 413/code 5021 (the window was then 65,536 for both).
|
|
636
|
+
// On 2026-10-03, clef-flash accepted 262,000 and refused 270,000. The
|
|
637
|
+
// threshold had moved from 65,527 accepted / 67,527 refused to 130,026 /
|
|
638
|
+
// 131,276 (clef) and 130,027 / 131,277 (flash) in a day, and the window
|
|
639
|
+
// of clef-flash has since fallen to 24,576 (by 2026-10-20), which moved
|
|
640
|
+
// its edge to 196,608. On 2026-10-05 both models accepted PNG bodies up
|
|
641
|
+
// to 492,485 bytes and refused 532,505 (flash) and 532,499 (clef) with the
|
|
642
|
+
// same 413, whose estimate (133,126) was the whole body over four, images
|
|
643
|
+
// included, which they no longer are. The old 195/202 KB boundary is
|
|
632
644
|
// historical. See the guide for exact model and date coverage.
|
|
633
645
|
media: { maxImages: 4, video: false, maxRequestBytes: 256_000 },
|
|
634
646
|
},
|
|
@@ -3,8 +3,10 @@ import type { ProviderModelManifest } from "../../types/index.js";
|
|
|
3
3
|
* Cloudflare Clef on Workers AI — the `decide` inference type, not text
|
|
4
4
|
* generation, and not the Workers AI text models of the `cloudflare` provider.
|
|
5
5
|
*
|
|
6
|
-
* `contextWindow` is the
|
|
7
|
-
*
|
|
6
|
+
* `contextWindow` is the window Cloudflare's own error messages print: 65,536
|
|
7
|
+
* tokens for `clef` (the figure its model page documents) and 24,576 for
|
|
8
|
+
* `clef-flash`, which printed 65,536 on 2026-10-04 and 24,576 by 2026-10-20.
|
|
9
|
+
* The Workers AI endpoint ignored state text past about 2,048 tokens until
|
|
8
10
|
* 2026-10-04 and read far more on 2026-10-07 (hosted service or model:
|
|
9
11
|
* unknown), so NeuroLink enforces the limit on the descriptor's
|
|
10
12
|
* `decisionLimits`, not this. `vision` describes `generate()`
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* Cloudflare Clef on Workers AI — the `decide` inference type, not text
|
|
3
3
|
* generation, and not the Workers AI text models of the `cloudflare` provider.
|
|
4
4
|
*
|
|
5
|
-
* `contextWindow` is the
|
|
6
|
-
*
|
|
5
|
+
* `contextWindow` is the window Cloudflare's own error messages print: 65,536
|
|
6
|
+
* tokens for `clef` (the figure its model page documents) and 24,576 for
|
|
7
|
+
* `clef-flash`, which printed 65,536 on 2026-10-04 and 24,576 by 2026-10-20.
|
|
8
|
+
* The Workers AI endpoint ignored state text past about 2,048 tokens until
|
|
7
9
|
* 2026-10-04 and read far more on 2026-10-07 (hosted service or model:
|
|
8
10
|
* unknown), so NeuroLink enforces the limit on the descriptor's
|
|
9
11
|
* `decisionLimits`, not this. `vision` describes `generate()`
|
|
@@ -34,7 +36,7 @@ export const cloudflareClefManifest = {
|
|
|
34
36
|
"clef-flash": {
|
|
35
37
|
aliases: ["@cf/cloudflare/clef-flash"],
|
|
36
38
|
displayName: "Cloudflare Clef-flash (9B)",
|
|
37
|
-
contextWindow:
|
|
39
|
+
contextWindow: 24_576,
|
|
38
40
|
maxOutputTokens: 256,
|
|
39
41
|
vision: false,
|
|
40
42
|
functionCalling: false,
|
|
@@ -41,6 +41,15 @@ export declare class CloudflareClefProvider extends SystemOneDecisionProvider {
|
|
|
41
41
|
* differs from the path.
|
|
42
42
|
*/
|
|
43
43
|
protected buildDecisionBody(state: DecisionState, questions: Record<string, Record<string, unknown>>, model: string, media?: DecisionPreparedMedia): Record<string, unknown>;
|
|
44
|
+
/**
|
|
45
|
+
* Cloudflare's 413/code 5021 gate, applied before the request is sent: the
|
|
46
|
+
* text of the body, without its images, may not pass eight characters per
|
|
47
|
+
* token of the model's window, less a margin (see `TEXT_GATE_WINDOW_TOKENS`).
|
|
48
|
+
* Refused as `max_tokens_exceeded`, which is how Cloudflare's own 413 is
|
|
49
|
+
* classified, so a caller sees the same kind whether the refusal is made here
|
|
50
|
+
* or there.
|
|
51
|
+
*/
|
|
52
|
+
protected assertWithinVendorLimits(payload: Record<string, unknown>, model: string): void;
|
|
44
53
|
/** A success is `{ result: { model, answers, usage }, success: true }`. */
|
|
45
54
|
protected readDecisionPayload(payload: unknown): unknown;
|
|
46
55
|
protected parseDecisionError(status: number, payload: unknown, requestId: string | undefined): DecisionError;
|
|
@@ -15,6 +15,32 @@ const MODEL_PREFIX = "@cf/cloudflare/";
|
|
|
15
15
|
* is merely large and retry it into the same wait.
|
|
16
16
|
*/
|
|
17
17
|
const TIMEOUT_MS_PER_THOUSAND_STATE_TOKENS = 250;
|
|
18
|
+
/**
|
|
19
|
+
* The context window each model's 413/code 5021 message prints. Cloudflare
|
|
20
|
+
* refuses a request with that code when its estimate passes twice the window,
|
|
21
|
+
* and the estimate is the characters of the request's JSON text, without its
|
|
22
|
+
* `images` array, over four: the images are not counted. Measured to the
|
|
23
|
+
* character on 2026-10-23 and 2026-10-24: clef-flash accepted a text of 196,609
|
|
24
|
+
* characters and refused 196,610 (twice 24,576, times four, is 196,608), clef
|
|
25
|
+
* accepted 524,288 and refused 524,292, with and without images of up to
|
|
26
|
+
* 1.1 MB in the body. The window of clef-flash fell from 65,536 (2026-10-04) to
|
|
27
|
+
* 24,576 (by 2026-10-20), so these are measurements to re-check, not constants
|
|
28
|
+
* of the model. A model missing from the map has no limit of this
|
|
29
|
+
* kind here: the service answers for it.
|
|
30
|
+
*/
|
|
31
|
+
const TEXT_GATE_WINDOW_TOKENS = new Map([
|
|
32
|
+
["clef", 65_536],
|
|
33
|
+
["clef-flash", 24_576],
|
|
34
|
+
]);
|
|
35
|
+
/** The estimate is characters / 4 and the refusal is past twice the window. */
|
|
36
|
+
const TEXT_GATE_CHARACTERS_PER_WINDOW_TOKEN = 8;
|
|
37
|
+
/**
|
|
38
|
+
* Kept below the measured edge. The edge is exact for the bodies that were
|
|
39
|
+
* probed (one question, an ASCII state, `JSON.stringify`), so the margin is for
|
|
40
|
+
* what they did not vary, and it is 0.5% of the smaller cap. See the
|
|
41
|
+
* descriptor for why it costs a caller nothing.
|
|
42
|
+
*/
|
|
43
|
+
const TEXT_GATE_MARGIN_CHARACTERS = 1_024;
|
|
18
44
|
/**
|
|
19
45
|
* Cloudflare account ids are 32 hex digits. The pattern is wider so that a
|
|
20
46
|
* change of format needs no release, but it still keeps `/`, `.`, `?` and
|
|
@@ -320,6 +346,31 @@ export class CloudflareClefProvider extends SystemOneDecisionProvider {
|
|
|
320
346
|
...(images.length > 0 ? { images } : {}),
|
|
321
347
|
};
|
|
322
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Cloudflare's 413/code 5021 gate, applied before the request is sent: the
|
|
351
|
+
* text of the body, without its images, may not pass eight characters per
|
|
352
|
+
* token of the model's window, less a margin (see `TEXT_GATE_WINDOW_TOKENS`).
|
|
353
|
+
* Refused as `max_tokens_exceeded`, which is how Cloudflare's own 413 is
|
|
354
|
+
* classified, so a caller sees the same kind whether the refusal is made here
|
|
355
|
+
* or there.
|
|
356
|
+
*/
|
|
357
|
+
assertWithinVendorLimits(payload, model) {
|
|
358
|
+
const wire = wireModel(model);
|
|
359
|
+
const window = wire === undefined ? undefined : TEXT_GATE_WINDOW_TOKENS.get(wire);
|
|
360
|
+
if (window === undefined) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const limit = window * TEXT_GATE_CHARACTERS_PER_WINDOW_TOKEN -
|
|
364
|
+
TEXT_GATE_MARGIN_CHARACTERS;
|
|
365
|
+
const characters = JSON.stringify({ ...payload, images: undefined }).length;
|
|
366
|
+
if (characters > limit) {
|
|
367
|
+
throw this.decisionError({
|
|
368
|
+
kind: "max_tokens_exceeded",
|
|
369
|
+
message: `The request's text, without its images, is ${characters} characters; ${this.vendorLabel()}'s "${wire}" model accepts at most ${limit}. Shorten the state or ask shorter questions.`,
|
|
370
|
+
retryable: false,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
323
374
|
/** A success is `{ result: { model, answers, usage }, success: true }`. */
|
|
324
375
|
readDecisionPayload(payload) {
|
|
325
376
|
return isRecord(payload) && isRecord(payload.result)
|
|
@@ -335,7 +386,7 @@ export class CloudflareClefProvider extends SystemOneDecisionProvider {
|
|
|
335
386
|
.slice(0, MAX_ERROR_MESSAGE_CHARS) || fallback;
|
|
336
387
|
if (kind === "max_tokens_exceeded") {
|
|
337
388
|
message +=
|
|
338
|
-
" Cloudflare counts the
|
|
389
|
+
" Cloudflare counts the request's text, without its images, at about four characters per token.";
|
|
339
390
|
}
|
|
340
391
|
else if (parsed?.code === 7000) {
|
|
341
392
|
message += `. Check the model name (Clef is served as "clef" and "clef-flash") and the base URL, which must end in /client/v4 and name your account.`;
|
|
@@ -71,6 +71,12 @@ export declare abstract class SystemOneDecisionProvider extends BaseProvider {
|
|
|
71
71
|
protected abstract decisionEndpoint(model: string): string;
|
|
72
72
|
protected abstract decisionHeaders(): Record<string, string>;
|
|
73
73
|
protected abstract buildDecisionBody(state: DecisionState, questions: Record<string, Record<string, unknown>>, model: string, media?: DecisionPreparedMedia): Record<string, unknown>;
|
|
74
|
+
/**
|
|
75
|
+
* A refusal the vendor's own gate would make on the request about to be
|
|
76
|
+
* sent, which the descriptor's limits cannot express. Throw the refusal
|
|
77
|
+
* (see `decisionError`); the default has no such gate.
|
|
78
|
+
*/
|
|
79
|
+
protected assertWithinVendorLimits(_payload: Record<string, unknown>, _model: string): void;
|
|
74
80
|
protected abstract parseDecisionError(status: number, payload: unknown, requestId: string | undefined): DecisionError;
|
|
75
81
|
protected abstract readRequestId(headers: Headers): string | undefined;
|
|
76
82
|
/**
|
|
@@ -141,6 +141,12 @@ export class SystemOneDecisionProvider extends BaseProvider {
|
|
|
141
141
|
missingConfigMessage() {
|
|
142
142
|
return undefined;
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* A refusal the vendor's own gate would make on the request about to be
|
|
146
|
+
* sent, which the descriptor's limits cannot express. Throw the refusal
|
|
147
|
+
* (see `decisionError`); the default has no such gate.
|
|
148
|
+
*/
|
|
149
|
+
assertWithinVendorLimits(_payload, _model) { }
|
|
144
150
|
/**
|
|
145
151
|
* The System One wire calls the yes/no primitive `noul`; this codebase and
|
|
146
152
|
* every SDK exposing it call it `boolean`.
|
|
@@ -287,8 +293,10 @@ export class SystemOneDecisionProvider extends BaseProvider {
|
|
|
287
293
|
this.encodeQuestion(question),
|
|
288
294
|
]));
|
|
289
295
|
const media = await this.prepareMedia(request);
|
|
290
|
-
const
|
|
296
|
+
const payload = this.buildDecisionBody(request.state, wireQuestions, resolvedModel, media);
|
|
297
|
+
const body = JSON.stringify(payload);
|
|
291
298
|
this.assertWithinRequestBytes(body);
|
|
299
|
+
this.assertWithinVendorLimits(payload, resolvedModel);
|
|
292
300
|
const timeoutMs = request.timeoutMs ??
|
|
293
301
|
this.defaultTimeoutMs(questionEntries.length, stateTokens) ??
|
|
294
302
|
this.defaultTimeout ??
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-body ceiling for the proxy's POST routes.
|
|
3
|
+
*
|
|
4
|
+
* The body is read before anything else about the request is known (the model,
|
|
5
|
+
* the share token), so it is bounded first, chunk by chunk, the way the server
|
|
6
|
+
* adapters bound theirs. Each route gets the request size limit its API
|
|
7
|
+
* documents; one environment variable overrides all of them.
|
|
8
|
+
*
|
|
9
|
+
* @module proxy/requestBodyLimit
|
|
10
|
+
*/
|
|
11
|
+
import { RawBodyTooLargeError } from "../server/utils/rawBody.js";
|
|
12
|
+
/**
|
|
13
|
+
* Environment variable that sets the ceiling for every POST route, replacing
|
|
14
|
+
* the per-route defaults below: a byte count, or `64mb` / `512kb` / `1gb`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const PROXY_MAX_REQUEST_BODY_ENV = "NEUROLINK_PROXY_MAX_REQUEST_BODY_BYTES";
|
|
17
|
+
/**
|
|
18
|
+
* 32 MiB. The Claude Messages API and Token Counting API document a 32 MB
|
|
19
|
+
* maximum request size and answer a larger request with 413 `request_too_large`:
|
|
20
|
+
* https://platform.claude.com/docs/en/api/errors#request-size-limits
|
|
21
|
+
*
|
|
22
|
+
* Also the limit for a POST route with no upstream API of its own (the peer
|
|
23
|
+
* endpoints), which has nothing documented to follow.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_PROXY_MAX_REQUEST_BODY_BYTES: number;
|
|
26
|
+
/**
|
|
27
|
+
* 100 MiB. The Gemini API documents inline data as "100 MB per request or
|
|
28
|
+
* payload (50 MB for PDFs)":
|
|
29
|
+
* https://ai.google.dev/gemini-api/docs/file-input-methods
|
|
30
|
+
*/
|
|
31
|
+
export declare const GEMINI_PROXY_MAX_REQUEST_BODY_BYTES: number;
|
|
32
|
+
/**
|
|
33
|
+
* 64 MiB. A local cap, not an OpenAI figure: OpenAI documents no general
|
|
34
|
+
* request-body limit for the Responses or Chat Completions APIs. The only
|
|
35
|
+
* per-request payload figure it states is the image input requirement, "Up to
|
|
36
|
+
* 512 MB total payload per request":
|
|
37
|
+
* https://developers.openai.com/api/docs/guides/images-vision
|
|
38
|
+
* (file inputs are separately limited to 50 MB combined:
|
|
39
|
+
* https://developers.openai.com/api/docs/guides/file-inputs). The proxy holds
|
|
40
|
+
* a body in memory while it reads it, about four times its size, so it does
|
|
41
|
+
* not take the 512 MB figure as its ceiling; a client that sends more raises
|
|
42
|
+
* `NEUROLINK_PROXY_MAX_REQUEST_BODY_BYTES`. The Codex route speaks the
|
|
43
|
+
* Responses format and documents nothing of its own, so it uses the same cap.
|
|
44
|
+
*/
|
|
45
|
+
export declare const OPENAI_PROXY_MAX_REQUEST_BODY_BYTES: number;
|
|
46
|
+
/**
|
|
47
|
+
* The ceiling in bytes for one route: the environment override when it is a
|
|
48
|
+
* positive size, else that route's documented limit.
|
|
49
|
+
*
|
|
50
|
+
* @param routePath - The route as registered, for example `/v1/messages`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveProxyMaxRequestBodyBytes(routePath: string, env?: Record<string, string | undefined>): number;
|
|
53
|
+
/**
|
|
54
|
+
* The request body as text, `undefined` when it cannot be read (a client that
|
|
55
|
+
* hung up mid-body), or the error when it is over the route's ceiling. Over the
|
|
56
|
+
* ceiling the stream is cancelled, so nothing past it is buffered.
|
|
57
|
+
*
|
|
58
|
+
* @param routePath - The route as registered, for example `/v1/messages`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function readProxyRequestText(request: Request, routePath: string, env?: Record<string, string | undefined>): Promise<string | undefined | RawBodyTooLargeError>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-body ceiling for the proxy's POST routes.
|
|
3
|
+
*
|
|
4
|
+
* The body is read before anything else about the request is known (the model,
|
|
5
|
+
* the share token), so it is bounded first, chunk by chunk, the way the server
|
|
6
|
+
* adapters bound theirs. Each route gets the request size limit its API
|
|
7
|
+
* documents; one environment variable overrides all of them.
|
|
8
|
+
*
|
|
9
|
+
* @module proxy/requestBodyLimit
|
|
10
|
+
*/
|
|
11
|
+
import { RawBodyTooLargeError, readBoundedRequestBody, tryParseByteLimit, } from "../server/utils/rawBody.js";
|
|
12
|
+
import { logger } from "../utils/logger.js";
|
|
13
|
+
/**
|
|
14
|
+
* Environment variable that sets the ceiling for every POST route, replacing
|
|
15
|
+
* the per-route defaults below: a byte count, or `64mb` / `512kb` / `1gb`.
|
|
16
|
+
*/
|
|
17
|
+
export const PROXY_MAX_REQUEST_BODY_ENV = "NEUROLINK_PROXY_MAX_REQUEST_BODY_BYTES";
|
|
18
|
+
const MIB = 1024 * 1024;
|
|
19
|
+
/**
|
|
20
|
+
* 32 MiB. The Claude Messages API and Token Counting API document a 32 MB
|
|
21
|
+
* maximum request size and answer a larger request with 413 `request_too_large`:
|
|
22
|
+
* https://platform.claude.com/docs/en/api/errors#request-size-limits
|
|
23
|
+
*
|
|
24
|
+
* Also the limit for a POST route with no upstream API of its own (the peer
|
|
25
|
+
* endpoints), which has nothing documented to follow.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_PROXY_MAX_REQUEST_BODY_BYTES = 32 * MIB;
|
|
28
|
+
/**
|
|
29
|
+
* 100 MiB. The Gemini API documents inline data as "100 MB per request or
|
|
30
|
+
* payload (50 MB for PDFs)":
|
|
31
|
+
* https://ai.google.dev/gemini-api/docs/file-input-methods
|
|
32
|
+
*/
|
|
33
|
+
export const GEMINI_PROXY_MAX_REQUEST_BODY_BYTES = 100 * MIB;
|
|
34
|
+
/**
|
|
35
|
+
* 64 MiB. A local cap, not an OpenAI figure: OpenAI documents no general
|
|
36
|
+
* request-body limit for the Responses or Chat Completions APIs. The only
|
|
37
|
+
* per-request payload figure it states is the image input requirement, "Up to
|
|
38
|
+
* 512 MB total payload per request":
|
|
39
|
+
* https://developers.openai.com/api/docs/guides/images-vision
|
|
40
|
+
* (file inputs are separately limited to 50 MB combined:
|
|
41
|
+
* https://developers.openai.com/api/docs/guides/file-inputs). The proxy holds
|
|
42
|
+
* a body in memory while it reads it, about four times its size, so it does
|
|
43
|
+
* not take the 512 MB figure as its ceiling; a client that sends more raises
|
|
44
|
+
* `NEUROLINK_PROXY_MAX_REQUEST_BODY_BYTES`. The Codex route speaks the
|
|
45
|
+
* Responses format and documents nothing of its own, so it uses the same cap.
|
|
46
|
+
*/
|
|
47
|
+
export const OPENAI_PROXY_MAX_REQUEST_BODY_BYTES = 64 * MIB;
|
|
48
|
+
/**
|
|
49
|
+
* Registered route path (a suffix, so a base path does not matter) to limit.
|
|
50
|
+
* The first match wins; no match means the default above.
|
|
51
|
+
*/
|
|
52
|
+
const ROUTE_LIMITS = [
|
|
53
|
+
[/\/v1\/messages(?:\/count_tokens)?$/, DEFAULT_PROXY_MAX_REQUEST_BODY_BYTES],
|
|
54
|
+
[/\/v1\/chat\/completions$/, OPENAI_PROXY_MAX_REQUEST_BODY_BYTES],
|
|
55
|
+
[/\/backend-api\/codex\/responses$/, OPENAI_PROXY_MAX_REQUEST_BODY_BYTES],
|
|
56
|
+
[/\/v1beta\/models\/[^/]+$/, GEMINI_PROXY_MAX_REQUEST_BODY_BYTES],
|
|
57
|
+
];
|
|
58
|
+
let warnedAbout;
|
|
59
|
+
/**
|
|
60
|
+
* The ceiling in bytes for one route: the environment override when it is a
|
|
61
|
+
* positive size, else that route's documented limit.
|
|
62
|
+
*
|
|
63
|
+
* @param routePath - The route as registered, for example `/v1/messages`.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveProxyMaxRequestBodyBytes(routePath, env = process.env) {
|
|
66
|
+
const raw = env[PROXY_MAX_REQUEST_BODY_ENV]?.trim();
|
|
67
|
+
if (raw) {
|
|
68
|
+
const parsed = tryParseByteLimit(raw);
|
|
69
|
+
if (parsed !== undefined && parsed > 0) {
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
if (warnedAbout !== raw) {
|
|
73
|
+
warnedAbout = raw;
|
|
74
|
+
logger.always(`[proxy] ignoring ${PROXY_MAX_REQUEST_BODY_ENV}=${JSON.stringify(raw)}: expected a positive byte count or a size such as 64mb; using each route's documented limit`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return (ROUTE_LIMITS.find(([pattern]) => pattern.test(routePath))?.[1] ??
|
|
78
|
+
DEFAULT_PROXY_MAX_REQUEST_BODY_BYTES);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The request body as text, `undefined` when it cannot be read (a client that
|
|
82
|
+
* hung up mid-body), or the error when it is over the route's ceiling. Over the
|
|
83
|
+
* ceiling the stream is cancelled, so nothing past it is buffered.
|
|
84
|
+
*
|
|
85
|
+
* @param routePath - The route as registered, for example `/v1/messages`.
|
|
86
|
+
*/
|
|
87
|
+
export async function readProxyRequestText(request, routePath, env = process.env) {
|
|
88
|
+
try {
|
|
89
|
+
const bytes = await readBoundedRequestBody(request, resolveProxyMaxRequestBodyBytes(routePath, env));
|
|
90
|
+
return bytes instanceof RawBodyTooLargeError
|
|
91
|
+
? bytes
|
|
92
|
+
: new TextDecoder().decode(bytes);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
import type { NeuroLink } from "../../neurolink.js";
|
|
7
7
|
import { BaseServerAdapter } from "../abstract/baseServerAdapter.js";
|
|
8
8
|
import type { MiddlewareDefinition, RouteDefinition, ServerAdapterConfig } from "../../types/index.js";
|
|
9
|
+
/**
|
|
10
|
+
* Hono-specific server adapter
|
|
11
|
+
* Supports multiple runtimes: Bun, Deno, Node.js
|
|
12
|
+
*/
|
|
9
13
|
export declare class HonoServerAdapter extends BaseServerAdapter {
|
|
10
14
|
private app;
|
|
11
15
|
private server?;
|
|
@@ -14,6 +18,7 @@ export declare class HonoServerAdapter extends BaseServerAdapter {
|
|
|
14
18
|
private requestContextStore;
|
|
15
19
|
private HTTPExceptionCtor;
|
|
16
20
|
private streamSSEFn;
|
|
21
|
+
private parseFormBody;
|
|
17
22
|
constructor(neurolink: NeuroLink, config?: ServerAdapterConfig);
|
|
18
23
|
/**
|
|
19
24
|
* Create rate limiter middleware for Hono
|
|
@@ -74,5 +79,13 @@ export declare class HonoServerAdapter extends BaseServerAdapter {
|
|
|
74
79
|
getNativeServer(): unknown;
|
|
75
80
|
private extractHeaders;
|
|
76
81
|
private extractQuery;
|
|
82
|
+
/**
|
|
83
|
+
* A request's context, or the 413 that answers a JSON or form body over its
|
|
84
|
+
* limit. That answer is built here, before the context exists, so no
|
|
85
|
+
* middleware (an auth check included) and no route runs for the request.
|
|
86
|
+
* An upload over the limit still reaches its route as the error itself,
|
|
87
|
+
* which answers in its own wire format.
|
|
88
|
+
*/
|
|
89
|
+
private createRequestContext;
|
|
77
90
|
private extractBody;
|
|
78
91
|
}
|