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.
- package/README.md +11 -11
- package/package.json +7 -4
- 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 +41 -0
- package/src/activation/parts.ts +53 -0
- package/src/activation/question-hooks.ts +95 -0
- package/src/activation/state.ts +97 -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 -633
- 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 +33 -0
- package/src/constants/options.ts +41 -0
- package/src/constants/plugin.ts +10 -0
- package/src/constants/types.ts +144 -0
- package/src/constants.ts +5 -261
- package/src/labels.ts +2 -16
- package/src/prompts.ts +2 -17
- package/src/question-tool.ts +1 -1
- 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
|
@@ -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,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
|
+
}
|