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,324 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createHash, randomBytes } 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 } from '../types/plugin.js'
7
+
8
+ const log = createLogger('plugin:anthropic')
9
+
10
+ const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
11
+ const AUTHORIZE_URL = 'https://claude.ai/oauth/authorize'
12
+ const TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token'
13
+ const API_KEY_EXCHANGE_URL = 'https://api.anthropic.com/api/oauth/claude_cli/create_api_key'
14
+ const REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback'
15
+ const OAUTH_SCOPES = 'org:create_api_key user:profile user:inference'
16
+ const OAUTH_BETA = 'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14'
17
+
18
+ interface TokenResponse {
19
+ access_token: string
20
+ refresh_token?: string
21
+ expires_in?: number
22
+ }
23
+
24
+ interface CreateApiKeyResponse {
25
+ raw_key?: string
26
+ }
27
+
28
+ function isOAuthToken(value: string): boolean {
29
+ return value.startsWith('sk-ant-oat') || value.startsWith('sk-ant-ort')
30
+ }
31
+
32
+ function looksLikeApiKey(value: string): boolean {
33
+ if (isOAuthToken(value)) return false
34
+ return value.startsWith('sk-ant-')
35
+ }
36
+
37
+ function applyBearerHeaders(headers: Headers, token: string): void {
38
+ headers.delete('x-api-key')
39
+ headers.delete('authorization')
40
+ headers.delete('Authorization')
41
+ headers.set('Authorization', `Bearer ${token}`)
42
+ headers.set('anthropic-beta', OAUTH_BETA)
43
+ }
44
+
45
+ function toBase64Url(inputValue: Buffer): string {
46
+ return inputValue.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
47
+ }
48
+
49
+ function createPkce(): { verifier: string; challenge: string } {
50
+ const verifier = toBase64Url(randomBytes(32))
51
+ const challenge = toBase64Url(createHash('sha256').update(verifier).digest())
52
+ return { verifier, challenge }
53
+ }
54
+
55
+ function buildAuthorizationRequest(): { url: string; verifier: string; state: string } {
56
+ const { verifier, challenge } = createPkce()
57
+ const state = verifier
58
+ const url = new URL(AUTHORIZE_URL)
59
+ url.searchParams.set('code', 'true')
60
+ url.searchParams.set('client_id', CLIENT_ID)
61
+ url.searchParams.set('response_type', 'code')
62
+ url.searchParams.set('redirect_uri', REDIRECT_URI)
63
+ url.searchParams.set('scope', OAUTH_SCOPES)
64
+ url.searchParams.set('code_challenge', challenge)
65
+ url.searchParams.set('code_challenge_method', 'S256')
66
+ url.searchParams.set('state', state)
67
+ return { url: url.toString(), verifier, state }
68
+ }
69
+
70
+ function openUrlInBrowser(url: string): void {
71
+ const platform = process.platform
72
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32' : 'xdg-open'
73
+ const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]
74
+ try {
75
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' })
76
+ child.unref()
77
+ } catch {
78
+ log('failed to open browser automatically')
79
+ }
80
+ }
81
+
82
+ async function readCallbackInput(question: string): Promise<string> {
83
+ const rl = createInterface({ input, output })
84
+ try {
85
+ return (await rl.question(question)).trim()
86
+ } finally {
87
+ rl.close()
88
+ }
89
+ }
90
+
91
+ function parseCallbackInput(value: string): { code?: string; state?: string } {
92
+ const raw = value.trim()
93
+ if (raw.length === 0) return {}
94
+
95
+ if (/^https?:\/\//i.test(raw)) {
96
+ try {
97
+ const u = new URL(raw)
98
+ return {
99
+ code: u.searchParams.get('code') ?? undefined,
100
+ state: u.searchParams.get('state') ?? undefined,
101
+ }
102
+ } catch {
103
+ return {}
104
+ }
105
+ }
106
+
107
+ const maybeQuery = raw.startsWith('?') ? raw.slice(1) : raw
108
+ if (maybeQuery.includes('=')) {
109
+ const params = new URLSearchParams(maybeQuery)
110
+ const code = params.get('code') ?? undefined
111
+ const state = params.get('state') ?? undefined
112
+ if (code !== undefined || state !== undefined) return { code, state }
113
+ }
114
+
115
+ return { code: raw }
116
+ }
117
+
118
+ async function exchangeAuthorizationCode(code: string, verifier: string, state: string): Promise<TokenResponse> {
119
+ const res = await globalThis.fetch(TOKEN_URL, {
120
+ method: 'POST',
121
+ headers: {
122
+ 'Content-Type': 'application/json',
123
+ Accept: 'application/json',
124
+ },
125
+ body: JSON.stringify({
126
+ grant_type: 'authorization_code',
127
+ code,
128
+ state,
129
+ client_id: CLIENT_ID,
130
+ redirect_uri: REDIRECT_URI,
131
+ code_verifier: verifier,
132
+ }),
133
+ })
134
+
135
+ if (!res.ok) {
136
+ const body = await res.text()
137
+ throw new Error(`Anthropic token exchange failed: ${res.status} ${res.statusText} ${body}`)
138
+ }
139
+
140
+ const raw = (await res.json()) as Record<string, unknown>
141
+ return {
142
+ access_token: String(raw.access_token ?? ''),
143
+ refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
144
+ expires_in: typeof raw.expires_in === 'number' ? raw.expires_in : undefined,
145
+ }
146
+ }
147
+
148
+ async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
149
+ const res = await globalThis.fetch(TOKEN_URL, {
150
+ method: 'POST',
151
+ headers: {
152
+ 'Content-Type': 'application/json',
153
+ Accept: 'application/json',
154
+ },
155
+ body: JSON.stringify({
156
+ grant_type: 'refresh_token',
157
+ refresh_token: refreshToken,
158
+ client_id: CLIENT_ID,
159
+ }),
160
+ })
161
+
162
+ if (!res.ok) {
163
+ const body = await res.text().catch(() => '')
164
+ throw new Error(`Anthropic token refresh failed: ${res.status} ${res.statusText} ${body}`)
165
+ }
166
+
167
+ const raw = (await res.json()) as Record<string, unknown>
168
+ return {
169
+ access_token: String(raw.access_token ?? ''),
170
+ refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
171
+ expires_in: typeof raw.expires_in === 'number' ? raw.expires_in : undefined,
172
+ }
173
+ }
174
+
175
+ async function createApiKeyFromOAuthAccessToken(accessToken: string): Promise<string> {
176
+ const res = await globalThis.fetch(API_KEY_EXCHANGE_URL, {
177
+ method: 'POST',
178
+ headers: {
179
+ Authorization: `Bearer ${accessToken}`,
180
+ 'Content-Type': 'application/json',
181
+ Accept: 'application/json',
182
+ },
183
+ body: '{}',
184
+ })
185
+
186
+ if (!res.ok) {
187
+ const body = await res.text()
188
+ throw new Error(`Anthropic API key exchange failed: ${res.status} ${res.statusText} ${body}`)
189
+ }
190
+
191
+ const raw = (await res.json()) as CreateApiKeyResponse
192
+ if (typeof raw.raw_key !== 'string' || raw.raw_key.length === 0) {
193
+ throw new Error('Anthropic API key exchange returned empty raw_key')
194
+ }
195
+
196
+ return raw.raw_key
197
+ }
198
+
199
+ /**
200
+ * Resolve a fresh OAuth access_token from the credential, refreshing if expired.
201
+ * Used both during loader setup and inside the per-request Bearer fallback fetch.
202
+ * When a refresh occurs and setAuth is provided, the updated credential is persisted.
203
+ */
204
+ async function resolveOAuthToken(
205
+ auth: AuthCredential,
206
+ setAuth?: (credential: AuthCredential) => Promise<void>,
207
+ ): Promise<string | undefined> {
208
+ let token = auth.key
209
+ if (auth.expires !== undefined && auth.expires < Date.now() && typeof auth.refresh === 'string') {
210
+ try {
211
+ const refreshed = await refreshAccessToken(auth.refresh)
212
+ token = refreshed.access_token
213
+ if (setAuth) {
214
+ const updated: AuthCredential = {
215
+ ...auth,
216
+ key: refreshed.access_token,
217
+ refresh: refreshed.refresh_token ?? auth.refresh,
218
+ expires: refreshed.expires_in !== undefined ? Date.now() + refreshed.expires_in * 1000 : undefined,
219
+ }
220
+ await setAuth(updated).catch((err) =>
221
+ log('failed to persist refreshed credential: %s', err instanceof Error ? err.message : String(err)),
222
+ )
223
+ }
224
+ } catch (error) {
225
+ log('anthropic token refresh failed: %s', error instanceof Error ? error.message : String(error))
226
+ }
227
+ }
228
+ return typeof token === 'string' && token.length > 0 ? token : undefined
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Anthropic auth supports two modes:
233
+ //
234
+ // 1. API key (type: 'api')
235
+ // - key holds a direct API key (sk-ant-api03-xxx)
236
+ // - The loader returns {} — no custom config needed, the SDK uses x-api-key
237
+ // header automatically.
238
+ //
239
+ // 2. OAuth (type: 'oauth')
240
+ // - key holds the short-lived access_token (sk-ant-oat-xxx)
241
+ // - refresh holds the long-lived refresh_token (sk-ant-ort-xxx)
242
+ // - expires is the absolute timestamp (ms) when the access_token expires
243
+ // - The loader handles the full lifecycle:
244
+ // a. If the access_token is expired and a refresh_token exists, refresh it
245
+ // b. If the token looks like an API key (sk-ant- but not oat/ort), use as apiKey
246
+ // c. Otherwise, exchange the OAuth access_token for an API key via
247
+ // /api/oauth/claude_cli/create_api_key
248
+ // d. If exchange fails, fall back to Bearer token auth with custom fetch
249
+ //
250
+ // IMPORTANT: Anthropic uses refresh token rotation — each refresh request
251
+ // invalidates the old refresh_token and returns a new one. This means OAuth
252
+ // credentials CANNOT be shared across applications. If opencode and this
253
+ // project both hold the same credential, whichever refreshes first will
254
+ // invalidate the other's refresh_token, causing a 400 error on subsequent
255
+ // refresh attempts. Each application must perform its own OAuth flow and
256
+ // maintain its own credential independently.
257
+ // ---------------------------------------------------------------------------
258
+ export const anthropicPlugin: AuthHook = {
259
+ provider: 'anthropic',
260
+
261
+ // API key auth: loader is a no-op — the SDK handles x-api-key header directly
262
+ async loader(getAuth, _provider, setAuth) {
263
+ const auth = await getAuth()
264
+ if (auth.type !== 'oauth') return {}
265
+
266
+ // OAuth: resolve a fresh token (refresh if expired), then try apiKey paths
267
+ const token = await resolveOAuthToken(auth, setAuth)
268
+
269
+ if (token !== undefined) {
270
+ if (looksLikeApiKey(token)) {
271
+ return { apiKey: token }
272
+ }
273
+
274
+ try {
275
+ const apiKey = await createApiKeyFromOAuthAccessToken(token)
276
+ return { apiKey }
277
+ } catch (error) {
278
+ log('anthropic api key exchange failed, using oauth bearer fallback: %s', String(error))
279
+ }
280
+ }
281
+
282
+ // Bearer fallback: per-request token refresh (same pattern as google/codex plugins)
283
+ return {
284
+ headers: {
285
+ 'anthropic-beta': OAUTH_BETA,
286
+ },
287
+ async fetch(request: Parameters<typeof globalThis.fetch>[0], init?: Parameters<typeof globalThis.fetch>[1]) {
288
+ const currentAuth = await getAuth()
289
+ const bearerToken = await resolveOAuthToken(currentAuth, setAuth)
290
+ const headers = new Headers(init?.headers)
291
+ applyBearerHeaders(headers, bearerToken ?? '')
292
+ return globalThis.fetch(request, { ...init, headers })
293
+ },
294
+ }
295
+ },
296
+
297
+ methods: [
298
+ {
299
+ type: 'oauth',
300
+ label: 'Claude Pro/Max (Browser OAuth)',
301
+ async handler(): Promise<AuthCredential> {
302
+ const authRequest = buildAuthorizationRequest()
303
+ console.log('Open this URL to continue Claude Pro/Max OAuth:')
304
+ console.log(authRequest.url)
305
+ openUrlInBrowser(authRequest.url)
306
+
307
+ const callbackInput = await readCallbackInput('Paste the callback URL or authorization code: ')
308
+ const parsed = parseCallbackInput(callbackInput)
309
+ if (!parsed.code) throw new Error('Missing authorization code in callback input')
310
+ if (parsed.state !== undefined && parsed.state !== authRequest.state) {
311
+ throw new Error('OAuth state mismatch')
312
+ }
313
+
314
+ const tokens = await exchangeAuthorizationCode(parsed.code, authRequest.verifier, authRequest.state)
315
+ return {
316
+ type: 'oauth',
317
+ key: tokens.access_token,
318
+ refresh: tokens.refresh_token,
319
+ expires: tokens.expires_in !== undefined ? Date.now() + tokens.expires_in * 1000 : undefined,
320
+ }
321
+ },
322
+ },
323
+ ],
324
+ }