openllmprovider 0.1.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 (91) hide show
  1. package/README.md +192 -0
  2. package/dist/auth/index.cjs +6 -0
  3. package/dist/auth/index.d.cts +3 -0
  4. package/dist/auth/index.d.mts +3 -0
  5. package/dist/auth/index.mjs +3 -0
  6. package/dist/auto-C2hXJY13.d.cts +33 -0
  7. package/dist/auto-C2hXJY13.d.cts.map +1 -0
  8. package/dist/auto-CBqNYBXs.mjs +48 -0
  9. package/dist/auto-CBqNYBXs.mjs.map +1 -0
  10. package/dist/auto-CInerwvs.d.mts +33 -0
  11. package/dist/auto-CInerwvs.d.mts.map +1 -0
  12. package/dist/auto-D77wgMqO.cjs +59 -0
  13. package/dist/auto-D77wgMqO.cjs.map +1 -0
  14. package/dist/file-DB-rxfzi.mjs +77 -0
  15. package/dist/file-DB-rxfzi.mjs.map +1 -0
  16. package/dist/file-DZ7FGcSW.cjs +73 -0
  17. package/dist/file-DZ7FGcSW.cjs.map +1 -0
  18. package/dist/index.cjs +1909 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.cts +1239 -0
  21. package/dist/index.d.cts.map +1 -0
  22. package/dist/index.d.mts +1241 -0
  23. package/dist/index.d.mts.map +1 -0
  24. package/dist/index.mjs +1891 -0
  25. package/dist/index.mjs.map +1 -0
  26. package/dist/logger-BsHpI_fH.mjs +11 -0
  27. package/dist/logger-BsHpI_fH.mjs.map +1 -0
  28. package/dist/logger-jRimlMFR.cjs +69 -0
  29. package/dist/logger-jRimlMFR.cjs.map +1 -0
  30. package/dist/plugin/index.cjs +29 -0
  31. package/dist/plugin/index.cjs.map +1 -0
  32. package/dist/plugin/index.d.cts +10 -0
  33. package/dist/plugin/index.d.cts.map +1 -0
  34. package/dist/plugin/index.d.mts +10 -0
  35. package/dist/plugin/index.d.mts.map +1 -0
  36. package/dist/plugin/index.mjs +25 -0
  37. package/dist/plugin/index.mjs.map +1 -0
  38. package/dist/plugin-BkeUu5LW.d.mts +46 -0
  39. package/dist/plugin-BkeUu5LW.d.mts.map +1 -0
  40. package/dist/plugin-wK7RmJhZ.d.cts +46 -0
  41. package/dist/plugin-wK7RmJhZ.d.cts.map +1 -0
  42. package/dist/resolver-BA7LWSJO.mjs +645 -0
  43. package/dist/resolver-BA7LWSJO.mjs.map +1 -0
  44. package/dist/resolver-BMTvzTt9.cjs +662 -0
  45. package/dist/resolver-BMTvzTt9.cjs.map +1 -0
  46. package/dist/resolver-MgJryMWG.d.cts +75 -0
  47. package/dist/resolver-MgJryMWG.d.cts.map +1 -0
  48. package/dist/resolver-_gfXzr_S.d.mts +76 -0
  49. package/dist/resolver-_gfXzr_S.d.mts.map +1 -0
  50. package/dist/storage/index.cjs +7 -0
  51. package/dist/storage/index.d.cts +12 -0
  52. package/dist/storage/index.d.cts.map +1 -0
  53. package/dist/storage/index.d.mts +12 -0
  54. package/dist/storage/index.d.mts.map +1 -0
  55. package/dist/storage/index.mjs +4 -0
  56. package/package.json +137 -0
  57. package/src/auth/.gitkeep +0 -0
  58. package/src/auth/index.ts +10 -0
  59. package/src/auth/resolver.ts +46 -0
  60. package/src/auth/scanners.ts +462 -0
  61. package/src/auth/store.ts +357 -0
  62. package/src/catalog/.gitkeep +0 -0
  63. package/src/catalog/catalog.ts +302 -0
  64. package/src/catalog/index.ts +17 -0
  65. package/src/catalog/mapper.ts +129 -0
  66. package/src/catalog/merger.ts +99 -0
  67. package/src/index.ts +37 -0
  68. package/src/logger.ts +7 -0
  69. package/src/plugin/.gitkeep +0 -0
  70. package/src/plugin/anthropic.test.ts +505 -0
  71. package/src/plugin/anthropic.ts +324 -0
  72. package/src/plugin/codex.ts +656 -0
  73. package/src/plugin/copilot.ts +161 -0
  74. package/src/plugin/google.ts +454 -0
  75. package/src/plugin/index.ts +30 -0
  76. package/src/provider/.gitkeep +0 -0
  77. package/src/provider/bundled.ts +59 -0
  78. package/src/provider/index.ts +249 -0
  79. package/src/provider/state.ts +163 -0
  80. package/src/storage/.gitkeep +0 -0
  81. package/src/storage/auto.ts +32 -0
  82. package/src/storage/file.ts +84 -0
  83. package/src/storage/index.ts +10 -0
  84. package/src/storage/memory.ts +23 -0
  85. package/src/types/.gitkeep +0 -0
  86. package/src/types/auth.ts +18 -0
  87. package/src/types/errors.ts +87 -0
  88. package/src/types/index.ts +26 -0
  89. package/src/types/model.ts +88 -0
  90. package/src/types/plugin.ts +49 -0
  91. package/src/types/provider.ts +48 -0
