@copilotkit/aimock 1.40.0 → 1.42.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 (251) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +28 -1
  4. package/README.md +5 -3
  5. package/dist/a2a-mock.cjs +18 -11
  6. package/dist/a2a-mock.cjs.map +1 -1
  7. package/dist/a2a-mock.d.cts +1 -0
  8. package/dist/a2a-mock.d.cts.map +1 -1
  9. package/dist/a2a-mock.d.ts +1 -0
  10. package/dist/a2a-mock.d.ts.map +1 -1
  11. package/dist/a2a-mock.js +18 -11
  12. package/dist/a2a-mock.js.map +1 -1
  13. package/dist/agui-handler.d.cts.map +1 -1
  14. package/dist/agui-handler.d.ts.map +1 -1
  15. package/dist/agui-mock.cjs +2 -0
  16. package/dist/agui-mock.cjs.map +1 -1
  17. package/dist/agui-mock.d.cts.map +1 -1
  18. package/dist/agui-mock.d.ts.map +1 -1
  19. package/dist/agui-mock.js +2 -0
  20. package/dist/agui-mock.js.map +1 -1
  21. package/dist/bedrock-converse.cjs +36 -2
  22. package/dist/bedrock-converse.cjs.map +1 -1
  23. package/dist/bedrock-converse.d.cts.map +1 -1
  24. package/dist/bedrock-converse.d.ts.map +1 -1
  25. package/dist/bedrock-converse.js +37 -3
  26. package/dist/bedrock-converse.js.map +1 -1
  27. package/dist/bedrock.cjs +50 -13
  28. package/dist/bedrock.cjs.map +1 -1
  29. package/dist/bedrock.d.cts.map +1 -1
  30. package/dist/bedrock.d.ts.map +1 -1
  31. package/dist/bedrock.js +51 -14
  32. package/dist/bedrock.js.map +1 -1
  33. package/dist/byteplus-video.cjs +954 -0
  34. package/dist/byteplus-video.cjs.map +1 -0
  35. package/dist/byteplus-video.d.cts +176 -0
  36. package/dist/byteplus-video.d.cts.map +1 -0
  37. package/dist/byteplus-video.d.ts +176 -0
  38. package/dist/byteplus-video.d.ts.map +1 -0
  39. package/dist/byteplus-video.js +947 -0
  40. package/dist/byteplus-video.js.map +1 -0
  41. package/dist/chaos.cjs +36 -7
  42. package/dist/chaos.cjs.map +1 -1
  43. package/dist/chaos.d.cts +8 -3
  44. package/dist/chaos.d.cts.map +1 -1
  45. package/dist/chaos.d.ts +8 -3
  46. package/dist/chaos.d.ts.map +1 -1
  47. package/dist/chaos.js +36 -8
  48. package/dist/chaos.js.map +1 -1
  49. package/dist/cli.cjs +5 -1
  50. package/dist/cli.cjs.map +1 -1
  51. package/dist/cli.js +5 -1
  52. package/dist/cli.js.map +1 -1
  53. package/dist/cohere.cjs +58 -5
  54. package/dist/cohere.cjs.map +1 -1
  55. package/dist/cohere.d.cts.map +1 -1
  56. package/dist/cohere.d.ts.map +1 -1
  57. package/dist/cohere.js +59 -6
  58. package/dist/cohere.js.map +1 -1
  59. package/dist/config-loader.d.cts.map +1 -1
  60. package/dist/elevenlabs-audio.cjs +36 -2
  61. package/dist/elevenlabs-audio.cjs.map +1 -1
  62. package/dist/elevenlabs-audio.d.cts.map +1 -1
  63. package/dist/elevenlabs-audio.d.ts.map +1 -1
  64. package/dist/elevenlabs-audio.js +37 -3
  65. package/dist/elevenlabs-audio.js.map +1 -1
  66. package/dist/embeddings.cjs +58 -3
  67. package/dist/embeddings.cjs.map +1 -1
  68. package/dist/embeddings.d.cts.map +1 -1
  69. package/dist/embeddings.d.ts.map +1 -1
  70. package/dist/embeddings.js +59 -4
  71. package/dist/embeddings.js.map +1 -1
  72. package/dist/fal-audio.cjs +38 -2
  73. package/dist/fal-audio.cjs.map +1 -1
  74. package/dist/fal-audio.d.cts.map +1 -1
  75. package/dist/fal-audio.d.ts.map +1 -1
  76. package/dist/fal-audio.js +39 -3
  77. package/dist/fal-audio.js.map +1 -1
  78. package/dist/fixture-loader.cjs +0 -0
  79. package/dist/fixture-loader.cjs.map +1 -1
  80. package/dist/fixture-loader.d.cts +9 -1
  81. package/dist/fixture-loader.d.cts.map +1 -1
  82. package/dist/fixture-loader.d.ts +9 -1
  83. package/dist/fixture-loader.d.ts.map +1 -1
  84. package/dist/fixture-loader.js +0 -0
  85. package/dist/fixture-loader.js.map +1 -1
  86. package/dist/gemini-embeddings.cjs +19 -1
  87. package/dist/gemini-embeddings.cjs.map +1 -1
  88. package/dist/gemini-embeddings.js +20 -2
  89. package/dist/gemini-embeddings.js.map +1 -1
  90. package/dist/gemini-interactions.cjs +15 -1
  91. package/dist/gemini-interactions.cjs.map +1 -1
  92. package/dist/gemini-interactions.d.cts.map +1 -1
  93. package/dist/gemini-interactions.d.ts.map +1 -1
  94. package/dist/gemini-interactions.js +16 -2
  95. package/dist/gemini-interactions.js.map +1 -1
  96. package/dist/gemini.cjs +70 -6
  97. package/dist/gemini.cjs.map +1 -1
  98. package/dist/gemini.d.cts.map +1 -1
  99. package/dist/gemini.d.ts.map +1 -1
  100. package/dist/gemini.js +71 -7
  101. package/dist/gemini.js.map +1 -1
  102. package/dist/grok-video.cjs +2 -2
  103. package/dist/grok-video.cjs.map +1 -1
  104. package/dist/grok-video.d.cts.map +1 -1
  105. package/dist/grok-video.d.ts.map +1 -1
  106. package/dist/grok-video.js +2 -2
  107. package/dist/grok-video.js.map +1 -1
  108. package/dist/helpers.cjs +160 -5
  109. package/dist/helpers.cjs.map +1 -1
  110. package/dist/helpers.d.cts.map +1 -1
  111. package/dist/helpers.d.ts.map +1 -1
  112. package/dist/helpers.js +152 -6
  113. package/dist/helpers.js.map +1 -1
  114. package/dist/images.cjs +24 -6
  115. package/dist/images.cjs.map +1 -1
  116. package/dist/images.d.cts +11 -3
  117. package/dist/images.d.cts.map +1 -1
  118. package/dist/images.d.ts +11 -3
  119. package/dist/images.d.ts.map +1 -1
  120. package/dist/images.js +25 -7
  121. package/dist/images.js.map +1 -1
  122. package/dist/index.cjs +7 -0
  123. package/dist/index.d.cts +2 -1
  124. package/dist/index.d.ts +2 -1
  125. package/dist/index.js +2 -1
  126. package/dist/llmock.cjs +15 -0
  127. package/dist/llmock.cjs.map +1 -1
  128. package/dist/llmock.d.cts +12 -0
  129. package/dist/llmock.d.cts.map +1 -1
  130. package/dist/llmock.d.ts +12 -0
  131. package/dist/llmock.d.ts.map +1 -1
  132. package/dist/llmock.js +16 -1
  133. package/dist/llmock.js.map +1 -1
  134. package/dist/mcp-mock.cjs +11 -0
  135. package/dist/mcp-mock.cjs.map +1 -1
  136. package/dist/mcp-mock.d.cts.map +1 -1
  137. package/dist/mcp-mock.d.ts.map +1 -1
  138. package/dist/mcp-mock.js +11 -0
  139. package/dist/mcp-mock.js.map +1 -1
  140. package/dist/messages.cjs +18 -1
  141. package/dist/messages.cjs.map +1 -1
  142. package/dist/messages.d.cts.map +1 -1
  143. package/dist/messages.d.ts.map +1 -1
  144. package/dist/messages.js +19 -2
  145. package/dist/messages.js.map +1 -1
  146. package/dist/metrics.cjs +20 -0
  147. package/dist/metrics.cjs.map +1 -1
  148. package/dist/metrics.d.cts.map +1 -1
  149. package/dist/metrics.d.ts.map +1 -1
  150. package/dist/metrics.js +19 -1
  151. package/dist/metrics.js.map +1 -1
  152. package/dist/moderation.cjs +67 -3
  153. package/dist/moderation.cjs.map +1 -1
  154. package/dist/moderation.d.cts +1 -0
  155. package/dist/moderation.d.cts.map +1 -1
  156. package/dist/moderation.d.ts +1 -0
  157. package/dist/moderation.d.ts.map +1 -1
  158. package/dist/moderation.js +68 -4
  159. package/dist/moderation.js.map +1 -1
  160. package/dist/ollama.cjs +75 -5
  161. package/dist/ollama.cjs.map +1 -1
  162. package/dist/ollama.d.cts.map +1 -1
  163. package/dist/ollama.d.ts.map +1 -1
  164. package/dist/ollama.js +76 -6
  165. package/dist/ollama.js.map +1 -1
  166. package/dist/openrouter-video.cjs +4 -4
  167. package/dist/openrouter-video.cjs.map +1 -1
  168. package/dist/openrouter-video.d.cts.map +1 -1
  169. package/dist/openrouter-video.d.ts.map +1 -1
  170. package/dist/openrouter-video.js +4 -4
  171. package/dist/openrouter-video.js.map +1 -1
  172. package/dist/provider-auth.cjs +2 -0
  173. package/dist/provider-auth.cjs.map +1 -1
  174. package/dist/provider-auth.js +2 -0
  175. package/dist/provider-auth.js.map +1 -1
  176. package/dist/rerank.cjs +65 -2
  177. package/dist/rerank.cjs.map +1 -1
  178. package/dist/rerank.d.cts +1 -1
  179. package/dist/rerank.d.cts.map +1 -1
  180. package/dist/rerank.d.ts +1 -1
  181. package/dist/rerank.d.ts.map +1 -1
  182. package/dist/rerank.js +66 -3
  183. package/dist/rerank.js.map +1 -1
  184. package/dist/responses.cjs +18 -1
  185. package/dist/responses.cjs.map +1 -1
  186. package/dist/responses.d.cts.map +1 -1
  187. package/dist/responses.d.ts.map +1 -1
  188. package/dist/responses.js +19 -2
  189. package/dist/responses.js.map +1 -1
  190. package/dist/router.cjs +1 -0
  191. package/dist/router.cjs.map +1 -1
  192. package/dist/router.d.cts.map +1 -1
  193. package/dist/router.d.ts.map +1 -1
  194. package/dist/router.js +1 -0
  195. package/dist/router.js.map +1 -1
  196. package/dist/search.cjs +67 -3
  197. package/dist/search.cjs.map +1 -1
  198. package/dist/search.d.cts +1 -0
  199. package/dist/search.d.cts.map +1 -1
  200. package/dist/search.d.ts +1 -0
  201. package/dist/search.d.ts.map +1 -1
  202. package/dist/search.js +68 -4
  203. package/dist/search.js.map +1 -1
  204. package/dist/server.cjs +406 -17
  205. package/dist/server.cjs.map +1 -1
  206. package/dist/server.d.cts +2 -0
  207. package/dist/server.d.cts.map +1 -1
  208. package/dist/server.d.ts +2 -0
  209. package/dist/server.d.ts.map +1 -1
  210. package/dist/server.js +410 -21
  211. package/dist/server.js.map +1 -1
  212. package/dist/speech.cjs +18 -1
  213. package/dist/speech.cjs.map +1 -1
  214. package/dist/speech.d.cts.map +1 -1
  215. package/dist/speech.d.ts.map +1 -1
  216. package/dist/speech.js +19 -2
  217. package/dist/speech.js.map +1 -1
  218. package/dist/stream-collapse.cjs +2 -1
  219. package/dist/stream-collapse.cjs.map +1 -1
  220. package/dist/stream-collapse.js +2 -1
  221. package/dist/stream-collapse.js.map +1 -1
  222. package/dist/transcription.cjs +21 -2
  223. package/dist/transcription.cjs.map +1 -1
  224. package/dist/transcription.d.cts.map +1 -1
  225. package/dist/transcription.d.ts.map +1 -1
  226. package/dist/transcription.js +21 -2
  227. package/dist/transcription.js.map +1 -1
  228. package/dist/types.d.cts +58 -5
  229. package/dist/types.d.cts.map +1 -1
  230. package/dist/types.d.ts +58 -5
  231. package/dist/types.d.ts.map +1 -1
  232. package/dist/vector-mock.cjs +88 -0
  233. package/dist/vector-mock.cjs.map +1 -1
  234. package/dist/vector-mock.d.cts.map +1 -1
  235. package/dist/vector-mock.d.ts.map +1 -1
  236. package/dist/vector-mock.js +89 -1
  237. package/dist/vector-mock.js.map +1 -1
  238. package/dist/vector-types.d.cts.map +1 -1
  239. package/dist/veo-video.cjs +2 -2
  240. package/dist/veo-video.cjs.map +1 -1
  241. package/dist/veo-video.d.cts.map +1 -1
  242. package/dist/veo-video.d.ts.map +1 -1
  243. package/dist/veo-video.js +2 -2
  244. package/dist/veo-video.js.map +1 -1
  245. package/dist/video.cjs +19 -2
  246. package/dist/video.cjs.map +1 -1
  247. package/dist/video.d.cts.map +1 -1
  248. package/dist/video.d.ts.map +1 -1
  249. package/dist/video.js +20 -3
  250. package/dist/video.js.map +1 -1
  251. package/package.json +2 -2
