@namzu/sdk 52.0.0 → 52.1.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/compaction/verifier.d.ts.map +1 -1
  3. package/dist/compaction/verifier.js +57 -17
  4. package/dist/compaction/verifier.js.map +1 -1
  5. package/dist/contracts/a2a.d.ts +2 -2
  6. package/dist/provider/capabilities.d.ts +9 -0
  7. package/dist/provider/capabilities.d.ts.map +1 -1
  8. package/dist/provider/capabilities.js +15 -0
  9. package/dist/provider/capabilities.js.map +1 -1
  10. package/dist/provider/errors.d.ts.map +1 -1
  11. package/dist/provider/errors.js +13 -0
  12. package/dist/provider/errors.js.map +1 -1
  13. package/dist/provider/fallback.d.ts.map +1 -1
  14. package/dist/provider/fallback.js +10 -0
  15. package/dist/provider/fallback.js.map +1 -1
  16. package/dist/provider/idle-timeout.d.ts.map +1 -1
  17. package/dist/provider/idle-timeout.js +5 -0
  18. package/dist/provider/idle-timeout.js.map +1 -1
  19. package/dist/provider/image-input-refusal.d.ts +9 -0
  20. package/dist/provider/image-input-refusal.d.ts.map +1 -0
  21. package/dist/provider/image-input-refusal.js +54 -0
  22. package/dist/provider/image-input-refusal.js.map +1 -0
  23. package/dist/provider/index.d.ts +2 -1
  24. package/dist/provider/index.d.ts.map +1 -1
  25. package/dist/provider/index.js +2 -1
  26. package/dist/provider/index.js.map +1 -1
  27. package/dist/provider/retry.d.ts +7 -2
  28. package/dist/provider/retry.d.ts.map +1 -1
  29. package/dist/provider/retry.js +38 -4
  30. package/dist/provider/retry.js.map +1 -1
  31. package/dist/public-runtime.d.ts +1 -1
  32. package/dist/public-runtime.d.ts.map +1 -1
  33. package/dist/public-runtime.js +1 -1
  34. package/dist/public-runtime.js.map +1 -1
  35. package/dist/runtime/query/deferred-sandbox.d.ts +54 -0
  36. package/dist/runtime/query/deferred-sandbox.d.ts.map +1 -0
  37. package/dist/runtime/query/deferred-sandbox.js +75 -0
  38. package/dist/runtime/query/deferred-sandbox.js.map +1 -0
  39. package/dist/runtime/query/executor.d.ts +10 -0
  40. package/dist/runtime/query/executor.d.ts.map +1 -1
  41. package/dist/runtime/query/executor.js +24 -3
  42. package/dist/runtime/query/executor.js.map +1 -1
  43. package/dist/runtime/query/index.d.ts.map +1 -1
  44. package/dist/runtime/query/index.js +93 -25
  45. package/dist/runtime/query/index.js.map +1 -1
  46. package/dist/runtime/query/iteration/index.d.ts +26 -0
  47. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  48. package/dist/runtime/query/iteration/index.js +164 -21
  49. package/dist/runtime/query/iteration/index.js.map +1 -1
  50. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts +10 -7
  51. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts.map +1 -1
  52. package/dist/runtime/query/iteration/phases/compaction-breaker.js +10 -7
  53. package/dist/runtime/query/iteration/phases/compaction-breaker.js.map +1 -1
  54. package/dist/runtime/query/iteration/phases/compaction.d.ts +42 -1
  55. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  56. package/dist/runtime/query/iteration/phases/compaction.js +119 -21
  57. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  58. package/dist/runtime/query/iteration/provider-rejected-image.d.ts +21 -1
  59. package/dist/runtime/query/iteration/provider-rejected-image.d.ts.map +1 -1
  60. package/dist/runtime/query/iteration/provider-rejected-image.js +53 -7
  61. package/dist/runtime/query/iteration/provider-rejected-image.js.map +1 -1
  62. package/dist/runtime/query/iteration/request-repair.d.ts +108 -0
  63. package/dist/runtime/query/iteration/request-repair.d.ts.map +1 -0
  64. package/dist/runtime/query/iteration/request-repair.js +593 -0
  65. package/dist/runtime/query/iteration/request-repair.js.map +1 -0
  66. package/dist/runtime/query/iteration/stream-turn.d.ts +6 -0
  67. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  68. package/dist/runtime/query/iteration/stream-turn.js +2 -2
  69. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  70. package/dist/runtime/query/request-rich-content.d.ts +5 -1
  71. package/dist/runtime/query/request-rich-content.d.ts.map +1 -1
  72. package/dist/runtime/query/request-rich-content.js +67 -25
  73. package/dist/runtime/query/request-rich-content.js.map +1 -1
  74. package/dist/tools/builtins/computer-use.d.ts +1 -1
  75. package/dist/tools/builtins/computer-use.d.ts.map +1 -1
  76. package/dist/tools/builtins/computer-use.js +12 -5
  77. package/dist/tools/builtins/computer-use.js.map +1 -1
  78. package/dist/types/provider/errors.d.ts.map +1 -1
  79. package/dist/types/provider/errors.js +30 -5
  80. package/dist/types/provider/errors.js.map +1 -1
  81. package/dist/types/provider/interface.d.ts +12 -0
  82. package/dist/types/provider/interface.d.ts.map +1 -1
  83. package/dist/types/provider/stream.d.ts +1 -1
  84. package/dist/types/session/config.d.ts +14 -0
  85. package/dist/types/session/config.d.ts.map +1 -1
  86. package/dist/types/session/events.d.ts +20 -5
  87. package/dist/types/session/events.d.ts.map +1 -1
  88. package/dist/types/session/events.js.map +1 -1
  89. package/package.json +1 -1
  90. package/src/compaction/verifier.ts +57 -20
  91. package/src/provider/capabilities.ts +17 -0
  92. package/src/provider/errors.ts +13 -0
  93. package/src/provider/fallback.ts +10 -0
  94. package/src/provider/idle-timeout.ts +5 -0
  95. package/src/provider/image-input-refusal.ts +54 -0
  96. package/src/provider/index.ts +2 -0
  97. package/src/provider/retry.ts +42 -4
  98. package/src/public-runtime.ts +3 -0
  99. package/src/runtime/query/deferred-sandbox.ts +110 -0
  100. package/src/runtime/query/executor.ts +25 -3
  101. package/src/runtime/query/index.ts +124 -49
  102. package/src/runtime/query/iteration/index.ts +188 -23
  103. package/src/runtime/query/iteration/phases/compaction-breaker.ts +10 -8
  104. package/src/runtime/query/iteration/phases/compaction.ts +151 -35
  105. package/src/runtime/query/iteration/provider-rejected-image.ts +71 -5
  106. package/src/runtime/query/iteration/request-repair.ts +743 -0
  107. package/src/runtime/query/iteration/stream-turn.ts +11 -1
  108. package/src/runtime/query/request-rich-content.ts +77 -27
  109. package/src/tools/builtins/computer-use.ts +13 -4
  110. package/src/types/provider/errors.ts +33 -5
  111. package/src/types/provider/interface.ts +13 -0
  112. package/src/types/provider/stream.ts +1 -1
  113. package/src/types/session/config.ts +14 -0
  114. package/src/types/session/events.ts +25 -5
