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/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +62 -0
- package/src/applydiff.d.ts +39 -0
- package/src/applydiff.js +404 -0
- package/src/env.d.ts +16 -0
- package/src/env.js +40 -0
- package/src/gptdiff.d.ts +161 -0
- package/src/gptdiff.js +352 -0
- package/src/index.d.ts +6 -0
- package/src/index.js +13 -0
- package/src/llm.d.ts +60 -0
- package/src/llm.js +296 -0
- package/src/oauth.d.ts +111 -0
- package/src/oauth.js +266 -0
- package/src/text.d.ts +69 -0
- package/src/text.js +218 -0
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
|
+
}
|