@@ -0,0 +1,161 @@
1
+ import { createLogger } from '../logger.js'
2
+ import type { AuthCredential, AuthHook } from '../types/plugin.js'
3
+
4
+ const log = createLogger('plugin:copilot')
5
+
6
+ const GITHUB_CLIENT_ID = 'Ov23li8tweQw6odWQebz'
7
+ const GITHUB_DEFAULT_DOMAIN = 'github.com'
8
+
9
+ function normalizeDomain(url: string): string {
10
+ return url.replace(/^https?:\/\//, '').replace(/\/$/, '')
11
+ }
12
+
13
+ function getUrls(domain: string): { deviceCodeUrl: string; accessTokenUrl: string } {
14
+ return {
15
+ deviceCodeUrl: `https://${domain}/login/device/code`,
16
+ accessTokenUrl: `https://${domain}/login/oauth/access_token`,
17
+ }
18
+ }
19
+
20
+ function resolveEnterpriseDomainFromEnv(): string {
21
+ const raw = process.env.OPENLLMPROVIDER_COPILOT_ENTERPRISE_URL?.trim()
22
+ if (!raw) {
23
+ throw new Error(
24
+ 'Missing OPENLLMPROVIDER_COPILOT_ENTERPRISE_URL. Example: github.company.com or https://github.company.com'
25
+ )
26
+ }
27
+ return normalizeDomain(raw)
28
+ }
29
+
30
+ async function runDeviceFlow(domain: string): Promise<AuthCredential> {
31
+ const urls = getUrls(domain)
32
+ const deviceRes = await globalThis.fetch(urls.deviceCodeUrl, {
33
+ method: 'POST',
34
+ headers: {
35
+ 'Content-Type': 'application/json',
36
+ Accept: 'application/json',
37
+ },
38
+ body: JSON.stringify({
39
+ client_id: GITHUB_CLIENT_ID,
40
+ scope: 'read:user',
41
+ }),
42
+ })
43
+
44
+ if (!deviceRes.ok) {
45
+ const body = await deviceRes.text()
46
+ throw new Error(`Device flow init failed: ${deviceRes.status} ${deviceRes.statusText} ${body}`)
47
+ }
48
+
49
+ const deviceData = (await deviceRes.json()) as {
50
+ device_code: string
51
+ user_code: string
52
+ verification_uri: string
53
+ interval: number
54
+ }
55
+
56
+ log(
57
+ 'device flow: domain=%s user_code=%s verification_uri=%s',
58
+ domain,
59
+ deviceData.user_code,
60
+ deviceData.verification_uri
61
+ )
62
+ const token = await pollForToken(urls.accessTokenUrl, deviceData.device_code, deviceData.interval)
63
+
64
+ return {
65
+ type: 'oauth',
66
+ refresh: token,
67
+ key: token,
68
+ expires: 0,
69
+ ...(domain !== GITHUB_DEFAULT_DOMAIN ? { enterpriseUrl: domain } : {}),
70
+ }
71
+ }
72
+
73
+ export const copilotPlugin: AuthHook = {
74
+ provider: 'github-copilot',
75
+
76
+ async loader(getAuth, _provider) {
77
+ const auth = await getAuth()
78
+ const enterpriseUrl = typeof auth.enterpriseUrl === 'string' ? auth.enterpriseUrl : undefined
79
+ const baseURL = enterpriseUrl
80
+ ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
81
+ : 'https://api.githubcopilot.com'
82
+
83
+ return {
84
+ baseURL,
85
+ apiKey: '',
86
+ async fetch(...[request, init]: Parameters<typeof globalThis.fetch>): ReturnType<typeof globalThis.fetch> {
87
+ const auth = await getAuth()
88
+ const headers = new Headers(init?.headers)
89
+ // Remove SDK-set auth headers
90
+ headers.delete('x-api-key')
91
+ headers.delete('Authorization')
92
+ // Set copilot-specific auth
93
+ headers.set('Authorization', `Bearer ${auth.refresh ?? auth.key ?? ''}`)
94
+ headers.set('Openai-Intent', 'conversation-edits')
95
+ log('copilot fetch: injecting auth headers')
96
+ return globalThis.fetch(request, { ...init, headers })
97
+ },
98
+ }
99
+ },
100
+
101
+ methods: [
102
+ {
103
+ type: 'oauth',
104
+ label: 'GitHub Copilot (GitHub.com)',
105
+ async handler(): Promise<AuthCredential> {
106
+ log('starting github.com device flow')
107
+ return runDeviceFlow(GITHUB_DEFAULT_DOMAIN)
108
+ },
109
+ },
110
+ {
111
+ type: 'device-flow',
112
+ label: 'GitHub Copilot Enterprise (Device Flow)',
113
+ async handler(): Promise<AuthCredential> {
114
+ log('starting enterprise device flow')
115
+ const domain = resolveEnterpriseDomainFromEnv()
116
+ return runDeviceFlow(domain)
117
+ },
118
+ },
119
+ ],
120
+ }
121
+
122
+ async function pollForToken(accessTokenUrl: string, deviceCode: string, interval: number): Promise<string> {
123
+ const pollInterval = Math.max(interval, 5) * 1000
124
+
125
+ while (true) {
126
+ await new Promise<void>((resolve) => setTimeout(resolve, pollInterval))
127
+
128
+ const res = await globalThis.fetch(accessTokenUrl, {
129
+ method: 'POST',
130
+ headers: {
131
+ 'Content-Type': 'application/json',
132
+ Accept: 'application/json',
133
+ },
134
+ body: JSON.stringify({
135
+ client_id: GITHUB_CLIENT_ID,
136
+ device_code: deviceCode,
137
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
138
+ }),
139
+ })
140
+
141
+ const data = (await res.json()) as { access_token?: string; error?: string }
142
+
143
+ if (data.access_token) {
144
+ log('device flow: token obtained')
145
+ return data.access_token
146
+ }
147
+
148
+ if (data.error === 'authorization_pending') {
149
+ log('device flow: waiting for user authorization...')
150
+ continue
151
+ }
152
+
153
+ if (data.error === 'slow_down') {
154
+ log('device flow: slowing down')
155
+ await new Promise<void>((resolve) => setTimeout(resolve, 5000))
156
+ continue
157
+ }
158
+
159
+ throw new Error(`Device flow failed: ${data.error ?? 'unknown error'}`)
160
+ }
161
+ }
@@ -0,0 +1,454 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
3
+ import { stdin as input, stdout as output } from 'node:process'
4
+ import { createInterface } from 'node:readline/promises'
5
+ import { createLogger } from '../logger.js'
6
+ import type { AuthCredential, AuthHook, ProviderInfo } from '../types/plugin.js'
7
+
8
+ const log = createLogger('plugin:google')
9
+
10
+ const GEMINI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
11
+ const GEMINI_CLIENT_SECRET = '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'
12
+ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
13
+ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
14
+ const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json'
15
+ const GEMINI_REDIRECT_URI = 'http://localhost:8085/oauth2callback'
16
+ const GEMINI_SCOPES = [
17
+ 'https://www.googleapis.com/auth/cloud-platform',
18
+ 'https://www.googleapis.com/auth/userinfo.email',
19
+ 'https://www.googleapis.com/auth/userinfo.profile',
20
+ ]
21
+
22
+ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
23
+ const CODE_ASSIST_HEADERS = {
24
+ 'User-Agent': 'google-api-nodejs-client/9.15.1',
25
+ 'X-Goog-Api-Client': 'gl-node/22.17.0',
26
+ 'Client-Metadata': 'ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI',
27
+ }
28
+
29
+ interface TokenResponse {
30
+ access_token: string
31
+ refresh_token?: string
32
+ expires_in: number
33
+ token_type: string
34
+ }
35
+
36
+ function resolveProjectId(): string {
37
+ const explicit = process.env.OPENLLMPROVIDER_GOOGLE_PROJECT_ID?.trim()
38
+ if (explicit) return explicit
39
+ const gcp = process.env.GOOGLE_CLOUD_PROJECT?.trim() ?? process.env.GOOGLE_CLOUD_PROJECT_ID?.trim()
40
+ if (gcp) return gcp
41
+ return ''
42
+ }
43
+
44
+ async function refreshGoogleToken(refreshToken: string): Promise<TokenResponse> {
45
+ log('refreshing Google OAuth token')
46
+
47
+ const body = new URLSearchParams({
48
+ grant_type: 'refresh_token',
49
+ refresh_token: refreshToken,
50
+ client_id: GEMINI_CLIENT_ID,
51
+ client_secret: GEMINI_CLIENT_SECRET,
52
+ })
53
+
54
+ const res = await globalThis.fetch(GOOGLE_TOKEN_URL, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
57
+ body: body.toString(),
58
+ })
59
+
60
+ if (!res.ok) {
61
+ throw new Error(`Google token refresh failed: ${res.status} ${res.statusText}`)
62
+ }
63
+
64
+ const raw = (await res.json()) as Record<string, unknown>
65
+ return {
66
+ access_token: String(raw.access_token ?? ''),
67
+ refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
68
+ expires_in: Number(raw.expires_in ?? 3600),
69
+ token_type: String(raw.token_type ?? 'Bearer'),
70
+ }
71
+ }
72
+
73
+ function toBase64Url(inputValue: Buffer): string {
74
+ return inputValue.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
75
+ }
76
+
77
+ function createPkce(): { verifier: string; challenge: string } {
78
+ const verifier = toBase64Url(randomBytes(32))
79
+ const challenge = toBase64Url(createHash('sha256').update(verifier).digest())
80
+ return { verifier, challenge }
81
+ }
82
+
83
+ function buildAuthorizationRequest(): { url: string; verifier: string; state: string } {
84
+ const { verifier, challenge } = createPkce()
85
+ const state = toBase64Url(randomBytes(24))
86
+
87
+ const url = new URL(GOOGLE_AUTH_URL)
88
+ url.searchParams.set('client_id', GEMINI_CLIENT_ID)
89
+ url.searchParams.set('response_type', 'code')
90
+ url.searchParams.set('redirect_uri', GEMINI_REDIRECT_URI)
91
+ url.searchParams.set('scope', GEMINI_SCOPES.join(' '))
92
+ url.searchParams.set('code_challenge', challenge)
93
+ url.searchParams.set('code_challenge_method', 'S256')
94
+ url.searchParams.set('state', state)
95
+ url.searchParams.set('access_type', 'offline')
96
+ url.searchParams.set('prompt', 'consent')
97
+
98
+ return { url: url.toString(), verifier, state }
99
+ }
100
+
101
+ async function readCallbackInput(question: string): Promise<string> {
102
+ const rl = createInterface({ input, output })
103
+ try {
104
+ return (await rl.question(question)).trim()
105
+ } finally {
106
+ rl.close()
107
+ }
108
+ }
109
+
110
+ function parseCallbackInput(value: string): { code?: string; state?: string } {
111
+ const raw = value.trim()
112
+ if (raw.length === 0) return {}
113
+
114
+ if (/^https?:\/\//i.test(raw)) {
115
+ try {
116
+ const u = new URL(raw)
117
+ return {
118
+ code: u.searchParams.get('code') ?? undefined,
119
+ state: u.searchParams.get('state') ?? undefined,
120
+ }
121
+ } catch {
122
+ return {}
123
+ }
124
+ }
125
+
126
+ const maybeQuery = raw.startsWith('?') ? raw.slice(1) : raw
127
+ if (maybeQuery.includes('=')) {
128
+ const params = new URLSearchParams(maybeQuery)
129
+ const code = params.get('code') ?? undefined
130
+ const state = params.get('state') ?? undefined
131
+ if (code !== undefined || state !== undefined) {
132
+ return { code, state }
133
+ }
134
+ }
135
+
136
+ return { code: raw }
137
+ }
138
+
139
+ function openUrlInBrowser(url: string): void {
140
+ const platform = process.platform
141
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32' : 'xdg-open'
142
+ const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]
143
+ try {
144
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' })
145
+ child.unref()
146
+ } catch {
147
+ log('failed to open browser automatically')
148
+ }
149
+ }
150
+
151
+ async function exchangeAuthorizationCode(code: string, verifier: string): Promise<TokenResponse> {
152
+ const body = new URLSearchParams({
153
+ client_id: GEMINI_CLIENT_ID,
154
+ client_secret: GEMINI_CLIENT_SECRET,
155
+ code,
156
+ grant_type: 'authorization_code',
157
+ redirect_uri: GEMINI_REDIRECT_URI,
158
+ code_verifier: verifier,
159
+ })
160
+
161
+ const res = await globalThis.fetch(GOOGLE_TOKEN_URL, {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
164
+ body: body.toString(),
165
+ })
166
+
167
+ if (!res.ok) {
168
+ const bodyText = await res.text()
169
+ throw new Error(`Google token exchange failed: ${res.status} ${res.statusText} ${bodyText}`)
170
+ }
171
+
172
+ const raw = (await res.json()) as Record<string, unknown>
173
+ return {
174
+ access_token: String(raw.access_token ?? ''),
175
+ refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
176
+ expires_in: Number(raw.expires_in ?? 3600),
177
+ token_type: String(raw.token_type ?? 'Bearer'),
178
+ }
179
+ }
180
+
181
+ async function fetchGoogleEmail(accessToken: string): Promise<string | undefined> {
182
+ const res = await globalThis.fetch(GOOGLE_USERINFO_URL, {
183
+ headers: { Authorization: `Bearer ${accessToken}` },
184
+ })
185
+ if (!res.ok) return undefined
186
+ const raw = (await res.json()) as Record<string, unknown>
187
+ return typeof raw.email === 'string' ? raw.email : undefined
188
+ }
189
+
190
+ type FetchInput = Parameters<typeof globalThis.fetch>[0]
191
+ type FetchInit = Parameters<typeof globalThis.fetch>[1]
192
+ type FetchBody = NonNullable<FetchInit>['body']
193
+
194
+ function toRequestUrlString(value: FetchInput | URL): string {
195
+ if (typeof value === 'string') return value
196
+ if (value instanceof URL) return value.toString()
197
+ return value.url
198
+ }
199
+
200
+ function parseGenerativeAction(
201
+ input: FetchInput | URL
202
+ ): { model: string; action: string; streaming: boolean } | undefined {
203
+ const url = toRequestUrlString(input)
204
+ const match = url.match(/\/models\/([^:]+):(\w+)/)
205
+ if (!match) return undefined
206
+ const model = match[1] ?? ''
207
+ const action = match[2] ?? ''
208
+ if (!model || !action) return undefined
209
+ return { model, action, streaming: action === 'streamGenerateContent' }
210
+ }
211
+
212
+ function buildCodeAssistUrl(action: string, streaming: boolean): string {
213
+ return `${CODE_ASSIST_ENDPOINT}/v1internal:${action}${streaming ? '?alt=sse' : ''}`
214
+ }
215
+
216
+ function rewriteRequestBody(body: FetchBody, projectId: string, model: string): FetchBody {
217
+ if (typeof body !== 'string' || body.length === 0) return body
218
+ try {
219
+ const parsed = JSON.parse(body) as Record<string, unknown>
220
+ if (typeof parsed.project === 'string' && parsed.request !== undefined) {
221
+ const wrapped = { ...parsed, model }
222
+ return JSON.stringify(wrapped)
223
+ }
224
+ const { model: _ignored, ...requestPayload } = parsed
225
+ const userPromptId = randomUUID()
226
+ const wrapped = {
227
+ project: projectId,
228
+ model,
229
+ user_prompt_id: userPromptId,
230
+ request: requestPayload,
231
+ }
232
+ return JSON.stringify(wrapped)
233
+ } catch {
234
+ return body
235
+ }
236
+ }
237
+
238
+ function rewriteStreamingLine(line: string): string {
239
+ if (!line.startsWith('data:')) return line
240
+ const payload = line.slice(5).trim()
241
+ if (!payload || payload === '[DONE]') return line
242
+ try {
243
+ const parsed = JSON.parse(payload) as Record<string, unknown>
244
+ const response = parsed.response
245
+ if (response !== undefined) {
246
+ return `data: ${JSON.stringify(response)}`
247
+ }
248
+ return line
249
+ } catch {
250
+ return line
251
+ }
252
+ }
253
+
254
+ function rewriteStreamingBody(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
255
+ const decoder = new TextDecoder()
256
+ const encoder = new TextEncoder()
257
+ let buffer = ''
258
+
259
+ return new ReadableStream<Uint8Array>({
260
+ async start(controller) {
261
+ const reader = stream.getReader()
262
+ try {
263
+ while (true) {
264
+ const { done, value } = await reader.read()
265
+ if (done) break
266
+ buffer += decoder.decode(value, { stream: true })
267
+ let idx = buffer.indexOf('\n')
268
+ while (idx !== -1) {
269
+ const line = buffer.slice(0, idx)
270
+ buffer = buffer.slice(idx + 1)
271
+ controller.enqueue(encoder.encode(`${rewriteStreamingLine(line)}\n`))
272
+ idx = buffer.indexOf('\n')
273
+ }
274
+ }
275
+ buffer += decoder.decode()
276
+ if (buffer.length > 0) {
277
+ controller.enqueue(encoder.encode(rewriteStreamingLine(buffer)))
278
+ }
279
+ controller.close()
280
+ } catch (error) {
281
+ controller.error(error)
282
+ } finally {
283
+ reader.releaseLock()
284
+ }
285
+ },
286
+ })
287
+ }
288
+
289
+ async function normalizeCodeAssistResponse(response: Response, streaming: boolean): Promise<Response> {
290
+ const contentType = response.headers.get('content-type') ?? ''
291
+
292
+ if (streaming && response.ok && contentType.includes('text/event-stream') && response.body) {
293
+ return new Response(rewriteStreamingBody(response.body), {
294
+ status: response.status,
295
+ statusText: response.statusText,
296
+ headers: new Headers(response.headers),
297
+ })
298
+ }
299
+
300
+ if (!contentType.includes('application/json')) {
301
+ return response
302
+ }
303
+
304
+ const text = await response.text()
305
+ try {
306
+ const parsed = JSON.parse(text) as Record<string, unknown>
307
+ const next = parsed.response
308
+ if (next !== undefined) {
309
+ return new Response(JSON.stringify(next), {
310
+ status: response.status,
311
+ statusText: response.statusText,
312
+ headers: new Headers(response.headers),
313
+ })
314
+ }
315
+ return new Response(text, {
316
+ status: response.status,
317
+ statusText: response.statusText,
318
+ headers: new Headers(response.headers),
319
+ })
320
+ } catch {
321
+ return new Response(text, {
322
+ status: response.status,
323
+ statusText: response.statusText,
324
+ headers: new Headers(response.headers),
325
+ })
326
+ }
327
+ }
328
+
329
+ export const googlePlugin: AuthHook = {
330
+ provider: 'google',
331
+
332
+ async loader(getAuth: () => Promise<AuthCredential>, _provider: ProviderInfo, setAuth: (credential: AuthCredential) => Promise<void>): Promise<Record<string, unknown>> {
333
+ const initialAuth = await getAuth()
334
+
335
+ if (initialAuth.type !== 'oauth') {
336
+ log('google loader: skipping (type=%s)', initialAuth.type)
337
+ return {}
338
+ }
339
+
340
+ log('google loader: activating OAuth fetch wrapper')
341
+ return {
342
+ apiKey: 'google-oauth-placeholder',
343
+ async fetch(
344
+ inputValue: Parameters<typeof globalThis.fetch>[0],
345
+ init?: Parameters<typeof globalThis.fetch>[1]
346
+ ): Promise<Response> {
347
+ const currentAuth = await getAuth()
348
+ if (currentAuth.type !== 'oauth') {
349
+ return globalThis.fetch(inputValue, init)
350
+ }
351
+ let currentToken = currentAuth.key ?? ''
352
+ if (
353
+ currentAuth.expires !== undefined &&
354
+ currentAuth.expires < Date.now() &&
355
+ typeof currentAuth.refresh === 'string'
356
+ ) {
357
+ log('token expired, attempting refresh...')
358
+ try {
359
+ const tokens = await refreshGoogleToken(currentAuth.refresh)
360
+ currentToken = tokens.access_token
361
+ const updated: AuthCredential = {
362
+ ...currentAuth,
363
+ key: tokens.access_token,
364
+ refresh: tokens.refresh_token ?? currentAuth.refresh,
365
+ expires: Date.now() + tokens.expires_in * 1000,
366
+ }
367
+ await setAuth(updated).catch((err) =>
368
+ log('failed to persist refreshed credential: %s', err instanceof Error ? err.message : String(err)),
369
+ )
370
+ log('token refreshed successfully, expires in %ds', tokens.expires_in)
371
+ } catch (err: unknown) {
372
+ log('token refresh failed: %s', err instanceof Error ? err.message : String(err))
373
+ }
374
+ }
375
+
376
+ const rewritten = parseGenerativeAction(inputValue)
377
+ if (!rewritten) {
378
+ const headers = new Headers(init?.headers)
379
+ headers.delete('x-goog-api-key')
380
+ headers.delete('x-api-key')
381
+ headers.set('Authorization', `Bearer ${currentToken}`)
382
+ return globalThis.fetch(inputValue, { ...init, headers })
383
+ }
384
+
385
+ const projectId = resolveProjectId()
386
+ if (!projectId) {
387
+ throw new Error(
388
+ 'Google OAuth via Code Assist requires project id. Set OPENLLMPROVIDER_GOOGLE_PROJECT_ID (or GOOGLE_CLOUD_PROJECT).'
389
+ )
390
+ }
391
+
392
+ const headers = new Headers(init?.headers)
393
+ headers.delete('x-goog-api-key')
394
+ headers.delete('x-api-key')
395
+ headers.set('Authorization', `Bearer ${currentToken}`)
396
+ headers.set('User-Agent', CODE_ASSIST_HEADERS['User-Agent'])
397
+ headers.set('X-Goog-Api-Client', CODE_ASSIST_HEADERS['X-Goog-Api-Client'])
398
+ headers.set('Client-Metadata', CODE_ASSIST_HEADERS['Client-Metadata'])
399
+ headers.set('x-activity-request-id', randomUUID())
400
+ if (rewritten.streaming) {
401
+ headers.set('Accept', 'text/event-stream')
402
+ }
403
+
404
+ const requestUrl = buildCodeAssistUrl(rewritten.action, rewritten.streaming)
405
+ const body = rewriteRequestBody(init?.body, projectId, rewritten.model)
406
+ const response = await globalThis.fetch(requestUrl, {
407
+ ...init,
408
+ headers,
409
+ body,
410
+ })
411
+
412
+ return normalizeCodeAssistResponse(response, rewritten.streaming)
413
+ },
414
+ }
415
+ },
416
+
417
+ methods: [
418
+ {
419
+ type: 'oauth',
420
+ label: 'Google OAuth (Gemini)',
421
+ async handler(): Promise<AuthCredential> {
422
+ const authRequest = buildAuthorizationRequest()
423
+
424
+ console.log('Open this URL to continue Google OAuth:')
425
+ console.log(authRequest.url)
426
+ openUrlInBrowser(authRequest.url)
427
+
428
+ const callbackInput = await readCallbackInput('Paste the callback URL or authorization code: ')
429
+ const parsed = parseCallbackInput(callbackInput)
430
+ if (!parsed.code) {
431
+ throw new Error('Missing authorization code in callback input')
432
+ }
433
+ if (parsed.state !== undefined && parsed.state !== authRequest.state) {
434
+ throw new Error('OAuth state mismatch')
435
+ }
436
+
437
+ const tokens = await exchangeAuthorizationCode(parsed.code, authRequest.verifier)
438
+ if (!tokens.refresh_token) {
439
+ throw new Error('Google OAuth did not return a refresh token; retry and grant consent')
440
+ }
441
+
442
+ const email = await fetchGoogleEmail(tokens.access_token)
443
+
444
+ return {
445
+ type: 'oauth',
446
+ key: tokens.access_token,
447
+ refresh: tokens.refresh_token,
448
+ expires: Date.now() + tokens.expires_in * 1000,
449
+ email,
450
+ }
451
+ },
452
+ },
453
+ ],
454
+ }
@@ -0,0 +1,30 @@
1
+ import { createLogger } from '../logger.js'
2
+ import type { AuthCredential, AuthHook, ProviderInfo } from '../types/plugin.js'
3
+
4
+ const log = createLogger('plugin')
5
+ const plugins: Map<string, AuthHook> = new Map()
6
+
7
+ export function registerPlugin(plugin: AuthHook): void {
8
+ log('registering plugin for provider: %s', plugin.provider)
9
+ plugins.set(plugin.provider, plugin)
10
+ }
11
+
12
+ export function getPlugins(): AuthHook[] {
13
+ return [...plugins.values()]
14
+ }
15
+
16
+ export function getPluginForProvider(providerId: string): AuthHook | undefined {
17
+ return plugins.get(providerId)
18
+ }
19
+
20
+ export async function loadPluginOptions(
21
+ providerId: string,
22
+ getAuth: () => Promise<AuthCredential>,
23
+ providerInfo: ProviderInfo,
24
+ setAuth: (credential: AuthCredential) => Promise<void>,
25
+ ): Promise<Record<string, unknown> | undefined> {
26
+ const plugin = plugins.get(providerId)
27
+ if (!plugin) return undefined
28
+ log('loading plugin options for provider: %s', providerId)
29
+ return plugin.loader(getAuth, providerInfo, setAuth)
30
+ }
File without changes