opencode-translate 1.0.6 → 1.0.7

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 (44) hide show
  1. package/dist/index.js +2271 -0
  2. package/index.d.ts +5 -0
  3. package/package.json +10 -4
  4. package/src/activation/chat-message.ts +0 -189
  5. package/src/activation/index.ts +0 -38
  6. package/src/activation/logging.ts +0 -11
  7. package/src/activation/messages-transform.ts +0 -44
  8. package/src/activation/metadata.ts +0 -41
  9. package/src/activation/parts.ts +0 -46
  10. package/src/activation/question-hooks.ts +0 -126
  11. package/src/activation/state.ts +0 -97
  12. package/src/activation/text-complete.ts +0 -50
  13. package/src/activation/trigger.ts +0 -57
  14. package/src/activation/types.ts +0 -47
  15. package/src/activation.ts +0 -1
  16. package/src/anthropic-oauth.ts +0 -148
  17. package/src/auth/codex-request.ts +0 -108
  18. package/src/auth/codex-response.ts +0 -78
  19. package/src/auth/codex-shared.ts +0 -3
  20. package/src/auth/headers.ts +0 -18
  21. package/src/auth/index.ts +0 -177
  22. package/src/auth/oauth-fetch.ts +0 -100
  23. package/src/auth/refresh.ts +0 -102
  24. package/src/auth/retry.ts +0 -70
  25. package/src/auth/store.ts +0 -98
  26. package/src/auth/types.ts +0 -27
  27. package/src/auth.ts +0 -1
  28. package/src/constants/errors.ts +0 -24
  29. package/src/constants/guards.ts +0 -33
  30. package/src/constants/options.ts +0 -55
  31. package/src/constants/plugin.ts +0 -9
  32. package/src/constants/types.ts +0 -159
  33. package/src/constants.ts +0 -5
  34. package/src/formatting.ts +0 -157
  35. package/src/index.ts +0 -7
  36. package/src/labels.ts +0 -3
  37. package/src/prompts.ts +0 -123
  38. package/src/question-tool.ts +0 -234
  39. package/src/translator/index.ts +0 -172
  40. package/src/translator/part-id.ts +0 -43
  41. package/src/translator/provider.ts +0 -411
  42. package/src/translator/retry.ts +0 -62
  43. package/src/translator/types.ts +0 -24
  44. package/src/translator.ts +0 -1
