@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.1

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 (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1816 -594
  5. package/package.json +1 -1
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -174
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +31 -13
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. package/src/token-refresh.ts +14 -8
@@ -2,39 +2,222 @@
2
2
  // Request body transformation
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
+ import { CLAUDE_CODE_IDENTITY_STRING, KNOWN_IDENTITY_STRINGS } from "../constants.js";
5
6
  import { buildSystemPromptBlocks } from "../system-prompt/builder.js";
6
7
  import { normalizeSystemTextBlocks } from "../system-prompt/normalize.js";
7
8
  import { normalizeThinkingBlock } from "../thinking.js";
8
9
  import type { RuntimeContext, SignatureConfig } from "../types.js";
9
10
  import { buildRequestMetadata } from "./metadata.js";
10
11
 
12
+ const TOOL_PREFIX = "mcp_";
13
+
14
+ function getBodyType(body: unknown): string {
15
+ if (body === null) return "null";
16
+ return typeof body;
17
+ }
18
+
19
+ function getInvalidBodyError(body: unknown): TypeError {
20
+ return new TypeError(
21
+ `opencode-anthropic-auth: expected string body, got ${getBodyType(body)}. This plugin does not support stream bodies. Please file a bug with the OpenCode version.`,
22
+ );
23
+ }
24
+
25
+ export function validateBodyType(body: unknown, throwOnInvalid = false): body is string {
26
+ if (body === undefined || body === null) {
27
+ return false;
28
+ }
29
+
30
+ if (typeof body === "string") {
31
+ return true;
32
+ }
33
+
34
+ if (throwOnInvalid) {
35
+ throw getInvalidBodyError(body);
36
+ }
37
+
38
+ return false;
39
+ }
40
+
41
+ export function cloneBodyForRetry(body: string): string {
42
+ validateBodyType(body, true);
43
+ return body;
44
+ }
45
+
46
+ export function detectDoublePrefix(name: string): boolean {
47
+ return name.startsWith(`${TOOL_PREFIX}${TOOL_PREFIX}`);
48
+ }
49
+
50
+ export function extractToolNamesFromBody(body: string): string[] {
51
+ const parsed = JSON.parse(body) as {
52
+ tools?: Array<{ name?: unknown }>;
53
+ messages?: Array<{ content?: unknown }>;
54
+ };
55
+ const names: string[] = [];
56
+
57
+ if (Array.isArray(parsed.tools)) {
58
+ for (const tool of parsed.tools) {
59
+ if (typeof tool?.name === "string") {
60
+ names.push(tool.name);
61
+ }
62
+ }
63
+ }
64
+
65
+ if (Array.isArray(parsed.messages)) {
66
+ for (const message of parsed.messages) {
67
+ if (!Array.isArray(message?.content)) {
68
+ continue;
69
+ }
70
+
71
+ for (const block of message.content) {
72
+ if (
73
+ block &&
74
+ typeof block === "object" &&
75
+ "type" in block &&
76
+ block.type === "tool_use" &&
77
+ "name" in block &&
78
+ typeof block.name === "string"
79
+ ) {
80
+ names.push(block.name);
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ return names;
87
+ }
88
+
89
+ function prefixToolDefinitionName(name: unknown): unknown {
90
+ if (typeof name !== "string") {
91
+ return name;
92
+ }
93
+
94
+ if (detectDoublePrefix(name)) {
95
+ throw new TypeError(`Double tool prefix detected: ${TOOL_PREFIX}${TOOL_PREFIX}`);
96
+ }
97
+
98
+ return `${TOOL_PREFIX}${name}`;
99
+ }
100
+
101
+ function prefixToolUseName(
102
+ name: unknown,
103
+ literalToolNames: ReadonlySet<string>,
104
+ debugLog?: (...args: unknown[]) => void,
105
+ ): unknown {
106
+ if (typeof name !== "string") {
107
+ return name;
108
+ }
109
+
110
+ if (detectDoublePrefix(name)) {
111
+ throw new TypeError(`Double tool prefix detected in tool_use block: ${name}`);
112
+ }
113
+
114
+ if (!name.startsWith(TOOL_PREFIX)) {
115
+ return `${TOOL_PREFIX}${name}`;
116
+ }
117
+
118
+ if (literalToolNames.has(name)) {
119
+ return `${TOOL_PREFIX}${name}`;
120
+ }
121
+
122
+ debugLog?.("prevented double-prefix drift for tool_use block", { name });
123
+ return name;
124
+ }
125
+
11
126
  export function transformRequestBody(
12
127
  body: string | undefined,
13
128
  signature: SignatureConfig,
14
129
  runtime: RuntimeContext,
130
+ relocateThirdPartyPrompts = true,
131
+ debugLog?: (...args: unknown[]) => void,
15
132
  ): string | undefined {
16
- if (!body || typeof body !== "string") return body;
17
-
18
- const TOOL_PREFIX = "mcp_";
133
+ if (body === undefined || body === null) return body;
134
+ validateBodyType(body, true);
19
135
 
20
136
  try {
21
- const parsed = JSON.parse(body);
137
+ const parsed = JSON.parse(body) as Record<string, unknown> & {
138
+ tools?: Array<Record<string, unknown>>;
139
+ messages?: Array<Record<string, unknown>>;
140
+ thinking?: unknown;
141
+ model?: string;
142
+ metadata?: Record<string, unknown>;
143
+ system?: unknown[] | undefined;
144
+ };
145
+ const parsedMessages = Array.isArray(parsed.messages) ? parsed.messages : [];
146
+ const literalToolNames = new Set<string>(
147
+ Array.isArray(parsed.tools)
148
+ ? parsed.tools
149
+ .map((tool: Record<string, unknown>) => tool.name)
150
+ .filter((name: unknown): name is string => typeof name === "string")
151
+ : [],
152
+ );
153
+
22
154
  if (Object.hasOwn(parsed, "betas")) {
23
155
  delete parsed.betas;
24
156
  }
25
157
  // Normalize thinking block for adaptive (Opus 4.6) vs manual (older models).
26
158
  if (Object.hasOwn(parsed, "thinking")) {
27
- parsed.thinking = normalizeThinkingBlock(parsed.thinking, parsed.model || "");
159
+ parsed.thinking = normalizeThinkingBlock(parsed.thinking as unknown, parsed.model || "");
28
160
  }
29
- const hasThinking = parsed.thinking && typeof parsed.thinking === "object" && parsed.thinking.type === "enabled";
161
+ const hasThinking =
162
+ parsed.thinking &&
163
+ typeof parsed.thinking === "object" &&
164
+ (parsed.thinking as { type?: string }).type === "enabled";
30
165
  if (hasThinking) {
31
166
  delete parsed.temperature;
32
167
  } else if (!Object.hasOwn(parsed, "temperature")) {
33
168
  parsed.temperature = 1;
34
169
  }
35
170
 
36
- // Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
37
- parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signature, parsed.messages);
171
+ // Sanitize system prompt and inject Claude Code identity/billing blocks.
172
+ const allSystemBlocks = buildSystemPromptBlocks(
173
+ normalizeSystemTextBlocks(parsed.system),
174
+ signature,
175
+ parsedMessages,
176
+ );
177
+
178
+ if (signature.enabled && relocateThirdPartyPrompts) {
179
+ // Keep CC blocks in system. Move blocks with third-party identifiers
180
+ // into messages to avoid system prompt content detection.
181
+ const THIRD_PARTY_MARKERS =
182
+ /sisyphus|ohmyclaude|oh\s*my\s*claude|morph[_ ]|\.sisyphus\/|ultrawork|autopilot mode|\bohmy\b|SwarmMode|\bomc\b|\bomo\b/i;
183
+
184
+ const ccBlocks: typeof allSystemBlocks = [];
185
+ const extraBlocks: typeof allSystemBlocks = [];
186
+ for (const block of allSystemBlocks) {
187
+ const isBilling = block.text.startsWith("x-anthropic-billing-header:");
188
+ const isIdentity = block.text === CLAUDE_CODE_IDENTITY_STRING || KNOWN_IDENTITY_STRINGS.has(block.text);
189
+ const hasThirdParty = THIRD_PARTY_MARKERS.test(block.text);
190
+
191
+ if (isBilling || isIdentity || !hasThirdParty) {
192
+ ccBlocks.push(block);
193
+ } else {
194
+ extraBlocks.push(block);
195
+ }
196
+ }
197
+ parsed.system = ccBlocks;
198
+
199
+ // Inject extra blocks as <system-instructions> in the first user message
200
+ if (extraBlocks.length > 0 && Array.isArray(parsed.messages) && parsed.messages.length > 0) {
201
+ const extraText = extraBlocks.map((b) => b.text).join("\n\n");
202
+ const wrapped = `<system-instructions>\n${extraText}\n</system-instructions>`;
203
+ const firstMsg = parsed.messages[0];
204
+ if (firstMsg && firstMsg.role === "user") {
205
+ if (typeof firstMsg.content === "string") {
206
+ firstMsg.content = `${wrapped}\n\n${firstMsg.content}`;
207
+ } else if (Array.isArray(firstMsg.content)) {
208
+ firstMsg.content.unshift({ type: "text", text: wrapped });
209
+ }
210
+ } else {
211
+ // No user message first — prepend a new user message
212
+ parsed.messages.unshift({
213
+ role: "user",
214
+ content: wrapped,
215
+ });
216
+ }
217
+ }
218
+ } else {
219
+ parsed.system = allSystemBlocks;
220
+ }
38
221
 
39
222
  if (signature.enabled) {
40
223
  const currentMetadata =
@@ -55,7 +238,7 @@ export function transformRequestBody(
55
238
  if (parsed.tools && Array.isArray(parsed.tools)) {
56
239
  parsed.tools = parsed.tools.map((tool: Record<string, unknown>) => ({
57
240
  ...tool,
58
- name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name,
241
+ name: prefixToolDefinitionName(tool.name),
59
242
  }));
60
243
  }
61
244
  // Add prefix to tool_use blocks in messages
@@ -66,7 +249,7 @@ export function transformRequestBody(
66
249
  if (block.type === "tool_use" && block.name) {
67
250
  return {
68
251
  ...block,
69
- name: `${TOOL_PREFIX}${block.name}`,
252
+ name: prefixToolUseName(block.name, literalToolNames, debugLog),
70
253
  };
71
254
  }
72
255
  return block;
@@ -76,8 +259,12 @@ export function transformRequestBody(
76
259
  });
77
260
  }
78
261
  return JSON.stringify(parsed);
79
- } catch {
80
- // ignore parse errors
81
- return body;
262
+ } catch (err) {
263
+ if (err instanceof SyntaxError) {
264
+ debugLog?.("body parse failed:", err.message);
265
+ return body;
266
+ }
267
+
268
+ throw err;
82
269
  }
83
270
  }
@@ -38,7 +38,10 @@ export function extractFileIds(parsed: unknown): string[] {
38
38
  return ids;
39
39
  }
40
40
 
41
- export function parseRequestBodyMetadata(body: string | undefined): RequestBodyMetadata {
41
+ export function parseRequestBodyMetadata(
42
+ body: string | undefined,
43
+ debugLog?: (...args: unknown[]) => void,
44
+ ): RequestBodyMetadata {
42
45
  if (!body || typeof body !== "string") {
43
46
  return { model: "", tools: [], messages: [], hasFileReferences: false };
44
47
  }
@@ -50,7 +53,8 @@ export function parseRequestBodyMetadata(body: string | undefined): RequestBodyM
50
53
  const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
51
54
  const hasFileReferences = extractFileIds(parsed).length > 0;
52
55
  return { model, tools, messages, hasFileReferences };
53
- } catch {
56
+ } catch (err) {
57
+ debugLog?.("extractFileIds failed:", (err as Error).message);
54
58
  return { model: "", tools: [], messages: [], hasFileReferences: false };
55
59
  }
56
60
  }
@@ -1,4 +1,4 @@
1
- export { stripMcpPrefixFromParsedEvent, stripMcpPrefixFromSSE } from "./mcp.js";
1
+ export { stripMcpPrefixFromJsonBody, stripMcpPrefixFromParsedEvent, stripMcpPrefixFromSSE } from "./mcp.js";
2
2
  export {
3
3
  extractUsageFromSSEEvent,
4
4
  getMidStreamAccountError,
@@ -21,6 +21,45 @@ export function stripMcpPrefixFromSSE(text: string): string {
21
21
  });
22
22
  }
23
23
 
24
+ function stripMcpPrefixFromToolUseBlock(block: unknown): boolean {
25
+ if (!block || typeof block !== "object") return false;
26
+
27
+ const parsedBlock = block as Record<string, unknown>;
28
+ if (parsedBlock.type !== "tool_use" || typeof parsedBlock.name !== "string") {
29
+ return false;
30
+ }
31
+
32
+ if (!parsedBlock.name.startsWith("mcp_")) {
33
+ return false;
34
+ }
35
+
36
+ parsedBlock.name = parsedBlock.name.slice(4);
37
+ return true;
38
+ }
39
+
40
+ function stripMcpPrefixFromContentBlocks(content: unknown): boolean {
41
+ if (!Array.isArray(content)) return false;
42
+
43
+ let modified = false;
44
+ for (const block of content) {
45
+ modified = stripMcpPrefixFromToolUseBlock(block) || modified;
46
+ }
47
+
48
+ return modified;
49
+ }
50
+
51
+ function stripMcpPrefixFromMessages(messages: unknown): boolean {
52
+ if (!Array.isArray(messages)) return false;
53
+
54
+ let modified = false;
55
+ for (const message of messages) {
56
+ if (!message || typeof message !== "object") continue;
57
+ modified = stripMcpPrefixFromContentBlocks((message as Record<string, unknown>).content) || modified;
58
+ }
59
+
60
+ return modified;
61
+ }
62
+
24
63
  /**
25
64
  * Mutate a parsed SSE event object, removing `mcp_` prefix from tool_use
26
65
  * name fields. Returns true if any modification was made.
@@ -32,42 +71,32 @@ export function stripMcpPrefixFromParsedEvent(parsed: unknown): boolean {
32
71
  let modified = false;
33
72
 
34
73
  // content_block_start: { content_block: { type: "tool_use", name: "mcp_..." } }
35
- if (
36
- p.content_block &&
37
- typeof p.content_block === "object" &&
38
- (p.content_block as Record<string, unknown>).type === "tool_use" &&
39
- typeof (p.content_block as Record<string, unknown>).name === "string" &&
40
- ((p.content_block as Record<string, unknown>).name as string).startsWith("mcp_")
41
- ) {
42
- (p.content_block as Record<string, unknown>).name = (
43
- (p.content_block as Record<string, unknown>).name as string
44
- ).slice(4);
45
- modified = true;
46
- }
74
+ modified = stripMcpPrefixFromToolUseBlock(p.content_block) || modified;
47
75
 
48
76
  // message_start: { message: { content: [{ type: "tool_use", name: "mcp_..." }] } }
49
- if (p.message && Array.isArray((p.message as Record<string, unknown>).content)) {
50
- for (const block of (p.message as Record<string, unknown>).content as unknown[]) {
51
- if (!block || typeof block !== "object") continue;
52
- const b = block as Record<string, unknown>;
53
- if (b.type === "tool_use" && typeof b.name === "string" && b.name.startsWith("mcp_")) {
54
- b.name = b.name.slice(4);
55
- modified = true;
56
- }
57
- }
77
+ if (p.message && typeof p.message === "object") {
78
+ modified = stripMcpPrefixFromContentBlocks((p.message as Record<string, unknown>).content) || modified;
58
79
  }
59
80
 
60
81
  // Top-level content array (non-streaming responses forwarded through SSE)
61
- if (Array.isArray(p.content)) {
62
- for (const block of p.content) {
63
- if (!block || typeof block !== "object") continue;
64
- const b = block as Record<string, unknown>;
65
- if (b.type === "tool_use" && typeof b.name === "string" && b.name.startsWith("mcp_")) {
66
- b.name = b.name.slice(4);
67
- modified = true;
68
- }
69
- }
70
- }
82
+ modified = stripMcpPrefixFromContentBlocks(p.content) || modified;
71
83
 
72
84
  return modified;
73
85
  }
86
+
87
+ export function stripMcpPrefixFromJsonBody(body: string): string {
88
+ try {
89
+ const parsed = JSON.parse(body) as Record<string, unknown>;
90
+ let modified = false;
91
+
92
+ modified = stripMcpPrefixFromContentBlocks(parsed.content) || modified;
93
+ modified = stripMcpPrefixFromMessages(parsed.messages) || modified;
94
+ if (parsed.message && typeof parsed.message === "object") {
95
+ modified = stripMcpPrefixFromContentBlocks((parsed.message as Record<string, unknown>).content) || modified;
96
+ }
97
+
98
+ return modified ? JSON.stringify(parsed) : body;
99
+ } catch {
100
+ return body;
101
+ }
102
+ }