@kaydash9999/banker-plugins 0.20.2 → 0.21.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 (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +203 -1
  4. package/PRIVACY.md +34 -4
  5. package/README.md +46 -15
  6. package/bin/banker.js +228 -51
  7. package/bin/lib/codex-hooks.mjs +249 -0
  8. package/bin/lib/telemetry-config.mjs +33 -16
  9. package/codex/manifest.json +14 -11
  10. package/codex/skills/graceful-pause/SKILL.md +30 -0
  11. package/codex/skills/progress/SKILL.md +42 -0
  12. package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
  13. package/codex/skills/progress/scripts/progress.mjs +295 -0
  14. package/codex/transform-matrix.md +19 -14
  15. package/commands/setup.md +23 -0
  16. package/hooks/obsidize-hook.mjs +116 -4
  17. package/hooks/progress-plan.mjs +1 -1
  18. package/hooks/progress.mjs +143 -38
  19. package/hooks/register.mjs +13 -3
  20. package/hooks/telemetry-count-codex.mjs +222 -0
  21. package/hooks/update-checkin.mjs +15 -7
  22. package/hooks/update-fetch.mjs +15 -9
  23. package/hooks/update-notify.mjs +30 -17
  24. package/package.json +1 -1
  25. package/skills/3d-intro-build/SKILL.md +11 -5
  26. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-build/references/curate.mjs +48 -14
  28. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  29. package/skills/3d-intro-build/references/prompts.md +4 -4
  30. package/skills/3d-intro-build/references/serve.mjs +6 -1
  31. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  32. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  33. package/skills/3d-intro-setup/SKILL.md +41 -10
  34. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  35. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  36. package/skills/anti-drm/SKILL.md +6 -3
  37. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  38. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  39. package/skills/append_wiki/SKILL.md +34 -8
  40. package/skills/audit-mock/SKILL.md +1 -1
  41. package/skills/audit-security/SKILL.md +64 -2
  42. package/skills/compact-wiki/SKILL.md +37 -6
  43. package/skills/deep-research/SKILL.md +11 -0
  44. package/skills/docs-setup/SKILL.md +37 -7
  45. package/skills/lfm/SKILL.md +148 -0
  46. package/skills/lineage/SKILL.md +25 -15
  47. package/skills/lineage/lineage.py +564 -301
  48. package/skills/motion-graphic-make/SKILL.md +1 -1
  49. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  50. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  51. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  52. package/skills/motion-graphic-setup/SKILL.md +58 -13
  53. package/skills/obsidizer/SKILL.md +30 -11
  54. package/skills/obsidizer/obsidize.mjs +50 -12
  55. package/skills/omc-patch/SKILL.md +86 -23
  56. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  57. package/skills/payload-mon/SKILL.md +6 -2
  58. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  59. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  60. package/skills/pdf-vision-extract/SKILL.md +11 -6
  61. package/skills/ralph-qa/SKILL.md +125 -151
  62. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  63. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  64. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  65. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  66. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  67. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  68. package/skills/rfp-author/SKILL.md +5 -3
  69. package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
  70. package/skills/setup-bypass-permissions/SKILL.md +27 -9
  71. package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
  72. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
  73. package/skills/setup-harness-factory/SKILL.md +17 -1
  74. package/skills/setup-insane-search/SKILL.md +16 -0
  75. package/skills/setup-java/SKILL.md +20 -1
  76. package/skills/setup-lsp/SKILL.md +56 -13
  77. package/skills/setup-mcp/SKILL.md +73 -4
  78. package/skills/setup-node/SKILL.md +31 -6
  79. package/skills/setup-omc/SKILL.md +24 -0
  80. package/skills/setup-omc-hud/SKILL.md +42 -9
  81. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  82. package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
  83. package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
  84. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  85. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  86. package/skills/setup-playwright/SKILL.md +45 -2
  87. package/skills/setup-pwsh/SKILL.md +19 -0
  88. package/skills/setup-python/SKILL.md +45 -7
  89. package/skills/setup-sandbox/SKILL.md +30 -5
  90. package/skills/setup-stitch/SKILL.md +22 -0
  91. package/skills/setup-tmux/SKILL.md +24 -4
  92. package/skills/smart-compact/SKILL.md +58 -12
  93. package/skills/smart-compact/scripts/codex-gate.mjs +384 -0
  94. package/skills/tone-compact/SKILL.md +2 -1
  95. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  96. package/skills/ultra-chain/SKILL.md +12 -7
  97. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  98. package/skills/ultra-chain/references/serve.mjs +6 -1
  99. package/skills/ultra-interview/SKILL.md +14 -2
  100. package/skills/update-banker/SKILL.md +19 -3
  101. package/skills/vertical-pptx/SKILL.md +6 -3
  102. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -18,11 +18,13 @@
18
18
  * WAN_EXHAUSTED_TTL_HOURS how long an exhausted key rests before one retry (default 24)
19
19
  * VIDEO_PROVIDER_ORDER comma list of wan,sora (default wan,sora)
20
20
  *
21
- * Billing rule: a leg is submitted to a second key ONLY when the first submission provably did
22
- * not start a task (the server rejected it, the connection failed before the request was sent, or
23
- * the task ended FAILED/CANCELED with a known key, capacity or transient code). Every other outcome
24
- * throws, with the taskId when a task exists, so the caller can resume (resumeClip) instead of
25
- * paying twice. A config error (every WAN entry malformed, ModelNotFound) never falls back to Sora.
21
+ * Billing rule: a leg is submitted to a second key ONLY when the first submission is taken not to
22
+ * have billed (the server rejected it, the connection failed before the request was sent, or the
23
+ * task ended FAILED/CANCELED with a known key, capacity or transient code). That a FAILED/CANCELED
24
+ * task is not billed is an assumption the official docs do not confirm; if it is wrong, one leg can
25
+ * be submitted up to (keys x 4 + 2) times. Every other outcome throws, with the taskId when a task
26
+ * exists, so the caller can resume (resumeClip) instead of paying twice. A config error (every WAN
27
+ * entry malformed, ModelNotFound) never falls back to Sora.
26
28
  *
27
29
  * Pool state lives in ~/.config/banker/3d-intro/video-pool-state.json (0600). It holds
28
30
  * FINGERPRINTS (sha256 of endpoint+key+model), never a key, so a rotated key or a changed model
@@ -37,7 +39,10 @@ import path from 'node:path';
37
39
  import os from 'node:os';
38
40
  import crypto from 'node:crypto';
39
41
  import cp from 'node:child_process';
40
- import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey } from './azure-adapter.mjs';
42
+ import {
43
+ createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey, keyHeldBack,
44
+ API_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, HELD_KEY, errnoOf, timeLeft,
45
+ } from './azure-adapter.mjs';
41
46
 
