@oevortex/opencode-qwen-auth 0.1.0 → 0.1.1

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.
@@ -1,460 +0,0 @@
1
- /**
2
- * Fetch wrapper for Qwen API requests
3
- * Handles authentication, token refresh, rate limiting, and request transformation
4
- */
5
-
6
- import {
7
- QWEN_DEFAULT_BASE_URL,
8
- QWEN_PORTAL_BASE_URL,
9
- QWEN_PROVIDER_ID,
10
- RATE_LIMIT_BACKOFF_BASE_MS,
11
- RATE_LIMIT_BACKOFF_MAX_MS,
12
- RATE_LIMIT_MAX_RETRIES,
13
- HTTP_UNAUTHORIZED,
14
- } from "../constants";
15
-
16
- import type {
17
- OAuthAuthDetails,
18
- PluginContext,
19
- GetAuth,
20
- RateLimitState,
21
- RateLimitDelay,
22
- } from "../types";
23
-
24
- import { isOAuthAuth, accessTokenExpired, parseRefreshParts } from "./auth";
25
- import { refreshAccessToken } from "./token";
26
- import { createLogger, printQwenConsole } from "./logger";
27
-
28
- const log = createLogger("fetch-wrapper");
29
-
30
- /**
31
- * Rate limit state tracking per session.
32
- */
33
- const rateLimitState: RateLimitState = {
34
- consecutive429: 0,
35
- lastAt: 0,
36
- };
37
-
38
- /**
39
- * Compute exponential backoff delay for rate limiting.
40
- */
41
- export function computeExponentialBackoffMs(
42
- attempt: number,
43
- baseMs: number = RATE_LIMIT_BACKOFF_BASE_MS,
44
- maxMs: number = RATE_LIMIT_BACKOFF_MAX_MS
45
- ): number {
46
- const safeAttempt = Math.max(1, Math.floor(attempt));
47
- const multiplier = 2 ** (safeAttempt - 1);
48
- return Math.min(maxMs, Math.max(0, Math.floor(baseMs * multiplier)));
49
- }
50
-
51
- /**
52
- * Convert URL or Request to string URL.
53
- */
54
- function toUrlString(value: RequestInfo | URL): string {
55
- if (value instanceof URL) {
56
- return value.toString();
57
- }
58
- if (typeof value === "string") {
59
- return value;
60
- }
61
- return (value as Request).url ?? value.toString();
62
- }
63
-
64
- /**
65
- * Sleep for a specified duration with abort signal support.
66
- */
67
- export function sleep(ms: number, signal?: AbortSignal | null): Promise<void> {
68
- return new Promise((resolve, reject) => {
69
- if (signal?.aborted) {
70
- reject(signal.reason instanceof Error ? signal.reason : new Error("Aborted"));
71
- return;
72
- }
73
-
74
- const timeout = setTimeout(() => {
75
- cleanup();
76
- resolve();
77
- }, ms);
78
-
79
- const onAbort = () => {
80
- cleanup();
81
- reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted"));
82
- };
83
-
84
- const cleanup = () => {
85
- clearTimeout(timeout);
86
- signal?.removeEventListener("abort", onAbort);
87
- };
88
-
89
- signal?.addEventListener("abort", onAbort, { once: true });
90
- });
91
- }
92
-
93
- /**
94
- * Sleep with progressive backoff for long waits.
95
- */
96
- export async function sleepWithBackoff(
97
- totalMs: number,
98
- signal?: AbortSignal | null
99
- ): Promise<void> {
100
- const stepsMs = [3000, 5000, 10000, 20000, 30000];
101
- let remainingMs = Math.max(0, totalMs);
102
- let stepIndex = 0;
103
-
104
- while (remainingMs > 0) {
105
- const stepMs = stepsMs[stepIndex] ?? stepsMs[stepsMs.length - 1] ?? 30000;
106
- const waitMs = Math.min(remainingMs, stepMs);
107
- await sleep(waitMs, signal);
108
- remainingMs -= waitMs;
109
- stepIndex++;
110
- }
111
- }
112
-
113
- /**
114
- * Format milliseconds as human-readable time string.
115
- */
116
- function formatWaitTimeMs(ms: number): string {
117
- const totalSeconds = Math.max(1, Math.ceil(ms / 1000));
118
- const hours = Math.floor(totalSeconds / 3600);
119
- const minutes = Math.floor((totalSeconds % 3600) / 60);
120
- const seconds = totalSeconds % 60;
121
-
122
- if (hours > 0) {
123
- return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
124
- }
125
- if (minutes > 0) {
126
- return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
127
- }
128
- return `${seconds}s`;
129
- }
130
-
131
- /**
132
- * Check if a URL is a Qwen API request.
133
- */
134
- function isQwenApiRequest(input: RequestInfo | URL): boolean {
135
- const urlString = toUrlString(input);
136
- return (
137
- urlString.includes("dashscope.aliyuncs.com") ||
138
- urlString.includes("portal.qwen.ai") ||
139
- urlString.includes("chat.qwen.ai") ||
140
- urlString.includes("/v1/chat/completions") ||
141
- urlString.includes("/compatible-mode/v1")
142
- );
143
- }
144
-
145
- /**
146
- * Get the base URL for Qwen API requests.
147
- * Uses OAuth resource URL if available, otherwise default.
148
- */
149
- function getBaseUrl(auth: OAuthAuthDetails): string {
150
- const parts = parseRefreshParts(auth.refresh);
151
- let baseUrl = parts.resourceUrl || QWEN_PORTAL_BASE_URL;
152
-
153
- if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
154
- baseUrl = `https://${baseUrl}`;
155
- }
156
-
157
- baseUrl = baseUrl.replace(/\/+$/, "");
158
- if (!baseUrl.endsWith("/v1")) {
159
- baseUrl = `${baseUrl}/v1`;
160
- }
161
-
162
- return baseUrl;
163
- }
164
-
165
- /**
166
- * Parse Retry-After header from response.
167
- */
168
- function parseRetryAfterMs(response: Response): number | null {
169
- const retryAfterMsHeader = response.headers.get("retry-after-ms");
170
- const retryAfterSecondsHeader = response.headers.get("retry-after");
171
-
172
- if (retryAfterMsHeader) {
173
- const parsed = parseInt(retryAfterMsHeader, 10);
174
- if (!Number.isNaN(parsed) && parsed >= 0) {
175
- return Math.min(parsed, RATE_LIMIT_BACKOFF_MAX_MS);
176
- }
177
- }
178
-
179
- if (retryAfterSecondsHeader) {
180
- const parsed = parseInt(retryAfterSecondsHeader, 10);
181
- if (!Number.isNaN(parsed) && parsed >= 0) {
182
- return Math.min(parsed * 1000, RATE_LIMIT_BACKOFF_MAX_MS);
183
- }
184
- }
185
-
186
- return null;
187
- }
188
-
189
- /**
190
- * Get rate limit delay information.
191
- */
192
- function getRateLimitDelay(serverRetryAfterMs: number | null): RateLimitDelay {
193
- const now = Date.now();
194
- const attempt = rateLimitState.consecutive429 + 1;
195
- const backoffMs = computeExponentialBackoffMs(attempt);
196
- const delayMs = serverRetryAfterMs !== null ? Math.max(serverRetryAfterMs, backoffMs) : backoffMs;
197
-
198
- rateLimitState.consecutive429 = attempt;
199
- rateLimitState.lastAt = now;
200
-
201
- return { attempt, serverRetryAfterMs, delayMs };
202
- }
203
-
204
- /**
205
- * Reset rate limit state after successful request.
206
- */
207
- function resetRateLimitState(): void {
208
- rateLimitState.consecutive429 = 0;
209
- rateLimitState.lastAt = 0;
210
- }
211
-
212
- /**
213
- * Prepare headers for Qwen API request.
214
- */
215
- function prepareHeaders(
216
- init: RequestInit | undefined,
217
- accessToken: string
218
- ): Headers {
219
- const headers = new Headers(init?.headers);
220
-
221
- // Set authorization
222
- headers.set("Authorization", `Bearer ${accessToken}`);
223
-
224
- // Ensure content type is set
225
- if (!headers.has("Content-Type")) {
226
- headers.set("Content-Type", "application/json");
227
- }
228
-
229
- // Set accept header
230
- if (!headers.has("Accept")) {
231
- headers.set("Accept", "application/json");
232
- }
233
-
234
- return headers;
235
- }
236
-
237
- /**
238
- * Transform request URL to use correct Qwen base URL.
239
- */
240
- function transformRequestUrl(input: RequestInfo | URL, baseUrl: string): string {
241
- const urlString = toUrlString(input);
242
-
243
- // If it's already a full URL with a recognized host, replace the base
244
- if (urlString.includes("generativelanguage.googleapis.com")) {
245
- // This is a Google API URL, transform to Qwen
246
- const path = urlString.split("/v1")[1] || "/chat/completions";
247
- return `${baseUrl}${path}`;
248
- }
249
-
250
- // If it's a relative path, prepend base URL
251
- if (urlString.startsWith("/")) {
252
- return `${baseUrl}${urlString}`;
253
- }
254
-
255
- // If URL already points to Qwen, use as-is
256
- if (isQwenApiRequest(urlString)) {
257
- return urlString;
258
- }
259
-
260
- // Default: assume it's a chat completions request
261
- if (!urlString.includes("/chat/completions")) {
262
- return `${baseUrl}/chat/completions`;
263
- }
264
-
265
- return urlString;
266
- }
267
-
268
- /**
269
- * Create a fetch wrapper that handles Qwen API authentication.
270
- *
271
- * This wrapper:
272
- * - Automatically refreshes tokens when expired
273
- * - Handles rate limiting with exponential backoff
274
- * - Transforms requests for Qwen API compatibility
275
- * - Retries on auth failures
276
- */
277
- export function createQwenFetch(
278
- getAuth: GetAuth,
279
- client: PluginContext["client"]
280
- ): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
281
- return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
282
- // Get current auth
283
- const auth = await getAuth();
284
-
285
- // If not OAuth auth, pass through to normal fetch
286
- if (!isOAuthAuth(auth)) {
287
- return fetch(input, init);
288
- }
289
-
290
- // Check if this is a Qwen API request
291
- const urlString = toUrlString(input);
292
- if (!isQwenApiRequest(urlString) && !urlString.includes("googleapis.com")) {
293
- return fetch(input, init);
294
- }
295
-
296
- // Ensure we have a valid access token
297
- let currentAuth = auth;
298
- if (accessTokenExpired(currentAuth)) {
299
- log.debug("Access token expired, refreshing...");
300
- const refreshed = await refreshAccessToken(currentAuth, client);
301
- if (!refreshed) {
302
- throw new Error("Failed to refresh access token");
303
- }
304
- currentAuth = refreshed;
305
-
306
- // Save refreshed auth
307
- try {
308
- await client.auth.set({
309
- path: { id: QWEN_PROVIDER_ID },
310
- body: {
311
- type: "oauth",
312
- access: refreshed.access,
313
- refresh: refreshed.refresh,
314
- expires: refreshed.expires,
315
- },
316
- });
317
- } catch (saveError) {
318
- log.warn("Failed to save refreshed auth", {
319
- error: saveError instanceof Error ? saveError.message : String(saveError),
320
- });
321
- }
322
- }
323
-
324
- const accessToken = currentAuth.access;
325
- if (!accessToken) {
326
- throw new Error("No access token available");
327
- }
328
-
329
- // Get base URL and prepare request
330
- const baseUrl = getBaseUrl(currentAuth);
331
- const requestUrl = transformRequestUrl(input, baseUrl);
332
- const headers = prepareHeaders(init, accessToken);
333
-
334
- const abortSignal = init?.signal;
335
-
336
- // Retry loop for rate limiting
337
- let retryCount = 0;
338
- while (retryCount <= RATE_LIMIT_MAX_RETRIES) {
339
- try {
340
- log.debug("Making Qwen API request", {
341
- url: requestUrl,
342
- method: init?.method || "GET",
343
- retry: retryCount,
344
- });
345
-
346
- const response = await fetch(requestUrl, {
347
- ...init,
348
- headers,
349
- });
350
-
351
- // Handle 401 Unauthorized - refresh token and retry once
352
- if (response.status === HTTP_UNAUTHORIZED && retryCount === 0) {
353
- log.info("Received 401, refreshing token and retrying...");
354
-
355
- const refreshed = await refreshAccessToken(currentAuth, client);
356
- if (!refreshed) {
357
- return response; // Return 401 response if refresh fails
358
- }
359
-
360
- currentAuth = refreshed;
361
- headers.set("Authorization", `Bearer ${refreshed.access}`);
362
-
363
- // Save refreshed auth
364
- try {
365
- await client.auth.set({
366
- path: { id: QWEN_PROVIDER_ID },
367
- body: {
368
- type: "oauth",
369
- access: refreshed.access,
370
- refresh: refreshed.refresh,
371
- expires: refreshed.expires,
372
- },
373
- });
374
- } catch {}
375
-
376
- retryCount++;
377
- continue;
378
- }
379
-
380
- // Handle 429 Rate Limit
381
- if (response.status === 429) {
382
- const serverRetryAfterMs = parseRetryAfterMs(response);
383
- const { attempt, delayMs } = getRateLimitDelay(serverRetryAfterMs);
384
-
385
- if (attempt > RATE_LIMIT_MAX_RETRIES) {
386
- log.warn("Max rate limit retries exceeded", { attempt });
387
- return response;
388
- }
389
-
390
- printQwenConsole(
391
- "error",
392
- `Rate limited (429). Retrying after ${formatWaitTimeMs(delayMs)} (attempt ${attempt})...`
393
- );
394
-
395
- await client.tui.showToast({
396
- body: {
397
- message: `Rate limited. Retrying in ${formatWaitTimeMs(delayMs)}...`,
398
- variant: "warning",
399
- },
400
- });
401
-
402
- await sleepWithBackoff(delayMs, abortSignal);
403
- retryCount++;
404
- continue;
405
- }
406
-
407
- // Handle 5xx server errors with retry
408
- if (response.status >= 500 && retryCount < RATE_LIMIT_MAX_RETRIES) {
409
- log.warn("Server error, retrying...", { status: response.status });
410
- const delayMs = computeExponentialBackoffMs(retryCount + 1);
411
- await sleep(delayMs, abortSignal);
412
- retryCount++;
413
- continue;
414
- }
415
-
416
- // Success - reset rate limit state
417
- if (response.ok) {
418
- resetRateLimitState();
419
- }
420
-
421
- return response;
422
- } catch (error) {
423
- // Network errors - retry with backoff
424
- if (retryCount < RATE_LIMIT_MAX_RETRIES) {
425
- const errorMessage = error instanceof Error ? error.message : String(error);
426
- log.warn("Request failed, retrying...", { error: errorMessage, retry: retryCount });
427
-
428
- const delayMs = computeExponentialBackoffMs(retryCount + 1);
429
- await sleep(delayMs, abortSignal);
430
- retryCount++;
431
- continue;
432
- }
433
-
434
- throw error;
435
- }
436
- }
437
-
438
- // Should not reach here, but just in case
439
- throw new Error("Max retries exceeded");
440
- };
441
- }
442
-
443
- /**
444
- * Create a simple fetch wrapper that just adds auth headers.
445
- * Use this for non-retrying scenarios.
446
- */
447
- export function createSimpleQwenFetch(
448
- accessToken: string,
449
- baseUrl: string = QWEN_DEFAULT_BASE_URL
450
- ): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
451
- return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
452
- const requestUrl = transformRequestUrl(input, baseUrl);
453
- const headers = prepareHeaders(init, accessToken);
454
-
455
- return fetch(requestUrl, {
456
- ...init,
457
- headers,
458
- });
459
- };
460
- }
@@ -1,111 +0,0 @@
1
- /**
2
- * Logger utility for the Qwen OpenCode plugin
3
- */
4
-
5
- import { ENV_CONSOLE_LOG } from "../constants";
6
- import type { PluginContext } from "../types";
7
-
8
- type LogLevel = "debug" | "info" | "warn" | "error";
9
-
10
- interface Logger {
11
- debug: (message: string, meta?: Record<string, unknown>) => void;
12
- info: (message: string, meta?: Record<string, unknown>) => void;
13
- warn: (message: string, meta?: Record<string, unknown>) => void;
14
- error: (message: string, meta?: Record<string, unknown>) => void;
15
- }
16
-
17
- let pluginClient: PluginContext["client"] | null = null;
18
-
19
- /**
20
- * Initialize the logger with the plugin client.
21
- * Should be called once when the plugin loads.
22
- */
23
- export function initLogger(client: PluginContext["client"]): void {
24
- pluginClient = client;
25
- }
26
-
27
- /**
28
- * Check if console logging is enabled via environment variable.
29
- */
30
- function isConsoleLogEnabled(): boolean {
31
- const value = process.env[ENV_CONSOLE_LOG];
32
- return value === "1" || value === "true";
33
- }
34
-
35
- /**
36
- * Format a log message with optional metadata.
37
- */
38
- function formatMessage(prefix: string, message: string, meta?: Record<string, unknown>): string {
39
- const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
40
- return `[${prefix}] ${message}${metaStr}`;
41
- }
42
-
43
- /**
44
- * Print to console if console logging is enabled.
45
- */
46
- export function printQwenConsole(level: LogLevel, message: string, meta?: Record<string, unknown>): void {
47
- if (!isConsoleLogEnabled()) {
48
- return;
49
- }
50
-
51
- const formattedMessage = formatMessage("qwen-auth", message, meta);
52
-
53
- switch (level) {
54
- case "debug":
55
- console.debug(formattedMessage);
56
- break;
57
- case "info":
58
- console.info(formattedMessage);
59
- break;
60
- case "warn":
61
- console.warn(formattedMessage);
62
- break;
63
- case "error":
64
- console.error(formattedMessage);
65
- break;
66
- }
67
- }
68
-
69
- /**
70
- * Create a logger instance with a specific prefix.
71
- * Uses the plugin client's log methods if available, falls back to console.
72
- */
73
- export function createLogger(prefix: string): Logger {
74
- const log = (level: LogLevel, message: string, meta?: Record<string, unknown>): void => {
75
- // Always print to console if enabled
76
- printQwenConsole(level, `[${prefix}] ${message}`, meta);
77
-
78
- // Use plugin client logger if available
79
- if (pluginClient?.log) {
80
- const clientLog = pluginClient.log;
81
- const formattedMessage = `[${prefix}] ${message}`;
82
-
83
- switch (level) {
84
- case "debug":
85
- clientLog.debug(formattedMessage, meta);
86
- break;
87
- case "info":
88
- clientLog.info(formattedMessage, meta);
89
- break;
90
- case "warn":
91
- clientLog.warn(formattedMessage, meta);
92
- break;
93
- case "error":
94
- clientLog.error(formattedMessage, meta);
95
- break;
96
- }
97
- }
98
- };
99
-
100
- return {
101
- debug: (message: string, meta?: Record<string, unknown>) => log("debug", message, meta),
102
- info: (message: string, meta?: Record<string, unknown>) => log("info", message, meta),
103
- warn: (message: string, meta?: Record<string, unknown>) => log("warn", message, meta),
104
- error: (message: string, meta?: Record<string, unknown>) => log("error", message, meta),
105
- };
106
- }
107
-
108
- /**
109
- * Default logger instance for general use.
110
- */
111
- export const log = createLogger("qwen");