opencode-translate 0.0.2 → 0.0.3

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.3",
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
@@ -255,152 +255,194 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
255
255
 
256
256
  return {
257
257
  "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")
258
+ // Hooks must never throw. A thrown error propagates into OpenCode's
259
+ // Effect runtime as a defect, kills the fiber, and stalls the session —
260
+ // to the user this looks like infinite loading with no error message.
261
+ // Instead, we log the failure and fall back to the untranslated text so
262
+ // the chat keeps moving.
263
+ try {
264
+ const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
265
+ let activeState = resolved.state
266
+ let activatedThisTurn = false
267
+
268
+ if (!activeState && resolved.canActivate) {
269
+ const match = findTriggerMatch(output.parts as TextPartLike[], options.triggerKeywords)
270
+ if (match) {
271
+ const part = output.parts[match.partArrayIndex] as TextPartLike & { text: string }
272
+ const originalText = part.text
273
+ part.text = stripTriggerKeyword(part.text, match.keyword, match.offset)
274
+ activeState = createState(options)
275
+ if (!NONCE_PATTERN.test(activeState.translate_nonce)) {
276
+ part.text = originalText
277
+ await logError(client, new Error("Generated invalid translation nonce"))
278
+ return
279
+ }
280
+ activatedThisTurn = true
281
+ sessionStateCache.set(input.sessionID, activeState)
270
282
  }
271
- activatedThisTurn = true
272
- sessionStateCache.set(input.sessionID, activeState)
273
283
  }
274
- }
275
284
 
276
- if (!activeState) return
285
+ if (!activeState) return
277
286
 
278
- const nextParts: TextPartLike[] = []
279
- let eligibleIndex = 0
287
+ const nextParts: TextPartLike[] = []
288
+ let eligibleIndex = 0
289
+ const translationErrors: { part: TextPartLike; error: unknown }[] = []
280
290
 
281
- for (const part of output.parts as TextPartLike[]) {
282
- nextParts.push(part)
283
- if (!isUserAuthoredTextPart(part)) continue
291
+ for (const part of output.parts as TextPartLike[]) {
292
+ nextParts.push(part)
293
+ if (!isUserAuthoredTextPart(part)) continue
284
294
 
285
- const currentEligibleIndex = eligibleIndex
286
- eligibleIndex += 1
287
- if (part.text.trim().length === 0) continue
295
+ const currentEligibleIndex = eligibleIndex
296
+ eligibleIndex += 1
297
+ if (part.text.trim().length === 0) continue
288
298
 
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
- })
299
+ try {
300
+ const english = await translator.translateText({
301
+ text: part.text,
302
+ sourceLanguage: activeState.translate_source_lang,
303
+ targetLanguage: LLM_LANGUAGE,
304
+ direction: "inbound",
305
+ })
296
306
 
297
- const sourceHash = hashText(part.text)
298
- part.metadata = {
299
- ...(part.metadata ?? {}),
300
- ...mergeTranslatedMetadata(activeState, part, english),
307
+ const sourceHash = hashText(part.text)
308
+ part.metadata = {
309
+ ...(part.metadata ?? {}),
310
+ ...mergeTranslatedMetadata(activeState, part, english),
311
+ }
312
+
313
+ nextParts.push(
314
+ createSyntheticTextPart(part.sessionID, part.messageID, `→ EN: ${english}`, {
315
+ translate_role: "translation_preview",
316
+ translate_nonce: activeState.translate_nonce,
317
+ translate_source_hash: sourceHash,
318
+ translate_part_index: currentEligibleIndex,
319
+ }),
320
+ )
321
+ } catch (error) {
322
+ // Fall back to sending the original text to the LLM so the user
323
+ // still gets a response. Surface the error as a synthetic part.
324
+ translationErrors.push({ part, error })
325
+ const wrapped = buildInboundTranslationError(activeState.translate_source_lang, normalizeReason(error))
326
+ await logError(client, wrapped)
327
+ nextParts.push(
328
+ createSyntheticTextPart(
329
+ part.sessionID,
330
+ part.messageID,
331
+ `⚠️ Translation failed: ${normalizeReason(error)}. Original text will be sent to the model.`,
332
+ {
333
+ translate_role: "translation_failure",
334
+ translate_nonce: activeState.translate_nonce,
335
+ translate_part_index: currentEligibleIndex,
336
+ },
337
+ ),
338
+ )
301
339
  }
340
+ }
302
341
 
