opencode-translate 0.0.2 → 0.0.4

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/README.md CHANGED
@@ -32,7 +32,18 @@
32
32
  - No title translation or title-path English enforcement.
33
33
  - No subagent translation.
34
34
  - No translation of tool inputs, tool outputs, or reasoning parts.
35
- - No self-healing for edited historical translated user messages. Those abort with a stale-cache error.
35
+ - Edited historical translated user messages are passed through as-is instead of being re-translated (the original edited text is what the LLM sees).
36
+
37
+ ## Hook Failure Handling
38
+
39
+ Hooks never throw. If the translator fails (network error, auth failure, provider 4xx/5xx), the plugin:
40
+
41
+ 1. Logs the error via `client.app.log` (visible with `verbose: true`).
42
+ 2. Emits a `⚠️ Translation failed: …` synthetic part.
43
+ 3. Falls back to sending the original (untranslated) user text to the model.
44
+ 4. On first-turn activation failure, it also rolls back activation so the next turn retries cleanly.
45
+
46
+ A stalled provider request is additionally bounded by a 60s hard timeout per translation call, so a hung upstream cannot block the OpenCode session.
36
47
 
37
48
  ## Install
38
49
 
@@ -89,15 +100,27 @@ Using this plugin means text goes to two model providers per turn:
89
100
 
90
101
  If you need strict single-provider or self-hosted-only behavior, do not enable this plugin.
91
102
 
92
- ## Anthropic OAuth Warning
103
+ ## Anthropic OAuth Support
104
+
105
+ If `translatorModel` uses Anthropic and OpenCode auth is backed by Anthropic OAuth (Claude Pro/Max), the plugin reuses those OAuth credentials for translation requests.
106
+
107
+ Anthropic's `/v1/messages` endpoint rejects OAuth-authenticated requests that do not match the Claude Code CLI fingerprint (response: `429 rate_limit_error` with an empty `"Error"` message). To pass, the plugin applies the same transformation that `@ex-machina/opencode-anthropic-auth` uses for OpenCode's main chat, but only for its own translator requests:
108
+
109
+ - `user-agent: claude-cli/2.1.87 (external, cli)`
110
+ - Required `anthropic-beta` headers (`oauth-2025-04-20`, `interleaved-thinking-2025-05-14`)
111
+ - `?beta=true` appended to the `/v1/messages` URL
112
+ - `x-anthropic-billing-header` block prepended to `system[]` (deterministic CCH of the first user message)
113
+ - `"You are a Claude agent, built on Anthropic's Claude Agent SDK."` injected as the next `system[]` block
114
+
115
+ The technique and constants are documented in https://github.com/ex-machina-co/opencode-anthropic-auth. See `src/anthropic-oauth.ts`.
93
116
 
94
- If `translatorModel` uses Anthropic and OpenCode auth is backed by Anthropic OAuth, this plugin will attempt to reuse those OAuth credentials for translation requests.
117
+ Tradeoffs:
95
118
 
96
- - This depends on undocumented Anthropic OAuth request shapes.
97
- - OpenCode upstream removed Anthropic OAuth support for legal / policy reasons.
98
- - `opencode-translate` does not spoof Claude CLI headers or reintroduce the evasions upstream removed.
119
+ - Relies on an undocumented Anthropic OAuth request shape. Anthropic can change this at any time and force the plugin to stop using OAuth.
120
+ - OpenCode upstream removed Anthropic OAuth support for legal / policy reasons. Installing this plugin reintroduces an equivalent code path in your environment.
121
+ - Translator requests contribute to your Claude Pro/Max rate limit alongside OpenCode's main chat.
99
122
 
100
- If you do not want that risk, use a plain API key for Anthropic or choose a different translator provider.
123
+ If you prefer a plain API key, set `ANTHROPIC_API_KEY` in the environment or pass `apiKey` in plugin options. The plugin prefers explicit `apiKey`, then `ANTHROPIC_API_KEY`, then OAuth.
101
124
 
