@gajae-code/ai 0.12.8 → 0.12.11
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/CHANGELOG.md +19 -0
- package/dist/types/auth-storage.d.ts +25 -2
- package/dist/types/index.d.ts +1 -1
- package/dist/types/model-pricing.d.ts +3 -0
- package/dist/types/providers/composer-discipline.d.ts +29 -23
- package/dist/types/providers/openai-responses-shared.d.ts +1 -0
- package/dist/types/providers/register-builtins.d.ts +2 -2
- package/dist/types/types.d.ts +14 -6
- package/dist/types/utils/fallback-transport.d.ts +23 -0
- package/dist/types/utils/oauth/anthropic.d.ts +21 -2
- package/dist/types/utils/oauth/callback-server.d.ts +7 -0
- package/dist/types/utils/oauth/types.d.ts +11 -0
- package/package.json +2 -2
- package/src/auth-gateway/server.ts +6 -0
- package/src/auth-storage.ts +46 -5
- package/src/index.ts +1 -0
- package/src/model-manager.ts +4 -1
- package/src/model-pricing.ts +68 -0
- package/src/model-thinking.ts +23 -1
- package/src/models.json +122 -24
- package/src/models.ts +7 -4
- package/src/prompts/composer-bash-policy-recovery.md +1 -0
- package/src/prompts/cursor-composer-bash-policy-recovery.md +1 -0
- package/src/prompts/cursor-composer-edit-discipline.md +7 -0
- package/src/providers/composer-discipline.ts +54 -0
- package/src/providers/cursor.ts +2 -2
- package/src/providers/openai-completions.ts +23 -15
- package/src/providers/openai-responses-shared.ts +14 -3
- package/src/providers/openai-responses.ts +15 -8
- package/src/providers/register-builtins.ts +4 -4
- package/src/stream.ts +60 -5
- package/src/types.ts +16 -6
- package/src/utils/fallback-transport.ts +79 -6
- package/src/utils/idle-iterator.ts +2 -0
- package/src/utils/oauth/anthropic.ts +41 -8
- package/src/utils/oauth/callback-server.ts +64 -16
- package/src/utils/oauth/types.ts +12 -0
package/src/stream.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { $credentialEnv, $env, $pickCredentialEnv, extractHttpStatusFromError } from "@gajae-code/utils";
|
|
5
|
-
import { assertManagedAttempt } from "./utils/fallback-transport";
|
|
5
|
+
import { assertManagedAttempt, classifyFallbackTrigger, type TransportFailureFacts } from "./utils/fallback-transport";
|
|
6
6
|
|
|
7
7
|
const managedAttemptValidated = Symbol("managedAttemptValidated");
|
|
8
8
|
|
|
@@ -396,13 +396,61 @@ function extractStatusFromAssistantError(message: AssistantMessage): number | un
|
|
|
396
396
|
return extractHttpStatusFromError({ message: message.errorMessage });
|
|
397
397
|
}
|
|
398
398
|
|
|
399
|
-
function createAssistantAuthError(
|
|
400
|
-
|
|
399
|
+
function createAssistantAuthError(
|
|
400
|
+
message: AssistantMessage,
|
|
401
|
+
): Error & { status?: number; transportFailure?: TransportFailureFacts } {
|
|
402
|
+
const error: Error & { status?: number; transportFailure?: TransportFailureFacts } = new Error(
|
|
403
|
+
message.errorMessage ?? "Provider authentication failed",
|
|
404
|
+
);
|
|
401
405
|
const status = extractStatusFromAssistantError(message);
|
|
402
406
|
if (status !== undefined) error.status = status;
|
|
407
|
+
// Preserve the structured facts. Without this the callback receives a
|
|
408
|
+
// status-only error and every downstream `auth` consumer loses the provider
|
|
409
|
+
// code it needs to tell a credential problem from a plain `forbidden`.
|
|
410
|
+
if (message.transportFailure) error.transportFailure = message.transportFailure;
|
|
403
411
|
return error;
|
|
404
412
|
}
|
|
405
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Unwraps a nested `error.transportFailure` carrier.
|
|
416
|
+
*
|
|
417
|
+
* `transportFailureFacts` dereferences `value`, `value.response`, `value.error`
|
|
418
|
+
* and the captured response, but NOT `value.transportFailure` — and that is the
|
|
419
|
+
* shape this repository actually throws for transport errors. Reading the
|
|
420
|
+
* carrier here keeps the shared extractor untouched (its ten production call
|
|
421
|
+
* sites and its idempotence invariant stay as they are) while still letting the
|
|
422
|
+
* auth veto below see the provider code.
|
|
423
|
+
*/
|
|
424
|
+
function carriedTransportFailure(candidate: unknown): unknown {
|
|
425
|
+
if (!candidate || typeof candidate !== "object") return undefined;
|
|
426
|
+
const carried = (candidate as { transportFailure?: unknown }).transportFailure;
|
|
427
|
+
return carried && typeof carried === "object" ? carried : undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Auth-relevant facts for a thrown error or an assistant error, carrier first. */
|
|
431
|
+
function authFailureFacts(candidate: unknown): unknown {
|
|
432
|
+
return carriedTransportFailure(candidate) ?? candidate;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Whether this failure is a credential problem worth retrying with a different
|
|
437
|
+
* credential.
|
|
438
|
+
*
|
|
439
|
+
* Consulted by BOTH capture exits below, and it is the ONLY auth predicate they
|
|
440
|
+
* use. Gating on HTTP 401 alone would contradict the classifier: a typed
|
|
441
|
+
* provider code is supposed to win over the status, so `403 + invalid_api_key`
|
|
442
|
+
* must be captured and `401 + forbidden` must not. A `forbidden` failure is an
|
|
443
|
+
* authorization or configuration defect — handing it to `onAuthError` lets the
|
|
444
|
+
* gateway and SDK consumers invalidate a perfectly healthy credential.
|
|
445
|
+
*/
|
|
446
|
+
function shouldCaptureAuthFailure(candidate: unknown, statusHint: number | undefined): boolean {
|
|
447
|
+
const trigger = classifyFallbackTrigger(authFailureFacts(candidate));
|
|
448
|
+
// Typed auth facts are authoritative and already encode code-over-status.
|
|
449
|
+
if (trigger.class === "auth") return trigger.authDisposition !== "forbidden";
|
|
450
|
+
// Nothing classifiable: keep the historical bare-401 admission.
|
|
451
|
+
return statusHint === 401;
|
|
452
|
+
}
|
|
453
|
+
|
|
406
454
|
function emitBufferedEvents(stream: AssistantMessageEventStream, events: AssistantMessageEvent[]): void {
|
|
407
455
|
for (const event of events) {
|
|
408
456
|
stream.push(event);
|
|
@@ -447,7 +495,9 @@ export function streamSimple<TApi extends Api>(
|
|
|
447
495
|
!emittedReplayUnsafeEvent &&
|
|
448
496
|
captureAuthFailure &&
|
|
449
497
|
event.type === "error" &&
|
|
450
|
-
|
|
498
|
+
// L0 gate, event exit. Classification decides; a typed
|
|
499
|
+
// `forbidden` never becomes an auth retry.
|
|
500
|
+
shouldCaptureAuthFailure(event.error, extractStatusFromAssistantError(event.error))
|
|
451
501
|
) {
|
|
452
502
|
return { error: createAssistantAuthError(event.error), bufferedEvents, terminalEvent: event };
|
|
453
503
|
}
|
|
@@ -459,7 +509,12 @@ export function streamSimple<TApi extends Api>(
|
|
|
459
509
|
flushBuffered();
|
|
460
510
|
if (!outer.done) outer.end(await inner.result());
|
|
461
511
|
} catch (error) {
|
|
462
|
-
if (
|
|
512
|
+
if (
|
|
513
|
+
!emittedReplayUnsafeEvent &&
|
|
514
|
+
captureAuthFailure &&
|
|
515
|
+
// L0 gate, throw exit: same rule, carrier-aware.
|
|
516
|
+
shouldCaptureAuthFailure(error, extractHttpStatusFromError(error))
|
|
517
|
+
) {
|
|
463
518
|
return { error, bufferedEvents };
|
|
464
519
|
}
|
|
465
520
|
flushBuffered();
|
package/src/types.ts
CHANGED
|
@@ -990,6 +990,19 @@ export interface ModelRequestTransform {
|
|
|
990
990
|
extraBody?: Record<string, unknown>;
|
|
991
991
|
}
|
|
992
992
|
|
|
993
|
+
export interface ModelCost {
|
|
994
|
+
input: number; // $/million tokens
|
|
995
|
+
output: number; // $/million tokens
|
|
996
|
+
cacheRead: number; // $/million tokens
|
|
997
|
+
cacheWrite: number; // $/million tokens
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export interface LongContextPricing {
|
|
1001
|
+
/** Input-token count above which the long-context rates apply to the full request. */
|
|
1002
|
+
threshold: number;
|
|
1003
|
+
cost: ModelCost;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
993
1006
|
export interface Model<TApi extends Api = any> {
|
|
994
1007
|
id: string;
|
|
995
1008
|
name: string;
|
|
@@ -1006,12 +1019,9 @@ export interface Model<TApi extends Api = any> {
|
|
|
1006
1019
|
* provider/id heuristics.
|
|
1007
1020
|
*/
|
|
1008
1021
|
output?: ("text" | "image")[];
|
|
1009
|
-
cost:
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
cacheRead: number; // $/million tokens
|
|
1013
|
-
cacheWrite: number; // $/million tokens
|
|
1014
|
-
};
|
|
1022
|
+
cost: ModelCost;
|
|
1023
|
+
/** Optional long-context rates selected from the request's total input-token count. */
|
|
1024
|
+
longContextPricing?: LongContextPricing;
|
|
1015
1025
|
/** Premium Copilot requests charged per user-initiated request (defaults to 1). */
|
|
1016
1026
|
premiumMultiplier?: number;
|
|
1017
1027
|
contextWindow: number;
|
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
export type FallbackTriggerClass = "rate_limit" | "quota" | "auth" | "server" | "unknown" | "other";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Refinement of an `auth` trigger.
|
|
5
|
+
*
|
|
6
|
+
* The transport deliberately collapses HTTP 401 and 403 into a single `auth`
|
|
7
|
+
* class, but the two demand opposite handling: a credential problem may be
|
|
8
|
+
* recoverable by trying a different stored credential, whereas a plain
|
|
9
|
+
* `forbidden` is an authorization or configuration defect that rotation would
|
|
10
|
+
* only hide — it would cycle and block every otherwise-healthy credential.
|
|
11
|
+
*
|
|
12
|
+
* This is a refinement rather than a new {@link FallbackTriggerClass} member so
|
|
13
|
+
* every existing `trigger.class === "auth"` consumer keeps compiling and keeps
|
|
14
|
+
* its current behavior until it explicitly opts into the distinction.
|
|
15
|
+
*/
|
|
16
|
+
export type AuthDisposition = "credential" | "forbidden";
|
|
17
|
+
|
|
3
18
|
export interface FallbackTrigger {
|
|
4
19
|
class: FallbackTriggerClass;
|
|
5
20
|
retryAfterMs?: number;
|
|
21
|
+
/** Present only when `class === "auth"`. */
|
|
22
|
+
authDisposition?: AuthDisposition;
|
|
6
23
|
}
|
|
7
24
|
|
|
8
25
|
/** Stable code for streams that time out before producing semantic progress. */
|
|
@@ -158,7 +175,13 @@ export function transportFailureFacts(
|
|
|
158
175
|
finiteStatus(propertyOf(value, "status")) ??
|
|
159
176
|
finiteStatus(propertyOf(response, "status")) ??
|
|
160
177
|
finiteStatus(propertyOf(capturedResponse, "status"));
|
|
161
|
-
|
|
178
|
+
// `anthropicErrorType` is also read from its own key so re-normalizing an
|
|
179
|
+
// already-built facts object (which consumers do deliberately) preserves it
|
|
180
|
+
// instead of silently dropping the Anthropic code on the second pass.
|
|
181
|
+
const anthropicErrorType =
|
|
182
|
+
stringValue(propertyOf(nestedError, "type")) ??
|
|
183
|
+
stringValue(propertyOf(value, "anthropicErrorType")) ??
|
|
184
|
+
stringValue(propertyOf(value, "type"));
|
|
162
185
|
const openaiErrorCode =
|
|
163
186
|
stringValue(propertyOf(value, "openaiErrorCode")) ?? stringValue(propertyOf(nestedError, "code"));
|
|
164
187
|
const providerCode =
|
|
@@ -229,17 +252,50 @@ function isQuotaCode(code: string | undefined): boolean {
|
|
|
229
252
|
);
|
|
230
253
|
}
|
|
231
254
|
|
|
232
|
-
|
|
255
|
+
const FORBIDDEN_AUTH_CODE = "forbidden";
|
|
256
|
+
|
|
257
|
+
/** Auth codes that name a credential problem rather than an authorization one. */
|
|
258
|
+
function isCredentialAuthCode(code: string | undefined): boolean {
|
|
233
259
|
return (
|
|
234
260
|
code === "authentication_error" ||
|
|
235
261
|
code === "invalid_api_key" ||
|
|
236
262
|
code === "invalid_token" ||
|
|
237
263
|
code === "token_expired" ||
|
|
238
|
-
code === "unauthorized"
|
|
239
|
-
code === "forbidden"
|
|
264
|
+
code === "unauthorized"
|
|
240
265
|
);
|
|
241
266
|
}
|
|
242
267
|
|
|
268
|
+
function isAuthCode(code: string | undefined): boolean {
|
|
269
|
+
return isCredentialAuthCode(code) || code === FORBIDDEN_AUTH_CODE;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Resolves the {@link AuthDisposition} for an `auth` trigger.
|
|
274
|
+
*
|
|
275
|
+
* Precedence is explicit and ordered by specificity rather than by field,
|
|
276
|
+
* because transport facts can carry a first-party typed code and a
|
|
277
|
+
* `providerCode` that disagree:
|
|
278
|
+
*
|
|
279
|
+
* 1. A code naming a concrete credential fault (`invalid_api_key`,
|
|
280
|
+
* `authentication_error`, …) wins, from whichever field it arrives in: it is
|
|
281
|
+
* a specific diagnosis, while `forbidden` is the generic bucket this
|
|
282
|
+
* refinement exists to distrust.
|
|
283
|
+
* 2. Otherwise a `forbidden` code in any field is terminal, so
|
|
284
|
+
* `{status: 401, providerCode: "forbidden"}` does not mutate credentials.
|
|
285
|
+
* 3. Otherwise the HTTP status decides, and an unknown-status `auth` defaults to
|
|
286
|
+
* `credential` because that is the classification the pre-refinement code
|
|
287
|
+
* already produced.
|
|
288
|
+
*
|
|
289
|
+
* Trigger-class selection deliberately keeps its single-code precedence
|
|
290
|
+
* (`openaiErrorCode ?? anthropicErrorType ?? providerCode`); only this auth
|
|
291
|
+
* refinement reads every code field.
|
|
292
|
+
*/
|
|
293
|
+
function resolveAuthDisposition(codes: readonly (string | undefined)[], status: number | undefined): AuthDisposition {
|
|
294
|
+
if (codes.some(code => isCredentialAuthCode(code))) return "credential";
|
|
295
|
+
if (codes.some(code => code === FORBIDDEN_AUTH_CODE)) return "forbidden";
|
|
296
|
+
return status === 403 ? "forbidden" : "credential";
|
|
297
|
+
}
|
|
298
|
+
|
|
243
299
|
function isRateLimitCode(code: string | undefined): boolean {
|
|
244
300
|
return (
|
|
245
301
|
code === "rate_limit" ||
|
|
@@ -259,7 +315,10 @@ export function classifyFallbackTrigger(
|
|
|
259
315
|
const retryAfterMs =
|
|
260
316
|
parseRetryAfterMilliseconds(headers?.get("retry-after-ms") ?? null) ??
|
|
261
317
|
parseRetryAfterSeconds(headers?.get("retry-after") ?? null);
|
|
262
|
-
const
|
|
318
|
+
const codes = [facts.openaiErrorCode, facts.anthropicErrorType, facts.providerCode].map(value =>
|
|
319
|
+
value?.toLowerCase(),
|
|
320
|
+
);
|
|
321
|
+
const code = codes[0] ?? codes[1] ?? codes[2];
|
|
263
322
|
const triggerClass: FallbackTriggerClass =
|
|
264
323
|
code === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
|
|
265
324
|
? "server"
|
|
@@ -272,5 +331,19 @@ export function classifyFallbackTrigger(
|
|
|
272
331
|
: facts.status !== undefined && facts.status >= 500 && facts.status <= 599
|
|
273
332
|
? "server"
|
|
274
333
|
: "other";
|
|
275
|
-
|
|
334
|
+
const trigger: FallbackTrigger = { class: triggerClass };
|
|
335
|
+
if (retryAfterMs !== undefined) trigger.retryAfterMs = retryAfterMs;
|
|
336
|
+
if (triggerClass === "auth") trigger.authDisposition = resolveAuthDisposition(codes, facts.status);
|
|
337
|
+
return trigger;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* True when a failure is an `auth` failure that must NOT rotate credentials.
|
|
342
|
+
*
|
|
343
|
+
* Callers that mutate credential state on auth failures should consult this
|
|
344
|
+
* first so a plain `forbidden` cannot block otherwise-healthy credentials.
|
|
345
|
+
*/
|
|
346
|
+
export function isForbiddenAuthFailure(errorOrFacts: TransportFailureFacts | FallbackTriggerInput | unknown): boolean {
|
|
347
|
+
const trigger = classifyFallbackTrigger(errorOrFacts);
|
|
348
|
+
return trigger.class === "auth" && trigger.authDisposition === "forbidden";
|
|
276
349
|
}
|
|
@@ -3,9 +3,11 @@ import { STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE } from "./fallback-transport";
|
|
|
3
3
|
|
|
4
4
|
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
|
|
5
5
|
const DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000;
|
|
6
|
+
const ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS = 600_000;
|
|
6
7
|
const KIMI_CODE_FIRST_EVENT_TIMEOUT_MS = 300_000;
|
|
7
8
|
|
|
8
9
|
export function getProviderFirstEventTimeoutFallbackMs(provider: string): number | undefined {
|
|
10
|
+
if (provider === "alibaba-token-plan") return ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS;
|
|
9
11
|
return provider === "kimi-code" ? KIMI_CODE_FIRST_EVENT_TIMEOUT_MS : undefined;
|
|
10
12
|
}
|
|
11
13
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Anthropic OAuth flow (Anthropic model Pro/Max)
|
|
3
3
|
*/
|
|
4
|
-
import { OAuthCallbackFlow } from "./callback-server";
|
|
4
|
+
import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
|
|
5
5
|
import { generatePKCE } from "./pkce";
|
|
6
6
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
7
7
|
|
|
@@ -11,6 +11,17 @@ const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
|
|
11
11
|
const TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
|
|
12
12
|
const CALLBACK_PORT = 54545;
|
|
13
13
|
const CALLBACK_PATH = "/callback";
|
|
14
|
+
/**
|
|
15
|
+
* Redirect target for the paste-a-code login. Anthropic renders the
|
|
16
|
+
* authorization code on this page instead of redirecting into this machine, so
|
|
17
|
+
* a gjc running over SSH, in a container, or on a headless box can be paired
|
|
18
|
+
* from a browser that has no route back to `localhost:54545`.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately a hard-coded constant rather than an env/config override: this
|
|
21
|
+
* is where the authorization code is delivered, so making it injectable would
|
|
22
|
+
* turn any writable environment into an auth-code exfiltration channel.
|
|
23
|
+
*/
|
|
24
|
+
export const ANTHROPIC_MANUAL_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
|
|
14
25
|
const SCOPES = "org:create_api_key user:profile user:inference";
|
|
15
26
|
|
|
16
27
|
function formatErrorDetails(error: unknown): string {
|
|
@@ -91,12 +102,30 @@ function extractAccountFromTokenResponse(data: AnthropicTokenResponse): {
|
|
|
91
102
|
};
|
|
92
103
|
}
|
|
93
104
|
|
|
105
|
+
export interface AnthropicOAuthFlowOptions {
|
|
106
|
+
/**
|
|
107
|
+
* Pair by pasting the code Anthropic displays instead of waiting on a local
|
|
108
|
+
* `localhost:54545` callback. Use when the browser completing the login has
|
|
109
|
+
* no network route back to the machine running gjc.
|
|
110
|
+
*/
|
|
111
|
+
manualCode?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
94
114
|
export class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
|
95
115
|
#verifier: string = "";
|
|
96
116
|
#challenge: string = "";
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
117
|
+
readonly #manualCode: boolean;
|
|
118
|
+
|
|
119
|
+
constructor(ctrl: OAuthController, options: AnthropicOAuthFlowOptions = {}) {
|
|
120
|
+
const manualCode = options.manualCode === true;
|
|
121
|
+
const flowOptions: OAuthCallbackFlowOptions = {
|
|
122
|
+
preferredPort: CALLBACK_PORT,
|
|
123
|
+
callbackPath: CALLBACK_PATH,
|
|
124
|
+
redirectUri: manualCode ? ANTHROPIC_MANUAL_REDIRECT_URI : undefined,
|
|
125
|
+
skipCallbackServer: manualCode,
|
|
126
|
+
};
|
|
127
|
+
super(ctrl, flowOptions);
|
|
128
|
+
this.#manualCode = manualCode;
|
|
100
129
|
}
|
|
101
130
|
|
|
102
131
|
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
|
@@ -118,8 +147,9 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
|
|
118
147
|
|
|
119
148
|
return {
|
|
120
149
|
url,
|
|
121
|
-
instructions:
|
|
122
|
-
"Complete login in your browser.
|
|
150
|
+
instructions: this.#manualCode
|
|
151
|
+
? "Complete login in your browser. Anthropic will show an authorization code — paste it here."
|
|
152
|
+
: "Complete login in your browser. If the browser cannot reach this machine, paste the final redirect URL or authorization code when prompted. To pair by code instead, cancel this login and run /login anthropic --manual.",
|
|
123
153
|
};
|
|
124
154
|
}
|
|
125
155
|
|
|
@@ -167,8 +197,11 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
|
|
167
197
|
/**
|
|
168
198
|
* Login with Anthropic OAuth
|
|
169
199
|
*/
|
|
170
|
-
export async function loginAnthropic(
|
|
171
|
-
|
|
200
|
+
export async function loginAnthropic(
|
|
201
|
+
ctrl: OAuthController,
|
|
202
|
+
options: AnthropicOAuthFlowOptions = {},
|
|
203
|
+
): Promise<OAuthCredentials> {
|
|
204
|
+
const flow = new AnthropicOAuthFlow(ctrl, options);
|
|
172
205
|
return flow.login();
|
|
173
206
|
}
|
|
174
207
|
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Handles:
|
|
5
5
|
* - Port allocation (tries expected port, falls back to random)
|
|
6
6
|
* - Callback server setup and request handling
|
|
7
|
+
* - Opting out of the local listener entirely (`skipCallbackServer`) for
|
|
8
|
+
* providers that redirect somewhere this process cannot observe
|
|
7
9
|
* - Common OAuth flow logic
|
|
8
10
|
*
|
|
9
11
|
* Providers extend this and implement:
|
|
@@ -27,6 +29,13 @@ export interface OAuthCallbackFlowOptions {
|
|
|
27
29
|
callbackBindHostname?: string;
|
|
28
30
|
/** Exact redirect URI advertised to the provider; disables port fallback. */
|
|
29
31
|
redirectUri?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Do not bind a local listener at all. The provider redirects somewhere this
|
|
34
|
+
* process cannot observe (a hosted "copy this code" page, a custom protocol),
|
|
35
|
+
* so the code arrives by paste instead. Requires both `redirectUri` and an
|
|
36
|
+
* `onManualCodeInput` handler on the controller.
|
|
37
|
+
*/
|
|
38
|
+
skipCallbackServer?: boolean;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/**
|
|
@@ -39,6 +48,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
39
48
|
callbackHostname: string;
|
|
40
49
|
callbackBindHostname: string;
|
|
41
50
|
redirectUri?: string;
|
|
51
|
+
readonly #skipCallbackServer: boolean;
|
|
42
52
|
#callbackResolve?: (result: CallbackResult) => void;
|
|
43
53
|
#callbackReject?: (error: string) => void;
|
|
44
54
|
|
|
@@ -53,6 +63,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
53
63
|
this.callbackPath = callbackPath;
|
|
54
64
|
this.callbackHostname = DEFAULT_HOSTNAME;
|
|
55
65
|
this.callbackBindHostname = DEFAULT_HOSTNAME;
|
|
66
|
+
this.#skipCallbackServer = false;
|
|
56
67
|
return;
|
|
57
68
|
}
|
|
58
69
|
|
|
@@ -61,6 +72,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
61
72
|
this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
|
|
62
73
|
this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? this.callbackHostname;
|
|
63
74
|
this.redirectUri = preferredPortOrOptions.redirectUri;
|
|
75
|
+
this.#skipCallbackServer = preferredPortOrOptions.skipCallbackServer === true;
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
/**
|
|
@@ -95,6 +107,13 @@ export abstract class OAuthCallbackFlow {
|
|
|
95
107
|
* Execute the OAuth login flow.
|
|
96
108
|
*/
|
|
97
109
|
async login(): Promise<OAuthCredentials> {
|
|
110
|
+
if (this.#skipCallbackServer && !this.ctrl.onManualCodeInput) {
|
|
111
|
+
// Fail before a browser is opened: without a listener and without a paste
|
|
112
|
+
// handler the flow can only sit until the 5-minute timeout.
|
|
113
|
+
throw new Error(
|
|
114
|
+
"OAuth flow is configured without a local callback server, but no manual authorization-code handler was provided",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
98
117
|
const state = this.generateState();
|
|
99
118
|
|
|
100
119
|
// Start callback server first to get actual redirect URI
|
|
@@ -106,7 +125,11 @@ export abstract class OAuthCallbackFlow {
|
|
|
106
125
|
|
|
107
126
|
// Notify controller that auth is ready
|
|
108
127
|
this.ctrl.onAuth?.({ url: authUrl, instructions });
|
|
109
|
-
this.ctrl.onProgress?.(
|
|
128
|
+
this.ctrl.onProgress?.(
|
|
129
|
+
this.#skipCallbackServer
|
|
130
|
+
? "Waiting for the authorization code..."
|
|
131
|
+
: "Waiting for browser authentication...",
|
|
132
|
+
);
|
|
110
133
|
|
|
111
134
|
// Wait for callback or manual input
|
|
112
135
|
const { code } = await this.#waitForCallback(state);
|
|
@@ -115,14 +138,23 @@ export abstract class OAuthCallbackFlow {
|
|
|
115
138
|
|
|
116
139
|
return await this.exchangeToken(code, state, redirectUri);
|
|
117
140
|
} finally {
|
|
118
|
-
server
|
|
141
|
+
server?.stop();
|
|
119
142
|
}
|
|
120
143
|
}
|
|
121
144
|
|
|
122
145
|
/**
|
|
123
146
|
* Start callback server, trying preferred port first, falling back to random.
|
|
147
|
+
* Returns no server when the flow opted out of the local listener.
|
|
124
148
|
*/
|
|
125
|
-
async #startCallbackServer(
|
|
149
|
+
async #startCallbackServer(
|
|
150
|
+
expectedState: string,
|
|
151
|
+
): Promise<{ server: Bun.Server<unknown> | undefined; redirectUri: string }> {
|
|
152
|
+
if (this.#skipCallbackServer) {
|
|
153
|
+
if (!this.redirectUri) {
|
|
154
|
+
throw new Error("OAuth flow skips the local callback server but no redirect URI was configured");
|
|
155
|
+
}
|
|
156
|
+
return { server: undefined, redirectUri: this.redirectUri };
|
|
157
|
+
}
|
|
126
158
|
try {
|
|
127
159
|
const server = this.#createServer(this.preferredPort, expectedState);
|
|
128
160
|
if (this.redirectUri) {
|
|
@@ -216,30 +248,46 @@ export abstract class OAuthCallbackFlow {
|
|
|
216
248
|
this.#callbackResolve = resolve;
|
|
217
249
|
this.#callbackReject = reject;
|
|
218
250
|
|
|
219
|
-
|
|
251
|
+
const cancel = () => {
|
|
220
252
|
this.#callbackResolve = undefined;
|
|
221
253
|
this.#callbackReject = undefined;
|
|
222
254
|
reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
|
|
223
|
-
}
|
|
255
|
+
};
|
|
256
|
+
// A signal that aborted before the listener was attached never fires the
|
|
257
|
+
// event. Without a local listener to fall back on there would be nothing
|
|
258
|
+
// left to settle this promise, so check the current state too.
|
|
259
|
+
if (signal.aborted) {
|
|
260
|
+
cancel();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
signal.addEventListener("abort", cancel);
|
|
224
264
|
});
|
|
225
265
|
|
|
266
|
+
const parseManualInput = (input: string): CallbackResult | null => {
|
|
267
|
+
const parsed = parseCallbackInput(input);
|
|
268
|
+
if (!parsed.code) return null;
|
|
269
|
+
if (expectedState && parsed.state && parsed.state !== expectedState) return null;
|
|
270
|
+
return { code: parsed.code, state: parsed.state ?? "" };
|
|
271
|
+
};
|
|
272
|
+
|
|
226
273
|
// Manual input race (if supported)
|
|
227
274
|
if (this.ctrl.onManualCodeInput) {
|
|
228
275
|
const requestManualInput = this.ctrl.onManualCodeInput;
|
|
229
276
|
const manualPromise = (async (): Promise<CallbackResult> => {
|
|
230
277
|
while (true) {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
.catch((): CallbackResult | null => null),
|
|
241
|
-
]);
|
|
278
|
+
const attempt = requestManualInput().then(parseManualInput);
|
|
279
|
+
// The losing branch of the race can still reject long after the login
|
|
280
|
+
// settled (the pending prompt is cleared on teardown); keep that from
|
|
281
|
+
// surfacing as an unhandled rejection.
|
|
282
|
+
attempt.catch(() => undefined);
|
|
283
|
+
// A rejection that arrives first is a cancellation — the prompt was
|
|
284
|
+
// cleared or superseded — not a bad value. Re-prompting would spin
|
|
285
|
+
// forever, and with no local listener nothing else can settle this.
|
|
286
|
+
const result = await Promise.race([callbackPromise, attempt]);
|
|
242
287
|
if (result) return result;
|
|
288
|
+
// Yield to the macrotask queue so a handler that immediately resolves
|
|
289
|
+
// unusable values cannot starve the abort/timeout timer.
|
|
290
|
+
await Bun.sleep(0);
|
|
243
291
|
}
|
|
244
292
|
})();
|
|
245
293
|
|
package/src/utils/oauth/types.ts
CHANGED
|
@@ -82,6 +82,18 @@ export interface OAuthProviderInfo {
|
|
|
82
82
|
available: boolean;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** Per-login switches that change how the authorization code is delivered. */
|
|
86
|
+
export interface OAuthLoginOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Pair by pasting the authorization code the provider displays instead of
|
|
89
|
+
* waiting on a local loopback callback. Set when the browser completing the
|
|
90
|
+
* login has no network route back to the machine running gjc (SSH, remote
|
|
91
|
+
* container, headless host). Providers without a paste-a-code redirect
|
|
92
|
+
* ignore it.
|
|
93
|
+
*/
|
|
94
|
+
manualCode?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
85
97
|
export interface OAuthController {
|
|
86
98
|
onAuth?(info: OAuthAuthInfo): void;
|
|
87
99
|
onProgress?(message: string): void;
|