opencode-translate 0.0.15 → 0.0.17

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 (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/auth.ts +125 -19
package/README.md CHANGED
@@ -127,7 +127,7 @@ If you prefer a plain API key, set `ANTHROPIC_API_KEY` in the environment or pas
127
127
 
128
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
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.
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
131
 
132
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
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
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
@@ -142,24 +142,30 @@ function normalizeCodexInputItem(item: unknown, instructions: string[]): unknown
142
142
  return item
143
143
  }
144
144
 
145
- function rewriteOpenAICodexBody(body: BodyInit | null | undefined): BodyInit | null | undefined {
146
- if (typeof body !== "string") return body
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 }
147
152
 
148
153
  let parsed: unknown
149
154
  try {
150
155
  parsed = JSON.parse(body)
151
156
  } catch {
152
- return body
157
+ return { body, originalStream: false }
153
158
  }
154
159
 
155
- if (!isRecord(parsed)) return body
160
+ if (!isRecord(parsed)) return { body, originalStream: false }
161
+ const originalStream = parsed.stream === true
156
162
 
157
163
  const sourceInput = Array.isArray(parsed.input)
158
164
  ? parsed.input
159
165
  : Array.isArray(parsed.messages)
160
166
  ? parsed.messages
161
167
  : undefined
162
- if (!sourceInput) return body
168
+ if (!sourceInput) return { body, originalStream }
163
169
 
164
170
  const instructions: string[] = []
165
171
  if (typeof parsed.instructions === "string" && parsed.instructions) instructions.push(parsed.instructions)
@@ -168,18 +174,110 @@ function rewriteOpenAICodexBody(body: BodyInit | null | undefined): BodyInit | n
168
174
  .map((item) => normalizeCodexInputItem(item, instructions))
169
175
  .filter((item): item is unknown => item !== undefined)
170
176
 
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
- })
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 normalizeCodexOutputItem(item: unknown, index: number): unknown | undefined {
202
+ if (!isRecord(item)) return undefined
203
+ if (item.type !== "message" || item.role !== "assistant") return item
204
+ if (!Array.isArray(item.content)) return undefined
205
+
206
+ const content: Record<string, unknown>[] = []
207
+ for (const part of item.content) {
208
+ if (!isRecord(part) || part.type !== "output_text" || typeof part.text !== "string") continue
209
+ content.push({ ...part, annotations: Array.isArray(part.annotations) ? part.annotations : [] })
210
+ }
211
+
212
+ if (content.length === 0) return undefined
213
+ return {
214
+ ...item,
215
+ id: typeof item.id === "string" ? item.id : `msg_opencode_translate_${index}`,
216
+ role: "assistant",
217
+ content,
218
+ }
219
+ }
220
+
221
+ function buildCodexTextOutput(text: string): Record<string, unknown> {
222
+ return {
223
+ type: "message",
224
+ id: "msg_opencode_translate_0",
225
+ role: "assistant",
226
+ content: [{ type: "output_text", text, annotations: [] }],
227
+ }
228
+ }
229
+
230
+ function parseCodexSSEResponse(text: string): unknown | undefined {
231
+ let finalResponse: unknown
232
+ let deltaText = ""
233
+ const outputItems: unknown[] = []
234
+
235
+ for (const line of text.split(/\r?\n/)) {
236
+ if (!line.startsWith("data: ")) continue
237
+ const payload = line.slice(6).trim()
238
+ if (!payload || payload === "[DONE]") continue
239
+ try {
240
+ const parsed = JSON.parse(payload) as Record<string, unknown>
241
+ if (parsed.type === "response.output_text.delta" && typeof parsed.delta === "string") {
242
+ deltaText += parsed.delta
243
+ continue
244
+ }
245
+ if (
246
+ (parsed.type === "response.output_item.done" || parsed.type === "response.output_item.added") &&
247
+ parsed.item
248
+ ) {
249
+ outputItems.push(parsed.item)
250
+ continue
251
+ }
252
+ if ((parsed.type === "response.done" || parsed.type === "response.completed") && parsed.response) {
253
+ finalResponse = parsed.response
254
+ }
255
+ } catch {}
256
+ }
257
+
258
+ if (!finalResponse && !deltaText && outputItems.length === 0) return undefined
259
+
260
+ const response: Record<string, unknown> = isRecord(finalResponse)
261
+ ? { ...finalResponse }
262
+ : { id: "resp_opencode_translate" }
263
+ const existingOutput: unknown[] = Array.isArray(response.output) ? response.output : []
264
+ const sourceOutput = existingOutput.length > 0 ? existingOutput : outputItems
265
+ const normalizedOutput = sourceOutput
266
+ .map((item, index) => normalizeCodexOutputItem(item, index))
267
+ .filter((item): item is unknown => item !== undefined)
268
+
269
+ response.output = normalizedOutput.length > 0 ? normalizedOutput : deltaText ? [buildCodexTextOutput(deltaText)] : []
270
+ return response
271
+ }
272
+
273
+ async function convertCodexSSEToJSON(response: Response): Promise<Response> {
274
+ const headers = new Headers(response.headers)
275
+ const text = await response.text()
276
+ const parsed = parseCodexSSEResponse(text)
277
+ if (!parsed) return new Response(text, { status: response.status, statusText: response.statusText, headers })
278
+
279
+ headers.set("content-type", "application/json; charset=utf-8")
280
+ return new Response(JSON.stringify(parsed), { status: response.status, statusText: response.statusText, headers })
183
281
  }
184
282
 
185
283
  function isMissingCredentialError(error: unknown): boolean {
@@ -495,6 +593,7 @@ export function createCredentialResolver(
495
593
  input instanceof URL ? new URL(input.href) : new URL(typeof input === "string" ? input : input.url)
496
594
 
497
595
  let nextBody = init?.body
596
+ let convertCodexResponse = false
498
597
 
499
598
  if (providerID === "anthropic") {
500
599
  // Match the Claude Code CLI fingerprint so Anthropic's OAuth rate-limit
@@ -514,11 +613,16 @@ export function createCredentialResolver(
514
613
  inputUrl.hostname === "api.openai.com" &&
515
614
  (inputUrl.pathname === "/v1/chat/completions" || inputUrl.pathname === "/v1/responses")
516
615
  ) {
616
+ const rewritten = rewriteOpenAICodexBody(nextBody)
517
617
  inputUrl.protocol = "https:"
518
618
  inputUrl.hostname = "chatgpt.com"
519
619
  inputUrl.pathname = "/backend-api/codex/responses"
520
620
  inputUrl.search = ""
521
- nextBody = rewriteOpenAICodexBody(nextBody)
621
+ nextBody = rewritten.body
622
+ convertCodexResponse = !rewritten.originalStream
623
+ headers.set("OpenAI-Beta", "responses=experimental")
624
+ headers.set("originator", "codex_cli_rs")
625
+ headers.set("accept", "text/event-stream")
522
626
  headers.delete("content-length")
523
627
  }
524
628
  }
@@ -547,11 +651,13 @@ export function createCredentialResolver(
547
651
  }
548
652
  }
549
653
 
550
- return fetchImpl(inputUrl, {
654
+ const response = await fetchImpl(inputUrl, {
551
655
  ...init,
552
656
  headers,
553
657
  body: nextBody,
554
658
  })
659
+ if (convertCodexResponse && response.ok) return convertCodexSSEToJSON(response)
660
+ return response
555
661
  }
556
662
  }
557
663