42
47
  export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
43
48
  const WAN_CREATE_PATH = '/api/v1/services/aigc/video-generation/video-synthesis';
@@ -56,10 +61,10 @@ const QUOTA_STRIKES_TO_EXHAUST = 3;
56
61
  const QUOTA_STRIKE_TTL_MS = 3_600_000;
57
62
  const TRANSIENT_BASE_MS = 15_000;
58
63
  const UNKNOWN_POLLS_TO_FAIL = 5;
59
- const API_TIMEOUT_MS = 120_000;
60
- const DOWNLOAD_TIMEOUT_MS = 600_000;
61
64
 
62
- const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
65
+ // A real wait is at least 1 s: an interval of 0 must not poll a paid API in a busy loop.
66
+ const MIN_REAL_WAIT_MS = 1000;
67
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, Math.max(MIN_REAL_WAIT_MS, ms)));
63
68
 
64
69
  // ---------------------------------------------------------------------------
65
70
  // Error classification
@@ -74,9 +79,9 @@ const TRANSIENT_CODES = new Set([
74
79
  'InternalError', 'InternalError.Timeout', 'ServiceUnavailable', 'ModelUnavailable', 'Throttling.ServiceOverloaded',
75
80
  ]);
76
81
  // Errors that mean the request never left this machine, so resubmitting elsewhere cannot double-bill.
77
- // EINSECURE is fetchGuarded refusing a key over http before sending. Its redirect codes are not here:
78
- // a redirect answers a request that was sent.
79
- const PRE_SEND_ERRORS = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT', 'ERR_INVALID_URL', 'EINSECURE']);
82
+ // EINSECURE and EPROXYKEY are fetchGuarded refusing a key over http (or through an env proxy) before
83
+ // sending. Its redirect codes are not here: a redirect answers a request that was sent.
84
+ const PRE_SEND_ERRORS = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT', 'ERR_INVALID_URL', 'EINSECURE', 'EPROXYKEY']);
80
85
 
81
86
  /**
82
87
  * Map a WAN failure to a pool action.
@@ -92,8 +97,7 @@ const PRE_SEND_ERRORS = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EHOS
92
97
  export function classifyWanError({ status, code } = {}) {
93
98
  const kind = codeKind(code);
94
99
  if (kind) return kind;
95
- if (status === 401) return 'unusable';
96
- if (status === 403) return 'denied';
100
+ if (status === 403 || status === 401) return 'denied';
97
101
  if (status === 429) return 'rate';
98
102
  return !status || status >= 500 ? 'transient' : 'invalid_request';
99
103
  }
@@ -245,6 +249,21 @@ export function poolSummary({ creds = {}, statePath = defaultStatePath(), now =
245
249
  const inflight = new Map();
246
250
 
247
251
  function pickEntry(pool, state, t, limit) {
252
+ const { ready, soonest, busy } = scanPool(pool, state, t, limit);
253
+ if (ready.length) {
254
+ // least recently used first: spreads requests over the pool, so no single key takes every rate hit
255
+ ready.sort((a, b) => (state.entries[a.fp]?.lastUsedAt || 0) - (state.entries[b.fp]?.lastUsedAt || 0) || a.n - b.n);
256
+ return { entry: ready[0] };
257
+ }
258
+ // a busy key frees up as soon as one of our own tasks finishes — always worth waiting for
259
+ if (busy) return { entry: null, waitMs: 1000, busy: true };
260
+ if (soonest != null) return { entry: null, waitMs: Math.max(0, soonest - t) };
261
+ return { entry: null, waitMs: null };
262
+ }
263
+
264
+ // The pool at time t: the entries that can take a task now, the soonest end of a cooldown, and
265
+ // whether a usable key is only at its in-flight limit.
266
+ function scanPool(pool, state, t, limit) {
248
267
  const ready = [];
249
268
  let soonest = null;
250
269
  let busy = false;
@@ -255,15 +274,7 @@ function pickEntry(pool, state, t, limit) {
255
274
  if ((inflight.get(e.fp) || 0) >= limit) { busy = true; continue; }
256
275
  ready.push(e);
257
276
  }
258
- if (ready.length) {
259
- // least recently used first: spreads requests over the pool, so no single key takes every rate hit
260
- ready.sort((a, b) => (state.entries[a.fp]?.lastUsedAt || 0) - (state.entries[b.fp]?.lastUsedAt || 0) || a.n - b.n);
261
- return { entry: ready[0] };
262
- }
263
- // a busy key frees up as soon as one of our own tasks finishes — always worth waiting for
264
- if (busy) return { entry: null, waitMs: 1000, busy: true };
265
- if (soonest != null) return { entry: null, waitMs: Math.max(0, soonest - t) };
266
- return { entry: null, waitMs: null };
277
+ return { ready, soonest, busy };
267
278
  }
268
279
 
269
280
  // ---------------------------------------------------------------------------
@@ -280,21 +291,23 @@ function parseRetryAfter(h) {
280
291
 
281
292
  const WAN_FETCH_DEFAULTS = { method: 'GET', binary: false, timeoutMs: API_TIMEOUT_MS };
282
293
 
294
+ // The body is read inside the try: a connection that drops mid-body is a network failure, which
295
+ // the caller's retry sees, not a throw past it.
283
296
  async function wanFetch(url, opts = {}) {
284
297
  const o = { ...WAN_FETCH_DEFAULTS, ...defined(opts) };
285
- let r;
286
298
  try {
287
- r = await fetchGuarded(url, {
299
+ const r = await fetchGuarded(url, {
288
300
  method: o.method, headers: wanHeaders(o), body: o.body === undefined ? undefined : JSON.stringify(o.body), signal: AbortSignal.timeout(o.timeoutMs),
289
301
  });
302
+ const retryAfterMs = parseRetryAfter(r.headers.get('retry-after'));
303
+ if (o.binary) return { status: r.status, ok: r.ok, body: Buffer.from(await r.arrayBuffer()), code: null, message: null, retryAfterMs };
304
+ return { status: r.status, ok: r.ok, ...(await parsedBody(r)), retryAfterMs };
290
305
  } catch (e) {
291
306
  return networkFailure(e);
292
307
  }
293
- const retryAfterMs = parseRetryAfter(r.headers.get('retry-after'));
294
- if (o.binary) return { status: r.status, ok: r.ok, body: Buffer.from(await r.arrayBuffer()), code: null, message: null, retryAfterMs };
295
- return { status: r.status, ok: r.ok, ...(await parsedBody(r)), retryAfterMs };
296
308
  }
297
309
 
310
+
298
311
  function wanHeaders({ key, body }) {
299
312
  const headers = {};
300
313
  if (key) headers.Authorization = `Bearer ${key}`;
@@ -302,8 +315,6 @@ function wanHeaders({ key, body }) {
302
315
  return headers;
303
316
  }
304
317
 
305
- // AbortSignal.timeout rejects with a DOMException whose numeric code means nothing to a reader
306
- const errnoOf = (e) => (e?.name === 'TimeoutError' ? 'TimeoutError' : e?.cause?.code || e?.code || null);
307
318
 
308
319
  // A fetch that threw. Its message can quote a request header in full (undici names an invalid
309
320
  // header value, Authorization included), so only the error code travels on, never the text.
@@ -312,8 +323,9 @@ function networkFailure(e) {
312
323
  return { status: 0, ok: false, body: null, code: null, message: errno || e?.name || 'network error', retryAfterMs: null, preSend: PRE_SEND_ERRORS.has(errno), errno };
313
324
  }
314
325
 
326
+ // A read cut short throws: wanFetch's catch makes it a network failure, as for a binary body.
315
327
  async function parsedBody(r) {
316
- const text = await r.text().catch(() => '');
328
+ const text = await r.text();
317
329
  let parsed = null;
318
330
  try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
319
331
  return { body: parsed, code: parsed?.code || null, message: parsed?.message || null };
@@ -351,58 +363,90 @@ export async function frameDataUri(buf) {
351
363
 
352
364
  /** WAN generation parameters for an output size like '720x1280'. */
