opencode-anthropic 0.0.0-stage → 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/LICENSE +25 -0
- package/README.md +269 -2
- package/index.js +380 -0
- package/login.mjs +158 -0
- package/oauth.mjs +136 -0
- package/package.json +46 -4
- package/retry.mjs +46 -0
- package/tests/plugin.test.mjs +575 -0
- package/tools.mjs +174 -0
- package/transform.mjs +316 -0
package/tools.mjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Explicit tool-compatibility table.
|
|
2
|
+
//
|
|
3
|
+
// Only OpenCode tools whose semantics line up with a Claude Code counterpart
|
|
4
|
+
// are exposed under a Claude Code name. OpenCode-only tools (compress,
|
|
5
|
+
// todoread, execute/Code Mode) are deliberately left alone.
|
|
6
|
+
//
|
|
7
|
+
// Each mapped tool may also translate the argument shapes a Claude-Code
|
|
8
|
+
// model is likely to produce (its training priors: snake_case keys,
|
|
9
|
+
// `file_path`, `run_in_background`, ...) back onto OpenCode's own schemas.
|
|
10
|
+
// Translation is conservative: unknown keys are kept, a mapped key is only
|
|
11
|
+
// renamed when the OpenCode-shaped key is not already present.
|
|
12
|
+
|
|
13
|
+
function isPlainObject(value) {
|
|
14
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function has(object, key) {
|
|
18
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renameKeys(input, mapping, drop = []) {
|
|
22
|
+
if (!isPlainObject(input)) return input;
|
|
23
|
+
const output = { ...input };
|
|
24
|
+
for (const [from, to] of Object.entries(mapping)) {
|
|
25
|
+
if (has(output, from)) {
|
|
26
|
+
if (!has(output, to)) output[to] = output[from];
|
|
27
|
+
delete output[from];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const key of drop) delete output[key];
|
|
31
|
+
return output;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const TOOL_COMPAT = {
|
|
35
|
+
read: {
|
|
36
|
+
cc: "Read",
|
|
37
|
+
translate: (input) => renameKeys(input, { file_path: "path" }, ["pages"]),
|
|
38
|
+
},
|
|
39
|
+
write: {
|
|
40
|
+
cc: "Write",
|
|
41
|
+
translate: (input) => renameKeys(input, { file_path: "path" }),
|
|
42
|
+
},
|
|
43
|
+
edit: {
|
|
44
|
+
cc: "Edit",
|
|
45
|
+
translate: (input) =>
|
|
46
|
+
renameKeys(input, {
|
|
47
|
+
file_path: "path",
|
|
48
|
+
old_string: "oldString",
|
|
49
|
+
new_string: "newString",
|
|
50
|
+
replace_all: "replaceAll",
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
shell: {
|
|
54
|
+
cc: "Bash",
|
|
55
|
+
translate: (input) =>
|
|
56
|
+
renameKeys(input, { run_in_background: "background" }, ["description", "dangerouslyDisableSandbox"]),
|
|
57
|
+
},
|
|
58
|
+
glob: {
|
|
59
|
+
cc: "Glob",
|
|
60
|
+
},
|
|
61
|
+
grep: {
|
|
62
|
+
cc: "Grep",
|
|
63
|
+
translate: (input) => {
|
|
64
|
+
const renamed = renameKeys(input, { glob: "include", head_limit: "limit" }, [
|
|
65
|
+
"-n",
|
|
66
|
+
"-A",
|
|
67
|
+
"-B",
|
|
68
|
+
"-C",
|
|
69
|
+
"output_mode",
|
|
70
|
+
"multiline",
|
|
71
|
+
"type",
|
|
72
|
+
"offset",
|
|
73
|
+
]);
|
|
74
|
+
if (!isPlainObject(renamed)) return renamed;
|
|
75
|
+
if (has(renamed, "-i")) {
|
|
76
|
+
if (!has(renamed, "caseSensitive")) renamed.caseSensitive = !renamed["-i"];
|
|
77
|
+
delete renamed["-i"];
|
|
78
|
+
}
|
|
79
|
+
return renamed;
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
webfetch: {
|
|
83
|
+
cc: "WebFetch",
|
|
84
|
+
// OpenCode's webfetch only converts a URL to a format; Claude Code's
|
|
85
|
+
// extra `prompt` argument has no equivalent and cannot be honoured.
|
|
86
|
+
translate: (input) => renameKeys(input, {}, ["prompt"]),
|
|
87
|
+
},
|
|
88
|
+
websearch: {
|
|
89
|
+
cc: "WebSearch",
|
|
90
|
+
// Domain filters are not part of OpenCode's websearch tool.
|
|
91
|
+
translate: (input) => renameKeys(input, {}, ["allowed_domains", "blocked_domains"]),
|
|
92
|
+
},
|
|
93
|
+
skill: {
|
|
94
|
+
cc: "Skill",
|
|
95
|
+
translate: (input) => renameKeys(input, { skill: "id" }, ["args"]),
|
|
96
|
+
},
|
|
97
|
+
question: {
|
|
98
|
+
cc: "AskUserQuestion",
|
|
99
|
+
translate: (input) => {
|
|
100
|
+
if (!isPlainObject(input) || !Array.isArray(input.questions)) return input;
|
|
101
|
+
return {
|
|
102
|
+
...input,
|
|
103
|
+
questions: input.questions.map((question) => {
|
|
104
|
+
if (!isPlainObject(question)) return question;
|
|
105
|
+
const next = { ...question };
|
|
106
|
+
if (has(next, "multiSelect") && !has(next, "multiple")) next.multiple = next.multiSelect;
|
|
107
|
+
delete next.multiSelect;
|
|
108
|
+
return next;
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
subagent: {
|
|
114
|
+
cc: "Agent",
|
|
115
|
+
legacy: ["Task"],
|
|
116
|
+
translate: (input) =>
|
|
117
|
+
renameKeys(input, { subagent_type: "agent", run_in_background: "background" }, ["isolation"]),
|
|
118
|
+
},
|
|
119
|
+
todowrite: {
|
|
120
|
+
cc: "TodoWrite",
|
|
121
|
+
translate: (input) => {
|
|
122
|
+
if (!isPlainObject(input) || !Array.isArray(input.todos)) return input;
|
|
123
|
+
return {
|
|
124
|
+
...input,
|
|
125
|
+
todos: input.todos.map((todo) => {
|
|
126
|
+
if (!isPlainObject(todo)) return todo;
|
|
127
|
+
const next = { ...todo };
|
|
128
|
+
delete next.activeForm;
|
|
129
|
+
return next;
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function translateToolInput(toolID, input) {
|
|
137
|
+
const key = String(toolID ?? "").toLowerCase();
|
|
138
|
+
const entry = TOOL_COMPAT[key];
|
|
139
|
+
if (!entry?.translate) return input;
|
|
140
|
+
return entry.translate(input);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function parseAliases(value) {
|
|
144
|
+
if (!value) return {};
|
|
145
|
+
if (typeof value === "object") return value;
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(value);
|
|
148
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
149
|
+
} catch {
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// `aliases` maps an OpenCode tool id to the Claude Code name it should be
|
|
155
|
+
// exposed as. It can override a default mapping (`{ subagent: "Task" }` for
|
|
156
|
+
// the legacy name) or add a mapping for a tool this table does not know.
|
|
157
|
+
export function buildToolNameMaps(aliases = {}) {
|
|
158
|
+
const forward = new Map(); // OpenCode tool id -> Claude Code name
|
|
159
|
+
const reverse = new Map(); // Claude Code name -> OpenCode tool id
|
|
160
|
+
for (const [toolID, entry] of Object.entries(TOOL_COMPAT)) {
|
|
161
|
+
forward.set(toolID, entry.cc);
|
|
162
|
+
reverse.set(entry.cc, toolID);
|
|
163
|
+
for (const legacy of entry.legacy ?? []) reverse.set(legacy, toolID);
|
|
164
|
+
}
|
|
165
|
+
for (const [from, to] of Object.entries(aliases)) {
|
|
166
|
+
if (typeof from !== "string" || typeof to !== "string" || !from || !to) continue;
|
|
167
|
+
const toolID = from.toLowerCase();
|
|
168
|
+
forward.set(toolID, to);
|
|
169
|
+
reverse.set(to, toolID);
|
|
170
|
+
}
|
|
171
|
+
return { forward, reverse };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const CC_TOOL_NAMES = Object.values(TOOL_COMPAT).map((entry) => entry.cc);
|
package/transform.mjs
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Request and response transforms that make OpenCode's Anthropic traffic look
|
|
2
|
+
// like a Claude Code session: the identity system block, Claude Code tool
|
|
3
|
+
// names on the way out, and the original names on the way back.
|
|
4
|
+
//
|
|
5
|
+
// Argument translation (Claude Code argument shapes -> OpenCode schemas) is
|
|
6
|
+
// table-driven in tools.mjs. It runs while the response is rewritten, before
|
|
7
|
+
// OpenCode parses a tool call: the `input_json_delta` fragments of a mapped
|
|
8
|
+
// tool are held until the block closes, translated as one JSON object, and
|
|
9
|
+
// re-emitted as a single delta. When nothing changes, the original frames are
|
|
10
|
+
// passed through byte for byte.
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { TOOL_COMPAT, buildToolNameMaps, translateToolInput } from "./tools.mjs";
|
|
13
|
+
|
|
14
|
+
export { CC_TOOL_NAMES, TOOL_COMPAT, buildToolNameMaps, parseAliases, translateToolInput } from "./tools.mjs";
|
|
15
|
+
|
|
16
|
+
// Current Claude Code identity line (2.1.x, Agent SDK wording). The 2025
|
|
17
|
+
// wording is kept for routes that still expect it: select it with
|
|
18
|
+
// ANTHROPIC_OAUTH_SYSTEM_IDENTITY or the plugin `identity` option.
|
|
19
|
+
export const SYSTEM_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
20
|
+
export const LEGACY_SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
21
|
+
const IDENTITY_PREFIXES = ["You are a Claude agent", "You are Claude Code"];
|
|
22
|
+
|
|
23
|
+
// A stable, Claude-Code-shaped UUID for an OpenCode session. Claude Code
|
|
24
|
+
// sends `x-claude-code-session-id: <uuid>`; deriving it from the OpenCode
|
|
25
|
+
// session id keeps every request of one session correlated without
|
|
26
|
+
// fabricating a device identity.
|
|
27
|
+
export function sessionUUID(sessionID) {
|
|
28
|
+
const hash = createHash("sha1").update(`opencode-anthropic:${sessionID}`).digest("hex");
|
|
29
|
+
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isToolUseBlock(block) {
|
|
33
|
+
return Boolean(block) && typeof block === "object" && block.type === "tool_use";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renameToolUseBlock(block, map) {
|
|
37
|
+
if (isToolUseBlock(block) && typeof block.name === "string" && map.has(block.name)) {
|
|
38
|
+
return { ...block, name: map.get(block.name) };
|
|
39
|
+
}
|
|
40
|
+
return block;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function shapeSystem(parsed, { systemMode, identity, billing }) {
|
|
44
|
+
const blocks = Array.isArray(parsed.system) ? parsed.system : [];
|
|
45
|
+
if (blocks.length === 0 && (!billing || systemMode === "off")) return blocks;
|
|
46
|
+
const first = blocks[0];
|
|
47
|
+
const firstText = first && typeof first === "object" && typeof first.text === "string" ? first.text : "";
|
|
48
|
+
const secondText =
|
|
49
|
+
firstText.startsWith("x-anthropic-billing-header") &&
|
|
50
|
+
blocks[1] &&
|
|
51
|
+
typeof blocks[1] === "object" &&
|
|
52
|
+
typeof blocks[1].text === "string"
|
|
53
|
+
? blocks[1].text
|
|
54
|
+
: "";
|
|
55
|
+
const hasIdentity = IDENTITY_PREFIXES.some(
|
|
56
|
+
(prefix) => firstText.startsWith(prefix) || secondText.startsWith(prefix),
|
|
57
|
+
);
|
|
58
|
+
const prefix = [];
|
|
59
|
+
if (billing && systemMode !== "off") prefix.push({ type: "text", text: billing });
|
|
60
|
+
if (systemMode !== "off" && !hasIdentity) {
|
|
61
|
+
if (systemMode === "replace" && blocks.length > 0) {
|
|
62
|
+
return [...prefix, { ...blocks[0], text: identity }, ...blocks.slice(1)];
|
|
63
|
+
}
|
|
64
|
+
prefix.push({ type: "text", text: identity });
|
|
65
|
+
}
|
|
66
|
+
return [...prefix, ...blocks];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function transformRequestBody(
|
|
70
|
+
raw,
|
|
71
|
+
{
|
|
72
|
+
systemMode = "prepend",
|
|
73
|
+
renameTools = true,
|
|
74
|
+
forward,
|
|
75
|
+
identity = SYSTEM_IDENTITY,
|
|
76
|
+
billing = null,
|
|
77
|
+
metadataUserId = null,
|
|
78
|
+
thinking = true,
|
|
79
|
+
} = {},
|
|
80
|
+
) {
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(raw);
|
|
84
|
+
} catch {
|
|
85
|
+
return raw;
|
|
86
|
+
}
|
|
87
|
+
const shaped = shapeSystem(parsed, { systemMode, identity, billing });
|
|
88
|
+
if (shaped.length > 0 || Array.isArray(parsed.system)) parsed.system = shaped;
|
|
89
|
+
|
|
90
|
+
if (metadataUserId && !parsed.metadata?.user_id) {
|
|
91
|
+
parsed.metadata = { ...parsed.metadata, user_id: metadataUserId };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const claudeModel = /claude-(sonnet|opus)/i.test(String(parsed.model ?? ""));
|
|
95
|
+
if (thinking && claudeModel) {
|
|
96
|
+
if (
|
|
97
|
+
parsed.thinking === undefined &&
|
|
98
|
+
typeof parsed.max_tokens === "number" &&
|
|
99
|
+
parsed.max_tokens > 1024
|
|
100
|
+
) {
|
|
101
|
+
parsed.thinking = { type: "enabled", budget_tokens: parsed.max_tokens - 1, display: "omitted" };
|
|
102
|
+
}
|
|
103
|
+
if (parsed.context_management === undefined && parsed.thinking !== undefined) {
|
|
104
|
+
parsed.context_management = { edits: [{ type: "clear_thinking_20251015", keep: "all" }] };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (renameTools) {
|
|
109
|
+
const map = forward ?? buildToolNameMaps().forward;
|
|
110
|
+
if (Array.isArray(parsed.tools)) {
|
|
111
|
+
parsed.tools = parsed.tools.map((tool) => ({
|
|
112
|
+
...tool,
|
|
113
|
+
name: map.get(String(tool?.name ?? "").toLowerCase()) ?? tool?.name,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(parsed.messages)) {
|
|
117
|
+
parsed.messages = parsed.messages.map((message) => {
|
|
118
|
+
if (!Array.isArray(message?.content)) return message;
|
|
119
|
+
return { ...message, content: message.content.map((block) => renameToolUseBlock(block, map)) };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return JSON.stringify(parsed);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The response rewriter is stateful: tool use blocks can be split across
|
|
127
|
+
// frames, so argument translation has to remember what it has seen for each
|
|
128
|
+
// content block index.
|
|
129
|
+
export function createResponseRewriter({ reverse, translateInput = translateToolInput } = {}) {
|
|
130
|
+
const pending = new Map(); // content block index -> { name, frames, json }
|
|
131
|
+
|
|
132
|
+
function rewriteToolBlocks(blocks) {
|
|
133
|
+
let changed = false;
|
|
134
|
+
for (const block of blocks) {
|
|
135
|
+
if (!isToolUseBlock(block)) continue;
|
|
136
|
+
if (typeof block.name === "string" && reverse?.has(block.name)) {
|
|
137
|
+
block.name = reverse.get(block.name);
|
|
138
|
+
changed = true;
|
|
139
|
+
}
|
|
140
|
+
const entry = TOOL_COMPAT[String(block.name ?? "").toLowerCase()];
|
|
141
|
+
if (
|
|
142
|
+
entry?.translate &&
|
|
143
|
+
block.input &&
|
|
144
|
+
typeof block.input === "object" &&
|
|
145
|
+
Object.keys(block.input).length > 0
|
|
146
|
+
) {
|
|
147
|
+
const mapped = translateInput(block.name, block.input);
|
|
148
|
+
if (JSON.stringify(mapped) !== JSON.stringify(block.input)) {
|
|
149
|
+
block.input = mapped;
|
|
150
|
+
changed = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return changed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rewriteBody(parsed) {
|
|
158
|
+
let changed = false;
|
|
159
|
+
if (Array.isArray(parsed?.content)) changed = rewriteToolBlocks(parsed.content) || changed;
|
|
160
|
+
if (parsed?.message && Array.isArray(parsed.message.content)) {
|
|
161
|
+
changed = rewriteToolBlocks(parsed.message.content) || changed;
|
|
162
|
+
}
|
|
163
|
+
return changed;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Returns a list of frames to emit (empty list = suppress this frame).
|
|
167
|
+
function rewriteFrame(frame) {
|
|
168
|
+
if (!frame.includes("data:")) {
|
|
169
|
+
const trimmed = frame.trim();
|
|
170
|
+
if (trimmed.startsWith("{")) {
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(trimmed);
|
|
173
|
+
if (rewriteBody(parsed)) return [JSON.stringify(parsed)];
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
return [frame];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const lines = frame.split("\n");
|
|
180
|
+
const emitted = [];
|
|
181
|
+
let changed = false;
|
|
182
|
+
let suppress = false;
|
|
183
|
+
for (let index = 0; index < lines.length; index++) {
|
|
184
|
+
const line = lines[index];
|
|
185
|
+
if (!line.startsWith("data:")) continue;
|
|
186
|
+
const payload = line.slice(5).replace(/^ /, "");
|
|
187
|
+
if (!payload || payload === "[DONE]") continue;
|
|
188
|
+
let parsed;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(payload);
|
|
191
|
+
} catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (parsed?.type === "content_block_start" && parsed.content_block) {
|
|
196
|
+
const blockChanged = rewriteToolBlocks([parsed.content_block]);
|
|
197
|
+
const block = parsed.content_block;
|
|
198
|
+
if (isToolUseBlock(block)) {
|
|
199
|
+
const entry = TOOL_COMPAT[String(block.name ?? "").toLowerCase()];
|
|
200
|
+
if (entry?.translate) {
|
|
201
|
+
pending.set(parsed.index, { name: String(block.name).toLowerCase(), frames: [], json: [] });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (blockChanged) {
|
|
205
|
+
lines[index] = `data: ${JSON.stringify(parsed)}`;
|
|
206
|
+
changed = true;
|
|
207
|
+
}
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (parsed?.type === "content_block_delta" && parsed.delta?.type === "input_json_delta") {
|
|
212
|
+
const state = pending.get(parsed.index);
|
|
213
|
+
if (state) {
|
|
214
|
+
state.frames.push(frame);
|
|
215
|
+
state.json.push(String(parsed.delta.partial_json ?? ""));
|
|
216
|
+
suppress = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (parsed?.type === "content_block_stop" && pending.has(parsed.index)) {
|
|
222
|
+
const state = pending.get(parsed.index);
|
|
223
|
+
pending.delete(parsed.index);
|
|
224
|
+
const frames = [];
|
|
225
|
+
let synthesized = null;
|
|
226
|
+
if (state.json.length > 0) {
|
|
227
|
+
try {
|
|
228
|
+
const input = JSON.parse(state.json.join(""));
|
|
229
|
+
const mapped = translateInput(state.name, input);
|
|
230
|
+
if (JSON.stringify(mapped) !== JSON.stringify(input)) {
|
|
231
|
+
synthesized = `event: content_block_delta\ndata: ${JSON.stringify({
|
|
232
|
+
type: "content_block_delta",
|
|
233
|
+
index: parsed.index,
|
|
234
|
+
delta: { type: "input_json_delta", partial_json: JSON.stringify(mapped) },
|
|
235
|
+
})}\n\n`;
|
|
236
|
+
}
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|
|
239
|
+
if (synthesized) frames.push(synthesized);
|
|
240
|
+
else frames.push(...state.frames);
|
|
241
|
+
frames.push(frame);
|
|
242
|
+
return frames;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (rewriteBody(parsed)) {
|
|
246
|
+
lines[index] = `data: ${JSON.stringify(parsed)}`;
|
|
247
|
+
changed = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (suppress) return [];
|
|
252
|
+
return changed ? [lines.join("\n")] : [frame];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
rewriteFrame,
|
|
257
|
+
// Emit anything still buffered for a truncated stream, so a missing
|
|
258
|
+
// content_block_stop can never swallow tool arguments.
|
|
259
|
+
flush() {
|
|
260
|
+
const frames = [];
|
|
261
|
+
for (const state of pending.values()) frames.push(...state.frames);
|
|
262
|
+
pending.clear();
|
|
263
|
+
return frames;
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Rewrites one complete SSE frame, or one non-streaming JSON body, touching
|
|
269
|
+
// only `tool_use` names. Kept for callers that only need name mapping.
|
|
270
|
+
export function rewriteEvent(eventText, reverse) {
|
|
271
|
+
const rewriter = createResponseRewriter({
|
|
272
|
+
reverse,
|
|
273
|
+
translateInput: (_name, input) => input,
|
|
274
|
+
});
|
|
275
|
+
return rewriter.rewriteFrame(eventText).join("");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function transformResponseStream(response, options = {}) {
|
|
279
|
+
if (!response.body) return response;
|
|
280
|
+
const rewriter = createResponseRewriter(options);
|
|
281
|
+
const reader = response.body.getReader();
|
|
282
|
+
const decoder = new TextDecoder();
|
|
283
|
+
const encoder = new TextEncoder();
|
|
284
|
+
let buffer = "";
|
|
285
|
+
const stream = new ReadableStream({
|
|
286
|
+
async pull(controller) {
|
|
287
|
+
for (;;) {
|
|
288
|
+
const boundary = buffer.indexOf("\n\n");
|
|
289
|
+
if (boundary !== -1) {
|
|
290
|
+
const frame = buffer.slice(0, boundary + 2);
|
|
291
|
+
buffer = buffer.slice(boundary + 2);
|
|
292
|
+
const pieces = rewriter.rewriteFrame(frame);
|
|
293
|
+
for (const piece of pieces) controller.enqueue(encoder.encode(piece));
|
|
294
|
+
if (pieces.length > 0) return;
|
|
295
|
+
continue; // suppressed frame: keep draining instead of stalling
|
|
296
|
+
}
|
|
297
|
+
const { done, value } = await reader.read();
|
|
298
|
+
if (done) {
|
|
299
|
+
if (buffer) {
|
|
300
|
+
for (const piece of rewriter.rewriteFrame(buffer)) controller.enqueue(encoder.encode(piece));
|
|
301
|
+
buffer = "";
|
|
302
|
+
}
|
|
303
|
+
for (const piece of rewriter.flush()) controller.enqueue(encoder.encode(piece));
|
|
304
|
+
controller.close();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
buffer += decoder.decode(value, { stream: true });
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
return new Response(stream, {
|
|
312
|
+
status: response.status,
|
|
313
|
+
statusText: response.statusText,
|
|
314
|
+
headers: response.headers,
|
|
315
|
+
});
|
|
316
|
+
}
|