responses-proxy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/cli.js +118 -0
- package/dist/anthropic-messages.js +383 -0
- package/dist/anthropic-messages.test.js +209 -0
- package/dist/audit-log.js +138 -0
- package/dist/audit-log.test.js +480 -0
- package/dist/billing-expiration.js +70 -0
- package/dist/billing-expiration.test.js +114 -0
- package/dist/billing.js +716 -0
- package/dist/billing.test.js +228 -0
- package/dist/chatgpt-oauth-store.js +240 -0
- package/dist/chatgpt-oauth-store.test.js +88 -0
- package/dist/chatgpt-oauth.js +118 -0
- package/dist/chatgpt-oauth.test.js +63 -0
- package/dist/chatgpt-provider-auth.js +60 -0
- package/dist/chatgpt-provider-auth.test.js +101 -0
- package/dist/client/app-icon.svg +17 -0
- package/dist/client/assets/index-C7Vvhst8.js +14 -0
- package/dist/client/assets/index-DpqgYK3L.css +1 -0
- package/dist/client/favicon.svg +17 -0
- package/dist/client/index.html +31 -0
- package/dist/client-config-apply.js +345 -0
- package/dist/client-config-apply.test.js +185 -0
- package/dist/client-token-limits.js +111 -0
- package/dist/client-token-limits.test.js +129 -0
- package/dist/codex-config.js +47 -0
- package/dist/codex-setup.js +87 -0
- package/dist/codex-setup.test.js +30 -0
- package/dist/config.js +314 -0
- package/dist/cost-analytics.js +31 -0
- package/dist/cost-analytics.test.js +38 -0
- package/dist/customer-key-access.js +126 -0
- package/dist/customer-key-access.test.js +178 -0
- package/dist/customer-keys.js +209 -0
- package/dist/customer-keys.test.js +68 -0
- package/dist/customer-usage.js +18 -0
- package/dist/customer-usage.test.js +55 -0
- package/dist/dashboard-auth.js +318 -0
- package/dist/dashboard-auth.test.js +133 -0
- package/dist/dashboard-serving.test.js +235 -0
- package/dist/error-response.js +174 -0
- package/dist/error-response.test.js +88 -0
- package/dist/forward.js +357 -0
- package/dist/health-websocket-manager.js +174 -0
- package/dist/http-rate-limit.js +36 -0
- package/dist/http-rate-limit.test.js +62 -0
- package/dist/kiro-auth.js +136 -0
- package/dist/kiro-auth.test.js +234 -0
- package/dist/kiro-codewhisperer.js +646 -0
- package/dist/kiro-codewhisperer.test.js +219 -0
- package/dist/kiro-device-login.js +338 -0
- package/dist/kiro-eventstream.js +219 -0
- package/dist/kiro-eventstream.test.js +79 -0
- package/dist/kiro-forward.js +401 -0
- package/dist/kiro-import-cli.js +69 -0
- package/dist/kiro-import.js +94 -0
- package/dist/kiro-import.test.js +125 -0
- package/dist/kiro-token-store.js +196 -0
- package/dist/kiro-token-store.test.js +207 -0
- package/dist/krouter-usage.js +243 -0
- package/dist/model-combo-repository.js +147 -0
- package/dist/model-routing.js +69 -0
- package/dist/model-routing.test.js +41 -0
- package/dist/normalize-request.js +531 -0
- package/dist/normalize-request.test.js +277 -0
- package/dist/omv-public-firewall.test.js +11 -0
- package/dist/package.json +17 -0
- package/dist/prompt-cache-state.js +146 -0
- package/dist/prompt-cache-state.test.js +71 -0
- package/dist/prompt-cache.js +229 -0
- package/dist/provider-health-service.js +404 -0
- package/dist/provider-request-parameters.js +107 -0
- package/dist/provider-request-parameters.test.js +26 -0
- package/dist/provider-routing.js +114 -0
- package/dist/provider-routing.test.js +64 -0
- package/dist/provider-usage.js +314 -0
- package/dist/request-timeout-policy.js +61 -0
- package/dist/request-timeout-policy.test.js +40 -0
- package/dist/response-cache.js +69 -0
- package/dist/response-cache.test.js +28 -0
- package/dist/routing-combo-repository.js +300 -0
- package/dist/routing-engine.js +377 -0
- package/dist/routing-integration.js +155 -0
- package/dist/routing-simulation-engine.js +326 -0
- package/dist/rtk-layer.js +483 -0
- package/dist/rtk-layer.test.js +198 -0
- package/dist/runtime-provider-repository.js +1742 -0
- package/dist/runtime-provider-repository.test.js +1177 -0
- package/dist/schema.js +118 -0
- package/dist/schema.test.js +16 -0
- package/dist/sepay-webhook.js +87 -0
- package/dist/sepay-webhook.test.js +142 -0
- package/dist/server-body-limit.test.js +35 -0
- package/dist/server-client-token-limits.test.js +161 -0
- package/dist/server-codex-config-setup.test.js +76 -0
- package/dist/server-http-rate-limit.test.js +80 -0
- package/dist/server-response-cache.test.js +105 -0
- package/dist/server-routes-alias.test.js +39 -0
- package/dist/server-sepay-webhook-security.test.js +59 -0
- package/dist/server.js +5906 -0
- package/dist/session-log.js +178 -0
- package/dist/tailnet-funnel-script.test.js +33 -0
- package/dist/telegram-bot/actions.js +118 -0
- package/dist/telegram-bot/admin-actions.js +103 -0
- package/dist/telegram-bot/auth.js +46 -0
- package/dist/telegram-bot/auth.test.js +1 -0
- package/dist/telegram-bot/bot-identity-repository.js +189 -0
- package/dist/telegram-bot/bot-identity-repository.test.js +78 -0
- package/dist/telegram-bot/callbacks.js +30 -0
- package/dist/telegram-bot/codex-config-delivery.js +38 -0
- package/dist/telegram-bot/codex-config-delivery.test.js +75 -0
- package/dist/telegram-bot/commands/accounts.js +140 -0
- package/dist/telegram-bot/commands/apikey.js +737 -0
- package/dist/telegram-bot/commands/apply.js +265 -0
- package/dist/telegram-bot/commands/clients.js +13 -0
- package/dist/telegram-bot/commands/customer-billing.test.js +271 -0
- package/dist/telegram-bot/commands/grant.js +138 -0
- package/dist/telegram-bot/commands/grant.test.js +217 -0
- package/dist/telegram-bot/commands/help.js +52 -0
- package/dist/telegram-bot/commands/me.js +53 -0
- package/dist/telegram-bot/commands/models.js +6 -0
- package/dist/telegram-bot/commands/oauth.js +64 -0
- package/dist/telegram-bot/commands/plans.js +96 -0
- package/dist/telegram-bot/commands/providers.js +27 -0
- package/dist/telegram-bot/commands/quota.js +10 -0
- package/dist/telegram-bot/commands/renew-user.js +139 -0
- package/dist/telegram-bot/commands/renew-user.test.js +184 -0
- package/dist/telegram-bot/commands/renew.js +1369 -0
- package/dist/telegram-bot/commands/renew.test.js +1633 -0
- package/dist/telegram-bot/commands/start.js +212 -0
- package/dist/telegram-bot/commands/start.test.js +280 -0
- package/dist/telegram-bot/commands/status.js +6 -0
- package/dist/telegram-bot/commands/tailscale.js +15 -0
- package/dist/telegram-bot/commands/tailscale.test.js +76 -0
- package/dist/telegram-bot/commands/test.js +51 -0
- package/dist/telegram-bot/commands/test.test.js +14 -0
- package/dist/telegram-bot/commands/usage.js +10 -0
- package/dist/telegram-bot/config.js +98 -0
- package/dist/telegram-bot/config.test.js +42 -0
- package/dist/telegram-bot/customer-actions.js +160 -0
- package/dist/telegram-bot/customer-api-keys.js +68 -0
- package/dist/telegram-bot/customer-billing.js +72 -0
- package/dist/telegram-bot/customer-workspace-repository.js +134 -0
- package/dist/telegram-bot/customer-workspace-repository.test.js +47 -0
- package/dist/telegram-bot/dashboard-login.js +39 -0
- package/dist/telegram-bot/format.js +140 -0
- package/dist/telegram-bot/grants.js +370 -0
- package/dist/telegram-bot/grants.test.js +290 -0
- package/dist/telegram-bot/index.js +85 -0
- package/dist/telegram-bot/message-cleanup.js +55 -0
- package/dist/telegram-bot/message-cleanup.test.js +77 -0
- package/dist/telegram-bot/message-format.js +45 -0
- package/dist/telegram-bot/message-format.test.js +10 -0
- package/dist/telegram-bot/proxy-client.js +174 -0
- package/dist/telegram-bot/rate-limit.js +95 -0
- package/dist/telegram-bot/rate-limit.test.js +58 -0
- package/dist/telegram-bot/sessions.js +171 -0
- package/dist/telegram-bot/sessions.test.js +107 -0
- package/dist/telegram-bot/telegram-adapter.js +126 -0
- package/dist/telegram-bot/worker.js +63 -0
- package/package.json +39 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { ProviderUsageLimitError } from "./provider-usage.js";
|
|
2
|
+
const MAX_UPSTREAM_ERROR_BODY_CHARS = 4_096;
|
|
3
|
+
export function resolveProxyError(args) {
|
|
4
|
+
const parsedUpstream = parseUpstreamErrorBody(args.upstreamBody);
|
|
5
|
+
const mapped = applyProviderErrorPolicy({
|
|
6
|
+
statusCode: args.statusCode,
|
|
7
|
+
message: args.message,
|
|
8
|
+
parsedUpstream,
|
|
9
|
+
upstreamBody: args.upstreamBody,
|
|
10
|
+
defaultCode: args.defaultCode ?? defaultProxyErrorCode(args.statusCode),
|
|
11
|
+
retryable: isRetryableStatusCode(args.statusCode),
|
|
12
|
+
}, args.providerErrorPolicy);
|
|
13
|
+
return {
|
|
14
|
+
errorCode: mapped.code,
|
|
15
|
+
retryable: mapped.retryable,
|
|
16
|
+
envelope: {
|
|
17
|
+
error: {
|
|
18
|
+
type: args.errorType ??
|
|
19
|
+
(args.statusCode >= 400 && args.statusCode < 500 ? "request_error" : "internal_error"),
|
|
20
|
+
code: mapped.code,
|
|
21
|
+
message: resolveProxyErrorMessage(mapped.message, parsedUpstream),
|
|
22
|
+
request_id: args.requestId,
|
|
23
|
+
upstream_status: args.statusCode,
|
|
24
|
+
upstream_body: truncateUpstreamErrorBody(args.upstreamBody),
|
|
25
|
+
upstream_error: parsedUpstream,
|
|
26
|
+
retryable: mapped.retryable,
|
|
27
|
+
usage: args.usage instanceof ProviderUsageLimitError
|
|
28
|
+
? args.usage.usage
|
|
29
|
+
: args.usage
|
|
30
|
+
? summarizeUsageLike(args.usage)
|
|
31
|
+
: undefined,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function buildProxyErrorEnvelope(args) {
|
|
37
|
+
return resolveProxyError(args).envelope;
|
|
38
|
+
}
|
|
39
|
+
export function defaultProxyErrorCode(statusCode) {
|
|
40
|
+
if (statusCode === 413) {
|
|
41
|
+
return "REQUEST_BODY_TOO_LARGE";
|
|
42
|
+
}
|
|
43
|
+
return statusCode >= 500 ? "UPSTREAM_REQUEST_FAILED" : "UPSTREAM_BAD_REQUEST";
|
|
44
|
+
}
|
|
45
|
+
export function isRetryableStatusCode(statusCode) {
|
|
46
|
+
return statusCode === 408 || statusCode === 409 || statusCode === 429 || statusCode >= 500;
|
|
47
|
+
}
|
|
48
|
+
export function parseUpstreamErrorBody(body) {
|
|
49
|
+
if (typeof body !== "string" || !body.trim()) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const parsed = parseJsonLike(body);
|
|
53
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const root = parsed;
|
|
57
|
+
const errorRecord = isRecord(root.error) ? root.error : root;
|
|
58
|
+
const message = readString(errorRecord.message) ?? readString(root.message);
|
|
59
|
+
const type = readString(errorRecord.type) ?? readString(root.type);
|
|
60
|
+
const code = readString(errorRecord.code) ?? readString(root.code);
|
|
61
|
+
const param = readString(errorRecord.param) ?? readString(root.param);
|
|
62
|
+
if (!message && !type && !code && !param) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
message,
|
|
67
|
+
type,
|
|
68
|
+
code,
|
|
69
|
+
param,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function truncateUpstreamErrorBody(body) {
|
|
73
|
+
if (typeof body !== "string") {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const trimmed = body.trim();
|
|
77
|
+
if (!trimmed) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
if (trimmed.length <= MAX_UPSTREAM_ERROR_BODY_CHARS) {
|
|
81
|
+
return trimmed;
|
|
82
|
+
}
|
|
83
|
+
const headBudget = Math.max(256, Math.floor(MAX_UPSTREAM_ERROR_BODY_CHARS * 0.75));
|
|
84
|
+
const tailBudget = Math.max(128, MAX_UPSTREAM_ERROR_BODY_CHARS - headBudget - 32);
|
|
85
|
+
return `${trimmed.slice(0, headBudget)}\n... upstream error body truncated ...\n${trimmed.slice(Math.max(0, trimmed.length - tailBudget))}`.trim();
|
|
86
|
+
}
|
|
87
|
+
function resolveProxyErrorMessage(message, parsedUpstream) {
|
|
88
|
+
if (!parsedUpstream?.message) {
|
|
89
|
+
return message;
|
|
90
|
+
}
|
|
91
|
+
if (message.includes("upstream rejected request") ||
|
|
92
|
+
message.includes("upstream request failed") ||
|
|
93
|
+
message.includes("Unknown proxy error")) {
|
|
94
|
+
return message.includes(parsedUpstream.message)
|
|
95
|
+
? message
|
|
96
|
+
: `${message}: ${parsedUpstream.message}`;
|
|
97
|
+
}
|
|
98
|
+
return message;
|
|
99
|
+
}
|
|
100
|
+
function parseJsonLike(value) {
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(value);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function readString(value) {
|
|
109
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
110
|
+
}
|
|
111
|
+
function isRecord(value) {
|
|
112
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
113
|
+
}
|
|
114
|
+
function summarizeUsageLike(usage) {
|
|
115
|
+
return {
|
|
116
|
+
allowed: usage.allowed,
|
|
117
|
+
remaining: usage.remaining,
|
|
118
|
+
limit: usage.limit,
|
|
119
|
+
used: usage.used,
|
|
120
|
+
raw: usage.raw,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function applyProviderErrorPolicy(base, policy) {
|
|
124
|
+
if (!policy?.rules?.length) {
|
|
125
|
+
return {
|
|
126
|
+
code: base.defaultCode,
|
|
127
|
+
message: base.message,
|
|
128
|
+
retryable: base.retryable,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const upstreamCode = base.parsedUpstream?.code?.toLowerCase();
|
|
132
|
+
const upstreamType = base.parsedUpstream?.type?.toLowerCase();
|
|
133
|
+
const message = base.message.toLowerCase();
|
|
134
|
+
const body = base.upstreamBody?.toLowerCase() ?? "";
|
|
135
|
+
for (const rule of policy.rules) {
|
|
136
|
+
if (rule.statusCodes?.length && !rule.statusCodes.includes(base.statusCode)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (rule.upstreamCodes?.length) {
|
|
140
|
+
const allowed = rule.upstreamCodes.map((item) => item.toLowerCase());
|
|
141
|
+
if (!upstreamCode || !allowed.includes(upstreamCode)) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (rule.upstreamTypes?.length) {
|
|
146
|
+
const allowed = rule.upstreamTypes.map((item) => item.toLowerCase());
|
|
147
|
+
if (!upstreamType || !allowed.includes(upstreamType)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (rule.messageIncludes?.length) {
|
|
152
|
+
const matched = rule.messageIncludes.some((item) => message.includes(item.toLowerCase()));
|
|
153
|
+
if (!matched) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (rule.bodyIncludes?.length) {
|
|
158
|
+
const matched = rule.bodyIncludes.some((item) => body.includes(item.toLowerCase()));
|
|
159
|
+
if (!matched) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
code: rule.code?.trim() || base.defaultCode,
|
|
165
|
+
message: rule.message?.trim() || base.message,
|
|
166
|
+
retryable: typeof rule.retryable === "boolean" ? rule.retryable : base.retryable,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
code: base.defaultCode,
|
|
171
|
+
message: base.message,
|
|
172
|
+
retryable: base.retryable,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { buildProxyErrorEnvelope, resolveProxyError, isRetryableStatusCode, parseUpstreamErrorBody, truncateUpstreamErrorBody, } from "./error-response.js";
|
|
4
|
+
test("parses structured upstream JSON error payloads", () => {
|
|
5
|
+
assert.deepEqual(parseUpstreamErrorBody(JSON.stringify({
|
|
6
|
+
error: {
|
|
7
|
+
message: "Model quota exceeded",
|
|
8
|
+
type: "rate_limit_error",
|
|
9
|
+
code: "quota_exceeded",
|
|
10
|
+
param: "model",
|
|
11
|
+
},
|
|
12
|
+
})), {
|
|
13
|
+
message: "Model quota exceeded",
|
|
14
|
+
type: "rate_limit_error",
|
|
15
|
+
code: "quota_exceeded",
|
|
16
|
+
param: "model",
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
test("builds backward-compatible proxy error envelopes with upstream details", () => {
|
|
20
|
+
const envelope = buildProxyErrorEnvelope({
|
|
21
|
+
statusCode: 429,
|
|
22
|
+
requestId: "req_123",
|
|
23
|
+
message: "[req_123] upstream rejected request (429 Too Many Requests)",
|
|
24
|
+
upstreamBody: JSON.stringify({
|
|
25
|
+
error: {
|
|
26
|
+
message: "Rate limit reached",
|
|
27
|
+
type: "rate_limit_error",
|
|
28
|
+
code: "rate_limit",
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
defaultCode: "UPSTREAM_BAD_REQUEST",
|
|
32
|
+
errorType: "proxy_error",
|
|
33
|
+
});
|
|
34
|
+
assert.equal(envelope.error.type, "proxy_error");
|
|
35
|
+
assert.equal(envelope.error.code, "UPSTREAM_BAD_REQUEST");
|
|
36
|
+
assert.equal(envelope.error.request_id, "req_123");
|
|
37
|
+
assert.equal(envelope.error.upstream_status, 429);
|
|
38
|
+
assert.equal(envelope.error.retryable, true);
|
|
39
|
+
assert.match(String(envelope.error.message), /Rate limit reached/);
|
|
40
|
+
assert.deepEqual(envelope.error.upstream_error, {
|
|
41
|
+
message: "Rate limit reached",
|
|
42
|
+
type: "rate_limit_error",
|
|
43
|
+
code: "rate_limit",
|
|
44
|
+
param: undefined,
|
|
45
|
+
});
|
|
46
|
+
assert.match(String(envelope.error.upstream_body), /rate_limit_error/);
|
|
47
|
+
});
|
|
48
|
+
test("truncates oversized upstream error bodies without dropping the tail", () => {
|
|
49
|
+
const raw = `${"A".repeat(5000)}TAIL`;
|
|
50
|
+
const truncated = truncateUpstreamErrorBody(raw);
|
|
51
|
+
assert.ok(truncated);
|
|
52
|
+
assert.match(String(truncated), /upstream error body truncated/);
|
|
53
|
+
assert.match(String(truncated), /TAIL$/);
|
|
54
|
+
});
|
|
55
|
+
test("marks only retryable upstream statuses as retryable", () => {
|
|
56
|
+
assert.equal(isRetryableStatusCode(400), false);
|
|
57
|
+
assert.equal(isRetryableStatusCode(413), false);
|
|
58
|
+
assert.equal(isRetryableStatusCode(429), true);
|
|
59
|
+
assert.equal(isRetryableStatusCode(500), true);
|
|
60
|
+
});
|
|
61
|
+
test("provider error policy can remap verbose upstream failures to cleaner codes and messages", () => {
|
|
62
|
+
const resolved = resolveProxyError({
|
|
63
|
+
statusCode: 500,
|
|
64
|
+
message: "[req_123] upstream rejected request (500 Internal Server Error)",
|
|
65
|
+
upstreamBody: JSON.stringify({
|
|
66
|
+
error: {
|
|
67
|
+
message: "Request body is too large",
|
|
68
|
+
type: "server_error",
|
|
69
|
+
code: "internal_error",
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
defaultCode: "UPSTREAM_REQUEST_FAILED",
|
|
73
|
+
errorType: "proxy_error",
|
|
74
|
+
providerErrorPolicy: {
|
|
75
|
+
rules: [
|
|
76
|
+
{
|
|
77
|
+
bodyIncludes: ["request body is too large"],
|
|
78
|
+
code: "UPSTREAM_REQUEST_TOO_LARGE",
|
|
79
|
+
message: "Upstream rejected the request because the serialized prompt body is too large",
|
|
80
|
+
retryable: false,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
assert.equal(resolved.errorCode, "UPSTREAM_REQUEST_TOO_LARGE");
|
|
86
|
+
assert.equal(resolved.retryable, false);
|
|
87
|
+
assert.equal(resolved.envelope.error.message, "Upstream rejected the request because the serialized prompt body is too large");
|
|
88
|
+
});
|
package/dist/forward.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
export async function forwardJson({ requestId, url, body, apiKey, headers, timeoutMs, logger, onEvent, }) {
|
|
3
|
+
const startedAt = Date.now();
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
6
|
+
try {
|
|
7
|
+
const response = await fetch(url, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: buildHeaders(apiKey, headers),
|
|
10
|
+
body: JSON.stringify(body),
|
|
11
|
+
signal: controller.signal,
|
|
12
|
+
});
|
|
13
|
+
logger.info({
|
|
14
|
+
requestId,
|
|
15
|
+
upstreamStatus: response.status,
|
|
16
|
+
connectMs: Date.now() - startedAt,
|
|
17
|
+
}, "upstream JSON response received");
|
|
18
|
+
await onEvent?.({
|
|
19
|
+
event: "upstream_json_response",
|
|
20
|
+
requestId,
|
|
21
|
+
upstreamStatus: response.status,
|
|
22
|
+
connectMs: Date.now() - startedAt,
|
|
23
|
+
});
|
|
24
|
+
return response;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw wrapFetchError(requestId, error);
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
clearTimeout(timeout);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function forwardSse({ requestId, url, body, apiKey, headers, timeoutMs, idleTimeoutMs, responseRaw, logger, onEvent, }) {
|
|
34
|
+
const startedAt = Date.now();
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
37
|
+
let upstream;
|
|
38
|
+
try {
|
|
39
|
+
upstream = await fetch(url, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: buildHeaders(apiKey, {
|
|
42
|
+
...headers,
|
|
43
|
+
Accept: "text/event-stream",
|
|
44
|
+
}),
|
|
45
|
+
body: JSON.stringify(body),
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
throw wrapFetchError(requestId, error);
|
|
52
|
+
}
|
|
53
|
+
if (!upstream.ok || !upstream.body) {
|
|
54
|
+
clearTimeout(timeout);
|
|
55
|
+
throw await buildUpstreamError(requestId, upstream);
|
|
56
|
+
}
|
|
57
|
+
logger.info({
|
|
58
|
+
requestId,
|
|
59
|
+
upstreamStatus: upstream.status,
|
|
60
|
+
connectMs: Date.now() - startedAt,
|
|
61
|
+
}, "upstream SSE stream opened");
|
|
62
|
+
await onEvent?.({
|
|
63
|
+
event: "upstream_sse_opened",
|
|
64
|
+
requestId,
|
|
65
|
+
upstreamStatus: upstream.status,
|
|
66
|
+
connectMs: Date.now() - startedAt,
|
|
67
|
+
});
|
|
68
|
+
responseRaw.setHeader("Content-Type", "text/event-stream");
|
|
69
|
+
responseRaw.setHeader("Cache-Control", "no-cache, no-transform");
|
|
70
|
+
responseRaw.setHeader("Connection", "keep-alive");
|
|
71
|
+
responseRaw.setHeader("X-Accel-Buffering", "no");
|
|
72
|
+
responseRaw.flushHeaders?.();
|
|
73
|
+
const reader = upstream.body.getReader();
|
|
74
|
+
let sseBuffer = "";
|
|
75
|
+
let firstForwardedAt;
|
|
76
|
+
let firstEventType;
|
|
77
|
+
let forwardedFrames = 0;
|
|
78
|
+
let forwardedBytes = 0;
|
|
79
|
+
let filteredNullFrames = 0;
|
|
80
|
+
let usageCaptured = false;
|
|
81
|
+
let idleTimer;
|
|
82
|
+
const resetIdleTimer = () => {
|
|
83
|
+
if (idleTimer)
|
|
84
|
+
clearTimeout(idleTimer);
|
|
85
|
+
idleTimer = setTimeout(() => {
|
|
86
|
+
logger.warn({ requestId }, "upstream SSE idle timeout reached");
|
|
87
|
+
controller.abort();
|
|
88
|
+
responseRaw.destroy(new Error("upstream stream idle timeout"));
|
|
89
|
+
}, idleTimeoutMs);
|
|
90
|
+
};
|
|
91
|
+
resetIdleTimer();
|
|
92
|
+
try {
|
|
93
|
+
while (true) {
|
|
94
|
+
const { done, value } = await reader.read();
|
|
95
|
+
if (done) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
resetIdleTimer();
|
|
99
|
+
if (value) {
|
|
100
|
+
const chunk = Buffer.from(value).toString("utf8");
|
|
101
|
+
sseBuffer += chunk;
|
|
102
|
+
const frames = extractCompleteSseFrames(sseBuffer);
|
|
103
|
+
sseBuffer = frames.remaining;
|
|
104
|
+
for (const frame of frames.complete) {
|
|
105
|
+
const filtered = filterMalformedSseFrame(frame);
|
|
106
|
+
if (filtered) {
|
|
107
|
+
if (!usageCaptured) {
|
|
108
|
+
const usageEvent = extractSseUsageEvent(filtered, requestId);
|
|
109
|
+
if (usageEvent) {
|
|
110
|
+
usageCaptured = true;
|
|
111
|
+
await onEvent?.(usageEvent);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (firstForwardedAt === undefined) {
|
|
115
|
+
firstForwardedAt = Date.now();
|
|
116
|
+
firstEventType = extractSseEventType(filtered);
|
|
117
|
+
logger.info({
|
|
118
|
+
requestId,
|
|
119
|
+
firstChunkMs: firstForwardedAt - startedAt,
|
|
120
|
+
firstEventType,
|
|
121
|
+
}, "upstream SSE first event forwarded");
|
|
122
|
+
await onEvent?.({
|
|
123
|
+
event: "upstream_sse_first_event",
|
|
124
|
+
requestId,
|
|
125
|
+
firstChunkMs: firstForwardedAt - startedAt,
|
|
126
|
+
firstEventType,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
forwardedFrames += 1;
|
|
130
|
+
forwardedBytes += Buffer.byteLength(filtered);
|
|
131
|
+
responseRaw.write(filtered);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
filteredNullFrames += 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
await delay(0);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (sseBuffer.length > 0) {
|
|
143
|
+
const filtered = filterMalformedSseFrame(sseBuffer);
|
|
144
|
+
if (filtered) {
|
|
145
|
+
if (!usageCaptured) {
|
|
146
|
+
const usageEvent = extractSseUsageEvent(filtered, requestId);
|
|
147
|
+
if (usageEvent) {
|
|
148
|
+
usageCaptured = true;
|
|
149
|
+
await onEvent?.(usageEvent);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (firstForwardedAt === undefined) {
|
|
153
|
+
firstForwardedAt = Date.now();
|
|
154
|
+
firstEventType = extractSseEventType(filtered);
|
|
155
|
+
logger.info({
|
|
156
|
+
requestId,
|
|
157
|
+
firstChunkMs: firstForwardedAt - startedAt,
|
|
158
|
+
firstEventType,
|
|
159
|
+
}, "upstream SSE first event forwarded");
|
|
160
|
+
await onEvent?.({
|
|
161
|
+
event: "upstream_sse_first_event",
|
|
162
|
+
requestId,
|
|
163
|
+
firstChunkMs: firstForwardedAt - startedAt,
|
|
164
|
+
firstEventType,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
forwardedFrames += 1;
|
|
168
|
+
forwardedBytes += Buffer.byteLength(filtered);
|
|
169
|
+
responseRaw.write(filtered);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
filteredNullFrames += 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
logger.info({
|
|
176
|
+
requestId,
|
|
177
|
+
totalMs: Date.now() - startedAt,
|
|
178
|
+
forwardedFrames,
|
|
179
|
+
forwardedBytes,
|
|
180
|
+
filteredNullFrames,
|
|
181
|
+
firstEventType,
|
|
182
|
+
}, "upstream SSE stream completed");
|
|
183
|
+
await onEvent?.({
|
|
184
|
+
event: "upstream_sse_completed",
|
|
185
|
+
requestId,
|
|
186
|
+
totalMs: Date.now() - startedAt,
|
|
187
|
+
forwardedFrames,
|
|
188
|
+
forwardedBytes,
|
|
189
|
+
filteredNullFrames,
|
|
190
|
+
firstEventType,
|
|
191
|
+
});
|
|
192
|
+
responseRaw.end();
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
throw wrapFetchError(requestId, error);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
if (idleTimer)
|
|
199
|
+
clearTimeout(idleTimer);
|
|
200
|
+
clearTimeout(timeout);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function extractCompleteSseFrames(buffer) {
|
|
204
|
+
const complete = [];
|
|
205
|
+
let cursor = 0;
|
|
206
|
+
while (cursor < buffer.length) {
|
|
207
|
+
const lfLfIndex = buffer.indexOf("\n\n", cursor);
|
|
208
|
+
const crlfCrlfIndex = buffer.indexOf("\r\n\r\n", cursor);
|
|
209
|
+
let endIndex = -1;
|
|
210
|
+
let delimiterLength = 0;
|
|
211
|
+
if (lfLfIndex !== -1 && (crlfCrlfIndex === -1 || lfLfIndex < crlfCrlfIndex)) {
|
|
212
|
+
endIndex = lfLfIndex;
|
|
213
|
+
delimiterLength = 2;
|
|
214
|
+
}
|
|
215
|
+
else if (crlfCrlfIndex !== -1) {
|
|
216
|
+
endIndex = crlfCrlfIndex;
|
|
217
|
+
delimiterLength = 4;
|
|
218
|
+
}
|
|
219
|
+
if (endIndex === -1) {
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
complete.push(buffer.slice(cursor, endIndex + delimiterLength));
|
|
223
|
+
cursor = endIndex + delimiterLength;
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
complete,
|
|
227
|
+
remaining: buffer.slice(cursor),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function filterMalformedSseFrame(frame) {
|
|
231
|
+
const normalized = frame.replace(/\r\n/g, "\n");
|
|
232
|
+
const dataLines = normalized
|
|
233
|
+
.split("\n")
|
|
234
|
+
.filter((line) => line.startsWith("data:"))
|
|
235
|
+
.map((line) => line.slice(5).trim());
|
|
236
|
+
if (dataLines.length === 1 && dataLines[0] === "null") {
|
|
237
|
+
return "";
|
|
238
|
+
}
|
|
239
|
+
return frame;
|
|
240
|
+
}
|
|
241
|
+
function extractSseEventType(frame) {
|
|
242
|
+
const normalized = frame.replace(/\r\n/g, "\n");
|
|
243
|
+
const dataLine = normalized
|
|
244
|
+
.split("\n")
|
|
245
|
+
.find((line) => line.startsWith("data:") && line.slice(5).trim().startsWith("{"));
|
|
246
|
+
if (!dataLine) {
|
|
247
|
+
return normalized.includes("[DONE]") ? "done" : undefined;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const payload = JSON.parse(dataLine.slice(5).trim());
|
|
251
|
+
return typeof payload.type === "string" ? payload.type : undefined;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function extractSseUsageEvent(frame, requestId) {
|
|
258
|
+
const payload = extractSsePayload(frame);
|
|
259
|
+
if (!payload || typeof payload.type !== "string") {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
const response = isRecord(payload.response) ? payload.response : undefined;
|
|
263
|
+
const usage = response && isRecord(response.usage) ? response.usage : undefined;
|
|
264
|
+
if (!usage) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
const totalTokens = readNumber(usage.total_tokens);
|
|
268
|
+
const outputTokens = readNumber(usage.output_tokens);
|
|
269
|
+
const inputTokens = readNumber(usage.input_tokens);
|
|
270
|
+
const inputTokensDetails = isRecord(usage.input_tokens_details)
|
|
271
|
+
? usage.input_tokens_details
|
|
272
|
+
: undefined;
|
|
273
|
+
const cachedTokens = inputTokensDetails && readNumber(inputTokensDetails.cached_tokens) !== undefined
|
|
274
|
+
? readNumber(inputTokensDetails.cached_tokens)
|
|
275
|
+
: undefined;
|
|
276
|
+
const cacheSavedPercent = computeCacheSavedPercent(inputTokens, cachedTokens);
|
|
277
|
+
if (payload.type !== "response.completed" &&
|
|
278
|
+
payload.type !== "response.incomplete" &&
|
|
279
|
+
totalTokens === undefined &&
|
|
280
|
+
outputTokens === undefined &&
|
|
281
|
+
inputTokens === undefined &&
|
|
282
|
+
cachedTokens === undefined) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
event: "upstream_response_usage",
|
|
287
|
+
requestId,
|
|
288
|
+
responseEventType: payload.type,
|
|
289
|
+
responseId: typeof response?.id === "string" ? response.id : undefined,
|
|
290
|
+
usage,
|
|
291
|
+
inputTokens,
|
|
292
|
+
outputTokens,
|
|
293
|
+
totalTokens,
|
|
294
|
+
inputTokensDetails,
|
|
295
|
+
cachedTokens,
|
|
296
|
+
cacheSavedPercent,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function extractSsePayload(frame) {
|
|
300
|
+
const normalized = frame.replace(/\r\n/g, "\n");
|
|
301
|
+
const dataLine = normalized
|
|
302
|
+
.split("\n")
|
|
303
|
+
.find((line) => line.startsWith("data:") && line.slice(5).trim().startsWith("{"));
|
|
304
|
+
if (!dataLine) {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const payload = JSON.parse(dataLine.slice(5).trim());
|
|
309
|
+
return isRecord(payload) ? payload : undefined;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function isRecord(value) {
|
|
316
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
317
|
+
}
|
|
318
|
+
function readNumber(value) {
|
|
319
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
320
|
+
}
|
|
321
|
+
function computeCacheSavedPercent(inputTokens, cachedTokens) {
|
|
322
|
+
if (inputTokens === undefined ||
|
|
323
|
+
cachedTokens === undefined ||
|
|
324
|
+
inputTokens <= 0 ||
|
|
325
|
+
cachedTokens < 0) {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
return Math.round((cachedTokens / inputTokens) * 1000) / 10;
|
|
329
|
+
}
|
|
330
|
+
export async function buildUpstreamError(requestId, upstream) {
|
|
331
|
+
const body = await upstream.text();
|
|
332
|
+
const error = new Error(`upstream rejected request (${upstream.status} ${upstream.statusText})`);
|
|
333
|
+
error.statusCode = upstream.status;
|
|
334
|
+
error.body = body;
|
|
335
|
+
error.message = `[${requestId}] ${error.message}`;
|
|
336
|
+
return error;
|
|
337
|
+
}
|
|
338
|
+
function buildHeaders(apiKey, extraHeaders) {
|
|
339
|
+
const headers = {
|
|
340
|
+
"Content-Type": "application/json",
|
|
341
|
+
...extraHeaders,
|
|
342
|
+
};
|
|
343
|
+
if (apiKey) {
|
|
344
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
345
|
+
}
|
|
346
|
+
return headers;
|
|
347
|
+
}
|
|
348
|
+
function wrapFetchError(requestId, error) {
|
|
349
|
+
const wrapped = new Error(error instanceof Error ? `[${requestId}] ${error.message}` : `[${requestId}] upstream request failed`);
|
|
350
|
+
if (error?.name === "AbortError") {
|
|
351
|
+
wrapped.statusCode = 504;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
wrapped.statusCode = 502;
|
|
355
|
+
}
|
|
356
|
+
return wrapped;
|
|
357
|
+
}
|