@oh-my-pi/pi-ai 16.3.11 → 16.3.13

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 (36) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/types/auth-broker/discover.d.ts +1 -1
  3. package/dist/types/auth-retry.d.ts +3 -1
  4. package/dist/types/auth-storage.d.ts +13 -8
  5. package/dist/types/providers/anthropic.d.ts +1 -0
  6. package/dist/types/providers/cursor.d.ts +5 -0
  7. package/dist/types/providers/openai-shared.d.ts +6 -3
  8. package/dist/types/providers/register-builtins.d.ts +5 -0
  9. package/dist/types/registry/oauth/callback-server.d.ts +2 -0
  10. package/dist/types/registry/oauth/xai-oauth.d.ts +1 -7
  11. package/dist/types/registry/registry.d.ts +2 -1
  12. package/dist/types/registry/xai-oauth.d.ts +2 -1
  13. package/dist/types/types.d.ts +2 -0
  14. package/dist/types/utils/event-stream.d.ts +7 -0
  15. package/dist/types/utils/idle-iterator.d.ts +10 -0
  16. package/package.json +4 -4
  17. package/src/auth-broker/discover.ts +20 -16
  18. package/src/auth-broker/remote-store.ts +55 -5
  19. package/src/auth-gateway/server.ts +1 -0
  20. package/src/auth-retry.ts +7 -2
  21. package/src/auth-storage.ts +210 -46
  22. package/src/providers/anthropic.ts +78 -8
  23. package/src/providers/cursor.ts +17 -10
  24. package/src/providers/openai-codex-responses.ts +37 -6
  25. package/src/providers/openai-responses.ts +1 -1
  26. package/src/providers/openai-shared.ts +30 -14
  27. package/src/providers/register-builtins.ts +15 -0
  28. package/src/registry/oauth/__tests__/xai-oauth.test.ts +55 -0
  29. package/src/registry/oauth/callback-server.ts +40 -12
  30. package/src/registry/oauth/xai-oauth.ts +6 -10
  31. package/src/registry/xai-oauth.ts +2 -1
  32. package/src/stream.ts +7 -1
  33. package/src/types.ts +2 -0
  34. package/src/usage/openai-codex.ts +3 -2
  35. package/src/utils/event-stream.ts +26 -0
  36. package/src/utils/idle-iterator.ts +65 -9
@@ -653,7 +653,8 @@ export interface UsageState {
653
653
  sawTokenDelta: boolean;
654
654
  }
655
655
 