353
365
  export function wanParams({ size = '720x1280', seconds = 5, hasFrame = false, creds = {}, overrides = {} } = {}) {
366
+ const { w, h } = sizeOf(size);
367
+ const resolution = wanResolution(creds, Math.min(w, h));
368
+ const ratio = wanRatio(w, h, hasFrame);
369
+ const duration = Math.min(30, Math.max(2, Math.round(Number(seconds) || 5)));
370
+ const promptExtend = String(creds.WAN_PROMPT_EXTEND || '').toLowerCase() === 'true';
371
+ return { resolution, ratio, duration, audio: false, watermark: false, prompt_extend: promptExtend, ...overrides };
372
+ }
373
+
374
+ // An output size like '720x1280' as numbers; 720x1280 when it is not one.
375
+ function sizeOf(size) {
354
376
  const m = String(size).match(/^(\d+)x(\d+)$/);
355
- const w = m ? +m[1] : 720, h = m ? +m[2] : 1280;
356
- const short = Math.min(w, h);
357
- const resolution = creds.WAN_RESOLUTION || (short >= 1080 ? '1080P' : short >= 720 ? '720P' : '480P');
377
+ return m ? { w: +m[1], h: +m[2] } : { w: 720, h: 1280 };
378
+ }
379
+
380
+ const wanResolution = (creds, short) => creds.WAN_RESOLUTION || (short >= 1080 ? '1080P' : short >= 720 ? '720P' : '480P');
381
+
382
+ // with a first frame, 'adaptive' follows the frame's own aspect, so nothing gets cropped
383
+ function wanRatio(w, h, hasFrame) {
384
+ if (hasFrame) return 'adaptive';
358
385
  const g = (a, b) => (b ? g(b, a % b) : a);
359
386
  const d = g(w, h);
360
387
  const exact = `${w / d}:${h / d}`;
361
- // with a first frame, 'adaptive' follows the frame's own aspect, so nothing gets cropped
362
- const ratio = hasFrame ? 'adaptive' : (WAN_RATIOS.has(exact) ? exact : 'adaptive');
363
- const duration = Math.min(30, Math.max(2, Math.round(Number(seconds) || 5)));
364
- const promptExtend = String(creds.WAN_PROMPT_EXTEND || '').toLowerCase() === 'true';
365
- return { resolution, ratio, duration, audio: false, watermark: false, prompt_extend: promptExtend, ...overrides };
388
+ return WAN_RATIOS.has(exact) ? exact : 'adaptive';
366
389
  }
367
390
 
368
391
  /** Submit one WAN task. Returns the wanFetch result plus taskId when accepted. PAID CALL. */
369
392
  export async function wanCreateTask({ entry, prompt, firstFramePng, lastFramePng, parameters } = {}) {
370
- if (lastFramePng && !firstFramePng) throw new Error('wanCreateTask: lastFramePng needs firstFramePng');
371
- const media = [];
372
- if (firstFramePng) media.push({ type: 'first_frame', url: await frameDataUri(firstFramePng) });
373
- if (lastFramePng) media.push({ type: 'last_frame', url: await frameDataUri(lastFramePng) });
374
- const input = { prompt };
375
- if (media.length) input.media = media;
393
+ const input = { prompt, ...(await wanMedia(firstFramePng, lastFramePng)) };
376
394
  const r = await wanFetch(`${entry.endpoint}${WAN_CREATE_PATH}`, {
377
395
  key: entry.key, method: 'POST', body: { model: entry.model, input, parameters },
378
396
  });
379
397
  return { ...r, taskId: r.ok ? r.body?.output?.task_id || null : null };
380
398
  }
381
399
 
400
+ // The frames a task starts (and ends) on, as WAN's input.media; nothing without frames.
401
+ async function wanMedia(firstFramePng, lastFramePng) {
402
+ if (lastFramePng && !firstFramePng) throw new Error('wanCreateTask: lastFramePng needs firstFramePng');
403
+ const media = [];
404
+ if (firstFramePng) media.push({ type: 'first_frame', url: await frameDataUri(firstFramePng) });
405
+ if (lastFramePng) media.push({ type: 'last_frame', url: await frameDataUri(lastFramePng) });
406
+ return media.length ? { media } : {};
407
+ }
408
+
382
409
  /**
383
410
  * Poll a WAN task to a terminal state. Returns {state, output} where state is
384
- * SUCCEEDED | FAILED | CANCELED | UNKNOWN | TIMEOUT | HTTP (a 4xx other than 429 on the poll itself).
411
+ * SUCCEEDED | FAILED | CANCELED | UNKNOWN | TIMEOUT | HTTP (a 4xx other than 429 on the poll itself, or
412
+ * status 0 with code EINSECURE or EPROXYKEY: the key was held back and nothing was sent).
413
+ * The poll ends after `timeoutMs` of wall time on the `now` clock: each request is limited to the
414
+ * time left, so a server that stops answering cannot stretch the wait. TIMEOUT carries `elapsedMs`.
385
415
  */