@@ -0,0 +1,947 @@
1
+ import { flattenHeaders, getContext, getTestId, isErrorResponse, isJSONResponse, resolveResponse, resolveStrictMode, serializeErrorResponse, strictNoMatchLogLine, strictNoMatchMessage, strictOverrideField } from "./helpers.js";
2
+ import { matchFixtureDiagnostic } from "./router.js";
3
+ import { writeErrorResponse } from "./sse-writer.js";
4
+ import { applyChaos } from "./chaos.js";
5
+ import { resolveUpstreamUrl } from "./url.js";
6
+ import { buildFixtureMatch, persistFixture, prepareEgressHeaders, sanitizeHeaderValue } from "./recorder.js";
7
+ import { resolveProgression } from "./fal.js";
8
+ import { readEnvelopeText, upstreamTimeoutSignal } from "./video-proxy-shared.js";
9
+ import crypto from "node:crypto";
10
+
11
+ //#region src/byteplus-video.ts
12
+ /**
13
+ * BytePlus Ark (ModelArk / Seedance) async video task lifecycle mock. Submit
14
+ * `POST /api/v3/contents/generations/tasks` returns `{ id }`; status
15
+ * `GET /api/v3/contents/generations/tasks/{id}` polls
16
+ * `queued → running → <recorded terminal status>`.
17
+ *
18
+ * THE FIXTURE FORMAT IS THE ARK TASK ENVELOPE ITSELF, stored as a
19
+ * `RawJSONResponse` (`{ response: { json: … } }`) — NOT a `VideoResponse`. That
20
+ * is the central decision of this surface and it is what lets `cancelled` and
21
+ * `expired` (terminal states with no representation in `VideoResponse.status`)
22
+ * record and replay natively, lets a recorded `error` object replay without
23
+ * inventing a code, and lets `usage`, `framespersecond`, `draft_task_id` and any
24
+ * field the vendor adds later survive because they were recorded rather than
25
+ * enumerated.
26
+ *
27
+ * PROVENANCE OF EVERY WIRE FACT IN THIS FILE. One source for the TASK shapes —
28
+ * create, poll, the `succeeded`/`failed` envelopes, the status tokens, the
29
+ * unknown-task 404: the client library `@tanstack/ai-byteplus@0.3.4` — its
30
+ * `wire-types.ts`, its request builders, and its author's note of live calls
31
+ * made on 2026-07-31. That is a SECONDARY source. BytePlus's own documentation
32
+ * is a client-rendered SPA and was never read. Exactly one layer is firsthand:
33
+ * the keyless canary in `src/__tests__/drift/byteplus-video.drift.ts` does call
34
+ * live Ark, and observed its AUTH-layer 401 error envelope on 2026-09-10 (see
35
+ * the `OBSERVED_*` constants there). No SUCCESS or resource-layer response from
36
+ * Ark has ever been observed here — the authenticated unknown-task canary is
37
+ * still `skipIf`-gated for want of an `ARK_API_KEY` in repo secrets. So "the
38
+ * client library's six status tokens" is a claim this repo can back, and "the
39
+ * vendor's documented six" is not — comments below say the former. Where a fact is not even secondhand, it is named as unverified
40
+ * rather than softened. A verification nobody performed is worse than an
41
+ * admitted gap: it stops the next person from looking.
42
+ *
43
+ * The governing rule: THE MOCK NEVER AUTHORS A WIRE VALUE IT DID NOT OBSERVE.
44
+ * On replay exactly three things are synthesized — the `id` rewrite, the
45
+ * `queued`/`running` token on a NON-terminal poll, and withholding
46
+ * `content`/`error`/`usage` until terminal. Nothing else. There is no default
47
+ * error code, no default error message, no fabricated `usage`, and no
48
+ * placeholder video url: a terminal fixture missing `content.video_url` gets a
49
+ * named warn, not a substituted URL, because the consumer's error naming the
50
+ * real problem beats a download of a URL aimock made up.
51
+ *
52
+ * `video.url` is served AS-IS — aimock does NOT proxy or capture video bytes.
53
+ * Ark's `content.video_url` expires 24h after `updated_at`; replay warns once
54
+ * when a fixture is served past that instant and still serves it.
55
+ *
56
+ * A fixture for this surface MUST carry `match.endpoint: "video"`. Without it
57
+ * `router.ts`'s endpoint/response compatibility gate skips it (a `{ json }`
58
+ * response is not a `VideoResponse`) before this handler ever sees it.
59
+ */
60
+ /**
61
+ * Ark's data plane lives under an `/api/v3` prefix ON THE ORIGIN, so
62
+ * `record.providers.byteplus` is configured as an ORIGIN ONLY
63
+ * (`https://ark.ap-southeast.bytepluses.com`) and this handler owns the whole
64
+ * path — the same split `openrouter-video.ts` uses with `OPENROUTER_VIDEOS_PATH`.
65
+ */
66
+ const BYTEPLUS_VIDEO_TASKS_PATH = "/api/v3/contents/generations/tasks";
67
+ /**
68
+ * The two NON-terminal task states, per `@tanstack/ai-byteplus@0.3.4`'s
69
+ * `wire-types.ts:38-44` — the client library's status union, not a BytePlus
70
+ * document. This is the primitive: "terminal" is derived as everything else,
71
+ * so a status token the client library does not list (whether BytePlus added
72
+ * it or the library simply never had it) records instead of proxying forever.
73
+ * See `isBytePlusTerminalStatus`.
74
+ */
75
+ const BYTEPLUS_NON_TERMINAL_STATUSES = new Set(["queued", "running"]);
76
+ /**
77
+ * The six task states the client library declares. Anything outside this set is
78
+ * a surprise — an authoring error, or a token the library does not cover.
79
+ */
80
+ const BYTEPLUS_TASK_STATUSES = new Set([
81
+ ...BYTEPLUS_NON_TERMINAL_STATUSES,
82
+ "succeeded",
83
+ "failed",
84
+ "cancelled",
85
+ "expired"
86
+ ]);
87
+ /**
88
+ * Terminal is the COMPLEMENT of the two non-terminal tokens, never a second
89
+ * hardcoded list of the four terminal ones. An unrecognized status is treated
90
+ * as terminal deliberately: a status absent from the client library's union is
91
+ * far more likely to be an end state than an intermediate one, and guessing wrong
92
+ * in that direction costs a captured fixture the operator can inspect, while
93
+ * guessing wrong the other way costs a record run that polls until the client
94
+ * times out and writes nothing.
95
+ */
96
+ function isBytePlusTerminalStatus(status) {
97
+ return !BYTEPLUS_NON_TERMINAL_STATUSES.has(status);
98
+ }
99
+ /** Ark output URLs expire 24h after the task produced them, anchored on `updated_at`. */
100
+ const BYTEPLUS_URL_TTL_SECONDS = 1440 * 60;
101
+ const BYTEPLUS_VIDEO_MAX_ENTRIES = 1e4;
102
+ const BYTEPLUS_VIDEO_TTL_MS = 36e5;
103
+ /**
104
+ * Warn latches keyed on the ENVELOPE OBJECT, not the job. A suite that replays
105
+ * one aged fixture across a hundred tests mints a hundred jobs off the SAME
106
+ * envelope object (the fixture's `response.json`), and a per-job latch would
107
+ * emit a hundred identical lines — noise, not signal. The envelope is the thing
108
+ * being complained about, so it owns the latch. WeakMap so a fixtures reset
109
+ * drops the latches with the fixtures.
110
+ */
111
+ const bytePlusWarnLatches = /* @__PURE__ */ new WeakMap();
112
+ function warnLatchesFor(envelope) {
113
+ const existing = bytePlusWarnLatches.get(envelope);
114
+ if (existing !== void 0) return existing;
115
+ const fresh = {};
116
+ bytePlusWarnLatches.set(envelope, fresh);
117
+ return fresh;
118
+ }
119
+ /**
120
+ * Per-testId job state. Mirrors GrokVideoJobMap / OpenRouterVideoJobMap: lazy
121
+ * TTL eviction on `get`, FIFO eviction of the oldest entries on `set` when over
122
+ * capacity, delete-before-set TTL refresh, monotonic world-generation counter
123
+ * for reset-mid-flight detection, no background sweep timer.
124
+ * Keys are `${testId}:${id}`.
125
+ */
126
+ var BytePlusVideoJobMap = class {
127
+ entries = /* @__PURE__ */ new Map();
128
+ worldGeneration = 0;
129
+ get generation() {
130
+ return this.worldGeneration;
131
+ }
132
+ get(key) {
133
+ const entry = this.entries.get(key);
134
+ if (!entry) return void 0;
135
+ if (Date.now() - entry.createdAt > BYTEPLUS_VIDEO_TTL_MS) {
136
+ this.entries.delete(key);
137
+ return;
138
+ }
139
+ return entry.job;
140
+ }
141
+ set(key, job) {
142
+ this.entries.delete(key);
143
+ this.entries.set(key, {
144
+ job,
145
+ createdAt: Date.now()
146
+ });
147
+ if (this.entries.size > BYTEPLUS_VIDEO_MAX_ENTRIES) {
148
+ const excess = this.entries.size - BYTEPLUS_VIDEO_MAX_ENTRIES;
149
+ const iter = this.entries.keys();
150
+ for (let i = 0; i < excess; i++) {
151
+ const next = iter.next();
152
+ if (!next.done) this.entries.delete(next.value);
153
+ }
154
+ }
155
+ }
156
+ delete(key) {
157
+ return this.entries.delete(key);
158
+ }
159
+ clear() {
160
+ this.entries.clear();
161
+ this.worldGeneration++;
162
+ }
163
+ get size() {
164
+ return this.entries.size;
165
+ }
166
+ };
167
+ /** The `{ url }` wrapper each media part carries, keyed by the part's `type`. */
168
+ function mediaUrlOf(part) {
169
+ const holder = part.type === "image_url" ? part.image_url : part.type === "video_url" ? part.video_url : part.type === "audio_url" ? part.audio_url : void 0;
170
+ if (holder === null || typeof holder !== "object") return void 0;
171
+ const url = holder.url;
172
+ return typeof url === "string" ? url : void 0;
173
+ }
174
+ function digest12(value) {
175
+ return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12);
176
+ }
177
+ /** Stable-enough serialisation of a part the media table could not resolve. */
178
+ function unresolvedPartToken(raw) {
179
+ if (raw === null || typeof raw !== "object") return `part:${digest12(String(raw))}`;
180
+ const part = raw;
181
+ const role = typeof part.type === "string" && part.type ? part.type : "part";
182
+ let serialised;
183
+ try {
184
+ serialised = JSON.stringify(raw) ?? String(raw);
185
+ } catch {
186
+ serialised = String(raw);
187
+ }
188
+ return `${role}:${digest12(serialised)}`;
189
+ }
190
+ /**
191
+ * The synthetic user message a submit is matched on.
192
+ *
193
+ * Concatenated text of every `type: "text"` part, then ALWAYS
194
+ * `[media: <role>:<sha256 first 12 hex>, …]` — one entry per non-text part in
195
+ * array order, `[media: ]` when there are none. The separating newline is
196
+ * present only when there IS text: a key with no text part is the bare
197
+ * `[media: …]` suffix, unprefixed.
198
+ *
199
+ * WHY the digest: a Seedance image-to-video job carries NO text part, so a
200
+ * text-only key would record `{ endpoint, model }` — a wildcard matching every
201
+ * image-to-video job in the suite for that model. `persistFixture`'s
202
+ * defective-fixture guard would not catch it either, because its `isEmptyMatch`
203
+ * requires `endpoint` to be undefined too. Image-to-video, last-frame and
204
+ * reference-media are first-class Seedance modes, so two recordings with the
205
+ * same prompt and different first frames must stay distinguishable.
206
+ *
207
+ * WHY the marker is UNCONDITIONAL: this key lands in `match.userMessage`, which
208
+ * the router SUBSTRING-matches by default (`useExactMatch = !!requestTransform`).
209
+ * A conditional suffix makes the text-only key `"a guitar"` a prefix of the
210
+ * image-to-video key `"a guitar\n[media: …]"`, so a t2v recording silently
211
+ * serves its own video_url to an i2v request that shares the prompt — a 200
212
+ * with the wrong bytes. Emitting `[media: ]` for the text-only case makes
213
+ * neither key a substring of the other. Prefixing would NOT fix it: the plain
214
+ * text is still contained in the prefixed form.
215
+ *
216
+ * WHY every unrecognised part still contributes: `mediaUrlOf` resolves exactly
217
+ * `image_url` / `video_url` / `audio_url` with a `{ url: string }` wrapper, and
218
+ * the whole wire contract here is unverified against BytePlus. Dropping the
219
+ * parts it cannot resolve reopened both failures above — an all-unrecognised
220
+ * content array returned `""` and minted the very wildcard this key exists to
221
+ * prevent, and two different unrecognised parts collapsed onto one key. They
222
+ * are digested over their own JSON instead, so they stay distinguishable
223
+ * without the key ever carrying the part's payload.
224
+ *
225
+ * The digest is taken over the url string whether that is a public URL or a
226
+ * multi-megabyte `data:` URI, and only 12 hex are kept — the match key stays
227
+ * short and the fixture never grows a data URI.
228
+ *
229
+ * `role` is the declared role, else `first_frame` for an image (the default
230
+ * `@tanstack/ai-byteplus@0.3.4` applies when a request omits the role — not a
231
+ * BytePlus-documented default; nothing here was read from BytePlus), else the
232
+ * part's `type` token, which is read off the request rather than drawn from any
233
+ * role vocabulary.
234
+ */
235
+ function buildBytePlusMatchText(content) {
236
+ const texts = [];
237
+ const media = [];
238
+ for (const raw of content) {
239
+ const part = raw !== null && typeof raw === "object" ? raw : void 0;
240
+ if (part?.type === "text") {
241
+ if (typeof part.text === "string" && part.text) texts.push(part.text);
242
+ continue;
243
+ }
244
+ const url = part === void 0 ? void 0 : mediaUrlOf(part);
245
+ if (part === void 0 || url === void 0) {
246
+ media.push(unresolvedPartToken(raw));
247
+ continue;
248
+ }
249
+ const role = typeof part.role === "string" && part.role ? part.role : part.type === "image_url" ? "first_frame" : String(part.type);
250
+ media.push(`${role}:${digest12(url)}`);
251
+ }
252
+ const suffix = `[media: ${media.join(", ")}]`;
253
+ const text = texts.join("\n");
254
+ return text ? `${text}\n${suffix}` : suffix;
255
+ }
256
+ /**
257
+ * Advance a replay job one poll. `queued → running → terminal` on poll-count
258
+ * thresholds; no-op once terminal, which is what makes the extra post-terminal
259
+ * poll the client library makes (getVideoStatus then getVideoUrl both hit this
260
+ * endpoint) byte-identical to the one before it.
261
+ */
262
+ function advanceJob(job) {
263
+ if (job.phase === "terminal") return;
264
+ job.pollCount += 1;
265
+ if (job.phase === "queued" && job.pollCount >= job.pollsBeforeRunning) job.phase = "running";
266
+ if (job.pollCount >= job.pollsBeforeTerminal) job.phase = "terminal";
267
+ }
268
+ /**
269
+ * Ark's error envelope SHAPE, carrying a message only — never an invented code.
270
+ *
271
+ * KNOWN, DELIBERATE, UNRESOLVED DIVERGENCE. The live canary in
272
+ * `src/__tests__/drift/byteplus-video.drift.ts` asserts that real Ark supplies a
273
+ * string `error.code`, so a consumer that reads `error.code` off an aimock-
274
+ * authored error (404 unknown task, 404 no fixture, 400 validation, 502 bad
275
+ * envelope, 503 strict) sees `undefined` where live Ark would give it a token.
276
+ *
277
+ * We omit it rather than close the gap because we do not know the value FOR
278
+ * THESE CLASSES. The canary is keyless and does run — it observed Ark's
279
+ * AUTH-layer error body live on 2026-09-10 (`code: "AuthenticationError"`,
280
+ * `type: "Unauthorized"`, `param: ""`; see the `OBSERVED_*` constants in the
281
+ * drift file). None of the five error classes above is an auth error, so that
282
+ * observation does not supply their codes, and `@tanstack/ai-byteplus` is not
283
+ * installed here, so nobody has verified what the client does with a missing
284
+ * `code` either. Minting one would put an aimock-authored token into a consumer's
285
+ * assertions and into any fixture recorded past it, which is the failure this
286
+ * module exists to avoid; the governing rule is that the mock never authors a
287
+ * wire value it did not observe, and an honest absence beats a plausible
288
+ * invention. Recorded fixtures are unaffected: a real `error.code` captured
289
+ * from upstream replays verbatim.
290
+ *
291
+ * Also omitted, for the same reason and on the same terms: `type` and `param`.
292
+ * Both ARE observed on the auth envelope, and neither is known for the five
293
+ * classes above.
294
+ *
295
+ * To close it: observe a real Ark 404/400/5xx for each class (a real key is
296
+ * needed to reach them), then carry the OBSERVED code per error class. Do not
297
+ * guess one.
298
+ */
299
+ function arkErrorBody(message) {
300
+ return JSON.stringify({ error: { message } });
301
+ }
302
+ function asRecord(value) {
303
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
304
+ }
305
+ /**
306
+ * Build the poll body for a replay job.
307
+ *
308
+ * Non-terminal: `{ id, model?, status, created_at? }` — the status token is the
309
+ * ONLY synthesized value, and `content`/`error`/`usage`/`updated_at` are
310
+ * withheld. `created_at` is a property of the task and is true at every phase;
311
+ * `updated_at` on a recorded envelope is the timestamp of the TERMINAL
312
+ * transition, so emitting it mid-flight would assert a field COMBINATION this
313
+ * design has never seen from Ark and cannot source from the client library
314
+ * either (and a consumer diffing it across polls would see it constant).
315
+ *
316
+ * Terminal: the stored envelope VERBATIM with `id` rewritten. Its status is
317
+ * whatever was recorded, including `cancelled` / `expired`.
318
+ *
319
+ * Returns `null` when the envelope cannot produce a poll body at all (not an
320
+ * object, or no `status`) — an authoring error the caller answers with a 502.
321
+ */
322
+ function serializeBytePlusVideoTask(job, logger) {
323
+ const envelope = job.envelope;
324
+ const status = envelope.status;
325
+ if (typeof status !== "string" || !status) return null;
326
+ if (job.phase !== "terminal") {
327
+ const body = {
328
+ id: job.id,
329
+ status: job.phase
330
+ };
331
+ if (envelope.model !== void 0) body.model = envelope.model;
332
+ if (envelope.created_at !== void 0) body.created_at = envelope.created_at;
333
+ return body;
334
+ }
335
+ const warned = warnLatchesFor(envelope);
336
+ if (!isBytePlusTerminalStatus(status) && !warned.nonTerminalEnvelope) {
337
+ warned.nonTerminalEnvelope = true;
338
+ logger.warn(`BytePlus video fixture for job ${job.id} has reached its terminal poll but its envelope carries the NON-TERMINAL status "${status}" — every later poll returns this same body, so the client will poll it forever and fail on its own timeout. Re-record the fixture, or author a terminal status (succeeded, failed, cancelled, expired)`);
339
+ }
340
+ if (!BYTEPLUS_TASK_STATUSES.has(status) && !warned.unknownStatus) {
341
+ warned.unknownStatus = true;
342
+ logger.warn(`BytePlus video fixture for job ${job.id} carries status "${status}", which is not one of the six the BytePlus client library declares (queued, running, succeeded, failed, cancelled, expired) — serving it as authored, but that library's mapStatus() THROWS on a status it does not recognize`);
343
+ }
344
+ const videoUrl = asRecord(envelope.content)?.video_url;
345
+ if (status === "succeeded" && typeof videoUrl !== "string" && !warned.missingUrl) {
346
+ warned.missingUrl = true;
347
+ logger.warn(`BytePlus video fixture for job ${job.id} reports status "succeeded" but carries no content.video_url — the client will throw "Video is not ready for download. Check status first." Add content.video_url to the fixture envelope`);
348
+ }
349
+ const updatedAt = envelope.updated_at;
350
+ if (status === "succeeded" && typeof videoUrl === "string" && typeof updatedAt === "number" && Number.isFinite(updatedAt) && !warned.expiredUrl) {
351
+ const expiresAt = updatedAt + BYTEPLUS_URL_TTL_SECONDS;
352
+ if (expiresAt * 1e3 < Date.now()) {
353
+ warned.expiredUrl = true;
354
+ logger.warn(`BytePlus video fixture for job ${job.id} has a content.video_url that expired at ${(/* @__PURE__ */ new Date(expiresAt * 1e3)).toISOString()} (Ark output urls live 24h from updated_at) — serving it anyway; re-record the fixture to get a live url`);
355
+ }
356
+ }
357
+ return {
358
+ ...envelope,
359
+ id: job.id
360
+ };
361
+ }
362
+ /**
363
+ * Structurally valid journal body for a field-validation 400. Mirrors
364
+ * grok-video / openrouter-video: `model` stays a string and `messages` is an
365
+ * empty array; underscore-prefixed keys are stripped so a request cannot spoof
366
+ * handler-set discriminators.
367
+ */
368
+ function validationJournalBody(videoReq) {
369
+ const rawModel = videoReq.model;
370
+ const model = typeof rawModel === "string" ? rawModel : rawModel === void 0 ? "" : JSON.stringify(rawModel);
371
+ return {
372
+ ...Object.fromEntries(Object.entries(videoReq).filter(([key]) => !key.startsWith("_"))),
373
+ model,
374
+ messages: []
375
+ };
376
+ }
377
+ async function handleBytePlusVideoCreate(req, res, raw, fixtures, journal, defaults, setCorsHeaders, jobs) {
378
+ setCorsHeaders(res);
379
+ const path = req.url ?? BYTEPLUS_VIDEO_TASKS_PATH;
380
+ const method = req.method ?? "POST";
381
+ const fail = (status, message, body) => {
382
+ journal.add({
383
+ method,
384
+ path,
385
+ headers: flattenHeaders(req.headers),
386
+ body,
387
+ response: {
388
+ status,
389
+ fixture: null
390
+ }
391
+ });
392
+ writeErrorResponse(res, status, arkErrorBody(message));
393
+ };
394
+ let videoReq;
395
+ try {
396
+ videoReq = JSON.parse(raw);
397
+ } catch (parseErr) {
398
+ fail(400, `Malformed JSON: ${parseErr instanceof Error ? parseErr.message : "unknown"}`, null);
399
+ return;
400
+ }
401
+ if (videoReq === null || typeof videoReq !== "object" || Array.isArray(videoReq)) {
402
+ fail(400, "Request body must be a JSON object", null);
403
+ return;
404
+ }
405
+ const parsedBody = validationJournalBody(videoReq);
406
+ if (typeof videoReq.model !== "string" || !videoReq.model) {
407
+ fail(400, videoReq.model === void 0 ? "Missing required parameter: 'model'" : "Invalid type for parameter: 'model' must be a non-empty string", parsedBody);
408
+ return;
409
+ }
410
+ if (!Array.isArray(videoReq.content) || videoReq.content.length === 0) {
411
+ fail(400, videoReq.content === void 0 ? "Missing required parameter: 'content'" : "Invalid type for parameter: 'content' must be a non-empty array", parsedBody);
412
+ return;
413
+ }
414
+ const syntheticReq = {
415
+ model: videoReq.model,
416
+ messages: [{
417
+ role: "user",
418
+ content: buildBytePlusMatchText(videoReq.content)
419
+ }],
420
+ _endpointType: "video",
421
+ _videoProvider: "byteplus",
422
+ _context: getContext(req)
423
+ };
424
+ const testId = getTestId(req);
425
+ const { fixture, skippedBySequenceOrTurn } = matchFixtureDiagnostic(fixtures, syntheticReq, journal.getFixtureMatchCountsForTest(testId), defaults.requestTransform);
426
+ if (fixture) {
427
+ journal.incrementFixtureMatchCount(fixture, fixtures, testId);
428
+ defaults.logger.debug(`Fixture matched: ${JSON.stringify(fixture.match).slice(0, 120)}`);
429
+ }
430
+ if (applyChaos(res, fixture, defaults.chaos, req.headers, req.url, journal, {
431
+ method,
432
+ path,
433
+ headers: flattenHeaders(req.headers),
434
+ body: syntheticReq
435
+ }, fixture ? "fixture" : resolveStrictMode(defaults.strict, req.headers) ? "internal" : defaults.record?.providers.byteplus ? "proxy" : "internal", defaults.registry, defaults.logger)) return;
436
+ if (!fixture) {
437
+ if (resolveStrictMode(defaults.strict, req.headers)) {
438
+ const strictMessage = strictNoMatchMessage(skippedBySequenceOrTurn);
439
+ defaults.logger.error(strictNoMatchLogLine(method, path, skippedBySequenceOrTurn));
440
+ journal.add({
441
+ method,
442
+ path,
443
+ headers: flattenHeaders(req.headers),
444
+ body: syntheticReq,
445
+ response: {
446
+ status: 503,
447
+ fixture: null,
448
+ ...strictOverrideField(defaults.strict, req.headers)
449
+ }
450
+ });
451
+ writeErrorResponse(res, 503, arkErrorBody(strictMessage));
452
+ return;
453
+ }
454
+ if (defaults.record) {
455
+ if (await proxyBytePlusVideoSubmit({
456
+ req,
457
+ res,
458
+ raw,
459
+ syntheticReq,
460
+ record: defaults.record,
461
+ journal,
462
+ defaults,
463
+ jobs,
464
+ method,
465
+ path
466
+ }) === "handled") return;
467
+ }
468
+ journal.add({
469
+ method,
470
+ path,
471
+ headers: flattenHeaders(req.headers),
472
+ body: syntheticReq,
473
+ response: {
474
+ status: 404,
475
+ fixture: null,
476
+ ...strictOverrideField(defaults.strict, req.headers)
477
+ }
478
+ });
479
+ writeErrorResponse(res, 404, arkErrorBody("No byteplus fixture matched. A byteplus video fixture must carry match.endpoint: \"video\" and a { json: <Ark task envelope> } response"));
480
+ return;
481
+ }
482
+ const worldGeneration = jobs.generation;
483
+ const response = await resolveResponse(fixture, syntheticReq);
484
+ if (isErrorResponse(response)) {
485
+ if (res.destroyed || res.writableEnded) return;
486
+ const status = response.status ?? 500;
487
+ journal.add({
488
+ method,
489
+ path,
490
+ headers: flattenHeaders(req.headers),
491
+ body: syntheticReq,
492
+ response: {
493
+ status,
494
+ fixture
495
+ }
496
+ });
497
+ writeErrorResponse(res, status, serializeErrorResponse(response), { retryAfter: response.retryAfter });
498
+ return;
499
+ }
500
+ if (!isJSONResponse(response)) {
501
+ if (res.destroyed || res.writableEnded) return;
502
+ defaults.logger.warn("BytePlus video fixture matched but its response is not a { json: … } payload — this surface stores the Ark task envelope verbatim, NOT a VideoResponse. Treating it as a no-match");
503
+ journal.add({
504
+ method,
505
+ path,
506
+ headers: flattenHeaders(req.headers),
507
+ body: syntheticReq,
508
+ response: {
509
+ status: 404,
510
+ fixture: null
511
+ }
512
+ });
513
+ writeErrorResponse(res, 404, arkErrorBody("Matched byteplus fixture has a non-JSON response; expected { json: <Ark task envelope> }"));
514
+ return;
515
+ }
516
+ const envelope = asRecord(response.json);
517
+ if (!envelope) {
518
+ if (res.destroyed || res.writableEnded) return;
519
+ defaults.logger.warn("BytePlus video fixture's json payload is not a JSON object — expected an Ark task envelope");
520
+ journal.add({
521
+ method,
522
+ path,
523
+ headers: flattenHeaders(req.headers),
524
+ body: syntheticReq,
525
+ response: {
526
+ status: 404,
527
+ fixture: null
528
+ }
529
+ });
530
+ writeErrorResponse(res, 404, arkErrorBody("Matched byteplus fixture's json is not an object"));
531
+ return;
532
+ }
533
+ if (res.destroyed || res.writableEnded) return;
534
+ journal.add({
535
+ method,
536
+ path,
537
+ headers: flattenHeaders(req.headers),
538
+ body: syntheticReq,
539
+ response: {
540
+ status: 200,
541
+ fixture
542
+ }
543
+ });
544
+ const id = `cgt-${crypto.randomUUID()}`;
545
+ const progression = resolveProgression(defaults.bytePlusVideo);
546
+ const job = {
547
+ kind: "replay",
548
+ id,
549
+ phase: "queued",
550
+ pollCount: 0,
551
+ pollsBeforeRunning: progression.pollsBeforeInProgress,
552
+ pollsBeforeTerminal: progression.pollsBeforeCompleted,
553
+ envelope
554
+ };
555
+ if (progression.pollsBeforeCompleted === 0) job.phase = "terminal";
556
+ if (jobs.generation === worldGeneration) jobs.set(`${testId}:${id}`, job);
557
+ else defaults.logger.warn(`BytePlus video submit resolved after a fixtures reset — not inserting job ${id} into the new world (its polls will 404)`);
558
+ res.writeHead(200, { "Content-Type": "application/json" });
559
+ res.end(JSON.stringify({ id }));
560
+ }
561
+ async function handleBytePlusVideoStatus(req, res, id, fixtures, journal, defaults, setCorsHeaders, jobs) {
562
+ setCorsHeaders(res);
563
+ const path = req.url ?? `${BYTEPLUS_VIDEO_TASKS_PATH}/${id}`;
564
+ const method = req.method ?? "GET";
565
+ const testId = getTestId(req);
566
+ const key = `${testId}:${id}`;
567
+ if (applyChaos(res, null, defaults.chaos, req.headers, req.url, journal, {
568
+ method,
569
+ path,
570
+ headers: flattenHeaders(req.headers),
571
+ body: null
572
+ }, "internal", defaults.registry, defaults.logger)) return;
573
+ const job = jobs.get(key);
574
+ if (!job) {
575
+ journal.add({
576
+ method,
577
+ path,
578
+ headers: flattenHeaders(req.headers),
579
+ body: null,
580
+ response: {
581
+ status: 404,
582
+ fixture: null
583
+ }
584
+ });
585
+ writeErrorResponse(res, 404, arkErrorBody(`Unknown video task id "${id}" — no such job for this test scope. It was never created, it belongs to a different x-test-id, or it aged out of aimock's 1h job TTL`));
586
+ return;
587
+ }
588
+ if (job.kind === "record") {
589
+ if (resolveStrictMode(defaults.strict, req.headers)) {
590
+ defaults.logger.error(`STRICT: video task ${id} is proxied live upstream (record mode) — refusing the upstream poll`);
591
+ journal.add({
592
+ method,
593
+ path,
594
+ headers: flattenHeaders(req.headers),
595
+ body: null,
596
+ response: {
597
+ status: 503,
598
+ fixture: null,
599
+ ...strictOverrideField(defaults.strict, req.headers)
600
+ }
601
+ });
602
+ writeErrorResponse(res, 503, arkErrorBody(`Strict mode: video task ${id} is proxied live upstream (record mode) — nothing reaches an upstream under strict mode`));
603
+ return;
604
+ }
605
+ await proxyBytePlusVideoRecordPoll({
606
+ req,
607
+ res,
608
+ job,
609
+ key,
610
+ testId,
611
+ fixtures,
612
+ journal,
613
+ defaults,
614
+ jobs,
615
+ method,
616
+ path
617
+ });
618
+ return;
619
+ }
620
+ if (res.destroyed || res.writableEnded) return;
621
+ advanceJob(job);
622
+ jobs.set(key, job);
623
+ const body = serializeBytePlusVideoTask(job, defaults.logger);
624
+ if (!body) {
625
+ defaults.logger.warn(`BytePlus video fixture for job ${id} has an envelope that is not a usable Ark task body (missing or non-string "status") — a fixture that cannot produce a poll body is an authoring error`);
626
+ journal.add({
627
+ method,
628
+ path,
629
+ headers: flattenHeaders(req.headers),
630
+ body: null,
631
+ response: {
632
+ status: 502,
633
+ fixture: null
634
+ }
635
+ });
636
+ writeErrorResponse(res, 502, arkErrorBody(`Fixture envelope for video task ${id} carries no "status" — expected an Ark task envelope`));
637
+ return;
638
+ }
639
+ journal.add({
640
+ method,
641
+ path,
642
+ headers: flattenHeaders(req.headers),
643
+ body: null,
644
+ response: {
645
+ status: 200,
646
+ fixture: null
647
+ }
648
+ });
649
+ res.writeHead(200, { "Content-Type": "application/json" });
650
+ res.end(JSON.stringify(body));
651
+ }
652
+ /**
653
+ * Proxy an unmatched submit to the configured upstream and answer the client
654
+ * with a mock-rewritten `{ id }`. The upstream lifecycle is then driven
655
+ * interactively by the client's own polls. Returns "no_upstream" when record
656
+ * mode has no byteplus provider URL — the caller falls through to 404.
657
+ */
658
+ async function proxyBytePlusVideoSubmit(args) {
659
+ const { req, res, raw, syntheticReq, record, journal, defaults, jobs, method, path } = args;
660
+ const upstreamBase = record.providers.byteplus;
661
+ if (!upstreamBase) {
662
+ defaults.logger.warn(`No upstream URL configured for provider "byteplus" — cannot proxy`);
663
+ return "no_upstream";
664
+ }
665
+ const proxyError = (msg) => {
666
+ defaults.logger.error(`BytePlus video submit proxy failed: ${msg}`);
667
+ if (res.destroyed || res.writableEnded) return "handled";
668
+ journal.add({
669
+ method,
670
+ path,
671
+ headers: flattenHeaders(req.headers),
672
+ body: syntheticReq,
673
+ response: {
674
+ status: 502,
675
+ fixture: null,
676
+ source: "proxy",
677
+ ...strictOverrideField(defaults.strict, req.headers)
678
+ }
679
+ });
680
+ writeErrorResponse(res, 502, arkErrorBody(`Proxy to upstream failed: ${msg}`));
681
+ return "handled";
682
+ };
683
+ let submitUrl;
684
+ try {
685
+ submitUrl = resolveUpstreamUrl(upstreamBase, BYTEPLUS_VIDEO_TASKS_PATH);
686
+ } catch (err) {
687
+ return proxyError(`Invalid upstream URL: ${upstreamBase} (${err instanceof Error ? err.message : String(err)})`);
688
+ }
689
+ defaults.logger.warn(`NO FIXTURE MATCH — proxying video submit to ${submitUrl.toString()}`);
690
+ const worldGeneration = jobs.generation;
691
+ let fetched;
692
+ try {
693
+ const headers = prepareEgressHeaders(req, submitUrl, "byteplus", record.providerKeys?.byteplus);
694
+ if (!headers) return proxyError("No configured provider credential");
695
+ const upstreamRes = await fetch(submitUrl, {
696
+ method: "POST",
697
+ headers,
698
+ body: raw,
699
+ signal: upstreamTimeoutSignal(record)
700
+ });
701
+ fetched = {
702
+ status: upstreamRes.status,
703
+ contentType: upstreamRes.headers.get("content-type"),
704
+ text: await readEnvelopeText(upstreamRes, record)
705
+ };
706
+ } catch (err) {
707
+ return proxyError(err instanceof Error ? err.message : "Unknown proxy error");
708
+ }
709
+ if (fetched.status === 401 || fetched.status === 403) {
710
+ defaults.logger.warn(`Upstream rejected the video submit (${fetched.status}) — relaying the upstream status`);
711
+ if (res.destroyed || res.writableEnded) return "handled";
712
+ journal.add({
713
+ method,
714
+ path,
715
+ headers: flattenHeaders(req.headers),
716
+ body: syntheticReq,
717
+ response: {
718
+ status: fetched.status,
719
+ fixture: null,
720
+ source: "proxy",
721
+ ...strictOverrideField(defaults.strict, req.headers)
722
+ }
723
+ });
724
+ res.writeHead(fetched.status, { "Content-Type": fetched.contentType ?? "application/json" });
725
+ res.end(fetched.text);
726
+ return "handled";
727
+ }
728
+ if (fetched.status < 200 || fetched.status >= 300) return proxyError(`Submit ${fetched.status}: ${fetched.text.slice(0, 200)}`);
729
+ let upstreamTaskId;
730
+ {
731
+ let parsed;
732
+ try {
733
+ parsed = JSON.parse(fetched.text);
734
+ } catch {
735
+ return proxyError(`Submit returned non-JSON: ${fetched.text.slice(0, 200)}`);
736
+ }
737
+ const envelope = asRecord(parsed);
738
+ if (!envelope) return proxyError("Submit response is not a JSON object");
739
+ upstreamTaskId = String(envelope.id ?? "").trim();
740
+ if (!upstreamTaskId) return proxyError("Submit response missing id");
741
+ }
742
+ const upstreamPollingUrl = resolveUpstreamUrl(upstreamBase, `${BYTEPLUS_VIDEO_TASKS_PATH}/${encodeURIComponent(upstreamTaskId)}`).toString();
743
+ const testId = getTestId(req);
744
+ const matchRequest = defaults.requestTransform ? defaults.requestTransform(syntheticReq) : syntheticReq;
745
+ const id = `cgt-${crypto.randomUUID()}`;
746
+ const job = {
747
+ kind: "record",
748
+ id,
749
+ upstreamTaskId,
750
+ upstreamPollingUrl,
751
+ match: buildFixtureMatch(matchRequest, record)
752
+ };
753
+ if (jobs.generation === worldGeneration) jobs.set(`${testId}:${id}`, job);
754
+ else defaults.logger.warn(`BytePlus video submit for upstream task ${upstreamTaskId} completed after a fixtures reset — not inserting the job into the new world (its polls will 404)`);
755
+ if (res.destroyed || res.writableEnded) return "handled";
756
+ journal.add({
757
+ method,
758
+ path,
759
+ headers: flattenHeaders(req.headers),
760
+ body: syntheticReq,
761
+ response: {
762
+ status: 200,
763
+ fixture: null,
764
+ source: "proxy",
765
+ ...strictOverrideField(defaults.strict, req.headers)
766
+ }
767
+ });
768
+ res.writeHead(200, { "Content-Type": "application/json" });
769
+ res.end(JSON.stringify({ id }));
770
+ return "handled";
771
+ }
772
+ /**
773
+ * Proxy a status poll 1:1 and relay the result with the mock id substituted.
774
+ *
775
+ * An upstream 4xx is RELAYED VERBATIM rather than converted to 502 — a 404 in
776
+ * particular, because the client has a deliberate 404 branch that keeps Ark's
777
+ * own code/message on purpose, and converting it would make record mode behave
778
+ * differently from live Ark for exactly the case that branch exists to serve.
779
+ * 5xx and transport failures become 502.
780
+ *
781
+ * On ANY terminal status the rewritten body is relayed immediately and the
782
+ * envelope is captured. All four terminal states are captured identically:
783
+ * there is no warn-and-drop branch, so a task cancelled from the Ark console
784
+ * mid-record yields an artifact rather than a warning and an empty directory.
785
+ */
786
+ async function proxyBytePlusVideoRecordPoll(args) {
787
+ const { req, res, job, key, testId, fixtures, journal, defaults, jobs, method, path } = args;
788
+ const logger = defaults.logger;
789
+ const journalProxy = (status) => {
790
+ journal.add({
791
+ method,
792
+ path,
793
+ headers: flattenHeaders(req.headers),
794
+ body: null,
795
+ response: {
796
+ status,
797
+ fixture: null,
798
+ source: "proxy",
799
+ ...strictOverrideField(defaults.strict, req.headers)
800
+ }
801
+ });
802
+ };
803
+ const proxyError = (msg) => {
804
+ logger.error(`BytePlus video poll proxy failed: ${msg}`);
805
+ if (res.destroyed || res.writableEnded) return;
806
+ journalProxy(502);
807
+ writeErrorResponse(res, 502, arkErrorBody(`Proxy to upstream failed: ${msg}`));
808
+ };
809
+ const record = defaults.record;
810
+ if (!record) {
811
+ proxyError("record mode is no longer configured for an in-flight record job");
812
+ return;
813
+ }
814
+ let fetched;
815
+ try {
816
+ const headers = prepareEgressHeaders(req, new URL(job.upstreamPollingUrl), "byteplus", record.providerKeys?.byteplus);
817
+ if (!headers) {
818
+ proxyError("No configured provider credential");
819
+ return;
820
+ }
821
+ const upstreamRes = await fetch(job.upstreamPollingUrl, {
822
+ headers,
823
+ signal: upstreamTimeoutSignal(record)
824
+ });
825
+ fetched = {
826
+ status: upstreamRes.status,
827
+ contentType: upstreamRes.headers.get("content-type"),
828
+ text: await readEnvelopeText(upstreamRes, record)
829
+ };
830
+ } catch (err) {
831
+ proxyError(err instanceof Error ? err.message : "Unknown proxy error");
832
+ return;
833
+ }
834
+ if (fetched.status >= 400 && fetched.status < 500) {
835
+ logger.warn(`Upstream returned ${fetched.status} for video task ${job.upstreamTaskId} — relaying it verbatim`);
836
+ if (res.destroyed || res.writableEnded) return;
837
+ journalProxy(fetched.status);
838
+ res.writeHead(fetched.status, { "Content-Type": fetched.contentType ?? "application/json" });
839
+ res.end(fetched.text);
840
+ return;
841
+ }
842
+ if (fetched.status < 200 || fetched.status >= 300) {
843
+ proxyError(`Status ${fetched.status}: ${fetched.text.slice(0, 200)}`);
844
+ return;
845
+ }
846
+ let upstreamBody;
847
+ {
848
+ let parsed;
849
+ try {
850
+ parsed = JSON.parse(fetched.text);
851
+ } catch {
852
+ proxyError(`Status returned non-JSON: ${fetched.text.slice(0, 200)}`);
853
+ return;
854
+ }
855
+ const envelope = asRecord(parsed);
856
+ if (!envelope) {
857
+ proxyError("Status response is not a JSON object");
858
+ return;
859
+ }
860
+ upstreamBody = envelope;
861
+ }
862
+ const relayBody = {
863
+ ...upstreamBody,
864
+ id: job.id
865
+ };
866
+ const relayJson = (body) => {
867
+ if (res.destroyed || res.writableEnded) return;
868
+ journalProxy(200);
869
+ res.writeHead(200, { "Content-Type": "application/json" });
870
+ res.end(JSON.stringify(body));
871
+ };
872
+ const rawStatus = upstreamBody.status;
873
+ const upstreamStatus = typeof rawStatus === "string" ? rawStatus : "";
874
+ if (!upstreamStatus || !isBytePlusTerminalStatus(upstreamStatus)) {
875
+ if (jobs.get(key) === job) jobs.set(key, job);
876
+ relayJson(relayBody);
877
+ return;
878
+ }
879
+ if (record.proxyOnly) {
880
+ if (jobs.get(key) === job) jobs.set(key, job);
881
+ relayJson(relayBody);
882
+ return;
883
+ }
884
+ if (jobs.get(key) !== job) {
885
+ relayJson(relayBody);
886
+ return;
887
+ }
888
+ captureBytePlusVideoRecordFixture({
889
+ job,
890
+ key,
891
+ testId,
892
+ fixtures,
893
+ defaults,
894
+ jobs,
895
+ record,
896
+ upstreamBody,
897
+ res
898
+ });
899
+ relayJson(relayBody);
900
+ }
901
+ /**
902
+ * Capture a terminal record job into a fixture (PERSIST only — NO byte
903
+ * download) and mutate the map entry into a terminal replay job.
904
+ *
905
+ * Runs BEFORE the caller relays, and is fully synchronous, so a persist failure
906
+ * still reaches the client as an `X-AIMock-Record-Error` header rather than
907
+ * only as a log line behind a 200 the client cannot distinguish from a clean
908
+ * record.
909
+ *
910
+ * The upstream `id` is REMOVED at capture: replay stamps its own, and keeping a
911
+ * real Ark task id in a committed artifact leaks it for no benefit.
912
+ */
913
+ function captureBytePlusVideoRecordFixture(args) {
914
+ const { job, key, testId, fixtures, defaults, jobs, record, upstreamBody, res } = args;
915
+ const logger = defaults.logger;
916
+ try {
917
+ const { id: _discardedUpstreamId, ...envelope } = upstreamBody;
918
+ const persistResult = persistFixture({
919
+ record,
920
+ providerKey: "byteplus",
921
+ testId,
922
+ fixture: {
923
+ match: job.match,
924
+ response: { json: envelope }
925
+ },
926
+ fixtures,
927
+ logger
928
+ });
929
+ if (persistResult.kind === "failed" && !res.headersSent) res.setHeader("X-AIMock-Record-Error", sanitizeHeaderValue(persistResult.error));
930
+ jobs.set(key, {
931
+ kind: "replay",
932
+ id: job.id,
933
+ phase: "terminal",
934
+ pollCount: 0,
935
+ pollsBeforeRunning: 0,
936
+ pollsBeforeTerminal: 0,
937
+ envelope
938
+ });
939
+ } catch (err) {
940
+ const msg = err instanceof Error ? err.message : String(err);
941
+ logger.error(`BytePlus video capture for task ${job.upstreamTaskId} failed unexpectedly (${msg}) — fixture not persisted; the job keeps proxying live`);
942
+ }
943
+ }
944
+
945
+ //#endregion
946
+ export { BYTEPLUS_VIDEO_MAX_ENTRIES, BYTEPLUS_VIDEO_TASKS_PATH, BytePlusVideoJobMap, buildBytePlusMatchText, handleBytePlusVideoCreate, handleBytePlusVideoStatus };
947
+ //# sourceMappingURL=byteplus-video.js.map