656
- async function handleServerMessage(
656
+ /** Exported for tests: drives one Cursor server message through the stream (exec waits mark the stream busy). */
657
+ export async function handleServerMessage(
657
658
  msg: AgentServerMessage,
658
659
  output: AssistantMessage,
659
660
  stream: AssistantMessageEventStream,
@@ -675,15 +676,21 @@ async function handleServerMessage(
675
676
  } else if (msgCase === "kvServerMessage") {
676
677
  handleKvServerMessage(msg.message.value as KvServerMessage, blobStore, h2Request);
677
678
  } else if (msgCase === "execServerMessage") {
678
- await handleExecServerMessage(
679
- msg.message.value as ExecServerMessage,
680
- h2Request,
681
- execHandlers,
682
- onToolResult,
683
- requestContextTools,
684
- output,
685
- stream,
686
- state,
679
+ // The server is waiting on OUR local tool result during this window — no
680
+ // AssistantMessageEvent flows until the handler finishes. Mark the wait
681
+ // as local work so the lazy stream idle watchdog attributes the silence
682
+ // to the tool run instead of aborting a healthy stream (issue #4593).
683
+ await stream.trackLocalWork(
684
+ handleExecServerMessage(
685
+ msg.message.value as ExecServerMessage,
686
+ h2Request,
687
+ execHandlers,
688
+ onToolResult,
689
+ requestContextTools,
690
+ output,
691
+ stream,
692
+ state,
693
+ ),
687
694
  );
688
695
  } else if (msgCase === "conversationCheckpointUpdate") {
689
696
  handleConversationCheckpointUpdate(msg.message.value, output, usageState, onConversationCheckpoint);
@@ -95,6 +95,7 @@ import {
95
95
  encodeTextSignatureV1,
96
96
  finalizeCustomToolCallInputDone,
97
97
  finalizePendingResponsesToolCalls,
98
+ finalizeReasoningThinking,
98
99
  finalizeToolCallArgumentsDone,
99
100
  isOpenAIResponsesProgressEvent,
100
101
  mapOpenAIResponsesStopReason,
@@ -1232,12 +1233,27 @@ function getOutputBlockStartEventType(block: CodexOutputBlock): "thinking_start"
1232
1233
  return "toolcall_start";
1233
1234
  }
1234
1235
 
1236
+ const CODEX_STALE_PREVIOUS_RESPONSE_CODES: Record<string, true> = {
1237
+ // OpenAI-standard code for an expired/missing `previous_response_id` chain.
1238
+ previous_response_not_found: true,
1239
+ // Proxy-specific: upstream response anchor expired. Same recovery class —
1240
+ // retry the turn with full context and no `previous_response_id`.
1241
+ codex_previous_response_stale: true,
1242
+ };
1243
+
1235
1244
  function isCodexStalePreviousResponseError(error: unknown): boolean {
1236
- if (error instanceof CodexProviderStreamError) return error.code === "previous_response_not_found";
1237
1245
  if (!(error instanceof Error)) return false;
1238
- if ((error as { code?: string }).code === "previous_response_not_found") return true;
1239
- // "unsupported": the backend intermittently rejects the parameter outright
1240
- // with `{"detail":"Unsupported parameter: previous_response_id"}` (no
1246
+ if (
1247
+ "code" in error &&
1248
+ typeof error.code === "string" &&
1249
+ Object.hasOwn(CODEX_STALE_PREVIOUS_RESPONSE_CODES, error.code)
1250
+ ) {
1251
+ return true;
1252
+ }
1253
+ // Message-based fallback for providers/proxies that report the condition
1254
+ // without a canonical code. Also covers "unsupported": the backend
1255
+ // intermittently rejects the parameter outright with
1256
+ // `{"detail":"Unsupported parameter: previous_response_id"}` (no
1241
1257
  // `error.code`); treat it like a stale chain so the turn replays with full
1242
1258
  // context instead of surfacing the 400.
1243
1259
  return (
@@ -1392,6 +1408,21 @@ class CodexStreamProcessor {
1392
1408
  return firstTokenTime;
1393
1409
  }
1394
1410
 
1411
+ if (eventType === "response.reasoning_text.delta") {
1412
+ const entry = this.runtime.openItemForEvent(rawEvent);
1413
+ const delta = typeof rawEvent.delta === "string" ? rawEvent.delta : "";
1414
+ if (entry?.item.type === "reasoning" && entry.block?.type === "thinking") {
1415
+ entry.block.thinking += delta;
1416
+ stream.push({
1417
+ type: "thinking_delta",
1418
+ contentIndex: entry.contentIndex,
1419
+ delta,
1420
+ partial: output,
1421
+ });
1422
+ }
1423
+ return firstTokenTime;
1424
+ }
1425
+
1395
1426
  if (eventType === "response.reasoning_summary_part.done") {
1396
1427
  if (this.runtime.currentItem?.type === "reasoning" && this.runtime.currentBlock?.type === "thinking") {
1397
1428
  appendReasoningSummaryPartDone(
@@ -1507,13 +1538,13 @@ class CodexStreamProcessor {
1507
1538
  // most-recently-added block may belong to a sibling (#2619). Some Codex
1508
1539
  // function/custom tool items omit `id`; in that case `output_index` still
1509
1540
  // routes `output_item.done` to the block that received `output_item.added`.
1510
- const itemId = typeof (item as { id?: string }).id === "string" ? (item as { id: string }).id : "";
1541
+ const itemId = "id" in item && typeof item.id === "string" ? item.id : "";
1511
1542
  const entry = (itemId ? runtime.openItems.get(itemId) : null) ?? runtime.openItemForEvent(rawEvent);
1512
1543
  const block = entry?.block ?? null;
1513
1544
  const contentIndex = entry?.contentIndex ?? output.content.length - 1;
1514
1545
 
1515
1546
  if (item.type === "reasoning" && block?.type === "thinking") {
1516
- block.thinking = item.summary?.map(summary => summary.text).join("\n\n") || "";
1547
+ block.thinking = finalizeReasoningThinking(item, block.thinking);
1517
1548
  block.thinkingSignature = JSON.stringify(item);
1518
1549
  stream.push({
1519
1550
  type: "thinking_end",
@@ -668,7 +668,7 @@ const streamOpenAIResponsesOnce = (
668
668
  }
669
669
 
670
670
  // Detect premature stream closure: the HTTP stream ended without the
671
- // provider sending `response.completed` or `response.incomplete`.
671
+ // provider sending a recognized terminal response event.
672
672
  // Custom/proxy providers may drop the connection mid-stream; without
673
673
  // this guard the incomplete output is silently surfaced as a successful
674
674
  // "stop".
@@ -80,6 +80,7 @@ import {
80
80
  import type { ChatCompletionCreateParamsStreaming } from "./openai-chat-wire";
81
81
  import type { InputItem } from "./openai-codex/request-transformer";
82
82
  import type {
83
+ Response as OpenAIResponse,
83
84
  ResponseContentPartAddedEvent,
84
85
  ResponseCreateParamsStreaming,
85
86
  ResponseCustomToolCall,
@@ -1683,6 +1684,14 @@ export function appendReasoningSummaryPart(
1683
1684
  item.summary.push(part);
1684
1685
  }
1685
1686
 
1687
+ /** Chooses the final reasoning text without discarding content already streamed into the block. */
1688
+ export function finalizeReasoningThinking(item: ResponseReasoningItem, streamedThinking: string): string {
1689
+ const summaryThinking = item.summary?.map(part => part.text).join("\n\n") ?? "";
1690
+ if (summaryThinking) return summaryThinking;
1691
+ const contentThinking = item.content?.[0]?.type === "reasoning_text" ? (item.content[0].text ?? "") : "";
1692
+ return contentThinking || streamedThinking || "";
1693
+ }
1694
+
1686
1695
  export function appendReasoningSummaryTextDelta(
1687
1696
  item: ResponseReasoningItem,
1688
1697
  block: ThinkingContent,
@@ -1798,13 +1807,25 @@ export function finalizeCustomToolCallInputDone(block: ResponsesToolCallBlock, i
1798
1807
  block.arguments = { input };
1799
1808
  }
1800
1809
 
1810
+ type OpenAIResponsesTerminalStreamEvent =
1811
+ | Extract<ResponseStreamEvent, { type: "response.completed" | "response.incomplete" }>
1812
+ | { type: "response.done"; response?: Partial<OpenAIResponse> };
1813
+
1814
+ function getOpenAIResponsesTerminalEvent(event: ResponseStreamEvent): OpenAIResponsesTerminalStreamEvent | undefined {
1815
+ const type = (event as { type?: unknown }).type;
1816
+ return type === "response.completed" || type === "response.incomplete" || type === "response.done"
1817
+ ? (event as OpenAIResponsesTerminalStreamEvent)
1818
+ : undefined;
1819
+ }
1820
+
1801
1821
  export interface ProcessResponsesStreamOptions {
1802
1822
  onFirstToken?: () => void;
1803
1823
  onOutputItemDone?: (item: ResponseOutputItem) => void;
1804
1824
  /**
1805
- * Called when a terminal `response.completed` or `response.incomplete` event
1806
- * is successfully processed. Only invoked on the successful-completion path;
1807
- * thrown failure (`response.failed`) and cancellation paths never call this.
1825
+ * Called when a terminal `response.completed`, `response.incomplete`, or
1826
+ * `response.done` event is successfully processed. Only invoked on the
1827
+ * successful-completion path; thrown failure (`response.failed`) and
1828
+ * cancellation paths never call this.
1808
1829
  * Used by callers to detect premature stream closure (i.e. the stream ended
1809
1830
  * without a recognized terminal event).
1810
1831
  */
@@ -2039,6 +2060,7 @@ export async function processResponsesStream<TApi extends Api>(
2039
2060
  let sawFirstToken = false;
2040
2061
 
2041
2062
  for await (const event of openaiStream) {
2063
+ const terminalEvent = getOpenAIResponsesTerminalEvent(event);
2042
2064
  if (event.type === "response.created") {
2043
2065
  output.responseId = event.response.id;
2044
2066
  } else if (event.type === "response.output_item.added") {
@@ -2194,12 +2216,6 @@ export async function processResponsesStream<TApi extends Api>(
2194
2216
  ? lookupOpenItem({ output_index: event.output_index, item_id: item.id ?? item.call_id })
2195
2217
  : lookupOpenItem({ output_index: event.output_index, item_id: item.id });
2196
2218
  if (item.type === "reasoning") {
2197
- const thinking =
2198
- item.summary?.length > 0
2199
- ? item.summary.map(part => part.text).join("\n\n")
2200
- : item.content?.[0]?.type === "reasoning_text"
2201
- ? (item.content[0].text ?? "")
2202
- : "";
2203
2219
  // Prefer the routed entry; the bare itemId find misroutes when ids are
2204
2220
  // absent (`undefined === undefined` matches the FIRST thinking block) and
2205
2221
  // misses entirely when the done-event id drifts from the added-event id.
@@ -2210,12 +2226,12 @@ export async function processResponsesStream<TApi extends Api>(
2210
2226
  | ThinkingContent
2211
2227
  | undefined);
2212
2228
  if (reasoningBlock) {
2213
- reasoningBlock.thinking = thinking;
2229
+ reasoningBlock.thinking = finalizeReasoningThinking(item, reasoningBlock.thinking);
2214
2230
  reasoningBlock.thinkingSignature = JSON.stringify(item);
2215
2231
  stream.push({
2216
2232
  type: "thinking_end",
2217
2233
  contentIndex: contentIndexOf(reasoningBlock),
2218
- content: thinking,
2234
+ content: reasoningBlock.thinking,
2219
2235
  partial: output,
2220
2236
  });
2221
2237
  }
@@ -2297,8 +2313,8 @@ export async function processResponsesStream<TApi extends Api>(
2297
2313
  closeOpenItem(event.output_index, item.id, entry, item.call_id, prefixedFunctionCallItemKey(item.call_id));
2298
2314
  stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
2299
2315
  }
2300
- } else if (event.type === "response.completed" || event.type === "response.incomplete") {
2301
- const response = event.response;
2316
+ } else if (terminalEvent) {
2317
+ const response = terminalEvent.response;
2302
2318
  finalizePendingResponsesToolCalls(output);
2303
2319
  if (response?.id) {
2304
2320
  output.responseId = response.id;
@@ -2336,7 +2352,7 @@ export async function processResponsesStream<TApi extends Api>(
2336
2352
  }
2337
2353
  promoteResponsesToolUseStopReason(output, (response as { end_turn?: boolean } | undefined)?.end_turn);
2338
2354
  options?.onCompleted?.();
2339
- // `response.completed`/`response.incomplete` is the last event of a
2355
+ // `response.completed`/`response.incomplete`/`response.done` is the last event of a
2340
2356
  // Responses stream. Stop pulling instead of waiting for the server to
2341
2357
  // close the connection: misbehaving providers keep the socket open
2342
2358
  // after the terminal event, which would park this loop until the idle
@@ -157,6 +157,7 @@ let openAICompletionsProviderModulePromise: Promise<LazyProviderModule<"openai-c
157
157
  let openAIResponsesProviderModulePromise: Promise<LazyProviderModule<"openai-responses">> | undefined;
158
158
  let ollamaProviderModulePromise: Promise<LazyProviderModule<"ollama-chat">> | undefined;
159
159
  let cursorProviderModulePromise: Promise<LazyProviderModule<"cursor-agent">> | undefined;
160
+ let cursorProviderModuleOverride: LazyProviderModule<"cursor-agent"> | undefined;
160
161
  let devinProviderModulePromise: Promise<LazyProviderModule<"devin-agent">> | undefined;
161
162
  let bedrockProviderModuleOverride: LazyProviderModule<"bedrock-converse-stream"> | undefined;
162
163
  let bedrockProviderModulePromise: Promise<LazyProviderModule<"bedrock-converse-stream">> | undefined;
@@ -167,6 +168,12 @@ export function setBedrockProviderModule(module: BedrockProviderModule): void {
167
168
  };
168
169
  }
169
170
 
171
+ export function setCursorProviderModule(module: CursorProviderModule): void {
172
+ cursorProviderModuleOverride = {
173
+ stream: module.streamCursor,
174
+ };
175
+ }
176
+
170
177
  // ---------------------------------------------------------------------------
171
178
  // Stream forwarding / error helpers
172
179
  // ---------------------------------------------------------------------------
@@ -245,6 +252,10 @@ function forwardStream<TApi extends Api>(
245
252
  (limits?.openAIIdleEnvFloorsFirstEvent
246
253
  ? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs, limits.defaultFirstEventTimeoutMs)
247
254
  : getStreamFirstEventTimeoutMs(idleTimeoutMs, limits?.defaultFirstEventTimeoutMs)));
255
+ // Providers with a server-driven local tool bridge (e.g. the Cursor
256
+ // exec channel) mark their stream busy while a local tool runs; the
257
+ // watchdog must not read that silence as a provider stall (#4593).
258
+ const localWorkSource = source instanceof EventStreamImpl ? source : undefined;
248
259
  const watchedSource = iterateWithIdleTimeout(source, {
249
260
  idleTimeoutMs,
250
261
  firstItemTimeoutMs,
@@ -260,6 +271,7 @@ function forwardStream<TApi extends Api>(
260
271
  // `idleTimeoutMs` while we're still legitimately waiting on the model's
261
272
  // first response (slow first-token from reasoning models, cold proxies, etc.).
262
273
  isProgressItem: event => (event as AssistantMessageEvent).type !== "start",
274
+ hasPendingLocalWork: localWorkSource ? () => localWorkSource.hasPendingLocalWork : undefined,
263
275
  });
264
276
 
265
277
  for await (const event of watchedSource) {
@@ -411,6 +423,9 @@ function loadOllamaProviderModule(): Promise<LazyProviderModule<"ollama-chat">>
411
423
  }
412
424
 
413
425
  function loadCursorProviderModule(): Promise<LazyProviderModule<"cursor-agent">> {
426
+ if (cursorProviderModuleOverride) {
427
+ return Promise.resolve(cursorProviderModuleOverride);
428
+ }
414
429
  cursorProviderModulePromise ||= import("./cursor").then(module => {
415
430
  const provider = module as CursorProviderModule;
416
431
  return { stream: provider.streamCursor };
@@ -69,6 +69,61 @@ describe("XAIOAuthFlow", () => {
69
69
 
70
70
  expect(flow.redirectUri).toBe("http://127.0.0.1:56121/callback");
71
71
  });
72
+
73
+ it("uses pasted-code login without starting a callback server", async () => {
74
+ const serveSpy = vi.spyOn(Bun, "serve").mockImplementation(() => {
75
+ throw new Error("callback server should not start");
76
+ });
77
+ let authUrl = "";
78
+ let tokenRequestBody = "";
79
+ const progress: string[] = [];
80
+ const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
81
+ const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
82
+ if (url.includes("/.well-known/openid-configuration")) {
83
+ return new Response(
84
+ JSON.stringify({
85
+ authorization_endpoint: "https://auth.x.ai/oauth/authorize",
86
+ token_endpoint: "https://auth.x.ai/oauth/token",
87
+ }),
88
+ { status: 200, headers: { "Content-Type": "application/json" } },
89
+ );
90
+ }
91
+ tokenRequestBody = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body ?? "");
92
+ return new Response(
93
+ JSON.stringify({
94
+ access_token: "access-token",
95
+ refresh_token: "refresh-token",
96
+ expires_in: 3600,
97
+ }),
98
+ { status: 200, headers: { "Content-Type": "application/json" } },
99
+ );
100
+ });
101
+
102
+ const flow = new XAIOAuthFlow({
103
+ fetch: fetchMock as unknown as typeof fetch,
104
+ onAuth: info => {
105
+ authUrl = info.url;
106
+ },
107
+ onManualCodeInput: async () => {
108
+ const parsed = new URL(authUrl);
109
+ const redirectUri = parsed.searchParams.get("redirect_uri") ?? "";
110
+ const state = parsed.searchParams.get("state") ?? "";
111
+ return `${redirectUri}?code=code-xyz&state=${encodeURIComponent(state)}`;
112
+ },
113
+ onProgress: message => progress.push(message),
114
+ });
115
+
116
+ const credentials = await flow.login();
117
+ const authorizeUrl = new URL(authUrl);
118
+ const tokenParams = new URLSearchParams(tokenRequestBody);
119
+
120
+ expect(serveSpy).not.toHaveBeenCalled();
121
+ expect(authorizeUrl.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback");
122
+ expect(progress).toContain("Waiting for pasted authorization code...");
123
+ expect(tokenParams.get("code")).toBe("code-xyz");
124
+ expect(credentials.access).toBe("access-token");
125
+ expect(credentials.refresh).toBe("refresh-token");
126
+ });
72
127
  });
73
128
 
74
129
  describe("XAIOAuthFlow.exchangeToken", () => {
@@ -48,6 +48,8 @@ export interface OAuthCallbackFlowOptions {
48
48
  * an actionable message before opening the browser.
49
49
  */
50
50
  allowPortFallback?: boolean;
51
+ /** Skip the local callback server entirely; the user pastes the code or redirect URL back. */
52
+ manualInputOnly?: boolean;
51
53
  }
52
54
 
53
55
  /**
@@ -60,6 +62,7 @@ export abstract class OAuthCallbackFlow {
60
62
  callbackHostname: string;
61
63
  redirectUri?: string;
62
64
  allowPortFallback: boolean;
65
+ #manualInputOnly: boolean;
63
66
  #callbackResolve?: (result: CallbackResult) => void;
64
67
  #callbackReject?: (error: string) => void;
65
68
  /**
@@ -82,6 +85,7 @@ export abstract class OAuthCallbackFlow {
82
85
  this.callbackPath = callbackPath;
83
86
  this.callbackHostname = DEFAULT_HOSTNAME;
84
87
  this.allowPortFallback = true;
88
+ this.#manualInputOnly = false;
85
89
  return;
86
90
  }
87
91
 
@@ -90,6 +94,7 @@ export abstract class OAuthCallbackFlow {
90
94
  this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
91
95
  this.redirectUri = preferredPortOrOptions.redirectUri;
92
96
  this.allowPortFallback = preferredPortOrOptions.allowPortFallback ?? true;
97
+ this.#manualInputOnly = preferredPortOrOptions.manualInputOnly ?? false;
93
98
  }
94
99
 
95
100
  /**
@@ -135,8 +140,12 @@ export abstract class OAuthCallbackFlow {
135
140
  const state = this.generateState();
136
141
  this.#throwIfCancelled();
137
142
 
138
- // Start callback server first to get actual redirect URI
139
- const { server, redirectUri, launchUrl } = await this.#startCallbackServer(state);
143
+ // Start callback server first to get actual redirect URI. Manual-only
144
+ // flows never bind a server the advertised redirect URI is fixed and
145
+ // the user pastes the code/redirect URL back instead.
146
+ const { server, redirectUri, launchUrl } = this.#manualInputOnly
147
+ ? { server: undefined, redirectUri: this.#buildRedirectUri(), launchUrl: undefined }
148
+ : await this.#startCallbackServer(state);
140
149
 
141
150
  try {
142
151
  this.#throwIfCancelled();
@@ -152,9 +161,12 @@ export abstract class OAuthCallbackFlow {
152
161
 
153
162
  // Notify controller that auth is ready
154
163
  this.ctrl.onAuth?.({ url: authUrl, launchUrl, instructions });
155
- this.ctrl.onProgress?.("Waiting for browser authentication...");
164
+ this.ctrl.onProgress?.(
165
+ this.#manualInputOnly
166
+ ? "Waiting for pasted authorization code..."
167
+ : "Waiting for browser authentication...",
168
+ );
156
169
 
157
- // Wait for callback or manual input
158
170
  const { code } = await this.#waitForCallback(state);
159
171
  this.#throwIfCancelled();
160
172
 
@@ -163,10 +175,14 @@ export abstract class OAuthCallbackFlow {
163
175
  return await this.exchangeToken(code, state, redirectUri);
164
176
  } finally {
165
177
  this.#pendingAuthUrl = undefined;
166
- server.stop();
178
+ server?.stop();
167
179
  }
168
180
  }
169
181
 
182
+ #buildRedirectUri(): string {
183
+ return this.redirectUri ?? `http://${this.callbackHostname}:${this.preferredPort}${this.callbackPath}`;
184
+ }
185
+
170
186
  /**
171
187
  * Start callback server, trying preferred port first, falling back to random.
172
188
  * `launchUrl` is `undefined` when the caller configured `callbackPath` to
@@ -229,20 +245,32 @@ export abstract class OAuthCallbackFlow {
229
245
 
230
246
  /**
231
247
  * Build the `/launch` URL served by the callback server bound to `port`, or
232
- * `undefined` when the configured `callbackPath` (or a `redirectUri` whose
233
- * pathname resolves to {@link LAUNCH_PATH}) would collide with the launch
234
- * route. Kept short (~30 chars) so UIs can advertise it as a
248
+ * `undefined` when it must not be advertised:
249
+ * - the configured `callbackPath` (or a `redirectUri` whose pathname
250
+ * resolves to {@link LAUNCH_PATH}) would collide with the launch route;
251
+ * - the flow's `redirectUri` never returns to this loopback server: fixed
252
+ * non-loopback hosts, or custom schemes like GitLab Duo's `vscode://`
253
+ * URI — which `new URL` parses without complaint, so a scheme/host check
254
+ * is required, not just the parse failure path. Advertising a localhost
255
+ * `/launch` target for such flows misrepresents the callback endpoint
256
+ * and hands remote users a URL that resolves nowhere.
257
+ * Kept short (~30 chars) so UIs can advertise it as a
235
258
  * viewport-truncation-safe copy target for the full authorization URL.
236
259
  */
237
260
  #launchUrlIfSafe(port: number): string | undefined {
238
261
  if (this.callbackPath === LAUNCH_PATH) return undefined;
239
262
  if (this.redirectUri) {
240
263
  try {
241
- if (new URL(this.redirectUri).pathname === LAUNCH_PATH) return undefined;
264
+ const parsed = new URL(this.redirectUri);
265
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined;
266
+ if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "[::1]") {
267
+ return undefined;
268
+ }
269
+ if (parsed.pathname === LAUNCH_PATH) return undefined;
242
270
  } catch {
243
- // A non-parseable redirectUri (e.g. `vscode://...` handled elsewhere)
244
- // can't collide with an HTTP `/launch` route fall through and
245
- // advertise the launch URL against the loopback server.
271
+ // A redirectUri even WHATWG URL cannot parse certainly does not
272
+ // return to this server never advertise a launch URL for it.
273
+ return undefined;
246
274
  }
247
275
  }
248
276
  return `http://${this.callbackHostname}:${port}${LAUNCH_PATH}`;
@@ -1,9 +1,10 @@
1
1
  // Ported from NousResearch/hermes-agent (MIT) — hermes_cli/auth.py xAI sections (L93-111, L2979-3160, L5286-5469).
2
2
 
3
3
  /**
4
- * xAI Grok (SuperGrok Subscription) OAuth flow.
4
+ * xAI Grok (SuperGrok or X Premium+) OAuth flow.
5
5
  *
6
- * Loopback PKCE flow on `127.0.0.1:56121/callback`. One token unlocks Grok-4.x
6
+ * Manual-code PKCE flow using `127.0.0.1:56121/callback` as the allowlisted
7
+ * redirect URI. One token unlocks Grok-4.x
7
8
  * chat, Grok Imagine image generation, and Grok Voice TTS via subsequent
8
9
  * commits. Endpoint discovery is hardened against MITM via
9
10
  * {@link validateXAIEndpoint}: any non-HTTPS or non-`x.ai`/`*.x.ai` host is
@@ -196,10 +197,7 @@ function buildXAIAuthorizeUrl(opts: BuildXAIAuthorizeUrlOptions): string {
196
197
  }
197
198
 
198
199
  /**
199
- * xAI Grok OAuth loopback flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
200
- *
201
- * Uses a fixed redirect URI so the callback server fails fast instead of
202
- * falling back to a random port that xAI's redirect_uri allowlist rejects.
200
+ * xAI Grok OAuth code flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
203
201
  */
204
202
  export class XAIOAuthFlow extends OAuthCallbackFlow {
205
203
  #verifier: string = "";
@@ -211,6 +209,7 @@ export class XAIOAuthFlow extends OAuthCallbackFlow {
211
209
  callbackPath: XAI_OAUTH_REDIRECT_PATH,
212
210
  callbackHostname: XAI_OAUTH_REDIRECT_HOST,
213
211
  redirectUri: `http://${XAI_OAUTH_REDIRECT_HOST}:${XAI_OAUTH_REDIRECT_PORT}${XAI_OAUTH_REDIRECT_PATH}`,
212
+ manualInputOnly: true,
214
213
  } satisfies OAuthCallbackFlowOptions);
215
214
  this.#fetch = ctrl.fetch ?? fetch;
216
215
  }
@@ -231,7 +230,7 @@ export class XAIOAuthFlow extends OAuthCallbackFlow {
231
230
 
232
231
  return {
233
232
  url,
234
- instructions: `Complete login in your browser for xAI Grok (SuperGrok). Docs: ${XAI_OAUTH_DOCS_URL}`,
233
+ instructions: `Complete login in your browser for xAI Grok (SuperGrok or X Premium+). Docs: ${XAI_OAUTH_DOCS_URL}`,
235
234
  };
236
235
  }
237
236
 
@@ -308,9 +307,6 @@ export class XAIOAuthFlow extends OAuthCallbackFlow {
308
307
  }
309
308
  }
310
309
 
311
- /**
312
- * Login with xAI Grok OAuth (SuperGrok Subscription).
313
- */
314
310
  export async function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredentials> {
315
311
  return new XAIOAuthFlow(ctrl).login();
316
312
  }
@@ -3,7 +3,7 @@ import type { ProviderDefinition } from "./types";
3
3
 
4
4
  export const xaiOauthProvider = {
5
5
  id: "xai-oauth",
6
- name: "xAI Grok OAuth (SuperGrok Subscription)",
6
+ name: "xAI Grok OAuth (SuperGrok or X Premium+)",
7
7
  login: async (cb: OAuthLoginCallbacks) => {
8
8
  // Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
9
9
  const { loginXAIOAuth } = await import("./oauth/xai-oauth");
@@ -14,4 +14,5 @@ export const xaiOauthProvider = {
14
14
  const { refreshXAIOAuthToken } = await import("./oauth/xai-oauth");
15
15
  return refreshXAIOAuthToken(credentials.refresh);
16
16
  },
17
+ pasteCodeFlow: true,
17
18
  } as const satisfies ProviderDefinition;
package/src/stream.ts CHANGED
@@ -1106,7 +1106,13 @@ export function streamSimple<TApi extends Api>(
1106
1106
  // Caller aborted between attempts: don't mint a fresh token or fire
1107
1107
  // another doomed request — emit the captured failure instead.
1108
1108
  if (signal?.aborted) break;
1109
- const nextKey = await resolveRetryKey(apiKeyResolver, AUTH_RETRY_STEPS[step]!, failure.error, signal);
1109
+ const nextKey = await resolveRetryKey(
1110
+ apiKeyResolver,
1111
+ AUTH_RETRY_STEPS[step]!,
1112
+ failure.error,
1113
+ signal,
1114
+ lastKey,
1115
+ );
1110
1116
  if (nextKey === undefined || nextKey === lastKey) continue;
1111
1117
  lastKey = nextKey;
1112
1118
  const isLastStep = step === AUTH_RETRY_STEPS.length - 1;
package/src/types.ts CHANGED
@@ -715,6 +715,8 @@ export interface AssistantMessage {
715
715
  stopReason: StopReason;
716
716
  stopDetails?: StopDetails | null;
717
717
  errorMessage?: string;
718
+ /** Per-tool abort messages used when an aborted assistant turn needs different placeholder results per tool call. */
719
+ toolCallAbortMessages?: Record<string, string>;
718
720
  /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
719
721
  errorStatus?: number;
720
722
  /** Structured machine-readable error classifier; see `utils/error-id.ts` for bit layout and helpers. */
@@ -285,8 +285,6 @@ function buildUsageLimit(args: {
285
285
  label: usageWindow.label,
286
286
  scope: {
287
287
  provider: "openai-codex",
288
- accountId: args.accountId,
289
- tier: args.planType,
290
288
  windowId: usageWindow.id,
291
289
  shared: true,
292
290
  },
@@ -507,6 +505,9 @@ export const openaiCodexUsageProvider: UsageProvider = {
507
505
  const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
508
506
 
509
507
  export const codexRankingStrategy: CredentialRankingStrategy = {
508
+ blockScope() {
509
+ return "shared";
510
+ },
510
511
  findWindowLimits(report) {
511
512
  const findLimit = (key: "primary" | "secondary"): UsageLimit | undefined => {
512
513
  const direct = report.limits.find(l => l.id === `openai-codex:${key}`);
@@ -10,6 +10,14 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
10
10
  resultSettled = false;
11
11
  #failed = false;
12
12
  #error: unknown = undefined;
13
+ /**
14
+ * Consumer-side local operations currently in flight for this stream — a
15
+ * provider transport waiting on a server-requested local tool bridge
16
+ * (e.g. the Cursor exec channel) before it can send the result upstream.
17
+ * While non-zero, event silence is attributable to our own pending work,
18
+ * not a provider stall; idle watchdogs consult {@link hasPendingLocalWork}.
19
+ */
20
+ #pendingLocalWork = 0;
13
21
  finalResultPromise: Promise<R>;
14
22
  resolveFinalResult!: (result: R) => void;
15
23
  rejectFinalResult!: (err: unknown) => void;
@@ -116,6 +124,24 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
116
124
  result(): Promise<R> {
117
125
  return this.finalResultPromise;
118
126
  }
127
+
128
+ /** True while local work tracked via {@link trackLocalWork} is pending. */
129
+ get hasPendingLocalWork(): boolean {
130
+ return this.#pendingLocalWork > 0;
131
+ }
132
+
133
+ /**
134
+ * Track a local-work promise so idle watchdogs on this stream do not treat
135
+ * the event silence while it is pending as a provider stall.
136
+ */
137
+ async trackLocalWork<TWork>(work: Promise<TWork>): Promise<TWork> {
138
+ this.#pendingLocalWork++;
139
+ try {
140
+ return await work;
141
+ } finally {
142
+ this.#pendingLocalWork--;
143
+ }
144
+ }
119
145
  }
120
146
 
121
147
  export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {