opencode-translate 0.0.13 → 0.0.15

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
@@ -123,6 +123,14 @@ Tradeoffs:
123
123
 
124
124
  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.
125
125
 
126
+ ## OpenAI OAuth Support
127
+
128
+ If `translatorModel` uses OpenAI and OpenCode auth is backed by the ChatGPT/Codex OAuth flow, the plugin reuses those OAuth credentials for translation requests.
129
+
130
+ For OAuth-backed OpenAI requests, the plugin routes the OpenAI AI SDK request to `https://chatgpt.com/backend-api/codex/responses` and normalizes the request body to Codex's expected typed `input` shape. This supports models such as `openai/gpt-5.5` when your ChatGPT plan has access.
131
+
132
+ If you prefer a plain API key, set `OPENAI_API_KEY` in the environment or pass `apiKey` in plugin options. The plugin prefers explicit `apiKey`, then `OPENAI_API_KEY`, then OAuth.
133
+
126
134
  ## Manual Smoke Test
127
135
 
128
136
  1. Install the plugin and configure `sourceLanguage: "ko"`, `displayLanguage: "ko"`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
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
@@ -134,12 +134,17 @@ function mergeTranslatedMetadata(state: TranslateState, part: TextPartLike, engl
134
134
  }
135
135
  }
136
136
 
137
- // OpenCode's flag semantics, observed from packages/opencode and packages/ui:
137
+ // OpenCode's flag semantics are not uniform across every UI path:
138
138
  // synthetic: true -> hidden from the user UI, still sent to the LLM
139
- // ignored: true -> hidden from the LLM, still shown in the user UI
139
+ // ignored: true -> hidden from the LLM, still shown in the main message UI
140
140
  // both true -> hidden from both (used for metadata-only marker
141
141
  // parts like the activation banner that exist purely
142
142
  // to carry state across reloads)
143
+ // Some secondary UIs, including the web `/fork` dialog, only consider text
144
+ // parts that are neither synthetic nor ignored. Persist translated user display
145
+ // parts as non-ignored so those flows can find them; before model serialization,
146
+ // `experimental.chat.messages.transform` marks those display parts ignored and
147
+ // lets the synthetic English twins carry the actual prompt text.
143
148
  // The plugin's user-facing status text (translation preview, activation
144
149
  // banner, failure notice) lives inline on the source-language user part
145
150
  // itself rather than as sibling parts, because OpenCode's
