opencode-anthropic-fix 0.1.31 → 0.2.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.
Files changed (40) hide show
  1. package/README.md +2 -0
  2. package/cli.mjs +42 -35
  3. package/index.mjs +399 -3591
  4. package/lib/config.mjs +9 -10
  5. package/lib/config.test.mjs +298 -1
  6. package/lib/diagnose.mjs +184 -0
  7. package/lib/diagnose.test.mjs +122 -0
  8. package/lib/env.mjs +19 -0
  9. package/lib/env.test.mjs +25 -0
  10. package/lib/mimicry/cache.mjs +62 -0
  11. package/lib/mimicry/cache.test.mjs +112 -0
  12. package/lib/mimicry/headers.mjs +530 -0
  13. package/lib/mimicry/headers.test.mjs +50 -0
  14. package/lib/mimicry/models.mjs +146 -0
  15. package/lib/mimicry/models.test.mjs +132 -0
  16. package/lib/mimicry/request-body.mjs +684 -0
  17. package/lib/mimicry/request-body.test.mjs +34 -0
  18. package/lib/mimicry/request-helpers.mjs +243 -0
  19. package/lib/mimicry/request-helpers.test.mjs +155 -0
  20. package/lib/mimicry/response-stream.mjs +429 -0
  21. package/lib/mimicry/response-stream.test.mjs +127 -0
  22. package/lib/mimicry/system-prompt.mjs +638 -0
  23. package/lib/mimicry/system-prompt.test.mjs +73 -0
  24. package/lib/oauth.mjs +1 -1
  25. package/lib/oauth.test.mjs +8 -4
  26. package/lib/redact.mjs +147 -0
  27. package/lib/redact.test.mjs +154 -0
  28. package/lib/refresh-lock.test.mjs +52 -0
  29. package/lib/retry/overload-loop.mjs +83 -0
  30. package/lib/retry/overload-loop.test.mjs +107 -0
  31. package/lib/session-metrics.mjs +75 -0
  32. package/lib/session-metrics.test.mjs +67 -0
  33. package/lib/storage.test.mjs +82 -0
  34. package/lib/token-economy/microcompact.mjs +35 -0
  35. package/lib/token-economy/microcompact.test.mjs +28 -0
  36. package/lib/token-economy/transforms.mjs +637 -0
  37. package/lib/token-economy/transforms.test.mjs +192 -0
  38. package/lib/tuning.mjs +70 -0
  39. package/lib/tuning.test.mjs +89 -0
  40. package/package.json +4 -1
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { CORE_TOOL_NAMES, transformRequestBody } from "./request-body.mjs";
3
+
4
+ describe("request body mimicry", () => {
5
+ it("exports core tools and renames opencode tool names", () => {
6
+ expect(CORE_TOOL_NAMES).toBeInstanceOf(Set);
7
+ expect(CORE_TOOL_NAMES.has("Bash")).toBe(true);
8
+ expect(CORE_TOOL_NAMES.has("Read")).toBe(true);
9
+ expect(CORE_TOOL_NAMES.has("Task")).toBe(true);
10
+
11
+ const body = {
12
+ model: "claude-opus-4-1",
13
+ max_tokens: 8000,
14
+ system: [{ type: "text", text: "You are opencode" }],
15
+ messages: [{ role: "user", content: "hi" }],
16
+ tools: [{ name: "bash", description: "run", input_schema: { type: "object" } }],
17
+ };
18
+ const signature = { enabled: false };
19
+ const runtime = {
20
+ turns: 1,
21
+ usedTools: new Set(),
22
+ tokenEconomySession: null,
23
+ cacheBoundaryStability: new Map(),
24
+ persistentUserId: "d",
25
+ accountId: "a",
26
+ sessionId: "s",
27
+ };
28
+
29
+ const result = transformRequestBody(JSON.stringify(body), signature, runtime, "oauth-2025-04-20", {});
30
+
31
+ expect(typeof result).toBe("string");
32
+ expect(JSON.parse(result).tools[0].name).toBe("Bash");
33
+ });
34
+ });
@@ -0,0 +1,243 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Request-body mimicry helpers (W3·P3.1)
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Leaf helpers used by transformRequestBody, extracted from index.mjs so the
6
+ // orchestrator can become a thin importer. All are pure except resolveMaxTokens,
7
+ // which reads/writes the shared sessionMetrics.lastStopReason singleton.
8
+
9
+ import { sessionMetrics } from "../session-metrics.mjs";
10
+
11
+ /**
12
+ * Ensure every assistant tool_use block has a matching tool_result in the next
13
+ * user message; synthesize placeholders for interrupted/orphaned tool calls so
14
+ * the API never rejects the conversation for unbalanced tool blocks.
15
+ * @param {any[]} messages
16
+ * @returns {any[]}
17
+ */
18
+ export function repairOrphanedToolUseBlocks(messages) {
19
+ if (!Array.isArray(messages) || messages.length === 0) return messages;
20
+
21
+ const repaired = [];
22
+
23
+ for (let i = 0; i < messages.length; i++) {
24
+ const msg = messages[i];
25
+ repaired.push(msg);
26
+
27
+ // Only check assistant messages with array content
28
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
29
+
30
+ // Collect tool_use IDs from this assistant message
31
+ const toolUseIds = [];
32
+ for (const block of msg.content) {
33
+ if (block.type === "tool_use" && block.id) {
34
+ toolUseIds.push(block.id);
35
+ }
36
+ }
37
+ if (toolUseIds.length === 0) continue;
38
+
39
+ // Check if the next message is a user message with matching tool_results
40
+ const next = messages[i + 1];
41
+ if (next && next.role === "user" && Array.isArray(next.content)) {
42
+ // Collect tool_result IDs present in the next user message
43
+ const resultIds = new Set();
44
+ for (const block of next.content) {
45
+ if (block.type === "tool_result" && block.tool_use_id) {
46
+ resultIds.add(block.tool_use_id);
47
+ }
48
+ }
49
+
50
+ // Find which tool_use IDs are missing from the tool_results
51
+ const missingIds = toolUseIds.filter((id) => !resultIds.has(id));
52
+ if (missingIds.length === 0) continue; // All paired, nothing to fix
53
+
54
+ // There are missing tool_results — inject them into the existing user message.
55
+ // Clone the next message to avoid mutating the original.
56
+ const patchedNext = {
57
+ ...next,
58
+ content: [
59
+ ...missingIds.map((id) => ({
60
+ type: "tool_result",
61
+ tool_use_id: id,
62
+ content: "[Result unavailable — tool execution was interrupted]",
63
+ })),
64
+ ...next.content,
65
+ ],
66
+ };
67
+ // Replace the next message in-place by skipping it and pushing the patched version
68
+ i++; // skip original next
69
+ repaired.push(patchedNext);
70
+ } else {
71
+ // Next message is missing or is not a user message — synthesize a full
72
+ // tool_result user message for all tool_use IDs.
73
+ repaired.push({
74
+ role: "user",
75
+ content: toolUseIds.map((id) => ({
76
+ type: "tool_result",
77
+ tool_use_id: id,
78
+ content: "[Result unavailable — tool execution was interrupted]",
79
+ })),
80
+ });
81
+ }
82
+ }
83
+
84
+ return repaired;
85
+ }
86
+
87
+ /**
88
+ * Strip internal `/anthropic` slash-command messages and their `▣ Anthropic`
89
+ * responses from the conversation before it is sent upstream.
90
+ * @param {any[]} messages
91
+ * @returns {any[]}
92
+ */
93
+ export function stripSlashCommandMessages(messages) {
94
+ if (!Array.isArray(messages) || messages.length === 0) return messages;
95
+
96
+ // Pattern: /anthropic followed by optional subcommand
97
+ const CMD_RE = /^\s*\/anthropic\b/i;
98
+ // Pattern: ▣ Anthropic — prefix used by all sendCommandMessage outputs
99
+ const RESP_RE = /^▣\s*Anthropic/;
100
+
101
+ /**
102
+ * Extract the first text content from a message's content field.
103
+ * Handles both string content and array-of-blocks content.
104
+ */
105
+ function getFirstText(msg) {
106
+ if (typeof msg.content === "string") return msg.content;
107
+ if (Array.isArray(msg.content)) {
108
+ for (const block of msg.content) {
109
+ if (block.type === "text" && typeof block.text === "string") return block.text;
110
+ }
111
+ }
112
+ return "";
113
+ }
114
+
115
+ /**
116
+ * Check if a user message is a /anthropic command.
117
+ * A message is a command if its text content starts with /anthropic.
118
+ */
119
+ function isCommandMessage(msg) {
120
+ if (msg.role !== "user") return false;
121
+ const text = getFirstText(msg);
122
+ return CMD_RE.test(text);
123
+ }
124
+
125
+ /**
126
+ * Check if an assistant message is a sendCommandMessage response.
127
+ * These always start with ▣ Anthropic.
128
+ */
129
+ function isCommandResponse(msg) {
130
+ if (msg.role !== "assistant") return false;
131
+ const text = getFirstText(msg);
132
+ return RESP_RE.test(text);
133
+ }
134
+
135
+ const filtered = [];
136
+ for (let i = 0; i < messages.length; i++) {
137
+ const msg = messages[i];
138
+
139
+ // Drop /anthropic command messages
140
+ if (isCommandMessage(msg)) {
141
+ // Also drop the immediately following assistant response if it's a command response
142
+ if (i + 1 < messages.length && isCommandResponse(messages[i + 1])) {
143
+ i++; // Skip the response too
144
+ }
145
+ continue;
146
+ }
147
+
148
+ // Drop orphaned command responses (in case the command message was already removed
149
+ // or the ordering is different)
150
+ if (isCommandResponse(msg)) {
151
+ continue;
152
+ }
153
+
154
+ filtered.push(msg);
155
+ }
156
+
157
+ // Safety: if filtering removed ALL messages, return the original to avoid sending
158
+ // an empty messages array to the API.
159
+ if (filtered.length === 0) return messages;
160
+
161
+ return filtered;
162
+ }
163
+
164
+ /**
165
+ * Return the first user message's text (string or first text block), or "".
166
+ * @param {any[]} messages
167
+ * @returns {string}
168
+ */
169
+ export function extractFirstUserMessageText(messages) {
170
+ if (!Array.isArray(messages)) return "";
171
+ for (const msg of messages) {
172
+ if (msg.role !== "user") continue;
173
+ if (typeof msg.content === "string") return msg.content;
174
+ if (Array.isArray(msg.content)) {
175
+ for (const block of msg.content) {
176
+ if (block.type === "text" && typeof block.text === "string") return block.text;
177
+ }
178
+ }
179
+ return "";
180
+ }
181
+ return "";
182
+ }
183
+
184
+ /**
185
+ * Build the request `metadata` field mimicking Claude Code's user_id envelope.
186
+ * Honors OPENCODE_ANTHROPIC_SIGNATURE_USER_ID (raw override) and merges
187
+ * CLAUDE_CODE_EXTRA_METADATA (JSON object) into the encoded user_id.
188
+ * @param {{persistentUserId: string, accountId: string, sessionId: string}} input
189
+ * @returns {{user_id: string}}
190
+ */
191
+ export function buildRequestMetadata(input) {
192
+ // Backward-compat override: raw user_id passed through without JSON-encoding.
193
+ const envUserId = process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID?.trim();
194
+ if (envUserId) return { user_id: envUserId };
195
+
196
+ const extraMetadataEnv = process.env.CLAUDE_CODE_EXTRA_METADATA?.trim();
197
+ let extraMetadata = {};
198
+ if (extraMetadataEnv) {
199
+ try {
200
+ const parsed = JSON.parse(extraMetadataEnv);
201
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
202
+ extraMetadata = parsed;
203
+ }
204
+ } catch {
205
+ /* ignore */
206
+ }
207
+ }
208
+
209
+ return {
210
+ user_id: JSON.stringify({
211
+ ...extraMetadata,
212
+ device_id: input.persistentUserId,
213
+ account_uuid: input.accountId,
214
+ session_id: input.sessionId,
215
+ }),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Resolve the outgoing max_tokens honoring the output_cap config and a
221
+ * one-turn escalation when the previous stream stopped on max_tokens.
222
+ * Reads/writes the shared sessionMetrics.lastStopReason singleton.
223
+ * @param {{max_tokens?: number}} body
224
+ * @param {any} config
225
+ * @returns {number|undefined}
226
+ */
227
+ export function resolveMaxTokens(body, config) {
228
+ if (!config.output_cap?.enabled) return body.max_tokens; // passthrough
229
+ if (body.max_tokens != null) return body.max_tokens; // caller-specified wins
230
+ // QA note L-escalation: lastStopReason is set by extractUsageFromSSEEvent AFTER the stream
231
+ // completes. The timing works correctly for "escalate for one turn" because this function runs
232
+ // BEFORE the next request's stream starts. If the response pipeline changes to update stop
233
+ // reason mid-stream or before response completion, this ordering assumption would break.
234
+ const escalated = sessionMetrics.lastStopReason === "max_tokens";
235
+ const result = escalated
236
+ ? (config.output_cap.escalated_max_tokens ?? 64_000)
237
+ : (config.output_cap.default_max_tokens ?? 8_000);
238
+ // Reset after escalation is consumed (sticky for exactly one turn)
239
+ if (escalated) {
240
+ sessionMetrics.lastStopReason = null;
241
+ }
242
+ return result;
243
+ }
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ repairOrphanedToolUseBlocks,
4
+ stripSlashCommandMessages,
5
+ extractFirstUserMessageText,
6
+ buildRequestMetadata,
7
+ resolveMaxTokens,
8
+ } from "./request-helpers.mjs";
9
+ import { sessionMetrics } from "../session-metrics.mjs";
10
+
11
+ describe("repairOrphanedToolUseBlocks", () => {
12
+ it("injects placeholder tool_result into the following user message when missing", () => {
13
+ const messages = [
14
+ { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Read", input: {} }] },
15
+ { role: "user", content: [{ type: "text", text: "no result here" }] },
16
+ ];
17
+ const out = repairOrphanedToolUseBlocks(messages);
18
+ const userBlocks = out[1].content;
19
+ expect(userBlocks[0]).toMatchObject({ type: "tool_result", tool_use_id: "t1" });
20
+ expect(userBlocks[0].content).toContain("interrupted");
21
+ // original text block preserved after the injected result
22
+ expect(userBlocks.some((b) => b.type === "text")).toBe(true);
23
+ });
24
+
25
+ it("synthesizes a user tool_result message when the assistant is the last message", () => {
26
+ const messages = [{ role: "assistant", content: [{ type: "tool_use", id: "t9", name: "Bash", input: {} }] }];
27
+ const out = repairOrphanedToolUseBlocks(messages);
28
+ expect(out).toHaveLength(2);
29
+ expect(out[1]).toMatchObject({ role: "user" });
30
+ expect(out[1].content[0]).toMatchObject({ type: "tool_result", tool_use_id: "t9" });
31
+ });
32
+
33
+ it("leaves already-paired tool_use/tool_result untouched", () => {
34
+ const messages = [
35
+ { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Read", input: {} }] },
36
+ { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "ok" }] },
37
+ ];
38
+ const out = repairOrphanedToolUseBlocks(messages);
39
+ expect(out).toEqual(messages);
40
+ });
41
+
42
+ it("passes non-array / empty through", () => {
43
+ expect(repairOrphanedToolUseBlocks([])).toEqual([]);
44
+ expect(repairOrphanedToolUseBlocks(null)).toBe(null);
45
+ });
46
+ });
47
+
48
+ describe("stripSlashCommandMessages", () => {
49
+ it("drops a /anthropic command and its ▣ Anthropic response", () => {
50
+ const messages = [
51
+ { role: "user", content: "/anthropic status" },
52
+ { role: "assistant", content: "▣ Anthropic — account 1 active" },
53
+ { role: "user", content: "real question" },
54
+ ];
55
+ const out = stripSlashCommandMessages(messages);
56
+ expect(out).toEqual([{ role: "user", content: "real question" }]);
57
+ });
58
+
59
+ it("drops orphaned ▣ Anthropic responses", () => {
60
+ const messages = [
61
+ { role: "assistant", content: [{ type: "text", text: "▣ Anthropic done" }] },
62
+ { role: "user", content: "hi" },
63
+ ];
64
+ const out = stripSlashCommandMessages(messages);
65
+ expect(out).toEqual([{ role: "user", content: "hi" }]);
66
+ });
67
+
68
+ it("returns the original array when filtering would remove everything", () => {
69
+ const messages = [{ role: "user", content: "/anthropic login" }];
70
+ expect(stripSlashCommandMessages(messages)).toBe(messages);
71
+ });
72
+ });
73
+
74
+ describe("extractFirstUserMessageText", () => {
75
+ it("returns string content of the first user message", () => {
76
+ expect(extractFirstUserMessageText([{ role: "user", content: "hello" }])).toBe("hello");
77
+ });
78
+
79
+ it("returns first text block for array content", () => {
80
+ expect(
81
+ extractFirstUserMessageText([
82
+ { role: "assistant", content: "ignored" },
83
+ { role: "user", content: [{ type: "image" }, { type: "text", text: "found" }] },
84
+ ]),
85
+ ).toBe("found");
86
+ });
87
+
88
+ it("returns empty string when no user message", () => {
89
+ expect(extractFirstUserMessageText([{ role: "assistant", content: "x" }])).toBe("");
90
+ expect(extractFirstUserMessageText(null)).toBe("");
91
+ });
92
+ });
93
+
94
+ describe("buildRequestMetadata", () => {
95
+ const ORIG = { ...process.env };
96
+ beforeEach(() => {
97
+ delete process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID;
98
+ delete process.env.CLAUDE_CODE_EXTRA_METADATA;
99
+ process.env = { ...ORIG };
100
+ delete process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID;
101
+ delete process.env.CLAUDE_CODE_EXTRA_METADATA;
102
+ });
103
+
104
+ it("encodes device/account/session ids as JSON user_id", () => {
105
+ const md = buildRequestMetadata({ persistentUserId: "dev", accountId: "acct", sessionId: "sess" });
106
+ expect(JSON.parse(md.user_id)).toEqual({ device_id: "dev", account_uuid: "acct", session_id: "sess" });
107
+ });
108
+
109
+ it("honors the raw OPENCODE_ANTHROPIC_SIGNATURE_USER_ID override verbatim", () => {
110
+ process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID = "raw-id";
111
+ expect(buildRequestMetadata({ persistentUserId: "d", accountId: "a", sessionId: "s" })).toEqual({
112
+ user_id: "raw-id",
113
+ });
114
+ });
115
+
116
+ it("merges CLAUDE_CODE_EXTRA_METADATA object into the encoded user_id", () => {
117
+ process.env.CLAUDE_CODE_EXTRA_METADATA = JSON.stringify({ org: "acme" });
118
+ const md = buildRequestMetadata({ persistentUserId: "d", accountId: "a", sessionId: "s" });
119
+ expect(JSON.parse(md.user_id)).toMatchObject({ org: "acme", device_id: "d" });
120
+ });
121
+
122
+ it("ignores malformed extra metadata JSON", () => {
123
+ process.env.CLAUDE_CODE_EXTRA_METADATA = "{not json";
124
+ const md = buildRequestMetadata({ persistentUserId: "d", accountId: "a", sessionId: "s" });
125
+ expect(JSON.parse(md.user_id)).toEqual({ device_id: "d", account_uuid: "a", session_id: "s" });
126
+ });
127
+ });
128
+
129
+ describe("resolveMaxTokens", () => {
130
+ beforeEach(() => {
131
+ sessionMetrics.lastStopReason = null;
132
+ });
133
+
134
+ it("passes through when output_cap disabled", () => {
135
+ expect(resolveMaxTokens({ max_tokens: 123 }, {})).toBe(123);
136
+ });
137
+
138
+ it("honors caller-specified max_tokens even when cap enabled", () => {
139
+ expect(resolveMaxTokens({ max_tokens: 500 }, { output_cap: { enabled: true } })).toBe(500);
140
+ });
141
+
142
+ it("uses default_max_tokens when unset and not escalating", () => {
143
+ expect(resolveMaxTokens({}, { output_cap: { enabled: true, default_max_tokens: 8000 } })).toBe(8000);
144
+ expect(resolveMaxTokens({}, { output_cap: { enabled: true } })).toBe(8000);
145
+ });
146
+
147
+ it("escalates for exactly one turn after a max_tokens stop, then resets", () => {
148
+ sessionMetrics.lastStopReason = "max_tokens";
149
+ const cfg = { output_cap: { enabled: true, default_max_tokens: 8000, escalated_max_tokens: 64000 } };
150
+ expect(resolveMaxTokens({}, cfg)).toBe(64000);
151
+ // sticky for one turn only
152
+ expect(sessionMetrics.lastStopReason).toBe(null);
153
+ expect(resolveMaxTokens({}, cfg)).toBe(8000);
154
+ });
155
+ });