@oh-my-pi/pi-ai 14.8.0 → 14.9.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 +19 -0
- package/package.json +3 -3
- package/src/auth-storage.ts +85 -1
- package/src/providers/anthropic.ts +90 -61
- package/src/providers/azure-openai-responses.ts +2 -0
- package/src/providers/gitlab-duo.ts +6 -0
- package/src/providers/google-gemini-cli.ts +1 -0
- package/src/providers/google-shared.ts +29 -20
- package/src/providers/kimi.ts +4 -0
- package/src/providers/openai-codex-responses.ts +92 -34
- package/src/providers/openai-completions.ts +39 -13
- package/src/providers/openai-responses-shared.ts +36 -21
- package/src/providers/openai-responses.ts +32 -1
- 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/src/utils.ts +21 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,30 @@
|
|
|
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
|
|
8
26
|
- Fixed Gemini 3 Pro thinking metadata so `medium` effort is rejected with the expected error instead of being silently accepted: `ThinkingConfig` now carries an optional explicit `levels` list that survives `expandEffortRange`, letting non-contiguous supported sets (e.g. `[low, high]`) round-trip through enrichment.
|
|
9
27
|
- Fixed Kimi Code OAuth expiry handling to refresh access tokens 5 minutes before server expiry, avoiding daily 401s from using tokens right up to the cutoff.
|
|
28
|
+
- Fixed OpenAI Responses custom tool replay to preserve custom tool call item IDs with the `ctc_` prefix instead of rewriting them as `fc_` function-call IDs ([#977](https://github.com/can1357/oh-my-pi/issues/977)).
|
|
10
29
|
|
|
11
30
|
## [14.7.6] - 2026-05-07
|
|
12
31
|
|
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.0",
|
|
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.0",
|
|
50
|
+
"@oh-my-pi/pi-utils": "14.9.0",
|
|
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
|
|
|
@@ -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") {
|
|
@@ -1340,6 +1378,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
1340
1378
|
dynamicHeaders,
|
|
1341
1379
|
hasTools = false,
|
|
1342
1380
|
isOAuth,
|
|
1381
|
+
onSseEvent,
|
|
1343
1382
|
} = args;
|
|
1344
1383
|
const compat = getAnthropicCompat(model);
|
|
1345
1384
|
const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinkingDisplay(model.id);
|
|
@@ -1348,6 +1387,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
1348
1387
|
const baseUrl = resolveAnthropicBaseUrl(model, apiKey);
|
|
1349
1388
|
const foundryCustomHeaders = resolveAnthropicCustomHeaders(model);
|
|
1350
1389
|
const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model, baseUrl);
|
|
1390
|
+
const debugFetch = onSseEvent ? wrapFetchForSseDebug(fetch, event => onSseEvent(event, model)) : undefined;
|
|
1351
1391
|
if (model.provider === "github-copilot") {
|
|
1352
1392
|
const copilotApiKey = parseGitHubCopilotApiKey(apiKey).accessToken;
|
|
1353
1393
|
const betaFeatures = [...extraBetas];
|
|
@@ -1375,6 +1415,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
1375
1415
|
dangerouslyAllowBrowser: true,
|
|
1376
1416
|
defaultHeaders,
|
|
1377
1417
|
logLevel: ANTHROPIC_SDK_LOG_LEVEL,
|
|
1418
|
+
...(debugFetch ? { fetch: debugFetch } : {}),
|
|
1378
1419
|
...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}),
|
|
1379
1420
|
};
|
|
1380
1421
|
}
|
|
@@ -1407,6 +1448,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
1407
1448
|
dangerouslyAllowBrowser: true,
|
|
1408
1449
|
defaultHeaders,
|
|
1409
1450
|
logLevel: ANTHROPIC_SDK_LOG_LEVEL,
|
|
1451
|
+
...(debugFetch ? { fetch: debugFetch } : {}),
|
|
1410
1452
|
};
|
|
1411
1453
|
}
|
|
1412
1454
|
|
|
@@ -1419,6 +1461,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
1419
1461
|
dangerouslyAllowBrowser: true,
|
|
1420
1462
|
defaultHeaders,
|
|
1421
1463
|
logLevel: ANTHROPIC_SDK_LOG_LEVEL,
|
|
1464
|
+
...(debugFetch ? { fetch: debugFetch } : {}),
|
|
1422
1465
|
...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}),
|
|
1423
1466
|
};
|
|
1424
1467
|
}
|
|
@@ -1850,7 +1893,7 @@ function buildToolResultBlock(model: Model<"anthropic-messages">, msg: ToolResul
|
|
|
1850
1893
|
const block: ContentBlockParam = {
|
|
1851
1894
|
type: "tool_result",
|
|
1852
1895
|
tool_use_id: msg.toolCallId,
|
|
1853
|
-
content: convertContentBlocks(msg.content),
|
|
1896
|
+
content: convertContentBlocks(msg.content, model.input.includes("image")),
|
|
1854
1897
|
is_error: msg.isError,
|
|
1855
1898
|
};
|
|
1856
1899
|
if (isZaiAnthropicEndpoint(model)) {
|
|
@@ -1883,33 +1926,19 @@ export function convertAnthropicMessages(
|
|
|
1883
1926
|
});
|
|
1884
1927
|
}
|
|
1885
1928
|
} 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;
|
|
1929
|
+
const contentBlocks = convertContentBlocks(msg.content, model.input.includes("image"));
|
|
1930
|
+
if (typeof contentBlocks === "string") {
|
|
1931
|
+
if (contentBlocks.trim().length === 0) continue;
|
|
1932
|
+
params.push({
|
|
1933
|
+
role: "user",
|
|
1934
|
+
content: contentBlocks,
|
|
1935
|
+
});
|
|
1936
|
+
continue;
|
|
1937
|
+
}
|
|
1938
|
+
if (contentBlocks.length === 0) continue;
|
|
1910
1939
|
params.push({
|
|
1911
1940
|
role: "user",
|
|
1912
|
-
content:
|
|
1941
|
+
content: contentBlocks,
|
|
1913
1942
|
});
|
|
1914
1943
|
}
|
|
1915
1944
|
} 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 {
|
|
@@ -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;
|
|
@@ -5,6 +5,7 @@ import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from
|
|
|
5
5
|
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types";
|
|
6
6
|
import { prepareSchemaForCCA, sanitizeSchemaForGoogle } from "../utils/schema";
|
|
7
7
|
import { transformMessages } from "./transform-messages";
|
|
8
|
+
import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
|
|
8
9
|
|
|
9
10
|
export { sanitizeSchemaForGoogle };
|
|
10
11
|
|
|
@@ -108,30 +109,32 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
|
|
108
109
|
parts: [{ text: msg.content.toWellFormed() }],
|
|
109
110
|
});
|
|
110
111
|
} else {
|
|
111
|
-
const
|
|
112
|
+
const supportsImages = model.input.includes("image");
|
|
113
|
+
const parts: Part[] = [];
|
|
114
|
+
let omittedImages = false;
|
|
115
|
+
for (const item of msg.content) {
|
|
112
116
|
if (item.type === "text") {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
const text = item.text.toWellFormed();
|
|
118
|
+
if (text.trim().length === 0) continue;
|
|
119
|
+
parts.push({ text });
|
|
120
|
+
} else if (supportsImages) {
|
|
121
|
+
parts.push({
|
|
116
122
|
inlineData: {
|
|
117
123
|
mimeType: item.mimeType,
|
|
118
124
|
data: item.data,
|
|
119
125
|
},
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Filter out images if model doesn't support them, and empty text blocks
|
|
124
|
-
let filteredParts = !model.input.includes("image") ? parts.filter(p => p.text !== undefined) : parts;
|
|
125
|
-
filteredParts = filteredParts.filter(p => {
|
|
126
|
-
if (p.text !== undefined) {
|
|
127
|
-
return p.text.trim().length > 0;
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
omittedImages = true;
|
|
128
129
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
}
|
|
131
|
+
if (omittedImages) {
|
|
132
|
+
parts.push({ text: NON_VISION_IMAGE_PLACEHOLDER });
|
|
133
|
+
}
|
|
134
|
+
if (parts.length === 0) continue;
|
|
132
135
|
contents.push({
|
|
133
136
|
role: "user",
|
|
134
|
-
parts
|
|
137
|
+
parts,
|
|
135
138
|
});
|
|
136
139
|
}
|
|
137
140
|
} else if (msg.role === "assistant") {
|
|
@@ -194,11 +197,11 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
|
|
194
197
|
});
|
|
195
198
|
} else if (msg.role === "toolResult") {
|
|
196
199
|
// Extract text and image content
|
|
200
|
+
const supportsImages = model.input.includes("image");
|
|
197
201
|
const textContent = msg.content.filter((c): c is TextContent => c.type === "text");
|
|
198
202
|
const textResult = textContent.map(c => c.text).join("\n");
|
|
199
|
-
const imageContent =
|
|
200
|
-
|
|
201
|
-
: [];
|
|
203
|
+
const imageContent = supportsImages ? msg.content.filter((c): c is ImageContent => c.type === "image") : [];
|
|
204
|
+
const omittedImages = !supportsImages && msg.content.some((c): c is ImageContent => c.type === "image");
|
|
202
205
|
|
|
203
206
|
const hasText = textResult.length > 0;
|
|
204
207
|
const hasImages = imageContent.length > 0;
|
|
@@ -209,7 +212,13 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
|
|
209
212
|
const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id);
|
|
210
213
|
|
|
211
214
|
// Use "output" key for success, "error" key for errors as per SDK documentation
|
|
212
|
-
const responseValue =
|
|
215
|
+
const responseValue = omittedImages
|
|
216
|
+
? [hasText ? textResult.toWellFormed() : "", NON_VISION_IMAGE_PLACEHOLDER].filter(Boolean).join("\n")
|
|
217
|
+
: hasText
|
|
218
|
+
? textResult.toWellFormed()
|
|
219
|
+
: hasImages
|
|
220
|
+
? "(see attached image)"
|
|
221
|
+
: "";
|
|
213
222
|
|
|
214
223
|
const imageParts: Part[] = imageContent.map(imageBlock => ({
|
|
215
224
|
inlineData: {
|
package/src/providers/kimi.ts
CHANGED
|
@@ -80,6 +80,8 @@ export function streamKimi(
|
|
|
80
80
|
headers: mergedHeaders,
|
|
81
81
|
sessionId: options?.sessionId,
|
|
82
82
|
onPayload: options?.onPayload,
|
|
83
|
+
onResponse: options?.onResponse,
|
|
84
|
+
onSseEvent: options?.onSseEvent,
|
|
83
85
|
thinkingEnabled,
|
|
84
86
|
thinkingBudgetTokens: thinkingBudget,
|
|
85
87
|
});
|
|
@@ -103,6 +105,8 @@ export function streamKimi(
|
|
|
103
105
|
headers: mergedHeaders,
|
|
104
106
|
sessionId: options?.sessionId,
|
|
105
107
|
onPayload: options?.onPayload,
|
|
108
|
+
onResponse: options?.onResponse,
|
|
109
|
+
onSseEvent: options?.onSseEvent,
|
|
106
110
|
reasoning: reasoningEffort,
|
|
107
111
|
});
|
|
108
112
|
|