@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 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.8.1",
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.8.1",
50
- "@oh-my-pi/pi-utils": "14.8.1",
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",
@@ -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 { createWatchdog, getStreamFirstEventTimeoutMs } from "../utils/idle-iterator";
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(content: (TextContent | ImageContent)[]):
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
- // If only text blocks, return as concatenated string for simplicity
414
- const hasImages = content.some(c => c.type === "image");
415
- if (!hasImages) {
416
- return content
417
- .map(c => (c as TextContent).text)
418
- .join("\n")
419
- .toWellFormed();
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
- // If only images (no text), add placeholder text block
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 firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs();
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(anthropicRequest, requestSignal);
987
- await notifyProviderResponse(options, response, model, requestId);
988
- const firstEventWatchdog = createWatchdog(firstEventTimeoutMs, () =>
989
- activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
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
- if (!sawEvent) {
997
- clearTimeout(firstEventWatchdog);
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 blocks: ContentBlockParam[] = msg.content.map(item => {
1887
- if (item.type === "text") {
1888
- return {
1889
- type: "text",
1890
- text: item.text.toWellFormed(),
1891
- };
1892
- }
1893
- return {
1894
- type: "image",
1895
- source: {
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: filteredBlocks,
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;