@oh-my-pi/pi-ai 16.3.12 → 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 +13 -0
- package/dist/types/auth-broker/discover.d.ts +1 -1
- package/dist/types/providers/cursor.d.ts +5 -0
- package/dist/types/providers/openai-shared.d.ts +2 -0
- 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/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/providers/anthropic.ts +29 -5
- package/src/providers/cursor.ts +17 -10
- package/src/providers/openai-codex-responses.ts +18 -2
- package/src/providers/openai-shared.ts +10 -8
- 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 +21 -5
- package/src/registry/oauth/xai-oauth.ts +6 -10
- package/src/registry/xai-oauth.ts +2 -1
- package/src/utils/event-stream.ts +26 -0
- package/src/utils/idle-iterator.ts +65 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.13] - 2026-07-09
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Changed the xAI Grok OAuth (`xai-oauth`) provider to use manual code-paste login by default. `/login` now accepts a pasted authorization code or full `http://127.0.0.1:56121/callback?code=...` redirect URL without starting a local callback listener ([#3277](https://github.com/can1357/oh-my-pi/pull/3277) by [@Jaaneek](https://github.com/Jaaneek)).
|
|
10
|
+
- Renamed the xAI Grok OAuth provider in login and credential prompts to "xAI Grok OAuth (SuperGrok or X Premium+)" ([#3277](https://github.com/can1357/oh-my-pi/pull/3277) by [@Jaaneek](https://github.com/Jaaneek)).
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Fixed the generic lazy-stream idle watchdog aborting healthy `cursor-agent` streams with "Provider stream stalled while waiting for the next event" while a Cursor exec-channel local tool (shell/read/grep/write/MCP/…) legitimately ran longer than the idle budget. Provider streams now advertise consumer-side local work in flight and the watchdog slides its deadline instead of aborting; genuinely silent streams still time out. ([#4593](https://github.com/can1357/oh-my-pi/issues/4593))
|
|
15
|
+
- Fixed OpenAI Codex/Responses reasoning streams so streamed thinking content is preserved when the final `output_item.done` reconstructs to an empty summary ([#4918](https://github.com/can1357/oh-my-pi/issues/4918)).
|
|
16
|
+
- Fixed Anthropic streams hanging forever when generation wedges mid-stream (notably long `write` tool calls on Opus 4.8 high/xhigh) while the server keeps sending `ping` keepalives: pings now extend the idle watchdog only within a bounded window (3x the idle timeout) since the last real stream event, so a stalled tool-call stream times out and recovers instead of hanging with no retry path ([#4900](https://github.com/can1357/oh-my-pi/issues/4900)).
|
|
17
|
+
|
|
5
18
|
## [16.3.12] - 2026-07-08
|
|
6
19
|
|
|
7
20
|
### Added
|
|
@@ -19,7 +19,7 @@ export declare function getAuthBrokerTokenFilePath(): string;
|
|
|
19
19
|
* Resolve broker connection configuration using the same precedence as the TUI:
|
|
20
20
|
*
|
|
21
21
|
* 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
|
|
22
|
-
* 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
|
|
22
|
+
* 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml` or `<agentDir>/config.yaml`.
|
|
23
23
|
* 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
|
|
24
24
|
*
|
|
25
25
|
* Returns `null` when no broker URL is configured — callers should fall back to
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import http2 from "node:http2";
|
|
1
2
|
import { type JsonValue } from "@bufbuild/protobuf";
|
|
3
|
+
import type { McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
|
|
4
|
+
import { type AgentServerMessage, type ConversationStateStructure } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
|
|
2
5
|
import type { AssistantMessage, CursorExecHandlerResult, CursorExecHandlers, CursorToolResultHandler, Message, StreamFunction, StreamOptions, TextContent, ThinkingContent, ToolCall, ToolResultMessage } from "../types.js";
|
|
3
6
|
import { kCursorExecResolved, kStreamingBlockIndex, kStreamingBlockKind, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols.js";
|
|
4
7
|
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
|
@@ -39,6 +42,8 @@ export interface BlockState {
|
|
|
39
42
|
export interface UsageState {
|
|
40
43
|
sawTokenDelta: boolean;
|
|
41
44
|
}
|
|
45
|
+
/** Exported for tests: drives one Cursor server message through the stream (exec waits mark the stream busy). */
|
|
46
|
+
export declare function handleServerMessage(msg: AgentServerMessage, output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState, blobStore: Map<string, Uint8Array>, h2Request: http2.ClientHttp2Stream, execHandlers: CursorExecHandlers | undefined, onToolResult: CursorToolResultHandler | undefined, usageState: UsageState, requestContextTools: McpToolDefinition[], onConversationCheckpoint?: (checkpoint: ConversationStateStructure) => void): Promise<void>;
|
|
42
47
|
/** Exported for tests: verifies handler is invoked with correct `this` when passed as bound. */
|
|
43
48
|
export declare function resolveExecHandler<TArgs, TResult>(args: TArgs, handler: ((args: TArgs) => Promise<CursorExecHandlerResult<TResult>>) | undefined, onToolResult: CursorToolResultHandler | undefined, buildFromToolResult: (toolResult: ToolResultMessage) => TResult, buildRejected: (reason: string) => TResult, buildError: (error: string) => TResult): Promise<{
|
|
44
49
|
execResult: TResult;
|
|
@@ -382,6 +382,8 @@ type ResponsesToolCallBlock = ToolCall & {
|
|
|
382
382
|
[kStreamingLastParseLen]?: number;
|
|
383
383
|
};
|
|
384
384
|
export declare function appendReasoningSummaryPart(item: ResponseReasoningItem, part: ResponseReasoningItem["summary"][number]): void;
|
|
385
|
+
/** Chooses the final reasoning text without discarding content already streamed into the block. */
|
|
386
|
+
export declare function finalizeReasoningThinking(item: ResponseReasoningItem, streamedThinking: string): string;
|
|
385
387
|
export declare function appendReasoningSummaryTextDelta(item: ResponseReasoningItem, block: ThinkingContent, delta: string, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
|
|
386
388
|
export declare function appendReasoningSummaryPartDone(item: ResponseReasoningItem, block: ThinkingContent, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
|
|
387
389
|
export declare function appendMessageContentPart(item: ResponseOutputMessage, part: ResponseContentPartAddedEvent["part"] | undefined): void;
|
|
@@ -13,10 +13,15 @@
|
|
|
13
13
|
import type { AssistantMessageEventStream, Context, Model, OptionsForApi } from "../types.js";
|
|
14
14
|
import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream.js";
|
|
15
15
|
import type { BedrockOptions } from "./amazon-bedrock.js";
|
|
16
|
+
import type { CursorOptions } from "./cursor.js";
|
|
17
|
+
interface CursorProviderModule {
|
|
18
|
+
streamCursor: (model: Model<"cursor-agent">, context: Context, options: CursorOptions) => AssistantMessageEventStream;
|
|
19
|
+
}
|
|
16
20
|
interface BedrockProviderModule {
|
|
17
21
|
streamBedrock: (model: Model<"bedrock-converse-stream">, context: Context, options: BedrockOptions) => AssistantMessageEventStream;
|
|
18
22
|
}
|
|
19
23
|
export declare function setBedrockProviderModule(module: BedrockProviderModule): void;
|
|
24
|
+
export declare function setCursorProviderModule(module: CursorProviderModule): void;
|
|
20
25
|
export declare const streamAnthropic: (model: Model<"anthropic-messages">, context: Context, options: OptionsForApi<"anthropic-messages">) => EventStreamImpl;
|
|
21
26
|
export declare const streamAzureOpenAIResponses: (model: Model<"azure-openai-responses">, context: Context, options: OptionsForApi<"azure-openai-responses">) => EventStreamImpl;
|
|
22
27
|
export declare const streamGoogle: (model: Model<"google-generative-ai">, context: Context, options: OptionsForApi<"google-generative-ai">) => EventStreamImpl;
|
|
@@ -23,6 +23,8 @@ export interface OAuthCallbackFlowOptions {
|
|
|
23
23
|
* an actionable message before opening the browser.
|
|
24
24
|
*/
|
|
25
25
|
allowPortFallback?: boolean;
|
|
26
|
+
/** Skip the local callback server entirely; the user pastes the code or redirect URL back. */
|
|
27
|
+
manualInputOnly?: boolean;
|
|
26
28
|
}
|
|
27
29
|
/**
|
|
28
30
|
* Abstract base class for OAuth flows with local callback servers.
|
|
@@ -24,10 +24,7 @@ export declare function validateXAIEndpoint(url: string, field: string): string;
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function isXAIAccessTokenExpiring(jwt: string, skewSeconds?: number): boolean;
|
|
26
26
|
/**
|
|
27
|
-
* xAI Grok OAuth
|
|
28
|
-
*
|
|
29
|
-
* Uses a fixed redirect URI so the callback server fails fast instead of
|
|
30
|
-
* falling back to a random port that xAI's redirect_uri allowlist rejects.
|
|
27
|
+
* xAI Grok OAuth code flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
|
|
31
28
|
*/
|
|
32
29
|
export declare class XAIOAuthFlow extends OAuthCallbackFlow {
|
|
33
30
|
#private;
|
|
@@ -38,9 +35,6 @@ export declare class XAIOAuthFlow extends OAuthCallbackFlow {
|
|
|
38
35
|
}>;
|
|
39
36
|
exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials>;
|
|
40
37
|
}
|
|
41
|
-
/**
|
|
42
|
-
* Login with xAI Grok OAuth (SuperGrok Subscription).
|
|
43
|
-
*/
|
|
44
38
|
export declare function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredentials>;
|
|
45
39
|
/**
|
|
46
40
|
* Refresh an xAI OAuth access token using a stored refresh_token.
|
|
@@ -265,9 +265,10 @@ declare const ALL: ({
|
|
|
265
265
|
readonly name: "xAI";
|
|
266
266
|
} | {
|
|
267
267
|
readonly id: "xai-oauth";
|
|
268
|
-
readonly name: "xAI Grok OAuth (SuperGrok
|
|
268
|
+
readonly name: "xAI Grok OAuth (SuperGrok or X Premium+)";
|
|
269
269
|
readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<import("./oauth/index.js").OAuthCredentials>;
|
|
270
270
|
readonly refreshToken: (credentials: import("./oauth/index.js").OAuthCredentials) => Promise<import("./oauth/index.js").OAuthCredentials>;
|
|
271
|
+
readonly pasteCodeFlow: true;
|
|
271
272
|
} | {
|
|
272
273
|
readonly id: "xiaomi";
|
|
273
274
|
readonly name: "Xiaomi MiMo";
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types.js";
|
|
2
2
|
export declare const xaiOauthProvider: {
|
|
3
3
|
readonly id: "xai-oauth";
|
|
4
|
-
readonly name: "xAI Grok OAuth (SuperGrok
|
|
4
|
+
readonly name: "xAI Grok OAuth (SuperGrok or X Premium+)";
|
|
5
5
|
readonly login: (cb: OAuthLoginCallbacks) => Promise<OAuthCredentials>;
|
|
6
6
|
readonly refreshToken: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
|
|
7
|
+
readonly pasteCodeFlow: true;
|
|
7
8
|
};
|
|
@@ -22,6 +22,13 @@ export declare class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
22
22
|
fail(err: unknown): void;
|
|
23
23
|
[Symbol.asyncIterator](): AsyncIterator<T>;
|
|
24
24
|
result(): Promise<R>;
|
|
25
|
+
/** True while local work tracked via {@link trackLocalWork} is pending. */
|
|
26
|
+
get hasPendingLocalWork(): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Track a local-work promise so idle watchdogs on this stream do not treat
|
|
29
|
+
* the event silence while it is pending as a provider stall.
|
|
30
|
+
*/
|
|
31
|
+
trackLocalWork<TWork>(work: Promise<TWork>): Promise<TWork>;
|
|
25
32
|
}
|
|
26
33
|
export declare class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
|
27
34
|
constructor();
|
|
@@ -83,6 +83,16 @@ export interface IdleTimeoutIteratorOptions {
|
|
|
83
83
|
* keepalive/no-op events from keeping a stalled tool call alive forever.
|
|
84
84
|
*/
|
|
85
85
|
isProgressItem?: (item: unknown) => boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Reports consumer-side local work in flight for the stream: the provider
|
|
88
|
+
* transport is waiting on a server-requested local tool bridge (e.g. the
|
|
89
|
+
* Cursor exec channel) before anything can flow upstream again. While it
|
|
90
|
+
* returns true, an expired idle / first-item deadline slides forward
|
|
91
|
+
* instead of aborting — the silence is ours, not a provider stall. The
|
|
92
|
+
* watchdog re-arms with a full budget once the local work completes, so a
|
|
93
|
+
* provider that stalls afterwards is still caught.
|
|
94
|
+
*/
|
|
95
|
+
hasPendingLocalWork?: () => boolean;
|
|
86
96
|
/**
|
|
87
97
|
* Cancel iteration as soon as this signal aborts. Required for caller-driven
|
|
88
98
|
* cancellation (ESC) when the underlying transport does not surface signal
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.3.
|
|
4
|
+
"version": "16.3.13",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.3.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.3.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.3.13",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.3.13",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.3.13",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Broker-aware auth-storage discovery used by both the coding-agent runtime and
|
|
3
|
-
* the catalog model generator. Keeps the precedence logic (env → config.yml →
|
|
3
|
+
* the catalog model generator. Keeps the precedence logic (env → config.yml/config.yaml →
|
|
4
4
|
* token file → local SQLite) in one place so build-time tooling sees the same
|
|
5
5
|
* credentials as the TUI.
|
|
6
6
|
*/
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getConfigRootDir,
|
|
13
13
|
isEnoent,
|
|
14
14
|
logger,
|
|
15
|
+
MAIN_CONFIG_FILENAMES,
|
|
15
16
|
} from "@oh-my-pi/pi-utils";
|
|
16
17
|
import { YAML } from "bun";
|
|
17
18
|
import { AuthStorage } from "../auth-storage";
|
|
@@ -72,21 +73,24 @@ interface ConfigSnapshot {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
76
|
+
for (const filename of MAIN_CONFIG_FILENAMES) {
|
|
77
|
+
const configPath = path.join(agentDir, filename);
|
|
78
|
+
try {
|
|
79
|
+
const raw = await Bun.file(configPath).text();
|
|
80
|
+
const parsed = YAML.parse(raw);
|
|
81
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
82
|
+
const record = parsed as Record<string, unknown>;
|
|
83
|
+
const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
|
|
84
|
+
const token =
|
|
85
|
+
typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
|
|
86
|
+
return { url, token };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (isEnoent(err)) continue;
|
|
89
|
+
logger.warn("auth-broker config unreadable", { path: configPath, error: String(err) });
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
89
92
|
}
|
|
93
|
+
return {};
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
function resolveSnapshotTtlMs(): number {
|
|
@@ -104,7 +108,7 @@ function resolveSnapshotTtlMs(): number {
|
|
|
104
108
|
* Resolve broker connection configuration using the same precedence as the TUI:
|
|
105
109
|
*
|
|
106
110
|
* 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
|
|
107
|
-
* 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
|
|
111
|
+
* 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml` or `<agentDir>/config.yaml`.
|
|
108
112
|
* 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
|
|
109
113
|
*
|
|
110
114
|
* Returns `null` when no broker URL is configured — callers should fall back to
|
|
@@ -1462,6 +1462,16 @@ async function* observeDecodedAnthropicSdkEvents(
|
|
|
1462
1462
|
|
|
1463
1463
|
const PROVIDER_MAX_RETRIES = 10;
|
|
1464
1464
|
|
|
1465
|
+
/**
|
|
1466
|
+
* How long `ping` keepalives may keep extending the idle deadline without any
|
|
1467
|
+
* semantic stream progress, as a multiple of the idle timeout. Anthropic pings
|
|
1468
|
+
* across legitimate generation gaps, so pings count as liveness — but a wedged
|
|
1469
|
+
* upstream that pings forever while producing no events must eventually trip
|
|
1470
|
+
* the idle watchdog instead of hanging an active tool-call stream without a
|
|
1471
|
+
* recovery path (#4900).
|
|
1472
|
+
*/
|
|
1473
|
+
const PING_PROGRESS_MAX_IDLE_MULTIPLIER = 3;
|
|
1474
|
+
|
|
1465
1475
|
/**
|
|
1466
1476
|
* Log a malformed-stream-envelope anomaly without aborting the turn. The strict
|
|
1467
1477
|
* parser would `throw new AnthropicStreamEnvelopeError(...)` here; we instead
|
|
@@ -2007,11 +2017,20 @@ const streamAnthropicOnce = (
|
|
|
2007
2017
|
}
|
|
2008
2018
|
>();
|
|
2009
2019
|
|
|
2010
|
-
// Pings keep the idle deadline alive once content is flowing
|
|
2011
|
-
//
|
|
2012
|
-
//
|
|
2013
|
-
//
|
|
2020
|
+
// Pings keep the idle deadline alive once content is flowing (Anthropic
|
|
2021
|
+
// bridges legitimate generation gaps with keepalives), but only within a
|
|
2022
|
+
// bounded window: a wedged upstream that pings forever while the model
|
|
2023
|
+
// produces nothing must still trip the idle watchdog, otherwise an
|
|
2024
|
+
// active tool-call stream hangs unrecoverably with no retry (#4900).
|
|
2025
|
+
// A ping before message_start must not consume the first-event watchdog
|
|
2026
|
+
// either: it would flip the (retryable) pre-content stall classification
|
|
2027
|
+
// into a terminal mid-stream idle timeout.
|
|
2014
2028
|
let sawNonPingEvent = false;
|
|
2029
|
+
let lastNonPingProgressAtMs = 0;
|
|
2030
|
+
const pingProgressCapMs =
|
|
2031
|
+
idleTimeoutMs !== undefined && idleTimeoutMs > 0
|
|
2032
|
+
? idleTimeoutMs * PING_PROGRESS_MAX_IDLE_MULTIPLIER
|
|
2033
|
+
: undefined;
|
|
2015
2034
|
const timedAnthropicStream = iterateWithIdleTimeout(anthropicStream, {
|
|
2016
2035
|
idleTimeoutMs,
|
|
2017
2036
|
firstItemTimeoutMs: firstEventTimeoutMs,
|
|
@@ -2021,8 +2040,13 @@ const streamAnthropicOnce = (
|
|
|
2021
2040
|
onFirstItemTimeout: () => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
|
|
2022
2041
|
abortSignal: options?.signal,
|
|
2023
2042
|
isProgressItem: item => {
|
|
2024
|
-
if ((item as AnthropicStreamEvent).type === "ping")
|
|
2043
|
+
if ((item as AnthropicStreamEvent).type === "ping") {
|
|
2044
|
+
if (!sawNonPingEvent) return false;
|
|
2045
|
+
if (pingProgressCapMs === undefined) return true;
|
|
2046
|
+
return Date.now() - lastNonPingProgressAtMs < pingProgressCapMs;
|
|
2047
|
+
}
|
|
2025
2048
|
sawNonPingEvent = true;
|
|
2049
|
+
lastNonPingProgressAtMs = Date.now();
|
|
2026
2050
|
return true;
|
|
2027
2051
|
},
|
|
2028
2052
|
});
|
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,
|
|
@@ -1407,6 +1408,21 @@ class CodexStreamProcessor {
|
|
|
1407
1408
|
return firstTokenTime;
|
|
1408
1409
|
}
|
|
1409
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
|
+
|
|
1410
1426
|
if (eventType === "response.reasoning_summary_part.done") {
|
|
1411
1427
|
if (this.runtime.currentItem?.type === "reasoning" && this.runtime.currentBlock?.type === "thinking") {
|
|
1412
1428
|
appendReasoningSummaryPartDone(
|
|
@@ -1522,13 +1538,13 @@ class CodexStreamProcessor {
|
|
|
1522
1538
|
// most-recently-added block may belong to a sibling (#2619). Some Codex
|
|
1523
1539
|
// function/custom tool items omit `id`; in that case `output_index` still
|
|
1524
1540
|
// routes `output_item.done` to the block that received `output_item.added`.
|
|
1525
|
-
const itemId =
|
|
1541
|
+
const itemId = "id" in item && typeof item.id === "string" ? item.id : "";
|
|
1526
1542
|
const entry = (itemId ? runtime.openItems.get(itemId) : null) ?? runtime.openItemForEvent(rawEvent);
|
|
1527
1543
|
const block = entry?.block ?? null;
|
|
1528
1544
|
const contentIndex = entry?.contentIndex ?? output.content.length - 1;
|
|
1529
1545
|
|
|
1530
1546
|
if (item.type === "reasoning" && block?.type === "thinking") {
|
|
1531
|
-
block.thinking = item
|
|
1547
|
+
block.thinking = finalizeReasoningThinking(item, block.thinking);
|
|
1532
1548
|
block.thinkingSignature = JSON.stringify(item);
|
|
1533
1549
|
stream.push({
|
|
1534
1550
|
type: "thinking_end",
|
|
@@ -1684,6 +1684,14 @@ export function appendReasoningSummaryPart(
|
|
|
1684
1684
|
item.summary.push(part);
|
|
1685
1685
|
}
|
|
1686
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
|
+
|
|
1687
1695
|
export function appendReasoningSummaryTextDelta(
|
|
1688
1696
|
item: ResponseReasoningItem,
|
|
1689
1697
|
block: ThinkingContent,
|
|
@@ -2208,12 +2216,6 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
2208
2216
|
? lookupOpenItem({ output_index: event.output_index, item_id: item.id ?? item.call_id })
|
|
2209
2217
|
: lookupOpenItem({ output_index: event.output_index, item_id: item.id });
|
|
2210
2218
|
if (item.type === "reasoning") {
|
|
2211
|
-
const thinking =
|
|
2212
|
-
item.summary?.length > 0
|
|
2213
|
-
? item.summary.map(part => part.text).join("\n\n")
|
|
2214
|
-
: item.content?.[0]?.type === "reasoning_text"
|
|
2215
|
-
? (item.content[0].text ?? "")
|
|
2216
|
-
: "";
|
|
2217
2219
|
// Prefer the routed entry; the bare itemId find misroutes when ids are
|
|
2218
2220
|
// absent (`undefined === undefined` matches the FIRST thinking block) and
|
|
2219
2221
|
// misses entirely when the done-event id drifts from the added-event id.
|
|
@@ -2224,12 +2226,12 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
2224
2226
|
| ThinkingContent
|
|
2225
2227
|
| undefined);
|
|
2226
2228
|
if (reasoningBlock) {
|
|
2227
|
-
reasoningBlock.thinking = thinking;
|
|
2229
|
+
reasoningBlock.thinking = finalizeReasoningThinking(item, reasoningBlock.thinking);
|
|
2228
2230
|
reasoningBlock.thinkingSignature = JSON.stringify(item);
|
|
2229
2231
|
stream.push({
|
|
2230
2232
|
type: "thinking_end",
|
|
2231
2233
|
contentIndex: contentIndexOf(reasoningBlock),
|
|
2232
|
-
content: thinking,
|
|
2234
|
+
content: reasoningBlock.thinking,
|
|
2233
2235
|
partial: output,
|
|
2234
2236
|
});
|
|
2235
2237
|
}
|
|
@@ -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
|
|
@@ -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;
|
|
@@ -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> {
|
|
@@ -135,6 +135,16 @@ export interface IdleTimeoutIteratorOptions {
|
|
|
135
135
|
* keepalive/no-op events from keeping a stalled tool call alive forever.
|
|
136
136
|
*/
|
|
137
137
|
isProgressItem?: (item: unknown) => boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Reports consumer-side local work in flight for the stream: the provider
|
|
140
|
+
* transport is waiting on a server-requested local tool bridge (e.g. the
|
|
141
|
+
* Cursor exec channel) before anything can flow upstream again. While it
|
|
142
|
+
* returns true, an expired idle / first-item deadline slides forward
|
|
143
|
+
* instead of aborting — the silence is ours, not a provider stall. The
|
|
144
|
+
* watchdog re-arms with a full budget once the local work completes, so a
|
|
145
|
+
* provider that stalls afterwards is still caught.
|
|
146
|
+
*/
|
|
147
|
+
hasPendingLocalWork?: () => boolean;
|
|
138
148
|
/**
|
|
139
149
|
* Cancel iteration as soon as this signal aborts. Required for caller-driven
|
|
140
150
|
* cancellation (ESC) when the underlying transport does not surface signal
|
|
@@ -157,7 +167,7 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
157
167
|
options: IdleTimeoutIteratorOptions,
|
|
158
168
|
): AsyncGenerator<T> {
|
|
159
169
|
const firstItemTimeoutMs = options.firstItemTimeoutMs ?? options.idleTimeoutMs;
|
|
160
|
-
|
|
170
|
+
let firstItemDeadlineMs =
|
|
161
171
|
firstItemTimeoutMs !== undefined && firstItemTimeoutMs > 0 ? Date.now() + firstItemTimeoutMs : undefined;
|
|
162
172
|
const abortSignal = options.abortSignal;
|
|
163
173
|
const iterator = iterable[Symbol.asyncIterator]();
|
|
@@ -197,6 +207,28 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
197
207
|
};
|
|
198
208
|
let lastProgressAt = Date.now();
|
|
199
209
|
|
|
210
|
+
const hasPendingLocalWork = (): boolean => {
|
|
211
|
+
if (!options.hasPendingLocalWork) return false;
|
|
212
|
+
try {
|
|
213
|
+
return options.hasPendingLocalWork();
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
// Local work means the current gap is attributable to the consumer side,
|
|
219
|
+
// not the provider: slide the active deadline a full budget past now
|
|
220
|
+
// instead of aborting. Once the work completes the watchdog resumes from
|
|
221
|
+
// the last extension, so a provider that stalls afterwards is still caught.
|
|
222
|
+
const extendDeadlineForLocalWork = (): void => {
|
|
223
|
+
if (awaitingFirstItem) {
|
|
224
|
+
if (firstItemDeadlineMs !== undefined && firstItemTimeoutMs !== undefined) {
|
|
225
|
+
firstItemDeadlineMs = Date.now() + firstItemTimeoutMs;
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
lastProgressAt = Date.now();
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
200
232
|
const noTimeoutEnforced =
|
|
201
233
|
(firstItemTimeoutMs === undefined || firstItemTimeoutMs <= 0) &&
|
|
202
234
|
(options.idleTimeoutMs === undefined || options.idleTimeoutMs <= 0);
|
|
@@ -271,6 +303,12 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
271
303
|
timer = setTimeout(onTimerFire, Math.max(0, deadlineMs - Date.now()));
|
|
272
304
|
};
|
|
273
305
|
|
|
306
|
+
// The in-flight iterator.next() promise, persisted across loop iterations:
|
|
307
|
+
// a deadline extension for pending local work loops without consuming it,
|
|
308
|
+
// and issuing a second next() while one is outstanding would drop an item.
|
|
309
|
+
let pendingNext:
|
|
310
|
+
| Promise<{ kind: "next"; result: IteratorResult<T> } | { kind: "error"; error: unknown }>
|
|
311
|
+
| undefined;
|
|
274
312
|
try {
|
|
275
313
|
let raceCount = 0;
|
|
276
314
|
while (true) {
|
|
@@ -291,21 +329,29 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
291
329
|
if (firstItemDeadlineMs !== undefined) {
|
|
292
330
|
activeTimeoutMs = firstItemDeadlineMs - Date.now();
|
|
293
331
|
if (activeTimeoutMs <= 0) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
332
|
+
if (!hasPendingLocalWork()) {
|
|
333
|
+
options.onFirstItemTimeout?.();
|
|
334
|
+
closeIterator();
|
|
335
|
+
throw new AIError.StreamTimeoutError(options.firstItemErrorMessage ?? options.errorMessage);
|
|
336
|
+
}
|
|
337
|
+
extendDeadlineForLocalWork();
|
|
338
|
+
activeTimeoutMs = firstItemDeadlineMs! - Date.now();
|
|
297
339
|
}
|
|
298
340
|
}
|
|
299
341
|
} else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
|
|
300
342
|
activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
|
|
301
343
|
if (activeTimeoutMs <= 0) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
344
|
+
if (!hasPendingLocalWork()) {
|
|
345
|
+
options.onIdle?.();
|
|
346
|
+
closeIterator();
|
|
347
|
+
throw new AIError.StreamTimeoutError(options.errorMessage);
|
|
348
|
+
}
|
|
349
|
+
extendDeadlineForLocalWork();
|
|
350
|
+
activeTimeoutMs = options.idleTimeoutMs;
|
|
305
351
|
}
|
|
306
352
|
}
|
|
307
353
|
|
|
308
|
-
|
|
354
|
+
pendingNext ??= withRacy(iterator.next());
|
|
309
355
|
|
|
310
356
|
const racers: Array<
|
|
311
357
|
Promise<
|
|
@@ -314,7 +360,7 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
314
360
|
| { kind: "timeout" }
|
|
315
361
|
| { kind: "abort" }
|
|
316
362
|
>
|
|
317
|
-
> = [
|
|
363
|
+
> = [pendingNext];
|
|
318
364
|
|
|
319
365
|
const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs > 0;
|
|
320
366
|
if (enforceTimeout) {
|
|
@@ -333,11 +379,21 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
333
379
|
let continuing = false;
|
|
334
380
|
try {
|
|
335
381
|
const outcome = await Promise.race(racers);
|
|
382
|
+
if (outcome.kind === "next" || outcome.kind === "error") {
|
|
383
|
+
pendingNext = undefined;
|
|
384
|
+
}
|
|
336
385
|
if (outcome.kind === "abort") {
|
|
337
386
|
closeIterator();
|
|
338
387
|
throw abortReason(abortSignal!);
|
|
339
388
|
}
|
|
340
389
|
if (outcome.kind === "timeout") {
|
|
390
|
+
if (hasPendingLocalWork()) {
|
|
391
|
+
// A local tool is still running; the provider cannot make
|
|
392
|
+
// progress until we hand its result back. Keep waiting.
|
|
393
|
+
extendDeadlineForLocalWork();
|
|
394
|
+
continuing = true;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
341
397
|
if (!awaitingFirstItem) {
|
|
342
398
|
options.onIdle?.();
|
|
343
399
|
} else {
|