@gajae-code/ai 0.12.7 → 0.12.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/types/auth-storage.d.ts +33 -6
  3. package/dist/types/index.d.ts +2 -1
  4. package/dist/types/model-manager.d.ts +2 -0
  5. package/dist/types/model-pricing.d.ts +3 -0
  6. package/dist/types/provider-models/special.d.ts +1 -0
  7. package/dist/types/providers/composer-discipline.d.ts +29 -23
  8. package/dist/types/providers/openai-opencodex-responses.d.ts +10 -0
  9. package/dist/types/providers/openai-responses-shared.d.ts +1 -0
  10. package/dist/types/providers/register-builtins.d.ts +2 -2
  11. package/dist/types/types.d.ts +15 -7
  12. package/dist/types/utils/fallback-transport.d.ts +23 -0
  13. package/dist/types/utils/oauth/anthropic.d.ts +21 -2
  14. package/dist/types/utils/oauth/callback-server.d.ts +7 -0
  15. package/dist/types/utils/oauth/types.d.ts +12 -1
  16. package/package.json +2 -2
  17. package/src/auth-gateway/server.ts +6 -0
  18. package/src/auth-storage.ts +325 -39
  19. package/src/index.ts +2 -0
  20. package/src/model-manager.ts +9 -3
  21. package/src/model-pricing.ts +68 -0
  22. package/src/model-thinking.ts +23 -1
  23. package/src/models.json +122 -24
  24. package/src/models.ts +7 -4
  25. package/src/prompts/composer-bash-policy-recovery.md +1 -0
  26. package/src/prompts/cursor-composer-bash-policy-recovery.md +1 -0
  27. package/src/prompts/cursor-composer-edit-discipline.md +7 -0
  28. package/src/provider-models/descriptors.ts +7 -1
  29. package/src/provider-models/special.ts +8 -0
  30. package/src/providers/composer-discipline.ts +54 -0
  31. package/src/providers/cursor.ts +2 -2
  32. package/src/providers/openai-codex/response-handler.ts +24 -2
  33. package/src/providers/openai-codex-responses.ts +5 -1
  34. package/src/providers/openai-completions.ts +109 -23
  35. package/src/providers/openai-opencodex-responses.ts +173 -0
  36. package/src/providers/openai-responses-shared.ts +14 -3
  37. package/src/providers/openai-responses.ts +91 -13
  38. package/src/providers/register-builtins.ts +4 -4
  39. package/src/stream.ts +61 -6
  40. package/src/types.ts +17 -6
  41. package/src/utils/discovery/openai-compatible.ts +18 -2
  42. package/src/utils/fallback-transport.ts +79 -6
  43. package/src/utils/http-inspector.ts +1 -0
  44. package/src/utils/idle-iterator.ts +2 -0
  45. package/src/utils/oauth/anthropic.ts +41 -8
  46. package/src/utils/oauth/callback-server.ts +64 -16
  47. package/src/utils/oauth/index.ts +5 -0
  48. package/src/utils/oauth/types.ts +13 -0
@@ -25,6 +25,7 @@ import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-s
25
25
  import { transportFailureFacts } from "../utils/fallback-transport";