386
- export async function wanPollTask({ entry, taskId, intervalMs = 15_000, timeoutMs = 30 * 60_000, sleep = realSleep, onTick } = {}) {
416
+ export async function wanPollTask({ entry, taskId, intervalMs = 15_000, timeoutMs = 30 * 60_000, sleep = realSleep, now = Date.now, onTick } = {}) {
387
417
  const url = `${entry.endpoint}${WAN_TASK_PATH}${encodeURIComponent(taskId)}`;
388
418
  const ticks = Math.max(1, Math.ceil(timeoutMs / Math.max(1, intervalMs)));
389
- let unknown = 0;
419
+ const start = now();
420
+ const end = start + timeoutMs;
421
+ const seen = { unknown: 0 };
390
422
  for (let i = 0; i < ticks; i++) {
391
- const r = await wanFetch(url, { key: entry.key });
392
- if (r.ok) {
393
- const out = r.body?.output || {};
394
- if (typeof onTick === 'function') onTick(out.task_status, i);
395
- if (out.task_status === 'SUCCEEDED' || out.task_status === 'FAILED' || out.task_status === 'CANCELED') {
396
- return { state: out.task_status, output: out, usage: r.body?.usage || null };
397
- }
398
- if (out.task_status === 'UNKNOWN' && ++unknown >= UNKNOWN_POLLS_TO_FAIL) return { state: 'UNKNOWN', output: out };
399
- } else if (r.status >= 400 && r.status < 500 && r.status !== 429) {
400
- return { state: 'HTTP', output: { code: r.code, message: r.message }, status: r.status };
401
- }
402
- // 429/5xx/network on a poll: keep polling — the task itself is still running
423
+ const r = await wanFetch(url, { key: entry.key, timeoutMs: timeLeft(end, now()) });
424
+ const outcome = pollOutcome(r, seen, onTick, i);
425
+ if (outcome) return outcome;
403
426
  await sleep(intervalMs);
427
+ if (now() >= end) break;
404
428
  }
405
- return { state: 'TIMEOUT', output: {} };
429
+ return { state: 'TIMEOUT', output: {}, elapsedMs: now() - start };
430
+ }
431
+
432
+ const WAN_TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELED']);
433
+ const pollOutcome = (r, seen, onTick, i) => (r.ok ? answerOutcome(r.body, seen, onTick, i) : refusedPoll(r));
434
+
435
+ // What a poll's answer ends the poll with, or null to keep polling (a task still on its way).
436
+ function answerOutcome(body, seen, onTick, i) {
437
+ const out = body?.output || {};
438
+ if (typeof onTick === 'function') onTick(out.task_status, i);
439
+ if (WAN_TERMINAL.has(out.task_status)) return { state: out.task_status, output: out, usage: body?.usage || null };
440
+ if (out.task_status === 'UNKNOWN' && ++seen.unknown >= UNKNOWN_POLLS_TO_FAIL) return { state: 'UNKNOWN', output: out };
441
+ return null;
442
+ }
443
+
444
+ // A 4xx other than 429 on the poll itself ends it; 429/5xx/network keep polling — the task is still running.
445
+ // So does a key held back before sending (EINSECURE, EPROXYKEY): no later poll could send it either.
446
+ function refusedPoll(r) {
447
+ if (r.status >= 400 && r.status < 500 && r.status !== 429) return { state: 'HTTP', output: { code: r.code, message: r.message }, status: r.status };
448
+ if (r.status === 0 && HELD_KEY.has(r.errno)) return { state: 'HTTP', output: { code: r.errno, message: r.message }, status: 0 };
449
+ return null;
406
450
  }
407
451
 
408
452
  /** Download a finished WAN video (signed URL, no auth header). Returns an mp4 Buffer. */
@@ -478,30 +522,39 @@ export function snapSoraSeconds(sec) {
478
522
 
479
523
  function applyFailure(file, entry, kind, { code, status, retryAfterMs, message }, t, creds, emit) {
480
524
  const reason = code || (status ? `HTTP ${status}` : 'network');
481
- updateEntry(file, entry, (st) => {
482
- // strikes count CONSECUTIVE hits: anything older than the window they guard is forgotten
483
- const fresh = st.lastFailAt && t - st.lastFailAt < RATE_MAX_MS * 2;
484
- const base = { ...st, reason, lastFailAt: t };
485
- if (kind === 'unusable') return { ...base, status: 'invalid', at: t };
486
- if (kind === 'denied' || kind === 'exhausted') return { ...base, status: 'exhausted', at: t, until: t + exhaustedTtlMs(creds), quotaStrikes: 0 };
487
- if (kind === 'quota') {
488
- const strikes = (fresh ? st.quotaStrikes || 0 : 0) + 1;
489
- if (strikes >= QUOTA_STRIKES_TO_EXHAUST) {
490
- return { ...base, status: 'exhausted', reason: `${reason} x${strikes}`, quotaStrikes: 0, at: t, until: t + QUOTA_STRIKE_TTL_MS };
491
- }
492
- return { ...base, quotaStrikes: strikes, cooldownUntil: t + (retryAfterMs ?? RATE_BASE_MS * 2 ** (strikes - 1)) };
493
- }
494
- if (kind === 'rate') {
495
- const strikes = (fresh ? st.rateStrikes || 0 : 0) + 1;
496
- return { ...base, rateStrikes: strikes, cooldownUntil: t + (retryAfterMs ?? Math.min(RATE_MAX_MS, RATE_BASE_MS * 2 ** (strikes - 1))) };
497
- }
498
- // transient
499
- const n = (fresh ? st.transientStrikes || 0 : 0) + 1;
500
- return {
501
- ...base, transientStrikes: n, cooldownUntil: t + Math.min(RATE_MAX_MS, TRANSIENT_BASE_MS * n),
502
- reason: message ? `${reason}: ${String(message).slice(0, 120)}` : reason,
503
- };
504
- }, emit);
525
+ updateEntry(file, entry, (st) => failedState(st, kind, { reason, retryAfterMs, message, t, creds }), emit);
526
+ }
527
+
528
+ // An entry's state after a failure of this kind at time f.t. Strikes count CONSECUTIVE hits: anything
529
+ // older than the window they guard is forgotten.
530
+ function failedState(st, kind, f) {
531
+ const fresh = Boolean(st.lastFailAt && f.t - st.lastFailAt < RATE_MAX_MS * 2);
532
+ const base = { ...st, reason: f.reason, lastFailAt: f.t };
533
+ if (kind === 'unusable') return { ...base, status: 'invalid', at: f.t };
534
+ if (kind === 'denied' || kind === 'exhausted') return { ...base, status: 'exhausted', at: f.t, until: f.t + exhaustedTtlMs(f.creds), quotaStrikes: 0 };
535
+ if (kind === 'quota') return quotaState(base, strikesAfter(fresh, st.quotaStrikes), f);
536
+ if (kind === 'rate') return rateState(base, strikesAfter(fresh, st.rateStrikes), f);
537
+ return transientState(base, strikesAfter(fresh, st.transientStrikes), f);
538
+ }
539
+
540
+ const strikesAfter = (fresh, n) => (fresh ? n || 0 : 0) + 1;
541
+
542
+ function quotaState(base, strikes, { reason, retryAfterMs, t }) {
543
+ if (strikes >= QUOTA_STRIKES_TO_EXHAUST) {
544
+ return { ...base, status: 'exhausted', reason: `${reason} x${strikes}`, quotaStrikes: 0, at: t, until: t + QUOTA_STRIKE_TTL_MS };
545
+ }
546
+ return { ...base, quotaStrikes: strikes, cooldownUntil: t + (retryAfterMs ?? RATE_BASE_MS * 2 ** (strikes - 1)) };
547
+ }
548
+
549
+ function rateState(base, strikes, { retryAfterMs, t }) {
550
+ return { ...base, rateStrikes: strikes, cooldownUntil: t + (retryAfterMs ?? Math.min(RATE_MAX_MS, RATE_BASE_MS * 2 ** (strikes - 1))) };
551
+ }
552
+
553
+ function transientState(base, n, { reason, message, t }) {
554
+ return {
555
+ ...base, transientStrikes: n, cooldownUntil: t + Math.min(RATE_MAX_MS, TRANSIENT_BASE_MS * n),
556
+ reason: message ? `${reason}: ${String(message).slice(0, 120)}` : reason,
557
+ };
505
558
  }
506
559
 
507
560
  // Idempotent per task: resuming a task whose download failed must not count it twice.
@@ -516,7 +569,7 @@ function recordSuccess(file, entry, t, taskId, emit) {
516
569
  // task may have billed; returns {fail} only when the task ended without output for a known reason.
517
570
  async function finishWanTask(ctx, e, taskId, parameters) {
518
571
  const p = await wanPollTask({
519
- entry: e, taskId, intervalMs: ctx.pollIntervalMs, timeoutMs: ctx.pollTimeoutMs, sleep: ctx.sleep,
572
+ entry: e, taskId, intervalMs: ctx.pollIntervalMs, timeoutMs: ctx.pollTimeoutMs, sleep: ctx.sleep, now: ctx.now,
520
573
  onTick: (s, i) => ctx.emit({ type: 'poll', provider: 'wan', label: e.label, taskId, status: s, tick: i }),
521
574
  });
522
575
  if (p.state === 'SUCCEEDED') return { clip: await succeededClip(ctx, e, taskId, p, parameters.duration) };
@@ -536,10 +589,10 @@ function failedTask(e, taskId, p) {
536
589
  // The poll could not tell how the task ended: keep the taskId so nothing is submitted twice.
537
590
  function lostTask(ctx, e, taskId, p, seconds) {
538
591
  if (p.state === 'TIMEOUT') {
539
- return stopError('timeout', `WAN task ${taskId} (${e.label}) still running after ${ctx.pollTimeoutMs} ms; resume with resumeClip`, { provider: 'wan', taskId, label: e.label, seconds });
592
+ return stopError('timeout', `WAN task ${taskId} (${e.label}) still running after polling for ${p.elapsedMs} ms; resume with resumeClip`, { provider: 'wan', taskId, label: e.label, seconds });
540
593
  }
541
594
  if (p.state === 'HTTP') {
542
- if (p.status === 401) applyFailure(ctx.statePath, e, 'unusable', { code: p.output.code, status: p.status }, ctx.now(), ctx.creds, ctx.emit);
595
+ if (p.status === 401) applyFailure(ctx.statePath, e, classifyWanError({ status: 401, code: p.output.code }), { code: p.output.code, status: p.status }, ctx.now(), ctx.creds, ctx.emit);
543
596
  return stopError('task_lost', `WAN poll for task ${taskId} (${e.label}) failed: HTTP ${p.status} ${p.output.code || ''}; check the Model Studio console before resubmitting`, { provider: 'wan', taskId, label: e.label, status: p.status, code: p.output.code });
544
597
  }
545
598
  return stopError('task_lost', `WAN task ${taskId} (${e.label}) reports UNKNOWN (expired or not found); check the console before resubmitting`, { provider: 'wan', taskId, label: e.label });
@@ -582,20 +635,37 @@ async function runWan(ctx) {
582
635
  return poolUnavailable(ctx, `gave up after ${attempts} attempts`);
583
636
  }
584
637
 
585
- // The usable part of the WAN pool. Malformed entries are skipped with an event. When every configured
586
- // entry is malformed the leg stops: a config error must not turn into a paid Sora clip.
638
+ // The usable part of the WAN pool. Malformed entries, and those whose key an env proxy would carry, are
639
+ // skipped with an event. When every configured entry is one of these the leg stops: a config error must
640
+ // not turn into a paid Sora clip.
587
641
  function usablePool(ctx) {
588
642
  const all = loadWanPool(ctx.creds);
589
- const bad = all.filter(malformedField);
590
- for (const b of bad) ctx.emit({ type: 'skip', provider: 'wan', label: b.label, reason: `${malformedField(b)} is malformed (not an https URL / not one model id)` });
591
- if (all.length && bad.length === all.length) {
592
- throw stopError('invalid_request', `every WAN entry is malformed (${bad.map((b) => `${b.label} ${malformedField(b)}`).join(', ')}); fix the env file — nothing was sent, and Sora was not used`, { provider: 'wan' });
593
- }
594
- const pool = all.filter((e) => !malformedField(e));
643
+ const refusals = all.map((e) => [e, entryRefusal(e)]).filter(([, r]) => r);
644
+ for (const [e, r] of refusals) ctx.emit({ type: 'skip', provider: 'wan', label: e.label, reason: r.reason });
645
+ if (all.length && refusals.length === all.length) throw everyEntryRefused(refusals);
646
+ const refused = new Set(refusals.map(([e]) => e));
647
+ const pool = all.filter((e) => !refused.has(e));
595
648
  if (!pool.length) ctx.emit({ type: 'skip', provider: 'wan', reason: 'no usable WAN_<n>_ENDPOINT + WAN_<n>_API_KEY entries' });
596
649
  return pool;
597
650
  }
598
651
 
652
+ // fetchGuarded would hold the key back from this origin (EPROXYKEY): http that an env proxy carries.
653
+ const proxyHolds = (origin) => Boolean(origin) && keyHeldBack(new URL(origin));
654
+ const HELD_BACK = 'the endpoint is http and the environment sends http through a proxy, so the key is held back (EPROXYKEY); name the host in NO_PROXY or use https';
655
+
656
+ // Why an entry cannot take a leg here, or null: a malformed field, or a key the env proxy would carry.
657
+ function entryRefusal(e) {
658
+ const field = malformedField(e);
659
+ if (field) return { tag: field, reason: `${field} is malformed (not an https URL / not one model id)` };
660
+ return proxyHolds(e.endpoint) ? { tag: 'EPROXYKEY', reason: HELD_BACK } : null;
661
+ }
662
+
663
+ function everyEntryRefused(refusals) {
664
+ const held = refusals.some(([, r]) => r.tag === 'EPROXYKEY');
665
+ const list = refusals.map(([e, r]) => `${e.label} ${r.tag}`).join(', ');
666
+ return stopError('invalid_request', `every WAN entry is ${held ? 'malformed or held back' : 'malformed'} (${list}); fix the env file${held ? ' or the proxy setting' : ''} — nothing was sent, and Sora was not used`, { provider: 'wan' });
667
+ }
668
+
599
669
  // Wait when a key frees up soon: an in-flight slot always, a cooldown only within maxRateWaitMs.
600
670
  async function waitForKey(ctx, pick) {
601
671
  if (!pick.busy && (pick.waitMs == null || pick.waitMs > ctx.maxRateWaitMs)) return false;
@@ -630,10 +700,11 @@ async function attemptOnKey(ctx, e, parameters, attempt) {
630
700
  // server, and a second key would bill the same leg twice.
631
701
  function assertSubmitSettled(e, c) {
632
702
  if (c.status === 0 && !c.preSend) {
633
- throw stopError('submit_unknown', `WAN submit to ${e.label} lost its connection (${c.message || 'network error'}); the task may exist — check the Model Studio console before resubmitting`, { provider: 'wan', label: e.label });
703
+ throw stopError('submit_unknown', `WAN submit to ${e.label} got no usable answer (${c.message || 'network error'}); the task may exist — check the Model Studio console before resubmitting`, { provider: 'wan', label: e.label });
634
704
  }
635
- if (!c.taskId && (c.ok || (c.status >= 500 && !c.code))) {
636
- // a 2xx without task_id or a bare gateway 5xx: the backend may have accepted the task
705
+ if (!c.taskId && (c.ok || (c.status >= 500 && !codeKind(c.code)))) {
706
+ // a 2xx without task_id, or a 5xx without a code the pool knows (a bare gateway 5xx, a new
707
+ // server code): the backend may have accepted the task
637
708
  throw stopError('submit_unknown', `WAN submit to ${e.label} got an ambiguous answer (HTTP ${c.status}, no task_id); the task may exist — check the Model Studio console before resubmitting`, { provider: 'wan', label: e.label, status: c.status });
638
709
  }
639
710
  }
@@ -660,10 +731,11 @@ function soraConn(creds) {
660
731
  // Poll an accepted Sora job to a downloaded clip; same stop rules as finishWanTask.
661
732
  async function finishSoraJob(ctx, jobId, secs) {
662
733
  const conn = soraConn(ctx.creds);
734
+ const t0 = ctx.now();
663
735
  const st = await pollSoraJob(ctx, conn, jobId);
664
736
  if (st && SORA_FAILED.has(st.status)) throw soraFailure(st, jobId);
665
737
  if (!st || !SORA_DONE.has(st.status)) {
666
- throw stopError('timeout', `Sora job ${jobId} still ${st?.status ?? 'unknown'} after ${ctx.pollTimeoutMs} ms; resume with resumeClip`, { provider: 'sora', taskId: jobId, label: 'AZURE_SORA', seconds: secs });
738
+ throw stopError('timeout', `Sora job ${jobId} still ${st?.status ?? 'unknown'} after polling for ${ctx.now() - t0} ms; resume with resumeClip`, { provider: 'sora', taskId: jobId, label: 'AZURE_SORA', seconds: secs });
667
739
  }
668
740
  const mp4 = await downloadSoraClip(conn, jobId, secs);
669
741
  ctx.emit({ type: 'done', provider: 'sora', label: 'AZURE_SORA', taskId: jobId });
@@ -676,8 +748,8 @@ async function pollSoraJob(ctx, conn, jobId) {
676
748
  const interval = Math.max(0, ctx.pollIntervalMs);
677
749
  try {
678
750
  return await pollVideo({
679
- endpoint: conn.endpoint, key: conn.key, id: jobId, apiVersion: conn.apiVersion, sleep: ctx.sleep,
680
- intervalMs: interval, maxTicks: Math.max(1, Math.ceil(ctx.pollTimeoutMs / Math.max(1, interval))),
751
+ endpoint: conn.endpoint, key: conn.key, id: jobId, apiVersion: conn.apiVersion, sleep: ctx.sleep, now: ctx.now,
752
+ intervalMs: interval, maxTicks: Math.max(1, Math.ceil(ctx.pollTimeoutMs / Math.max(1, interval))), timeoutMs: ctx.pollTimeoutMs,
681
753
  onTick: (s, i) => ctx.emit({ type: 'poll', provider: 'sora', taskId: jobId, status: s?.status, tick: i }),
682
754
  });
683
755
  } catch (e) {
@@ -701,34 +773,58 @@ async function downloadSoraClip(conn, jobId, secs) {
701
773
  }
702
774
 
703
775
  async function runSora(ctx) {
704
- const { creds, prompt, firstFramePng, lastFramePng, size, seconds, emit, sleep } = ctx;
705
- const { endpoint, key, apiVersion, model } = soraConn(creds);
706
- const secs = snapSoraSeconds(seconds);
707
- if (lastFramePng) emit({ type: 'note', provider: 'sora', message: 'Sora takes one input_reference; the last frame is ignored (forward-chaining)' });
708
- let job;
776
+ const conn = soraConn(ctx.creds);
777
+ const secs = snapSoraSeconds(ctx.seconds);
778
+ if (ctx.lastFramePng) ctx.emit({ type: 'note', provider: 'sora', message: 'Sora takes one input_reference; the last frame is ignored (forward-chaining)' });
779
+ const job = await submitSora(ctx, conn, secs);
780
+ return finishSoraJob(ctx, job.id, secs);
781
+ }
782
+
783
+ const SORA_SUBMIT_TRIES = 5;
784
+
785
+ // A 429 is a refusal, so retrying it cannot double-bill; a lost connection or a bare 5xx can. Each
786
+ // wait is the server's Retry-After, else 30 s doubling, from 1 s to 10 min. A Retry-After longer
787
+ // than maxRateWaitMs is not waited out: the leg stops as `rate` at once, as WAN's waits do.
788
+ async function submitSora(ctx, conn, secs) {
709
789
  for (let i = 0; ; i++) {
710
790
  try {
711
- emit({ type: 'submit', provider: 'sora', label: 'AZURE_SORA', model, attempt: i + 1 });
712
- job = await createVideo({ endpoint, key, model, prompt, size, seconds: secs, inputReferencePng: firstFramePng, apiVersion });
713
- break;
791
+ ctx.emit({ type: 'submit', provider: 'sora', label: 'AZURE_SORA', model: conn.model, attempt: i + 1 });
792
+ return await createVideo({
793
+ endpoint: conn.endpoint, key: conn.key, model: conn.model, prompt: ctx.prompt, size: ctx.size, seconds: secs, inputReferencePng: ctx.firstFramePng, apiVersion: conn.apiVersion,
794
+ });
714
795
  } catch (e) {
715
- // a 429 is a refusal, so retrying it cannot double-bill; a lost connection or a bare 5xx can
716
- if (e.status === 429 && i < 4) {
717
- const ms = Math.min(RATE_MAX_MS, RATE_BASE_MS * 2 ** i);
718
- emit({ type: 'wait', provider: 'sora', ms, reason: 'HTTP 429' });
719
- await sleep(ms);
720
- continue;
721
- }
722
- if (/moderation|content_policy|safety/i.test(e.message)) throw new ContentRejectedError('sora', 'moderation', e.message);
723
- if ((e.status === 0 && !PRE_SEND_ERRORS.has(e.errno)) || e.status >= 500 || (e.status >= 200 && e.status < 300)) {
724
- throw stopError('submit_unknown', `Sora submit got no usable answer (${e.status === 0 ? e.errno || 'network' : `HTTP ${e.status}`}); the job may exist — check Azure before resubmitting`, { provider: 'sora', status: e.status });
725
- }
726
- throw stopError(e.status === 0 ? 'unreachable' : 'invalid_request', e.message, { provider: 'sora', status: e.status });
796
+ if (e.status !== 429 || i >= SORA_SUBMIT_TRIES - 1) throw soraSubmitError(e);
797
+ const want = e.retryAfterMs ?? RATE_BASE_MS * 2 ** i;
798
+ if (want > ctx.maxRateWaitMs) throw soraRateStop(want, ctx.maxRateWaitMs, e.retryAfterMs != null);
799
+ const ms = Math.min(RATE_MAX_MS, Math.max(RETRY_MIN_MS, want));
800
+ ctx.emit({ type: 'wait', provider: 'sora', ms, reason: 'HTTP 429' });
801
+ await ctx.sleep(ms);
727
802
  }
728
803
  }
729
- return finishSoraJob(ctx, job.id, secs);
730
804
  }
731
805
 
806
+ // The stop for a Sora 429 that asks for a longer wait than the caller allows; no job was made.
807
+ function soraRateStop(waitMs, maxRateWaitMs, asked) {
808
+ const wait = asked ? `with a Retry-After of ${Math.ceil(waitMs / 1000)} s` : `again, and its next wait (${Math.ceil(waitMs / 1000)} s) is`;
809
+ return stopError('rate', `Sora answered HTTP 429 ${wait}, past maxRateWaitMs (${maxRateWaitMs} ms); no job was made; wait, then generate the leg again`, { provider: 'sora', status: 429, retryAfterMs: asked ? waitMs : null });
810
+ }
811
+
812
+ // The stop for a Sora submit that made no job, or may have made one.
813
+ function soraSubmitError(e) {
814
+ if (/moderation|content_policy|safety/i.test(e.message)) return new ContentRejectedError('sora', 'moderation', e.message);
815
+ if (e.status === 429) {
816
+ return stopError('rate', `Sora still answered HTTP 429 after ${SORA_SUBMIT_TRIES} tries; no job was made — wait, then generate the leg again`, { provider: 'sora', status: 429 });
817
+ }
818
+ if (soraMayHaveJob(e)) {
819
+ const answer = e.status === 0 ? e.errno || 'network' : `HTTP ${e.status}`;
820
+ return stopError('submit_unknown', `Sora submit got no usable answer (${answer}); the job may exist — check Azure before resubmitting`, { provider: 'sora', status: e.status });
821
+ }
822
+ return stopError(e.status === 0 ? 'unreachable' : 'invalid_request', e.message, { provider: 'sora', status: e.status });
823
+ }
824
+
825
+ // A lost connection, a 5xx, or a 2xx without an id: the request may have reached Sora.
826
+ const soraMayHaveJob = (e) => (e.status === 0 && !PRE_SEND_ERRORS.has(e.errno)) || e.status >= 500 || (e.status >= 200 && e.status < 300);
827
+
732
828
  // Explicit `undefined`/`null` options fall back to the defaults instead of overriding them.
733
829
  const defined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v != null));
734
830
 
@@ -746,35 +842,47 @@ const DEFAULTS = { size: '720x1280', seconds: 5, pollIntervalMs: 15_000, pollTim
746
842
  *
747
843
  * Throws (never resubmitting the leg) when retrying could pay twice or cannot help:
748
844
  * ContentRejectedError moderation — change the prompt/frame
749
- * kind 'invalid_request' malformed request, ModelNotFound, every WAN entry malformed or a
750
- * Sora endpoint that is not https (config)
845
+ * kind 'invalid_request' malformed request, ModelNotFound, every WAN entry malformed, a
846
+ * Sora endpoint that is not https, or creds from a project key file
847
+ * git tracks (config; nothing is sent)
751
848
  * kind 'timeout' | 'download' the task exists — call resumeClip({ ..., provider, label, taskId })
752
849
  * kind 'submit_unknown' | 'task_lost' | 'no_video' | 'failed' outcome unknown — check the console
753
850
  * ('failed': the task ended for a reason the pool does not know)
754
851
  * kind 'unreachable' Sora host refused the connection (nothing was sent)
852
+ * kind 'rate' Sora kept answering 429, or asked (Retry-After, err.retryAfterMs) for a
853
+ * wait past maxRateWaitMs (no job was made); wait, then call again
755
854
  * @returns {Promise<{mp4:Buffer, provider:'wan'|'sora', label:string, model:string, seconds:number, taskId:string, usage:object|null}>}
756
855
  */
757
856
  export async function generateClip(opts = {}) {
758
857
  const o = { ...DEFAULTS, statePath: defaultStatePath(), sleep: realSleep, now: Date.now, ...defined(opts) };
759
858
  if (!o.creds) throw new Error('generateClip: creds required (resolveCreds())');
859
+ // a project key file git tracks may have come with the repository: its endpoint would get the prompt and the frames
860
+ if (o.creds._tracked) {
861
+ throw stopError('invalid_request', `generateClip: the project key file is tracked by git (${o.creds._source}); untrack it (git rm --cached) or use the home key file`);
862
+ }
760
863
  if (!o.prompt) throw new Error('generateClip: prompt required');
761
864
  const ctx = makeCtx(o);
762
865
  const tried = [];
763
866
  for (const p of o.order || parseProviderOrder(o.creds.VIDEO_PROVIDER_ORDER)) {
764
- if (p === 'wan') {
765
- tried.push('wan');
766
- const r = await runWan(ctx);
767
- if (r) return r;
768
- } else if (p === 'sora') {
769
- if (!soraReady(ctx)) continue;
770
- tried.push('sora');
771
- ctx.emit({ type: 'fallback', provider: 'sora', from: tried.slice(0, -1) });
772
- return runSora(ctx);
773
- }
867
+ const clip = await tryProvider(ctx, p, tried);
868
+ if (clip) return clip;
774
869
  }
775
870
  throw new Error(`generateClip: no video provider available (tried: ${tried.join(', ') || 'none'})`);
776
871
  }
777
872
 
873
+ // One provider's go at the leg: the clip, or null to go on to the next. Sora, once it runs, ends the
874
+ // leg (with a clip or a throw).
875
+ async function tryProvider(ctx, p, tried) {
876
+ if (p === 'wan') {
877
+ tried.push('wan');
878
+ return runWan(ctx);
879
+ }
880
+ if (p !== 'sora' || !soraReady(ctx)) return null;
881
+ tried.push('sora');
882
+ ctx.emit({ type: 'fallback', provider: 'sora', from: tried.slice(0, -1) });
883
+ return runSora(ctx);
884
+ }
885
+
778
886
  // Whether Sora can take the leg. Not set up: skipped with an event. An endpoint that is set but not
779
887
  // https (http only to this machine) stops the leg before the key is sent in the clear.
780
888
  function soraReady(ctx) {
@@ -784,6 +892,7 @@ function soraReady(ctx) {
784
892
  return false;
785
893
  }
786
894
  if (!originOf(endpoint)) throw stopError('invalid_request', 'AZURE_SORA_ENDPOINT is not an https URL; fix the env file — nothing was sent', { provider: 'sora' });
895
+ if (proxyHolds(originOf(endpoint))) throw stopError('invalid_request', `AZURE_SORA_ENDPOINT: ${HELD_BACK} — nothing was sent`, { provider: 'sora' });
787
896
  return true;
788
897
  }
789
898
 
@@ -802,6 +911,9 @@ export async function resumeClip(opts = {}) {
802
911
  if (!originOf(o.creds.AZURE_SORA_ENDPOINT) || !o.creds.AZURE_SORA_API_KEY) {
803
912
  throw stopError('invalid_request', 'resumeClip: resuming a Sora job needs AZURE_SORA_ENDPOINT as an https URL and AZURE_SORA_API_KEY (run 3d-intro-setup); nothing was sent', { provider: 'sora', taskId: o.taskId });
804
913
  }
914
+ if (proxyHolds(originOf(o.creds.AZURE_SORA_ENDPOINT))) {
915
+ throw stopError('invalid_request', `resumeClip: AZURE_SORA_ENDPOINT: ${HELD_BACK} — nothing was sent`, { provider: 'sora', taskId: o.taskId });
916
+ }
805
917
  return finishSoraJob(ctx, o.taskId, o.seconds == null ? null : snapSoraSeconds(o.seconds));
806
918
  }
807
919
 
@@ -811,6 +923,9 @@ async function resumeWan(ctx, o) {
811
923
  if (e.badEndpoint) {
812
924
  throw stopError('invalid_request', `resumeClip: ${o.label} endpoint is not an https URL; fix the env file — nothing was sent`, { provider: 'wan', label: o.label, taskId: o.taskId });
813
925
  }
926
+ if (proxyHolds(e.endpoint)) {
927
+ throw stopError('invalid_request', `resumeClip: ${o.label}: ${HELD_BACK} — nothing was sent`, { provider: 'wan', label: o.label, taskId: o.taskId });
928
+ }
814
929
  const out = await finishWanTask(ctx, e, o.taskId, { duration: Math.round(Number(o.seconds) || 0) || null });
815
930
  if (out.clip) return out.clip;
816
931
  throw stopError('failed', `WAN task ${o.taskId} (${o.label}) ended ${out.fail.code}: ${out.fail.message || ''}; it produced no clip — generate the leg again`, { provider: 'wan', taskId: o.taskId, label: o.label, code: out.fail.code });