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 +8 -0
- package/package.json +1 -1
- package/src/auth.ts +156 -2
- package/src/translator.ts +7 -1
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
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
|
-
|
|
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,
|