26
26
  import {
27
27
  FirstEventTimeoutError,
28
+ getProviderFirstEventTimeoutFallbackMs,
28
29
  getStreamFirstEventTimeoutMs,
29
30
  getStreamIdleTimeoutMs,
30
31
  iterateWithIdleTimeout,
@@ -197,13 +198,12 @@ interface LazyStreamLimits {
197
198
  const GOOGLE_GEMINI_CLI_LAZY_STREAM_LIMITS: LazyStreamLimits = {
198
199
  defaultFirstEventTimeoutMs: 300_000,
199
200
  };
200
- const SLOW_FIRST_EVENT_PROVIDERS = new Set(["alibaba-token-plan", "kimi-code"]);
201
201
 
202
202
  /**
203
203
  * Resolves the first-event timeout fallback for the outer lazy-stream watchdog.
204
204
  * A configured wrapper-specific fallback (from `LazyStreamLimits`) always wins;
205
- * otherwise providers known to have slow first events get a five-minute floor
206
- * matching their inner provider-level override. Returns `undefined` for
205
+ * otherwise providers known to have slow first events use the same centralized
206
+ * fallback as their inner provider-level watchdog. Returns `undefined` for
207
207
  * providers that should use the shared default.
208
208
  */
209
209
  export function resolveLazyStreamFirstEventFallbackMs(
@@ -211,7 +211,7 @@ export function resolveLazyStreamFirstEventFallbackMs(
211
211
  configuredFallbackMs?: number,
212
212
  ): number | undefined {
213
213
  if (configuredFallbackMs !== undefined) return configuredFallbackMs;
214
- return SLOW_FIRST_EVENT_PROVIDERS.has(provider) ? 300_000 : undefined;
214
+ return getProviderFirstEventTimeoutFallbackMs(provider);
215
215
  }
216
216
 
217
217
  function forwardStream<TApi extends Api>(
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
 
@@ -326,7 +326,7 @@ export function stream<TApi extends Api>(
326
326
  return streamBedrock(model as Model<"bedrock-converse-stream">, context, (options || {}) as BedrockOptions);
327
327
  }
328
328
 
329
- const apiKey = options?.apiKey || getEnvApiKey(model.provider);
329
+ const apiKey = options?.apiKey || (model.provider === "opencodex" ? "local" : getEnvApiKey(model.provider));
330
330
  if (!apiKey) {
331
331
  throw new Error(formatMissingApiKeyError(model.provider));
332
332
  }
@@ -396,13 +396,61 @@ function extractStatusFromAssistantError(message: AssistantMessage): number | un
396
396
  return extractHttpStatusFromError({ message: message.errorMessage });
397
397
  }
398
398
 
399
- function createAssistantAuthError(message: AssistantMessage): Error & { status?: number } {
400
- const error: Error & { status?: number } = new Error(message.errorMessage ?? "Provider authentication failed");
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
- extractStatusFromAssistantError(event.error) === 401
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 (!emittedReplayUnsafeEvent && captureAuthFailure && extractHttpStatusFromError(error) === 401) {
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
@@ -124,6 +124,7 @@ export type KnownProvider =
124
124
  | "google-vertex"
125
125
  | "openai"
126
126
  | "openai-codex"
127
+ | "opencodex"
127
128
  | "kimi-code"
128
129
  | "minimax-code"
129
130
  | "minimax-code-cn"
@@ -989,6 +990,19 @@ export interface ModelRequestTransform {
989
990
  extraBody?: Record<string, unknown>;
990
991
  }
991
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
+
992
1006
  export interface Model<TApi extends Api = any> {
993
1007
  id: string;
994
1008
  name: string;
@@ -1005,12 +1019,9 @@ export interface Model<TApi extends Api = any> {
1005
1019
  * provider/id heuristics.
1006
1020
  */
1007
1021
  output?: ("text" | "image")[];
1008
- cost: {
1009
- input: number; // $/million tokens
1010
- output: number; // $/million tokens
1011
- cacheRead: number; // $/million tokens
1012
- cacheWrite: number; // $/million tokens
1013
- };
1022
+ cost: ModelCost;
1023
+ /** Optional long-context rates selected from the request's total input-token count. */
1024
+ longContextPricing?: LongContextPricing;
1014
1025
  /** Premium Copilot requests charged per user-initiated request (defaults to 1). */
1015
1026
  premiumMultiplier?: number;
1016
1027
  contextWindow: number;
@@ -125,7 +125,7 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
125
125
  const fetchImpl = options.fetch ?? globalThis.fetch;
126
126
  let response: Response;
127
127
  try {
128
- response = await fetchImpl(`${baseUrl}${MODELS_PATH}`, {
128
+ response = await fetchImpl(buildModelsUrl(baseUrl), {
129
129
  method: "GET",
130
130
  headers: requestHeaders,
131
131
  signal: options.signal,
@@ -193,7 +193,23 @@ function normalizeBaseUrl(baseUrl: string): string {
193
193
  if (!trimmed) {
194
194
  return "";
195
195
  }
196
- return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
196
+ try {
197
+ const parsed = new URL(trimmed);
198
+ parsed.pathname = parsed.pathname.replace(/\/+$/g, "");
199
+ return parsed.toString();
200
+ } catch {
201
+ return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
202
+ }
203
+ }
204
+
205
+ function buildModelsUrl(baseUrl: string): string {
206
+ try {
207
+ const parsed = new URL(baseUrl);
208
+ parsed.pathname = `${parsed.pathname.replace(/\/+$/g, "")}${MODELS_PATH}`;
209
+ return parsed.toString();
210
+ } catch {
211
+ return `${baseUrl}${MODELS_PATH}`;
212
+ }
197
213
  }
198
214
 
199
215
  function extractModelEntries(payload: unknown): ParsedOpenAICompatibleModelRecord[] | null {
@@ -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
- const anthropicErrorType = stringValue(propertyOf(nestedError, "type")) ?? stringValue(propertyOf(value, "type"));
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
- function isAuthCode(code: string | undefined): boolean {
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 code = (facts.openaiErrorCode ?? facts.anthropicErrorType ?? facts.providerCode)?.toLowerCase();
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
- return retryAfterMs === undefined ? { class: triggerClass } : { class: triggerClass, retryAfterMs };
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
  }
@@ -267,6 +267,7 @@ export function rewriteCopilotError(errorMessage: string, error: unknown, provid
267
267
  function sanitizeDump(dump: RawHttpRequestDump): RawHttpRequestDump {
268
268
  return {
269
269
  ...dump,
270
+ url: redactRequestUrl(dump.url),
270
271
  headers: redactHeaders(dump.headers),
271
272
  body: sanitizeDumpBody(dump.body),
272
273
  };
@@ -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
- constructor(ctrl: OAuthController) {
99
- super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
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. If the browser cannot reach this machine, paste the final redirect URL or authorization code when prompted.",
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(ctrl: OAuthController): Promise<OAuthCredentials> {
171
- const flow = new AnthropicOAuthFlow(ctrl);
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?.("Waiting for browser authentication...");
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.stop();
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(expectedState: string): Promise<{ server: Bun.Server<unknown>; redirectUri: string }> {
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
- signal.addEventListener("abort", () => {
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 result = await Promise.race([
232
- callbackPromise,
233
- requestManualInput()
234
- .then((input): CallbackResult | null => {
235
- const parsed = parseCallbackInput(input);
236
- if (!parsed.code) return null;
237
- if (expectedState && parsed.state && parsed.state !== expectedState) return null;
238
- return { code: parsed.code, state: parsed.state ?? "" };
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
 
@@ -25,6 +25,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
25
25
  name: "ChatGPT Plus/Pro (Codex Subscription)",
26
26
  available: true,
27
27
  },
28
+ {
29
+ id: "opencodex",
30
+ name: "OpenCodex (local proxy status)",
31
+ available: true,
32
+ },
28
33
  {
29
34
  id: "openai-codex-device",
30
35
  name: "ChatGPT Plus/Pro (Codex, headless/device)",
@@ -60,6 +60,7 @@ export type OAuthProvider =
60
60
  | "xiaomi-token-plan-ams"
61
61
  | "xiaomi-token-plan-cn"
62
62
  | "zenmux"
63
+ | "opencodex"
63
64
  | "zai";
64
65
 
65
66
  export type OAuthProviderId = OAuthProvider | (string & {});
@@ -81,6 +82,18 @@ export interface OAuthProviderInfo {
81
82
  available: boolean;
82
83
  }
83
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
+
84
97
  export interface OAuthController {
85
98
  onAuth?(info: OAuthAuthInfo): void;
86
99
  onProgress?(message: string): void;