@@ -16,17 +16,14 @@ import { serializeState } from '../../../../compaction/serializer.js'
16
16
  import { buildCompactionMessage, isCompactionMessage } from '../../../../compaction/summary.js'
17
17
  import { estimateMessagesTokens } from '../../../../compaction/token-estimate.js'
18
18
  import { buildVerifiedSummaryWithBoundedProvider } from '../../../../compaction/verifier.js'
19
+ import type { CompactionConfig } from '../../../../config/runtime.js'
19
20
  import { CHARS_PER_TOKEN } from '../../../../constants/limits.js'
20
21
  import { NAMZU } from '../../../../constants/telemetry/index.js'
21
22
  import { invariants } from '../../../../invariants/index.js'
22
23
  import { resolveTaskModel } from '../../../../model-router/task-router.js'
23
24
  import type { Message } from '../../../../types/message/index.js'
24
25
  import { applyLifecycleHookResults } from '../../plugin-hooks.js'
25
- import {
26
- COMPACTION_FAILURE_MESSAGE,
27
- COMPACTION_REFILL_MESSAGE,
28
- compactionBreaker,
29
- } from './compaction-breaker.js'
26
+ import { COMPACTION_REFILL_MESSAGE, compactionBreaker } from './compaction-breaker.js'
30
27
  import type { IterationContext } from './context.js'