@@ -1,50 +0,0 @@
1
- import type { Hooks } from "@opencode-ai/plugin"
2
- import { LLM_LANGUAGE, type MessageWithPartsLike, unwrapData } from "../constants"
3
- import { composeTranslatedAssistantText, composeTranslationFailureText } from "../formatting"
4
- import { getDisplayLanguageLabel } from "../labels"
5
- import { logError } from "./logging"
6
- import { resolveSessionState } from "./state"
7
- import type { HookContext } from "./types"
8
-
9
- type TextCompleteHook = NonNullable<Hooks["experimental.text.complete"]>
10
-
11
- export function createTextCompleteHook(ctx: HookContext): TextCompleteHook {
12
- return async (input, output) => {
13
- try {
14
- const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
15
- const activeState = resolved.state
16
- if (!activeState) return
17
-
18
- const message = unwrapData(
19
- await ctx.client.session.message({
20
- path: { id: input.sessionID, messageID: input.messageID },
21
- query: { ...(ctx.directory ? { directory: ctx.directory } : {}) },
22
- throwOnError: true,
23
- }),
24
- ) as MessageWithPartsLike & { info: Record<string, unknown> }
25
-
26
- if (message.info.role !== "assistant") return
27
- if (message.info.summary === true) return
28
- if (activeState.translate_user_lang === LLM_LANGUAGE || output.text.length === 0) return
29
-
30
- try {
31
- const translated = await ctx.translator.translateText({
32
- text: output.text,
33
- sourceLanguage: LLM_LANGUAGE,
34
- targetLanguage: activeState.translate_user_lang,
35
- direction: "outbound",
36
- })
37
- output.text = composeTranslatedAssistantText(
38
- output.text,
39
- getDisplayLanguageLabel(activeState.translate_user_lang),
40
- translated,
41
- )
42
- } catch (error) {
43
- output.text = composeTranslationFailureText(output.text)
44
- await logError(ctx.client, error)
45
- }
46
- } catch (error) {
47
- await logError(ctx.client, error)
48
- }
49
- }
50
- }
@@ -1,57 +0,0 @@
1
- import { isUserAuthoredTextPart, type TextPartLike } from "../constants"
2
- import type { TriggerMatch } from "./types"
3
-
4
- function escapeRegex(value: string): string {
5
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
6
- }
7
-
8
- export function findTriggerMatch(parts: TextPartLike[], trigger: string[]): TriggerMatch | undefined {
9
- let eligibleIndex = 0
10
- for (let partArrayIndex = 0; partArrayIndex < parts.length; partArrayIndex += 1) {
11
- const part = parts[partArrayIndex]
12
- if (!isUserAuthoredTextPart(part)) continue
13
-
14
- let bestForPart: TriggerMatch | undefined
15
- for (const keyword of trigger) {
16
- const pattern = new RegExp(`(^|[ \\t\\r\\n\\f\\v])${escapeRegex(keyword)}(?=$|[ \\t\\r\\n\\f\\v])`)
17
- const match = pattern.exec(part.text)
18
- if (!match) continue
19
- const offset = match.index + match[1].length
20
- if (!bestForPart || offset < bestForPart.offset) bestForPart = { partArrayIndex, eligibleIndex, keyword, offset }
21
- }
22
-
23
- if (bestForPart) return bestForPart
24
- eligibleIndex += 1
25
- }
26
-
27
- return undefined
28
- }
29
-
30
- export function stripTriggerKeyword(text: string, keyword: string, offset: number): string {
31
- const lineStart = text.lastIndexOf("\n", offset - 1) + 1
32
- const nextNewline = text.indexOf("\n", offset)
33
- const lineEnd = nextNewline === -1 ? text.length : nextNewline
34
- const line = text.slice(lineStart, lineEnd)
35
- const localOffset = offset - lineStart
36
-
37
- let rewrittenLine: string
38
- if (localOffset === 0 && line.startsWith(`${keyword} `)) {
39
- rewrittenLine = line.slice(keyword.length + 1)
40
- } else if (
41
- localOffset + keyword.length === line.length &&
42
- localOffset > 0 &&
43
- line.slice(localOffset - 1, localOffset) === " "
44
- ) {
45
- rewrittenLine = line.slice(0, localOffset - 1)
46
- } else if (
47
- localOffset > 0 &&
48
- line.slice(localOffset - 1, localOffset) === " " &&
49
- line.slice(localOffset + keyword.length, localOffset + keyword.length + 1) === " "
50
- ) {
51
- rewrittenLine = `${line.slice(0, localOffset - 1)} ${line.slice(localOffset + keyword.length + 1)}`
52
- } else {
53
- rewrittenLine = `${line.slice(0, localOffset)}${line.slice(localOffset + keyword.length)}`
54
- }
55
-
56
- return `${text.slice(0, lineStart)}${rewrittenLine}${text.slice(lineEnd)}`
57
- }
@@ -1,47 +0,0 @@
1
- import type { PluginClientLike, ResolvedTranslateOptions, TranslateState } from "../constants"
2
-
3
- export const INACTIVE_ROOT_SESSION = "inactive-root"
4
- export const INACTIVE_CHILD_SESSION = "inactive-child"
5
- export const QUESTION_TOOL_ID = "question"
6
-
7
- export type CachedSessionState = TranslateState | typeof INACTIVE_ROOT_SESSION | typeof INACTIVE_CHILD_SESSION
8
-
9
- export interface ResolvedSessionState {
10
- sessionActive: boolean
11
- canActivate: boolean
12
- state?: TranslateState
13
- storedMessages: import("../constants").MessageWithPartsLike[]
14
- }
15
-
16
- export interface TriggerMatch {
17
- partArrayIndex: number
18
- eligibleIndex: number
19
- keyword: string
20
- offset: number
21
- }
22
-
23
- interface TranslatorLike {
24
- translateText(input: {
25
- text: string
26
- sourceLanguage: string
27
- targetLanguage: string
28
- direction: "inbound" | "outbound"
29
- }): Promise<string>
30
- translateTexts?(input: {
31
- texts: readonly string[]
32
- sourceLanguage: string
33
- targetLanguage: string
34
- direction: "inbound" | "outbound"
35
- }): Promise<readonly string[]>
36
- }
37
-
38
- export interface HookDependencies {
39
- translator?: TranslatorLike
40
- }
41
-
42
- export interface HookContext {
43
- client: PluginClientLike
44
- directory?: string
45
- options: ResolvedTranslateOptions
46
- translator: TranslatorLike
47
- }
package/src/activation.ts DELETED
@@ -1 +0,0 @@
1
- export * from "./activation/index"
@@ -1,148 +0,0 @@
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
- const REQUIRED_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"] as const
26
-
27
- const CLAUDE_CODE_VERSION = "2.1.87"
28
- 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
- }
@@ -1,108 +0,0 @@
1
- import { isRecord } from "./codex-shared"
2
- import type { CodexBodyRewrite } from "./types"
3
-
4
- function textFromContent(content: unknown): string | undefined {
5
- if (typeof content === "string") return content
6
- if (!Array.isArray(content)) return undefined
7
-
8
- const text = content
9
- .map((part) => (isRecord(part) && typeof part.text === "string" ? part.text : undefined))
10
- .filter((value): value is string => value !== undefined)
11
- .join("\n")
12
-
13
- return text || undefined
14
- }
15
-
16
- function normalizeCodexContent(role: string, content: unknown): Record<string, unknown>[] {
17
- const textType = role === "assistant" ? "output_text" : "input_text"
18
- if (typeof content === "string") return [{ type: textType, text: content }]
19
- if (!Array.isArray(content)) return []
20
-
21
- const result: Record<string, unknown>[] = []
22
- for (const part of content) {
23
- if (!isRecord(part)) continue
24
- const type = part.type
25
- if (type === "input_text" || type === "output_text") {
26
- result.push({ ...part, type: textType })
27
- continue
28
- }
29
- if (type === "input_image") {
30
- result.push({ ...part })
31
- continue
32
- }
33
- if (typeof part.text === "string") result.push({ type: textType, text: part.text })
34
- }
35
-
36
- return result
37
- }
38
-
39
- function normalizeCodexInputItem(item: unknown, instructions: string[]): unknown | undefined {
40
- if (!isRecord(item)) return item
41
- const role = typeof item.role === "string" ? item.role : undefined
42
-
43
- if (role === "system" || role === "developer") {
44
- const text = textFromContent(item.content)
45
- if (text) instructions.push(text)
46
- return undefined
47
- }
48
-
49
- if (item.type === "message" && role) {
50
- const content = normalizeCodexContent(role, item.content)
51
- return content.length > 0 ? { ...item, role, content } : undefined
52
- }
53
-
54
- if (role) {
55
- const content = normalizeCodexContent(role, item.content)
56
- return content.length > 0 ? { type: "message", role, content } : undefined
57
- }
58
-
59
- return item
60
- }
61
-
62
- export function rewriteOpenAICodexBody(body: BodyInit | null | undefined): CodexBodyRewrite {
63
- if (typeof body !== "string") return { body, originalStream: false }
64
-
65
- let parsed: unknown
66
- try {
67
- parsed = JSON.parse(body)
68
- } catch {
69
- return { body, originalStream: false }
70
- }
71
-
72
- if (!isRecord(parsed)) return { body, originalStream: false }
73
- const originalStream = parsed.stream === true
74
- const sourceInput = Array.isArray(parsed.input)
75
- ? parsed.input
76
- : Array.isArray(parsed.messages)
77
- ? parsed.messages
78
- : undefined
79
- if (!sourceInput) return { body, originalStream }
80
-
81
- const instructions: string[] = []
82
- if (typeof parsed.instructions === "string" && parsed.instructions) instructions.push(parsed.instructions)
83
- const input = sourceInput
84
- .map((item) => normalizeCodexInputItem(item, instructions))
85
- .filter((item): item is unknown => item !== undefined)
86
- const include = Array.isArray(parsed.include)
87
- ? parsed.include.filter((item): item is string => typeof item === "string")
88
- : []
89
- if (!include.includes("reasoning.encrypted_content")) include.push("reasoning.encrypted_content")
90
-
91
- return {
92
- body: JSON.stringify({
93
- ...parsed,
94
- instructions: instructions.join("\n\n"),
95
- input,
96
- tools: Array.isArray(parsed.tools) ? parsed.tools : [],
97
- tool_choice: typeof parsed.tool_choice === "string" ? parsed.tool_choice : "auto",
98
- parallel_tool_calls: typeof parsed.parallel_tool_calls === "boolean" ? parsed.parallel_tool_calls : false,
99
- store: false,
100
- stream: true,
101
- include,
102
- max_output_tokens: undefined,
103
- max_completion_tokens: undefined,
104
- messages: undefined,
105
- }),
106
- originalStream,
107
- }
108
- }
@@ -1,78 +0,0 @@
1
- import { isRecord } from "./codex-shared"
2
-
3
- function normalizeCodexOutputItem(item: unknown, index: number): unknown | undefined {
4
- if (!isRecord(item)) return undefined
5
- if (item.type !== "message" || item.role !== "assistant") return item
6
- if (!Array.isArray(item.content)) return undefined
7
-
8
- const content: Record<string, unknown>[] = []
9
- for (const part of item.content) {
10
- if (!isRecord(part) || part.type !== "output_text" || typeof part.text !== "string") continue
11
- content.push({ ...part, annotations: Array.isArray(part.annotations) ? part.annotations : [] })
12
- }
13
-
14
- if (content.length === 0) return undefined
15
- return {
16
- ...item,
17
- id: typeof item.id === "string" ? item.id : `msg_opencode_translate_${index}`,
18
- role: "assistant",
19
- content,
20
- }
21
- }
22
-
23
- function buildCodexTextOutput(text: string): Record<string, unknown> {
24
- return {
25
- type: "message",
26
- id: "msg_opencode_translate_0",
27
- role: "assistant",
28
- content: [{ type: "output_text", text, annotations: [] }],
29
- }
30
- }
31
-
32
- function parseCodexSSEResponse(text: string): unknown | undefined {
33
- let finalResponse: unknown
34
- let deltaText = ""
35
- const outputItems: unknown[] = []
36
-
37
- for (const line of text.split(/\r?\n/)) {
38
- if (!line.startsWith("data: ")) continue
39
- const payload = line.slice(6).trim()
40
- if (!payload || payload === "[DONE]") continue
41
- try {
42
- const parsed = JSON.parse(payload) as Record<string, unknown>
43
- if (parsed.type === "response.output_text.delta" && typeof parsed.delta === "string") {
44
- deltaText += parsed.delta
45
- } else if (
46
- (parsed.type === "response.output_item.done" || parsed.type === "response.output_item.added") &&
47
- parsed.item
48
- ) {
49
- outputItems.push(parsed.item)
50
- } else if ((parsed.type === "response.done" || parsed.type === "response.completed") && parsed.response) {
51
- finalResponse = parsed.response
52
- }
53
- } catch {}
54
- }
55
-
56
- if (!finalResponse && !deltaText && outputItems.length === 0) return undefined
57
- const response: Record<string, unknown> = isRecord(finalResponse)
58
- ? { ...finalResponse }
59
- : { id: "resp_opencode_translate" }
60
- const existingOutput: unknown[] = Array.isArray(response.output) ? response.output : []
61
- const sourceOutput = existingOutput.length > 0 ? existingOutput : outputItems
62
- const normalizedOutput = sourceOutput
63
- .map((item, index) => normalizeCodexOutputItem(item, index))
64
- .filter((item): item is unknown => item !== undefined)
65
-
66
- response.output = normalizedOutput.length > 0 ? normalizedOutput : deltaText ? [buildCodexTextOutput(deltaText)] : []
67
- return response
68
- }
69
-
70
- export async function convertCodexSSEToJSON(response: Response): Promise<Response> {
71
- const headers = new Headers(response.headers)
72
- const text = await response.text()
73
- const parsed = parseCodexSSEResponse(text)
74
- if (!parsed) return new Response(text, { status: response.status, statusText: response.statusText, headers })
75
-
76
- headers.set("content-type", "application/json; charset=utf-8")
77
- return new Response(JSON.stringify(parsed), { status: response.status, statusText: response.statusText, headers })
78
- }
@@ -1,3 +0,0 @@
1
- export function isRecord(value: unknown): value is Record<string, unknown> {
2
- return !!value && typeof value === "object" && !Array.isArray(value)
3
- }
@@ -1,18 +0,0 @@
1
- import { USER_AGENT } from "../constants"
2
-
3
- export function copyHeaders(headers?: HeadersInit): Headers {
4
- return new Headers(headers)
5
- }
6
-
7
- export function headerValue(headers: Headers, key: string): string | undefined {
8
- const value = headers.get(key)
9
- return value === null ? undefined : value
10
- }
11
-
12
- export function packageUserAgent(packageVersion?: string): string {
13
- return packageVersion ? USER_AGENT.replace("0.0.0", packageVersion) : USER_AGENT
14
- }
15
-
16
- export function setUserAgent(headers: Headers, packageVersion?: string) {
17
- headers.set("User-Agent", packageUserAgent(packageVersion))
18
- }