@@ -148,8 +153,9 @@ function mergeTranslatedMetadata(state: TranslateState, part: TextPartLike, engl
148
153
 
149
154
  // LLM-only text part: hidden from the TUI but the only LLM-visible
150
155
  // representation of the user's source-language text. The original
151
- // user-authored part is marked `ignored:true` so the LLM never sees it,
152
- // and this synthetic English twin carries the actual prompt content.
156
+ // user-authored part is marked `ignored:true` in the model-bound transform so
157
+ // the LLM never sees it, and this synthetic English twin carries the actual
158
+ // prompt content.
153
159
  function createLlmOnlyTextPart(
154
160
  sessionID: string,
155
161
  messageID: string,
@@ -168,6 +174,11 @@ function createLlmOnlyTextPart(
168
174
  }
169
175
  }
170
176
 
177
+ function isTranslatedUserDisplayPart(part: TextPartLike): boolean {
178
+ if (!isTextPart(part) || part.synthetic === true) return false
179
+ return extractStateFromMetadata(asMetadata(part)) !== undefined
180
+ }
181
+
171
182
  function escapeRegex(value: string): string {
172
183
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
173
184
  }
@@ -367,15 +378,14 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
367
378
  // ONE text part per user message — the first non-synthetic —
368
379
  // so a sibling `synthetic:false + ignored:true` preview part
369
380
  // would never reach the screen. Combining them here keeps
370
- // the original visible alongside the English twin while
371
- // `ignored:true` still hides the whole thing from the LLM
372
- // serializer (`message-v2.ts:773`); the LLM-only synthetic
373
- // twin below carries the clean English prompt.
381
+ // the original visible alongside the English twin while still
382
+ // satisfying secondary UI filters like `/fork`, which require
383
+ // non-synthetic, non-ignored text. The transform hook marks this
384
+ // display part ignored in the model-bound copy.
374
385
  part.text = `${part.text}\n\n_→ EN: ${english}_`
375
- part.ignored = true
376
386
 
377
387
  // LLM-only English twin. This is the actual prompt the model
378
- // sees in place of the now-`ignored` source-language part.
388
+ // sees in place of the source-language display part.
379
389
  nextParts.push(
380
390
  createLlmOnlyTextPart(part.sessionID, part.messageID, english, {
381
391
  translate_role: "llm_only_translation",
@@ -465,12 +475,15 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
465
475
  const activeState = resolved.state
466
476
  if (!activeState) return
467
477
 
468
- // User parts need no in-place rewriting: the source-language text
469
- // part is `ignored:true` so the LLM serializer skips it, and a
470
- // synthetic English twin (created in `chat.message`) carries the
471
- // actual prompt content. Only assistant parts still need their
472
- // localized trailer stripped before re-entering the model.
473
478
  for (const message of output.messages as MessageWithPartsLike[]) {
479
+ if (message.info.role === "user") {
480
+ for (const part of message.parts) {
481
+ if (!isTranslatedUserDisplayPart(part)) continue
482
+ part.ignored = true
483
+ }
484
+ continue
485
+ }
486
+
474
487
  if (message.info.role !== "assistant") continue
475
488
  for (const part of message.parts) {
476
489
  if (!isTextPart(part)) continue
package/src/auth.ts CHANGED
@@ -78,6 +78,110 @@ function setUserAgent(headers: Headers, packageVersion?: string) {
78
78
  headers.set("User-Agent", packageVersion ? `${USER_AGENT.replace("0.0.0", packageVersion)}` : USER_AGENT)
79
79
  }
80
80
 
81
+ function isRecord(value: unknown): value is Record<string, unknown> {
82
+ return !!value && typeof value === "object" && !Array.isArray(value)
83
+ }
84
+
85
+ function textFromContent(content: unknown): string | undefined {
86
+ if (typeof content === "string") return content
87
+ if (!Array.isArray(content)) return undefined
88
+
89
+ const text = content
90
+ .map((part) => (isRecord(part) && typeof part.text === "string" ? part.text : undefined))
91
+ .filter((value): value is string => value !== undefined)
92
+ .join("\n")
93
+
94
+ return text || undefined
95
+ }
96
+
97
+ function normalizeCodexContent(role: string, content: unknown): Record<string, unknown>[] {
98
+ const textType = role === "assistant" ? "output_text" : "input_text"
99
+ if (typeof content === "string") return [{ type: textType, text: content }]
100
+ if (!Array.isArray(content)) return []
101
+
102
+ const result: Record<string, unknown>[] = []
103
+ for (const part of content) {
104
+ if (!isRecord(part)) continue
105
+ const type = part.type
106
+ if (type === "input_text" || type === "output_text") {
107
+ result.push({ ...part, type: textType })
108
+ continue
109
+ }
110
+ if (type === "input_image") {
111
+ result.push({ ...part })
112
+ continue
113
+ }
114
+ if (typeof part.text === "string") {
115
+ result.push({ type: textType, text: part.text })
116
+ }
117
+ }
118
+
119
+ return result
120
+ }
121
+
122
+ function normalizeCodexInputItem(item: unknown, instructions: string[]): unknown | undefined {
123
+ if (!isRecord(item)) return item
124
+ const role = typeof item.role === "string" ? item.role : undefined
125
+
126
+ if (role === "system" || role === "developer") {
127
+ const text = textFromContent(item.content)
128
+ if (text) instructions.push(text)
129
+ return undefined
130
+ }
131
+
132
+ if (item.type === "message" && role) {
133
+ const content = normalizeCodexContent(role, item.content)
134
+ return content.length > 0 ? { ...item, role, content } : undefined
135
+ }
136
+
137
+ if (role) {
138
+ const content = normalizeCodexContent(role, item.content)
139
+ return content.length > 0 ? { type: "message", role, content } : undefined
140
+ }
141
+
142
+ return item
143
+ }
144
+
145
+ function rewriteOpenAICodexBody(body: BodyInit | null | undefined): BodyInit | null | undefined {
146
+ if (typeof body !== "string") return body
147
+
148
+ let parsed: unknown
149
+ try {
150
+ parsed = JSON.parse(body)
151
+ } catch {
152
+ return body
153
+ }
154
+
155
+ if (!isRecord(parsed)) return body
156
+
157
+ const sourceInput = Array.isArray(parsed.input)
158
+ ? parsed.input
159
+ : Array.isArray(parsed.messages)
160
+ ? parsed.messages
161
+ : undefined
162
+ if (!sourceInput) return body
163
+
164
+ const instructions: string[] = []
165
+ if (typeof parsed.instructions === "string" && parsed.instructions) instructions.push(parsed.instructions)
166
+
167
+ const input = sourceInput
168
+ .map((item) => normalizeCodexInputItem(item, instructions))
169
+ .filter((item): item is unknown => item !== undefined)
170
+
171
+ return JSON.stringify({
172
+ ...parsed,
173
+ instructions: instructions.join("\n\n"),
174
+ input,
175
+ tools: Array.isArray(parsed.tools) ? parsed.tools : [],
176
+ tool_choice: typeof parsed.tool_choice === "string" ? parsed.tool_choice : "auto",
177
+ parallel_tool_calls: typeof parsed.parallel_tool_calls === "boolean" ? parsed.parallel_tool_calls : false,
178
+ store: typeof parsed.store === "boolean" ? parsed.store : false,
179
+ stream: typeof parsed.stream === "boolean" ? parsed.stream : false,
180
+ include: Array.isArray(parsed.include) ? parsed.include : [],
181
+ messages: undefined,
182
+ })
183
+ }
184
+
81
185
  function isMissingCredentialError(error: unknown): boolean {
82
186
  const message = normalizeReason(error).toLowerCase()
83
187
  return (
@@ -244,7 +348,6 @@ async function refreshOpenAI(
244
348
  grant_type: "refresh_token",
245
349
  refresh_token: info.refresh,
246
350
  client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
247
- scope: "openid profile email offline_access",
248
351
  })
249
352
  const response = await withRetry(
250
353
  () =>
@@ -415,6 +518,8 @@ export function createCredentialResolver(
415
518
  inputUrl.hostname = "chatgpt.com"
416
519
  inputUrl.pathname = "/backend-api/codex/responses"
417
520
  inputUrl.search = ""
521
+ nextBody = rewriteOpenAICodexBody(nextBody)
522
+ headers.delete("content-length")
418
523
  }
419
524
  }
420
525
 
package/src/translator.ts CHANGED
@@ -210,6 +210,12 @@ function instantiateModel(provider: unknown, modelID: string): unknown {
210
210
  throw new Error(`Unable to instantiate model "${modelID}"`)
211
211
  }
212
212
 
213
+ function supportsTemperature(providerID: string, modelID: string): boolean {
214
+ if (providerID !== "openai") return true
215
+ if (modelID.startsWith("o1") || modelID.startsWith("o3") || modelID.startsWith("o4-mini")) return false
216
+ return !(modelID.startsWith("gpt-5") && !modelID.startsWith("gpt-5-chat"))
217
+ }
218
+
213
219
  function isAuthMessage(error: unknown): boolean {
214
220
  if (!(error instanceof Error)) return false
215
221
  return error.message.includes(":AUTH_UNAVAILABLE]") || error.message.includes(":OAUTH_REFRESH_FAILED]")
@@ -272,7 +278,7 @@ export function createTranslator(
272
278
  targetLanguage: input.targetLanguage,
273
279
  text: input.text,
274
280
  }),
275
- temperature: 0,
281
+ ...(supportsTemperature(providerID, modelID) ? { temperature: 0 } : {}),
276
282
  prompt: buildUserPrompt({
277
283
  sourceLanguage: input.sourceLanguage,
278
284
  targetLanguage: input.targetLanguage,