opencode-agentrouter-auth 0.0.0-stage → 0.1.5
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 +25 -0
- package/LICENSE +21 -0
- package/LOCAL-CHECKIN.md +70 -0
- package/README.md +584 -2
- package/body-inspection.mjs +32 -0
- package/content-filter.example.json +4 -0
- package/content-filter.mjs +219 -0
- package/docs/OPTIMIZATION.md +235 -0
- package/github-cookie.mjs +73 -0
- package/index.mjs +1218 -0
- package/local-checkin.mjs +270 -0
- package/package.json +40 -3
- package/request.mjs +94 -0
- package/response.mjs +308 -0
package/response.mjs
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { readCloneText } from "./body-inspection.mjs";
|
|
2
|
+
|
|
3
|
+
// Some relays commit HTTP 200 before they know whether a model can answer.
|
|
4
|
+
// Inspect only the bounded SSE prelude, before any answer or tool call escapes.
|
|
5
|
+
// Success (including its original bytes), unknown events, and late failures pass
|
|
6
|
+
// through. Never retry here: magpie owns routing, the caller owns cancellation.
|
|
7
|
+
const PRELUDE_BYTES = 64 * 1024;
|
|
8
|
+
const PRELUDE_MS = 15_000;
|
|
9
|
+
const TOKEN_RATE_LIMIT = /exceeded\s+token\s+rate\s+limit/i;
|
|
10
|
+
const TRANSIENT_STATUSES = [429, 500, 502, 503, 504];
|
|
11
|
+
const TRANSIENT_CODES = ["rate_limit_exceeded", "server_error", "overloaded_error", "high_demand"];
|
|
12
|
+
|
|
13
|
+
const messageText = value => typeof value.message === "string" && value.message
|
|
14
|
+
? value.message : typeof value.msg === "string" ? value.msg : "";
|
|
15
|
+
const numericCode = value => typeof value === "number" ? value
|
|
16
|
+
: typeof value === "string" && /^\d{3}$/.test(value) ? Number(value) : null;
|
|
17
|
+
|
|
18
|
+
function hasNestedError(value) {
|
|
19
|
+
return value != null && typeof value === "object" && !Array.isArray(value) &&
|
|
20
|
+
("error" in value || (value.response != null && typeof value.response === "object" && "error" in value.response));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasErrorDetail(value) {
|
|
24
|
+
return value != null && (messageText(value).trim().length > 0 || value.code === "content-blocked" ||
|
|
25
|
+
[value.status, value.status_code, value.code].some(code => TRANSIENT_STATUSES.includes(numericCode(code))) ||
|
|
26
|
+
[value.code, value.type].some(code => TRANSIENT_CODES.includes(code)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function inheritErrorStatus(ancestor, detail) {
|
|
30
|
+
if (!hasErrorDetail(detail)) return null;
|
|
31
|
+
const status = Object.fromEntries(["status", "status_code", "code"]
|
|
32
|
+
.filter(key => TRANSIENT_STATUSES.includes(numericCode(ancestor[key])))
|
|
33
|
+
.map(key => [key, ancestor[key]]));
|
|
34
|
+
return { ...status, ...detail };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function errorDetail(value, depth = 0) {
|
|
38
|
+
if (depth > 4 || value == null) return null;
|
|
39
|
+
if (typeof value === "string") {
|
|
40
|
+
try { return errorDetail(JSON.parse(value), depth + 1); }
|
|
41
|
+
catch { return { message: value }; }
|
|
42
|
+
}
|
|
43
|
+
if (typeof value !== "object" || Array.isArray(value)) return null;
|
|
44
|
+
const message = messageText(value);
|
|
45
|
+
const detail = message ? { ...value, message } : value;
|
|
46
|
+
if (hasNestedError(value)) {
|
|
47
|
+
const nested = errorDetail(value.error ?? value.response?.error, depth + 1);
|
|
48
|
+
return inheritErrorStatus(value, nested);
|
|
49
|
+
}
|
|
50
|
+
if (!message) return detail;
|
|
51
|
+
let encoded;
|
|
52
|
+
try { encoded = JSON.parse(message); } catch { return detail; }
|
|
53
|
+
const nested = errorDetail(encoded, depth + 1);
|
|
54
|
+
// Exhaustion or an invalid encoded detail is not permission to use a partial parent.
|
|
55
|
+
return inheritErrorStatus(value, nested);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function changedHeaders(original) {
|
|
59
|
+
const h = new Headers(original);
|
|
60
|
+
h.set("content-type", "application/json");
|
|
61
|
+
// Fetch exposes decoded bytes, and the new body has a different length.
|
|
62
|
+
for (const k of ["content-length", "content-encoding", "transfer-encoding", "etag", "content-md5", "digest"]) h.delete(k);
|
|
63
|
+
return h;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isBusinessError(e, depth = 0) {
|
|
67
|
+
if (depth > 4 || e == null) return false;
|
|
68
|
+
if (typeof e === "string") {
|
|
69
|
+
try { return isBusinessError(JSON.parse(e), depth + 1); }
|
|
70
|
+
catch { return isBusinessError({ message: e }, depth + 1); }
|
|
71
|
+
}
|
|
72
|
+
if (typeof e !== "object" || Array.isArray(e)) return false;
|
|
73
|
+
if (isBusinessError(e.error ?? e.response?.error, depth + 1)) return true;
|
|
74
|
+
const message = messageText(e);
|
|
75
|
+
if (message) {
|
|
76
|
+
try { if (isBusinessError(JSON.parse(message), depth + 1)) return true; } catch {}
|
|
77
|
+
}
|
|
78
|
+
if ([e.status, e.status_code, e.code].some(value => [401, 402, 403].includes(numericCode(value)))) return true;
|
|
79
|
+
const tags = [e.code, e.type].filter(value => typeof value === "string").join(" ");
|
|
80
|
+
if (/auth|permission|forbidden|invalid_api_key|quota|balance|billing|payment|content[-_]?(policy|filter|blocked)|moderation|safety/i.test(tags)) return true;
|
|
81
|
+
return /invalid.{0,12}api.?key|unauthenticated|unauthorized|authentication|permission denied|forbidden|quota|balance|billing|payment required|content policy|content filter/i.test(message);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function transientFailureResponse(e, originalHeaders) {
|
|
85
|
+
const numericStatus = [e.status, e.status_code, e.code].map(numericCode).find(value =>
|
|
86
|
+
TRANSIENT_STATUSES.includes(value));
|
|
87
|
+
const code = [e.code, e.type].find(value =>
|
|
88
|
+
TRANSIENT_CODES.includes(value));
|
|
89
|
+
const rateLimited = numericStatus === 429 || code === "rate_limit_exceeded";
|
|
90
|
+
const busy = code === "overloaded_error" || code === "high_demand" ||
|
|
91
|
+
/currently experiencing high demand/i.test(e.message ?? "");
|
|
92
|
+
const status = numericStatus ?? (rateLimited ? 429 : busy ? 503 : code === "server_error" ? 500 : null);
|
|
93
|
+
if (!status) return null;
|
|
94
|
+
return new Response(JSON.stringify({ error: {
|
|
95
|
+
code: e.code ?? status,
|
|
96
|
+
type: rateLimited ? "agent_router_upstream_rate_limit" : busy ? "agent_router_upstream_busy" : "agent_router_upstream_error",
|
|
97
|
+
message: rateLimited
|
|
98
|
+
? "AgentRouter 上游报告了速率限制。请按上游 Retry-After 等待,或稍后再试;插件未自动重试。"
|
|
99
|
+
: busy ? "AgentRouter 上游当前繁忙。请稍后再试;插件未自动重试。"
|
|
100
|
+
: "AgentRouter 上游报告了暂时性服务错误。请稍后再试;插件未自动重试。",
|
|
101
|
+
upstream: typeof e.message === "string" ? e.message : "",
|
|
102
|
+
} }), { status, headers: changedHeaders(originalHeaders) });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function failureResponse(value, originalHeaders) {
|
|
106
|
+
const e = hasNestedError(value)
|
|
107
|
+
? inheritErrorStatus(value, errorDetail(value.error ?? value.response?.error, 1))
|
|
108
|
+
: errorDetail(value);
|
|
109
|
+
if (!e) return null;
|
|
110
|
+
const message = typeof e.message === "string" ? e.message : "";
|
|
111
|
+
const blocked = e.code === "content-blocked";
|
|
112
|
+
const foreignItem = /requested item was created under a different[^.]{0,100}OpenAI resource/i.test(message);
|
|
113
|
+
if (!blocked && !foreignItem && (isBusinessError(value) || isBusinessError(e))) return null;
|
|
114
|
+
const capped = !blocked && !foreignItem && TOKEN_RATE_LIMIT.test(message);
|
|
115
|
+
if (!capped && !blocked && !foreignItem) return transientFailureResponse(e, originalHeaders);
|
|
116
|
+
const headers = changedHeaders(originalHeaders);
|
|
117
|
+
if (capped && !headers.has("retry-after")) headers.set("retry-after", "60");
|
|
118
|
+
const model = /requests to (\S+) for \S+/i.exec(message)?.[1];
|
|
119
|
+
const id = /request id:\s*([A-Za-z0-9]+)/i.exec(message)?.[1];
|
|
120
|
+
const trace = /trace_id[=:]([A-Za-z0-9_-]{1,128})/.exec(message)?.[1];
|
|
121
|
+
const note = capped
|
|
122
|
+
? `AgentRouter${model ? ` 的 ${model}` : ""} 达到了上游 token 速率上限。请按 Retry-After 等待后重试;上游未提供等待时间时,插件建议一分钟后再试,不保证届时已恢复。不要立即重发同一请求。`
|
|
123
|
+
: foreignItem
|
|
124
|
+
? `AgentRouter 的 Responses 历史条目属于另一个 OpenAI 资源${trace ? ` [trace_id=${trace}]` : ""}。请由客户端重发完整内联历史(store:false),或固定到创建该条目的同一上游资源;仅有 previous_response_id / item_reference 或不兼容的加密状态时,插件不能无损恢复。未自动重试。`
|
|
125
|
+
: `AgentRouter 的内容过滤器拒绝了这段请求内容${id ? ` (request id: ${id})` : ""}。请改写被拒的部分,或改用其他服务商。`;
|
|
126
|
+
return new Response(JSON.stringify({ error: {
|
|
127
|
+
code: e.code ?? (capped ? "token_rate_limit" : foreignItem ? "resource_mismatch" : "content-blocked"),
|
|
128
|
+
message: note,
|
|
129
|
+
type: capped ? "agent_router_token_rate_limit" : foreignItem ? "agent_router_resource_mismatch" : "agent_router_content_blocked",
|
|
130
|
+
upstream: message,
|
|
131
|
+
} }), { status: capped ? 429 : 400, headers });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function emptyResponse(headers) {
|
|
135
|
+
return new Response(JSON.stringify({ error: {
|
|
136
|
+
code: "empty_response",
|
|
137
|
+
type: "agent_router_empty_response",
|
|
138
|
+
message: "AgentRouter 返回了空的完成事件(没有输出且用量为零),本次没有得到有效回答。",
|
|
139
|
+
} }), { status: 502, headers: changedHeaders(headers) });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hasArrayOutput(value) {
|
|
143
|
+
return value !== undefined && (!Array.isArray(value) || value.length > 0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function hasStringOutput(value) {
|
|
147
|
+
return value !== undefined && (typeof value !== "string" || value.length > 0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hasPartOutput(part) {
|
|
151
|
+
if (!part || typeof part !== "object") return true;
|
|
152
|
+
if (!["output_text", "summary_text", "refusal"].includes(part.type)) return true;
|
|
153
|
+
if (Object.keys(part).some(key => !["type", "text", "refusal", "annotations", "logprobs"].includes(key))) return true;
|
|
154
|
+
return hasStringOutput(part.text) || hasStringOutput(part.refusal) ||
|
|
155
|
+
hasArrayOutput(part.annotations) || hasArrayOutput(part.logprobs);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasOutput(item) {
|
|
159
|
+
if (!item || typeof item !== "object") return true;
|
|
160
|
+
if (item.type !== "message" && item.type !== "reasoning") return true;
|
|
161
|
+
if (Object.keys(item).some(key => !["type", "id", "role", "status", "content", "summary"].includes(key))) return true;
|
|
162
|
+
for (const key of ["content", "summary"]) {
|
|
163
|
+
if (item[key] !== undefined && (!Array.isArray(item[key]) || item[key].some(hasPartOutput))) return true;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hasChatOutput(choice) {
|
|
169
|
+
if (!choice || typeof choice !== "object") return true;
|
|
170
|
+
if (Object.keys(choice).some(key => !["index", "delta", "message", "finish_reason", "logprobs"].includes(key))) return true;
|
|
171
|
+
const delta = choice.delta ?? choice.message ?? {};
|
|
172
|
+
if (!delta || typeof delta !== "object" || Array.isArray(delta)) return true;
|
|
173
|
+
if (Object.keys(delta).some(key => !["role", "content", "refusal", "reasoning", "reasoning_content", "tool_calls", "function_call"].includes(key))) return true;
|
|
174
|
+
return (delta.role !== undefined && delta.role !== "assistant") ||
|
|
175
|
+
[delta.content, delta.refusal, delta.reasoning, delta.reasoning_content].some(hasStringOutput) ||
|
|
176
|
+
hasArrayOutput(delta.tool_calls) || (delta.function_call !== undefined && delta.function_call !== null) ||
|
|
177
|
+
(choice.finish_reason !== undefined && choice.finish_reason !== null && choice.finish_reason !== "") ||
|
|
178
|
+
(choice.logprobs !== undefined && choice.logprobs !== null);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// null means harmless prelude; true means release unchanged; Response is an
|
|
182
|
+
// explicit early failure. Looking only in error envelopes avoids matching text
|
|
183
|
+
// in a perfectly good answer that quotes an error message.
|
|
184
|
+
function inspectEvent(frame, headers) {
|
|
185
|
+
const lines = frame.split(/\r\n|\n|\r/);
|
|
186
|
+
let name;
|
|
187
|
+
const dataLines = [];
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
const colon = line.indexOf(":");
|
|
190
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
191
|
+
const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
192
|
+
if (field === "event") name = value; // SSE uses the last field, including an empty one.
|
|
193
|
+
if (field === "data") dataLines.push(value);
|
|
194
|
+
}
|
|
195
|
+
const data = dataLines.join("\n");
|
|
196
|
+
if (!data) return lines.every(line => !line || line.startsWith(":")) ? null : true;
|
|
197
|
+
if (data === "[DONE]") return true;
|
|
198
|
+
let j;
|
|
199
|
+
try { j = JSON.parse(data); } catch { return true; }
|
|
200
|
+
if (!j || typeof j !== "object") return true;
|
|
201
|
+
const type = j.type ?? name;
|
|
202
|
+
const knownTypes = ["message", "error", "response.failed", "response.completed", "response.created", "response.in_progress", "response.output_item.added", "response.output_item.done", "response.content_part.added"];
|
|
203
|
+
const namedFailure = name === "error" || name === "response.failed";
|
|
204
|
+
if (name && !knownTypes.includes(name)) return true;
|
|
205
|
+
if (!namedFailure && type && !knownTypes.includes(type)) return true;
|
|
206
|
+
if (typeof j.delta === "string" && j.delta) return true;
|
|
207
|
+
if (j.item && hasOutput(j.item)) return true;
|
|
208
|
+
if (j.part && hasPartOutput(j.part)) return true;
|
|
209
|
+
if (Array.isArray(j.response?.output) && j.response.output.some(hasOutput)) return true;
|
|
210
|
+
if (Array.isArray(j.choices) && j.choices.some(hasChatOutput)) return true;
|
|
211
|
+
const error = j.error ?? j.response?.error ?? ((type === "error" || type === "response.failed" || name === "error" || name === "response.failed") ? j : null);
|
|
212
|
+
if (error) return failureResponse(j, headers) ?? true;
|
|
213
|
+
if (type === "response.completed") {
|
|
214
|
+
const r = j.response;
|
|
215
|
+
if (r?.status === "completed" && Array.isArray(r.output) && r.output.length === 0 &&
|
|
216
|
+
r.usage?.input_tokens === 0 && r.usage?.output_tokens === 0) return emptyResponse(headers);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
if (Array.isArray(j.choices)) return null; // known chat role-only/usage prelude
|
|
220
|
+
if (["response.created", "response.in_progress", "response.output_item.added", "response.output_item.done", "response.content_part.added"].includes(type)) return null;
|
|
221
|
+
return true; // unknown protocol or event: fail open, not data loss
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// CR ends a line immediately; a following LF (even in the next chunk) belongs
|
|
225
|
+
// to that same ending. This also handles legal mixed newline event separators.
|
|
226
|
+
function eventScanner(headers) {
|
|
227
|
+
let line = "", lines = [], afterCR = false;
|
|
228
|
+
return text => {
|
|
229
|
+
for (const char of text) {
|
|
230
|
+
if (afterCR && char === "\n") { afterCR = false; continue; }
|
|
231
|
+
afterCR = false;
|
|
232
|
+
if (char !== "\r" && char !== "\n") { line += char; continue; }
|
|
233
|
+
afterCR = char === "\r";
|
|
234
|
+
if (line) { lines.push(line); line = ""; continue; }
|
|
235
|
+
const result = inspectEvent(lines.join("\n"), headers);
|
|
236
|
+
lines = [];
|
|
237
|
+
if (result !== null) return result;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function inspectStream(res) {
|
|
244
|
+
if (!res.body) return res;
|
|
245
|
+
const reader = res.body.getReader();
|
|
246
|
+
const chunks = [];
|
|
247
|
+
const decoder = new TextDecoder();
|
|
248
|
+
const scan = eventScanner(res.headers);
|
|
249
|
+
let bytes = 0, inflight, timer;
|
|
250
|
+
const expired = new Promise(resolve => { timer = setTimeout(() => resolve(null), PRELUDE_MS); });
|
|
251
|
+
const cancel = reason => { void reader.cancel(reason).catch(() => {}); };
|
|
252
|
+
// Keep the in-flight read if the inspection deadline expires. A fresh read
|
|
253
|
+
// would skip that chunk; a tee/clone would buffer an unbounded unused branch.
|
|
254
|
+
try {
|
|
255
|
+
while (true) {
|
|
256
|
+
inflight = reader.read();
|
|
257
|
+
const next = await Promise.race([inflight, expired]);
|
|
258
|
+
if (next === null) break;
|
|
259
|
+
inflight = null;
|
|
260
|
+
if (next.done) break;
|
|
261
|
+
chunks.push(next.value);
|
|
262
|
+
bytes += next.value.byteLength;
|
|
263
|
+
if (bytes > PRELUDE_BYTES) break;
|
|
264
|
+
const result = scan(decoder.decode(next.value, { stream: true }));
|
|
265
|
+
if (result instanceof Response) { cancel("early upstream failure"); return result; }
|
|
266
|
+
if (result === true) return replay();
|
|
267
|
+
}
|
|
268
|
+
return replay();
|
|
269
|
+
} catch (error) {
|
|
270
|
+
cancel(error);
|
|
271
|
+
throw error;
|
|
272
|
+
} finally {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function replay() {
|
|
277
|
+
let i = 0;
|
|
278
|
+
return new Response(new ReadableStream({
|
|
279
|
+
async pull(controller) {
|
|
280
|
+
if (i < chunks.length) { controller.enqueue(chunks[i++]); return; }
|
|
281
|
+
try {
|
|
282
|
+
const next = await (inflight ?? reader.read());
|
|
283
|
+
inflight = null;
|
|
284
|
+
if (next.done) { reader.releaseLock(); controller.close(); }
|
|
285
|
+
else controller.enqueue(next.value);
|
|
286
|
+
} catch (error) { controller.error(error); }
|
|
287
|
+
},
|
|
288
|
+
cancel,
|
|
289
|
+
}), { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function explainResponse(res) {
|
|
294
|
+
if (!res) return res;
|
|
295
|
+
const type = res.headers.get("content-type") ?? "";
|
|
296
|
+
if (res.status === 200 && /text\/event-stream/i.test(type)) return inspectStream(res);
|
|
297
|
+
if (![200, 400, 429].includes(res.status)) return res;
|
|
298
|
+
// JSON success responses can also carry an error envelope. Ordinary successful
|
|
299
|
+
// replies, unknown failures and non-JSON content remain untouched.
|
|
300
|
+
if (res.status === 200 && !/\bjson\b/i.test(type)) return res;
|
|
301
|
+
const text = await readCloneText(res, { maxBytes: 64 * 1024, timeoutMs: 2000 });
|
|
302
|
+
if (text === null) return res;
|
|
303
|
+
let j;
|
|
304
|
+
try { j = JSON.parse(text); } catch { return res; }
|
|
305
|
+
const error = j?.error ?? j?.response?.error ??
|
|
306
|
+
((res.status >= 400 || j?.type === "error" || j?.type === "response.failed") ? j : null);
|
|
307
|
+
return error ? failureResponse(j, res.headers) ?? res : res;
|
|
308
|
+
}
|