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 +31 -8
- package/package.json +1 -1
- package/src/activation.ts +167 -119
- package/src/anthropic-oauth.ts +148 -0
- package/src/auth.ts +15 -5
- package/src/translator.ts +41 -15
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
|
-
-
|
|
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
|
|
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
|
-
|
|
117
|
+
Tradeoffs:
|
|
95
118
|
|
|
96
|
-
-
|
|
97
|
-
- OpenCode upstream removed Anthropic OAuth support for legal / policy reasons.
|
|
98
|
-
-
|
|
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
|
|
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
|
|
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
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:
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
291
|
+
if (!activeState) return
|
|
277
292
|
|
|
278
|
-
|
|
279
|
-
|
|
293
|
+
const nextParts: TextPartLike[] = []
|
|
294
|
+
let eligibleIndex = 0
|
|
295
|
+
const translationErrors: { part: TextPartLike; error: unknown }[] = []
|
|
280
296
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
297
|
+
for (const part of output.parts as TextPartLike[]) {
|
|
298
|
+
nextParts.push(part)
|
|
299
|
+
if (!isUserAuthoredTextPart(part)) continue
|
|
284
300
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
301
|
+
const currentEligibleIndex = eligibleIndex
|
|
302
|
+
eligibleIndex += 1
|
|
303
|
+
if (part.text.trim().length === 0) continue
|
|
288
304
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
393
|
-
if (inputUrl
|
|
394
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|