@oh-my-pi/pi-ai 17.0.0 → 17.0.2

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/auth-broker/wire-schemas.d.ts +13 -0
  3. package/dist/types/auth-storage.d.ts +2 -0
  4. package/dist/types/error/auth-classify.d.ts +2 -0
  5. package/dist/types/error/rate-limit.d.ts +9 -5
  6. package/dist/types/providers/cursor.d.ts +2 -0
  7. package/dist/types/providers/openai-codex/request-transformer.d.ts +9 -3
  8. package/dist/types/providers/openai-shared.d.ts +12 -6
  9. package/dist/types/providers/transform-messages.d.ts +5 -9
  10. package/dist/types/types.d.ts +6 -1
  11. package/dist/types/utils/empty-completion-retry.d.ts +3 -3
  12. package/dist/types/utils/schema/normalize.d.ts +3 -1
  13. package/package.json +4 -4
  14. package/src/auth-broker/wire-schemas.ts +1 -0
  15. package/src/auth-retry.ts +2 -2
  16. package/src/auth-storage.ts +120 -79
  17. package/src/dialect/owned-stream.ts +11 -0
  18. package/src/dialect/thinking.ts +186 -16
  19. package/src/error/auth-classify.ts +13 -0
  20. package/src/error/flags.ts +2 -2
  21. package/src/error/rate-limit.ts +28 -9
  22. package/src/providers/__tests__/kimi-code-thinking.test.ts +62 -7
  23. package/src/providers/anthropic.ts +82 -25
  24. package/src/providers/cursor.ts +53 -30
  25. package/src/providers/gitlab-duo-workflow.ts +6 -2
  26. package/src/providers/kimi.ts +1 -4
  27. package/src/providers/openai-codex/request-transformer.ts +12 -3
  28. package/src/providers/openai-codex-responses.ts +22 -4
  29. package/src/providers/openai-completions.ts +28 -22
  30. package/src/providers/openai-responses.ts +5 -1
  31. package/src/providers/openai-shared.ts +66 -14
  32. package/src/providers/transform-messages.ts +171 -1
  33. package/src/stream.ts +2 -0
  34. package/src/types.ts +9 -1
  35. package/src/utils/empty-completion-retry.ts +6 -3
  36. package/src/utils/leaked-thinking-stream.ts +19 -1
  37. package/src/utils/proxy.ts +1 -1
  38. package/src/utils/schema/CONSTRAINTS.md +1 -0
  39. package/src/utils/schema/fields.ts +3 -0
  40. package/src/utils/schema/normalize.ts +94 -24
  41. package/src/utils.ts +4 -1
@@ -60,6 +60,7 @@ import {
60
60
  getOpenAIStreamIdleTimeoutMs,
61
61
  iterateWithIdleTimeout,
62
62
  } from "../utils/idle-iterator";
63
+ import { getProxyForProvider, shouldBypassProxy } from "../utils/proxy";
63
64
  import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
64
65
  import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
65
66
  import { notifyRawSseEvent } from "../utils/sse-debug";
@@ -111,7 +112,7 @@ import {
111
112
  promoteResponsesToolUseStopReason,
112
113
  type SequentialCutoffSummaryState,
113
114
  } from "./openai-shared";
114
- import { transformMessages } from "./transform-messages";
115
+ import { redactSensitiveInObject, transformMessages } from "./transform-messages";
115
116
 
