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.
@@ -0,0 +1,219 @@
1
+ import { open } from "node:fs/promises";
2
+
3
+ const MARKER = "[FILTERED]";
4
+ const MAX_CONFIG_BYTES = 32 * 1024;
5
+ const MAX_TERMS = 128;
6
+ const MAX_TERM_LENGTH = 256;
7
+ const MAX_TOTAL_TERM_LENGTH = 8192;
8
+
9
+ function configurationError() {
10
+ const error = new Error("AgentRouter text filter configuration is invalid or unreadable. Check content-filter.local.json; no request was sent. Rule contents are not logged.");
11
+ error.code = "agent_router_text_filter_config";
12
+ return error;
13
+ }
14
+
15
+ function escapePattern(text) {
16
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
+ }
18
+
19
+ // Protect Markdown code rather than changing executable examples accidentally.
20
+ // An unclosed code delimiter conservatively protects the rest of the segment.
21
+ function inlineCodeRanges(text) {
22
+ const ranges = [];
23
+ const runs = /`+/g;
24
+ let opening;
25
+ while ((opening = runs.exec(text))) {
26
+ let closing;
27
+ while ((closing = runs.exec(text)) && closing[0].length !== opening[0].length) {}
28
+ ranges.push([opening.index, closing ? runs.lastIndex : text.length]);
29
+ if (!closing) break;
30
+ }
31
+ return ranges;
32
+ }
33
+
34
+ // Container fences have context-dependent indentation/closing rules. Without a
35
+ // full Markdown parser, preserve that whole field instead of risking code edits.
36
+ function codePrefix(line) {
37
+ // Advance monotonically: nested whitespace regexes can backtrack exponentially
38
+ // on innocent deeply quoted prose even when the request is only a few bytes.
39
+ const space = char => char === " " || char === "\t";
40
+ let position = 0, container = false, indented = false;
41
+ while (position < line.length) {
42
+ const start = position;
43
+ while (space(line[position])) {
44
+ if (line[position] === "\t") indented = true;
45
+ position++;
46
+ }
47
+ if (position - start >= 4) indented = true;
48
+ let end = position;
49
+ if (line[position] === ">") end++;
50
+ else if (["-", "+", "*"].includes(line[position]) && space(line[position + 1])) end++;
51
+ else {
52
+ while (line[end] >= "0" && line[end] <= "9") end++;
53
+ if (end > position && [".", ")"].includes(line[end]) && space(line[end + 1])) end++;
54
+ else end = position;
55
+ }
56
+ if (end === position) break;
57
+ container = true;
58
+ position = end;
59
+ if (space(line[position])) position++; // One container separator is not code indentation.
60
+ }
61
+ return { indented, containerFence: container && (line.startsWith("```", position) || line.startsWith("~~~", position)) };
62
+ }
63
+
64
+ function hasContainerFence(text) {
65
+ return text.split(/\r?\n/).some(line => codePrefix(line).containerFence);
66
+ }
67
+
68
+ // Keep structural separators with code, but leave indentation on the next
69
+ // nonblank line for that line's own Markdown classification.
70
+ function codeRangeWithBoundaries(text, start, end) {
71
+ while (start > 0 && /[ \t\r\n]/.test(text[start - 1])) start--;
72
+ let cursor = end;
73
+ while (cursor < text.length) {
74
+ let next = cursor;
75
+ while (text[next] === " " || text[next] === "\t") next++;
76
+ if (text[next] === "\r" && text[next + 1] === "\n") next += 2;
77
+ else if (text[next] === "\n") next++;
78
+ else break;
79
+ cursor = next;
80
+ }
81
+ return [start, cursor];
82
+ }
83
+
84
+ function outsideProseCode(text, replace) {
85
+ // Collect ranges against the original text, not sliced suffixes: inline code
86
+ // must not erase indentation, nor may indentation split a multiline code span.
87
+ const inline = inlineCodeRanges(text);
88
+ const protectedRanges = [...inline];
89
+ // A literal rule may contain the marker; protecting only matches that start
90
+ // with it still lets a longer rule consume a marker on a later pass.
91
+ for (const marker of text.matchAll(/\[FILTERED\]/g)) {
92
+ protectedRanges.push([marker.index, marker.index + marker[0].length]);
93
+ }
94
+ const paragraphs = text.split(/(\r?\n[ \t]*\r?\n)/);
95
+ let position = 0, inlineIndex = 0;
96
+ for (let index = 0; index < paragraphs.length; index++) {
97
+ const paragraph = paragraphs[index];
98
+ let linePosition = position;
99
+ for (const line of paragraph.split("\n")) {
100
+ while (inlineIndex < inline.length && inline[inlineIndex][1] <= linePosition) inlineIndex++;
101
+ const insideInline = inlineIndex < inline.length && inline[inlineIndex][0] <= linePosition;
102
+ if (!insideInline && line.trim() && codePrefix(line).indented) {
103
+ // Preserve both adjoining separators: consuming either can change the
104
+ // next pass's Markdown classification even without touching code bytes.
105
+ protectedRanges.push(codeRangeWithBoundaries(text, position, position + paragraph.length));
106
+ break;
107
+ }
108
+ linePosition += line.length + 1;
109
+ }
110
+ position += paragraph.length;
111
+ }
112
+ protectedRanges.sort((left, right) => left[0] - right[0]);
113
+ const output = [];
114
+ position = 0;
115
+ for (const [start, end] of protectedRanges) {
116
+ if (start > position) output.push(replace(text.slice(position, start)));
117
+ if (end > position) output.push(text.slice(Math.max(position, start), end));
118
+ position = Math.max(position, end);
119
+ }
120
+ output.push(replace(text.slice(position)));
121
+ return output.join("");
122
+ }
123
+
124
+ function outsideCode(text, replace) {
125
+ if (hasContainerFence(text)) return text;
126
+ const output = [];
127
+ const fences = /^ {0,3}(`{3,}|~{3,})([^\n]*)(?:\n|$)/gm;
128
+ let position = 0, opening = null, match;
129
+ while ((match = fences.exec(text))) {
130
+ if (!opening) {
131
+ const start = Math.max(position, codeRangeWithBoundaries(text, match.index, match.index)[0]);
132
+ output.push(outsideProseCode(text.slice(position, start), replace));
133
+ opening = { index: start, marker: match[1] };
134
+ } else if (match[1][0] === opening.marker[0] && match[1].length >= opening.marker.length && !match[2].trim()) {
135
+ const end = codeRangeWithBoundaries(text, opening.index, fences.lastIndex)[1];
136
+ output.push(text.slice(opening.index, end));
137
+ position = fences.lastIndex = end;
138
+ opening = null;
139
+ }
140
+ }
141
+ output.push(opening ? text.slice(opening.index) : outsideProseCode(text.slice(position), replace));
142
+ return output.join("");
143
+ }
144
+
145
+ // Literal, bounded rules only: no inferred vendor keywords and no user regex.
146
+ export function createTextFilter(config) {
147
+ if (!config || typeof config !== "object" || Array.isArray(config) ||
148
+ Object.keys(config).some(key => !["terms", "caseSensitive"].includes(key)) ||
149
+ !Array.isArray(config.terms) || config.terms.length > MAX_TERMS ||
150
+ (config.caseSensitive !== undefined && typeof config.caseSensitive !== "boolean") ||
151
+ config.terms.some(term => typeof term !== "string" || !term.trim() || term.length > MAX_TERM_LENGTH) ||
152
+ config.terms.reduce((total, term) => total + term.length, 0) > MAX_TOTAL_TERM_LENGTH) throw configurationError();
153
+ if (!config.terms.length) return null;
154
+ const terms = [...new Set(config.terms)].sort((left, right) => right.length - left.length);
155
+ const pattern = new RegExp(`(${escapePattern(MARKER)})|${terms.map(escapePattern).join("|")}`, config.caseSensitive === false ? "giu" : "gu");
156
+ return text => {
157
+ let replacements = 0;
158
+ const result = outsideCode(text, segment => segment.replace(pattern, (match, marker) => {
159
+ if (marker) return match;
160
+ replacements++;
161
+ return MARKER;
162
+ }));
163
+ return { text: result, replacements };
164
+ };
165
+ }
166
+
167
+ // Loaded when the provider loader is created. Editing the file requires a plugin
168
+ // reload. A missing file/empty list is off; a broken configured file is not ignored.
169
+ export async function loadTextFilter(path = new URL("./content-filter.local.json", import.meta.url)) {
170
+ let file;
171
+ try { file = await open(path, "r"); }
172
+ catch (error) { if (error.code === "ENOENT") return null; throw configurationError(); }
173
+ try {
174
+ if (!(await file.stat()).isFile()) throw configurationError();
175
+ const buffer = Buffer.alloc(MAX_CONFIG_BYTES + 1);
176
+ let bytes = 0;
177
+ while (bytes < buffer.length) {
178
+ const { bytesRead } = await file.read(buffer, bytes, buffer.length - bytes, bytes);
179
+ if (!bytesRead) break;
180
+ bytes += bytesRead;
181
+ }
182
+ if (bytes > MAX_CONFIG_BYTES) throw configurationError();
183
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytes));
184
+ return createTextFilter(JSON.parse(text));
185
+ } catch { throw configurationError(); }
186
+ finally { await file.close(); }
187
+ }
188
+
189
+ // This is a narrowly scoped text transformation, not a data-loss prevention
190
+ // boundary: tool data, annotations, media and server-side/opaque history stay put.
191
+ export function redactResponsesBody(body, filter) {
192
+ if (!filter || !body || typeof body !== "object" || Array.isArray(body)) return { body, replacements: 0 };
193
+ let replacements = 0;
194
+ const redact = text => { const result = filter(text); replacements += result.replacements; return result.text; };
195
+ const part = value => {
196
+ if (!value || typeof value !== "object" || !["input_text", "output_text"].includes(value.type) ||
197
+ typeof value.text !== "string" || Object.keys(value).some(key =>
198
+ !["type", "text", "annotations"].includes(key) &&
199
+ !(value.type === "output_text" && key === "logprobs" && Array.isArray(value.logprobs) && !value.logprobs.length)) ||
200
+ value.annotations != null && (!Array.isArray(value.annotations) || value.annotations.length)) return value;
201
+ const text = redact(value.text);
202
+ return text === value.text ? value : { ...value, text };
203
+ };
204
+ const message = item => {
205
+ if (!item || typeof item !== "object" || (item.type != null && item.type !== "message") ||
206
+ !["user", "assistant", "system", "developer"].includes(item.role) ||
207
+ Object.keys(item).some(key => !["type", "id", "role", "content", "status", "phase"].includes(key))) return item;
208
+ const before = replacements;
209
+ const content = typeof item.content === "string" ? redact(item.content) : Array.isArray(item.content) ? item.content.map(part) : item.content;
210
+ return replacements === before ? item : { ...item, content };
211
+ };
212
+ const input = typeof body.input === "string" ? redact(body.input) : Array.isArray(body.input) ? body.input.map(message) : body.input;
213
+ const instructions = typeof body.instructions === "string" ? redact(body.instructions) : body.instructions;
214
+ if (!replacements) return { body, replacements: 0 };
215
+ const result = { ...body };
216
+ if (Object.hasOwn(body, "input")) result.input = input;
217
+ if (Object.hasOwn(body, "instructions")) result.instructions = instructions;
218
+ return { body: result, replacements };
219
+ }
@@ -0,0 +1,235 @@
1
+ # 0.1.5 优化设计与兼容边界
2
+
3
+ [使用说明](<../README.md>) · [版本记录](<../CHANGELOG.md>) · [MIT 许可](<../LICENSE>)
4
+
5
+ 本文说明当前 0.1.5 源码的行为、取舍与维护方法。版本号表示源码版本,**不表示该版本已在 npm 发布**。示例均为虚构数据,不含真实请求、账号或凭据。
6
+
7
+ ## 1. 优化什么,不优化什么
8
+
9
+ 这里的“优化”是减少错误归类和兼容处理中的误判、无界等待与信息丢失,不是增加上游额度或提高模型能力:
10
+
11
+ - **HTTP 200 不一定是成功回答。** 上游可能在 JSON 错误封套或 SSE 前导事件中报告失败。只识别明确、有限的错误依据,不从正常正文中搜索错误词。
12
+ - **诊断不能吃掉原始正文。** 请求改写与 JSON 诊断检查副本,设置字节和时间预算;不确定就放弃改写,让调用方继续读取原文。
13
+ - **历史 ID 不能任意删除。** 只有内容完整、形状已知的条目才能内联;服务端引用、推理和加密状态不能靠删字段恢复。
14
+ - **文本替换与历史内联相互独立。** 本地词表只修改获准的普通正文;内联开关不应改变文字过滤器识别空 `logprobs: []` 的能力。
15
+
16
+ 本插件仍运行在既有宿主中,不新增独立服务、仪表盘、遥测、自动重试、账号切换或有损历史降级。
17
+
18
+ ## 2. 参考来源与独立实现
19
+
20
+ 设计参考 [AgentRouter-Codex-Filter](<https://github.com/alexcarterio/AgentRouter-Codex-Filter>)。参考审阅依据是离线提供的源码 ZIP,其中为 **V12.2 独立 Node.js 代理**;实际服务默认使用 lazy history / streaming,不能把可选的历史清理行为误写成默认行为。归档中的版本信息不等于已核验远端某个提交,本文不作远端提交一致性声明。
21
+
22
+ 参考项目采用 MIT 许可。本插件独立实现适合插件边界的请求适配、错误分类和字节保留逻辑,不是该代理的服务化移植,也不宣称与其行为完全等价:
23
+
24
+ | 参考与取舍 | 本插件的决定 |
25
+ | --- | --- |
26
+ | 显式错误分类、有界诊断 | 采用保守白名单及有界观察,不能确定时保留原始数据 |
27
+ | 流式处理 | 只检查 SSE 前导,正常或未知数据按原始字节放行 |
28
+ | 多层自动重试路径 | 不移植;不同分支的重试次数不能当作一个清晰的总预算,插件选择完全不追加尝试 |
29
+ | 独立代理、控制台和遥测 | 不引入;宿主继续负责部署、路由和运维 |
30
+ | 历史剥离或降级 | 不采用;不以丢弃引用、工具或加密状态换取表面上的成功 |
31
+
32
+ 本项目许可见 [LICENSE](<../LICENSE>)。如后续直接复用第三方代码或实质性片段,应另行核对并保留适用的版权与许可声明。
33
+
34
+ ## 3. 模块职责与处理顺序
35
+
36
+ 请求侧顺序为:检查适用端点与正文 → 有界读取 → 本地普通正文过滤 → 可选完整历史内联 → 仅在实际修改时重新序列化。响应侧独立执行有界诊断。
37
+
38
+ | 模块 | 职责 |
39
+ | --- | --- |
40
+ | [body-inspection.mjs](<../body-inspection.mjs>) | 内部共享读取器 `readCloneText`;返回完整有效的 UTF-8 文本或无法检查的结果,不消费调用方原始正文 |
41
+ | [request.mjs](<../request.mjs>) | 判断请求范围、执行过滤及内联、维护改写后的请求头 |
42
+ | [content-filter.mjs](<../content-filter.mjs>) | 加载私有字面量词表,保护代码、工具数据和不透明内容 |
43
+ | [response.mjs](<../response.mjs>) | JSON 错误分类、SSE 前导观察、失败说明与原始流转交 |
44
+ | [index.mjs](<../index.mjs>) | 对接宿主选项及 fetch;一次宿主 fetch 调用只发起一次上游 fetch |
45
+
46
+ 共享读取器是内部模块,不新增根入口公开 API。0.1.5 没有新增依赖、环境变量、提供商选项或登录配置迁移。
47
+
48
+ ## 4. 选项组合:关闭内联不等于关闭过滤
49
+
50
+ 提供商编辑器保留原有选项和默认值:
51
+
52
+ - `portable-responses`:**Replay complete Responses items inline**,默认开启。
53
+ - `text-filter`:**Filter configured prose before sending**,默认开启,但没有本地词表或 `terms` 为空时不替换正文。
54
+ - `send-codex-headers`:**Send the Codex client headers**,默认开启,与上述两个正文选项独立。
55
+
56
+ | `portable-responses` | `text-filter` | 请求正文行为 |
57
+ | --- | --- | --- |
58
+ | 开 | 开 | 先过滤获准正文,再按条件内联完整历史 |
59
+ | 开 | 关 | 不过滤正文,只做符合条件的历史内联 |
60
+ | 关 | 开 | 只过滤获准正文;不删除消息 ID,不转换条目类型 |
61
+ | 关 | 关 | 不做这两项正文变换,原始正文透传 |
62
+
63
+ “两项都关”仅指请求正文变换:认证/客户端请求头处理与响应诊断仍存在,不是整个插件的旁路开关。没有实际替换或内联变化时,保留原始请求正文,不为了格式化 JSON 而重写。只有真正执行了历史内联,才设置 `store:false`;仅过滤正文不会附带修改该值。
64
+
65
+ 本地规则从插件安装目录的 `content-filter.local.json` 加载,格式见 [示例配置](<../content-filter.example.json>)。以下仅是虚构字面量,不代表厂商拦截词:
66
+
67
+ ```json
68
+ { "terms": ["示例占位短语"], "caseSensitive": true }
69
+ ```
70
+
71
+ 规则按最长字面量优先、非重叠替换为 `[FILTERED]`,不是正则,也不自动添加单词边界。缺少文件或空词表表示不启用替换;已经配置但无法读取、格式无效或超限时会报告配置错误,**不会静默忽略坏配置后发送请求**。加载中的规则是快照,修改后须重新加载插件。私有词表不要提交到 Git、示例文件、日志或命令行。
72
+
73
+ ## 5. 三个读取预算不能混为一谈
74
+
75
+ | 检查对象 | 字节上限 | 等待上限 | 范围与退出方式 |
76
+ | --- | --- | --- | --- |
77
+ | 请求正文改写 | **4 MiB** | **2 秒**,用于 Request 副本读取 | `POST .../responses` 的 JSON 请求;超限或无法可靠读取就不改写 |
78
+ | JSON 响应诊断 | **64 KiB** | **2 秒** | 仅 HTTP **200 / 400 / 429**;200 还须声明 JSON 内容类型;解析不成立则保留原响应 |
79
+ | SSE 前导诊断 | **64 KiB** | **15 秒** | 仅 HTTP 200 的 `text/event-stream`,并且尚未放行有效内容或未知事件 |
80
+
81
+ 1 MiB = 1,048,576 字节,1 KiB = 1,024 字节。预算依据实际读取的 UTF-8 字节,不依赖 `Content-Length` 是否存在或准确;已提供的字符串请求体也要通过字节上限检查。
82
+
83
+ 请求中的压缩正文、非 JSON、其他端点(包括 `/responses/compact`)和不支持的正文形态不改写。Request/JSON Response 副本遇到超限、超时、取消、解码或读取失败时放弃检查;定时器和监听器会清理,不等待可能受原始消费者阻塞的 tee 分支取消。
84
+
85
+ 这些是**诊断预算,不是整段响应大小、总请求时长或进程内存上限**。正常长回复仍可继续流动;上游一次交付的数据块可能已大于预算,插件不能阻止运行时分配该块。底层网络/流读取错误也不会被伪造成成功数据。
86
+
87
+ ## 6. 显式错误、叶子验证与业务否决
88
+
89
+ ### 6.1 先确定是错误,再确定能否分类
90
+
91
+ 检查入口包括明确的 `error` / `response.error`、`response.failed`、SSE `event:error`,以及已处于 HTTP 400/429 错误响应中的错误详情。支持 `message` / `msg` 和其中 JSON 编码的错误,不扫描成功回答、工具结果或普通说明文字。
92
+
93
+ 嵌套展开使用四层递归预算。每一步都先确认子层存在有效错误详情;空对象、空白消息、数组或未知且无有效详情的结构,不能借包装层的“繁忙”文字变成已知故障。祖先只能补充白名单内的合法数值状态,不能借用祖先正文。深度预算耗尽且仍有未检查嵌套时,**整条诊断 fail-open:保留原始响应,不依据部分祖先字段猜测**。
94
+
95
+ ### 6.2 新增白名单
96
+
97
+ 下表只适用于通过上述验证的错误详情,且没有命中业务否决:
98
+
99
+ | 依据 | 输出 HTTP 状态 | 错误类型 |
100
+ | --- | --- | --- |
101
+ | `status` / `status_code` / `code` 的数值或严格三位数字字符串 `429`;或 `rate_limit_exceeded` | 429 | `agent_router_upstream_rate_limit` |
102
+ | `overloaded_error` / `high_demand`,或错误消息中的完整短语 `currently experiencing high demand` | 503 | `agent_router_upstream_busy` |
103
+ | `server_error` | **500** | `agent_router_upstream_error` |
104
+ | 数值或严格三位数字字符串 `500` / `502` / `503` / `504` | 对应状态 | `agent_router_upstream_error` |
105
+
106
+ 错误码白名单从详情的 `code` 读取;该字段缺失或为 `null` 时才取 `type`。`"503"` 是合法数值字符串,`" 503 "`、`"503 busy"` 等不是。若合法数值状态与已知代码同时存在,**HTTP 状态优先采用数值状态**;错误类型仍按限流、繁忙、服务故障特征区分,不能用类型倒推另一个 HTTP 状态。
107
+
108
+ ### 6.3 业务原因优先,不把拒绝包装成“稍后会好”
109
+
110
+ 明确的认证、权限、余额、计费、配额和内容策略错误优先于新增暂时性分类。即使同一封套带有 503 或“繁忙”文字,也不会因此包装为暂时性服务故障;无法分类时保留原响应。
111
+
112
+ 既有专用错误行为保持:
113
+
114
+ | 原有依据 | 状态 / 类型 |
115
+ | --- | --- |
116
+ | 明确的 token 速率上限消息 | 429 / `agent_router_token_rate_limit` |
117
+ | `content-blocked` | 400 / `agent_router_content_blocked` |
118
+ | 精确的“条目创建于另一 OpenAI 资源”消息 | 400 / `agent_router_resource_mismatch` |
119
+ | SSE 明确完成、空 `output`、输入和输出用量均为零,且此前未出现有效输出 | 502 / `agent_router_empty_response` |
120
+
121
+ 内容拒绝和跨资源错误仍走自己的专用说明,不会因为业务否决变成通用繁忙错误。零用量本身、一次空片段或截断流,都不足以证明 token 限流或空完成。
122
+
123
+ 虚构反例,用于理解分类边界:
124
+
125
+ ```json
126
+ {"error":{"status":"503","message":"Fictional upstream is temporarily unavailable."}}
127
+ ```
128
+
129
+ 这是可识别的明确服务错误。以下两条则不应用新增暂时性分类:第一条有配额业务原因;第二条只有包装层有状态和文字,错误叶子为空。
130
+
131
+ ```json
132
+ {"error":{"status":503,"code":"insufficient_quota","message":"Fictional quota exhausted."}}
133
+ ```
134
+
135
+ ```json
136
+ {"status":503,"message":"currently experiencing high demand","error":{}}
137
+ ```
138
+
139
+ ## 7. SSE:诊断截止线就是数据保留线
140
+
141
+ 前导解析按增量方式分行,支持 LF、CRLF、CR 及混合换行、跨块 UTF-8、注释和多行 `data:`;重复 `event:` 使用最后一个值,字段冒号后只移除一个可选空格。
142
+
143
+ 一旦看到有效正文、推理或工具内容,或未知事件/未知数据形状,就结束观察。之后的字节和错误不重新包装,避免抹掉部分答案或诱发工具重复执行。未知形状即使出现在已知事件中,也不是继续等待后续“更像错误”的事件的理由。
144
+
145
+ - 已读正常前缀、未知数据、未完成帧、超限或超时路径,均转交原始字节,而不是重新编码解析后的文本。
146
+ - 超时时保留已发起但尚未完成的读取;不能通过另起一次读取跳过该数据块。
147
+ - 保留消费者取消和背压;不发送心跳,不全量缓存回答,不添加放行后的空闲超时。
148
+ - 明确的早期错误可以替换为 JSON 错误响应;已放行内容后的错误不做这种替换。
149
+
150
+ “逐字节保留”适用于未命中明确早期改写的路径,不等于每种响应都保持同一对象,也不表示底层 I/O 永不失败。
151
+
152
+ ## 8. 历史、工具与不透明状态
153
+
154
+ `portable-responses` 只处理含完整内联内容的已知普通消息、`function_call` 和 `function_call_output`,删除用于上游条目查找的顶层 `id`。普通输出消息转换为可接受的内联输入形式,保留角色、正文和 `phase`;工具的 `call_id`、名称、参数、结果和嵌套业务 `id` 不动。
155
+
156
+ 保留规则优先:
157
+
158
+ - 不删除 `previous_response_id`、`conversation` 或 `item_reference`;存在这些服务端历史引用时不执行 ID 内联改写。
159
+ - 不删改 reasoning、`encrypted_content`、compaction、摘要或未知条目;`store:false` 不会让加密状态跨资源可移植。
160
+ - 非空引用注释、未知消息字段、文件/图像/容器等资源引用不做递归“清理”。
161
+ - 不内置“失败后删历史再发一次”路径,不默认硬拒绝不透明历史,也不会从其他资源偷偷获取缺失上下文。
162
+
163
+ 如果旧会话只有服务端引用,仍需要客户端重发完整历史,或让上游固定到创建这些条目的原资源。仅升级插件、换 key 或设置 `store:false` 都不构成无损恢复保证。
164
+
165
+ ## 9. 空 logprobs、Markdown 与幂等性
166
+
167
+ 0.1.4 已让符合条件的普通 `output_text` 的空 `logprobs: []` 参与历史内联。0.1.5 补齐的是**独立文字过滤路径**:过滤先于内联执行,因此两种内联开关状态都能处理这种普通文本。
168
+
169
+ 当关闭内联时,仅替换文本,保留原消息 ID、`output_text` 类型、空概率数组及其他获准元数据。非空、`null` 或未知形状的概率数据、真实引用注释、签名和未知字段不会被这项例外放行;`input_text` 中的未知概率字段也不会因此变成可过滤内容。
170
+
171
+ 文字过滤范围是 `instructions`、字符串 `input` 和普通 user/assistant/system/developer 消息中的获准文本片段;工具参数/结果/schema、模型名、metadata、媒体和不透明历史不在范围内。
172
+
173
+ Markdown 采取保守策略,而不是试图实现完整语法解析器:
174
+
175
+ - 保护行内代码、根级反引号/波浪线围栏和未闭合代码段。
176
+ - 含缩进代码的段落不改,其前后独立正文段落仍可过滤;引用或列表容器中的围栏会使整个文本字段保持原样。
177
+ - 保护已有 `[FILTERED]`、代码结构分隔符及下一非空行缩进,跨越这些边界的字面量匹配保守跳过。
178
+ - 普通正文跨段落的字面量仍可替换;同一规则重复应用应保持稳定,不因第一次替换吞掉换行而在第二次暴露代码。
179
+
180
+ 这种幂等性和保留性有回归覆盖,但它**不是通用脱敏或数据防泄漏系统**:被刻意保留的代码、工具、附件或引用背后仍可能含有词表内容。
181
+
182
+ ## 10. 请求头、等待提示与重试所有权
183
+
184
+ 响应改写会保留上游等待提示与请求标识,包括 `Retry-After` 的秒数或 HTTP 日期形式,以及上游提供的毫秒等待头。上游错误代码和消息分别保留在 `error.code` 与 `error.upstream`;没有代码时使用相应的回退代码。诊断内容可能仍含上游提供的信息,公开报障前应自行脱敏。
185
+
186
+ 只有既有 **token 速率专用错误** 在未带 `Retry-After` 时补 `Retry-After: 60`。这是建议,不保证 60 秒后恢复。新增通用分类不凭空生成等待时长。
187
+
188
+ 实体头只在正文确实重建时调整:
189
+
190
+ - 请求改写移除 `content-length`、`content-md5`、`digest`、`content-digest`。
191
+ - 响应改写设为 JSON,并移除 `content-length`、`content-encoding`、`transfer-encoding`、`etag`、`content-md5`、`digest`。
192
+ - 不改写的路径保留原始正文和对应响应头;默认客户端标识开关与用户配置头仍遵循插件既有规则。不要把 Cookie 或 Authorization 当作普通客户端标识示例传播。
193
+
194
+ **一次宿主 fetch = 一次上游 fetch。** 插件不追加 POST,不切账号、不重放工具、不降级历史;宿主自身的重试、路由和故障转移是另一层策略,必须分别配置和核查。返回 429/503 或等待头不等于插件执行了重试,也不能保证宿主一定按同样策略等待。
195
+
196
+ ## 11. 升级与回退
197
+
198
+ 1. 阅读 [使用说明](<../README.md>) 和 [版本记录](<../CHANGELOG.md>),核对实际安装来源与可用版本。不要仅凭本文版本号执行未经核实的 npm 版本安装。
199
+ 2. 在受保护的位置备份当前插件版本、私有词表及宿主管理的配置;不要将凭据备份加入公开仓库。
200
+ 3. 使用已核验的完整本地包或完整插件目录升级,不能只覆盖入口文件。新增 [body-inspection.mjs](<../body-inspection.mjs>) 是运行时必需模块,漏带会导致模块加载失败。
201
+ 4. 无需迁移认证字段、选项或环境变量。重新加载插件,让运行中的 loader 使用新源码与规则快照。先确认四种开关组合符合自己的使用意图。
202
+ 5. 如只需停用正文变换,可关闭两个正文选项;这不会停用响应诊断。需要完整回退时,恢复此前验证过的完整版本并重新加载,不混用新旧模块。
203
+ 6. 回退到 0.1.4 会失去本页列出的 0.1.5 诊断、SSE 和独立过滤修复;回退本身也不能恢复已缺失的上游历史。
204
+
205
+ 本节不要求重新登录、提交真实 Cookie、执行自动签到或回放私有会话。涉及真实流量的验收应单独评估并由部署者授权。
206
+
207
+ ## 12. 维护者的原生验证
208
+
209
+ 在源码仓库根目录使用 [package.json](<../package.json>) 中的原生入口:
210
+
211
+ ```bash
212
+ npm test
213
+ npm run test:headers
214
+ ```
215
+
216
+ 第二条需要 Python 3,仅执行维护脚本的 Python 测试部分,不调用 Docker。这些回归不需要真实账号或凭据;不要用真实登录或生产请求替代合成回归。
217
+
218
+ 0.1.5 当前源码的本地原生回归记录为 **Node.js 255 项、Python 2 项通过**,并经独立范围复核接受。该结论只覆盖已验证的源码行为;后续代码改变后应重新运行,不能沿用旧计数作为通过凭据。
219
+
220
+ 重点维护以下断言,而不是只追求测试数量:
221
+
222
+ - [读取器回归](<../body-inspection.test.mjs>):精确字节界限、多字节、错误长度头、慢流、取消、UTF-8 和读取失败。
223
+ - [响应保留性回归](<../response-preservation.test.mjs>):错误叶子、包装层状态、递归预算、业务否决、未知形状和重复事件字段。
224
+ - [流式回归](<../loader-stream.test.mjs>) 与 [响应边界回归](<../response-bounds.test.mjs>):成功原始字节、早期/后期失败、超限、超时、在途读取、取消与背压。
225
+ - [文本过滤回归](<../content-filter.test.mjs>) 与 [请求内联回归](<../request-replay.test.mjs>):四种开关组合、空/非空概率数据、无变化字节、幂等性、工具及不透明状态保留。
226
+
227
+ ## 13. 已知非保证
228
+
229
+ - 不承诺真实上游已恢复,也不能仅凭合成回归确认某次线上故障根因。
230
+ - 不增加配额、放宽速率限制,不保证重试成功或跨账号存在独立容量。
231
+ - 不保证所有错误封套都能被识别;预算外、未知或有歧义的结构有意原样透传。
232
+ - 不保证跨资源历史或加密状态可恢复,不自动删除历史来掩盖失败。
233
+ - 不保证词表能避免内容拒绝,也不提供通用 DLP、审核类别推断或自动词表学习。
234
+ - 不保证延迟、吞吐或模型质量提升;有界检查仍可能等待到预算,且不是整体内存/超时限制。
235
+ - 不改变登录、签到、WAF 或宿主重试策略,也不是对整个部署的安全认证。
@@ -0,0 +1,73 @@
1
+ // Keep GitHub session parsing local, bounded, and shared by prompts, saves, and check-in.
2
+ export const MAX_GITHUB_COOKIE_BYTES = 32 * 1024;
3
+
4
+ const INVALID_COOKIE = "请提供包含 user_session 或 __Host-user_session_same_site 的 GitHub 登录 Cookie;本站 Cookie、API Key、访问令牌和网页地址不能替代它";
5
+ const COOKIE_NAME = /^[!#$%&'*+.^_\x60|~0-9A-Za-z-]+$/;
6
+ const COOKIE_VALUE = /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*$/;
7
+ const SESSION_NAMES = new Set(["user_session", "__Host-user_session_same_site"]);
8
+
9
+ function githubDomain(domain) {
10
+ return domain === undefined || domain === "" ||
11
+ (typeof domain === "string" && ["github.com", ".github.com"].includes(domain.toLowerCase()));
12
+ }
13
+
14
+ function exportedPairs(rows) {
15
+ const pairs = [];
16
+ for (const row of rows) {
17
+ if (!row || typeof row !== "object" || Array.isArray(row)) return undefined;
18
+ if (!githubDomain(row.domain)) continue;
19
+ pairs.push([row.name, row.value]);
20
+ }
21
+ return pairs;
22
+ }
23
+
24
+ function tablePairs(text) {
25
+ const pairs = [];
26
+ for (const line of text.split(/\r?\n/)) {
27
+ if (!line.trim()) continue;
28
+ const columns = line.split("\t");
29
+ if (columns.length < 2) return undefined;
30
+ if (columns[0] === "Name" && columns[1] === "Value" && columns[2] === "Domain") continue;
31
+ // Browser devtools: name, value, domain, path, ...
32
+ if (!githubDomain(columns[2])) continue;
33
+ pairs.push([columns[0], columns[1]]);
34
+ }
35
+ return pairs;
36
+ }
37
+
38
+ export function parseGithubCookie(input = "") {
39
+ if (typeof input !== "string") return { error: INVALID_COOKIE };
40
+ if (Buffer.byteLength(input, "utf8") > MAX_GITHUB_COOKIE_BYTES) {
41
+ return { error: "GitHub Cookie 最多支持 32 KiB,请只复制 GitHub 所需的 Cookie" };
42
+ }
43
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\r(?!\n)/.test(input)) return { error: INVALID_COOKIE };
44
+ const text = input.trim();
45
+ if (!text) return { cookie: "" };
46
+ let pairs;
47
+ if (text.startsWith("[") || text.startsWith("{")) {
48
+ try {
49
+ const rows = JSON.parse(text);
50
+ if (Array.isArray(rows)) pairs = exportedPairs(rows);
51
+ } catch { /* Never expose pasted values or JSON parse errors. */ }
52
+ } else if (input.includes("\t")) {
53
+ pairs = tablePairs(text);
54
+ } else if (text && !/[\r\n]/.test(input)) {
55
+ const header = text.replace(/^Cookie:\s*/i, "");
56
+ pairs = header.split(";").map(part => part.trim()).filter(Boolean).map(part => {
57
+ const equals = part.indexOf("=");
58
+ return equals > 0 ? [part.slice(0, equals), part.slice(equals + 1)] : [];
59
+ });
60
+ }
61
+ if (!pairs?.length || pairs.some(([name, value]) =>
62
+ typeof name !== "string" || typeof value !== "string" ||
63
+ !COOKIE_NAME.test(name) || !COOKIE_VALUE.test(value))) {
64
+ return { error: INVALID_COOKIE };
65
+ }
66
+ if (pairs.some(([name, value]) => name === "logged_in" && /^(no|false|0)$/i.test(value))) {
67
+ return { error: "GitHub Cookie 包含已退出登录的标记,请在浏览器重新登录同一账号后复制" };
68
+ }
69
+ // Match the existing browser-cookie reader: the last value of a name wins.
70
+ const cookies = new Map(pairs);
71
+ if (![...SESSION_NAMES].some(name => cookies.get(name))) return { error: INVALID_COOKIE };
72
+ return { cookie: [...cookies].map(([name, value]) => name + "=" + value).join("; ") };
73
+ }