openclaw-autoproxy 1.0.3 → 1.0.6
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 +66 -159
- package/README.zh-CN.md +127 -0
- package/dist/gateway/anthropic-compat.js +841 -0
- package/dist/gateway/config.js +16 -0
- package/dist/gateway/model-load-metrics.js +125 -0
- package/dist/gateway/proxy.js +331 -19
- package/dist/gateway/server-http.js +83 -6
- package/dist/gateway/server.impl.js +1 -1
- package/package.json +2 -1
- package/src/gateway/anthropic-compat.ts +1085 -0
- package/src/gateway/config.ts +29 -0
- package/src/gateway/model-load-metrics.ts +192 -0
- package/src/gateway/proxy.ts +452 -25
- package/src/gateway/server-http.ts +104 -6
- package/src/gateway/server.impl.ts +1 -1
- package/openclaw-autoproxy-1.0.1.tgz +0 -0
package/dist/gateway/config.js
CHANGED
|
@@ -12,6 +12,16 @@ function parseCsvList(value) {
|
|
|
12
12
|
.map((item) => item.trim())
|
|
13
13
|
.filter(Boolean);
|
|
14
14
|
}
|
|
15
|
+
function parsePositiveInteger(value, fallback) {
|
|
16
|
+
if (!value) {
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
const parsed = Number.parseInt(value, 10);
|
|
20
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
15
25
|
function parseRetryCodes(value) {
|
|
16
26
|
const defaults = new Set([412, 429, 500, 502, 503, 504]);
|
|
17
27
|
if (!value) {
|
|
@@ -248,6 +258,9 @@ function loadRouteFileConfig() {
|
|
|
248
258
|
const host = process.env.HOST ?? "0.0.0.0";
|
|
249
259
|
const port = Number.parseInt(process.env.PORT ?? "8787", 10);
|
|
250
260
|
const timeoutMs = Number.parseInt(process.env.REQUEST_TIMEOUT_MS ?? "60000", 10);
|
|
261
|
+
const upstreamMaxConnections = parsePositiveInteger(process.env.UPSTREAM_MAX_CONNECTIONS, 200);
|
|
262
|
+
const upstreamKeepAliveTimeoutMs = parsePositiveInteger(process.env.UPSTREAM_KEEPALIVE_TIMEOUT_MS, 60_000);
|
|
263
|
+
const upstreamKeepAliveMaxTimeoutMs = parsePositiveInteger(process.env.UPSTREAM_KEEPALIVE_MAX_TIMEOUT_MS, 300_000);
|
|
251
264
|
const upstreamBaseUrl = (process.env.UPSTREAM_BASE_URL ?? "https://api.openai.com").replace(/\/+$/, "");
|
|
252
265
|
const routeFileConfig = loadRouteFileConfig();
|
|
253
266
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
@@ -262,6 +275,9 @@ export const config = {
|
|
|
262
275
|
timeoutMs,
|
|
263
276
|
upstreamBaseUrl,
|
|
264
277
|
upstreamApiKey: process.env.UPSTREAM_API_KEY ?? "",
|
|
278
|
+
upstreamMaxConnections,
|
|
279
|
+
upstreamKeepAliveTimeoutMs,
|
|
280
|
+
upstreamKeepAliveMaxTimeoutMs,
|
|
265
281
|
retryStatusCodes: routeFileConfig.retryStatusCodes ?? parseRetryCodes(process.env.RETRY_STATUS_CODES),
|
|
266
282
|
globalFallbackModels: parseCsvList(process.env.GLOBAL_FALLBACK_MODELS),
|
|
267
283
|
modelFallbackMap: parseModelFallbackMap(process.env.MODEL_FALLBACK_MAP),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const DEFAULT_WINDOW_MS = 12 * 60 * 60 * 1000;
|
|
2
|
+
const DEFAULT_MAX_SAMPLES_PER_MODEL = 5000;
|
|
3
|
+
export const DEFAULT_MODEL_HEALTH_WINDOW_MS = DEFAULT_WINDOW_MS;
|
|
4
|
+
const modelSamples = new Map();
|
|
5
|
+
function roundMs(value) {
|
|
6
|
+
return Math.round(value * 100) / 100;
|
|
7
|
+
}
|
|
8
|
+
function pruneModelSamples(samples, cutoffAt) {
|
|
9
|
+
let startIndex = 0;
|
|
10
|
+
while (startIndex < samples.length && samples[startIndex] && samples[startIndex].at < cutoffAt) {
|
|
11
|
+
startIndex += 1;
|
|
12
|
+
}
|
|
13
|
+
if (startIndex <= 0) {
|
|
14
|
+
return samples;
|
|
15
|
+
}
|
|
16
|
+
return samples.slice(startIndex);
|
|
17
|
+
}
|
|
18
|
+
function pruneExpiredSamples(cutoffAt) {
|
|
19
|
+
for (const [model, samples] of modelSamples.entries()) {
|
|
20
|
+
const pruned = pruneModelSamples(samples, cutoffAt);
|
|
21
|
+
if (pruned.length === 0) {
|
|
22
|
+
modelSamples.delete(model);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (pruned !== samples) {
|
|
26
|
+
modelSamples.set(model, pruned);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function recordModelRequestSample(model, params) {
|
|
31
|
+
if (!model) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!Number.isFinite(params.responseMs) || params.responseMs < 0) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const sample = {
|
|
39
|
+
at: now,
|
|
40
|
+
ok: params.ok,
|
|
41
|
+
responseMs: params.responseMs,
|
|
42
|
+
statusCode: params.statusCode ?? null,
|
|
43
|
+
};
|
|
44
|
+
const existing = modelSamples.get(model) ?? [];
|
|
45
|
+
existing.push(sample);
|
|
46
|
+
if (existing.length > DEFAULT_MAX_SAMPLES_PER_MODEL) {
|
|
47
|
+
existing.splice(0, existing.length - DEFAULT_MAX_SAMPLES_PER_MODEL);
|
|
48
|
+
}
|
|
49
|
+
modelSamples.set(model, existing);
|
|
50
|
+
const cutoffAt = now - DEFAULT_WINDOW_MS;
|
|
51
|
+
pruneExpiredSamples(cutoffAt);
|
|
52
|
+
}
|
|
53
|
+
export function recordModelLoadSample(model, loadMs) {
|
|
54
|
+
recordModelRequestSample(model, {
|
|
55
|
+
ok: true,
|
|
56
|
+
responseMs: loadMs,
|
|
57
|
+
statusCode: 200,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function summarizeModel(model, samples) {
|
|
61
|
+
if (samples.length === 0) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const accessCount = samples.length;
|
|
65
|
+
const successCount = samples.reduce((count, sample) => count + (sample.ok ? 1 : 0), 0);
|
|
66
|
+
const totalResponseMs = samples.reduce((total, sample) => total + sample.responseMs, 0);
|
|
67
|
+
const lastSample = samples[samples.length - 1] ?? null;
|
|
68
|
+
const avgResponseMs = totalResponseMs / accessCount;
|
|
69
|
+
const successRatePct = accessCount > 0 ? (successCount / accessCount) * 100 : 0;
|
|
70
|
+
return {
|
|
71
|
+
model,
|
|
72
|
+
accessCount,
|
|
73
|
+
avgResponseMs: roundMs(avgResponseMs),
|
|
74
|
+
lastResponseMs: roundMs(lastSample?.responseMs ?? 0),
|
|
75
|
+
lastSeenAt: new Date(lastSample?.at ?? Date.now()).toISOString(),
|
|
76
|
+
lastStatusCode: lastSample?.statusCode ?? null,
|
|
77
|
+
successCount,
|
|
78
|
+
successRatePct: roundMs(successRatePct),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function getModelHealthWindow(windowMs = DEFAULT_WINDOW_MS) {
|
|
82
|
+
const normalizedWindowMs = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : DEFAULT_WINDOW_MS;
|
|
83
|
+
const cutoffAt = Date.now() - normalizedWindowMs;
|
|
84
|
+
pruneExpiredSamples(cutoffAt);
|
|
85
|
+
const summaries = [];
|
|
86
|
+
for (const [model, samples] of modelSamples.entries()) {
|
|
87
|
+
const filtered = pruneModelSamples(samples, cutoffAt);
|
|
88
|
+
if (filtered.length === 0) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (filtered !== samples) {
|
|
92
|
+
modelSamples.set(model, filtered);
|
|
93
|
+
}
|
|
94
|
+
const summary = summarizeModel(model, filtered);
|
|
95
|
+
if (summary) {
|
|
96
|
+
summaries.push(summary);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
summaries.sort((a, b) => {
|
|
100
|
+
if (a.accessCount !== b.accessCount) {
|
|
101
|
+
return b.accessCount - a.accessCount;
|
|
102
|
+
}
|
|
103
|
+
if (a.successRatePct !== b.successRatePct) {
|
|
104
|
+
return b.successRatePct - a.successRatePct;
|
|
105
|
+
}
|
|
106
|
+
if (a.avgResponseMs !== b.avgResponseMs) {
|
|
107
|
+
return a.avgResponseMs - b.avgResponseMs;
|
|
108
|
+
}
|
|
109
|
+
return a.model.localeCompare(b.model);
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
windowHours: roundMs(normalizedWindowMs / (60 * 60 * 1000)),
|
|
113
|
+
models: summaries.map((entry, index) => ({
|
|
114
|
+
rank: index + 1,
|
|
115
|
+
...entry,
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function getModelLoadRankingHealth(windowMs = DEFAULT_WINDOW_MS) {
|
|
120
|
+
const health = getModelHealthWindow(windowMs);
|
|
121
|
+
return {
|
|
122
|
+
windowHours: health.windowHours,
|
|
123
|
+
rankedModels: health.models,
|
|
124
|
+
};
|
|
125
|
+
}
|
package/dist/gateway/proxy.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { PassThrough, Readable } from "node:stream";
|
|
2
|
+
import { Agent } from "undici";
|
|
3
|
+
import { createAnthropicMessagesEventStreamTransformer, maybeTransformAnthropicMessagesRequest, transformOpenAiChatCompletionToAnthropicMessage, transformUpstreamErrorToAnthropicError, } from "./anthropic-compat.js";
|
|
2
4
|
import { config } from "./config.js";
|
|
5
|
+
import { recordModelRequestSample } from "./model-load-metrics.js";
|
|
3
6
|
const HOP_BY_HOP_HEADERS = new Set([
|
|
4
7
|
"connection",
|
|
5
8
|
"keep-alive",
|
|
@@ -13,8 +16,37 @@ const HOP_BY_HOP_HEADERS = new Set([
|
|
|
13
16
|
const MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
|
|
14
17
|
const AUTO_MODEL = "auto";
|
|
15
18
|
let autoModelCursor = 0;
|
|
19
|
+
const upstreamAgent = new Agent({
|
|
20
|
+
connections: config.upstreamMaxConnections,
|
|
21
|
+
pipelining: 1,
|
|
22
|
+
keepAliveTimeout: config.upstreamKeepAliveTimeoutMs,
|
|
23
|
+
keepAliveMaxTimeout: config.upstreamKeepAliveMaxTimeoutMs,
|
|
24
|
+
});
|
|
25
|
+
const fetchWithDispatcher = fetch;
|
|
26
|
+
function formatGatewayLogValue(value) {
|
|
27
|
+
if (value === null || value === undefined || value === "") {
|
|
28
|
+
return "-";
|
|
29
|
+
}
|
|
30
|
+
const normalized = String(value);
|
|
31
|
+
return /\s|"/.test(normalized) ? JSON.stringify(normalized) : normalized;
|
|
32
|
+
}
|
|
33
|
+
function buildGatewayLogLine(protocol, event, fields) {
|
|
34
|
+
const parts = [
|
|
35
|
+
"[gateway]",
|
|
36
|
+
`protocol=${formatGatewayLogValue(protocol)}`,
|
|
37
|
+
`event=${formatGatewayLogValue(event)}`,
|
|
38
|
+
];
|
|
39
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
40
|
+
parts.push(`${key}=${formatGatewayLogValue(value)}`);
|
|
41
|
+
}
|
|
42
|
+
return parts.join(" ");
|
|
43
|
+
}
|
|
16
44
|
function logProxyModelRoute(params) {
|
|
17
|
-
console.log(
|
|
45
|
+
console.log(buildGatewayLogLine(params.protocol, "routed", {
|
|
46
|
+
requested_model: params.requestedModel,
|
|
47
|
+
used_model: params.usedModel,
|
|
48
|
+
route: params.routeName,
|
|
49
|
+
}));
|
|
18
50
|
}
|
|
19
51
|
function resolveRouteNameForModel(modelId) {
|
|
20
52
|
if (modelId && config.modelRouteMap[modelId]) {
|
|
@@ -23,7 +55,27 @@ function resolveRouteNameForModel(modelId) {
|
|
|
23
55
|
return config.modelRouteMap["*"]?.routeName ?? null;
|
|
24
56
|
}
|
|
25
57
|
function logProxyModelSwitch(params) {
|
|
26
|
-
console.log(
|
|
58
|
+
console.log(buildGatewayLogLine(params.protocol, "switch", {
|
|
59
|
+
trigger_status: params.triggerStatus,
|
|
60
|
+
from_model: params.fromModel,
|
|
61
|
+
from_route: params.fromRoute,
|
|
62
|
+
to_model: params.toModel,
|
|
63
|
+
to_route: params.toRoute,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
function resolveGatewayProtocolFromPath(requestPath) {
|
|
67
|
+
const { pathname } = parsePathnameAndSearch(requestPath);
|
|
68
|
+
if (pathname === "/anthropic" ||
|
|
69
|
+
pathname.startsWith("/anthropic/") ||
|
|
70
|
+
isAnthropicApiPath(pathname)) {
|
|
71
|
+
return "anthropic";
|
|
72
|
+
}
|
|
73
|
+
return "openai";
|
|
74
|
+
}
|
|
75
|
+
function resolveGatewayProtocol(request) {
|
|
76
|
+
const rawUrl = request.url ?? "/";
|
|
77
|
+
const normalizedRawUrl = rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
|
|
78
|
+
return resolveGatewayProtocolFromPath(normalizedRawUrl);
|
|
27
79
|
}
|
|
28
80
|
function sendJson(response, statusCode, payload) {
|
|
29
81
|
if (response.writableEnded) {
|
|
@@ -39,11 +91,25 @@ function normalizeRequestPath(request) {
|
|
|
39
91
|
const rawUrl = request.url ?? "/";
|
|
40
92
|
try {
|
|
41
93
|
const parsed = new URL(rawUrl, "http://localhost");
|
|
42
|
-
return `${parsed.pathname}${parsed.search}
|
|
94
|
+
return normalizeGatewayRequestPath(`${parsed.pathname}${parsed.search}`);
|
|
43
95
|
}
|
|
44
96
|
catch {
|
|
45
|
-
|
|
97
|
+
const normalizedRawUrl = rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
|
|
98
|
+
return normalizeGatewayRequestPath(normalizedRawUrl);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function normalizeGatewayRequestPath(requestPath) {
|
|
102
|
+
const { pathname, search } = parsePathnameAndSearch(requestPath);
|
|
103
|
+
if (pathname === "/anthropic") {
|
|
104
|
+
return `/v1${search}`;
|
|
46
105
|
}
|
|
106
|
+
if (pathname === "/anthropic/v1" || pathname.startsWith("/anthropic/v1/")) {
|
|
107
|
+
return `${pathname.slice("/anthropic".length)}${search}`;
|
|
108
|
+
}
|
|
109
|
+
if (pathname.startsWith("/anthropic/")) {
|
|
110
|
+
return `/v1${pathname.slice("/anthropic".length)}${search}`;
|
|
111
|
+
}
|
|
112
|
+
return `${pathname}${search}`;
|
|
47
113
|
}
|
|
48
114
|
function rotateCandidates(candidates, startIndex) {
|
|
49
115
|
if (candidates.length <= 1) {
|
|
@@ -71,31 +137,102 @@ function buildModelCandidates(requestedModel) {
|
|
|
71
137
|
// Non-auto requests are pinned to the exact model specified by client.
|
|
72
138
|
return [requestedModel];
|
|
73
139
|
}
|
|
74
|
-
function
|
|
140
|
+
function parsePathnameAndSearch(requestPath) {
|
|
141
|
+
try {
|
|
142
|
+
const parsed = new URL(requestPath, "http://localhost");
|
|
143
|
+
return {
|
|
144
|
+
pathname: parsed.pathname,
|
|
145
|
+
search: parsed.search,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
const [pathnamePart, ...searchParts] = requestPath.split("?");
|
|
150
|
+
return {
|
|
151
|
+
pathname: pathnamePart || "/",
|
|
152
|
+
search: searchParts.length > 0 ? `?${searchParts.join("?")}` : "",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function isAnthropicApiPath(pathname) {
|
|
157
|
+
return (pathname === "/v1/messages" ||
|
|
158
|
+
pathname.startsWith("/v1/messages/") ||
|
|
159
|
+
pathname === "/v1/models" ||
|
|
160
|
+
pathname === "/v1/complete");
|
|
161
|
+
}
|
|
162
|
+
function rewriteFixedChatCompletionsRouteUrlForAnthropic(routeUrl, requestPath) {
|
|
163
|
+
const { pathname: requestPathname, search: requestSearch } = parsePathnameAndSearch(requestPath);
|
|
164
|
+
if (!isAnthropicApiPath(requestPathname)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
let parsedRouteUrl;
|
|
168
|
+
try {
|
|
169
|
+
parsedRouteUrl = new URL(routeUrl);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const normalizedRoutePath = parsedRouteUrl.pathname.replace(/\/+$/, "");
|
|
175
|
+
const fixedChatCompletionsSuffix = "/v1/chat/completions";
|
|
176
|
+
if (!normalizedRoutePath.endsWith(fixedChatCompletionsSuffix)) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const routePrefixPath = normalizedRoutePath.slice(0, -fixedChatCompletionsSuffix.length);
|
|
180
|
+
parsedRouteUrl.pathname = `${routePrefixPath}${requestPathname}`.replace(/\/{2,}/g, "/");
|
|
181
|
+
parsedRouteUrl.search = requestSearch;
|
|
182
|
+
return parsedRouteUrl.toString();
|
|
183
|
+
}
|
|
184
|
+
function buildRoutedUpstreamUrl(requestPath, selectedRoute) {
|
|
75
185
|
if (!selectedRoute) {
|
|
76
|
-
return `${config.upstreamBaseUrl}${
|
|
186
|
+
return `${config.upstreamBaseUrl}${requestPath}`;
|
|
77
187
|
}
|
|
78
188
|
if (!selectedRoute.isBaseUrl) {
|
|
189
|
+
// Backward-compatible Anthropic support when route URL is fixed to /v1/chat/completions.
|
|
190
|
+
const anthropicCompatUrl = rewriteFixedChatCompletionsRouteUrlForAnthropic(selectedRoute.url, requestPath);
|
|
191
|
+
if (anthropicCompatUrl) {
|
|
192
|
+
return anthropicCompatUrl;
|
|
193
|
+
}
|
|
79
194
|
return selectedRoute.url;
|
|
80
195
|
}
|
|
81
196
|
const routeBase = selectedRoute.url.replace(/\/+$/, "");
|
|
82
|
-
const requestPath = normalizeRequestPath(request);
|
|
83
197
|
if (routeBase.endsWith("/v1") && requestPath.startsWith("/v1")) {
|
|
84
198
|
return `${routeBase}${requestPath.slice(3)}`;
|
|
85
199
|
}
|
|
86
200
|
return `${routeBase}${requestPath}`;
|
|
87
201
|
}
|
|
88
|
-
function resolveUpstreamTarget(
|
|
202
|
+
function resolveUpstreamTarget(requestPath, modelId) {
|
|
89
203
|
const modelRoute = modelId ? config.modelRouteMap[modelId] ?? null : null;
|
|
90
204
|
const wildcardRoute = config.modelRouteMap["*"] ?? null;
|
|
91
205
|
const selectedRoute = modelRoute ?? wildcardRoute;
|
|
92
206
|
return {
|
|
93
|
-
upstreamUrl: buildRoutedUpstreamUrl(
|
|
207
|
+
upstreamUrl: buildRoutedUpstreamUrl(requestPath, selectedRoute),
|
|
94
208
|
selectedRoute,
|
|
95
209
|
};
|
|
96
210
|
}
|
|
211
|
+
async function logUpstreamErrorResponse(params) {
|
|
212
|
+
let detail = "-";
|
|
213
|
+
try {
|
|
214
|
+
const raw = await params.response.clone().text();
|
|
215
|
+
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
216
|
+
if (normalized) {
|
|
217
|
+
detail = normalized.slice(0, 2000);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
detail = "<unavailable>";
|
|
222
|
+
}
|
|
223
|
+
console.error(buildGatewayLogLine(params.protocol, "upstream_error", {
|
|
224
|
+
status: params.response.status,
|
|
225
|
+
path: params.requestPath,
|
|
226
|
+
route: params.routeName,
|
|
227
|
+
model: params.modelId,
|
|
228
|
+
upstream: params.upstreamUrl,
|
|
229
|
+
detail,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
97
232
|
function buildUpstreamHeaders(reqHeaders, bodyLength, selectedRoute) {
|
|
98
233
|
const headers = new Headers();
|
|
234
|
+
const selectedAuthHeader = selectedRoute?.authHeader || "authorization";
|
|
235
|
+
const conflictingAuthHeaders = ["authorization", "x-api-key", "api-key"];
|
|
99
236
|
for (const [key, value] of Object.entries(reqHeaders)) {
|
|
100
237
|
if (value === undefined) {
|
|
101
238
|
continue;
|
|
@@ -106,13 +243,20 @@ function buildUpstreamHeaders(reqHeaders, bodyLength, selectedRoute) {
|
|
|
106
243
|
}
|
|
107
244
|
headers.set(key, Array.isArray(value) ? value.join(",") : String(value));
|
|
108
245
|
}
|
|
246
|
+
if (selectedRoute?.apiKey) {
|
|
247
|
+
for (const headerName of conflictingAuthHeaders) {
|
|
248
|
+
if (headerName !== selectedAuthHeader) {
|
|
249
|
+
headers.delete(headerName);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
109
253
|
if (selectedRoute?.headers) {
|
|
110
254
|
for (const [key, value] of Object.entries(selectedRoute.headers)) {
|
|
111
255
|
headers.set(key, value);
|
|
112
256
|
}
|
|
113
257
|
}
|
|
114
258
|
if (selectedRoute?.apiKey) {
|
|
115
|
-
const authHeader =
|
|
259
|
+
const authHeader = selectedAuthHeader;
|
|
116
260
|
const authPrefix = selectedRoute.authPrefix ?? "Bearer ";
|
|
117
261
|
if (!headers.has(authHeader)) {
|
|
118
262
|
headers.set(authHeader, `${authPrefix}${selectedRoute.apiKey}`);
|
|
@@ -224,10 +368,61 @@ async function fetchWithTimeout(url, options, timeoutMs) {
|
|
|
224
368
|
const controller = new AbortController();
|
|
225
369
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
226
370
|
try {
|
|
227
|
-
return await
|
|
371
|
+
return await fetchWithDispatcher(url, {
|
|
372
|
+
...options,
|
|
373
|
+
signal: controller.signal,
|
|
374
|
+
dispatcher: upstreamAgent,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
clearTimeout(timeoutId);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function createClientAbortSignal(request, response) {
|
|
382
|
+
const controller = new AbortController();
|
|
383
|
+
let aborted = false;
|
|
384
|
+
const abort = () => {
|
|
385
|
+
if (aborted) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
aborted = true;
|
|
389
|
+
controller.abort();
|
|
390
|
+
};
|
|
391
|
+
request.once("aborted", abort);
|
|
392
|
+
response.once("close", () => {
|
|
393
|
+
if (!response.writableEnded) {
|
|
394
|
+
abort();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
return controller.signal;
|
|
398
|
+
}
|
|
399
|
+
async function fetchWithTimeoutAndClientSignal(url, options, timeoutMs, clientSignal) {
|
|
400
|
+
if (!clientSignal) {
|
|
401
|
+
return fetchWithTimeout(url, options, timeoutMs);
|
|
402
|
+
}
|
|
403
|
+
const controller = new AbortController();
|
|
404
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
405
|
+
const onClientAbort = () => {
|
|
406
|
+
if (!controller.signal.aborted) {
|
|
407
|
+
controller.abort();
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
if (clientSignal.aborted) {
|
|
411
|
+
onClientAbort();
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
clientSignal.addEventListener("abort", onClientAbort, { once: true });
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
return await fetchWithDispatcher(url, {
|
|
418
|
+
...options,
|
|
419
|
+
signal: controller.signal,
|
|
420
|
+
dispatcher: upstreamAgent,
|
|
421
|
+
});
|
|
228
422
|
}
|
|
229
423
|
finally {
|
|
230
424
|
clearTimeout(timeoutId);
|
|
425
|
+
clientSignal.removeEventListener("abort", onClientAbort);
|
|
231
426
|
}
|
|
232
427
|
}
|
|
233
428
|
async function disposeBody(response) {
|
|
@@ -295,6 +490,9 @@ async function readRequestBody(request) {
|
|
|
295
490
|
export async function proxyRequest(request, response) {
|
|
296
491
|
const method = (request.method ?? "GET").toUpperCase();
|
|
297
492
|
const supportsBody = method !== "GET" && method !== "HEAD";
|
|
493
|
+
const clientSignal = createClientAbortSignal(request, response);
|
|
494
|
+
const normalizedRequestPath = normalizeRequestPath(request);
|
|
495
|
+
const requestProtocol = resolveGatewayProtocol(request);
|
|
298
496
|
let incomingBody = Buffer.alloc(0);
|
|
299
497
|
if (supportsBody) {
|
|
300
498
|
try {
|
|
@@ -344,23 +542,64 @@ export async function proxyRequest(request, response) {
|
|
|
344
542
|
let switchNotice = null;
|
|
345
543
|
for (let attemptIndex = 0; attemptIndex < modelCandidates.length; attemptIndex += 1) {
|
|
346
544
|
const modelId = modelCandidates[attemptIndex];
|
|
347
|
-
let
|
|
348
|
-
|
|
349
|
-
|
|
545
|
+
let requestPath = normalizedRequestPath;
|
|
546
|
+
let responseFormat = null;
|
|
547
|
+
let requestJsonPayload = null;
|
|
548
|
+
if (supportsBody && parsedJsonBody) {
|
|
549
|
+
requestJsonPayload = {
|
|
350
550
|
...parsedJsonBody,
|
|
351
|
-
model: modelId,
|
|
352
|
-
}
|
|
551
|
+
...(modelId ? { model: modelId } : {}),
|
|
552
|
+
};
|
|
353
553
|
}
|
|
354
|
-
|
|
554
|
+
let { upstreamUrl, selectedRoute } = resolveUpstreamTarget(requestPath, modelId);
|
|
355
555
|
lastAttemptRouteName = selectedRoute?.routeName ?? null;
|
|
556
|
+
if (requestJsonPayload) {
|
|
557
|
+
const compatRequest = maybeTransformAnthropicMessagesRequest({
|
|
558
|
+
requestPath,
|
|
559
|
+
upstreamUrl,
|
|
560
|
+
body: requestJsonPayload,
|
|
561
|
+
});
|
|
562
|
+
if (compatRequest.error) {
|
|
563
|
+
console.error(buildGatewayLogLine(requestProtocol, "compat_error", {
|
|
564
|
+
path: requestPath,
|
|
565
|
+
route: selectedRoute?.routeName ?? null,
|
|
566
|
+
model: modelId,
|
|
567
|
+
detail: compatRequest.error,
|
|
568
|
+
}));
|
|
569
|
+
sendJson(response, 400, {
|
|
570
|
+
error: {
|
|
571
|
+
message: compatRequest.error,
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
requestPath = compatRequest.requestPath;
|
|
577
|
+
requestJsonPayload = compatRequest.body;
|
|
578
|
+
responseFormat = compatRequest.responseFormat;
|
|
579
|
+
if (responseFormat) {
|
|
580
|
+
upstreamUrl = buildRoutedUpstreamUrl(requestPath, selectedRoute);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
let bodyBuffer = supportsBody && incomingBody.length > 0 ? incomingBody : undefined;
|
|
584
|
+
if (supportsBody && requestJsonPayload) {
|
|
585
|
+
bodyBuffer = Buffer.from(JSON.stringify(requestJsonPayload), "utf8");
|
|
586
|
+
}
|
|
356
587
|
const requestBody = bodyBuffer ? new Uint8Array(bodyBuffer) : undefined;
|
|
357
588
|
const headers = buildUpstreamHeaders(request.headers, bodyBuffer ? bodyBuffer.length : undefined, selectedRoute);
|
|
589
|
+
const attemptStartedAt = Date.now();
|
|
358
590
|
try {
|
|
359
|
-
const upstreamResponse = await
|
|
591
|
+
const upstreamResponse = await fetchWithTimeoutAndClientSignal(upstreamUrl, {
|
|
360
592
|
method,
|
|
361
593
|
headers,
|
|
362
594
|
body: requestBody,
|
|
363
|
-
}, config.timeoutMs);
|
|
595
|
+
}, config.timeoutMs, clientSignal);
|
|
596
|
+
const headerLoadMs = Date.now() - attemptStartedAt;
|
|
597
|
+
const modelForMetric = modelId ?? requestedModel;
|
|
598
|
+
recordModelRequestSample(modelForMetric, {
|
|
599
|
+
ok: upstreamResponse.ok,
|
|
600
|
+
responseMs: headerLoadMs,
|
|
601
|
+
statusCode: upstreamResponse.status,
|
|
602
|
+
});
|
|
364
603
|
const contentType = (upstreamResponse.headers.get("content-type") ?? "").toLowerCase();
|
|
365
604
|
const isEventStream = contentType.includes("text/event-stream");
|
|
366
605
|
const isJsonResponse = contentType.includes("application/json");
|
|
@@ -378,6 +617,7 @@ export async function proxyRequest(request, response) {
|
|
|
378
617
|
const triggerStatus = retryTriggerStatus ?? upstreamResponse.status;
|
|
379
618
|
const nextRouteName = resolveRouteNameForModel(nextModel);
|
|
380
619
|
logProxyModelSwitch({
|
|
620
|
+
protocol: requestProtocol,
|
|
381
621
|
triggerStatus,
|
|
382
622
|
fromModel: modelId,
|
|
383
623
|
toModel: nextModel,
|
|
@@ -396,6 +636,16 @@ export async function proxyRequest(request, response) {
|
|
|
396
636
|
await disposeBody(upstreamResponse);
|
|
397
637
|
continue;
|
|
398
638
|
}
|
|
639
|
+
if (!upstreamResponse.ok) {
|
|
640
|
+
await logUpstreamErrorResponse({
|
|
641
|
+
protocol: requestProtocol,
|
|
642
|
+
requestPath,
|
|
643
|
+
upstreamUrl,
|
|
644
|
+
routeName: selectedRoute?.routeName ?? null,
|
|
645
|
+
modelId,
|
|
646
|
+
response: upstreamResponse,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
399
649
|
const attemptCount = attemptIndex + 1;
|
|
400
650
|
const effectiveSwitchNotice = switchNotice;
|
|
401
651
|
copyResponseHeaders(upstreamResponse, response);
|
|
@@ -407,6 +657,7 @@ export async function proxyRequest(request, response) {
|
|
|
407
657
|
response.setHeader("x-gateway-switched", "1");
|
|
408
658
|
}
|
|
409
659
|
logProxyModelRoute({
|
|
660
|
+
protocol: requestProtocol,
|
|
410
661
|
requestedModel,
|
|
411
662
|
usedModel: modelId,
|
|
412
663
|
routeName: selectedRoute?.routeName ?? null,
|
|
@@ -416,6 +667,61 @@ export async function proxyRequest(request, response) {
|
|
|
416
667
|
response.end();
|
|
417
668
|
return;
|
|
418
669
|
}
|
|
670
|
+
if (responseFormat === "anthropic-messages" && isEventStream) {
|
|
671
|
+
const nodeStream = Readable.fromWeb(upstreamResponse.body);
|
|
672
|
+
const anthropicStream = nodeStream.pipe(createAnthropicMessagesEventStreamTransformer(modelId));
|
|
673
|
+
response.removeHeader("content-length");
|
|
674
|
+
response.setHeader("content-type", "text/event-stream; charset=utf-8");
|
|
675
|
+
if (effectiveSwitchNotice) {
|
|
676
|
+
createSsePrefixedStream(anthropicStream, effectiveSwitchNotice).pipe(response);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
anthropicStream.on("error", () => {
|
|
680
|
+
if (!response.writableEnded) {
|
|
681
|
+
response.destroy();
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
anthropicStream.pipe(response);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (responseFormat === "anthropic-messages" && isJsonResponse && !isEventStream) {
|
|
688
|
+
const rawText = await upstreamResponse.text();
|
|
689
|
+
response.removeHeader("content-length");
|
|
690
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
691
|
+
try {
|
|
692
|
+
const parsed = JSON.parse(rawText);
|
|
693
|
+
if (!upstreamResponse.ok) {
|
|
694
|
+
response.end(JSON.stringify(transformUpstreamErrorToAnthropicError(parsed, upstreamResponse.status)));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const transformed = transformOpenAiChatCompletionToAnthropicMessage(parsed, modelId);
|
|
698
|
+
if (transformed.value) {
|
|
699
|
+
response.end(JSON.stringify(transformed.value));
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
console.error(buildGatewayLogLine(requestProtocol, "compat_error", {
|
|
703
|
+
path: requestPath,
|
|
704
|
+
route: selectedRoute?.routeName ?? null,
|
|
705
|
+
model: modelId,
|
|
706
|
+
detail: transformed.error ?? "Unknown transform error",
|
|
707
|
+
}));
|
|
708
|
+
sendJson(response, 502, {
|
|
709
|
+
error: {
|
|
710
|
+
message: "Gateway failed to translate the OpenAI-compatible response to Anthropic format.",
|
|
711
|
+
detail: transformed.error ?? "Unknown transform error",
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
if (!upstreamResponse.ok) {
|
|
718
|
+
response.end(JSON.stringify(transformUpstreamErrorToAnthropicError({
|
|
719
|
+
message: rawText,
|
|
720
|
+
}, upstreamResponse.status)));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
419
725
|
if (effectiveSwitchNotice && isJsonResponse && !isEventStream) {
|
|
420
726
|
const rawText = await upstreamResponse.text();
|
|
421
727
|
response.removeHeader("content-length");
|
|
@@ -449,6 +755,11 @@ export async function proxyRequest(request, response) {
|
|
|
449
755
|
}
|
|
450
756
|
catch (error) {
|
|
451
757
|
lastError = error;
|
|
758
|
+
recordModelRequestSample(modelId ?? requestedModel, {
|
|
759
|
+
ok: false,
|
|
760
|
+
responseMs: Date.now() - attemptStartedAt,
|
|
761
|
+
statusCode: null,
|
|
762
|
+
});
|
|
452
763
|
if (attemptIndex < modelCandidates.length - 1) {
|
|
453
764
|
continue;
|
|
454
765
|
}
|
|
@@ -461,6 +772,7 @@ export async function proxyRequest(request, response) {
|
|
|
461
772
|
const errorStatusCode = timeoutLike ? 504 : 502;
|
|
462
773
|
const lastTriedModel = modelCandidates[modelCandidates.length - 1] ?? null;
|
|
463
774
|
logProxyModelRoute({
|
|
775
|
+
protocol: requestProtocol,
|
|
464
776
|
requestedModel,
|
|
465
777
|
usedModel: lastTriedModel,
|
|
466
778
|
routeName: lastAttemptRouteName,
|