31
28
  import { isWorkingMemoryMessage } from './working-memory.js'
32
29
 
@@ -223,6 +220,73 @@ export function measureContext(ctx: IterationContext): {
223
220
  return { tokens: estimateTokens(ctx), source: 'estimate' }
224
221
  }
225
222
 
223
+ /**
224
+ * Reactive compactions allowed for one overflow, as in the reference
225
+ * terminal: the first relief, then at most one deeper pass.
226
+ */
227
+ export const OVERFLOW_RELIEF_LIMIT = 2
228
+
229
+ /** Round-groups that must remain for the deeper pass to be worth running. */
230
+ export const DEEPER_PASS_MIN_GROUPS = 3
231
+
232
+ export interface OverflowReliefOptions {
233
+ /**
234
+ * The second rung. Keep only the newest round-group verbatim instead of the
235
+ * configured recent window (when at least {@link DEEPER_PASS_MIN_GROUPS}
236
+ * groups remain), and clear oversized tool results even inside the recent
237
+ * window, with the same placeholder and spill pointer a stale result gets.
238
+ */
239
+ readonly deeper?: boolean
240
+ }
241
+
242
+ /**
243
+ * A round-group starts at each assistant message and runs to the next one, so
244
+ * its tool results stay with the call that made them. The newest group is the
245
+ * last assistant message and everything after it; the opening user messages
246
+ * ride with the first group.
247
+ */
248
+ function roundGroups(messages: readonly Message[]): number {
249
+ let groups = 0
250
+ for (const message of messages) if (message.role === 'assistant') groups++
251
+ return groups
252
+ }
253
+
254
+ /**
255
+ * The deeper rung's configuration: no tool result protected, and a recent
256
+ * window no larger than the first rung's. With three or more groups the
257
+ * window shrinks to the newest group, but never grows past what the
258
+ * configuration already kept: a newest group bigger than that would make the
259
+ * "deeper" pass shed less than the first one.
260
+ */
261
+ export function deepenConfig(
262
+ config: CompactionConfig,
263
+ messages: readonly Message[],
264
+ ): CompactionConfig {
265
+ let lastAssistant = -1
266
+ for (let i = messages.length - 1; i >= 0; i--) {
267
+ if (messages[i]?.role === 'assistant') {
268
+ lastAssistant = i
269
+ break
270
+ }
271
+ }
272
+ const deeper = { ...config, keepRecentToolResults: 0 }
273
+ if (lastAssistant < 0 || roundGroups(messages) < DEEPER_PASS_MIN_GROUPS) return deeper
274
+ const newest = messages.slice(lastAssistant)
275
+ if (config.keepRecentTokens !== undefined) {
276
+ return {
277
+ ...deeper,
278
+ keepRecentTokens: Math.max(
279
+ 1,
280
+ Math.min(config.keepRecentTokens, estimateMessagesTokens(newest)),
281
+ ),
282
+ }
283
+ }
284
+ return {
285
+ ...deeper,
286
+ keepRecentMessages: Math.max(1, Math.min(config.keepRecentMessages, newest.length)),
287
+ }
288
+ }
289
+
226
290
  /**
227
291
  * Shed history because the PROVIDER said the prompt is too long.
228
292
  *
@@ -241,12 +305,18 @@ export function measureContext(ctx: IterationContext): {
241
305
  * @returns whether anything was actually shed. `false` means retrying would
242
306
  * send the same prompt again, so the caller must not.
243
307
  */