102
125
  ## Manual Smoke Test
103
126
 
@@ -107,7 +130,7 @@ If you do not want that risk, use a plain API key for Anthropic or choose a diff
107
130
  4. Confirm the `→ EN: ...` preview appears under the user message.
108
131
  5. Confirm assistant text streams in English, then gains a translated trailer when the text part finishes.
109
132
  6. Confirm later messages in the same session translate without repeating `$en`.
110
- 7. Confirm editing a historical translated user message aborts with the stale-cache error.
133
+ 7. Confirm editing a historical translated user message falls back to the edited text being sent as-is (with a log entry visible under `verbose: true`).
111
134
  8. Confirm task-tool child sessions are not translated.
112
135
  9. Confirm the title remains in the source language in v1.
113
136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "OpenCode plugin that lets the user chat in a configured source language while the main chat loop only sees English.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/activation.ts CHANGED
@@ -124,6 +124,12 @@ function mergeTranslatedMetadata(state: TranslateState, part: TextPartLike, engl
124
124
  }
125
125
  }
126
126
 
127
+ // OpenCode's flag semantics, observed from packages/opencode and packages/ui:
128
+ // synthetic: true -> hidden from the user UI, still sent to the LLM
129
+ // ignored: true -> hidden from the LLM, still shown in the user UI
130
+ // The translation preview, activation banner, and failure notices are
131
+ // user-facing status/diagnostic parts that must not leak into the LLM
132
+ // prompt, so they use synthetic:false + ignored:true.
127
133
  function createSyntheticTextPart(
128
134
  sessionID: string,
129
135
  messageID: string,
@@ -136,7 +142,7 @@ function createSyntheticTextPart(
136
142
  messageID,
137
143
  type: "text",
138
144
  text,
139
- synthetic: true,
145
+ synthetic: false,
140
146
  ignored: true,
141
147
  metadata,
142
148
  }
@@ -255,152 +261,194 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
255
261
 
256
262
  return {
257
263
  "chat.message": async (input, output) => {
258
- const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
259
- let activeState = resolved.state
260
- let activatedThisTurn = false
261
-
262
- if (!activeState && resolved.canActivate) {
263
- const match = findTriggerMatch(output.parts as TextPartLike[], options.triggerKeywords)
264
- if (match) {
265
- const part = output.parts[match.partArrayIndex] as TextPartLike & { text: string }
266
- part.text = stripTriggerKeyword(part.text, match.keyword, match.offset)
267
- activeState = createState(options)
268
- if (!NONCE_PATTERN.test(activeState.translate_nonce)) {
269
- throw new Error("Generated invalid translation nonce")
264
+ // Hooks must never throw. A thrown error propagates into OpenCode's
265
+ // Effect runtime as a defect, kills the fiber, and stalls the session —
266
+ // to the user this looks like infinite loading with no error message.
267
+ // Instead, we log the failure and fall back to the untranslated text so
268
+ // the chat keeps moving.
269
+ try {
270
+ const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
271
+ let activeState = resolved.state
272
+ let activatedThisTurn = false
273
+
274
+ if (!activeState && resolved.canActivate) {
275
+ const match = findTriggerMatch(output.parts as TextPartLike[], options.triggerKeywords)
276
+ if (match) {
277
+ const part = output.parts[match.partArrayIndex] as TextPartLike & { text: string }
278
+ const originalText = part.text
279
+ part.text = stripTriggerKeyword(part.text, match.keyword, match.offset)
280
+ activeState = createState(options)
281
+ if (!NONCE_PATTERN.test(activeState.translate_nonce)) {
282
+ part.text = originalText
283
+ await logError(client, new Error("Generated invalid translation nonce"))
284
+ return
285
+ }
286
+ activatedThisTurn = true
287
+ sessionStateCache.set(input.sessionID, activeState)
270
288
  }
271
- activatedThisTurn = true
272
- sessionStateCache.set(input.sessionID, activeState)
273
289
  }
274
- }
275
290
 
276
- if (!activeState) return
291
+ if (!activeState) return
277
292
 
278
- const nextParts: TextPartLike[] = []
279
- let eligibleIndex = 0
293
+ const nextParts: TextPartLike[] = []
294
+ let eligibleIndex = 0
295
+ const translationErrors: { part: TextPartLike; error: unknown }[] = []
280
296
 
281
- for (const part of output.parts as TextPartLike[]) {
282
- nextParts.push(part)
283
- if (!isUserAuthoredTextPart(part)) continue
297
+ for (const part of output.parts as TextPartLike[]) {
298
+ nextParts.push(part)
299
+ if (!isUserAuthoredTextPart(part)) continue
284
300
 
285
- const currentEligibleIndex = eligibleIndex
286
- eligibleIndex += 1
287
- if (part.text.trim().length === 0) continue
301
+ const currentEligibleIndex = eligibleIndex
302
+ eligibleIndex += 1
303
+ if (part.text.trim().length === 0) continue
288
304
 
289
- try {
290
- const english = await translator.translateText({
291
- text: part.text,
292
- sourceLanguage: activeState.translate_source_lang,
293
- targetLanguage: LLM_LANGUAGE,
294
- direction: "inbound",
295
- })
305
+ try {
306
+ const english = await translator.translateText({
307
+ text: part.text,
308
+ sourceLanguage: activeState.translate_source_lang,
309
+ targetLanguage: LLM_LANGUAGE,
310
+ direction: "inbound",
311
+ })
296
312
 
297
- const sourceHash = hashText(part.text)
298
- part.metadata = {
299
- ...(part.metadata ?? {}),
300
- ...mergeTranslatedMetadata(activeState, part, english),
313
+ const sourceHash = hashText(part.text)
314
+ part.metadata = {
315
+ ...(part.metadata ?? {}),
316
+ ...mergeTranslatedMetadata(activeState, part, english),
317
+ }
318
+
319
+ nextParts.push(
320
+ createSyntheticTextPart(part.sessionID, part.messageID, `→ EN: ${english}`, {
321
+ translate_role: "translation_preview",
322
+ translate_nonce: activeState.translate_nonce,
323
+ translate_source_hash: sourceHash,
324
+ translate_part_index: currentEligibleIndex,
325
+ }),
326
+ )
327
+ } catch (error) {
328
+ // Fall back to sending the original text to the LLM so the user
329
+ // still gets a response. Surface the error as a synthetic part.
330
+ translationErrors.push({ part, error })
331
+ const wrapped = buildInboundTranslationError(activeState.translate_source_lang, normalizeReason(error))
332
+ await logError(client, wrapped)
333
+ nextParts.push(
334
+ createSyntheticTextPart(
335
+ part.sessionID,
336
+ part.messageID,
337
+ `⚠️ Translation failed: ${normalizeReason(error)}. Original text will be sent to the model.`,
338
+ {
339
+ translate_role: "translation_failure",
340
+ translate_nonce: activeState.translate_nonce,
341
+ translate_part_index: currentEligibleIndex,
342
+ },
343
+ ),
344
+ )
301
345
  }
346
+ }
302
347
 
348
+ // If we activated this turn but translation failed for every
349
+ // user-authored part, roll back activation so the next turn does a
350
+ // clean retry instead of cementing broken state.
351
+ if (activatedThisTurn && translationErrors.length > 0 && eligibleIndex === translationErrors.length) {
352
+ sessionStateCache.set(input.sessionID, null)
353
+ return
354
+ }
355
+
356
+ if (activatedThisTurn) {
303
357
  nextParts.push(
304
- createSyntheticTextPart(part.sessionID, part.messageID, `→ EN: ${english}`, {
305
- translate_role: "translation_preview",
306
- translate_nonce: activeState.translate_nonce,
307
- translate_source_hash: sourceHash,
308
- translate_part_index: currentEligibleIndex,
358
+ createSyntheticTextPart(input.sessionID, output.message.id, createActivationBannerText(options), {
359
+ ...activeState,
360
+ translate_role: "activation_banner",
361
+ translate_spec_version: SPEC_VERSION,
309
362
  }),
310
363
  )
311
- } catch (error) {
312
- if (
313
- error instanceof Error &&
314
- (error.message.includes(":AUTH_UNAVAILABLE]") || error.message.includes(":OAUTH_REFRESH_FAILED]"))
315
- ) {
316
- throw error
317
- }
318
- throw buildInboundTranslationError(activeState.translate_source_lang, normalizeReason(error))
319
364
  }
320
- }
321
365
 
322
- if (activatedThisTurn) {
323
- nextParts.push(
324
- createSyntheticTextPart(input.sessionID, output.message.id, createActivationBannerText(options), {
325
- ...activeState,
326
- translate_role: "activation_banner",
327
- translate_spec_version: SPEC_VERSION,
328
- }),
329
- )
366
+ output.parts.splice(0, output.parts.length, ...(nextParts as typeof output.parts))
367
+ } catch (error) {
368
+ await logError(client, error)
330
369
  }
331
-
332
- output.parts.splice(0, output.parts.length, ...(nextParts as typeof output.parts))
333
370
  },
334
371
  "experimental.chat.messages.transform": async (_input, output) => {
335
- const sessionID = output.messages[0]?.info.sessionID
336
- if (!sessionID) return
337
-
338
- const resolved = await resolveSessionState(client, ctx.directory, sessionID)
339
- const activeState = resolved.state
340
- if (!activeState) return
341
-
342
- for (const message of output.messages as MessageWithPartsLike[]) {
343
- if (message.info.role === "user") {
344
- for (const part of message.parts) {
345
- if (!isTextPart(part)) continue
346
- if (!shouldRequireCache(part)) continue
347
- const metadata = asMetadata(part)
348
- const sourceHash = hashText(part.text)
349
- if (
350
- metadata.translate_enabled === true &&
351
- metadata.translate_nonce === activeState.translate_nonce &&
352
- metadata.translate_source_hash === sourceHash &&
353
- typeof metadata.translate_en === "string"
354
- ) {
355
- part.text = metadata.translate_en
356
- continue
372
+ try {
373
+ const sessionID = output.messages[0]?.info.sessionID
374
+ if (!sessionID) return
375
+
376
+ const resolved = await resolveSessionState(client, ctx.directory, sessionID)
377
+ const activeState = resolved.state
378
+ if (!activeState) return
379
+
380
+ for (const message of output.messages as MessageWithPartsLike[]) {
381
+ if (message.info.role === "user") {
382
+ for (const part of message.parts) {
383
+ if (!isTextPart(part)) continue
384
+ if (!shouldRequireCache(part)) continue
385
+ const metadata = asMetadata(part)
386
+ const sourceHash = hashText(part.text)
387
+ if (
388
+ metadata.translate_enabled === true &&
389
+ metadata.translate_nonce === activeState.translate_nonce &&
390
+ metadata.translate_source_hash === sourceHash &&
391
+ typeof metadata.translate_en === "string"
392
+ ) {
393
+ part.text = metadata.translate_en
394
+ continue
395
+ }
396
+
397
+ // Stale cache or untranslated text. Send it through as-is
398
+ // (English history would be ideal, but we shouldn't block the
399
+ // session). Also log so the user can diagnose if needed.
400
+ await logError(client, buildStaleCacheError())
357
401
  }
358
-
359
- throw buildStaleCacheError()
360
402
  }
361
- }
362
403
 
363
- if (message.info.role === "assistant") {
364
- for (const part of message.parts) {
365
- if (!isTextPart(part)) continue
366
- part.text = extractEnglishHistoryText(part.text, activeState.translate_nonce)
404
+ if (message.info.role === "assistant") {
405
+ for (const part of message.parts) {
406
+ if (!isTextPart(part)) continue
407
+ part.text = extractEnglishHistoryText(part.text, activeState.translate_nonce)
408
+ }
367
409
  }
368
410
  }
411
+ } catch (error) {
412
+ await logError(client, error)
369
413
  }
370
414
  },
371
415
  "experimental.text.complete": async (input, output) => {
372
- const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
373
- const activeState = resolved.state
374
- if (!activeState) return
375
-
376
- const message = unwrapData(
377
- await client.session.message({
378
- path: { id: input.sessionID, messageID: input.messageID },
379
- query: { ...(ctx.directory ? { directory: ctx.directory } : {}) },
380
- throwOnError: true,
381
- }),
382
- ) as MessageWithPartsLike & { info: Record<string, unknown> }
383
-
384
- if (message.info.role !== "assistant") return
385
- if (message.info.summary === true) return
386
- if (activeState.translate_display_lang === LLM_LANGUAGE || output.text.length === 0) return
387
-
388
416
  try {
389
- const translated = await translator.translateText({
390
- text: output.text,
391
- sourceLanguage: LLM_LANGUAGE,
392
- targetLanguage: activeState.translate_display_lang,
393
- direction: "outbound",
394
- })
395
-
396
- output.text = composeTranslatedAssistantText(
397
- output.text,
398
- getDisplayLanguageLabel(activeState.translate_display_lang),
399
- translated,
400
- activeState.translate_nonce,
401
- )
417
+ const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
418
+ const activeState = resolved.state
419
+ if (!activeState) return
420
+
421
+ const message = unwrapData(
422
+ await client.session.message({
423
+ path: { id: input.sessionID, messageID: input.messageID },
424
+ query: { ...(ctx.directory ? { directory: ctx.directory } : {}) },
425
+ throwOnError: true,
426
+ }),
427
+ ) as MessageWithPartsLike & { info: Record<string, unknown> }
428
+
429
+ if (message.info.role !== "assistant") return
430
+ if (message.info.summary === true) return
431
+ if (activeState.translate_display_lang === LLM_LANGUAGE || output.text.length === 0) return
432
+
433
+ try {
434
+ const translated = await translator.translateText({
435
+ text: output.text,
436
+ sourceLanguage: LLM_LANGUAGE,
437
+ targetLanguage: activeState.translate_display_lang,
438
+ direction: "outbound",
439
+ })
440
+
441
+ output.text = composeTranslatedAssistantText(
442
+ output.text,
443
+ getDisplayLanguageLabel(activeState.translate_display_lang),
444
+ translated,
445
+ activeState.translate_nonce,
446
+ )
447
+ } catch (error) {
448
+ output.text = composeTranslationFailureText(output.text, activeState.translate_nonce)
449
+ await logError(client, error)
450
+ }
402
451
  } catch (error) {
403
- output.text = composeTranslationFailureText(output.text, activeState.translate_nonce)
404
452
  await logError(client, error)
405
453
  }
406
454
  },
@@ -0,0 +1,148 @@
1
+ // Anthropic OAuth request transformations.
2
+ //
3
+ // When using Anthropic's OAuth credentials (Claude Pro/Max) outside of the
4
+ // official Claude Code client, the /v1/messages API responds with a
5
+ // `429 rate_limit_error` and an empty "Error" message unless the request
6
+ // shape matches the Claude Code CLI fingerprint: specific headers, a
7
+ // `?beta=true` query, the Claude Code identity in `system[0]`, and a
8
+ // deterministic billing header block.
9
+ //
10
+ // This module implements the minimum transformation that makes translator
11
+ // requests pass those checks. The technique (including the identity string,
12
+ // required beta headers, and CCH billing header format) is documented by the
13
+ // `@ex-machina/opencode-anthropic-auth` plugin:
14
+ //
15
+ // https://github.com/ex-machina-co/opencode-anthropic-auth
16
+ //
17
+ // We only apply these transformations to translator requests this plugin
18
+ // originates. OpenCode's main chat already has its own auth loader (e.g.
19
+ // `@ex-machina/opencode-anthropic-auth`) handling its requests independently.
20
+
21
+ import { createHash } from "node:crypto"
22
+
23
+ export const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK."
24
+
25
+ export const REQUIRED_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"] as const
26
+
27
+ export const CLAUDE_CODE_VERSION = "2.1.87"
28
+ export const CLAUDE_CODE_ENTRYPOINT = "sdk-cli"
29
+ export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`
30
+
31
+ // Deterministic billing header parameters — must match Claude Code's own derivation.
32
+ const CCH_SALT = "59cf53e54c78"
33
+ const CCH_POSITIONS = [4, 7, 20] as const
34
+
35
+ type SystemBlock = { type: string; text: string; [key: string]: unknown }
36
+
37
+ type MessageLike = {
38
+ role?: string
39
+ content?: string | Array<{ type?: string; text?: string }>
40
+ }
41
+
42
+ function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return value != null && typeof value === "object" && !Array.isArray(value)
44
+ }
45
+
46
+ function extractFirstUserMessageText(messages: MessageLike[] | undefined): string {
47
+ if (!Array.isArray(messages)) return ""
48
+ const first = messages.find((message) => message?.role === "user")
49
+ if (!first) return ""
50
+ const { content } = first
51
+ if (typeof content === "string") return content
52
+ if (Array.isArray(content)) {
53
+ const textBlock = content.find((block) => block?.type === "text")
54
+ if (textBlock?.text) return textBlock.text
55
+ }
56
+ return ""
57
+ }
58
+
59
+ function computeCCH(messageText: string): string {
60
+ return createHash("sha256").update(messageText).digest("hex").slice(0, 5)
61
+ }
62
+
63
+ function computeVersionSuffix(messageText: string, version: string): string {
64
+ const chars = CCH_POSITIONS.map((index) => messageText[index] ?? "0").join("")
65
+ return createHash("sha256").update(`${CCH_SALT}${chars}${version}`).digest("hex").slice(0, 3)
66
+ }
67
+
68
+ export function buildBillingHeaderValue(messages: MessageLike[] | undefined): string {
69
+ const text = extractFirstUserMessageText(messages)
70
+ const cch = computeCCH(text)
71
+ const suffix = computeVersionSuffix(text, CLAUDE_CODE_VERSION)
72
+ return (
73
+ "x-anthropic-billing-header: " +
74
+ `cc_version=${CLAUDE_CODE_VERSION}.${suffix}; ` +
75
+ `cc_entrypoint=${CLAUDE_CODE_ENTRYPOINT}; ` +
76
+ `cch=${cch};`
77
+ )
78
+ }
79
+
80
+ export function mergeBetaHeaders(headers: Headers): string {
81
+ const incoming = headers.get("anthropic-beta") || ""
82
+ const incomingList = incoming
83
+ .split(",")
84
+ .map((value) => value.trim())
85
+ .filter(Boolean)
86
+ return [...new Set([...REQUIRED_BETAS, ...incomingList])].join(",")
87
+ }
88
+
89
+ export function setOAuthHeaders(headers: Headers, accessToken: string): Headers {
90
+ headers.set("authorization", `Bearer ${accessToken}`)
91
+ headers.set("anthropic-beta", mergeBetaHeaders(headers))
92
+ headers.set("user-agent", CLAUDE_CLI_USER_AGENT)
93
+ headers.delete("x-api-key")
94
+ return headers
95
+ }
96
+
97
+ export function rewriteMessagesURL(input: URL): URL {
98
+ if (input.pathname === "/v1/messages" && !input.searchParams.has("beta")) {
99
+ input.searchParams.set("beta", "true")
100
+ }
101
+ return input
102
+ }
103
+
104
+ function normalizeSystem(raw: unknown): SystemBlock[] {
105
+ if (raw == null) return []
106
+ if (typeof raw === "string") return raw.length > 0 ? [{ type: "text", text: raw }] : []
107
+ if (isRecord(raw)) {
108
+ const type = typeof raw.type === "string" ? raw.type : "text"
109
+ const text = typeof raw.text === "string" ? raw.text : ""
110
+ return [{ ...raw, type, text }]
111
+ }
112
+ if (!Array.isArray(raw)) return []
113
+ return raw
114
+ .map((item): SystemBlock | null => {
115
+ if (typeof item === "string") return { type: "text", text: item }
116
+ if (isRecord(item) && typeof item.text === "string") {
117
+ const type = typeof item.type === "string" ? item.type : "text"
118
+ return { ...item, type, text: item.text }
119
+ }
120
+ return null
121
+ })
122
+ .filter((block): block is SystemBlock => block !== null)
123
+ }
124
+
125
+ export function buildOAuthSystem(rawSystem: unknown, messages: MessageLike[] | undefined): SystemBlock[] {
126
+ const identity: SystemBlock = { type: "text", text: CLAUDE_CODE_IDENTITY }
127
+ const existing = normalizeSystem(rawSystem).filter((block) => block.text !== CLAUDE_CODE_IDENTITY)
128
+ const billing: SystemBlock = { type: "text", text: buildBillingHeaderValue(messages) }
129
+ return [billing, identity, ...existing]
130
+ }
131
+
132
+ // Rewrite an /v1/messages POST body so its system prompt and billing header
133
+ // satisfy Claude Code's OAuth fingerprint. Returns the original body on parse
134
+ // failure so we never break a request that was already well-formed.
135
+ export function rewriteMessagesBody(body: string): string {
136
+ try {
137
+ const parsed = JSON.parse(body) as Record<string, unknown>
138
+ const messages = Array.isArray(parsed.messages) ? (parsed.messages as MessageLike[]) : undefined
139
+ parsed.system = buildOAuthSystem(parsed.system, messages)
140
+ return JSON.stringify(parsed)
141
+ } catch {
142
+ return body
143
+ }
144
+ }
145
+
146
+ export function isAnthropicMessagesRequest(url: URL): boolean {
147
+ return url.pathname === "/v1/messages"
148
+ }
package/src/auth.ts CHANGED
@@ -2,6 +2,12 @@ import { readFile, stat } from "node:fs/promises"
2
2
  import os from "node:os"
3
3
  import path from "node:path"
4
4
  import { setTimeout as sleep } from "node:timers/promises"
5
+ import {
6
+ isAnthropicMessagesRequest,
7
+ rewriteMessagesBody,
8
+ rewriteMessagesURL,
9
+ setOAuthHeaders as setAnthropicOAuthHeaders,
10
+ } from "./anthropic-oauth"
5
11
  import {
6
12
  AUTH_ENV_FALLBACK,
7
13
  type AuthInfo,
@@ -385,13 +391,16 @@ export function createCredentialResolver(
385
391
  const inputUrl =
386
392
  input instanceof URL ? new URL(input.href) : new URL(typeof input === "string" ? input : input.url)
387
393
 
394
+ let nextBody = init?.body
395
+
388
396
  if (providerID === "anthropic") {
389
- headers.set("Authorization", `Bearer ${info.access}`)
390
- headers.set("anthropic-beta", "oauth-2025-04-20,interleaved-thinking-2025-05-14")
397
+ // Match the Claude Code CLI fingerprint so Anthropic's OAuth rate-limit
398
+ // guard doesn't reject third-party agents. See src/anthropic-oauth.ts.
399
+ setAnthropicOAuthHeaders(headers, info.access)
391
400
  headers.set("anthropic-version", "2023-06-01")
392
- headers.delete("x-api-key")
393
- if (inputUrl.pathname === "/v1/messages" && !inputUrl.searchParams.has("beta")) {
394
- inputUrl.searchParams.set("beta", "true")
401
+ rewriteMessagesURL(inputUrl)
402
+ if (isAnthropicMessagesRequest(inputUrl) && typeof nextBody === "string") {
403
+ nextBody = rewriteMessagesBody(nextBody)
395
404
  }
396
405
  }
397
406
 
@@ -436,6 +445,7 @@ export function createCredentialResolver(
436
445
  return fetchImpl(inputUrl, {
437
446
  ...init,
438
447
  headers,
448
+ body: nextBody,
439
449
  })
440
450
  }
441
451
  }
package/src/translator.ts CHANGED
@@ -20,6 +20,7 @@ interface TranslatorDependencies {
20
20
  sleep?: (ms: number) => Promise<void>
21
21
  now?: () => number
22
22
  credentialResolver?: ReturnType<typeof createCredentialResolver>
23
+ timeoutMs?: number
23
24
  }
24
25
 
25
26
  interface TranslateTextInput {
@@ -29,6 +30,26 @@ interface TranslateTextInput {
29
30
  direction: "inbound" | "outbound"
30
31
  }
31
32
 
33
+ // Hard timeout for a single generateText call. Without this, a stalled
34
+ // provider request can block the chat.message hook indefinitely.
35
+ const DEFAULT_TRANSLATE_TIMEOUT_MS = 60_000
36
+
37
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
38
+ return new Promise<T>((resolve, reject) => {
39
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)
40
+ promise.then(
41
+ (value) => {
42
+ clearTimeout(timer)
43
+ resolve(value)
44
+ },
45
+ (error) => {
46
+ clearTimeout(timer)
47
+ reject(error)
48
+ },
49
+ )
50
+ })
51
+ }
52
+
32
53
  const providerFactoryCache = new Map<string, unknown>()
33
54
 
34
55
  export function __resetTranslatorCachesForTest() {
@@ -193,6 +214,7 @@ export function createTranslator(
193
214
  const now = deps.now ?? (() => Date.now())
194
215
  const generateTextImpl = deps.generateTextImpl ?? generateText
195
216
  const credentialResolver = deps.credentialResolver ?? createCredentialResolver(client, options)
217
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TRANSLATE_TIMEOUT_MS
196
218
 
197
219
  async function translateText(input: TranslateTextInput): Promise<string> {
198
220
  if (!input.text) return input.text
@@ -213,21 +235,25 @@ export function createTranslator(
213
235
  try {
214
236
  const translated = await withRetry(async () => {
215
237
  try {
216
- const result = (await generateTextImpl({
217
- model: model as never,
218
- system: buildSystemPrompt({
219
- sourceLanguage: input.sourceLanguage,
220
- targetLanguage: input.targetLanguage,
221
- text: protectedText.text,
222
- strictPlaceholderRetry: missingPlaceholders,
223
- }),
224
- temperature: 0,
225
- prompt: buildUserPrompt({
226
- sourceLanguage: input.sourceLanguage,
227
- targetLanguage: input.targetLanguage,
228
- text: protectedText.text,
229
- }),
230
- })) as { text: string }
238
+ const result = (await withTimeout(
239
+ generateTextImpl({
240
+ model: model as never,
241
+ system: buildSystemPrompt({
242
+ sourceLanguage: input.sourceLanguage,
243
+ targetLanguage: input.targetLanguage,
244
+ text: protectedText.text,
245
+ strictPlaceholderRetry: missingPlaceholders,
246
+ }),
247
+ temperature: 0,
248
+ prompt: buildUserPrompt({
249
+ sourceLanguage: input.sourceLanguage,
250
+ targetLanguage: input.targetLanguage,
251
+ text: protectedText.text,
252
+ }),
253
+ }) as Promise<{ text: string }>,
254
+ timeoutMs,
255
+ "Translator generateText",
256
+ )) as { text: string }
231
257
  return result.text
232
258
  } catch (error) {
233
259
  if (isAuthMessage(error)) throw error