116
117
  export interface OpenAICodexResponsesOptions extends StreamOptions {
117
118
  reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
@@ -1481,6 +1482,7 @@ async function openCodexWebSocketTransport(
1481
1482
  websocketState,
1482
1483
  toWebSocketUrl(requestContext.url),
1483
1484
  websocketHeaders,
1485
+ model.provider,
1484
1486
  requestSetup.requestSignal,
1485
1487
  );
1486
1488
  const eventStream = websocketConnection.streamRequest(
@@ -2614,6 +2616,7 @@ export async function prewarmOpenAICodexResponses(
2614
2616
  state,
2615
2617
  toWebSocketUrl(url),
2616
2618
  headers,
2619
+ model.provider,
2617
2620
  options?.signal,
2618
2621
  );
2619
2622
  state.prewarmed = true;
@@ -3076,11 +3079,13 @@ interface CodexWebSocketRequestTimeouts {
3076
3079
 
3077
3080
  interface CodexWebSocketConnectionOptions {
3078
3081
  onHandshakeHeaders?: (headers: Headers) => void;
3082
+ proxy?: string;
3079
3083
  }
3080
3084
 
3081
3085
  class CodexWebSocketConnection {
3082
3086
  #url: string;
3083
3087
  #headers: Record<string, string>;
3088
+ #proxy?: string;
3084
3089
  #onHandshakeHeaders?: (headers: Headers) => void;
3085
3090
  #socket: Bun.WebSocket | null = null;
3086
3091
  #queue: Array<Record<string, unknown> | Error | null> = [];
@@ -3111,6 +3116,7 @@ class CodexWebSocketConnection {
3111
3116
  constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
3112
3117
  this.#url = url;
3113
3118
  this.#headers = headers;
3119
+ this.#proxy = options.proxy;
3114
3120
  this.#onHandshakeHeaders = options.onHandshakeHeaders;
3115
3121
  }
3116
3122
 
@@ -3172,7 +3178,7 @@ class CodexWebSocketConnection {
3172
3178
  this.#connectPromise = promise;
3173
3179
  const socket = new (WebSocket as unknown as new (url: string, opts: Bun.WebSocketOptions) => Bun.WebSocket)(
3174
3180
  this.#url,
3175
- { headers: this.#headers },
3181
+ { headers: this.#headers, proxy: this.#proxy },
3176
3182
  );
3177
3183
  socket.binaryType = "nodebuffer";
3178
3184
  this.#socket = socket;
@@ -3663,8 +3669,18 @@ async function getOrCreateCodexWebSocketConnection(
3663
3669
  state: CodexWebSocketSessionState,
3664
3670
  url: string,
3665
3671
  headers: Headers,
3672
+ provider: string,
3666
3673
  signal?: AbortSignal,
3667
3674
  ): Promise<CodexWebSocketConnection> {
3675
+ const targetUrl = new URL(url);
3676
+ const proxy = shouldBypassProxy(targetUrl)
3677
+ ? undefined
3678
+ : (getProxyForProvider(provider) ??
3679
+ (targetUrl.protocol === "wss:"
3680
+ ? Bun.env.HTTPS_PROXY || Bun.env.https_proxy
3681
+ : Bun.env.HTTP_PROXY || Bun.env.http_proxy) ??
3682
+ Bun.env.ALL_PROXY ??
3683
+ Bun.env.all_proxy);
3668
3684
  const headerRecord = headersToRecord(headers);
3669
3685
  // Join an in-flight handshake instead of tearing it down: closing a
3670
3686
  // CONNECTING socket rejects the concurrent caller (prewarm racing the first
@@ -3707,6 +3723,7 @@ async function getOrCreateCodexWebSocketConnection(
3707
3723
  onHandshakeHeaders: handshakeHeaders => {
3708
3724
  updateCodexSessionMetadataFromHeaders(state, handshakeHeaders);
3709
3725
  },
3726
+ proxy,
3710
3727
  });
3711
3728
  await state.connection.connect(signal);
3712
3729
  return state.connection;
@@ -3937,13 +3954,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
3937
3954
  | Array<ResponseInput[number]>
3938
3955
  | undefined;
3939
3956
  if (historyItems) {
3940
- for (const item of historyItems) {
3957
+ const redactedHistoryItems = redactSensitiveInObject(historyItems).result as Array<ResponseInput[number]>;
3958
+ for (const item of redactedHistoryItems) {
3941
3959
  const maybe = item as { type?: string; call_id?: string };
3942
3960
  if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") {
3943
3961
  customCallIds.add(maybe.call_id);
3944
3962
  }
3945
3963
  }
3946
- messages.push(...historyItems);
3964
+ messages.push(...redactedHistoryItems);
3947
3965
  msgIndex += 1;
3948
3966
  continue;
3949
3967
  }
@@ -76,6 +76,7 @@ import {
76
76
  applyOpenAIExtraBody,
77
77
  applyOpenAIGatewayRouting,
78
78
  applyOpenAIServiceTier,
79
+ applyOpenRouterReportedCost,
79
80
  applyWireModelIdTransform,
80
81
  calculateOpenAIUsageAccounting,
81
82
  clearOpenAIStrictToolsState,
@@ -93,9 +94,9 @@ import {
93
94
  type OpenAIStrictToolsState,
94
95
  parseAzureDeploymentNameMap,
95
96
  resolveOpenAICompatPolicy,
97
+ resolveOpenAICompletionsOutputClamp,
96
98
  resolveOpenAIOutputTokenParam,
97
99
  resolveOpenAIRequestSetup,
98
- resolveZaiReasoningOutputClamp,
99
100
  shouldRetryWithoutStrictTools,
100
101
  } from "./openai-shared";
101
102
  import { transformMessages } from "./transform-messages";
@@ -1460,31 +1461,35 @@ function buildParams(
1460
1461
  params.store = false;
1461
1462
  }
1462
1463
 
1463
- if (options?.temperature !== undefined) {
1464
- params.temperature = options.temperature;
1465
- }
1466
- if (options?.topP !== undefined) {
1467
- params.top_p = options.topP;
1468
- }
1469
- if (options?.topK !== undefined) {
1470
- params.top_k = options.topK;
1471
- }
1472
- if (options?.minP !== undefined) {
1473
- params.min_p = options.minP;
1474
- }
1475
- if (options?.presencePenalty !== undefined) {
1476
- params.presence_penalty = options.presencePenalty;
1477
- }
1478
- if (options?.repetitionPenalty !== undefined) {
1479
- params.repetition_penalty = options.repetitionPenalty;
1464
+ // OpenAI proprietary reasoning models (o-series, gpt-5+) reject explicit
1465
+ // sampling params with a 400 on every serving host (#5606).
1466
+ if (initialCompat.supportsSamplingParams) {
1467
+ if (options?.temperature !== undefined) {
1468
+ params.temperature = options.temperature;
1469
+ }
1470
+ if (options?.topP !== undefined) {
1471
+ params.top_p = options.topP;
1472
+ }
1473
+ if (options?.topK !== undefined) {
1474
+ params.top_k = options.topK;
1475
+ }
1476
+ if (options?.minP !== undefined) {
1477
+ params.min_p = options.minP;
1478
+ }
1479
+ if (options?.presencePenalty !== undefined) {
1480
+ params.presence_penalty = options.presencePenalty;
1481
+ }
1482
+ if (options?.repetitionPenalty !== undefined) {
1483
+ params.repetition_penalty = options.repetitionPenalty;
1484
+ }
1485
+ if (options?.frequencyPenalty !== undefined) {
1486
+ params.frequency_penalty = options.frequencyPenalty;
1487
+ }
1480
1488
  }
1481
1489
  if (options?.stopSequences?.length) {
1482
1490
  const seqs = options.stopSequences;
1483
1491
  params.stop = seqs.length === 1 ? seqs[0] : seqs.slice(0, 4);
1484
1492
  }
1485
- if (options?.frequencyPenalty !== undefined) {
1486
- params.frequency_penalty = options.frequencyPenalty;
1487
- }
1488
1493
  applyOpenAIServiceTier(params, options?.serviceTier, model);
1489
1494
 
1490
1495
  if (context.tools?.length) {
@@ -1566,7 +1571,7 @@ function buildParams(
1566
1571
  omitMaxOutputTokens: model.omitMaxOutputTokens ?? false,
1567
1572
  isOpenRouterHost: compat.isOpenRouterHost,
1568
1573
  alwaysSendMaxTokens: compat.alwaysSendMaxTokens,
1569
- providerOutputClamp: resolveZaiReasoningOutputClamp(model, compat),
1574
+ providerOutputClamp: resolveOpenAICompletionsOutputClamp(model, compat),
1570
1575
  });
1571
1576
  if (outputToken) {
1572
1577
  if (outputToken.field === "max_tokens") {
@@ -1629,6 +1634,7 @@ export function parseChunkUsage(
1629
1634
  ...(premiumRequests !== undefined ? { premiumRequests } : {}),
1630
1635
  };
1631
1636
  calculateCost(model, usage);
1637
+ applyOpenRouterReportedCost(model, usage, rawUsage);
1632
1638
  return usage;
1633
1639
  }
1634
1640
 
@@ -594,7 +594,11 @@ const streamOpenAIResponsesOnce = (
594
594
  error instanceof Error &&
595
595
  /previous[ _]?response/i.test(error.message) &&
596
596
  /zero[ _-]?data[ _-]?retention/i.test(error.message);
597
- if (!zdrRejection && !isOpenAIResponsesStalePreviousResponseError(error)) {
597
+ const isPromptBlocked =
598
+ error instanceof Error &&
599
+ ((error as { code?: string }).code === "invalid_prompt" ||
600
+ /invalid_prompt|Request blocked/i.test(error.message));
601
+ if (!zdrRejection && !isPromptBlocked && !isOpenAIResponsesStalePreviousResponseError(error)) {
598
602
  throw error;
599
603
  }
600
604
  // Server rejected the chain baseline: reset, count the failure (or
@@ -1,6 +1,6 @@
1
1
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
2
2
  import { toFirepassWireModelId, toFireworksWireModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
3
- import { isGlm52ReasoningEffortModelId } from "@oh-my-pi/pi-catalog/identity";
3
+ import { isGlm52ReasoningEffortModelId, isKimiK3ModelId } from "@oh-my-pi/pi-catalog/identity";
4
4
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
5
5
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
6
6
  import type {
@@ -25,6 +25,7 @@ import {
25
25
  classifyJsonPrefix,
26
26
  extractHttpStatusFromError,
27
27
  logger,
28
+ parseImageMetadata,
28
29
  parseStreamingJson,
29
30
  parseStreamingJsonThrottled,
30
31
  stringifyJson,
@@ -338,6 +339,29 @@ export function applyOpenAIResponsesServiceTierCost(
338
339
  usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
339
340
  }
340
341
 
342
+ /** Reconcile token-price estimates with OpenRouter's authoritative account charge. */
343
+ export function applyOpenRouterReportedCost(model: Pick<Model, "provider">, usage: Usage, rawUsage: unknown): void {
344
+ if (model.provider !== "openrouter" || typeof rawUsage !== "object" || rawUsage === null) return;
345
+ const reportedCost = Reflect.get(rawUsage, "cost");
346
+ if (typeof reportedCost !== "number" || !Number.isFinite(reportedCost) || reportedCost < 0) return;
347
+
348
+ const estimatedCost = usage.cost.total;
349
+ if (Number.isFinite(estimatedCost) && estimatedCost > 0) {
350
+ const scale = reportedCost / estimatedCost;
351
+ usage.cost.input *= scale;
352
+ usage.cost.output *= scale;
353
+ usage.cost.cacheRead *= scale;
354
+ usage.cost.cacheWrite *= scale;
355
+ } else {
356
+ // Keep legacy component-only aggregators additive when catalog pricing is unavailable.
357
+ usage.cost.input = reportedCost;
358
+ usage.cost.output = 0;
359
+ usage.cost.cacheRead = 0;
360
+ usage.cost.cacheWrite = 0;
361
+ }
362
+ usage.cost.total = reportedCost;
363
+ }
364
+
341
365
  export interface OpenAIUsageAccountingInput {
342
366
  promptTokens: number;
343
367
  outputTokens: number;
@@ -1001,16 +1025,24 @@ function isZaiReasoningEffortDialect(model: Model<"openai-completions">, compat:
1001
1025
  }
1002
1026
 
1003
1027
  /**
1004
- * Output-token clamp for the Z.AI/GLM-5.2 reasoning dialect: these hosts accept
1005
- * the full model window on reasoning turns, so clamp to the model cap. Returns
1006
- * `undefined` for every other model, leaving {@link resolveOpenAIOutputTokenParam}
1007
- * on its default `OPENAI_MAX_OUTPUT_TOKENS` clamp.
1028
+ * Provider-specific Chat Completions output clamp.
1029
+ *
1030
+ * Most OpenAI-compatible endpoints retain the conservative 64k ceiling from
1031
+ * {@link resolveOpenAIOutputTokenParam}. Z.AI/GLM-5.2 reasoning and native
1032
+ * Moonshot K3 explicitly accept their full advertised model caps, so those
1033
+ * routes clamp to `model.maxTokens` instead.
1008
1034
  */
1009
- export function resolveZaiReasoningOutputClamp(
1035
+ export function resolveOpenAICompletionsOutputClamp(
1010
1036
  model: Model<"openai-completions">,
1011
1037
  compat: ResolvedOpenAICompat,
1012
1038
  ): number | undefined {
1013
- return isZaiReasoningEffortDialect(model, compat) ? (model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS) : undefined;
1039
+ if (isZaiReasoningEffortDialect(model, compat)) {
1040
+ return model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS;
1041
+ }
1042
+ if (model.provider === "moonshot" && isKimiK3ModelId(model.id)) {
1043
+ return model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS;
1044
+ }
1045
+ return undefined;
1014
1046
  }
1015
1047
 
1016
1048
  /**
@@ -2532,6 +2564,19 @@ export async function processResponsesStream<TApi extends Api>(
2532
2564
  }
2533
2565
  closeOpenItem(event.output_index, item.id, entry, item.call_id, prefixedFunctionCallItemKey(item.call_id));
2534
2566
  stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
2567
+ } else if (item.type === "image_generation_call" && item.status === "completed" && item.result) {
2568
+ const image: ImageContent = {
2569
+ type: "image",
2570
+ data: item.result,
2571
+ mimeType: parseImageMetadata(Buffer.from(item.result, "base64"))?.mimeType ?? "image/png",
2572
+ };
2573
+ output.content.push(image);
2574
+ stream.push({
2575
+ type: "image_end",
2576
+ contentIndex: output.content.length - 1,
2577
+ content: image,
2578
+ partial: output,
2579
+ });
2535
2580
  }
2536
2581
  } else if (terminalEvent) {
2537
2582
  const response = terminalEvent.response;
@@ -2541,6 +2586,7 @@ export async function processResponsesStream<TApi extends Api>(
2541
2586
  }
2542
2587
  populateResponsesUsageFromResponse(output, response?.usage);
2543
2588
  calculateCost(model, output.usage);
2589
+ applyOpenRouterReportedCost(model, output.usage, response?.usage);
2544
2590
  applyOpenAIResponsesServiceTierCost(
2545
2591
  model,
2546
2592
  output.usage,
@@ -2717,7 +2763,9 @@ type CommonSamplingOptions = Pick<
2717
2763
  export function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(
2718
2764
  params: P,
2719
2765
  options: CommonSamplingOptions | undefined,
2720
- model: Pick<Model, "provider" | "api" | "id" | "omitMaxOutputTokens" | "maxTokens">,
2766
+ model: Pick<Model, "provider" | "api" | "id" | "omitMaxOutputTokens" | "maxTokens"> & {
2767
+ compat: Pick<ResolvedOpenAISharedCompat, "supportsSamplingParams">;
2768
+ },
2721
2769
  ): void {
2722
2770
  if (options?.maxTokens && !model.omitMaxOutputTokens) {
2723
2771
  params.max_output_tokens = Math.min(
@@ -2726,12 +2774,16 @@ export function applyCommonResponsesSamplingParams<P extends CommonResponsesPara
2726
2774
  OPENAI_MAX_OUTPUT_TOKENS,
2727
2775
  );
2728
2776
  }
2729
- if (options?.temperature !== undefined) params.temperature = options.temperature;
2730
- if (options?.topP !== undefined) params.top_p = options.topP;
2731
- if (options?.topK !== undefined) params.top_k = options.topK;
2732
- if (options?.minP !== undefined) params.min_p = options.minP;
2733
- if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
2734
- if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
2777
+ // OpenAI proprietary reasoning models (o-series, gpt-5+) reject explicit
2778
+ // sampling params with a 400 on every serving host (#5606).
2779
+ if (model.compat.supportsSamplingParams) {
2780
+ if (options?.temperature !== undefined) params.temperature = options.temperature;
2781
+ if (options?.topP !== undefined) params.top_p = options.topP;
2782
+ if (options?.topK !== undefined) params.top_k = options.topK;
2783
+ if (options?.minP !== undefined) params.min_p = options.minP;
2784
+ if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
2785
+ if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
2786
+ }
2735
2787
  applyOpenAIServiceTier(params, options?.serviceTier, model);
2736
2788
  }
2737
2789
 
@@ -1,5 +1,14 @@
1
1
  import { renderDemotedThinking } from "../dialect/demotion";
2
- import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage, UserMessage } from "../types";
2
+ import type {
3
+ Api,
4
+ AssistantMessage,
5
+ DeveloperMessage,
6
+ Message,
7
+ Model,
8
+ ToolCall,
9
+ ToolResultMessage,
10
+ UserMessage,
11
+ } from "../types";
3
12
  import { isDemotedThinking, kDemotedThinking } from "../utils/block-symbols";
4
13
 
5
14
  const enum ToolCallStatus {
@@ -286,6 +295,156 @@ function normalizeAnthropicTargetToolCallId<TApi extends Api>(
286
295
  * - Preserves tool call structure (unlike converting to text summaries)
287
296
  * - Injects synthetic "aborted" tool results
288
297
  */
298
+ const SENSITIVE_TOKEN_RE =
299
+ /(?<![a-zA-Z0-9_*-])(gh[opusr]_[a-zA-Z0-9_*]{36,}|github_pat_[a-zA-Z0-9_*]{36,}|glpat-[a-zA-Z0-9_*-]{20,}|sk-proj-[a-zA-Z0-9_*-]{36,}|sk-ant-[a-zA-Z0-9_*-]{36,}|sk-[a-zA-Z0-9_*-]{48,})(?![a-zA-Z0-9_*-])/gi;
300
+
301
+ function hasPlausibleCredentialEntropy(token: string): boolean {
302
+ const lower = token.toLowerCase();
303
+ const prefixLength = lower.startsWith("github_pat_")
304
+ ? "github_pat_".length
305
+ : lower.startsWith("glpat-")
306
+ ? "glpat-".length
307
+ : lower.startsWith("sk-proj-")
308
+ ? "sk-proj-".length
309
+ : lower.startsWith("sk-ant-")
310
+ ? "sk-ant-".length
311
+ : lower.startsWith("gh")
312
+ ? 4
313
+ : 3;
314
+ const secret = token.slice(prefixLength);
315
+ if (/^\*+$/.test(secret)) return true;
316
+ return [/[a-z]/, /[A-Z]/, /\d/, /[_-]/].filter(pattern => pattern.test(secret)).length >= 2;
317
+ }
318
+
319
+ export function redactSensitiveCredentials(text: string): string {
320
+ return text.replace(SENSITIVE_TOKEN_RE, match => {
321
+ if (!hasPlausibleCredentialEntropy(match)) return match;
322
+ const lower = match.toLowerCase();
323
+ if (lower.startsWith("gh")) {
324
+ return "[github_token_redacted]";
325
+ }
326
+ if (lower.startsWith("gl")) {
327
+ return "[gitlab_token_redacted]";
328
+ }
329
+ if (lower.startsWith("sk-ant-")) {
330
+ return "[anthropic_token_redacted]";
331
+ }
332
+ if (lower.startsWith("sk")) {
333
+ return "[openai_token_redacted]";
334
+ }
335
+ return "[token_redacted]";
336
+ });
337
+ }
338
+
339
+ export function redactSensitiveInObject(val: unknown): { result: unknown; changed: boolean } {
340
+ if (typeof val === "string") {
341
+ const redacted = redactSensitiveCredentials(val);
342
+ return { result: redacted, changed: redacted !== val };
343
+ }
344
+ if (Array.isArray(val)) {
345
+ let changed = false;
346
+ const result = val.map(item => {
347
+ const res = redactSensitiveInObject(item);
348
+ if (res.changed) changed = true;
349
+ return res.result;
350
+ });
351
+ return { result, changed };
352
+ }
353
+ if (val !== null && typeof val === "object") {
354
+ let changed = false;
355
+ const res: Record<string, unknown> = {};
356
+ for (const [k, v] of Object.entries(val)) {
357
+ const sub = redactSensitiveInObject(v);
358
+ if (sub.changed) changed = true;
359
+ res[k] = sub.result;
360
+ }
361
+ return { result: res, changed };
362
+ }
363
+ return { result: val, changed: false };
364
+ }
365
+
366
+ function redactSensitiveCredentialsInMessages(messages: Message[]): Message[] {
367
+ return messages.map((msg): Message => {
368
+ if (msg.role === "user" || msg.role === "developer") {
369
+ const userMsg = msg as UserMessage | DeveloperMessage;
370
+ if (typeof userMsg.content === "string") {
371
+ const redacted = redactSensitiveCredentials(userMsg.content);
372
+ if (redacted === userMsg.content) return msg;
373
+ return { ...userMsg, content: redacted } as Message;
374
+ }
375
+ const contentArray = userMsg.content;
376
+ let changed = false;
377
+ const content = contentArray.map((block): UserMessage["content"][number] => {
378
+ if (block.type === "text") {
379
+ const redacted = redactSensitiveCredentials(block.text);
380
+ if (redacted !== block.text) {
381
+ changed = true;
382
+ return { ...block, text: redacted };
383
+ }
384
+ }
385
+ return block;
386
+ });
387
+ return (changed ? { ...userMsg, content } : userMsg) as Message;
388
+ }
389
+
390
+ if (msg.role === "toolResult") {
391
+ const toolResultMsg = msg as ToolResultMessage;
392
+ let changed = false;
393
+ const content = toolResultMsg.content.map((block): ToolResultMessage["content"][number] => {
394
+ if (block.type === "text") {
395
+ const redacted = redactSensitiveCredentials(block.text);
396
+ if (redacted !== block.text) {
397
+ changed = true;
398
+ return { ...block, text: redacted };
399
+ }
400
+ }
401
+ return block;
402
+ });
403
+ return (changed ? { ...toolResultMsg, content } : toolResultMsg) as Message;
404
+ }
405
+
406
+ if (msg.role === "assistant") {
407
+ const assistantMsg = msg as AssistantMessage;
408
+ let changed = false;
409
+ const content = assistantMsg.content.map((block): AssistantMessage["content"][number] => {
410
+ if (block.type === "text") {
411
+ const redacted = redactSensitiveCredentials(block.text);
412
+ if (redacted !== block.text) {
413
+ changed = true;
414
+ return { ...block, text: redacted };
415
+ }
416
+ } else if (block.type === "thinking") {
417
+ const redacted = redactSensitiveCredentials(block.thinking);
418
+ if (redacted !== block.thinking) {
419
+ changed = true;
420
+ return { ...block, thinking: redacted, thinkingSignature: undefined };
421
+ }
422
+ } else if (block.type === "toolCall") {
423
+ if (block.arguments) {
424
+ const { result: redactedArgs, changed: argsChanged } = redactSensitiveInObject(block.arguments);
425
+ if (argsChanged) {
426
+ changed = true;
427
+ const castArgs =
428
+ redactedArgs && typeof redactedArgs === "object" && !Array.isArray(redactedArgs)
429
+ ? (redactedArgs as Record<string, unknown>)
430
+ : undefined;
431
+ return {
432
+ ...block,
433
+ arguments: castArgs,
434
+ thoughtSignature: undefined,
435
+ } as AssistantMessage["content"][number];
436
+ }
437
+ }
438
+ }
439
+ return block;
440
+ });
441
+ return (changed ? { ...assistantMsg, content } : assistantMsg) as Message;
442
+ }
443
+
444
+ return msg;
445
+ });
446
+ }
447
+
289
448
  export function transformMessages<TApi extends Api>(
290
449
  messages: Message[],
291
450
  model: Model<TApi>,
@@ -294,6 +453,10 @@ export function transformMessages<TApi extends Api>(
294
453
  duplicateToolCallIdSuffixPrefix = "_dup",
295
454
  targetCompat: Model<TApi>["compat"] = model.compat,
296
455
  ): Message[] {
456
+ // Redact sensitive credential-like patterns from all outbound messages
457
+ // to prevent security block errors from LLM providers (e.g. invalid_prompt).
458
+ messages = redactSensitiveCredentialsInMessages(messages);
459
+
297
460
  // Drop assistant `toolCall` blocks with empty/whitespace `id` or `name`
298
461
  // (and their matched `toolResult` messages) before anything else looks at
299
462
  // the history. Replays of these would 400 every provider — see
@@ -554,6 +717,13 @@ export function transformMessages<TApi extends Api>(
554
717
  return [];
555
718
  }
556
719
 
720
+ if (block.type === "image") {
721
+ // Assistant images are display artifacts. No provider accepts them
722
+ // in an assistant replay turn; the native Responses result remains
723
+ // in providerPayload for OpenAI replay.
724
+ return [];
725
+ }
726
+
557
727
  if (block.type === "text") {
558
728
  if (isSameModel) return block;
559
729
  return {
package/src/stream.ts CHANGED
@@ -20,6 +20,7 @@ import { getCustomApi } from "./api-registry";
20
20
  import { createAuthRetryKeyState, isApiKeyResolver, resolveNextAuthRetryKey } from "./auth-retry";
21
21
  import * as AIError from "./error";
22
22
  import { ProviderHttpError } from "./error";
23
+ import { isInvalidatedOAuthTokenError } from "./error/auth-classify";
23
24
  import { isUsageLimitOutcome } from "./error/rate-limit";
24
25
  import type { BedrockOptions } from "./providers/amazon-bedrock";
25
26
  import type { AnthropicOptions } from "./providers/anthropic";
@@ -979,6 +980,7 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me
979
980
  // `parseRateLimitReason` and stay in the provider's own backoff layer
980
981
  // instead of burning siblings.
981
982
  if (AIError.isUsageLimit(error)) return true;
983
+ if (isInvalidatedOAuthTokenError(error)) return true;
982
984
  if (status === 401) return true;
983
985
  return isUsageLimitOutcome(status, message);
984
986
  }
package/src/types.ts CHANGED
@@ -706,7 +706,14 @@ export interface ContextSnapshot {
706
706
 
707
707
  export interface AssistantMessage {
708
708
  role: "assistant";
709
- content: (TextContent | ThinkingContent | RedactedThinkingContent | AnthropicFallbackContent | ToolCall)[];
709
+ content: (
710
+ | TextContent
711
+ | ThinkingContent
712
+ | RedactedThinkingContent
713
+ | AnthropicFallbackContent
714
+ | ImageContent
715
+ | ToolCall
716
+ )[];
710
717
  api: Api;
711
718
  provider: Provider;
712
719
  model: string;
@@ -893,6 +900,7 @@ export type AssistantMessageEvent =
893
900
  | { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
894
901
  | { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
895
902
  | { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
903
+ | { type: "image_end"; contentIndex: number; content: ImageContent; partial: AssistantMessage }
896
904
  | { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
897
905
  | { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
898
906
  | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
@@ -27,12 +27,13 @@ export const EMPTY_COMPLETION_BASE_DELAY_MS = 500;
27
27
  const NON_WHITESPACE_RE = /\S/;
28
28
 
29
29
  /**
30
- * Whether a completed assistant message carries content worth delivering: a tool
31
- * call or any non-whitespace text. An empty/whitespace-only message — or one
32
- * that only ever produced thinking — is the "empty response" failure.
30
+ * Whether a completed assistant message carries content worth delivering: an
31
+ * image, tool call, or any non-whitespace text. An empty/whitespace-only message
32
+ * — or one that only ever produced thinking — is the "empty response" failure.
33
33
  */
34
34
  export function hasVisibleAssistantContent(message: AssistantMessage): boolean {
35
35
  for (const block of message.content) {
36
+ if (block.type === "image") return true;
36
37
  if (block.type === "toolCall") return true;
37
38
  if (block.type === "text" && NON_WHITESPACE_RE.test(block.text)) return true;
38
39
  }
@@ -49,6 +50,8 @@ function isMeaningfulCompletionEvent(event: AssistantMessageEvent): boolean {
49
50
  case "text_end":
50
51
  case "thinking_end":
51
52
  return event.content.length > 0;
53
+ case "image_end":
54
+ return true;
52
55
  case "toolcall_start":
53
56
  case "toolcall_end":
54
57
  return true;
@@ -25,7 +25,7 @@
25
25
  * events are forwarded verbatim.
26
26
  */
27
27
 
28
- import type { AssistantMessage, TextContent, ThinkingContent, ToolCall } from "../types";
28
+ import type { AssistantMessage, ImageContent, TextContent, ThinkingContent, ToolCall } from "../types";
29
29
  import {
30
30
  clearStreamingPartialJson,
31
31
  getStreamingPartialJson,
@@ -78,6 +78,10 @@ export function wrapLeakedThinkingStream(inner: AssistantMessageEventStream): As
78
78
  projector.thinking(event.delta, block?.type === "thinking" ? block.thinkingSignature : undefined);
79
79
  break;
80
80
  }
81
+ case "image_end":
82
+ projector ??= new LeakedThinkingProjector(out, event.partial);
83
+ projector.image(event.content);
84
+ break;
81
85
  case "toolcall_start": {
82
86
  projector ??= new LeakedThinkingProjector(out, event.partial);
83
87
  const block = event.partial.content[event.contentIndex];
@@ -163,6 +167,20 @@ class LeakedThinkingProjector {
163
167
  this.#out.push({ type: "thinking_delta", contentIndex: index, delta, partial: this.#partial });
164
168
  }
165
169
 
170
+ /** Forward a completed native image after releasing held text. */
171
+ image(content: ImageContent): void {
172
+ this.#apply(this.#healer.flushEvents(), this.#lastTextSignature);
173
+ this.#closeText();
174
+ this.#closeThinking();
175
+ this.#partial.content.push(content);
176
+ this.#out.push({
177
+ type: "image_end",
178
+ contentIndex: this.#partial.content.length - 1,
179
+ content,
180
+ partial: this.#partial,
181
+ });
182
+ }
183
+
166
184
  /** Forward a native tool call's start, releasing any held-back text first. */
167
185
  toolStart(srcIndex: number, source: StreamingToolCall | undefined): void {
168
186
  if (!source) return;
@@ -61,7 +61,7 @@ export function shouldBypassProxy(urlObj: URL): boolean {
61
61
  .map(r => r.trim())
62
62
  .filter(Boolean);
63
63
  const targetHost = urlObj.hostname.toLowerCase();
64
- const targetPort = urlObj.port || (urlObj.protocol === "https:" ? "443" : "80");
64
+ const targetPort = urlObj.port || (urlObj.protocol === "https:" || urlObj.protocol === "wss:" ? "443" : "80");
65
65
 
66
66
  for (const rule of rules) {
67
67
  if (rule === "*") {
@@ -69,6 +69,7 @@ Schemas sent on the Google JSON Schema path MUST follow:
69
69
  - `minItems`, `maxItems`, `minLength`, `maxLength`
70
70
  - `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`
71
71
  - `pattern`, `format`
72
+ - `dependencies`, `dependentSchemas`, `dependentRequired`
72
73
  - Important: keys inside a `properties` object are treated as property names and MUST NOT be stripped by keyword match.
73
74
  - Human-meaningful stripped keys (`pattern`, `format`, min/max constraints, `default`, `examples`, etc.) are appended to the sibling `description` as an Anthropic-style spill block: `{pattern: "^foo$", minimum: 0}`. Structural/meta keys such as `$ref`, `$defs`, and `additionalProperties` are not spilled.
74
75