opencode-translate 0.0.14 → 0.0.16

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`, adds the required Codex beta/originator headers, normalizes the request body to Codex's expected typed `input` shape, and converts Codex SSE responses back to JSON for translation calls. 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.14",
3
+ "version": "0.0.16",
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/auth.ts CHANGED
@@ -78,6 +78,151 @@ 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
+ interface CodexBodyRewrite {
146
+ body: BodyInit | null | undefined
147
+ originalStream: boolean
148
+ }
149
+
150
+ function rewriteOpenAICodexBody(body: BodyInit | null | undefined): CodexBodyRewrite {
151
+ if (typeof body !== "string") return { body, originalStream: false }
152
+
153
+ let parsed: unknown
154
+ try {
155
+ parsed = JSON.parse(body)
156
+ } catch {
157
+ return { body, originalStream: false }
158
+ }
159
+
160
+ if (!isRecord(parsed)) return { body, originalStream: false }
161
+ const originalStream = parsed.stream === true
162
+
163
+ const sourceInput = Array.isArray(parsed.input)
164
+ ? parsed.input
165
+ : Array.isArray(parsed.messages)
166
+ ? parsed.messages
167
+ : undefined
168
+ if (!sourceInput) return { body, originalStream }
169
+
170
+ const instructions: string[] = []
171
+ if (typeof parsed.instructions === "string" && parsed.instructions) instructions.push(parsed.instructions)
172
+
173
+ const input = sourceInput
174
+ .map((item) => normalizeCodexInputItem(item, instructions))
175
+ .filter((item): item is unknown => item !== undefined)
176
+
177
+ const include = Array.isArray(parsed.include)
178
+ ? parsed.include.filter((item): item is string => typeof item === "string")
179
+ : []
180
+ if (!include.includes("reasoning.encrypted_content")) include.push("reasoning.encrypted_content")
181
+
182
+ return {
183
+ body: JSON.stringify({
184
+ ...parsed,
185
+ instructions: instructions.join("\n\n"),
186
+ input,
187
+ tools: Array.isArray(parsed.tools) ? parsed.tools : [],
188
+ tool_choice: typeof parsed.tool_choice === "string" ? parsed.tool_choice : "auto",
189
+ parallel_tool_calls: typeof parsed.parallel_tool_calls === "boolean" ? parsed.parallel_tool_calls : false,
190
+ store: false,
191
+ stream: true,
192
+ include,
193
+ max_output_tokens: undefined,
194
+ max_completion_tokens: undefined,
195
+ messages: undefined,
196
+ }),
197
+ originalStream,
198
+ }
199
+ }
200
+
201
+ function parseCodexSSEResponse(text: string): unknown | undefined {
202
+ for (const line of text.split(/\r?\n/)) {
203
+ if (!line.startsWith("data: ")) continue
204
+ const payload = line.slice(6).trim()
205
+ if (!payload || payload === "[DONE]") continue
206
+ try {
207
+ const parsed = JSON.parse(payload) as Record<string, unknown>
208
+ if ((parsed.type === "response.done" || parsed.type === "response.completed") && parsed.response) {
209
+ return parsed.response
210
+ }
211
+ } catch {}
212
+ }
213
+ return undefined
214
+ }
215
+
216
+ async function convertCodexSSEToJSON(response: Response): Promise<Response> {
217
+ const headers = new Headers(response.headers)
218
+ const text = await response.text()
219
+ const parsed = parseCodexSSEResponse(text)
220
+ if (!parsed) return new Response(text, { status: response.status, statusText: response.statusText, headers })
221
+
222
+ headers.set("content-type", "application/json; charset=utf-8")
223
+ return new Response(JSON.stringify(parsed), { status: response.status, statusText: response.statusText, headers })
224
+ }
225
+
81
226
  function isMissingCredentialError(error: unknown): boolean {
82
227
  const message = normalizeReason(error).toLowerCase()
83
228
  return (
@@ -244,7 +389,6 @@ async function refreshOpenAI(
244
389
  grant_type: "refresh_token",
245
390
  refresh_token: info.refresh,
246
391
  client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
247
- scope: "openid profile email offline_access",
248
392
  })
249
393
  const response = await withRetry(
250
394
  () =>
@@ -392,6 +536,7 @@ export function createCredentialResolver(
392
536
  input instanceof URL ? new URL(input.href) : new URL(typeof input === "string" ? input : input.url)
393
537
 
394
538
  let nextBody = init?.body
539
+ let convertCodexResponse = false
395
540
 
396
541
  if (providerID === "anthropic") {
397
542
  // Match the Claude Code CLI fingerprint so Anthropic's OAuth rate-limit
@@ -411,10 +556,17 @@ export function createCredentialResolver(
411
556
  inputUrl.hostname === "api.openai.com" &&
412
557
  (inputUrl.pathname === "/v1/chat/completions" || inputUrl.pathname === "/v1/responses")
413
558
  ) {
559
+ const rewritten = rewriteOpenAICodexBody(nextBody)
414
560
  inputUrl.protocol = "https:"
415
561
  inputUrl.hostname = "chatgpt.com"
416
562
  inputUrl.pathname = "/backend-api/codex/responses"
417
563
  inputUrl.search = ""
564
+ nextBody = rewritten.body
565
+ convertCodexResponse = !rewritten.originalStream
566
+ headers.set("OpenAI-Beta", "responses=experimental")
567
+ headers.set("originator", "codex_cli_rs")
568
+ headers.set("accept", "text/event-stream")
569
+ headers.delete("content-length")
418
570
  }
419
571
  }
420
572
 
@@ -442,11 +594,13 @@ export function createCredentialResolver(
442
594
  }
443
595
  }
444
596
 
445
- return fetchImpl(inputUrl, {
597
+ const response = await fetchImpl(inputUrl, {
446
598
  ...init,
447
599
  headers,
448
600
  body: nextBody,
449
601
  })
602
+ if (convertCodexResponse && response.ok) return convertCodexSSEToJSON(response)
603
+ return response
450
604
  }
451
605
  }
452
606
 
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,