244
- export async function relieveOverflow(ctx: IterationContext): Promise<boolean> {
308
+ export async function relieveOverflow(
309
+ ctx: IterationContext,
310
+ options: OverflowReliefOptions = {},
311
+ ): Promise<boolean> {
312
+ // The cause belongs to the rung that is about to run: an earlier rung's
313
+ // summary failure must not be blamed for a later rung that compacted cleanly.
314
+ clearCompactionErrorNote(ctx)
245
315
  const before = ctx.recorder.messages.length
246
316
  const beforeChars = totalChars(ctx.recorder.messages)
247
317
  const beforeTokens = estimateMessagesTokens(ctx.recorder.messages)
248
318
 
249
- await runCompactionCheck(ctx, { force: true })
319
+ await runCompactionCheck(ctx, { force: true, ...(options.deeper ? { deeper: true } : {}) })
250
320
 
251
321
  const shed = beforeChars - totalChars(ctx.recorder.messages)
252
322
  const tokensShed = beforeTokens - estimateMessagesTokens(ctx.recorder.messages)
@@ -278,6 +348,31 @@ export async function relieveOverflow(ctx: IterationContext): Promise<boolean> {
278
348
  return true
279
349
  }
280
350
 
351
+ /**
352
+ * Why the last compaction attempt errored, kept for the turn so that when
353
+ * relief runs out the failure it ends on can say so, as the reference
354
+ * terminal's " · automatic compaction failed: <cause>" does.
355
+ */
356
+ const compactionErrors = new WeakMap<object, string>()
357
+ const MAX_COMPACTION_CAUSE_CHARS = 300
358
+ /** The sentence the cause is appended with. */
359
+ export const AUTOMATIC_COMPACTION_FAILED = ' · automatic compaction failed: '
360
+
361
+ function noteCompactionError(ctx: IterationContext, error: unknown): void {
362
+ const text = error instanceof Error ? error.message : String(error)
363
+ if (text.length > 0) compactionErrors.set(ctx.recorder, text.slice(0, MAX_COMPACTION_CAUSE_CHARS))
364
+ }
365
+
366
+ /** The turn's last compaction error, or `undefined` when none errored. */
367
+ export function compactionErrorNote(ctx: IterationContext): string | undefined {
368
+ return compactionErrors.get(ctx.recorder)
369
+ }
370
+
371
+ /** A turn that got through has nothing left to explain. */
372
+ export function clearCompactionErrorNote(ctx: IterationContext): void {
373
+ compactionErrors.delete(ctx.recorder)
374
+ }
375
+
281
376
  function totalChars(messages: readonly { content: unknown }[]): number {
282
377
  let total = 0
283
378
  for (const msg of messages) total += measureContentChars(msg.content)
@@ -335,8 +430,11 @@ async function declined(
335
430
  /**
336
431
  * The third refill in a row: stop the turn instead of compacting again.
337
432
  * Returns true when this pass must not run. Only with `circuitBreaker`.
433
+ * The loop also asks it before the first reactive relief of an overflow, as
434
+ * the reference terminal does (`rhr` runs first in its withheld-overflow
435
+ * handling), so a re-read loop made only of overflows is counted too.
338
436
  */
339
- async function stopOnRefill(ctx: IterationContext): Promise<boolean> {
437
+ export async function stopOnRefill(ctx: IterationContext): Promise<boolean> {
340
438
  const breaker = compactionBreaker(ctx)
341
439
  if (!breaker) return false
342
440
  // Already announced: the turn is on its way out; do not say it twice.
@@ -418,6 +516,7 @@ async function applyReducer(
418
516
  'namzu.runtime.reason': reduction.reason,
419
517
  'exception.message': message,
420
518
  })
519
+ noteCompactionError(ctx, error)
421
520
  await declined(ctx, 'reducer_threw', before, message)
422
521
  return
423
522
  }
@@ -549,7 +648,7 @@ interface CompactionPass {
549
648
 
550
649
  export async function runCompactionCheck(
551
650
  ctx: IterationContext,
552
- options?: { force?: boolean },
651
+ options?: { force?: boolean; deeper?: boolean },
553
652
  ): Promise<void> {
554
653
  const pass: CompactionPass = {
555
654
  attempted: false,
@@ -585,11 +684,12 @@ export async function runCompactionCheck(
585
684
  async function runCompactionCheckInner(
586
685
  ctx: IterationContext,
587
686
  pass: CompactionPass,
588
- options?: { force?: boolean },
687
+ options?: { force?: boolean; deeper?: boolean },
589
688
  ): Promise<void> {
590
- const config = ctx.compactionConfig
591
- if (!config) return
592
- if (config.strategy === 'disabled') return
689
+ const configured = ctx.compactionConfig
690
+ if (!configured) return
691
+ if (configured.strategy === 'disabled') return
692
+ const config = options?.deeper ? deepenConfig(configured, ctx.recorder.messages) : configured
593
693
 
594
694
  const measured = measureContext(ctx)
595
695
  const estimatedTokens = measured.tokens
@@ -616,11 +716,14 @@ async function runCompactionCheckInner(
616
716
  const startAt = salience ? Math.min(config.softTarget ?? DEFAULT_SOFT_TARGET, trigger) : trigger
617
717
  if (!options?.force && usage < startAt) return
618
718
 
619
- // Three failed passes in a row: stop trying for the rest of the turn, and
620
- // say so once. A pass the provider forced is never gated.
719
+ // Three failed passes in a row: stop proactive compaction for the rest of
720
+ // the turn. A pass the provider forced is never gated.
621
721
  const breaker = compactionBreaker(ctx)
622
722
  if (breaker?.failuresOpen && !options?.force) {
623
723
  if (breaker.takeFailureNotice()) {
724
+ // Silent for the person, as in the reference terminal: a log line and the
725
+ // event for a host that wants it, but no sentence to show. Only the
726
+ // proactive pass stops; a real overflow still runs the reactive ladder.
624
727
  ctx.log.warn('Compaction failed three times in a row — switched off for this turn', {
625
728
  [NAMZU.TURN_ID]: ctx.recorder.turnId,
626
729
  })
@@ -630,7 +733,6 @@ async function runCompactionCheckInner(
630
733
  iteration: ctx.recorder.currentIteration,
631
734
  cause: 'breaker_open',
632
735
  messages: ctx.recorder.messages.length,
633
- error: COMPACTION_FAILURE_MESSAGE,
634
736
  })
635
737
  }
636
738
  return
@@ -851,25 +953,39 @@ async function runCompactionCheckInner(
851
953
  // to a cheap model while the bill is written against the expensive one
852
954
  // is a mistake with no symptom.
853
955
  const compactionModel = resolveTaskModel('compaction', ctx.taskRouter, ctx.turnConfig.model)
854
- compactedContent = await buildVerifiedSummaryWithBoundedProvider(
855
- manager,
856
- olderMessages as Message[],
857
- ctx.provider,
858
- config,
859
- (usage) =>
860
- ctx.recorder.accumulateUsage(usage, {
861
- providerId: ctx.recorder.servingProviderId,
862
- model: compactionModel,
863
- }),
864
- // The one model call a turn makes that the user never asked for. It
865
- // reads a transcript and writes a summary, which is the cheapest
866
- // thing a small model does well, and it fires on exactly the long
867
- // runs where the primary model is most expensive. `taskRouter` had
868
- // been accepted, validated and threaded through four types since it
869
- // was added, and nothing ever consulted it.
870
- compactionModel,
871
- ctx.abortController.signal,
872
- )
956
+ try {
957
+ compactedContent = await buildVerifiedSummaryWithBoundedProvider(
958
+ manager,
959
+ olderMessages as Message[],
960
+ ctx.provider,
961
+ config,
962
+ (usage) =>
963
+ ctx.recorder.accumulateUsage(usage, {
964
+ providerId: ctx.recorder.servingProviderId,
965
+ model: compactionModel,
966
+ }),
967
+ // The one model call a turn makes that the user never asked for. It
968
+ // reads a transcript and writes a summary, which is the cheapest
969
+ // thing a small model does well, and it fires on exactly the long
970
+ // runs where the primary model is most expensive. `taskRouter` had
971
+ // been accepted, validated and threaded through four types since it
972
+ // was added, and nothing ever consulted it.
973
+ compactionModel,
974
+ ctx.abortController.signal,
975
+ )
976
+ } catch (error) {
977
+ // The model step only ADDS to the extracted state, so a failed one (a
978
+ // timeout, a provider error, its own overflow after the ladder in the
979
+ // verifier ran out) is not a reason to leave the history as it is. Cancellation
980
+ // is the caller's, and still stops the pass.
981
+ if (ctx.abortController.signal.aborted) throw error
982
+ noteCompactionError(ctx, error)
983
+ ctx.log.warn('The compaction summary call failed — using the extracted state alone', {
984
+ [NAMZU.TURN_ID]: ctx.recorder.turnId,
985
+ 'exception.message': error instanceof Error ? error.message : String(error),
986
+ })
987
+ compactedContent = serializeState(manager.getState())
988
+ }
873
989
  } else {
874
990
  compactedContent = serializeState(manager.getState())
875
991
  }
@@ -1,4 +1,5 @@
1
1
  import { isProviderRequestError } from '../../../provider/errors.js'
2
+ import { isImageInputRefusal } from '../../../provider/image-input-refusal.js'
2
3
  import type { Message } from '../../../types/message/index.js'
3
4
  import type {
4
5
  ChatCompletionParams,
@@ -8,8 +9,11 @@ import type {
8
9
  import {
9
10
  type RequestImageIdentity,
10
11
  findSingleRequestImage,
12
+ projectRequestRichContent,
11
13
  projectRequestWithoutImage,
14
+ requestHasImages,
12
15
  } from '../request-rich-content.js'
16
+ import type { RequestRepair } from './request-repair.js'
13
17
 
14
18
  const IMAGE_REJECTION_PATTERNS: readonly RegExp[] = [
15
19
  /\binvalid[_ -]?image\b/i,
@@ -52,6 +56,24 @@ function throwIfAborted(signal: AbortSignal | undefined): void {
52
56
  if (signal?.aborted) throw signal.reason
53
57
  }
54
58
 
59
+ /** The provider and model that refused a request for holding an image. */
60
+ export interface TextOnlyModelRefusal {
61
+ readonly providerId: string
62
+ readonly model: string | undefined
63
+ }
64
+
65
+ /** See {@link streamWithProviderRejectedImageRecovery}. */
66
+ export interface TextOnlyModelRecovery {
67
+ /**
68
+ * Who is serving the request. A fallback chain reports its head's id and
69
+ * moves between members, so without this the refusal would be filed against
70
+ * a model that never saw the request. Read when the refusal arrives.
71
+ */
72
+ readonly servedBy?: () => { readonly providerId: string; readonly model?: string }
73
+ /** The retry without images was accepted: `refused` cannot read images. */
74
+ readonly onAccepted: (refused: TextOnlyModelRefusal) => void | Promise<void>
75
+ }
76
+
55
77
  /**
56
78
  * Retry one provider-invalid request without its only distinct image.
57
79
  *
@@ -65,29 +87,73 @@ export async function* streamWithProviderRejectedImageRecovery(
65
87
  params: ChatCompletionParams,
66
88
  onAccepted: (identity: RequestImageIdentity) => Promise<void>,
67
89
  onRequest?: (messages: readonly Message[]) => void,
90
+ textOnly?: TextOnlyModelRecovery,
91
+ repair?: RequestRepair,
68
92
  ): AsyncIterable<StreamChunk> {
93
+ // Every send, including the retries below, goes through the request repair.
94
+ // They share one repair budget: the original request and its retries are
95
+ // one request to the person, and each resend is a paid call.
96
+ const session = repair?.session()
97
+ const send = (request: ChatCompletionParams): AsyncIterable<StreamChunk> =>
98
+ repair ? repair.stream(provider, request, session) : provider.chatStream(request)
69
99
  const candidate = findSingleRequestImage(params.messages)
70
100
  onRequest?.(params.messages)
71
- if (candidate === null) {
72
- yield* provider.chatStream(params)
101
+ if (candidate === null && !requestHasImages(params.messages)) {
102
+ yield* send(params)
73
103
  return
74
104
  }
75
105
 
76
106
  let produced = false
77
107
  let confidence: ImageRejectionConfidence | null = null
108
+ let refused: TextOnlyModelRefusal | null = null
78
109
  try {
79
- for await (const chunk of provider.chatStream(params)) {
110
+ for await (const chunk of send(params)) {
80
111
  if (isAcceptedChunk(chunk)) produced = true
81
112
  yield chunk
82
113
  }
83
114
  return
84
115
  } catch (error) {
85
116
  throwIfAborted(params.signal)
117
+ if (produced) throw error
118
+ // A fault in one image (the server's own `invalid_image`, or wording that
119
+ // says an image could not be decoded) is about that image, not about the
120
+ // model, and it wins: the model reads images, so it keeps the others.
86
121
  confidence = classifyProviderRejectedImageError(error)
87
- if (produced || confidence === null) throw error
122
+ if (confidence === null && isImageInputRefusal(error)) {
123
+ // A model that reads text only refuses the request however many images
124
+ // it holds, so every image goes. Named now, while the member that
125
+ // refused is still the one serving.
126
+ const served = textOnly?.servedBy?.()
127
+ refused = {
128
+ providerId: served?.providerId ?? provider.id,
129
+ model: served?.model ?? params.model,
130
+ }
131
+ } else if (confidence === null || candidate === null) {
132
+ throw error
133
+ }
88
134
  }
89
135
 
90
136
  throwIfAborted(params.signal)
137
+ if (refused !== null) {
138
+ // One retry, without any image. The stored conversation is untouched, so
139
+ // a model that does read images gets them again.
140
+ const messages = projectRequestRichContent(params.messages, 0, { imagesReadable: false })
141
+ onRequest?.(messages)
142
+ let accepted = false
143
+ let reportedError = false
144
+ for await (const chunk of send({ ...params, messages })) {
145
+ if (chunk.error !== undefined) reportedError = true
146
+ if (!accepted && isAcceptedChunk(chunk)) {
147
+ throwIfAborted(params.signal)
148
+ await textOnly?.onAccepted(refused)
149
+ accepted = true
150
+ }
151
+ yield chunk
152
+ }
153
+ if (!accepted && !reportedError) await textOnly?.onAccepted(refused)
154
+ return
155
+ }
156
+ if (candidate === null) return
91
157
  const retryParams: ChatCompletionParams = {
92
158
  ...params,
93
159
  messages: projectRequestWithoutImage(params.messages, candidate),
@@ -95,7 +161,7 @@ export async function* streamWithProviderRejectedImageRecovery(
95
161
  let accepted = false
96
162
  let retryReportedError = false
97
163
  onRequest?.(retryParams.messages)
98
- for await (const chunk of provider.chatStream(retryParams)) {
164
+ for await (const chunk of send(retryParams)) {
99
165
  if (chunk.error !== undefined) retryReportedError = true
100
166
  if (!accepted && isAcceptedChunk(chunk)) {
101
167
  throwIfAborted(params.signal)