opencode-translate 0.1.1 → 0.1.3
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 +1 -1
- package/package.json +6 -3
- package/src/activation/chat-message.ts +164 -0
- package/src/activation/index.ts +38 -0
- package/src/activation/logging.ts +11 -0
- package/src/activation/messages-transform.ts +38 -0
- package/src/activation/metadata.ts +42 -0
- package/src/activation/parts.ts +53 -0
- package/src/activation/question-hooks.ts +95 -0
- package/src/activation/state.ts +98 -0
- package/src/activation/text-complete.ts +51 -0
- package/src/activation/trigger.ts +57 -0
- package/src/activation/types.ts +41 -0
- package/src/activation.ts +1 -607
- package/src/anthropic-oauth.ts +3 -3
- package/src/auth/codex-request.ts +108 -0
- package/src/auth/codex-response.ts +78 -0
- package/src/auth/codex-shared.ts +3 -0
- package/src/auth/headers.ts +18 -0
- package/src/auth/index.ts +153 -0
- package/src/auth/oauth-fetch.ts +100 -0
- package/src/auth/refresh.ts +102 -0
- package/src/auth/retry.ts +70 -0
- package/src/auth/store.ts +45 -0
- package/src/auth/types.ts +27 -0
- package/src/auth.ts +1 -725
- package/src/constants/errors.ts +24 -0
- package/src/constants/guards.ts +34 -0
- package/src/constants/options.ts +37 -0
- package/src/constants/plugin.ts +10 -0
- package/src/constants/types.ts +147 -0
- package/src/constants.ts +5 -261
- package/src/labels.ts +0 -2
- package/src/question-tool.ts +45 -22
- package/src/translator/index.ts +125 -0
- package/src/translator/part-id.ts +43 -0
- package/src/translator/provider.ts +81 -0
- package/src/translator/retry.ts +62 -0
- package/src/translator/types.ts +17 -0
- package/src/translator.ts +1 -326
package/src/auth.ts
CHANGED
|
@@ -1,725 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import os from "node:os"
|
|
3
|
-
import path from "node:path"
|
|
4
|
-
import { setTimeout as sleep } from "node:timers/promises"
|
|
5
|
-
import {
|
|
6
|
-
isAnthropicMessagesRequest,
|
|
7
|
-
rewriteMessagesBody,
|
|
8
|
-
rewriteMessagesURL,
|
|
9
|
-
setOAuthHeaders as setAnthropicOAuthHeaders,
|
|
10
|
-
} from "./anthropic-oauth"
|
|
11
|
-
import {
|
|
12
|
-
AUTH_ENV_FALLBACK,
|
|
13
|
-
type AuthInfo,
|
|
14
|
-
buildAuthUnavailableError,
|
|
15
|
-
buildOAuthRefreshError,
|
|
16
|
-
type FetchLike,
|
|
17
|
-
getEnvVarHint,
|
|
18
|
-
normalizeReason,
|
|
19
|
-
OAUTH_DUMMY_KEY,
|
|
20
|
-
type OAuthInfo,
|
|
21
|
-
type PluginClientLike,
|
|
22
|
-
type ProviderInfo,
|
|
23
|
-
parseTranslatorModel,
|
|
24
|
-
type ResolvedTranslateOptions,
|
|
25
|
-
USER_AGENT,
|
|
26
|
-
unwrapData,
|
|
27
|
-
} from "./constants"
|
|
28
|
-
|
|
29
|
-
export interface ResolvedCredential {
|
|
30
|
-
providerID: string
|
|
31
|
-
provider?: ProviderInfo
|
|
32
|
-
apiKey?: string
|
|
33
|
-
fetch?: FetchLike
|
|
34
|
-
mode: "apiKey" | "oauth" | "default"
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface AuthDependencies {
|
|
38
|
-
fetchImpl?: FetchLike
|
|
39
|
-
now?: () => number
|
|
40
|
-
sleep?: (ms: number) => Promise<void>
|
|
41
|
-
readFile?: (filePath: string, encoding: BufferEncoding) => Promise<string>
|
|
42
|
-
stat?: (filePath: string) => Promise<{ mode: number }>
|
|
43
|
-
packageVersion?: string
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const credentialCache = new Map<string, ResolvedCredential>()
|
|
47
|
-
const oauthRefreshInflight = new Map<string, Promise<OAuthInfo>>()
|
|
48
|
-
|
|
49
|
-
export function __resetAuthCachesForTest() {
|
|
50
|
-
credentialCache.clear()
|
|
51
|
-
oauthRefreshInflight.clear()
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// opencode itself resolves `auth.json` through xdg-basedir (packages/opencode/src/global/index.ts:10).
|
|
55
|
-
// When XDG_DATA_HOME is unset, xdg-basedir returns `~/.local/share` on macOS and Linux, and
|
|
56
|
-
// %LOCALAPPDATA% on Windows. The spec's macOS fallback to `~/Library/Application Support` is only
|
|
57
|
-
// accurate if the user exports XDG_DATA_HOME themselves; in practice opencode stores auth.json at
|
|
58
|
-
// `~/.local/share/opencode/auth.json` on macOS, so we mirror that here.
|
|
59
|
-
function authFilePath(): string {
|
|
60
|
-
const xdgDataHome = process.env.XDG_DATA_HOME
|
|
61
|
-
if (xdgDataHome) return path.join(xdgDataHome, "opencode", "auth.json")
|
|
62
|
-
if (process.platform === "win32") {
|
|
63
|
-
return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "opencode", "auth.json")
|
|
64
|
-
}
|
|
65
|
-
return path.join(os.homedir(), ".local", "share", "opencode", "auth.json")
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function copyHeaders(headers?: HeadersInit): Headers {
|
|
69
|
-
return new Headers(headers)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function headerValue(headers: Headers, key: string): string | undefined {
|
|
73
|
-
const value = headers.get(key)
|
|
74
|
-
return value === null ? undefined : value
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function setUserAgent(headers: Headers, packageVersion?: string) {
|
|
78
|
-
headers.set("User-Agent", packageVersion ? `${USER_AGENT.replace("0.0.0", packageVersion)}` : USER_AGENT)
|
|
79
|
-
}
|
|
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 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 })
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function isMissingCredentialError(error: unknown): boolean {
|
|
284
|
-
const message = normalizeReason(error).toLowerCase()
|
|
285
|
-
return (
|
|
286
|
-
message.includes("api key") ||
|
|
287
|
-
message.includes("api-key") ||
|
|
288
|
-
message.includes("missing credentials") ||
|
|
289
|
-
message.includes("missing authentication") ||
|
|
290
|
-
message.includes("missing auth") ||
|
|
291
|
-
message.includes("no auth")
|
|
292
|
-
)
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function getStatus(error: unknown): number | undefined {
|
|
296
|
-
if (!error || typeof error !== "object") return undefined
|
|
297
|
-
const record = error as Record<string, unknown>
|
|
298
|
-
if (typeof record.status === "number") return record.status
|
|
299
|
-
if (typeof record.statusCode === "number") return record.statusCode
|
|
300
|
-
const response = record.response
|
|
301
|
-
if (response && typeof response === "object") {
|
|
302
|
-
const maybeStatus = (response as Record<string, unknown>).status
|
|
303
|
-
if (typeof maybeStatus === "number") return maybeStatus
|
|
304
|
-
}
|
|
305
|
-
return undefined
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function getRetryAfterMs(error: unknown): number {
|
|
309
|
-
if (!error || typeof error !== "object") return 2000
|
|
310
|
-
const record = error as Record<string, unknown>
|
|
311
|
-
const response = record.response
|
|
312
|
-
if (response && typeof response === "object") {
|
|
313
|
-
const headers = (response as { headers?: Headers }).headers
|
|
314
|
-
if (headers instanceof Headers) {
|
|
315
|
-
const retryAfter = headerValue(headers, "retry-after")
|
|
316
|
-
if (!retryAfter) return 2000
|
|
317
|
-
const seconds = Number(retryAfter)
|
|
318
|
-
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
|
|
319
|
-
const date = Date.parse(retryAfter)
|
|
320
|
-
if (Number.isFinite(date)) return Math.max(0, date - Date.now())
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
return 2000
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function isRetryableError(error: unknown): boolean {
|
|
327
|
-
const status = getStatus(error)
|
|
328
|
-
if (status === 429) return true
|
|
329
|
-
if (status !== undefined) return status >= 500
|
|
330
|
-
const message = normalizeReason(error).toLowerCase()
|
|
331
|
-
return (
|
|
332
|
-
message.includes("network") ||
|
|
333
|
-
message.includes("fetch") ||
|
|
334
|
-
message.includes("timeout") ||
|
|
335
|
-
message.includes("socket") ||
|
|
336
|
-
message.includes("econn")
|
|
337
|
-
)
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
async function withRetry<T>(task: () => Promise<T>, deps: Required<Pick<AuthDependencies, "sleep">>): Promise<T> {
|
|
341
|
-
let lastError: unknown
|
|
342
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
343
|
-
try {
|
|
344
|
-
return await task()
|
|
345
|
-
} catch (error) {
|
|
346
|
-
lastError = error
|
|
347
|
-
if (!isRetryableError(error)) throw error
|
|
348
|
-
if (getStatus(error) === 429) {
|
|
349
|
-
if (attempt >= 1) throw error
|
|
350
|
-
await deps.sleep(getRetryAfterMs(error))
|
|
351
|
-
continue
|
|
352
|
-
}
|
|
353
|
-
if (attempt >= 2) throw error
|
|
354
|
-
await deps.sleep(attempt === 0 ? 500 : 1500)
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
throw lastError
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function normalizeProviderKey(value: string | undefined): string | undefined {
|
|
361
|
-
if (!value || value === OAUTH_DUMMY_KEY) return undefined
|
|
362
|
-
return value
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function ensureOAuthInfo(value: AuthInfo | undefined): OAuthInfo | undefined {
|
|
366
|
-
return value && value.type === "oauth" ? value : undefined
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
async function readAuthMap(deps: AuthDependencies): Promise<Record<string, AuthInfo> | undefined> {
|
|
370
|
-
if (process.env.OPENCODE_AUTH_CONTENT) {
|
|
371
|
-
try {
|
|
372
|
-
const parsed = JSON.parse(process.env.OPENCODE_AUTH_CONTENT) as Record<string, AuthInfo>
|
|
373
|
-
if (parsed && typeof parsed === "object") return parsed
|
|
374
|
-
} catch {}
|
|
375
|
-
return undefined
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const filePath = authFilePath()
|
|
379
|
-
try {
|
|
380
|
-
const fileStat = await (deps.stat ?? stat)(filePath)
|
|
381
|
-
if ((fileStat.mode & 0o777) !== 0o600) return undefined
|
|
382
|
-
const raw = await (deps.readFile ?? readFile)(filePath, "utf8")
|
|
383
|
-
const parsed = JSON.parse(raw) as Record<string, AuthInfo>
|
|
384
|
-
return parsed && typeof parsed === "object" ? parsed : undefined
|
|
385
|
-
} catch {
|
|
386
|
-
return undefined
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
async function refreshAnthropic(
|
|
391
|
-
info: OAuthInfo,
|
|
392
|
-
deps: Required<Pick<AuthDependencies, "fetchImpl" | "sleep">>,
|
|
393
|
-
): Promise<OAuthInfo> {
|
|
394
|
-
const response = await withRetry(
|
|
395
|
-
() =>
|
|
396
|
-
deps
|
|
397
|
-
.fetchImpl("https://console.anthropic.com/v1/oauth/token", {
|
|
398
|
-
method: "POST",
|
|
399
|
-
headers: {
|
|
400
|
-
"Content-Type": "application/json",
|
|
401
|
-
},
|
|
402
|
-
body: JSON.stringify({
|
|
403
|
-
grant_type: "refresh_token",
|
|
404
|
-
refresh_token: info.refresh,
|
|
405
|
-
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
|
406
|
-
}),
|
|
407
|
-
})
|
|
408
|
-
.then(async (result) => {
|
|
409
|
-
if (!result.ok) {
|
|
410
|
-
const error = new Error(`HTTP ${result.status}`) as Error & { response?: Response; status?: number }
|
|
411
|
-
error.response = result
|
|
412
|
-
error.status = result.status
|
|
413
|
-
throw error
|
|
414
|
-
}
|
|
415
|
-
return result
|
|
416
|
-
}),
|
|
417
|
-
deps,
|
|
418
|
-
)
|
|
419
|
-
|
|
420
|
-
let body: Record<string, unknown>
|
|
421
|
-
try {
|
|
422
|
-
body = (await response.json()) as Record<string, unknown>
|
|
423
|
-
} catch (error) {
|
|
424
|
-
throw buildOAuthRefreshError("anthropic", normalizeReason(error))
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
if (typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
|
|
428
|
-
throw buildOAuthRefreshError("anthropic", "Invalid token response")
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
return {
|
|
432
|
-
type: "oauth",
|
|
433
|
-
access: body.access_token,
|
|
434
|
-
refresh: body.refresh_token,
|
|
435
|
-
expires: Date.now() + (typeof body.expires_in === "number" ? body.expires_in : 3600) * 1000,
|
|
436
|
-
accountId: info.accountId,
|
|
437
|
-
enterpriseUrl: info.enterpriseUrl,
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
async function refreshOpenAI(
|
|
442
|
-
info: OAuthInfo,
|
|
443
|
-
deps: Required<Pick<AuthDependencies, "fetchImpl" | "sleep">>,
|
|
444
|
-
): Promise<OAuthInfo> {
|
|
445
|
-
const body = new URLSearchParams({
|
|
446
|
-
grant_type: "refresh_token",
|
|
447
|
-
refresh_token: info.refresh,
|
|
448
|
-
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
449
|
-
})
|
|
450
|
-
const response = await withRetry(
|
|
451
|
-
() =>
|
|
452
|
-
deps
|
|
453
|
-
.fetchImpl("https://auth.openai.com/oauth/token", {
|
|
454
|
-
method: "POST",
|
|
455
|
-
headers: {
|
|
456
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
457
|
-
},
|
|
458
|
-
body,
|
|
459
|
-
})
|
|
460
|
-
.then(async (result) => {
|
|
461
|
-
if (!result.ok) {
|
|
462
|
-
const error = new Error(`HTTP ${result.status}`) as Error & { response?: Response; status?: number }
|
|
463
|
-
error.response = result
|
|
464
|
-
error.status = result.status
|
|
465
|
-
throw error
|
|
466
|
-
}
|
|
467
|
-
return result
|
|
468
|
-
}),
|
|
469
|
-
deps,
|
|
470
|
-
)
|
|
471
|
-
|
|
472
|
-
let parsed: Record<string, unknown>
|
|
473
|
-
try {
|
|
474
|
-
parsed = (await response.json()) as Record<string, unknown>
|
|
475
|
-
} catch (error) {
|
|
476
|
-
throw buildOAuthRefreshError("openai", normalizeReason(error))
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
if (typeof parsed.access_token !== "string" || typeof parsed.refresh_token !== "string") {
|
|
480
|
-
throw buildOAuthRefreshError("openai", "Invalid token response")
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
return {
|
|
484
|
-
type: "oauth",
|
|
485
|
-
access: parsed.access_token,
|
|
486
|
-
refresh: parsed.refresh_token,
|
|
487
|
-
expires: Date.now() + (typeof parsed.expires_in === "number" ? parsed.expires_in : 3600) * 1000,
|
|
488
|
-
accountId: info.accountId,
|
|
489
|
-
enterpriseUrl: info.enterpriseUrl,
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
async function exchangeCopilotToken(
|
|
494
|
-
info: OAuthInfo,
|
|
495
|
-
deps: Required<Pick<AuthDependencies, "fetchImpl" | "sleep">>,
|
|
496
|
-
): Promise<{ token: string }> {
|
|
497
|
-
const response = await withRetry(
|
|
498
|
-
() =>
|
|
499
|
-
deps
|
|
500
|
-
.fetchImpl("https://api.github.com/copilot_internal/v2/token", {
|
|
501
|
-
method: "GET",
|
|
502
|
-
headers: {
|
|
503
|
-
Authorization: `token ${info.refresh}`,
|
|
504
|
-
},
|
|
505
|
-
})
|
|
506
|
-
.then(async (result) => {
|
|
507
|
-
if (!result.ok) {
|
|
508
|
-
const error = new Error(`HTTP ${result.status}`) as Error & { response?: Response; status?: number }
|
|
509
|
-
error.response = result
|
|
510
|
-
error.status = result.status
|
|
511
|
-
throw error
|
|
512
|
-
}
|
|
513
|
-
return result
|
|
514
|
-
}),
|
|
515
|
-
deps,
|
|
516
|
-
)
|
|
517
|
-
|
|
518
|
-
const parsed = (await response.json()) as Record<string, unknown>
|
|
519
|
-
if (typeof parsed.token !== "string") {
|
|
520
|
-
throw buildOAuthRefreshError("github-copilot", "Invalid token response")
|
|
521
|
-
}
|
|
522
|
-
return { token: parsed.token }
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
export function createCredentialResolver(
|
|
526
|
-
client: PluginClientLike,
|
|
527
|
-
options: ResolvedTranslateOptions,
|
|
528
|
-
deps: AuthDependencies = {},
|
|
529
|
-
) {
|
|
530
|
-
const fetchImpl = deps.fetchImpl ?? fetch
|
|
531
|
-
const now = deps.now ?? (() => Date.now())
|
|
532
|
-
const sleepImpl = deps.sleep ?? ((ms: number) => sleep(ms))
|
|
533
|
-
|
|
534
|
-
async function getProvider(providerID: string): Promise<ProviderInfo | undefined> {
|
|
535
|
-
try {
|
|
536
|
-
const listed = unwrapData(await client.provider.list({ throwOnError: true }))
|
|
537
|
-
return listed.all.find((provider) => provider.id === providerID)
|
|
538
|
-
} catch {
|
|
539
|
-
return undefined
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
async function resolveOAuth(providerID: string): Promise<OAuthInfo | undefined> {
|
|
544
|
-
const authMap = await readAuthMap(deps)
|
|
545
|
-
const info = ensureOAuthInfo(authMap?.[providerID])
|
|
546
|
-
if (!info) return undefined
|
|
547
|
-
if (info.expires >= now() + 60_000) return info
|
|
548
|
-
|
|
549
|
-
const existing = oauthRefreshInflight.get(providerID)
|
|
550
|
-
if (existing) return existing
|
|
551
|
-
|
|
552
|
-
const refreshPromise = (async () => {
|
|
553
|
-
try {
|
|
554
|
-
let refreshed: OAuthInfo
|
|
555
|
-
try {
|
|
556
|
-
if (providerID === "anthropic") {
|
|
557
|
-
refreshed = await refreshAnthropic(info, { fetchImpl, sleep: sleepImpl })
|
|
558
|
-
} else if (providerID === "openai") {
|
|
559
|
-
refreshed = await refreshOpenAI(info, { fetchImpl, sleep: sleepImpl })
|
|
560
|
-
} else if (providerID === "github-copilot") {
|
|
561
|
-
return info
|
|
562
|
-
} else {
|
|
563
|
-
return info
|
|
564
|
-
}
|
|
565
|
-
} catch (error) {
|
|
566
|
-
if (error instanceof Error && error.message.includes(":OAUTH_REFRESH_FAILED]")) throw error
|
|
567
|
-
throw buildOAuthRefreshError(providerID, normalizeReason(error))
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
await client.auth.set({
|
|
571
|
-
path: { id: providerID },
|
|
572
|
-
body: refreshed,
|
|
573
|
-
})
|
|
574
|
-
|
|
575
|
-
return refreshed
|
|
576
|
-
} finally {
|
|
577
|
-
oauthRefreshInflight.delete(providerID)
|
|
578
|
-
}
|
|
579
|
-
})()
|
|
580
|
-
|
|
581
|
-
oauthRefreshInflight.set(providerID, refreshPromise)
|
|
582
|
-
return refreshPromise
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function buildOAuthFetch(providerID: string): FetchLike {
|
|
586
|
-
return async (input, init) => {
|
|
587
|
-
const info = await resolveOAuth(providerID)
|
|
588
|
-
if (!info) return fetchImpl(input, init)
|
|
589
|
-
|
|
590
|
-
const headers = copyHeaders(init?.headers)
|
|
591
|
-
setUserAgent(headers, deps.packageVersion)
|
|
592
|
-
const inputUrl =
|
|
593
|
-
input instanceof URL ? new URL(input.href) : new URL(typeof input === "string" ? input : input.url)
|
|
594
|
-
|
|
595
|
-
let nextBody = init?.body
|
|
596
|
-
let convertCodexResponse = false
|
|
597
|
-
|
|
598
|
-
if (providerID === "anthropic") {
|
|
599
|
-
// Match the Claude Code CLI fingerprint so Anthropic's OAuth rate-limit
|
|
600
|
-
// guard doesn't reject third-party agents. See src/anthropic-oauth.ts.
|
|
601
|
-
setAnthropicOAuthHeaders(headers, info.access)
|
|
602
|
-
headers.set("anthropic-version", "2023-06-01")
|
|
603
|
-
rewriteMessagesURL(inputUrl)
|
|
604
|
-
if (isAnthropicMessagesRequest(inputUrl) && typeof nextBody === "string") {
|
|
605
|
-
nextBody = rewriteMessagesBody(nextBody)
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
if (providerID === "openai") {
|
|
610
|
-
headers.set("Authorization", `Bearer ${info.access}`)
|
|
611
|
-
if (info.accountId) headers.set("ChatGPT-Account-Id", info.accountId)
|
|
612
|
-
if (
|
|
613
|
-
inputUrl.hostname === "api.openai.com" &&
|
|
614
|
-
(inputUrl.pathname === "/v1/chat/completions" || inputUrl.pathname === "/v1/responses")
|
|
615
|
-
) {
|
|
616
|
-
const rewritten = rewriteOpenAICodexBody(nextBody)
|
|
617
|
-
inputUrl.protocol = "https:"
|
|
618
|
-
inputUrl.hostname = "chatgpt.com"
|
|
619
|
-
inputUrl.pathname = "/backend-api/codex/responses"
|
|
620
|
-
inputUrl.search = ""
|
|
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")
|
|
626
|
-
headers.delete("content-length")
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
if (providerID === "github-copilot") {
|
|
631
|
-
const session = await exchangeCopilotToken(info, { fetchImpl, sleep: sleepImpl })
|
|
632
|
-
headers.set("Authorization", `Bearer ${session.token}`)
|
|
633
|
-
headers.set(
|
|
634
|
-
"Editor-Version",
|
|
635
|
-
deps.packageVersion ? `${USER_AGENT.replace("0.0.0", deps.packageVersion)}` : USER_AGENT,
|
|
636
|
-
)
|
|
637
|
-
headers.set(
|
|
638
|
-
"Editor-Plugin-Version",
|
|
639
|
-
deps.packageVersion ? `${USER_AGENT.replace("0.0.0", deps.packageVersion)}` : USER_AGENT,
|
|
640
|
-
)
|
|
641
|
-
headers.set("Copilot-Integration-Id", "vscode-chat")
|
|
642
|
-
headers.delete("x-api-key")
|
|
643
|
-
|
|
644
|
-
if (info.enterpriseUrl) {
|
|
645
|
-
const target = new URL(
|
|
646
|
-
info.enterpriseUrl.includes("://") ? info.enterpriseUrl : `https://${info.enterpriseUrl}`,
|
|
647
|
-
)
|
|
648
|
-
inputUrl.protocol = target.protocol
|
|
649
|
-
inputUrl.hostname = target.hostname
|
|
650
|
-
inputUrl.port = target.port
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const response = await fetchImpl(inputUrl, {
|
|
655
|
-
...init,
|
|
656
|
-
headers,
|
|
657
|
-
body: nextBody,
|
|
658
|
-
})
|
|
659
|
-
if (convertCodexResponse && response.ok) return convertCodexSSEToJSON(response)
|
|
660
|
-
return response
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
async function resolve(providerModel: string): Promise<ResolvedCredential> {
|
|
665
|
-
const { providerID } = parseTranslatorModel(providerModel)
|
|
666
|
-
const cached = credentialCache.get(providerID)
|
|
667
|
-
if (cached) return cached
|
|
668
|
-
|
|
669
|
-
const provider = await getProvider(providerID)
|
|
670
|
-
|
|
671
|
-
if (options.apiKey) {
|
|
672
|
-
const resolved = { providerID, provider, apiKey: options.apiKey, mode: "apiKey" as const }
|
|
673
|
-
credentialCache.set(providerID, resolved)
|
|
674
|
-
return resolved
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
const providerKey = normalizeProviderKey(provider?.key)
|
|
678
|
-
|
|
679
|
-
if (provider?.source === "api" && providerKey) {
|
|
680
|
-
const resolved = { providerID, provider, apiKey: providerKey, mode: "apiKey" as const }
|
|
681
|
-
credentialCache.set(providerID, resolved)
|
|
682
|
-
return resolved
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
if (provider?.source === "env" && providerKey) {
|
|
686
|
-
const resolved = { providerID, provider, apiKey: providerKey, mode: "apiKey" as const }
|
|
687
|
-
credentialCache.set(providerID, resolved)
|
|
688
|
-
return resolved
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
if (provider?.source === "custom" || provider?.key === OAUTH_DUMMY_KEY) {
|
|
692
|
-
const oauthInfo = await resolveOAuth(providerID)
|
|
693
|
-
if (oauthInfo) {
|
|
694
|
-
const resolved = {
|
|
695
|
-
providerID,
|
|
696
|
-
provider,
|
|
697
|
-
apiKey: "",
|
|
698
|
-
fetch: buildOAuthFetch(providerID),
|
|
699
|
-
mode: "oauth" as const,
|
|
700
|
-
}
|
|
701
|
-
credentialCache.set(providerID, resolved)
|
|
702
|
-
return resolved
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
if (provider?.key === undefined && (provider?.env.length ?? 0) > 1) {
|
|
707
|
-
const resolved = { providerID, provider, mode: "default" as const }
|
|
708
|
-
credentialCache.set(providerID, resolved)
|
|
709
|
-
return resolved
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
return { providerID, provider, mode: "default" }
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
function authUnavailable(providerID: string, provider?: ProviderInfo): Error {
|
|
716
|
-
return buildAuthUnavailableError(providerID, getEnvVarHint(provider))
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
return {
|
|
720
|
-
resolve,
|
|
721
|
-
authUnavailable,
|
|
722
|
-
isMissingCredentialError,
|
|
723
|
-
envFallback: AUTH_ENV_FALLBACK,
|
|
724
|
-
}
|
|
725
|
-
}
|
|
1
|
+
export * from "./auth/index"
|