patchling 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/src/llm.js ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * LLM completion client for gptdiff-js.
3
+ *
4
+ * Speaks the OpenAI-compatible chat-completions protocol (used by NanoGPT)
5
+ * over `fetch`, so it runs unchanged in the browser and in Node 18+. An
6
+ * Anthropic branch is included for parity with the Python implementation.
7
+ */
8
+
9
+ import { getEnv, DEFAULT_BASE_URL } from './env.js';
10
+
11
+ /**
12
+ * Normalize a base URL so it has no trailing slash, then append `path`.
13
+ */
14
+ function joinUrl(baseUrl, path) {
15
+ const base = baseUrl.replace(/\/+$/, '');
16
+ return `${base}${path}`;
17
+ }
18
+
19
+ /**
20
+ * @param {string} baseUrl
21
+ * @returns {string}
22
+ */
23
+ function domainForUrl(baseUrl) {
24
+ try {
25
+ return new URL(baseUrl).host || baseUrl;
26
+ } catch {
27
+ return baseUrl;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Convert an OpenAI-style message (possibly with image_url blocks) to the
33
+ * Anthropic content-block shape.
34
+ */
35
+ function convertOpenAiMessageToAnthropic(message) {
36
+ if (!Array.isArray(message.content)) return message;
37
+ const converted = [];
38
+ for (const block of message.content) {
39
+ if (!block || typeof block !== 'object') continue;
40
+ if (block.type === 'text') {
41
+ converted.push({ type: 'text', text: block.text || '' });
42
+ } else if (block.type === 'image_url') {
43
+ const url = block.image_url?.url || '';
44
+ if (url.startsWith('data:') && url.includes(';base64,')) {
45
+ const [header, data] = url.split(',', 2);
46
+ const mediaType = header.split(':', 2)[1].split(';', 1)[0];
47
+ converted.push({
48
+ type: 'image',
49
+ source: { type: 'base64', media_type: mediaType, data },
50
+ });
51
+ }
52
+ } else {
53
+ converted.push(block);
54
+ }
55
+ }
56
+ return { ...message, content: converted };
57
+ }
58
+
59
+ /**
60
+ * Consume an OpenAI-compatible SSE stream (`stream: true`) from a fetch
61
+ * Response, invoking `onToken(text)` for every content delta.
62
+ *
63
+ * Handles `data:` events split across network chunks, multi-line `data:`
64
+ * events (lines are joined with `\n` per the SSE spec), and the terminal
65
+ * `data: [DONE]` sentinel. Accumulates the full content and the final
66
+ * `usage` object (requested via `stream_options: { include_usage: true }`)
67
+ * and returns the same shape as a non-streaming chat completion.
68
+ *
69
+ * @param {Response} resp
70
+ * @param {(text: string) => void} onToken
71
+ * @returns {Promise<{choices: Array<{message: {content: string}}>, usage: object}>}
72
+ */
73
+ async function consumeSseStream(resp, onToken) {
74
+ if (!resp.body || typeof resp.body.getReader !== 'function') {
75
+ throw new Error('Streaming requested but the response body is not a readable stream.');
76
+ }
77
+ const reader = resp.body.getReader();
78
+ const decoder = new TextDecoder();
79
+ let buffer = '';
80
+ let content = '';
81
+ let usage = null;
82
+ let dataLines = [];
83
+
84
+ // Dispatch one SSE event; returns true when the [DONE] sentinel is seen.
85
+ const dispatch = () => {
86
+ if (!dataLines.length) return false;
87
+ const payload = dataLines.join('\n');
88
+ dataLines = [];
89
+ if (payload.trim() === '[DONE]') return true;
90
+ let json;
91
+ try {
92
+ json = JSON.parse(payload);
93
+ } catch {
94
+ return false; // ignore keep-alives / unparseable fragments
95
+ }
96
+ if (json.error) {
97
+ throw new Error(`LLM API error (stream): ${JSON.stringify(json.error)}`);
98
+ }
99
+ const text = json.choices?.[0]?.delta?.content;
100
+ if (typeof text === 'string' && text.length) {
101
+ content += text;
102
+ onToken(text);
103
+ }
104
+ if (json.usage) usage = json.usage;
105
+ return false;
106
+ };
107
+
108
+ // Process one line; returns true when the stream is logically finished.
109
+ const handleLine = (rawLine) => {
110
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
111
+ if (line === '') return dispatch(); // blank line terminates an event
112
+ if (line.startsWith('data:')) {
113
+ dataLines.push(line.startsWith('data: ') ? line.slice(6) : line.slice(5));
114
+ }
115
+ // `event:`, `id:` and `:` comment lines are ignored.
116
+ return false;
117
+ };
118
+
119
+ let finished = false;
120
+ while (!finished) {
121
+ const { value, done } = await reader.read();
122
+ if (done) break;
123
+ buffer += decoder.decode(value, { stream: true });
124
+ let idx;
125
+ while ((idx = buffer.indexOf('\n')) !== -1) {
126
+ const line = buffer.slice(0, idx);
127
+ buffer = buffer.slice(idx + 1);
128
+ if (handleLine(line)) {
129
+ finished = true;
130
+ break;
131
+ }
132
+ }
133
+ }
134
+ // Flush any trailing bytes / final event lacking a terminating blank line.
135
+ buffer += decoder.decode();
136
+ if (!finished && buffer) handleLine(buffer);
137
+ if (!finished) dispatch();
138
+
139
+ return { choices: [{ message: { content } }], usage };
140
+ }
141
+
142
+ /**
143
+ * Call a chat-completions endpoint.
144
+ *
145
+ * @param {object} opts
146
+ * @param {string} [opts.apiKey]
147
+ * @param {string} [opts.baseUrl]
148
+ * @param {string} opts.model
149
+ * @param {Array<object>} opts.messages
150
+ * @param {number} [opts.maxTokens]
151
+ * @param {number} [opts.temperature]
152
+ * @param {number} [opts.budgetTokens] Anthropic extended-thinking budget
153
+ * @param {(text: string) => void} [opts.onToken] when provided, the request is
154
+ * made with `stream: true` and this is called once per content delta as
155
+ * tokens arrive. The resolved value keeps the exact non-streaming shape
156
+ * (`{ choices: [{ message: { content } }], usage }`), so callers work
157
+ * unchanged. Ignored on the Anthropic branch (which stays non-streaming).
158
+ * @param {typeof fetch} [opts.fetchImpl] override for testing
159
+ * @param {number} [opts.maxRetries] retries on transient 429/5xx (default 3)
160
+ * @param {number} [opts.retryBaseMs] base backoff in ms (default 1500, exponential, capped 30s)
161
+ * @returns {Promise<{choices: Array<{message: {content: string}}>, usage: object}>}
162
+ */
163
+ export async function callLlm({
164
+ apiKey,
165
+ baseUrl,
166
+ model,
167
+ messages,
168
+ maxTokens = null,
169
+ temperature = 1.0,
170
+ budgetTokens = null,
171
+ onToken = null,
172
+ fetchImpl,
173
+ maxRetries = 3,
174
+ retryBaseMs = 1500,
175
+ }) {
176
+ const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
177
+ if (!doFetch) {
178
+ throw new Error('No fetch implementation available. Pass opts.fetchImpl in this environment.');
179
+ }
180
+ const url = baseUrl || DEFAULT_BASE_URL;
181
+
182
+ if (url.includes('api.anthropic.com')) {
183
+ const anthropicUrl = 'https://api.anthropic.com/v1/messages';
184
+ let systemMessage = null;
185
+ const filtered = [];
186
+ for (const m of messages) {
187
+ if (m.role === 'system') systemMessage = m.content;
188
+ else filtered.push(convertOpenAiMessageToAnthropic(m));
189
+ }
190
+ // Anthropic's API requires max_tokens, so fall back to a generous default
191
+ // only on this path; callers can still pass an explicit cap.
192
+ const data = { model, messages: filtered, max_tokens: maxTokens ?? 32000, temperature };
193
+ if (systemMessage) data.system = systemMessage;
194
+ if (budgetTokens) {
195
+ data.temperature = 1;
196
+ data.thinking = { budget_tokens: budgetTokens, type: 'enabled' };
197
+ }
198
+ const resp = await doFetch(anthropicUrl, {
199
+ method: 'POST',
200
+ headers: {
201
+ 'x-api-key': apiKey,
202
+ 'Content-Type': 'application/json',
203
+ 'anthropic-version': '2023-06-01',
204
+ },
205
+ body: JSON.stringify(data),
206
+ });
207
+ const responseData = await resp.json();
208
+ if (responseData.error) {
209
+ throw new Error(`Error from Anthropic API: ${JSON.stringify(responseData)}`);
210
+ }
211
+ const thinkingItems = responseData.content.filter((c) => c.type === 'thinking').map((c) => c.thinking);
212
+ const textItems = responseData.content.filter((c) => c.type === 'text').map((c) => c.text);
213
+ if (!textItems.length) throw new Error("No 'text' type found in response content");
214
+ let messageContent = textItems[0];
215
+ if (thinkingItems.length) {
216
+ messageContent = `<think>${thinkingItems[0]}</think>\n${messageContent}`;
217
+ }
218
+ const inputTokens = responseData.usage.input_tokens;
219
+ const outputTokens = responseData.usage.output_tokens;
220
+ return {
221
+ choices: [{ message: { content: messageContent } }],
222
+ usage: {
223
+ prompt_tokens: inputTokens,
224
+ completion_tokens: outputTokens,
225
+ total_tokens: inputTokens + outputTokens,
226
+ },
227
+ };
228
+ }
229
+
230
+ // OpenAI-compatible (NanoGPT) path, with backoff on transient errors.
231
+ const endpoint = joinUrl(url, '/chat/completions');
232
+ // Only send max_tokens when explicitly capped; otherwise let the model emit its full output.
233
+ const requestBody = JSON.stringify({
234
+ model,
235
+ messages,
236
+ temperature,
237
+ ...(maxTokens ? { max_tokens: maxTokens } : {}),
238
+ // Streaming: ask for SSE deltas plus a final usage chunk.
239
+ ...(onToken ? { stream: true, stream_options: { include_usage: true } } : {}),
240
+ });
241
+ let resp;
242
+ for (let attempt = 0; ; attempt++) {
243
+ resp = await doFetch(endpoint, {
244
+ method: 'POST',
245
+ headers: {
246
+ Authorization: `Bearer ${apiKey}`,
247
+ 'Content-Type': 'application/json',
248
+ },
249
+ body: requestBody,
250
+ });
251
+ // Retry transient overload / rate-limit / server errors with backoff.
252
+ const transient = resp.status === 429 || resp.status >= 500;
253
+ if (resp.ok || !transient || attempt >= maxRetries) break;
254
+ const retryAfter = parseFloat(resp.headers.get?.('retry-after'));
255
+ const waitMs = Number.isFinite(retryAfter)
256
+ ? Math.min(retryAfter * 1000, 30000)
257
+ : Math.min(retryBaseMs * 2 ** attempt, 30000);
258
+ await new Promise((r) => setTimeout(r, waitMs));
259
+ }
260
+ if (!resp.ok) {
261
+ const body = await resp.text().catch(() => '');
262
+ throw new Error(`LLM request failed (${resp.status}) from ${domainForUrl(url)}: ${body}`);
263
+ }
264
+ if (onToken) {
265
+ // Retry/backoff above covered the initial request; an error mid-stream
266
+ // (after tokens have flowed) simply propagates to the caller.
267
+ return consumeSseStream(resp, onToken);
268
+ }
269
+ const json = await resp.json();
270
+ if (json.error) {
271
+ throw new Error(`LLM API error from ${domainForUrl(url)}: ${JSON.stringify(json.error)}`);
272
+ }
273
+ return json;
274
+ }
275
+
276
+ /**
277
+ * Resolve the effective API key, preferring an explicit override then the
278
+ * GPTDIFF_LLM_API_KEY environment variable.
279
+ * @param {string} [apiKey]
280
+ * @returns {string | undefined}
281
+ */
282
+ export function resolveApiKey(apiKey) {
283
+ return apiKey || getEnv('GPTDIFF_LLM_API_KEY');
284
+ }
285
+
286
+ /**
287
+ * Resolve the effective base URL, preferring an explicit override then the
288
+ * GPTDIFF_LLM_BASE_URL environment variable, then the NanoGPT default.
289
+ * @param {string} [baseUrl]
290
+ * @returns {string}
291
+ */
292
+ export function resolveBaseUrl(baseUrl) {
293
+ return baseUrl || getEnv('GPTDIFF_LLM_BASE_URL', DEFAULT_BASE_URL) || DEFAULT_BASE_URL;
294
+ }
295
+
296
+ export { domainForUrl };
package/src/oauth.d.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Base64url-encode an ArrayBuffer or Uint8Array (no padding).
3
+ * @param {ArrayBuffer | Uint8Array} buffer
4
+ * @returns {string}
5
+ */
6
+ export function base64UrlEncode(buffer: ArrayBuffer | Uint8Array): string;
7
+ /** Generate a high-entropy PKCE code verifier (base64url, 43+ chars). */
8
+ export function generateCodeVerifier(byteLength?: number): string;
9
+ /**
10
+ * Compute the S256 code challenge for a verifier.
11
+ * @param {string} verifier
12
+ * @returns {Promise<string>}
13
+ */
14
+ export function generateCodeChallenge(verifier: string): Promise<string>;
15
+ /** Generate a random CSRF `state` value. */
16
+ export function generateState(byteLength?: number): string;
17
+ /** Generate a complete PKCE pair: `{ verifier, challenge }`. */
18
+ export function generatePkce(): Promise<{
19
+ verifier: string;
20
+ challenge: string;
21
+ }>;
22
+ /**
23
+ * Dynamically register a public client with NanoGPT.
24
+ * @param {object} [opts]
25
+ * @param {string} [opts.clientName]
26
+ * @param {string} [opts.redirectUri]
27
+ * @param {typeof fetch} [opts.fetchImpl]
28
+ * @returns {Promise<{ client_id: string, [k: string]: any }>}
29
+ */
30
+ export function registerClient({ clientName, redirectUri, fetchImpl }?: {
31
+ clientName?: string;
32
+ redirectUri?: string;
33
+ fetchImpl?: typeof fetch;
34
+ }): Promise<{
35
+ client_id: string;
36
+ [k: string]: any;
37
+ }>;
38
+ /**
39
+ * Build the authorization URL the user's browser should be sent to.
40
+ * @param {object} opts
41
+ * @param {string} opts.clientId
42
+ * @param {string} opts.redirectUri
43
+ * @param {string} [opts.scope]
44
+ * @param {string} opts.state
45
+ * @param {string} opts.codeChallenge
46
+ * @returns {string}
47
+ */
48
+ export function buildAuthorizeUrl({ clientId, redirectUri, scope, state, codeChallenge, }: {
49
+ clientId: string;
50
+ redirectUri: string;
51
+ scope?: string;
52
+ state: string;
53
+ codeChallenge: string;
54
+ }): string;
55
+ /**
56
+ * Exchange an authorization code for an access token.
57
+ * @param {object} opts
58
+ * @param {string} opts.clientId
59
+ * @param {string} opts.redirectUri
60
+ * @param {string} opts.code
61
+ * @param {string} opts.codeVerifier
62
+ * @param {typeof fetch} [opts.fetchImpl]
63
+ * @returns {Promise<{ access_token: string, token_type: string, scope: string }>}
64
+ */
65
+ export function exchangeCodeForToken({ clientId, redirectUri, code, codeVerifier, fetchImpl, }: {
66
+ clientId: string;
67
+ redirectUri: string;
68
+ code: string;
69
+ codeVerifier: string;
70
+ fetchImpl?: typeof fetch;
71
+ }): Promise<{
72
+ access_token: string;
73
+ token_type: string;
74
+ scope: string;
75
+ }>;
76
+ /**
77
+ * Start the sign-in redirect. Stores the PKCE verifier, state, client_id and
78
+ * redirect_uri in sessionStorage and navigates the browser to NanoGPT.
79
+ *
80
+ * @param {object} opts
81
+ * @param {string} [opts.clientId] a pre-registered NanoGPT client_id (ngpt_...)
82
+ * @param {string} [opts.redirectUri] defaults to the current page URL
83
+ * @param {string} [opts.scope]
84
+ * @param {boolean} [opts.redirect=true] navigate automatically
85
+ * @returns {Promise<string>} the authorization URL
86
+ */
87
+ export function beginSignIn({ clientId, redirectUri, scope, redirect, }?: {
88
+ clientId?: string;
89
+ redirectUri?: string;
90
+ scope?: string;
91
+ redirect?: boolean;
92
+ }): Promise<string>;
93
+ /**
94
+ * Complete the sign-in after NanoGPT redirects back with `?code=...&state=...`.
95
+ * Validates state, exchanges the code, registers the access token as the
96
+ * GPTDIFF_LLM_API_KEY override, and clears the URL query string.
97
+ *
98
+ * @param {object} [opts]
99
+ * @param {string} [opts.search] query string to parse (defaults to window.location.search)
100
+ * @param {typeof fetch} [opts.fetchImpl]
101
+ * @returns {Promise<string | null>} the access token, or null if no code present
102
+ */
103
+ export function completeSignIn({ search, fetchImpl }?: {
104
+ search?: string;
105
+ fetchImpl?: typeof fetch;
106
+ }): Promise<string | null>;
107
+ export const NANOGPT_ORIGIN: "https://nano-gpt.com";
108
+ export const REGISTER_URL: "https://nano-gpt.com/oauth/register";
109
+ export const AUTHORIZE_URL: "https://nano-gpt.com/oauth/authorize";
110
+ export const TOKEN_URL: "https://nano-gpt.com/oauth/token";
111
+ export const DEFAULT_SCOPE: "api.use models.read";
package/src/oauth.js ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * "Sign in with NanoGPT" — OAuth 2.0 Authorization Code flow with PKCE (S256).
3
+ *
4
+ * Reference: https://nano-gpt.com/blog/sign-in-with-nanogpt-oauth-pkce
5
+ *
6
+ * Endpoints:
7
+ * register : POST https://nano-gpt.com/oauth/register
8
+ * authorize: GET https://nano-gpt.com/oauth/authorize
9
+ * token : POST https://nano-gpt.com/oauth/token
10
+ *
11
+ * The low-level functions are environment-agnostic. The high-level
12
+ * `beginSignIn` / `completeSignIn` helpers drive the browser redirect flow and
13
+ * stash the returned access token as the GPTDIFF_LLM_API_KEY override so the
14
+ * rest of gptdiff-js can use it transparently.
15
+ */
16
+
17
+ import { setEnv } from './env.js';
18
+
19
+ export const NANOGPT_ORIGIN = 'https://nano-gpt.com';
20
+ export const REGISTER_URL = `${NANOGPT_ORIGIN}/oauth/register`;
21
+ export const AUTHORIZE_URL = `${NANOGPT_ORIGIN}/oauth/authorize`;
22
+ export const TOKEN_URL = `${NANOGPT_ORIGIN}/oauth/token`;
23
+ export const DEFAULT_SCOPE = 'api.use models.read';
24
+
25
+ const STORAGE_KEY = 'gptdiff_oauth_pkce';
26
+
27
+ function getCrypto() {
28
+ const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined;
29
+ if (!c || !c.subtle) {
30
+ throw new Error('Web Crypto (crypto.subtle) is required for PKCE but is unavailable.');
31
+ }
32
+ return c;
33
+ }
34
+
35
+ /**
36
+ * Base64url-encode an ArrayBuffer or Uint8Array (no padding).
37
+ * @param {ArrayBuffer | Uint8Array} buffer
38
+ * @returns {string}
39
+ */
40
+ export function base64UrlEncode(buffer) {
41
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
42
+ let binary = '';
43
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
44
+ const b64 = typeof btoa !== 'undefined'
45
+ ? btoa(binary)
46
+ : Buffer.from(bytes).toString('base64');
47
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
48
+ }
49
+
50
+ /** Generate a high-entropy PKCE code verifier (base64url, 43+ chars). */
51
+ export function generateCodeVerifier(byteLength = 64) {
52
+ const random = new Uint8Array(byteLength);
53
+ getCrypto().getRandomValues(random);
54
+ return base64UrlEncode(random);
55
+ }
56
+
57
+ /**
58
+ * Compute the S256 code challenge for a verifier.
59
+ * @param {string} verifier
60
+ * @returns {Promise<string>}
61
+ */
62
+ export async function generateCodeChallenge(verifier) {
63
+ const data = new TextEncoder().encode(verifier);
64
+ const digest = await getCrypto().subtle.digest('SHA-256', data);
65
+ return base64UrlEncode(digest);
66
+ }
67
+
68
+ /** Generate a random CSRF `state` value. */
69
+ export function generateState(byteLength = 16) {
70
+ const random = new Uint8Array(byteLength);
71
+ getCrypto().getRandomValues(random);
72
+ return base64UrlEncode(random);
73
+ }
74
+
75
+ /** Generate a complete PKCE pair: `{ verifier, challenge }`. */
76
+ export async function generatePkce() {
77
+ const verifier = generateCodeVerifier();
78
+ const challenge = await generateCodeChallenge(verifier);
79
+ return { verifier, challenge };
80
+ }
81
+
82
+ /**
83
+ * Dynamically register a public client with NanoGPT.
84
+ * @param {object} [opts]
85
+ * @param {string} [opts.clientName]
86
+ * @param {string} [opts.redirectUri]
87
+ * @param {typeof fetch} [opts.fetchImpl]
88
+ * @returns {Promise<{ client_id: string, [k: string]: any }>}
89
+ */
90
+ export async function registerClient({ clientName, redirectUri, fetchImpl } = {}) {
91
+ const doFetch = fetchImpl || fetch;
92
+ const resp = await doFetch(REGISTER_URL, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({
96
+ client_name: clientName,
97
+ redirect_uris: [redirectUri],
98
+ grant_types: ['authorization_code'],
99
+ response_types: ['code'],
100
+ token_endpoint_auth_method: 'none',
101
+ }),
102
+ });
103
+ if (!resp.ok) {
104
+ const body = await resp.text().catch(() => '');
105
+ throw new Error(`Client registration failed (${resp.status}): ${body}`);
106
+ }
107
+ return resp.json();
108
+ }
109
+
110
+ /**
111
+ * Build the authorization URL the user's browser should be sent to.
112
+ * @param {object} opts
113
+ * @param {string} opts.clientId
114
+ * @param {string} opts.redirectUri
115
+ * @param {string} [opts.scope]
116
+ * @param {string} opts.state
117
+ * @param {string} opts.codeChallenge
118
+ * @returns {string}
119
+ */
120
+ export function buildAuthorizeUrl({
121
+ clientId,
122
+ redirectUri,
123
+ scope = DEFAULT_SCOPE,
124
+ state,
125
+ codeChallenge,
126
+ }) {
127
+ const params = new URLSearchParams({
128
+ response_type: 'code',
129
+ client_id: clientId,
130
+ redirect_uri: redirectUri,
131
+ scope,
132
+ state,
133
+ code_challenge: codeChallenge,
134
+ code_challenge_method: 'S256',
135
+ });
136
+ return `${AUTHORIZE_URL}?${params.toString()}`;
137
+ }
138
+
139
+ /**
140
+ * Exchange an authorization code for an access token.
141
+ * @param {object} opts
142
+ * @param {string} opts.clientId
143
+ * @param {string} opts.redirectUri
144
+ * @param {string} opts.code
145
+ * @param {string} opts.codeVerifier
146
+ * @param {typeof fetch} [opts.fetchImpl]
147
+ * @returns {Promise<{ access_token: string, token_type: string, scope: string }>}
148
+ */
149
+ export async function exchangeCodeForToken({
150
+ clientId,
151
+ redirectUri,
152
+ code,
153
+ codeVerifier,
154
+ fetchImpl,
155
+ }) {
156
+ const doFetch = fetchImpl || fetch;
157
+ const body = new URLSearchParams({
158
+ grant_type: 'authorization_code',
159
+ client_id: clientId,
160
+ redirect_uri: redirectUri,
161
+ code,
162
+ code_verifier: codeVerifier,
163
+ });
164
+ const resp = await doFetch(TOKEN_URL, {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
167
+ body: body.toString(),
168
+ });
169
+ if (!resp.ok) {
170
+ const text = await resp.text().catch(() => '');
171
+ throw new Error(`Token exchange failed (${resp.status}): ${text}`);
172
+ }
173
+ return resp.json();
174
+ }
175
+
176
+ /* -------------------------------------------------------------------------- */
177
+ /* High-level browser redirect flow */
178
+ /* -------------------------------------------------------------------------- */
179
+
180
+ function requireBrowser() {
181
+ if (typeof window === 'undefined' || !window.location || !window.sessionStorage) {
182
+ throw new Error('beginSignIn/completeSignIn require a browser environment.');
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Start the sign-in redirect. Stores the PKCE verifier, state, client_id and
188
+ * redirect_uri in sessionStorage and navigates the browser to NanoGPT.
189
+ *
190
+ * @param {object} opts
191
+ * @param {string} [opts.clientId] a pre-registered NanoGPT client_id (ngpt_...)
192
+ * @param {string} [opts.redirectUri] defaults to the current page URL
193
+ * @param {string} [opts.scope]
194
+ * @param {boolean} [opts.redirect=true] navigate automatically
195
+ * @returns {Promise<string>} the authorization URL
196
+ */
197
+ export async function beginSignIn({
198
+ clientId,
199
+ redirectUri,
200
+ scope = DEFAULT_SCOPE,
201
+ redirect = true,
202
+ } = {}) {
203
+ requireBrowser();
204
+ if (!clientId) throw new Error('beginSignIn requires a clientId.');
205
+ const finalRedirect = redirectUri || window.location.origin + window.location.pathname;
206
+ const { verifier, challenge } = await generatePkce();
207
+ const state = generateState();
208
+ window.sessionStorage.setItem(
209
+ STORAGE_KEY,
210
+ JSON.stringify({ verifier, state, clientId, redirectUri: finalRedirect }),
211
+ );
212
+ const authUrl = buildAuthorizeUrl({
213
+ clientId,
214
+ redirectUri: finalRedirect,
215
+ scope,
216
+ state,
217
+ codeChallenge: challenge,
218
+ });
219
+ if (redirect) window.location.assign(authUrl);
220
+ return authUrl;
221
+ }
222
+
223
+ /**
224
+ * Complete the sign-in after NanoGPT redirects back with `?code=...&state=...`.
225
+ * Validates state, exchanges the code, registers the access token as the
226
+ * GPTDIFF_LLM_API_KEY override, and clears the URL query string.
227
+ *
228
+ * @param {object} [opts]
229
+ * @param {string} [opts.search] query string to parse (defaults to window.location.search)
230
+ * @param {typeof fetch} [opts.fetchImpl]
231
+ * @returns {Promise<string | null>} the access token, or null if no code present
232
+ */
233
+ export async function completeSignIn({ search, fetchImpl } = {}) {
234
+ requireBrowser();
235
+ const params = new URLSearchParams(search ?? window.location.search);
236
+ const code = params.get('code');
237
+ if (!code) return null;
238
+
239
+ const raw = window.sessionStorage.getItem(STORAGE_KEY);
240
+ if (!raw) throw new Error('No stored PKCE state; cannot complete sign-in.');
241
+ const stored = JSON.parse(raw);
242
+
243
+ const returnedState = params.get('state');
244
+ if (returnedState !== stored.state) {
245
+ throw new Error('OAuth state mismatch — possible CSRF, aborting.');
246
+ }
247
+
248
+ const tokenResponse = await exchangeCodeForToken({
249
+ clientId: stored.clientId,
250
+ redirectUri: stored.redirectUri,
251
+ code,
252
+ codeVerifier: stored.verifier,
253
+ fetchImpl,
254
+ });
255
+
256
+ window.sessionStorage.removeItem(STORAGE_KEY);
257
+ setEnv('GPTDIFF_LLM_API_KEY', tokenResponse.access_token);
258
+
259
+ // Clean the code/state out of the URL.
260
+ const cleanUrl = window.location.origin + window.location.pathname;
261
+ if (window.history && window.history.replaceState) {
262
+ window.history.replaceState({}, document.title, cleanUrl);
263
+ }
264
+
265
+ return tokenResponse.access_token;
266
+ }