opencode-translate 0.1.2 → 0.2.0

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 (41) hide show
  1. package/README.md +11 -11
  2. package/package.json +7 -4
  3. package/src/activation/chat-message.ts +164 -0
  4. package/src/activation/index.ts +38 -0
  5. package/src/activation/logging.ts +11 -0
  6. package/src/activation/messages-transform.ts +38 -0
  7. package/src/activation/metadata.ts +41 -0
  8. package/src/activation/parts.ts +53 -0
  9. package/src/activation/question-hooks.ts +95 -0
  10. package/src/activation/state.ts +97 -0
  11. package/src/activation/text-complete.ts +51 -0
  12. package/src/activation/trigger.ts +57 -0
  13. package/src/activation/types.ts +41 -0
  14. package/src/activation.ts +1 -633
  15. package/src/anthropic-oauth.ts +3 -3
  16. package/src/auth/codex-request.ts +108 -0
  17. package/src/auth/codex-response.ts +78 -0
  18. package/src/auth/codex-shared.ts +3 -0
  19. package/src/auth/headers.ts +18 -0
  20. package/src/auth/index.ts +153 -0
  21. package/src/auth/oauth-fetch.ts +100 -0
  22. package/src/auth/refresh.ts +102 -0
  23. package/src/auth/retry.ts +70 -0
  24. package/src/auth/store.ts +45 -0
  25. package/src/auth/types.ts +27 -0
  26. package/src/auth.ts +1 -725
  27. package/src/constants/errors.ts +24 -0
  28. package/src/constants/guards.ts +33 -0
  29. package/src/constants/options.ts +41 -0
  30. package/src/constants/plugin.ts +10 -0
  31. package/src/constants/types.ts +144 -0
  32. package/src/constants.ts +5 -261
  33. package/src/labels.ts +2 -16
  34. package/src/prompts.ts +2 -17
  35. package/src/question-tool.ts +1 -1
  36. package/src/translator/index.ts +125 -0
  37. package/src/translator/part-id.ts +43 -0
  38. package/src/translator/provider.ts +81 -0
  39. package/src/translator/retry.ts +62 -0
  40. package/src/translator/types.ts +17 -0
  41. package/src/translator.ts +1 -326
