opencode-translate 0.0.1

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/src/auth.ts ADDED
@@ -0,0 +1,504 @@
1
+ import { readFile, stat } from "node:fs/promises"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { setTimeout as sleep } from "node:timers/promises"
5
+ import {
6
+ AUTH_ENV_FALLBACK,
7
+ type AuthInfo,
8
+ buildAuthUnavailableError,
9
+ buildOAuthRefreshError,
10
+ type FetchLike,
11
+ getEnvVarHint,
12
+ normalizeReason,
13
+ OAUTH_DUMMY_KEY,
14
+ type OAuthInfo,
15
+ type PluginClientLike,
16
+ type ProviderInfo,
17
+ parseTranslatorModel,
18
+ type ResolvedTranslateOptions,
19
+ USER_AGENT,
20
+ unwrapData,
21
+ } from "./constants"
22
+
23
+ export interface ResolvedCredential {
24
+ providerID: string
25
+ provider?: ProviderInfo
26
+ apiKey?: string
27
+ fetch?: FetchLike
28
+ mode: "apiKey" | "oauth" | "default"
29
+ }
30
+
31
+ interface AuthDependencies {
32
+ fetchImpl?: FetchLike
33
+ now?: () => number
34
+ sleep?: (ms: number) => Promise<void>
35
+ readFile?: (filePath: string, encoding: BufferEncoding) => Promise<string>
36
+ stat?: (filePath: string) => Promise<{ mode: number }>
37
+ packageVersion?: string
38
+ }
39
+
40
+ const credentialCache = new Map<string, ResolvedCredential>()
41
+ const oauthRefreshInflight = new Map<string, Promise<OAuthInfo>>()
42
+
43
+ export function __resetAuthCachesForTest() {
44
+ credentialCache.clear()
45
+ oauthRefreshInflight.clear()
46
+ }
47
+
48
+ // opencode itself resolves `auth.json` through xdg-basedir (packages/opencode/src/global/index.ts:10).
49
+ // When XDG_DATA_HOME is unset, xdg-basedir returns `~/.local/share` on macOS and Linux, and
50
+ // %LOCALAPPDATA% on Windows. The spec's macOS fallback to `~/Library/Application Support` is only
51
+ // accurate if the user exports XDG_DATA_HOME themselves; in practice opencode stores auth.json at
52
+ // `~/.local/share/opencode/auth.json` on macOS, so we mirror that here.
53
+ function authFilePath(): string {
54
+ const xdgDataHome = process.env.XDG_DATA_HOME
55
+ if (xdgDataHome) return path.join(xdgDataHome, "opencode", "auth.json")
56
+ if (process.platform === "win32") {
57
+ return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "opencode", "auth.json")
58
+ }
59
+ return path.join(os.homedir(), ".local", "share", "opencode", "auth.json")
60
+ }
61
+
62
+ function copyHeaders(headers?: HeadersInit): Headers {
63
+ return new Headers(headers)
64
+ }
65
+
66
+ function headerValue(headers: Headers, key: string): string | undefined {
67
+ const value = headers.get(key)
68
+ return value === null ? undefined : value
69
+ }
70
+
71
+ function setUserAgent(headers: Headers, packageVersion?: string) {
72
+ headers.set("User-Agent", packageVersion ? `${USER_AGENT.replace("0.0.0", packageVersion)}` : USER_AGENT)
73
+ }
74
+
75
+ function isMissingCredentialError(error: unknown): boolean {
76
+ const message = normalizeReason(error).toLowerCase()
77
+ return (
78
+ message.includes("api key") ||
79
+ message.includes("api-key") ||
80
+ message.includes("missing credentials") ||
81
+ message.includes("missing authentication") ||
82
+ message.includes("missing auth") ||
83
+ message.includes("no auth")
84
+ )
85
+ }
86
+
87
+ function getStatus(error: unknown): number | undefined {
88
+ if (!error || typeof error !== "object") return undefined
89
+ const record = error as Record<string, unknown>
90
+ if (typeof record.status === "number") return record.status
91
+ if (typeof record.statusCode === "number") return record.statusCode
92
+ const response = record.response
93
+ if (response && typeof response === "object") {
94
+ const maybeStatus = (response as Record<string, unknown>).status
95
+ if (typeof maybeStatus === "number") return maybeStatus
96
+ }
97
+ return undefined
98
+ }
99
+
100
+ function getRetryAfterMs(error: unknown): number {
101
+ if (!error || typeof error !== "object") return 2000
102
+ const record = error as Record<string, unknown>
103
+ const response = record.response
104
+ if (response && typeof response === "object") {
105
+ const headers = (response as { headers?: Headers }).headers
106
+ if (headers instanceof Headers) {
107
+ const retryAfter = headerValue(headers, "retry-after")
108
+ if (!retryAfter) return 2000
109
+ const seconds = Number(retryAfter)
110
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
111
+ const date = Date.parse(retryAfter)
112
+ if (Number.isFinite(date)) return Math.max(0, date - Date.now())
113
+ }
114
+ }
115
+ return 2000
116
+ }
117
+
118
+ function isRetryableError(error: unknown): boolean {
119
+ const status = getStatus(error)
120
+ if (status === 429) return true
121
+ if (status !== undefined) return status >= 500
122
+ const message = normalizeReason(error).toLowerCase()
123
+ return (
124
+ message.includes("network") ||
125
+ message.includes("fetch") ||
126
+ message.includes("timeout") ||
127
+ message.includes("socket") ||
128
+ message.includes("econn")
129
+ )
130
+ }
131
+
132
+ async function withRetry<T>(task: () => Promise<T>, deps: Required<Pick<AuthDependencies, "sleep">>): Promise<T> {
133
+ let lastError: unknown
134
+ for (let attempt = 0; attempt < 3; attempt += 1) {
135
+ try {
136
+ return await task()
137
+ } catch (error) {
138
+ lastError = error
139
+ if (!isRetryableError(error)) throw error
140
+ if (getStatus(error) === 429) {
141
+ if (attempt >= 1) throw error
142
+ await deps.sleep(getRetryAfterMs(error))
143
+ continue
144
+ }
145
+ if (attempt >= 2) throw error
146
+ await deps.sleep(attempt === 0 ? 500 : 1500)
147
+ }
148
+ }
149
+ throw lastError
150
+ }
151
+
152
+ function normalizeProviderKey(value: string | undefined): string | undefined {
153
+ if (!value || value === OAUTH_DUMMY_KEY) return undefined
154
+ return value
155
+ }
156
+
157
+ function ensureOAuthInfo(value: AuthInfo | undefined): OAuthInfo | undefined {
158
+ return value && value.type === "oauth" ? value : undefined
159
+ }
160
+
161
+ async function readAuthMap(deps: AuthDependencies): Promise<Record<string, AuthInfo> | undefined> {
162
+ if (process.env.OPENCODE_AUTH_CONTENT) {
163
+ try {
164
+ const parsed = JSON.parse(process.env.OPENCODE_AUTH_CONTENT) as Record<string, AuthInfo>
165
+ if (parsed && typeof parsed === "object") return parsed
166
+ } catch {}
167
+ return undefined
168
+ }
169
+
170
+ const filePath = authFilePath()
171
+ try {
172
+ const fileStat = await (deps.stat ?? stat)(filePath)
173
+ if ((fileStat.mode & 0o777) !== 0o600) return undefined
174
+ const raw = await (deps.readFile ?? readFile)(filePath, "utf8")
175
+ const parsed = JSON.parse(raw) as Record<string, AuthInfo>
176
+ return parsed && typeof parsed === "object" ? parsed : undefined
177
+ } catch {
178
+ return undefined
179
+ }
180
+ }
181
+
182
+ async function refreshAnthropic(
183
+ info: OAuthInfo,
184
+ deps: Required<Pick<AuthDependencies, "fetchImpl" | "sleep">>,
185
+ ): Promise<OAuthInfo> {
186
+ const response = await withRetry(
187
+ () =>
188
+ deps
189
+ .fetchImpl("https://console.anthropic.com/v1/oauth/token", {
190
+ method: "POST",
191
+ headers: {
192
+ "Content-Type": "application/json",
193
+ },
194
+ body: JSON.stringify({
195
+ grant_type: "refresh_token",
196
+ refresh_token: info.refresh,
197
+ client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
198
+ }),
199
+ })
200
+ .then(async (result) => {
201
+ if (!result.ok) {
202
+ const error = new Error(`HTTP ${result.status}`) as Error & { response?: Response; status?: number }
203
+ error.response = result
204
+ error.status = result.status
205
+ throw error
206
+ }
207
+ return result
208
+ }),
209
+ deps,
210
+ )
211
+
212
+ let body: Record<string, unknown>
213
+ try {
214
+ body = (await response.json()) as Record<string, unknown>
215
+ } catch (error) {
216
+ throw buildOAuthRefreshError("anthropic", normalizeReason(error))
217
+ }
218
+
219
+ if (typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
220
+ throw buildOAuthRefreshError("anthropic", "Invalid token response")
221
+ }
222
+
223
+ return {
224
+ type: "oauth",
225
+ access: body.access_token,
226
+ refresh: body.refresh_token,
227
+ expires: Date.now() + (typeof body.expires_in === "number" ? body.expires_in : 3600) * 1000,
228
+ accountId: info.accountId,
229
+ enterpriseUrl: info.enterpriseUrl,
230
+ }
231
+ }
232
+
233
+ async function refreshOpenAI(
234
+ info: OAuthInfo,
235
+ deps: Required<Pick<AuthDependencies, "fetchImpl" | "sleep">>,
236
+ ): Promise<OAuthInfo> {
237
+ const body = new URLSearchParams({
238
+ grant_type: "refresh_token",
239
+ refresh_token: info.refresh,
240
+ client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
241
+ scope: "openid profile email offline_access",
242
+ })
243
+ const response = await withRetry(
244
+ () =>
245
+ deps
246
+ .fetchImpl("https://auth.openai.com/oauth/token", {
247
+ method: "POST",
248
+ headers: {
249
+ "Content-Type": "application/x-www-form-urlencoded",
250
+ },
251
+ body,
252
+ })
253
+ .then(async (result) => {
254
+ if (!result.ok) {
255
+ const error = new Error(`HTTP ${result.status}`) as Error & { response?: Response; status?: number }
256
+ error.response = result
257
+ error.status = result.status
258
+ throw error
259
+ }
260
+ return result
261
+ }),
262
+ deps,
263
+ )
264
+
265
+ let parsed: Record<string, unknown>
266
+ try {
267
+ parsed = (await response.json()) as Record<string, unknown>
268
+ } catch (error) {
269
+ throw buildOAuthRefreshError("openai", normalizeReason(error))
270
+ }
271
+
272
+ if (typeof parsed.access_token !== "string" || typeof parsed.refresh_token !== "string") {
273
+ throw buildOAuthRefreshError("openai", "Invalid token response")
274
+ }
275
+
276
+ return {
277
+ type: "oauth",
278
+ access: parsed.access_token,
279
+ refresh: parsed.refresh_token,
280
+ expires: Date.now() + (typeof parsed.expires_in === "number" ? parsed.expires_in : 3600) * 1000,
281
+ accountId: info.accountId,
282
+ enterpriseUrl: info.enterpriseUrl,
283
+ }
284
+ }
285
+
286
+ async function exchangeCopilotToken(
287
+ info: OAuthInfo,
288
+ deps: Required<Pick<AuthDependencies, "fetchImpl" | "sleep">>,
289
+ ): Promise<{ token: string }> {
290
+ const response = await withRetry(
291
+ () =>
292
+ deps
293
+ .fetchImpl("https://api.github.com/copilot_internal/v2/token", {
294
+ method: "GET",
295
+ headers: {
296
+ Authorization: `token ${info.refresh}`,
297
+ },
298
+ })
299
+ .then(async (result) => {
300
+ if (!result.ok) {
301
+ const error = new Error(`HTTP ${result.status}`) as Error & { response?: Response; status?: number }
302
+ error.response = result
303
+ error.status = result.status
304
+ throw error
305
+ }
306
+ return result
307
+ }),
308
+ deps,
309
+ )
310
+
311
+ const parsed = (await response.json()) as Record<string, unknown>
312
+ if (typeof parsed.token !== "string") {
313
+ throw buildOAuthRefreshError("github-copilot", "Invalid token response")
314
+ }
315
+ return { token: parsed.token }
316
+ }
317
+
318
+ export function createCredentialResolver(
319
+ client: PluginClientLike,
320
+ options: ResolvedTranslateOptions,
321
+ deps: AuthDependencies = {},
322
+ ) {
323
+ const fetchImpl = deps.fetchImpl ?? fetch
324
+ const now = deps.now ?? (() => Date.now())
325
+ const sleepImpl = deps.sleep ?? ((ms: number) => sleep(ms))
326
+
327
+ async function getProvider(providerID: string): Promise<ProviderInfo | undefined> {
328
+ try {
329
+ const listed = unwrapData(await client.provider.list({ throwOnError: true }))
330
+ return listed.all.find((provider) => provider.id === providerID)
331
+ } catch {
332
+ return undefined
333
+ }
334
+ }
335
+
336
+ async function resolveOAuth(providerID: string): Promise<OAuthInfo | undefined> {
337
+ const authMap = await readAuthMap(deps)
338
+ const info = ensureOAuthInfo(authMap?.[providerID])
339
+ if (!info) return undefined
340
+ if (info.expires >= now() + 60_000) return info
341
+
342
+ const existing = oauthRefreshInflight.get(providerID)
343
+ if (existing) return existing
344
+
345
+ const refreshPromise = (async () => {
346
+ try {
347
+ let refreshed: OAuthInfo
348
+ try {
349
+ if (providerID === "anthropic") {
350
+ refreshed = await refreshAnthropic(info, { fetchImpl, sleep: sleepImpl })
351
+ } else if (providerID === "openai") {
352
+ refreshed = await refreshOpenAI(info, { fetchImpl, sleep: sleepImpl })
353
+ } else if (providerID === "github-copilot") {
354
+ return info
355
+ } else {
356
+ return info
357
+ }
358
+ } catch (error) {
359
+ if (error instanceof Error && error.message.includes(":OAUTH_REFRESH_FAILED]")) throw error
360
+ throw buildOAuthRefreshError(providerID, normalizeReason(error))
361
+ }
362
+
363
+ await client.auth.set({
364
+ path: { id: providerID },
365
+ body: refreshed,
366
+ })
367
+
368
+ return refreshed
369
+ } finally {
370
+ oauthRefreshInflight.delete(providerID)
371
+ }
372
+ })()
373
+
374
+ oauthRefreshInflight.set(providerID, refreshPromise)
375
+ return refreshPromise
376
+ }
377
+
378
+ function buildOAuthFetch(providerID: string): FetchLike {
379
+ return async (input, init) => {
380
+ const info = await resolveOAuth(providerID)
381
+ if (!info) return fetchImpl(input, init)
382
+
383
+ const headers = copyHeaders(init?.headers)
384
+ setUserAgent(headers, deps.packageVersion)
385
+ const inputUrl =
386
+ input instanceof URL ? new URL(input.href) : new URL(typeof input === "string" ? input : input.url)
387
+
388
+ if (providerID === "anthropic") {
389
+ headers.set("Authorization", `Bearer ${info.access}`)
390
+ headers.set("anthropic-beta", "oauth-2025-04-20,interleaved-thinking-2025-05-14")
391
+ headers.set("anthropic-version", "2023-06-01")
392
+ headers.delete("x-api-key")
393
+ if (inputUrl.pathname === "/v1/messages" && !inputUrl.searchParams.has("beta")) {
394
+ inputUrl.searchParams.set("beta", "true")
395
+ }
396
+ }
397
+
398
+ if (providerID === "openai") {
399
+ headers.set("Authorization", `Bearer ${info.access}`)
400
+ if (info.accountId) headers.set("ChatGPT-Account-Id", info.accountId)
401
+ if (
402
+ inputUrl.hostname === "api.openai.com" &&
403
+ (inputUrl.pathname === "/v1/chat/completions" || inputUrl.pathname === "/v1/responses")
404
+ ) {
405
+ inputUrl.protocol = "https:"
406
+ inputUrl.hostname = "chatgpt.com"
407
+ inputUrl.pathname = "/backend-api/codex/responses"
408
+ inputUrl.search = ""
409
+ }
410
+ }
411
+
412
+ if (providerID === "github-copilot") {
413
+ const session = await exchangeCopilotToken(info, { fetchImpl, sleep: sleepImpl })
414
+ headers.set("Authorization", `Bearer ${session.token}`)
415
+ headers.set(
416
+ "Editor-Version",
417
+ deps.packageVersion ? `${USER_AGENT.replace("0.0.0", deps.packageVersion)}` : USER_AGENT,
418
+ )
419
+ headers.set(
420
+ "Editor-Plugin-Version",
421
+ deps.packageVersion ? `${USER_AGENT.replace("0.0.0", deps.packageVersion)}` : USER_AGENT,
422
+ )
423
+ headers.set("Copilot-Integration-Id", "vscode-chat")
424
+ headers.delete("x-api-key")
425
+
426
+ if (info.enterpriseUrl) {
427
+ const target = new URL(
428
+ info.enterpriseUrl.includes("://") ? info.enterpriseUrl : `https://${info.enterpriseUrl}`,
429
+ )
430
+ inputUrl.protocol = target.protocol
431
+ inputUrl.hostname = target.hostname
432
+ inputUrl.port = target.port
433
+ }
434
+ }
435
+
436
+ return fetchImpl(inputUrl, {
437
+ ...init,
438
+ headers,
439
+ })
440
+ }
441
+ }
442
+
443
+ async function resolve(providerModel: string): Promise<ResolvedCredential> {
444
+ const { providerID } = parseTranslatorModel(providerModel)
445
+ const cached = credentialCache.get(providerID)
446
+ if (cached) return cached
447
+
448
+ const provider = await getProvider(providerID)
449
+
450
+ if (options.apiKey) {
451
+ const resolved = { providerID, provider, apiKey: options.apiKey, mode: "apiKey" as const }
452
+ credentialCache.set(providerID, resolved)
453
+ return resolved
454
+ }
455
+
456
+ const providerKey = normalizeProviderKey(provider?.key)
457
+
458
+ if (provider?.source === "api" && providerKey) {
459
+ const resolved = { providerID, provider, apiKey: providerKey, mode: "apiKey" as const }
460
+ credentialCache.set(providerID, resolved)
461
+ return resolved
462
+ }
463
+
464
+ if (provider?.source === "env" && providerKey) {
465
+ const resolved = { providerID, provider, apiKey: providerKey, mode: "apiKey" as const }
466
+ credentialCache.set(providerID, resolved)
467
+ return resolved
468
+ }
469
+
470
+ if (provider?.source === "custom" || provider?.key === OAUTH_DUMMY_KEY) {
471
+ const oauthInfo = await resolveOAuth(providerID)
472
+ if (oauthInfo) {
473
+ const resolved = {
474
+ providerID,
475
+ provider,
476
+ apiKey: "",
477
+ fetch: buildOAuthFetch(providerID),
478
+ mode: "oauth" as const,
479
+ }
480
+ credentialCache.set(providerID, resolved)
481
+ return resolved
482
+ }
483
+ }
484
+
485
+ if (provider?.key === undefined && (provider?.env.length ?? 0) > 1) {
486
+ const resolved = { providerID, provider, mode: "default" as const }
487
+ credentialCache.set(providerID, resolved)
488
+ return resolved
489
+ }
490
+
491
+ return { providerID, provider, mode: "default" }
492
+ }
493
+
494
+ function authUnavailable(providerID: string, provider?: ProviderInfo): Error {
495
+ return buildAuthUnavailableError(providerID, getEnvVarHint(provider))
496
+ }
497
+
498
+ return {
499
+ resolve,
500
+ authUnavailable,
501
+ isMissingCredentialError,
502
+ envFallback: AUTH_ENV_FALLBACK,
503
+ }
504
+ }