@linxiraos/pi-ai 1.2.0 → 1.3.0
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 +83 -0
- package/README.md +4 -2
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/auth/cascade.d.ts +5 -3
- package/dist/types/auth/pool.d.ts +3 -3
- package/dist/types/auth/select.d.ts +5 -1
- package/dist/types/auth/types.d.ts +15 -5
- package/dist/types/auth/usage-cache.d.ts +3 -1
- package/dist/types/auth-gateway/clients.d.ts +67 -0
- package/dist/types/auth-gateway/dispatch.d.ts +28 -10
- package/dist/types/auth-gateway/http.d.ts +2 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/server.d.ts +9 -4
- package/dist/types/auth-gateway/session-state.d.ts +6 -0
- package/dist/types/auth-gateway/types.d.ts +16 -2
- package/dist/types/dialect/coercion.d.ts +6 -0
- package/dist/types/error/classes.d.ts +1 -1
- package/dist/types/error/flags.d.ts +15 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/error/network-cause.d.ts +40 -0
- package/dist/types/images/shared.d.ts +26 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/judgment/native.d.ts +51 -3
- package/dist/types/judgment/systemone.d.ts +9 -2
- package/dist/types/judgment/text.d.ts +11 -5
- package/dist/types/judgment/types.d.ts +5 -1
- package/dist/types/judgment/wire.d.ts +16 -3
- package/dist/types/provider-session-state.d.ts +2 -2
- package/dist/types/providers/anthropic-images.d.ts +7 -0
- package/dist/types/providers/anthropic-signature.d.ts +12 -2
- package/dist/types/providers/anthropic-state.d.ts +7 -0
- package/dist/types/providers/anthropic.d.ts +21 -6
- package/dist/types/providers/connection-prewarm.d.ts +15 -0
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +13 -0
- package/dist/types/providers/cursor.d.ts +7 -0
- package/dist/types/providers/openai-codex-responses.d.ts +6 -0
- package/dist/types/providers/openai-responses.d.ts +9 -0
- package/dist/types/providers/openai-shared.d.ts +30 -0
- package/dist/types/providers/systemone-server.d.ts +7 -3
- package/dist/types/providers/transform-messages.d.ts +26 -5
- package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
- package/dist/types/stream.d.ts +0 -2
- package/dist/types/types.d.ts +33 -9
- package/dist/types/utils/dsml-leak.d.ts +4 -2
- package/dist/types/utils/narration.d.ts +9 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/request-body.d.ts +11 -0
- package/dist/types/utils/request-compression.d.ts +21 -0
- package/dist/types/utils/tool-call-arguments.d.ts +2 -1
- package/dist/types/utils/transport-fetch.d.ts +8 -0
- package/dist/types/utils.d.ts +1 -1
- package/package.json +8 -8
- package/src/auth/cascade.ts +23 -13
- package/src/auth/pool.ts +3 -3
- package/src/auth/refresh.ts +39 -12
- package/src/auth/select.ts +8 -2
- package/src/auth/types.ts +16 -5
- package/src/auth/usage-cache.ts +23 -7
- package/src/auth/usage.ts +2 -4
- package/src/auth-broker/remote-store.ts +31 -6
- package/src/auth-gateway/clients.ts +318 -0
- package/src/auth-gateway/dispatch.ts +66 -35
- package/src/auth-gateway/http.ts +11 -5
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/routes/embeddings.ts +4 -10
- package/src/auth-gateway/routes/images.ts +6 -10
- package/src/auth-gateway/routes/rerank.ts +4 -10
- package/src/auth-gateway/routes/speech.ts +10 -10
- package/src/auth-gateway/routes/systemone.ts +22 -12
- package/src/auth-gateway/routes/transcriptions.ts +4 -2
- package/src/auth-gateway/routes/video.ts +12 -15
- package/src/auth-gateway/server.ts +136 -53
- package/src/auth-gateway/session-state.ts +14 -0
- package/src/auth-gateway/types.ts +16 -2
- package/src/dialect/coercion.ts +16 -0
- package/src/dialect/hermes.ts +7 -11
- package/src/dialect/qwen3.ts +3 -11
- package/src/error/classes.ts +3 -2
- package/src/error/flags.ts +24 -3
- package/src/error/index.ts +1 -0
- package/src/error/network-cause.ts +179 -0
- package/src/images/codex-format-directive.md +3 -0
- package/src/images/google-antigravity.ts +2 -1
- package/src/images/google-generative-ai.ts +4 -2
- package/src/images/openai-hosted.ts +25 -2
- package/src/images/openai-images.ts +11 -6
- package/src/images/shared.ts +77 -3
- package/src/index.ts +1 -0
- package/src/judgment/chat.ts +17 -1
- package/src/judgment/native.ts +182 -12
- package/src/judgment/openai-decisions.ts +27 -6
- package/src/judgment/systemone.ts +131 -10
- package/src/judgment/text.ts +20 -7
- package/src/judgment/types.ts +5 -1
- package/src/judgment/wire.ts +17 -3
- package/src/provider-session-state.ts +2 -2
- package/src/providers/amazon-bedrock.ts +4 -1
- package/src/providers/anthropic-client.ts +1 -1
- package/src/providers/anthropic-images.ts +263 -0
- package/src/providers/anthropic-messages-server.ts +84 -1
- package/src/providers/anthropic-signature.ts +35 -8
- package/src/providers/anthropic-state.ts +13 -0
- package/src/providers/anthropic.ts +479 -397
- package/src/providers/connection-prewarm.ts +78 -0
- package/src/providers/cowork-fetch.ts +5 -1
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor-pi-args.ts +19 -0
- package/src/providers/cursor.ts +53 -13
- package/src/providers/devin.ts +10 -4
- package/src/providers/factory-droid/gemini.ts +9 -3
- package/src/providers/google-gemini-cli.ts +11 -1
- package/src/providers/google-shared.ts +2 -6
- package/src/providers/images-server.ts +6 -1
- package/src/providers/ollama.ts +15 -5
- package/src/providers/openai-anthropic-shim.ts +1 -0
- package/src/providers/openai-chat-server.ts +29 -0
- package/src/providers/openai-codex/access-programs.ts +2 -1
- package/src/providers/openai-codex-responses.ts +49 -82
- package/src/providers/openai-completions.ts +117 -19
- package/src/providers/openai-responses.ts +25 -2
- package/src/providers/openai-shared.ts +36 -5
- package/src/providers/pi-native-server.ts +1 -0
- package/src/providers/systemone-server.ts +72 -12
- package/src/providers/transform-messages.ts +95 -51
- package/src/registry/oauth/google-antigravity.ts +1 -1
- package/src/stream.ts +13 -73
- package/src/types.ts +33 -9
- package/src/usage/claude.ts +2 -9
- package/src/usage/github-copilot.ts +26 -4
- package/src/usage/openai-codex.ts +13 -7
- package/src/usage/opencode-go.ts +7 -8
- package/src/usage/xai-oauth.ts +2 -9
- package/src/utils/dsml-leak.ts +30 -14
- package/src/utils/leaked-thinking-stream.ts +10 -0
- package/src/utils/narration.ts +18 -0
- package/src/utils/openai-http.ts +27 -18
- package/src/utils/request-body.ts +27 -0
- package/src/utils/request-compression.ts +202 -0
- package/src/utils/request-debug.ts +23 -9
- package/src/utils/schema/dereference.ts +51 -18
- package/src/utils/schema/draft.ts +10 -2
- package/src/utils/schema/json-schema-validator.ts +4 -10
- package/src/utils/schema/normalize.ts +6 -9
- package/src/utils/tool-call-arguments.ts +2 -1
- package/src/utils/transport-fetch.ts +37 -0
- package/src/utils/validation.ts +26 -16
- package/src/utils.ts +38 -23
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { isCatalogEndpointUrl } from "@linxiraos/pi-catalog/compat/catalog-endpoint";
|
|
2
|
+
import { logger } from "@linxiraos/pi-utils";
|
|
3
|
+
import type { Api, FetchImpl, Model } from "../types";
|
|
4
|
+
import { lastDispatchOrigin, transportFetch } from "../utils/transport-fetch";
|
|
5
|
+
import { resolveAnthropicPrewarmTarget } from "./anthropic";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimum gap between warm-ups of one origin. Idle pooled sockets outlive it
|
|
9
|
+
* by minutes (Copilot drops them after ~2.5–5 min, most hosts later), so a
|
|
10
|
+
* tighter cadence would only add requests.
|
|
11
|
+
*/
|
|
12
|
+
const PREWARM_INTERVAL_MS = 30_000;
|
|
13
|
+
const PREWARM_TIMEOUT_MS = 10_000;
|
|
14
|
+
|
|
15
|
+
const prewarmedAt = new Map<string, number>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where a warm-up for {@link model} goes, or `undefined` when it must not be
|
|
19
|
+
* probed. First-party Anthropic resolves its effective endpoint (following
|
|
20
|
+
* `ANTHROPIC_BASE_URL`/Foundry reroutes) and TLS options up front. Every
|
|
21
|
+
* other model warms only the origin its last inference request actually
|
|
22
|
+
* reached, so credential endpoints (Copilot plans), env overrides
|
|
23
|
+
* (`MOONSHOT_BASE_URL`, `XAI_BASE_URL`), and regional hosts are never guessed
|
|
24
|
+
* from `baseUrl`; before its first request there is nothing to warm. That
|
|
25
|
+
* origin must also be the provider's own endpoint: a custom provider, or a
|
|
26
|
+
* catalog provider id routed to a gateway, proxy, or local server, is never
|
|
27
|
+
* probed.
|
|
28
|
+
*/
|
|
29
|
+
function resolvePrewarmTarget(model: Model<Api>): { origin: string; init: RequestInit } | undefined {
|
|
30
|
+
if (model.provider === "anthropic") {
|
|
31
|
+
const target = resolveAnthropicPrewarmTarget(model);
|
|
32
|
+
return target && { origin: new URL(target.url).origin, init: target.init };
|
|
33
|
+
}
|
|
34
|
+
const origin = lastDispatchOrigin(model);
|
|
35
|
+
if (!origin || !isCatalogEndpointUrl(model, origin)) return undefined;
|
|
36
|
+
return { origin, init: {} };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Opens, or keeps alive, a pooled connection to {@link model}'s API host so
|
|
41
|
+
* the next turn skips DNS + TCP + TLS. Sends a credential-less `HEAD` (nothing
|
|
42
|
+
* is billed; hosts answer 401/404/405) through the same transport — proxy,
|
|
43
|
+
* extra CA, Cowork profile, TLS options — as real requests, because sockets
|
|
44
|
+
* pool per transport configuration and a warm-up made any other way (e.g.
|
|
45
|
+
* `fetch.preconnect`, which also throws on Windows) is never reused.
|
|
46
|
+
* Throttled per origin; best effort, never throws.
|
|
47
|
+
*/
|
|
48
|
+
export async function prewarmModelConnection(model: Model<Api>, options?: { fetch?: FetchImpl }): Promise<void> {
|
|
49
|
+
let origin: string | undefined;
|
|
50
|
+
try {
|
|
51
|
+
// Inside the boundary: resolving Anthropic's TLS options throws on an
|
|
52
|
+
// unusable CA or client-certificate setup, which the real request reports.
|
|
53
|
+
const target = resolvePrewarmTarget(model);
|
|
54
|
+
if (!target) return;
|
|
55
|
+
origin = target.origin;
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const last = prewarmedAt.get(origin);
|
|
58
|
+
if (last !== undefined && now - last < PREWARM_INTERVAL_MS) return;
|
|
59
|
+
prewarmedAt.set(origin, now);
|
|
60
|
+
const response = await transportFetch(model, options?.fetch)(origin, {
|
|
61
|
+
...target.init,
|
|
62
|
+
method: "HEAD",
|
|
63
|
+
headers: {},
|
|
64
|
+
signal: AbortSignal.timeout(PREWARM_TIMEOUT_MS),
|
|
65
|
+
});
|
|
66
|
+
await response.body?.cancel();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
logger.debug("Model connection prewarm failed", {
|
|
69
|
+
origin,
|
|
70
|
+
error: error instanceof Error ? error.message : String(error),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Test hook: forgets every warm-up so the next call is not throttled. */
|
|
76
|
+
export function resetConnectionPrewarmForTesting(): void {
|
|
77
|
+
prewarmedAt.clear();
|
|
78
|
+
}
|
|
@@ -198,7 +198,11 @@ async function sendCoworkRequest(
|
|
|
198
198
|
};
|
|
199
199
|
const abort = (): void => {
|
|
200
200
|
const reason = signal?.reason;
|
|
201
|
-
|
|
201
|
+
const error = reason instanceof Error ? reason : new DOMException("The operation was aborted.", "AbortError");
|
|
202
|
+
request?.destroy(error);
|
|
203
|
+
// Bun before 1.4 emits 'close' but not 'error' for destroy(error), so the
|
|
204
|
+
// 'error' listener below never settles a request still waiting for headers.
|
|
205
|
+
result.reject(error);
|
|
202
206
|
};
|
|
203
207
|
if (signal?.aborted) {
|
|
204
208
|
release();
|
|
@@ -209,6 +209,25 @@ export function shellTimeoutSeconds(timeoutMs: number | undefined): number | und
|
|
|
209
209
|
return Math.max(1, Math.round(timeoutMs / 1000));
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/** The bash tool's deadline when its `timeout` is omitted. */
|
|
213
|
+
const BASH_DEFAULT_DEADLINE_SECONDS = 300;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The bash-tool kill deadline, in seconds, for a Cursor `ShellArgs`/`ShellStreamArgs` frame.
|
|
217
|
+
*
|
|
218
|
+
* Cursor's `timeout` carries the model's `block_until_ms`, which Cursor's Shell tool documents as
|
|
219
|
+
* how long to block before moving the command to the background, not as a kill deadline; the kill
|
|
220
|
+
* deadline is `hard_timeout`. Without one, a short wait (`block_until_ms: 5000` on a build) must not
|
|
221
|
+
* kill the command: the deadline stays the bash default (`undefined`), raised to the wait only when
|
|
222
|
+
* the wait is longer.
|
|
223
|
+
*/
|
|
224
|
+
export function shellDeadlineSeconds(args: { timeout: number; hardTimeout?: number }): number | undefined {
|
|
225
|
+
const hardSeconds = shellTimeoutSeconds(args.hardTimeout);
|
|
226
|
+
if (hardSeconds !== undefined) return hardSeconds;
|
|
227
|
+
const waitSeconds = shellTimeoutSeconds(args.timeout);
|
|
228
|
+
return waitSeconds !== undefined && waitSeconds > BASH_DEFAULT_DEADLINE_SECONDS ? waitSeconds : undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
212
231
|
/**
|
|
213
232
|
* Drop keys whose value is `undefined` so optional local-tool kwargs stay
|
|
214
233
|
* absent rather than present-as-undefined.
|
package/src/providers/cursor.ts
CHANGED
|
@@ -261,7 +261,7 @@ import {
|
|
|
261
261
|
piReadDisplayPath,
|
|
262
262
|
piReadPathHasRange,
|
|
263
263
|
piTimeout,
|
|
264
|
-
|
|
264
|
+
shellDeadlineSeconds,
|
|
265
265
|
} from "./cursor/exec-modern";
|
|
266
266
|
import { handleInteractionQuery, protoUnknownFields } from "./cursor/interaction-query";
|
|
267
267
|
|
|
@@ -348,6 +348,18 @@ function isCursorModelNotFound(error: unknown): boolean {
|
|
|
348
348
|
);
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Failures that, before any token, mean the wire conversation is poisoned
|
|
353
|
+
* rather than the account: a bare gRPC `resource_exhausted` (#8345), or the
|
|
354
|
+
* structured PROVIDER_ERROR every resume of a turn cut mid-stream draws (#15830).
|
|
355
|
+
*/
|
|
356
|
+
function isCursorConversationRejected(error: unknown, message: string): boolean {
|
|
357
|
+
return (
|
|
358
|
+
(error instanceof AIError.ProviderHttpError && error.code === "PROVIDER_ERROR") ||
|
|
359
|
+
RESOURCE_EXHAUSTED_PATTERN.test(message)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
351
363
|
const CURSOR_MAX_STREAM_RETRIES = 5;
|
|
352
364
|
const CURSOR_RETRY_BASE_DELAY_MS = 500;
|
|
353
365
|
const NOT_IMPLEMENTED = `Not implemented by this client`;
|
|
@@ -1814,13 +1826,14 @@ function streamCursorWithWireMode(
|
|
|
1814
1826
|
flushOpenToolCalls(output, stream, openBlockState);
|
|
1815
1827
|
}
|
|
1816
1828
|
const result = await AIError.finalize(error, { api: model.api, signal: options?.signal });
|
|
1817
|
-
// #8345: a server-side per-conversation rejection
|
|
1818
|
-
// resource_exhausted
|
|
1819
|
-
//
|
|
1820
|
-
// the
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
1823
|
-
//
|
|
1829
|
+
// #8345/#15830: a server-side per-conversation rejection (a bare
|
|
1830
|
+
// resource_exhausted, or PROVIDER_ERROR after a mid-turn cut) with zero
|
|
1831
|
+
// tokens means the conversation is poisoned, not the account. Rotate
|
|
1832
|
+
// the wire id and rebuild from `context` on the next attempt (the
|
|
1833
|
+
// caller's retry loop). Do not migrate cached side-state:
|
|
1834
|
+
// pendingToolCalls from a mid-turn checkpoint re-poison the new id.
|
|
1835
|
+
// One rotation per failure streak; a new rotation is allowed only
|
|
1836
|
+
// after the current rotated id completed a turn.
|
|
1824
1837
|
const currentRotated =
|
|
1825
1838
|
baseConversationId === undefined ? undefined : rotatedConversationIds.get(baseConversationId);
|
|
1826
1839
|
const canRotate = currentRotated === undefined || successfulRotatedConversationIds.has(currentRotated);
|
|
@@ -1829,7 +1842,7 @@ function streamCursorWithWireMode(
|
|
|
1829
1842
|
baseConversationId !== undefined &&
|
|
1830
1843
|
usageState !== undefined &&
|
|
1831
1844
|
!usageState.sawTokenDelta &&
|
|
1832
|
-
|
|
1845
|
+
isCursorConversationRejected(error, result.message) &&
|
|
1833
1846
|
canRotate
|
|
1834
1847
|
) {
|
|
1835
1848
|
const rotated = crypto.randomUUID();
|
|
@@ -2668,7 +2681,7 @@ async function handleExecServerMessage(
|
|
|
2668
2681
|
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
|
|
2669
2682
|
command: args.command,
|
|
2670
2683
|
cwd: args.workingDirectory || undefined,
|
|
2671
|
-
timeout:
|
|
2684
|
+
timeout: shellDeadlineSeconds(args),
|
|
2672
2685
|
});
|
|
2673
2686
|
const { execResult } = await resolveExecHandler(
|
|
2674
2687
|
args,
|
|
@@ -2689,7 +2702,7 @@ async function handleExecServerMessage(
|
|
|
2689
2702
|
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
|
|
2690
2703
|
command: args.command,
|
|
2691
2704
|
cwd: args.workingDirectory || undefined,
|
|
2692
|
-
timeout:
|
|
2705
|
+
timeout: shellDeadlineSeconds(args),
|
|
2693
2706
|
});
|
|
2694
2707
|
await handleShellStreamArgs(args, execMsg, runTransport, execHandlers, onToolResult);
|
|
2695
2708
|
return;
|
|
@@ -3167,7 +3180,7 @@ async function handleExecServerMessage(
|
|
|
3167
3180
|
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
|
|
3168
3181
|
command: args.command,
|
|
3169
3182
|
cwd: args.workingDirectory || undefined,
|
|
3170
|
-
timeout:
|
|
3183
|
+
timeout: shellDeadlineSeconds(args),
|
|
3171
3184
|
});
|
|
3172
3185
|
const { execResult } = await resolveExecHandler(
|
|
3173
3186
|
normalizedArgs,
|
|
@@ -5076,6 +5089,13 @@ function endCurrentThinkingBlock(
|
|
|
5076
5089
|
* call handed to an external executor is only a handoff while the marker is
|
|
5077
5090
|
* absent, and the consumer that runs it needs a runnable block.
|
|
5078
5091
|
*
|
|
5092
|
+
* A frame whose `toolCallId` already has a block in `output` is the server
|
|
5093
|
+
* re-dispatching that call (a new `execId`, e.g. a `read` re-sent once a
|
|
5094
|
+
* parallel shell created the file). It adds no block: a second block with the
|
|
5095
|
+
* same id makes every rebuilt history replay the call twice, which Cursor
|
|
5096
|
+
* rejects (issue #15749). The re-run's result supersedes the first one in the
|
|
5097
|
+
* host's result buffer.
|
|
5098
|
+
*
|
|
5079
5099
|
* Exported for tests to exercise ordering with adjacent text/thinking blocks.
|
|
5080
5100
|
*/
|
|
5081
5101
|
export function synthesizeCursorExecToolCall(
|
|
@@ -5087,6 +5107,7 @@ export function synthesizeCursorExecToolCall(
|
|
|
5087
5107
|
args: Record<string, unknown>,
|
|
5088
5108
|
{ executed = true }: { executed?: boolean } = {},
|
|
5089
5109
|
): void {
|
|
5110
|
+
if (output.content.some(block => block.type === "toolCall" && block.id === toolCallId)) return;
|
|
5090
5111
|
endCurrentTextBlock(output, stream, state);
|
|
5091
5112
|
endCurrentThinkingBlock(output, stream, state);
|
|
5092
5113
|
// Exec-frame translators often write `optional: value || undefined`. A
|
|
@@ -5919,12 +5940,20 @@ interface CursorAssistantStep {
|
|
|
5919
5940
|
* the text and reasoning that followed each result; a round ends at a call
|
|
5920
5941
|
* followed by anything other than another call. Consecutive calls stay in one
|
|
5921
5942
|
* round — they were issued together. Hidden reasoning still marks a boundary.
|
|
5943
|
+
* A call id repeated within the message — a re-dispatched exec frame recorded
|
|
5944
|
+
* before issue #15749 was fixed — is issued once; Cursor rejects a history that
|
|
5945
|
+
* repeats it.
|
|
5922
5946
|
*/
|
|
5923
5947
|
function buildCursorAssistantSteps(msg: AssistantMessage, targetModelId: string | undefined): CursorAssistantStep[] {
|
|
5924
5948
|
const steps: CursorAssistantStep[] = [];
|
|
5925
5949
|
let step: CursorAssistantStep = { content: [], callIds: [] };
|
|
5926
5950
|
const replayThinking = canReplayCursorThinking(msg, targetModelId);
|
|
5951
|
+
const issuedCallIds = new Set<string>();
|
|
5927
5952
|
for (const item of msg.content) {
|
|
5953
|
+
if (item.type === "toolCall") {
|
|
5954
|
+
if (issuedCallIds.has(item.id)) continue;
|
|
5955
|
+
issuedCallIds.add(item.id);
|
|
5956
|
+
}
|
|
5928
5957
|
if (item.type !== "toolCall" && step.callIds.length > 0) {
|
|
5929
5958
|
steps.push(step);
|
|
5930
5959
|
step = { content: [], callIds: [] };
|
|
@@ -6147,7 +6176,14 @@ function buildRootPromptMessagesJson(
|
|
|
6147
6176
|
});
|
|
6148
6177
|
continue;
|
|
6149
6178
|
}
|
|
6150
|
-
|
|
6179
|
+
// A re-dispatched call recorded two results in the same batch; the
|
|
6180
|
+
// re-run's answer is the one the model saw.
|
|
6181
|
+
let result = msg;
|
|
6182
|
+
for (let j = i + 1; j < historyEnd && messages[j].role === "toolResult"; j++) {
|
|
6183
|
+
const later = messages[j];
|
|
6184
|
+
if (later.role === "toolResult" && later.toolCallId === msg.toolCallId) result = later;
|
|
6185
|
+
}
|
|
6186
|
+
pushToolResult(result);
|
|
6151
6187
|
}
|
|
6152
6188
|
}
|
|
6153
6189
|
|
|
@@ -6299,6 +6335,8 @@ function buildConversationTurns(
|
|
|
6299
6335
|
while (i < messages.length && messages[i].role !== "user" && messages[i].role !== "developer") {
|
|
6300
6336
|
const stepMsg = messages[i];
|
|
6301
6337
|
if (stepMsg.role === "assistant") {
|
|
6338
|
+
// Mirrors `buildCursorAssistantSteps`: a repeated call id is issued once.
|
|
6339
|
+
const issuedCallIds = new Set<string>();
|
|
6302
6340
|
for (const item of stepMsg.content) {
|
|
6303
6341
|
let step: ConversationStep;
|
|
6304
6342
|
if (item.type === "text") {
|
|
@@ -6322,6 +6360,8 @@ function buildConversationTurns(
|
|
|
6322
6360
|
},
|
|
6323
6361
|
});
|
|
6324
6362
|
} else if (item.type === "toolCall") {
|
|
6363
|
+
if (issuedCallIds.has(item.id)) continue;
|
|
6364
|
+
issuedCallIds.add(item.id);
|
|
6325
6365
|
step = createCursorToolCallStep(item, toolResults.get(item.id));
|
|
6326
6366
|
} else {
|
|
6327
6367
|
continue;
|
package/src/providers/devin.ts
CHANGED
|
@@ -61,7 +61,12 @@ import {
|
|
|
61
61
|
ConnectFrameDecoder,
|
|
62
62
|
frameConnectMessage,
|
|
63
63
|
} from "./connect-frame";
|
|
64
|
-
import {
|
|
64
|
+
import {
|
|
65
|
+
isResponsesFamilyApi,
|
|
66
|
+
normalizeToolCallId,
|
|
67
|
+
responsesCallComponent,
|
|
68
|
+
transformMessages,
|
|
69
|
+
} from "./transform-messages";
|
|
65
70
|
|
|
66
71
|
/** Base host for Codeium/Windsurf's Cascade chat API (Connect protocol over HTTP/1.1). */
|
|
67
72
|
export const DEVIN_API_URL = DEVIN_DEFAULT_BASE_URL;
|
|
@@ -244,9 +249,10 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
|
|
|
244
249
|
context.messages,
|
|
245
250
|
model,
|
|
246
251
|
(id, _target, source) =>
|
|
247
|
-
(
|
|
248
|
-
.
|
|
249
|
-
|
|
252
|
+
normalizeToolCallId(
|
|
253
|
+
isResponsesFamilyApi(source.api) ? responsesCallComponent(id) : id,
|
|
254
|
+
DEVIN_TOOL_CALL_ID_MAX_LENGTH,
|
|
255
|
+
),
|
|
250
256
|
DEVIN_TOOL_CALL_ID_MAX_LENGTH,
|
|
251
257
|
),
|
|
252
258
|
};
|
|
@@ -118,6 +118,11 @@ const GOOGLE_APIS: Record<string, true> = {
|
|
|
118
118
|
* everything else is DROPPED (never demoted to visible text). A text block
|
|
119
119
|
* keeps its signature only when this model signed it.
|
|
120
120
|
*
|
|
121
|
+
* Tool-call names are encoded to their wire form here, before
|
|
122
|
+
* `transformMessages` drops calls whose names no provider could declare: the
|
|
123
|
+
* proxy accepts any caller tool name once sanitized, so a declared
|
|
124
|
+
* `my.tool/name` call must survive replay.
|
|
125
|
+
*
|
|
121
126
|
* Only Google-origin turns are stamped as the target, so `transformMessages`
|
|
122
127
|
* keeps their surviving signatures verbatim instead of demoting/stripping them
|
|
123
128
|
* as cross-model. Their ids are plain in either stamp, so pairing is
|
|
@@ -137,8 +142,9 @@ function adoptFactoryReplay(message: Message, model: Model<"factory-droid-agent"
|
|
|
137
142
|
if (googleOrigin && block.thinking.trim() && signature && signature !== SKIP_THOUGHT_SIGNATURE) {
|
|
138
143
|
content.push(block);
|
|
139
144
|
}
|
|
140
|
-
} else if (block.type === "toolCall"
|
|
141
|
-
|
|
145
|
+
} else if (block.type === "toolCall") {
|
|
146
|
+
const thoughtSignature = googleOrigin ? block.thoughtSignature : undefined;
|
|
147
|
+
content.push({ ...block, name: sanitizeFactoryDroidToolName(block.name), thoughtSignature });
|
|
142
148
|
} else if (block.type === "text" && !sameModel && block.textSignature) {
|
|
143
149
|
content.push({ ...block, textSignature: undefined });
|
|
144
150
|
} else {
|
|
@@ -204,7 +210,7 @@ function toGeminiContents(
|
|
|
204
210
|
parts.push({ text: block.thinking, thoughtSignature: block.thinkingSignature });
|
|
205
211
|
} else if (block.type === "toolCall") {
|
|
206
212
|
parts.push({
|
|
207
|
-
functionCall: { name:
|
|
213
|
+
functionCall: { name: block.name, args: block.arguments },
|
|
208
214
|
...(block.thoughtSignature ? { thoughtSignature: block.thoughtSignature } : {}),
|
|
209
215
|
});
|
|
210
216
|
}
|
|
@@ -36,6 +36,7 @@ import { armPreResponseTimeout, getStreamFirstEventTimeoutMs, iterateWithIdleTim
|
|
|
36
36
|
// the stream provider trusts the access token threaded through `options.apiKey`.
|
|
37
37
|
import { normalizeSchemaForCCA } from "../utils/schema";
|
|
38
38
|
import { StreamMarkupHealing, type StreamMarkupHealingEvent } from "../utils/stream-markup-healing";
|
|
39
|
+
import { prepareAnthropicImageContext } from "./anthropic-images";
|
|
39
40
|
import forcedToolDirective from "./google-antigravity-forced-tool.md" with { type: "text" };
|
|
40
41
|
import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "./google-shared";
|
|
41
42
|
import {
|
|
@@ -573,7 +574,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
573
574
|
endpoints = baseUrl ? [baseUrl] : [DEFAULT_ENDPOINT];
|
|
574
575
|
}
|
|
575
576
|
|
|
576
|
-
|
|
577
|
+
// A host that declares an image contract (Claude behind Antigravity)
|
|
578
|
+
// rejects oversized images, and a rejected image stays in context, so
|
|
579
|
+
// clamp before building; readers without one take images as sent. The
|
|
580
|
+
// cost is one pass over the context, as on `anthropic-messages`: images
|
|
581
|
+
// already within bounds hit the per-image cache and are not decoded.
|
|
582
|
+
const requestContext =
|
|
583
|
+
model.compat.maxImageDimension !== undefined || model.compat.maxImagePayloadBytes !== undefined
|
|
584
|
+
? await prepareAnthropicImageContext(context, model)
|
|
585
|
+
: context;
|
|
586
|
+
let requestBody = buildRequest(model, requestContext, projectId, options, isAntigravity);
|
|
577
587
|
const replacementPayload = await options?.onPayload?.(requestBody, model);
|
|
578
588
|
if (replacementPayload !== undefined) {
|
|
579
589
|
requestBody = replacementPayload as typeof requestBody;
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
Usage,
|
|
26
26
|
} from "../types";
|
|
27
27
|
import { shouldSendServiceTier } from "../types";
|
|
28
|
-
import { normalizeSystemPrompts } from "../utils";
|
|
28
|
+
import { normalizeSystemPrompts, normalizeToolCallId } from "../utils";
|
|
29
29
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
30
30
|
import type { RawHttpRequestDump } from "../utils/http-inspector";
|
|
31
31
|
import { normalizeSchemaForCCA, normalizeSchemaForGoogle, toolWireSchema } from "../utils/schema";
|
|
@@ -162,11 +162,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
|
|
162
162
|
const contents: Content[] = [];
|
|
163
163
|
const emittedToolCallNames = new Map<string, string>();
|
|
164
164
|
|
|
165
|
-
const
|
|
166
|
-
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
|
165
|
+
const transformedMessages = transformMessages(context.messages, model, id => normalizeToolCallId(id));
|
|
170
166
|
|
|
171
167
|
// Gemini < 3 image tool results go in a separate user turn, but parallel tool results must
|
|
172
168
|
// stay a single contiguous functionResponse turn ("number of function response parts is not
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type } from "@linxiraos/pi-omptype";
|
|
2
2
|
import * as AIError from "../error";
|
|
3
|
+
import { isDataUrlMimeType } from "../images/shared";
|
|
3
4
|
import type { ImageGenerationRequest, ImageGenerationResult } from "../images/types";
|
|
4
5
|
|
|
5
6
|
const imageJsonRequestSchema = type({
|
|
@@ -56,7 +57,11 @@ function decodeInputReference(value: unknown): { data: string; mimeType: string
|
|
|
56
57
|
}
|
|
57
58
|
if (typeof raw !== "string" || raw.length === 0) validation("input image must be base64 or a data URL");
|
|
58
59
|
const match = raw.match(/^data:([^;]+);base64,(.+)$/s);
|
|
59
|
-
if (match)
|
|
60
|
+
if (match) {
|
|
61
|
+
const mimeType = match[1] ?? "image/png";
|
|
62
|
+
if (!isDataUrlMimeType(mimeType)) validation("data URL MIME type must not contain a '#' fragment marker");
|
|
63
|
+
return { data: match[2] ?? "", mimeType };
|
|
64
|
+
}
|
|
60
65
|
if (/^[A-Za-z0-9+/]+={0,2}$/.test(raw)) return { data: raw, mimeType: "image/png" };
|
|
61
66
|
validation("input image URLs are not supported; send a data URL or base64 bytes");
|
|
62
67
|
}
|
package/src/providers/ollama.ts
CHANGED
|
@@ -68,6 +68,7 @@ type OllamaMessage = {
|
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
type OllamaChatChunk = {
|
|
71
|
+
error?: string;
|
|
71
72
|
message?: {
|
|
72
73
|
role?: string;
|
|
73
74
|
content?: string;
|
|
@@ -252,7 +253,10 @@ function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaM
|
|
|
252
253
|
timestamp: Date.now(),
|
|
253
254
|
}));
|
|
254
255
|
const messages: Message[] = [...systemMessages, ...context.messages];
|
|
255
|
-
|
|
256
|
+
// Model rows cached or bundled before Ollama had a compat record carry none;
|
|
257
|
+
// stripping is the replay-safe answer for them until they are rebuilt.
|
|
258
|
+
const compat: Partial<typeof model.compat> | undefined = model.compat;
|
|
259
|
+
const stripThinkingHistory = compat?.stripThinkingHistory ?? true;
|
|
256
260
|
const supportsImages = model.input.includes("image");
|
|
257
261
|
const converted = transformMessages(messages, model).map((msg, index) => {
|
|
258
262
|
// Real `systemPrompt` entries (always emitted first) stay on Ollama's
|
|
@@ -266,10 +270,9 @@ function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaM
|
|
|
266
270
|
const developerRole =
|
|
267
271
|
msg.role === "developer" && (index < systemPrompts.length || msg.attribution !== "user") ? "system" : "user";
|
|
268
272
|
const converted = convertMessage(msg, supportsImages, developerRole);
|
|
269
|
-
// Ollama
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
if (isCloud && converted.role === "assistant" && converted.thinking) {
|
|
273
|
+
// Some Ollama Cloud models historically rejected thinking in history (#1000).
|
|
274
|
+
// Preserve reasoning for the verified model without weakening that guard everywhere.
|
|
275
|
+
if (stripThinkingHistory && converted.role === "assistant" && converted.thinking) {
|
|
273
276
|
const { thinking: _t, ...rest } = converted;
|
|
274
277
|
return rest;
|
|
275
278
|
}
|
|
@@ -619,6 +622,13 @@ const streamOllamaOnce = (
|
|
|
619
622
|
}
|
|
620
623
|
stream.push({ type: "start", partial: output });
|
|
621
624
|
for await (const chunk of readJsonl<OllamaChatChunk>(response.body)) {
|
|
625
|
+
// Once HTTP 200 has started, Ollama reports failures as NDJSON error records.
|
|
626
|
+
if (chunk.error) {
|
|
627
|
+
throw new AIError.ProviderResponseError(`Ollama stream error: ${chunk.error}`, {
|
|
628
|
+
provider: model.provider,
|
|
629
|
+
kind: "runtime",
|
|
630
|
+
});
|
|
631
|
+
}
|
|
622
632
|
if (chunk.message?.thinking) {
|
|
623
633
|
suppressHealedThinking = true;
|
|
624
634
|
endActiveTextBlock();
|
|
@@ -133,6 +133,7 @@ export function streamOpenAIAnthropicShim(
|
|
|
133
133
|
presencePenalty: options?.presencePenalty,
|
|
134
134
|
repetitionPenalty: options?.repetitionPenalty,
|
|
135
135
|
maxTokens: options?.maxTokens ?? model.maxTokens ?? undefined,
|
|
136
|
+
maxHttpAttempts: options?.maxHttpAttempts,
|
|
136
137
|
signal: options?.signal,
|
|
137
138
|
headers: mergedHeaders,
|
|
138
139
|
cacheRetention: config.forwardCacheOptions ? options?.cacheRetention : undefined,
|
|
@@ -531,6 +531,13 @@ function makeId(): string {
|
|
|
531
531
|
// encodeStream (SSE)
|
|
532
532
|
// ---------------------------------------------------------------------------
|
|
533
533
|
|
|
534
|
+
// Keepalive cadence for the SSE encoder. Some upstreams go silent for minutes
|
|
535
|
+
// mid-stream (e.g. xAI emits a large tool call's arguments only when complete);
|
|
536
|
+
// without bytes on the wire the listener's idle timeout and client watchdogs
|
|
537
|
+
// drop a healthy stream. SSE comment lines are ignored by spec-compliant clients.
|
|
538
|
+
const STREAM_KEEPALIVE_INTERVAL_MS = 15_000;
|
|
539
|
+
const SSE_KEEPALIVE = ": keepalive\n\n";
|
|
540
|
+
|
|
534
541
|
export function encodeStream(
|
|
535
542
|
events: AssistantMessageEventStream,
|
|
536
543
|
requestedModelId: string,
|
|
@@ -546,6 +553,13 @@ export function encodeStream(
|
|
|
546
553
|
cancelled = true;
|
|
547
554
|
};
|
|
548
555
|
control?.signal?.addEventListener("abort", markCancelled, { once: true });
|
|
556
|
+
let keepaliveTimer: NodeJS.Timeout | undefined;
|
|
557
|
+
const stopKeepalive = () => {
|
|
558
|
+
if (keepaliveTimer !== undefined) {
|
|
559
|
+
clearInterval(keepaliveTimer);
|
|
560
|
+
keepaliveTimer = undefined;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
549
563
|
|
|
550
564
|
const baseChunk = (delta: Record<string, unknown>, finishReason: string | null) => ({
|
|
551
565
|
id,
|
|
@@ -584,6 +598,19 @@ export function encodeStream(
|
|
|
584
598
|
let hasToolCalls = false;
|
|
585
599
|
let finishReason: string = "stop";
|
|
586
600
|
|
|
601
|
+
keepaliveTimer = setInterval(() => {
|
|
602
|
+
try {
|
|
603
|
+
if (cancelled) {
|
|
604
|
+
stopKeepalive();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
controller.enqueue(encoder.encode(SSE_KEEPALIVE));
|
|
608
|
+
} catch {
|
|
609
|
+
// Controller already closed/errored (client gone); stop the timer.
|
|
610
|
+
stopKeepalive();
|
|
611
|
+
}
|
|
612
|
+
}, STREAM_KEEPALIVE_INTERVAL_MS);
|
|
613
|
+
|
|
587
614
|
try {
|
|
588
615
|
if (cancelled) {
|
|
589
616
|
controller.close();
|
|
@@ -733,11 +760,13 @@ export function encodeStream(
|
|
|
733
760
|
controller.close();
|
|
734
761
|
}
|
|
735
762
|
} finally {
|
|
763
|
+
stopKeepalive();
|
|
736
764
|
control?.signal?.removeEventListener("abort", markCancelled);
|
|
737
765
|
}
|
|
738
766
|
},
|
|
739
767
|
cancel(reason) {
|
|
740
768
|
cancelled = true;
|
|
769
|
+
stopKeepalive();
|
|
741
770
|
control?.signal?.removeEventListener("abort", markCancelled);
|
|
742
771
|
control?.onCancel?.(reason);
|
|
743
772
|
},
|
|
@@ -65,6 +65,7 @@ export function dropRejectedCodexAccessPrograms(
|
|
|
65
65
|
function isAccessProgramRejection(error: unknown): boolean {
|
|
66
66
|
if (!(error instanceof Error)) return false;
|
|
67
67
|
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
|
|
68
|
-
|
|
68
|
+
// HTTP parsing promotes generic error.type to code; only specific codes rule out message fallback.
|
|
69
|
+
if (code !== undefined && code !== "invalid_request_error") return Object.hasOwn(REJECTION_CODES, code);
|
|
69
70
|
return REJECTION_PATTERN.test(error.message);
|
|
70
71
|
}
|