@oh-my-pi/pi-ai 14.8.1 → 14.9.1
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 +18 -0
- package/package.json +3 -3
- package/src/auth-storage.ts +85 -1
- package/src/providers/amazon-bedrock.ts +1 -1
- package/src/providers/anthropic.ts +98 -62
- package/src/providers/azure-openai-responses.ts +3 -1
- package/src/providers/gitlab-duo.ts +6 -0
- package/src/providers/google-gemini-cli.ts +2 -1
- package/src/providers/google-shared.ts +29 -20
- package/src/providers/google-vertex.ts +1 -1
- package/src/providers/google.ts +1 -1
- package/src/providers/kimi.ts +4 -0
- package/src/providers/openai-codex-responses.ts +91 -33
- package/src/providers/openai-completions.ts +39 -13
- package/src/providers/openai-responses-shared.ts +34 -19
- package/src/providers/openai-responses.ts +33 -2
- package/src/providers/synthetic.ts +4 -0
- package/src/providers/vision-guard.ts +31 -0
- package/src/stream.ts +1 -0
- package/src/types.ts +18 -0
- package/src/utils/idle-iterator.ts +60 -11
- package/src/utils/oauth/anthropic.ts +38 -10
- package/src/utils/sse-debug.ts +70 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [14.9.0] - 2026-05-10
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Fixed silent forwarding of image content (for example Python plot output rendered in the terminal) to models without vision support, which produced opaque 404 errors from upstream. Image blocks are now stripped and replaced with a `[image omitted: model does not support vision]` placeholder for non-vision models, including tool-result payloads ([#967](https://github.com/can1357/oh-my-pi/issues/967), [#968](https://github.com/can1357/oh-my-pi/issues/968)).
|
|
11
|
+
|
|
12
|
+
- Added `AuthStorage` `onCredentialDisabled` callback (sync or async) so embedders can react when a credential is automatically disabled (e.g. OAuth refresh fails with `invalid_grant`) — useful for surfacing a banner or auto-launching a re-login flow instead of letting the credential silently disappear. Sync throws and async rejections are both caught and logged so a misbehaving subscriber cannot break the disable path.
|
|
13
|
+
- Added Anthropic OAuth `account.uuid` and `account.email_address` extraction from the `/v1/oauth/token` exchange and refresh responses; both `AnthropicOAuthFlow.exchangeToken()` and `refreshAnthropicToken()` now populate `OAuthCredentials.{accountId, email}` so downstream consumers can attribute requests to the authenticated account without a separate `/api/oauth/profile` round-trip.
|
|
14
|
+
- Added `onSseEvent` stream diagnostics so HTTP SSE providers can expose raw SSE frames without changing parsed model output.
|
|
15
|
+
- Added `streamIdleTimeoutMs` option (and `PI_STREAM_IDLE_TIMEOUT_MS` env override; `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` remains a backward-compatible alias) for a steady-state inter-event watchdog. Set to `0` to disable.
|
|
16
|
+
- Added a semantic-progress predicate to OpenAI Responses and Codex SSE/WebSocket transports so `response.in_progress`-style keepalives no longer reset the idle deadline on stalled tool calls.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Anthropic streams now enforce a steady-state idle timeout (defaults to 120s, same control as `PI_STREAM_IDLE_TIMEOUT_MS`) in addition to the first-event watchdog. Long-running responses that go fully silent between events will now surface as `Anthropic stream stalled while waiting for the next event` instead of hanging.
|
|
21
|
+
- Fixed `resolveAnthropicMetadataUserId()` to accept JSON-format `user_id` values that match real Claude Code's payload shape (`{ device_id, account_uuid, session_id, ... }` from `services/api/claude.ts:getAPIMetadata`). Previously only the synthetic `user_<hex>_account_<uuid>_session_<uuid>` cloaking format was accepted on OAuth, which caused stable session-keyed metadata supplied by callers to be discarded and replaced with fresh random entropy on every request — defeating session-count attribution on the Claude OAuth path.
|
|
22
|
+
|
|
5
23
|
## [14.8.0] - 2026-05-09
|
|
6
24
|
|
|
7
25
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "14.
|
|
4
|
+
"version": "14.9.1",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"@aws-sdk/credential-provider-node": "^3.972.39",
|
|
47
47
|
"@bufbuild/protobuf": "^2.12.0",
|
|
48
48
|
"@google/genai": "^1.52.0",
|
|
49
|
-
"@oh-my-pi/pi-natives": "14.
|
|
50
|
-
"@oh-my-pi/pi-utils": "14.
|
|
49
|
+
"@oh-my-pi/pi-natives": "14.9.1",
|
|
50
|
+
"@oh-my-pi/pi-utils": "14.9.1",
|
|
51
51
|
"@sinclair/typebox": "^0.34.49",
|
|
52
52
|
"@smithy/node-http-handler": "^4.6.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
package/src/auth-storage.ts
CHANGED
|
@@ -82,6 +82,21 @@ export interface StoredAuthCredential {
|
|
|
82
82
|
// AuthStorage Options
|
|
83
83
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Event payload describing a credential that was just soft-disabled.
|
|
87
|
+
*
|
|
88
|
+
* Today the only call site is OAuth refresh failures with a definitive cause
|
|
89
|
+
* (`invalid_grant`, `401/403` not from a network blip, etc.) — the
|
|
90
|
+
* disabled_cause string is the verbatim error captured for forensics.
|
|
91
|
+
*
|
|
92
|
+
* Subscribers can use this to surface a notification, banner, or auto-launch
|
|
93
|
+
* a re-login flow instead of letting the credential silently disappear.
|
|
94
|
+
*/
|
|
95
|
+
export interface CredentialDisabledEvent {
|
|
96
|
+
provider: string;
|
|
97
|
+
disabledCause: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
85
100
|
export type AuthStorageOptions = {
|
|
86
101
|
usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
|
|
87
102
|
rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
|
|
@@ -94,6 +109,14 @@ export type AuthStorageOptions = {
|
|
|
94
109
|
* - Default: checks environment variable first, then treats as literal
|
|
95
110
|
*/
|
|
96
111
|
configValueResolver?: (config: string) => Promise<string | undefined>;
|
|
112
|
+
/**
|
|
113
|
+
* Optional callback fired when AuthStorage automatically disables a
|
|
114
|
+
* credential because something detected it as no longer usable — today
|
|
115
|
+
* that's the OAuth refresh-failure path in `getApiKey`. NOT fired for
|
|
116
|
+
* user-initiated `remove()` (the user already knows) or dedup of
|
|
117
|
+
* duplicate credentials (uninteresting hygiene).
|
|
118
|
+
*/
|
|
119
|
+
onCredentialDisabled?: (event: CredentialDisabledEvent) => void | Promise<void>;
|
|
97
120
|
};
|
|
98
121
|
|
|
99
122
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -260,6 +283,7 @@ export class AuthStorage {
|
|
|
260
283
|
#fallbackResolver?: (provider: string) => string | undefined;
|
|
261
284
|
#store: AuthCredentialStore;
|
|
262
285
|
#configValueResolver: (config: string) => Promise<string | undefined>;
|
|
286
|
+
#onCredentialDisabled?: (event: CredentialDisabledEvent) => void | Promise<void>;
|
|
263
287
|
#closed = false;
|
|
264
288
|
|
|
265
289
|
constructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {
|
|
@@ -270,6 +294,7 @@ export class AuthStorage {
|
|
|
270
294
|
this.#usageCache = new AuthStorageUsageCache(this.#store);
|
|
271
295
|
this.#usageFetch = options.usageFetch ?? fetch;
|
|
272
296
|
this.#usageRequestTimeoutMs = options.usageRequestTimeoutMs ?? DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
|
|
297
|
+
this.#onCredentialDisabled = options.onCredentialDisabled;
|
|
273
298
|
this.#usageLogger =
|
|
274
299
|
options.usageLogger ??
|
|
275
300
|
({
|
|
@@ -601,6 +626,23 @@ export class AuthStorage {
|
|
|
601
626
|
const updated = entries.filter((_value, idx) => idx !== index);
|
|
602
627
|
this.#setStoredCredentials(provider, updated);
|
|
603
628
|
this.#resetProviderAssignments(provider);
|
|
629
|
+
this.#emitCredentialDisabled({ provider, disabledCause });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
#emitCredentialDisabled(event: CredentialDisabledEvent): void {
|
|
633
|
+
const handler = this.#onCredentialDisabled;
|
|
634
|
+
if (!handler) return;
|
|
635
|
+
const logHandlerError = (error: unknown): void => {
|
|
636
|
+
logger.warn("onCredentialDisabled handler threw", { provider: event.provider, error: String(error) });
|
|
637
|
+
};
|
|
638
|
+
try {
|
|
639
|
+
const result = handler(event);
|
|
640
|
+
if (result && typeof (result as PromiseLike<void>).then === "function") {
|
|
641
|
+
(result as Promise<void>).catch(logHandlerError);
|
|
642
|
+
}
|
|
643
|
+
} catch (error) {
|
|
644
|
+
logHandlerError(error);
|
|
645
|
+
}
|
|
604
646
|
}
|
|
605
647
|
|
|
606
648
|
/**
|
|
@@ -684,6 +726,44 @@ export class AuthStorage {
|
|
|
684
726
|
);
|
|
685
727
|
}
|
|
686
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Get the OAuth `accountId` for a provider, preferring the credential that is
|
|
731
|
+
* session-sticky for `sessionId` when multiple OAuth credentials are configured.
|
|
732
|
+
* Falls back to the first OAuth credential when no session preference exists (e.g.
|
|
733
|
+
* first call before any `getApiKey` has been issued, or single-credential setups).
|
|
734
|
+
* Returns `undefined` when no OAuth credential carries an `accountId`.
|
|
735
|
+
*/
|
|
736
|
+
getOAuthAccountId(provider: string, sessionId?: string): string | undefined {
|
|
737
|
+
const allCredentials = this.#getCredentialsForProvider(provider);
|
|
738
|
+
const oauthCredentials = allCredentials.filter((c): c is OAuthCredential => c.type === "oauth");
|
|
739
|
+
if (oauthCredentials.length === 0) return undefined;
|
|
740
|
+
|
|
741
|
+
// Runtime override always returns before recording a session credential.
|
|
742
|
+
if (this.#runtimeOverrides.has(provider)) return undefined;
|
|
743
|
+
|
|
744
|
+
// Prefer the session-sticky credential when available.
|
|
745
|
+
const sessionPref = this.#getSessionCredential(provider, sessionId);
|
|
746
|
+
// If the session has been routed to a stored API key, do not inject OAuth account_uuid.
|
|
747
|
+
if (sessionPref !== undefined && sessionPref.type !== "oauth") return undefined;
|
|
748
|
+
|
|
749
|
+
// When no session-sticky credential is recorded yet (first call before any getApiKey,
|
|
750
|
+
// or all stored credentials are unavailable), the request falls through to the env-key
|
|
751
|
+
// or fallback-resolver path in getApiKey() — neither is OAuth-authenticated, so
|
|
752
|
+
// account_uuid injection would misattribute traffic. Only apply this guard when
|
|
753
|
+
// sessionPref is absent; a recorded OAuth sticky (sessionPref.type === "oauth") must
|
|
754
|
+
// NOT be blocked even if an env key also happens to exist.
|
|
755
|
+
if (!sessionPref && (getEnvApiKey(provider) || this.#fallbackResolver?.(provider))) return undefined;
|
|
756
|
+
// Resolve the sticky index against the full credential list — the index is
|
|
757
|
+
// recorded against the unfiltered provider array (by #recordSessionCredential /
|
|
758
|
+
// #tryOAuthCredential), not the OAuth-only subset, so dereferencing it into the
|
|
759
|
+
// filtered array would be off-by-N when any non-OAuth credential precedes the
|
|
760
|
+
// OAuth ones (e.g. [api_key, oauth_A, oauth_B] stored order).
|
|
761
|
+
const stickyCredential = sessionPref?.type === "oauth" ? allCredentials[sessionPref.index] : undefined;
|
|
762
|
+
const preferred = stickyCredential?.type === "oauth" ? stickyCredential : oauthCredentials[0];
|
|
763
|
+
const accountId = preferred?.accountId;
|
|
764
|
+
return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined;
|
|
765
|
+
}
|
|
766
|
+
|
|
687
767
|
/**
|
|
688
768
|
* Get all credentials.
|
|
689
769
|
*/
|
|
@@ -1992,7 +2072,11 @@ export class AuthStorage {
|
|
|
1992
2072
|
return oauthKey;
|
|
1993
2073
|
}
|
|
1994
2074
|
|
|
1995
|
-
// Fall back to environment variable
|
|
2075
|
+
// Fall back to environment variable or custom resolver. If we reach here after
|
|
2076
|
+
// an OAuth miss, the session sticky (if any) is stale — the request will
|
|
2077
|
+
// authenticate via env/fallback, not OAuth, so clear the sticky now so that
|
|
2078
|
+
// getOAuthAccountId() correctly suppresses account_uuid for this session.
|
|
2079
|
+
if (sessionId) this.#sessionLastCredential.get(provider)?.delete(sessionId);
|
|
1996
2080
|
const envKey = getEnvApiKey(provider);
|
|
1997
2081
|
if (envKey) return envKey;
|
|
1998
2082
|
|
|
@@ -259,7 +259,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
if (output.stopReason === "error" || output.stopReason === "aborted") {
|
|
262
|
-
throw new Error("An unknown error occurred");
|
|
262
|
+
throw new Error(output.errorMessage ?? "An unknown error occurred");
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
output.duration = Date.now() - startTime;
|
|
@@ -44,18 +44,20 @@ import { createAbortSourceTracker } from "../utils/abort";
|
|
|
44
44
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
45
45
|
import { isFoundryEnabled } from "../utils/foundry";
|
|
46
46
|
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
|
|
47
|
-
import {
|
|
47
|
+
import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
|
|
48
48
|
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
|
|
49
49
|
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
|
|
50
50
|
import { notifyProviderResponse } from "../utils/provider-response";
|
|
51
51
|
import { extractHttpStatusFromError, isCopilotRetryableError, isUnexpectedSocketCloseMessage } from "../utils/retry";
|
|
52
52
|
import { COMBINATOR_KEYS, NO_STRICT } from "../utils/schema";
|
|
53
|
+
import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug";
|
|
53
54
|
import {
|
|
54
55
|
buildCopilotDynamicHeaders,
|
|
55
56
|
hasCopilotVisionInput,
|
|
56
57
|
resolveGitHubCopilotBaseUrl,
|
|
57
58
|
} from "./github-copilot-headers";
|
|
58
59
|
import { transformMessages } from "./transform-messages";
|
|
60
|
+
import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
|
|
59
61
|
|
|
60
62
|
export type AnthropicHeaderOptions = {
|
|
61
63
|
apiKey: string;
|
|
@@ -361,6 +363,26 @@ export function isClaudeCloakingUserId(userId: string): boolean {
|
|
|
361
363
|
return CLAUDE_CLOAKING_USER_ID_REGEX.test(userId);
|
|
362
364
|
}
|
|
363
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Real Claude Code sends `metadata.user_id` as a JSON-stringified object of the
|
|
368
|
+
* shape `{ device_id, account_uuid, session_id, ...extra }` (see
|
|
369
|
+
* services/api/claude.ts → getAPIMetadata). Accept that shape so callers that
|
|
370
|
+
* supply a stable `session_id` aren't silently overwritten with fresh entropy
|
|
371
|
+
* on every request, which would inflate the backend session count.
|
|
372
|
+
*/
|
|
373
|
+
function isClaudeJsonUserId(userId: string): boolean {
|
|
374
|
+
if (userId.length === 0 || userId[0] !== "{") return false;
|
|
375
|
+
let parsed: unknown;
|
|
376
|
+
try {
|
|
377
|
+
parsed = JSON.parse(userId);
|
|
378
|
+
} catch {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
382
|
+
const obj = parsed as Record<string, unknown>;
|
|
383
|
+
return typeof obj.session_id === "string" && obj.session_id.length > 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
364
386
|
export function generateClaudeCloakingUserId(): string {
|
|
365
387
|
const userHash = nodeCrypto.randomBytes(32).toString("hex");
|
|
366
388
|
const accountId = nodeCrypto.randomUUID().toLowerCase();
|
|
@@ -370,7 +392,7 @@ export function generateClaudeCloakingUserId(): string {
|
|
|
370
392
|
|
|
371
393
|
function resolveAnthropicMetadataUserId(userId: unknown, isOAuthToken: boolean): string | undefined {
|
|
372
394
|
if (typeof userId === "string") {
|
|
373
|
-
if (!isOAuthToken || isClaudeCloakingUserId(userId)) {
|
|
395
|
+
if (!isOAuthToken || isClaudeCloakingUserId(userId) || isClaudeJsonUserId(userId)) {
|
|
374
396
|
return userId;
|
|
375
397
|
}
|
|
376
398
|
}
|
|
@@ -397,7 +419,10 @@ export const stripClaudeToolPrefix = (name: string, prefixOverride: string = cla
|
|
|
397
419
|
/**
|
|
398
420
|
* Convert content blocks to Anthropic API format
|
|
399
421
|
*/
|
|
400
|
-
function convertContentBlocks(
|
|
422
|
+
function convertContentBlocks(
|
|
423
|
+
content: (TextContent | ImageContent)[],
|
|
424
|
+
supportsImages = true,
|
|
425
|
+
):
|
|
401
426
|
| string
|
|
402
427
|
| Array<
|
|
403
428
|
| { type: "text"; text: string }
|
|
@@ -410,36 +435,35 @@ function convertContentBlocks(content: (TextContent | ImageContent)[]):
|
|
|
410
435
|
};
|
|
411
436
|
}
|
|
412
437
|
> {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
// If we have images, convert to content block array
|
|
423
|
-
const blocks = content.map(block => {
|
|
424
|
-
if (block.type === "text") {
|
|
425
|
-
return {
|
|
426
|
-
type: "text" as const,
|
|
427
|
-
text: block.text.toWellFormed(),
|
|
428
|
-
};
|
|
438
|
+
const textBlocks = content
|
|
439
|
+
.filter((block): block is TextContent => block.type === "text")
|
|
440
|
+
.map(block => block.text.toWellFormed())
|
|
441
|
+
.filter(text => text.trim().length > 0);
|
|
442
|
+
const imageBlocks = content.filter((block): block is ImageContent => block.type === "image");
|
|
443
|
+
const omittedImages = !supportsImages && imageBlocks.length > 0;
|
|
444
|
+
if (imageBlocks.length === 0 || !supportsImages) {
|
|
445
|
+
if (omittedImages) {
|
|
446
|
+
textBlocks.push(NON_VISION_IMAGE_PLACEHOLDER);
|
|
429
447
|
}
|
|
430
|
-
return
|
|
448
|
+
return textBlocks.join("\n").toWellFormed();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const blocks = [
|
|
452
|
+
...textBlocks.map(text => ({
|
|
453
|
+
type: "text" as const,
|
|
454
|
+
text,
|
|
455
|
+
})),
|
|
456
|
+
...imageBlocks.map(block => ({
|
|
431
457
|
type: "image" as const,
|
|
432
458
|
source: {
|
|
433
459
|
type: "base64" as const,
|
|
434
460
|
media_type: block.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
|
|
435
461
|
data: block.data,
|
|
436
462
|
},
|
|
437
|
-
}
|
|
438
|
-
|
|
463
|
+
})),
|
|
464
|
+
];
|
|
439
465
|
|
|
440
|
-
|
|
441
|
-
const hasText = blocks.some(b => b.type === "text");
|
|
442
|
-
if (!hasText) {
|
|
466
|
+
if (!textBlocks.length) {
|
|
443
467
|
blocks.unshift({
|
|
444
468
|
type: "text" as const,
|
|
445
469
|
text: "(see attached image)",
|
|
@@ -508,6 +532,7 @@ export type AnthropicClientOptionsArgs = {
|
|
|
508
532
|
dynamicHeaders?: Record<string, string>;
|
|
509
533
|
isOAuth?: boolean;
|
|
510
534
|
hasTools?: boolean;
|
|
535
|
+
onSseEvent?: AnthropicOptions["onSseEvent"];
|
|
511
536
|
};
|
|
512
537
|
|
|
513
538
|
export type AnthropicClientOptionsResult = {
|
|
@@ -519,6 +544,7 @@ export type AnthropicClientOptionsResult = {
|
|
|
519
544
|
dangerouslyAllowBrowser: boolean;
|
|
520
545
|
defaultHeaders: Record<string, string>;
|
|
521
546
|
logLevel: AnthropicSdkClientOptions["logLevel"];
|
|
547
|
+
fetch?: AnthropicSdkClientOptions["fetch"];
|
|
522
548
|
fetchOptions?: AnthropicSdkClientOptions["fetchOptions"];
|
|
523
549
|
};
|
|
524
550
|
|
|
@@ -670,6 +696,7 @@ const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet<string> = new Set([
|
|
|
670
696
|
async function* iterateAnthropicEvents(
|
|
671
697
|
response: Response,
|
|
672
698
|
signal?: AbortSignal,
|
|
699
|
+
onSseEvent?: AnthropicOptions["onSseEvent"],
|
|
673
700
|
): AsyncGenerator<RawMessageStreamEvent> {
|
|
674
701
|
if (!response.body) {
|
|
675
702
|
throw new Error("Attempted to iterate over an Anthropic response with no body");
|
|
@@ -679,6 +706,7 @@ async function* iterateAnthropicEvents(
|
|
|
679
706
|
let sawMessageEnd = false;
|
|
680
707
|
|
|
681
708
|
for await (const sse of readSseEvents(response.body, signal)) {
|
|
709
|
+
notifyRawSseEvent(onSseEvent, sse);
|
|
682
710
|
if (sse.event === "error") {
|
|
683
711
|
throw new Error(sse.data);
|
|
684
712
|
}
|
|
@@ -731,11 +759,12 @@ function hasAnthropicStreamWithResponseRequest(request: unknown): request is Ant
|
|
|
731
759
|
async function getAnthropicStreamResponse(
|
|
732
760
|
request: unknown,
|
|
733
761
|
signal?: AbortSignal,
|
|
762
|
+
onSseEvent?: AnthropicOptions["onSseEvent"],
|
|
734
763
|
): Promise<{ events: AsyncIterable<RawMessageStreamEvent>; response: Response; requestId: string | null }> {
|
|
735
764
|
if (hasAnthropicRawResponseRequest(request)) {
|
|
736
765
|
const response = await request.asResponse();
|
|
737
766
|
return {
|
|
738
|
-
events: iterateAnthropicEvents(response, signal),
|
|
767
|
+
events: iterateAnthropicEvents(response, signal, onSseEvent),
|
|
739
768
|
response,
|
|
740
769
|
requestId: response.headers.get("request-id"),
|
|
741
770
|
};
|
|
@@ -924,6 +953,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
924
953
|
dynamicHeaders: copilotDynamicHeaders?.headers,
|
|
925
954
|
isOAuth: options?.isOAuth,
|
|
926
955
|
hasTools: !!context.tools?.length,
|
|
956
|
+
onSseEvent: options?.onSseEvent,
|
|
927
957
|
});
|
|
928
958
|
client = created.client;
|
|
929
959
|
isOAuthToken = created.isOAuthToken;
|
|
@@ -963,7 +993,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
963
993
|
| (ToolCall & { partialJson: string })
|
|
964
994
|
) & { index: number };
|
|
965
995
|
const blocks = output.content as Block[];
|
|
966
|
-
const
|
|
996
|
+
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
|
|
997
|
+
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
|
|
967
998
|
stream.push({ type: "start", partial: output });
|
|
968
999
|
// Retry loop for transient errors from the stream.
|
|
969
1000
|
// Provider-level transport/rate-limit failures: only before any streamed content starts.
|
|
@@ -974,6 +1005,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
974
1005
|
const firstEventTimeoutAbortError = new Error(
|
|
975
1006
|
"Anthropic stream timed out while waiting for the first event",
|
|
976
1007
|
);
|
|
1008
|
+
const idleTimeoutAbortError = new Error("Anthropic stream stalled while waiting for the next event");
|
|
977
1009
|
const { requestSignal } = activeAbortTracker;
|
|
978
1010
|
const anthropicRequest = client.messages.create({ ...params, stream: true }, { signal: requestSignal });
|
|
979
1011
|
let streamedReplayUnsafeContent = false;
|
|
@@ -983,19 +1015,25 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
983
1015
|
events: anthropicStream,
|
|
984
1016
|
response,
|
|
985
1017
|
requestId,
|
|
986
|
-
} = await getAnthropicStreamResponse(
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1018
|
+
} = await getAnthropicStreamResponse(
|
|
1019
|
+
anthropicRequest,
|
|
1020
|
+
requestSignal,
|
|
1021
|
+
options?.client ? event => options?.onSseEvent?.(event, model) : undefined,
|
|
990
1022
|
);
|
|
1023
|
+
await notifyProviderResponse(options, response, model, requestId);
|
|
991
1024
|
let sawEvent = false;
|
|
992
1025
|
let sawMessageStart = false;
|
|
993
1026
|
let sawTerminalEnvelope = false;
|
|
994
1027
|
|
|
995
|
-
for await (const event of anthropicStream
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1028
|
+
for await (const event of iterateWithIdleTimeout(anthropicStream, {
|
|
1029
|
+
idleTimeoutMs,
|
|
1030
|
+
firstItemTimeoutMs: firstEventTimeoutMs,
|
|
1031
|
+
errorMessage: idleTimeoutAbortError.message,
|
|
1032
|
+
firstItemErrorMessage: firstEventTimeoutAbortError.message,
|
|
1033
|
+
onIdle: () => activeAbortTracker.abortLocally(idleTimeoutAbortError),
|
|
1034
|
+
onFirstItemTimeout: () => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
|
|
1035
|
+
abortSignal: options?.signal,
|
|
1036
|
+
})) {
|
|
999
1037
|
sawEvent = true;
|
|
1000
1038
|
|
|
1001
1039
|
if (event.type === "message_start") {
|
|
@@ -1157,6 +1195,13 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
1157
1195
|
output.stopReason = mapStopReason(event.delta.stop_reason);
|
|
1158
1196
|
sawTerminalEnvelope = true;
|
|
1159
1197
|
}
|
|
1198
|
+
const stopDetails = event.delta.stop_details;
|
|
1199
|
+
if (stopDetails && stopDetails.type === "refusal") {
|
|
1200
|
+
const explanation = stopDetails.explanation?.trim();
|
|
1201
|
+
const category = stopDetails.category;
|
|
1202
|
+
const label = category ? `Refusal (${category})` : "Refusal";
|
|
1203
|
+
output.errorMessage = explanation ? `${label}: ${explanation}` : label;
|
|
1204
|
+
}
|
|
1160
1205
|
if (event.usage.input_tokens != null) {
|
|
1161
1206
|
output.usage.input = event.usage.input_tokens;
|
|
1162
1207
|
}
|
|
@@ -1193,7 +1238,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
1193
1238
|
}
|
|
1194
1239
|
|
|
1195
1240
|
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
|
1196
|
-
throw new Error("An unknown error occurred");
|
|
1241
|
+
throw new Error(output.errorMessage ?? "An unknown error occurred");
|
|
1197
1242
|
}
|
|
1198
1243
|
break;
|
|
1199
1244
|
} catch (streamError) {
|
|
@@ -1340,6 +1385,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
1340
1385
|
dynamicHeaders,
|
|
1341
1386
|
hasTools = false,
|
|
1342
1387
|
isOAuth,
|
|
1388
|
+
onSseEvent,
|
|
1343
1389
|
} = args;
|
|
1344
1390
|
const compat = getAnthropicCompat(model);
|
|
1345
1391
|
const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinkingDisplay(model.id);
|
|
@@ -1348,6 +1394,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
1348
1394
|
const baseUrl = resolveAnthropicBaseUrl(model, apiKey);
|
|
1349
1395
|
const foundryCustomHeaders = resolveAnthropicCustomHeaders(model);
|
|
1350
1396
|
const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model, baseUrl);
|
|
1397
|
+
const debugFetch = onSseEvent ? wrapFetchForSseDebug(fetch, event => onSseEvent(event, model)) : undefined;
|
|
1351
1398
|
if (model.provider === "github-copilot") {
|
|
1352
1399
|
const copilotApiKey = parseGitHubCopilotApiKey(apiKey).accessToken;
|
|
1353
1400
|
const betaFeatures = [...extraBetas];
|
|
@@ -1375,6 +1422,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
1375
1422
|
dangerouslyAllowBrowser: true,
|
|
1376
1423
|
defaultHeaders,
|
|
1377
1424
|
logLevel: ANTHROPIC_SDK_LOG_LEVEL,
|
|
1425
|
+
...(debugFetch ? { fetch: debugFetch } : {}),
|
|
1378
1426
|
...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}),
|
|
1379
1427
|
};
|
|
1380
1428
|
}
|
|
@@ -1407,6 +1455,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
1407
1455
|
dangerouslyAllowBrowser: true,
|
|
1408
1456
|
defaultHeaders,
|
|
1409
1457
|
logLevel: ANTHROPIC_SDK_LOG_LEVEL,
|
|
1458
|
+
...(debugFetch ? { fetch: debugFetch } : {}),
|
|
1410
1459
|
};
|
|
1411
1460
|
}
|
|
1412
1461
|
|
|
@@ -1419,6 +1468,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
1419
1468
|
dangerouslyAllowBrowser: true,
|
|
1420
1469
|
defaultHeaders,
|
|
1421
1470
|
logLevel: ANTHROPIC_SDK_LOG_LEVEL,
|
|
1471
|
+
...(debugFetch ? { fetch: debugFetch } : {}),
|
|
1422
1472
|
...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}),
|
|
1423
1473
|
};
|
|
1424
1474
|
}
|
|
@@ -1850,7 +1900,7 @@ function buildToolResultBlock(model: Model<"anthropic-messages">, msg: ToolResul
|
|
|
1850
1900
|
const block: ContentBlockParam = {
|
|
1851
1901
|
type: "tool_result",
|
|
1852
1902
|
tool_use_id: msg.toolCallId,
|
|
1853
|
-
content: convertContentBlocks(msg.content),
|
|
1903
|
+
content: convertContentBlocks(msg.content, model.input.includes("image")),
|
|
1854
1904
|
is_error: msg.isError,
|
|
1855
1905
|
};
|
|
1856
1906
|
if (isZaiAnthropicEndpoint(model)) {
|
|
@@ -1883,33 +1933,19 @@ export function convertAnthropicMessages(
|
|
|
1883
1933
|
});
|
|
1884
1934
|
}
|
|
1885
1935
|
} else {
|
|
1886
|
-
const
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
type: "base64",
|
|
1897
|
-
media_type: item.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
|
|
1898
|
-
data: item.data,
|
|
1899
|
-
},
|
|
1900
|
-
};
|
|
1901
|
-
});
|
|
1902
|
-
let filteredBlocks = !model?.input.includes("image") ? blocks.filter(b => b.type !== "image") : blocks;
|
|
1903
|
-
filteredBlocks = filteredBlocks.filter(b => {
|
|
1904
|
-
if (b.type === "text") {
|
|
1905
|
-
return b.text.trim().length > 0;
|
|
1906
|
-
}
|
|
1907
|
-
return true;
|
|
1908
|
-
});
|
|
1909
|
-
if (filteredBlocks.length === 0) continue;
|
|
1936
|
+
const contentBlocks = convertContentBlocks(msg.content, model.input.includes("image"));
|
|
1937
|
+
if (typeof contentBlocks === "string") {
|
|
1938
|
+
if (contentBlocks.trim().length === 0) continue;
|
|
1939
|
+
params.push({
|
|
1940
|
+
role: "user",
|
|
1941
|
+
content: contentBlocks,
|
|
1942
|
+
});
|
|
1943
|
+
continue;
|
|
1944
|
+
}
|
|
1945
|
+
if (contentBlocks.length === 0) continue;
|
|
1910
1946
|
params.push({
|
|
1911
1947
|
role: "user",
|
|
1912
|
-
content:
|
|
1948
|
+
content: contentBlocks,
|
|
1913
1949
|
});
|
|
1914
1950
|
}
|
|
1915
1951
|
} else if (msg.role === "assistant") {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
getStreamFirstEventTimeoutMs,
|
|
29
29
|
iterateWithIdleTimeout,
|
|
30
30
|
} from "../utils/idle-iterator";
|
|
31
|
+
import { wrapFetchForSseDebug } from "../utils/sse-debug";
|
|
31
32
|
import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
|
|
32
33
|
import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
|
|
33
34
|
import {
|
|
@@ -171,7 +172,7 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
|
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
|
174
|
-
throw new Error("An unknown error occurred");
|
|
175
|
+
throw new Error(output.errorMessage ?? "An unknown error occurred");
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
output.duration = Date.now() - startTime;
|
|
@@ -258,6 +259,7 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
|
|
|
258
259
|
maxRetries: 5,
|
|
259
260
|
defaultHeaders: headers,
|
|
260
261
|
baseURL: baseUrl,
|
|
262
|
+
fetch: options?.onSseEvent ? wrapFetchForSseDebug(fetch, event => options.onSseEvent?.(event, model)) : fetch,
|
|
261
263
|
});
|
|
262
264
|
}
|
|
263
265
|
|
|
@@ -276,6 +276,8 @@ export function streamGitLabDuo(
|
|
|
276
276
|
sessionId: options.sessionId,
|
|
277
277
|
providerSessionState: options.providerSessionState,
|
|
278
278
|
onPayload: options.onPayload,
|
|
279
|
+
onResponse: options.onResponse,
|
|
280
|
+
onSseEvent: options.onSseEvent,
|
|
279
281
|
thinkingEnabled: Boolean(reasoningEffort) && model.reasoning,
|
|
280
282
|
thinkingBudgetTokens: reasoningEffort
|
|
281
283
|
? (options.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
|
|
@@ -310,6 +312,8 @@ export function streamGitLabDuo(
|
|
|
310
312
|
sessionId: options.sessionId,
|
|
311
313
|
providerSessionState: options.providerSessionState,
|
|
312
314
|
onPayload: options.onPayload,
|
|
315
|
+
onResponse: options.onResponse,
|
|
316
|
+
onSseEvent: options.onSseEvent,
|
|
313
317
|
reasoning: reasoningEffort,
|
|
314
318
|
toolChoice: options.toolChoice,
|
|
315
319
|
} satisfies OpenAIResponsesOptions,
|
|
@@ -339,6 +343,8 @@ export function streamGitLabDuo(
|
|
|
339
343
|
sessionId: options.sessionId,
|
|
340
344
|
providerSessionState: options.providerSessionState,
|
|
341
345
|
onPayload: options.onPayload,
|
|
346
|
+
onResponse: options.onResponse,
|
|
347
|
+
onSseEvent: options.onSseEvent,
|
|
342
348
|
reasoning: reasoningEffort,
|
|
343
349
|
toolChoice: options.toolChoice,
|
|
344
350
|
} satisfies OpenAICompletionsOptions,
|
|
@@ -508,6 +508,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
508
508
|
for await (const chunk of readSseJson<CloudCodeAssistResponseChunk>(
|
|
509
509
|
activeResponse.body!,
|
|
510
510
|
options?.signal,
|
|
511
|
+
event => options?.onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, model),
|
|
511
512
|
)) {
|
|
512
513
|
const responseData = chunk.response;
|
|
513
514
|
if (!responseData) continue;
|
|
@@ -747,7 +748,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
747
748
|
}
|
|
748
749
|
|
|
749
750
|
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
|
750
|
-
throw new Error("An unknown error occurred");
|
|
751
|
+
throw new Error(output.errorMessage ?? "An unknown error occurred");
|
|
751
752
|
}
|
|
752
753
|
|
|
753
754
|
output.duration = Date.now() - startTime;
|