@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.
- package/CHANGELOG.md +32 -0
- package/dist/types/auth-broker/discover.d.ts +1 -1
- package/dist/types/auth-retry.d.ts +3 -1
- package/dist/types/auth-storage.d.ts +13 -8
- package/dist/types/providers/anthropic.d.ts +1 -0
- package/dist/types/providers/cursor.d.ts +5 -0
- package/dist/types/providers/openai-shared.d.ts +6 -3
- package/dist/types/providers/register-builtins.d.ts +5 -0
- package/dist/types/registry/oauth/callback-server.d.ts +2 -0
- package/dist/types/registry/oauth/xai-oauth.d.ts +1 -7
- package/dist/types/registry/registry.d.ts +2 -1
- package/dist/types/registry/xai-oauth.d.ts +2 -1
- package/dist/types/types.d.ts +2 -0
- package/dist/types/utils/event-stream.d.ts +7 -0
- package/dist/types/utils/idle-iterator.d.ts +10 -0
- package/package.json +4 -4
- package/src/auth-broker/discover.ts +20 -16
- package/src/auth-broker/remote-store.ts +55 -5
- package/src/auth-gateway/server.ts +1 -0
- package/src/auth-retry.ts +7 -2
- package/src/auth-storage.ts +210 -46
- package/src/providers/anthropic.ts +78 -8
- package/src/providers/cursor.ts +17 -10
- package/src/providers/openai-codex-responses.ts +37 -6
- package/src/providers/openai-responses.ts +1 -1
- package/src/providers/openai-shared.ts +30 -14
- package/src/providers/register-builtins.ts +15 -0
- package/src/registry/oauth/__tests__/xai-oauth.test.ts +55 -0
- package/src/registry/oauth/callback-server.ts +40 -12
- package/src/registry/oauth/xai-oauth.ts +6 -10
- package/src/registry/xai-oauth.ts +2 -1
- package/src/stream.ts +7 -1
- package/src/types.ts +2 -0
- package/src/usage/openai-codex.ts +3 -2
- package/src/utils/event-stream.ts +26 -0
- package/src/utils/idle-iterator.ts +65 -9
package/src/providers/cursor.ts
CHANGED
|
@@ -653,7 +653,8 @@ export interface UsageState {
|
|
|
653
653
|
sawTokenDelta: boolean;
|
|
654
654
|
}
|
|
655
655
|
|
|
656
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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 (
|
|
1239
|
-
|
|
1240
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
1806
|
-
* is successfully processed. Only invoked on the
|
|
1807
|
-
* thrown failure (`response.failed`) and
|
|
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 (
|
|
2301
|
-
const 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
|
-
|
|
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?.(
|
|
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
|
|
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
|
|
233
|
-
*
|
|
234
|
-
*
|
|
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
|
-
|
|
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
|
|
244
|
-
//
|
|
245
|
-
|
|
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
|
|
4
|
+
* xAI Grok (SuperGrok or X Premium+) OAuth flow.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
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> {
|