pi-vision-handoff 0.9.3 → 0.9.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Give text-only pi models vision — describe images with a vision model you pick via an interactive picker, then hand off the text description to non-vision models",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",
@@ -31,9 +31,9 @@
31
31
  "README.md"
32
32
  ],
33
33
  "devDependencies": {
34
- "@earendil-works/pi-ai": "0.83.0",
35
- "@earendil-works/pi-coding-agent": "0.83.0",
36
- "@earendil-works/pi-tui": "0.83.0",
34
+ "@earendil-works/pi-ai": "0.84.1",
35
+ "@earendil-works/pi-coding-agent": "0.84.1",
36
+ "@earendil-works/pi-tui": "0.84.1",
37
37
  "@types/node": "25.9.1",
38
38
  "@vitest/coverage-v8": "4.1.7",
39
39
  "knip": "6.14.1",
package/src/dataloader.ts CHANGED
@@ -154,8 +154,23 @@ export class DescriptionLoader implements Disposable {
154
154
  * prewarm batch be aborted the moment the live signal arrives. */
155
155
  bindTurnContext(ctx: { modelRegistry: ModelRegistry; signal?: AbortSignal }): void {
156
156
  this.turnModelRegistry = ctx.modelRegistry;
157
- const resolved = this.deps.resolveVisionModel(ctx.modelRegistry, this.deps.getConfig().visionModel!);
158
- if (resolved) this.turnVisionModel = resolved;
157
+ const cfg = this.deps.getConfig();
158
+ const resolved = this.deps.resolveVisionModel(ctx.modelRegistry, cfg.visionModel!);
159
+ if (resolved) {
160
+ this.turnVisionModel = resolved;
161
+ } else {
162
+ // Primary unresolvable (typo, removed model): pick the first resolvable
163
+ // fallback so failover still works instead of leaving the loader dead.
164
+ // Call-time failover in dispatchBatch covers the case where the primary
165
+ // RESOLVES but fails at call time.
166
+ for (const ref of cfg.fallbackModels ?? []) {
167
+ const fb = this.deps.resolveVisionModel(ctx.modelRegistry, ref);
168
+ if (fb) {
169
+ this.turnVisionModel = fb;
170
+ break;
171
+ }
172
+ }
173
+ }
159
174
  const signal = ctx.signal;
160
175
  if (!signal) return;
161
176
  if (signal.aborted) {
@@ -314,6 +329,34 @@ export class DescriptionLoader implements Disposable {
314
329
  );
315
330
  }
316
331
  }
332
+ // Failover: the same-model retry failed too. Try each configured fallback
333
+ // describer in order (a different provider has independent rate limits /
334
+ // auth / availability). No per-fallback backoff — the fallback doesn't
335
+ // share the primary's rate-limit state. A partial result from any
336
+ // fallback wins; only when every fallback also returns empty do we give
337
+ // up (UNAVAILABLE, evicted from cache, re-attempted next turn). User
338
+ // cancel (ESC) skips failover.
339
+ if (parsed.size === 0 && misses.length > 0 && !this.turnAbortController.signal.aborted) {
340
+ for (const ref of cfg.fallbackModels ?? []) {
341
+ if (this.turnAbortController.signal.aborted) break;
342
+ const fallbackModel = this.deps.resolveVisionModel(this.turnModelRegistry, ref);
343
+ if (!fallbackModel) {
344
+ this.deps.setLastError(`fallback vision model "${ref}" could not be resolved`);
345
+ continue;
346
+ }
347
+ this.deps.setLastError(null);
348
+ parsed = await runBatch(
349
+ misses,
350
+ this.pendingTurnPrompt,
351
+ fallbackModel,
352
+ this.turnModelRegistry,
353
+ cfg,
354
+ this.deps,
355
+ this.turnAbortController.signal,
356
+ );
357
+ if (parsed.size > 0 || this.turnAbortController.signal.aborted) break;
358
+ }
359
+ }
317
360
  // Build per-hash results, then fan them out to every callback. This reaches
318
361
  // duplicate-hash callbacks that a key-indexed loop would have skipped (and
319
362
  // left hanging).
package/src/index.ts CHANGED
@@ -133,6 +133,13 @@ export interface VisionHandoffConfig {
133
133
  enabled: boolean;
134
134
  /** The vision-capable model that describes images, as "provider/id". null = not configured. */
135
135
  visionModel: string | null;
136
+ /** Optional fallback describers, tried in order when the primary vision
137
+ * model's batched call fails entirely (auth, rate limit, timeout, network,
138
+ * empty/error stopReason) and the same-model retry didn't recover.
139
+ * Each entry is a "provider/id" ref; providers usually differ from the
140
+ * primary so their rate limits / auth / availability are independent.
141
+ * Empty (default) = no failover. */
142
+ fallbackModels: string[];
136
143
  /** When true (default), handoff is applied to every model whose input does not include "image". */
137
144
  autoHandoff: boolean;
138
145
  /** Extra "provider/id" refs that should ALSO receive handoff (e.g. weak vision models). */
@@ -188,6 +195,7 @@ export interface VisionHandoffConfig {
188
195
  export const DEFAULT_CONFIG: VisionHandoffConfig = {
189
196
  enabled: true,
190
197
  visionModel: null,
198
+ fallbackModels: [],
191
199
  autoHandoff: true,
192
200
  handoffModels: [],
193
201
  prewarmPastedImages: false,
@@ -233,6 +241,12 @@ export function normalizeConfig(raw: unknown): VisionHandoffConfig {
233
241
  } else if (obj.visionModel === null) {
234
242
  base.visionModel = null;
235
243
  }
244
+ if (Array.isArray(obj.fallbackModels)) {
245
+ base.fallbackModels = obj.fallbackModels
246
+ .filter((m): m is string => typeof m === "string")
247
+ .map((m) => m.trim())
248
+ .filter((m) => m && parseModelRef(m));
249
+ }
236
250
  if (typeof obj.autoHandoff === "boolean") base.autoHandoff = obj.autoHandoff;
237
251
 
238
252
  if (Array.isArray(obj.handoffModels)) {
package/vision-handoff.ts CHANGED
@@ -206,6 +206,16 @@ function resolveVisionModel(modelRegistry: ModelRegistry, ref: string): Model<Ap
206
206
  return model;
207
207
  }
208
208
 
209
+ /** True when the primary vision model resolves, or — failing that — at least
210
+ * one configured fallback resolves. A broken primary ref (typo, removed
211
+ * model) must not short-circuit the whole pipeline to "unresolved" warnings
212
+ * when a fallback could still describe images; the loader's failover picks
213
+ * the resolvable fallback up in bindTurnContext. */
214
+ function isAnyVisionModelResolvable(modelRegistry: ModelRegistry, config: VisionHandoffConfig): boolean {
215
+ if (resolveVisionModel(modelRegistry, config.visionModel!)) return true;
216
+ return config.fallbackModels.some((ref) => resolveVisionModel(modelRegistry, ref));
217
+ }
218
+
209
219
  const loaderDeps: LoaderDeps = {
210
220
  getConfig: () => config,
211
221
  resolveVisionModel,
@@ -415,7 +425,7 @@ export default function (pi: ExtensionAPI) {
415
425
  loader.setPendingTurnPrompt(event.prompt || "");
416
426
  loader.bindTurnContext(ctx);
417
427
 
418
- if (!resolveVisionModel(ctx.modelRegistry, config.visionModel!)) {
428
+ if (!isAnyVisionModelResolvable(ctx.modelRegistry, config)) {
419
429
  notifyUnresolvedVisionModel(ctx, config.visionModel!);
420
430
  return;
421
431
  }
@@ -575,7 +585,7 @@ export default function (pi: ExtensionAPI) {
575
585
 
576
586
  if (imgs.length === 0) return;
577
587
  loader.bindTurnContext(ctx);
578
- if (!resolveVisionModel(ctx.modelRegistry, config.visionModel!)) {
588
+ if (!isAnyVisionModelResolvable(ctx.modelRegistry, config)) {
579
589
  notifyUnresolvedVisionModel(ctx, config.visionModel!);
580
590
  return;
581
591
  }
@@ -667,7 +677,7 @@ export default function (pi: ExtensionAPI) {
667
677
  if (!anyImage) return;
668
678
  if (!isHandoffTarget(ctx.model, config)) return;
669
679
  loader.bindTurnContext(ctx);
670
- if (!resolveVisionModel(ctx.modelRegistry, config.visionModel!)) {
680
+ if (!isAnyVisionModelResolvable(ctx.modelRegistry, config)) {
671
681
  notifyUnresolvedVisionModel(ctx, config.visionModel!);
672
682
  return;
673
683
  }