@@ -0,0 +1,108 @@
1
+ import { isRecord } from "./codex-shared"
2
+ import type { CodexBodyRewrite } from "./types"
3
+
4
+ function textFromContent(content: unknown): string | undefined {
5
+ if (typeof content === "string") return content
6
+ if (!Array.isArray(content)) return undefined
7
+
8
+ const text = content
9
+ .map((part) => (isRecord(part) && typeof part.text === "string" ? part.text : undefined))
10
+ .filter((value): value is string => value !== undefined)
11
+ .join("\n")
12
+
13
+ return text || undefined
14
+ }
15
+
16
+ function normalizeCodexContent(role: string, content: unknown): Record<string, unknown>[] {
17
+ const textType = role === "assistant" ? "output_text" : "input_text"
18
+ if (typeof content === "string") return [{ type: textType, text: content }]
19
+ if (!Array.isArray(content)) return []
20
+
21
+ const result: Record<string, unknown>[] = []
22
+ for (const part of content) {
23
+ if (!isRecord(part)) continue
24
+ const type = part.type
25
+ if (type === "input_text" || type === "output_text") {
26
+ result.push({ ...part, type: textType })
27
+ continue
28
+ }
29
+ if (type === "input_image") {
30
+ result.push({ ...part })
31
+ continue
32
+ }
33
+ if (typeof part.text === "string") result.push({ type: textType, text: part.text })
34
+ }
35
+
36
+ return result
37
+ }
38
+
39
+ function normalizeCodexInputItem(item: unknown, instructions: string[]): unknown | undefined {
40
+ if (!isRecord(item)) return item
41
+ const role = typeof item.role === "string" ? item.role : undefined
42
+
43
+ if (role === "system" || role === "developer") {
44
+ const text = textFromContent(item.content)
45
+ if (text) instructions.push(text)
46
+ return undefined
47
+ }
48
+
49
+ if (item.type === "message" && role) {
50
+ const content = normalizeCodexContent(role, item.content)
51
+ return content.length > 0 ? { ...item, role, content } : undefined
52
+ }
53
+
54
+ if (role) {
55
+ const content = normalizeCodexContent(role, item.content)
56
+ return content.length > 0 ? { type: "message", role, content } : undefined
57
+ }
58
+
59
+ return item
60
+ }
61
+
62
+ export function rewriteOpenAICodexBody(body: BodyInit | null | undefined): CodexBodyRewrite {
63
+ if (typeof body !== "string") return { body, originalStream: false }
64
+
65
+ let parsed: unknown
66
+ try {
67
+ parsed = JSON.parse(body)
68
+ } catch {
69
+ return { body, originalStream: false }
70
+ }
71
+
72
+ if (!isRecord(parsed)) return { body, originalStream: false }
73
+ const originalStream = parsed.stream === true
74
+ const sourceInput = Array.isArray(parsed.input)
75
+ ? parsed.input
76
+ : Array.isArray(parsed.messages)
77
+ ? parsed.messages
78
+ : undefined
79
+ if (!sourceInput) return { body, originalStream }
80
+
81
+ const instructions: string[] = []
82
+ if (typeof parsed.instructions === "string" && parsed.instructions) instructions.push(parsed.instructions)
83
+ const input = sourceInput
84
+ .map((item) => normalizeCodexInputItem(item, instructions))
85
+ .filter((item): item is unknown => item !== undefined)
86
+ const include = Array.isArray(parsed.include)
87
+ ? parsed.include.filter((item): item is string => typeof item === "string")
88
+ : []
89
+ if (!include.includes("reasoning.encrypted_content")) include.push("reasoning.encrypted_content")
90
+
91
+ return {
92
+ body: JSON.stringify({
93
+ ...parsed,
94
+ instructions: instructions.join("\n\n"),
95
+ input,
96
+ tools: Array.isArray(parsed.tools) ? parsed.tools : [],
97
+ tool_choice: typeof parsed.tool_choice === "string" ? parsed.tool_choice : "auto",
98
+ parallel_tool_calls: typeof parsed.parallel_tool_calls === "boolean" ? parsed.parallel_tool_calls : false,
99
+ store: false,
100
+ stream: true,
101
+ include,
102
+ max_output_tokens: undefined,
103
+ max_completion_tokens: undefined,
104
+ messages: undefined,
105
+ }),
106
+ originalStream,
107
+ }
108
+ }
@@ -0,0 +1,78 @@
1
+ import { isRecord } from "./codex-shared"
2
+
3
+ function normalizeCodexOutputItem(item: unknown, index: number): unknown | undefined {
4
+ if (!isRecord(item)) return undefined
5
+ if (item.type !== "message" || item.role !== "assistant") return item
6
+ if (!Array.isArray(item.content)) return undefined
7
+
8
+ const content: Record<string, unknown>[] = []
9
+ for (const part of item.content) {
10
+ if (!isRecord(part) || part.type !== "output_text" || typeof part.text !== "string") continue
11
+ content.push({ ...part, annotations: Array.isArray(part.annotations) ? part.annotations : [] })
12
+ }
13
+
14
+ if (content.length === 0) return undefined
15
+ return {
16
+ ...item,
17
+ id: typeof item.id === "string" ? item.id : `msg_opencode_translate_${index}`,
18
+ role: "assistant",
19
+ content,
20
+ }
21
+ }
22
+
23
+ function buildCodexTextOutput(text: string): Record<string, unknown> {
24
+ return {
25
+ type: "message",
26
+ id: "msg_opencode_translate_0",
27
+ role: "assistant",
28
+ content: [{ type: "output_text", text, annotations: [] }],
29
+ }
30
+ }
31
+
32
+ function parseCodexSSEResponse(text: string): unknown | undefined {
33
+ let finalResponse: unknown
34
+ let deltaText = ""
35
+ const outputItems: unknown[] = []
36
+
37
+ for (const line of text.split(/\r?\n/)) {
38
+ if (!line.startsWith("data: ")) continue
39
+ const payload = line.slice(6).trim()
40
+ if (!payload || payload === "[DONE]") continue
41
+ try {
42
+ const parsed = JSON.parse(payload) as Record<string, unknown>
43
+ if (parsed.type === "response.output_text.delta" && typeof parsed.delta === "string") {
44
+ deltaText += parsed.delta
45
+ } else if (
46
+ (parsed.type === "response.output_item.done" || parsed.type === "response.output_item.added") &&
47
+ parsed.item
48
+ ) {
49
+ outputItems.push(parsed.item)
50
+ } else if ((parsed.type === "response.done" || parsed.type === "response.completed") && parsed.response) {
51
+ finalResponse = parsed.response
52
+ }
53
+ } catch {}
54
+ }
55
+
56
+ if (!finalResponse && !deltaText && outputItems.length === 0) return undefined
57
+ const response: Record<string, unknown> = isRecord(finalResponse)
58
+ ? { ...finalResponse }
59
+ : { id: "resp_opencode_translate" }
60
+ const existingOutput: unknown[] = Array.isArray(response.output) ? response.output : []
61
+ const sourceOutput = existingOutput.length > 0 ? existingOutput : outputItems
62
+ const normalizedOutput = sourceOutput
63
+ .map((item, index) => normalizeCodexOutputItem(item, index))
64
+ .filter((item): item is unknown => item !== undefined)
65
+
66
+ response.output = normalizedOutput.length > 0 ? normalizedOutput : deltaText ? [buildCodexTextOutput(deltaText)] : []
67
+ return response
68
+ }
69
+
70
+ export async function convertCodexSSEToJSON(response: Response): Promise<Response> {
71
+ const headers = new Headers(response.headers)
72
+ const text = await response.text()
73
+ const parsed = parseCodexSSEResponse(text)
74
+ if (!parsed) return new Response(text, { status: response.status, statusText: response.statusText, headers })
75
+
76
+ headers.set("content-type", "application/json; charset=utf-8")
77
+ return new Response(JSON.stringify(parsed), { status: response.status, statusText: response.statusText, headers })
78
+ }
@@ -0,0 +1,3 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return !!value && typeof value === "object" && !Array.isArray(value)
3
+ }
@@ -0,0 +1,18 @@
1
+ import { USER_AGENT } from "../constants"
2
+
3
+ export function copyHeaders(headers?: HeadersInit): Headers {
4
+ return new Headers(headers)
5
+ }
6
+
7
+ export function headerValue(headers: Headers, key: string): string | undefined {
8
+ const value = headers.get(key)
9
+ return value === null ? undefined : value
10
+ }
11
+
12
+ export function packageUserAgent(packageVersion?: string): string {
13
+ return packageVersion ? USER_AGENT.replace("0.0.0", packageVersion) : USER_AGENT
14
+ }
15
+
16
+ export function setUserAgent(headers: Headers, packageVersion?: string) {
17
+ headers.set("User-Agent", packageUserAgent(packageVersion))
18
+ }
@@ -0,0 +1,153 @@
1
+ import { setTimeout as sleep } from "node:timers/promises"
2
+ import {
3
+ AUTH_ENV_FALLBACK,
4
+ buildAuthUnavailableError,
5
+ buildOAuthRefreshError,
6
+ getEnvVarHint,
7
+ normalizeReason,
8
+ OAUTH_DUMMY_KEY,
9
+ type OAuthInfo,
10
+ type PluginClientLike,
11
+ type ProviderInfo,
12
+ parseTranslatorModel,
13
+ type ResolvedTranslateOptions,
14
+ unwrapData,
15
+ } from "../constants"
16
+ import { buildOAuthFetch } from "./oauth-fetch"
17
+ import { refreshAnthropic, refreshOpenAI } from "./refresh"
18
+ import { ensureOAuthInfo, normalizeProviderKey, readAuthMap } from "./store"
19
+ import type { AuthDependencies, AuthRuntime, ResolvedCredential } from "./types"
20
+
21
+ const credentialCache = new Map<string, ResolvedCredential>()
22
+ const oauthRefreshInflight = new Map<string, Promise<OAuthInfo>>()
23
+
24
+ export function __resetAuthCachesForTest() {
25
+ credentialCache.clear()
26
+ oauthRefreshInflight.clear()
27
+ }
28
+
29
+ function isMissingCredentialError(error: unknown): boolean {
30
+ const message = normalizeReason(error).toLowerCase()
31
+ return (
32
+ message.includes("api key") ||
33
+ message.includes("api-key") ||
34
+ message.includes("missing credentials") ||
35
+ message.includes("missing authentication") ||
36
+ message.includes("missing auth") ||
37
+ message.includes("no auth")
38
+ )
39
+ }
40
+
41
+ async function refreshProviderOAuth(
42
+ providerID: string,
43
+ info: OAuthInfo,
44
+ client: PluginClientLike,
45
+ runtime: AuthRuntime,
46
+ ) {
47
+ let refreshed: OAuthInfo
48
+ try {
49
+ if (providerID === "anthropic") refreshed = await refreshAnthropic(info, runtime)
50
+ else if (providerID === "openai") refreshed = await refreshOpenAI(info, runtime)
51
+ else return info
52
+ } catch (error) {
53
+ if (error instanceof Error && error.message.includes(":OAUTH_REFRESH_FAILED]")) throw error
54
+ throw buildOAuthRefreshError(providerID, normalizeReason(error))
55
+ }
56
+
57
+ await client.auth.set({ path: { id: providerID }, body: refreshed })
58
+ return refreshed
59
+ }
60
+
61
+ async function getProvider(client: PluginClientLike, providerID: string): Promise<ProviderInfo | undefined> {
62
+ try {
63
+ const listed = unwrapData(await client.provider.list({ throwOnError: true }))
64
+ return listed.all.find((provider) => provider.id === providerID)
65
+ } catch {
66
+ return undefined
67
+ }
68
+ }
69
+
70
+ export function createCredentialResolver(
71
+ client: PluginClientLike,
72
+ options: ResolvedTranslateOptions,
73
+ deps: AuthDependencies = {},
74
+ ) {
75
+ const runtime: AuthRuntime = {
76
+ fetchImpl: deps.fetchImpl ?? fetch,
77
+ sleep: deps.sleep ?? ((ms: number) => sleep(ms)),
78
+ }
79
+ const now = deps.now ?? (() => Date.now())
80
+
81
+ async function resolveOAuth(providerID: string): Promise<OAuthInfo | undefined> {
82
+ const authMap = await readAuthMap(deps)
83
+ const info = ensureOAuthInfo(authMap?.[providerID])
84
+ if (!info) return undefined
85
+ if (info.expires >= now() + 60_000) return info
86
+
87
+ const existing = oauthRefreshInflight.get(providerID)
88
+ if (existing) return existing
89
+
90
+ const refreshPromise = refreshProviderOAuth(providerID, info, client, runtime).finally(() => {
91
+ oauthRefreshInflight.delete(providerID)
92
+ })
93
+ oauthRefreshInflight.set(providerID, refreshPromise)
94
+ return refreshPromise
95
+ }
96
+
97
+ function credentialFromOAuth(providerID: string, provider?: ProviderInfo): ResolvedCredential {
98
+ return {
99
+ providerID,
100
+ provider,
101
+ apiKey: "",
102
+ fetch: buildOAuthFetch({ ...runtime, providerID, resolveOAuth, packageVersion: deps.packageVersion }),
103
+ mode: "oauth",
104
+ }
105
+ }
106
+
107
+ async function resolve(providerModel: string): Promise<ResolvedCredential> {
108
+ const { providerID } = parseTranslatorModel(providerModel)
109
+ const cached = credentialCache.get(providerID)
110
+ if (cached) return cached
111
+
112
+ const provider = await getProvider(client, providerID)
113
+ if (options.apiKey) {
114
+ const resolved = { providerID, provider, apiKey: options.apiKey, mode: "apiKey" as const }
115
+ credentialCache.set(providerID, resolved)
116
+ return resolved
117
+ }
118
+
119
+ const providerKey = normalizeProviderKey(provider?.key)
120
+ if ((provider?.source === "api" || provider?.source === "env") && providerKey) {
121
+ const resolved = { providerID, provider, apiKey: providerKey, mode: "apiKey" as const }
122
+ credentialCache.set(providerID, resolved)
123
+ return resolved
124
+ }
125
+
126
+ if (provider?.source === "custom" || provider?.key === OAUTH_DUMMY_KEY) {
127
+ const oauthInfo = await resolveOAuth(providerID)
128
+ if (oauthInfo) {
129
+ const resolved = credentialFromOAuth(providerID, provider)
130
+ credentialCache.set(providerID, resolved)
131
+ return resolved
132
+ }
133
+ }
134
+
135
+ if (provider?.key === undefined && (provider?.env.length ?? 0) > 1) {
136
+ const resolved = { providerID, provider, mode: "default" as const }
137
+ credentialCache.set(providerID, resolved)
138
+ return resolved
139
+ }
140
+
141
+ return { providerID, provider, mode: "default" }
142
+ }
143
+
144
+ return {
145
+ resolve,
146
+ authUnavailable: (providerID: string, provider?: ProviderInfo) =>
147
+ buildAuthUnavailableError(providerID, getEnvVarHint(provider)),
148
+ isMissingCredentialError,
149
+ envFallback: AUTH_ENV_FALLBACK,
150
+ }
151
+ }
152
+
153
+ export type { AuthDependencies, ResolvedCredential }
@@ -0,0 +1,100 @@
1
+ import {
2
+ isAnthropicMessagesRequest,
3
+ rewriteMessagesBody,
4
+ rewriteMessagesURL,
5
+ setOAuthHeaders as setAnthropicOAuthHeaders,
6
+ } from "../anthropic-oauth"
7
+ import type { FetchLike, OAuthInfo } from "../constants"
8
+ import { rewriteOpenAICodexBody } from "./codex-request"
9
+ import { convertCodexSSEToJSON } from "./codex-response"
10
+ import { copyHeaders, packageUserAgent, setUserAgent } from "./headers"
11
+ import { exchangeCopilotToken } from "./refresh"
12
+ import type { AuthRuntime, OAuthResolver } from "./types"
13
+
14
+ interface OAuthFetchOptions extends AuthRuntime {
15
+ providerID: string
16
+ resolveOAuth: OAuthResolver
17
+ packageVersion?: string
18
+ }
19
+
20
+ interface RequestState {
21
+ headers: Headers
22
+ inputUrl: URL
23
+ body: BodyInit | null | undefined
24
+ convertCodexResponse: boolean
25
+ }
26
+
27
+ function applyAnthropicRequest(state: RequestState, info: OAuthInfo) {
28
+ setAnthropicOAuthHeaders(state.headers, info.access)
29
+ state.headers.set("anthropic-version", "2023-06-01")
30
+ rewriteMessagesURL(state.inputUrl)
31
+ if (isAnthropicMessagesRequest(state.inputUrl) && typeof state.body === "string") {
32
+ state.body = rewriteMessagesBody(state.body)
33
+ }
34
+ }
35
+
36
+ function applyOpenAIRequest(state: RequestState, info: OAuthInfo) {
37
+ state.headers.set("Authorization", `Bearer ${info.access}`)
38
+ if (info.accountId) state.headers.set("ChatGPT-Account-Id", info.accountId)
39
+ if (
40
+ state.inputUrl.hostname !== "api.openai.com" ||
41
+ (state.inputUrl.pathname !== "/v1/chat/completions" && state.inputUrl.pathname !== "/v1/responses")
42
+ ) {
43
+ return
44
+ }
45
+
46
+ const rewritten = rewriteOpenAICodexBody(state.body)
47
+ state.inputUrl.protocol = "https:"
48
+ state.inputUrl.hostname = "chatgpt.com"
49
+ state.inputUrl.pathname = "/backend-api/codex/responses"
50
+ state.inputUrl.search = ""
51
+ state.body = rewritten.body
52
+ state.convertCodexResponse = !rewritten.originalStream
53
+ state.headers.set("OpenAI-Beta", "responses=experimental")
54
+ state.headers.set("originator", "codex_cli_rs")
55
+ state.headers.set("accept", "text/event-stream")
56
+ state.headers.delete("content-length")
57
+ }
58
+
59
+ async function applyCopilotRequest(state: RequestState, info: OAuthInfo, options: OAuthFetchOptions) {
60
+ const session = await exchangeCopilotToken(info, options)
61
+ state.headers.set("Authorization", `Bearer ${session.token}`)
62
+ state.headers.set("Editor-Version", packageUserAgent(options.packageVersion))
63
+ state.headers.set("Editor-Plugin-Version", packageUserAgent(options.packageVersion))
64
+ state.headers.set("Copilot-Integration-Id", "vscode-chat")
65
+ state.headers.delete("x-api-key")
66
+
67
+ if (info.enterpriseUrl) {
68
+ const target = new URL(info.enterpriseUrl.includes("://") ? info.enterpriseUrl : `https://${info.enterpriseUrl}`)
69
+ state.inputUrl.protocol = target.protocol
70
+ state.inputUrl.hostname = target.hostname
71
+ state.inputUrl.port = target.port
72
+ }
73
+ }
74
+
75
+ function inputToURL(input: RequestInfo | URL): URL {
76
+ return input instanceof URL ? new URL(input.href) : new URL(typeof input === "string" ? input : input.url)
77
+ }
78
+
79
+ export function buildOAuthFetch(options: OAuthFetchOptions): FetchLike {
80
+ return async (input, init) => {
81
+ const info = await options.resolveOAuth(options.providerID)
82
+ if (!info) return options.fetchImpl(input, init)
83
+
84
+ const state: RequestState = {
85
+ headers: copyHeaders(init?.headers),
86
+ inputUrl: inputToURL(input),
87
+ body: init?.body,
88
+ convertCodexResponse: false,
89
+ }
90
+ setUserAgent(state.headers, options.packageVersion)
91
+
92
+ if (options.providerID === "anthropic") applyAnthropicRequest(state, info)
93
+ if (options.providerID === "openai") applyOpenAIRequest(state, info)
94
+ if (options.providerID === "github-copilot") await applyCopilotRequest(state, info, options)
95
+
96
+ const response = await options.fetchImpl(state.inputUrl, { ...init, headers: state.headers, body: state.body })
97
+ if (state.convertCodexResponse && response.ok) return convertCodexSSEToJSON(response)
98
+ return response
99
+ }
100
+ }
@@ -0,0 +1,102 @@
1
+ import { buildOAuthRefreshError, normalizeReason, type OAuthInfo } from "../constants"
2
+ import { withRetry } from "./retry"
3
+ import type { AuthRuntime } from "./types"
4
+
5
+ async function postOAuthToken(url: string, init: RequestInit, deps: AuthRuntime): Promise<Response> {
6
+ return withRetry(
7
+ () =>
8
+ deps.fetchImpl(url, init).then(async (result) => {
9
+ if (!result.ok) {
10
+ const error = new Error(`HTTP ${result.status}`) as Error & { response?: Response; status?: number }
11
+ error.response = result
12
+ error.status = result.status
13
+ throw error
14
+ }
15
+ return result
16
+ }),
17
+ deps,
18
+ )
19
+ }
20
+
21
+ export async function refreshAnthropic(info: OAuthInfo, deps: AuthRuntime): Promise<OAuthInfo> {
22
+ const response = await postOAuthToken(
23
+ "https://console.anthropic.com/v1/oauth/token",
24
+ {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({
28
+ grant_type: "refresh_token",
29
+ refresh_token: info.refresh,
30
+ client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
31
+ }),
32
+ },
33
+ deps,
34
+ )
35
+
36
+ let body: Record<string, unknown>
37
+ try {
38
+ body = (await response.json()) as Record<string, unknown>
39
+ } catch (error) {
40
+ throw buildOAuthRefreshError("anthropic", normalizeReason(error))
41
+ }
42
+
43
+ if (typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
44
+ throw buildOAuthRefreshError("anthropic", "Invalid token response")
45
+ }
46
+
47
+ return {
48
+ type: "oauth",
49
+ access: body.access_token,
50
+ refresh: body.refresh_token,
51
+ expires: Date.now() + (typeof body.expires_in === "number" ? body.expires_in : 3600) * 1000,
52
+ accountId: info.accountId,
53
+ enterpriseUrl: info.enterpriseUrl,
54
+ }
55
+ }
56
+
57
+ export async function refreshOpenAI(info: OAuthInfo, deps: AuthRuntime): Promise<OAuthInfo> {
58
+ const response = await postOAuthToken(
59
+ "https://auth.openai.com/oauth/token",
60
+ {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
63
+ body: new URLSearchParams({
64
+ grant_type: "refresh_token",
65
+ refresh_token: info.refresh,
66
+ client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
67
+ }),
68
+ },
69
+ deps,
70
+ )
71
+
72
+ let parsed: Record<string, unknown>
73
+ try {
74
+ parsed = (await response.json()) as Record<string, unknown>
75
+ } catch (error) {
76
+ throw buildOAuthRefreshError("openai", normalizeReason(error))
77
+ }
78
+
79
+ if (typeof parsed.access_token !== "string" || typeof parsed.refresh_token !== "string") {
80
+ throw buildOAuthRefreshError("openai", "Invalid token response")
81
+ }
82
+
83
+ return {
84
+ type: "oauth",
85
+ access: parsed.access_token,
86
+ refresh: parsed.refresh_token,
87
+ expires: Date.now() + (typeof parsed.expires_in === "number" ? parsed.expires_in : 3600) * 1000,
88
+ accountId: info.accountId,
89
+ enterpriseUrl: info.enterpriseUrl,
90
+ }
91
+ }
92
+
93
+ export async function exchangeCopilotToken(info: OAuthInfo, deps: AuthRuntime): Promise<{ token: string }> {
94
+ const response = await postOAuthToken(
95
+ "https://api.github.com/copilot_internal/v2/token",
96
+ { method: "GET", headers: { Authorization: `token ${info.refresh}` } },
97
+ deps,
98
+ )
99
+ const parsed = (await response.json()) as Record<string, unknown>
100
+ if (typeof parsed.token !== "string") throw buildOAuthRefreshError("github-copilot", "Invalid token response")
101
+ return { token: parsed.token }
102
+ }
@@ -0,0 +1,70 @@
1
+ import { normalizeReason } from "../constants"
2
+ import { headerValue } from "./headers"
3
+ import type { AuthDependencies } from "./types"
4
+
5
+ function getStatus(error: unknown): number | undefined {
6
+ if (!error || typeof error !== "object") return undefined
7
+ const record = error as Record<string, unknown>
8
+ if (typeof record.status === "number") return record.status
9
+ if (typeof record.statusCode === "number") return record.statusCode
10
+ const response = record.response
11
+ if (response && typeof response === "object") {
12
+ const maybeStatus = (response as Record<string, unknown>).status
13
+ if (typeof maybeStatus === "number") return maybeStatus
14
+ }
15
+ return undefined
16
+ }
17
+
18
+ function getRetryAfterMs(error: unknown): number {
19
+ if (!error || typeof error !== "object") return 2000
20
+ const response = (error as Record<string, unknown>).response
21
+ if (response && typeof response === "object") {
22
+ const headers = (response as { headers?: Headers }).headers
23
+ if (headers instanceof Headers) {
24
+ const retryAfter = headerValue(headers, "retry-after")
25
+ if (!retryAfter) return 2000
26
+ const seconds = Number(retryAfter)
27
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
28
+ const date = Date.parse(retryAfter)
29
+ if (Number.isFinite(date)) return Math.max(0, date - Date.now())
30
+ }
31
+ }
32
+ return 2000
33
+ }
34
+
35
+ function isRetryableError(error: unknown): boolean {
36
+ const status = getStatus(error)
37
+ if (status === 429) return true
38
+ if (status !== undefined) return status >= 500
39
+ const message = normalizeReason(error).toLowerCase()
40
+ return (
41
+ message.includes("network") ||
42
+ message.includes("fetch") ||
43
+ message.includes("timeout") ||
44
+ message.includes("socket") ||
45
+ message.includes("econn")
46
+ )
47
+ }
48
+
49
+ export async function withRetry<T>(
50
+ task: () => Promise<T>,
51
+ deps: Required<Pick<AuthDependencies, "sleep">>,
52
+ ): Promise<T> {
53
+ let lastError: unknown
54
+ for (let attempt = 0; attempt < 3; attempt += 1) {
55
+ try {
56
+ return await task()
57
+ } catch (error) {
58
+ lastError = error
59
+ if (!isRetryableError(error)) throw error
60
+ if (getStatus(error) === 429) {
61
+ if (attempt >= 1) throw error
62
+ await deps.sleep(getRetryAfterMs(error))
63
+ continue
64
+ }
65
+ if (attempt >= 2) throw error
66
+ await deps.sleep(attempt === 0 ? 500 : 1500)
67
+ }
68
+ }
69
+ throw lastError
70
+ }
@@ -0,0 +1,45 @@
1
+ import { readFile, stat } from "node:fs/promises"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { type AuthInfo, OAUTH_DUMMY_KEY, type OAuthInfo } from "../constants"
5
+ import type { AuthDependencies } from "./types"
6
+
7
+ // Mirrors opencode's xdg-basedir auth location; see packages/opencode/src/global/index.ts.
8
+ function authFilePath(): string {
9
+ const xdgDataHome = process.env.XDG_DATA_HOME
10
+ if (xdgDataHome) return path.join(xdgDataHome, "opencode", "auth.json")
11
+ if (process.platform === "win32") {
12
+ return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "opencode", "auth.json")
13
+ }
14
+ return path.join(os.homedir(), ".local", "share", "opencode", "auth.json")
15
+ }
16
+
17
+ export function normalizeProviderKey(value: string | undefined): string | undefined {
18
+ if (!value || value === OAUTH_DUMMY_KEY) return undefined
19
+ return value
20
+ }
21
+
22
+ export function ensureOAuthInfo(value: AuthInfo | undefined): OAuthInfo | undefined {
23
+ return value && value.type === "oauth" ? value : undefined
24
+ }
25
+
26
+ export async function readAuthMap(deps: AuthDependencies): Promise<Record<string, AuthInfo> | undefined> {
27
+ if (process.env.OPENCODE_AUTH_CONTENT) {
28
+ try {
29
+ const parsed = JSON.parse(process.env.OPENCODE_AUTH_CONTENT) as Record<string, AuthInfo>
30
+ if (parsed && typeof parsed === "object") return parsed
31
+ } catch {}
32
+ return undefined
33
+ }
34
+
35
+ const filePath = authFilePath()
36
+ try {
37
+ const fileStat = await (deps.stat ?? stat)(filePath)
38
+ if ((fileStat.mode & 0o777) !== 0o600) return undefined
39
+ const raw = await (deps.readFile ?? readFile)(filePath, "utf8")
40
+ const parsed = JSON.parse(raw) as Record<string, AuthInfo>
41
+ return parsed && typeof parsed === "object" ? parsed : undefined
42
+ } catch {
43
+ return undefined
44
+ }
45
+ }