342
+ // If we activated this turn but translation failed for every
343
+ // user-authored part, roll back activation so the next turn does a
344
+ // clean retry instead of cementing broken state.
345
+ if (activatedThisTurn && translationErrors.length > 0 && eligibleIndex === translationErrors.length) {
346
+ sessionStateCache.set(input.sessionID, null)
347
+ return
348
+ }
349
+
350
+ if (activatedThisTurn) {
303
351
  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,
352
+ createSyntheticTextPart(input.sessionID, output.message.id, createActivationBannerText(options), {
353
+ ...activeState,
354
+ translate_role: "activation_banner",
355
+ translate_spec_version: SPEC_VERSION,
309
356
  }),
310
357
  )
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
358
  }
320
- }
321
359
 
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
- )
360
+ output.parts.splice(0, output.parts.length, ...(nextParts as typeof output.parts))
361
+ } catch (error) {
362
+ await logError(client, error)
330
363
  }
331
-
332
- output.parts.splice(0, output.parts.length, ...(nextParts as typeof output.parts))
333
364
  },
334
365
  "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
366
+ try {
367
+ const sessionID = output.messages[0]?.info.sessionID
368
+ if (!sessionID) return
369
+
370
+ const resolved = await resolveSessionState(client, ctx.directory, sessionID)
371
+ const activeState = resolved.state
372
+ if (!activeState) return
373
+
374
+ for (const message of output.messages as MessageWithPartsLike[]) {
375
+ if (message.info.role === "user") {
376
+ for (const part of message.parts) {
377
+ if (!isTextPart(part)) continue
378
+ if (!shouldRequireCache(part)) continue
379
+ const metadata = asMetadata(part)
380
+ const sourceHash = hashText(part.text)
381
+ if (
382
+ metadata.translate_enabled === true &&
383
+ metadata.translate_nonce === activeState.translate_nonce &&
384
+ metadata.translate_source_hash === sourceHash &&
385
+ typeof metadata.translate_en === "string"
386
+ ) {
387
+ part.text = metadata.translate_en
388
+ continue
389
+ }
390
+
391
+ // Stale cache or untranslated text. Send it through as-is
392
+ // (English history would be ideal, but we shouldn't block the
393
+ // session). Also log so the user can diagnose if needed.
394
+ await logError(client, buildStaleCacheError())
357
395
  }
358
-
359
- throw buildStaleCacheError()
360
396
  }
361
- }
362
397
 
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)
398
+ if (message.info.role === "assistant") {
399
+ for (const part of message.parts) {
400
+ if (!isTextPart(part)) continue
401
+ part.text = extractEnglishHistoryText(part.text, activeState.translate_nonce)
402
+ }
367
403
  }
368
404
  }
405
+ } catch (error) {
406
+ await logError(client, error)
369
407
  }
370
408
  },
371
409
  "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
410
  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
- )
411
+ const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
412
+ const activeState = resolved.state
413
+ if (!activeState) return
414
+
415
+ const message = unwrapData(
416
+ await client.session.message({
417
+ path: { id: input.sessionID, messageID: input.messageID },
418
+ query: { ...(ctx.directory ? { directory: ctx.directory } : {}) },
419
+ throwOnError: true,
420
+ }),
421
+ ) as MessageWithPartsLike & { info: Record<string, unknown> }
422
+
423
+ if (message.info.role !== "assistant") return
424
+ if (message.info.summary === true) return
425
+ if (activeState.translate_display_lang === LLM_LANGUAGE || output.text.length === 0) return
426
+
427
+ try {
428
+ const translated = await translator.translateText({
429
+ text: output.text,
430
+ sourceLanguage: LLM_LANGUAGE,
431
+ targetLanguage: activeState.translate_display_lang,
432
+ direction: "outbound",
433
+ })
434
+
435
+ output.text = composeTranslatedAssistantText(
436
+ output.text,
437
+ getDisplayLanguageLabel(activeState.translate_display_lang),
438
+ translated,
439
+ activeState.translate_nonce,
440
+ )
441
+ } catch (error) {
442
+ output.text = composeTranslationFailureText(output.text, activeState.translate_nonce)
443
+ await logError(client, error)
444
+ }
402
445
  } catch (error) {
403
- output.text = composeTranslationFailureText(output.text, activeState.translate_nonce)
404
446
  await logError(client, error)
405
447
  }
406
448
  },
@@ -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