@kaydash9999/banker-plugins 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +4 -1
- package/skills/3d-intro-build/references/azure-adapter.mjs +90 -16
- package/skills/3d-intro-build/references/video-pool.mjs +58 -21
- package/skills/3d-intro-setup/SKILL.md +81 -45
- package/skills/3d-intro-setup/references/azure-adapter.mjs +90 -16
- package/skills/3d-intro-setup/references/video-pool.mjs +58 -21
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "banker",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "smallOpenSource",
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.16.1] - 2026-10-11
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
- **3d-intro 의 API 키는 https 로만 간다.** 0.16.0 은 Sora 영상 생성, Azure 이미지 생성과 목록, setup 프리플라이트가 `http://` endpoint 에도 키를 평문으로 보냈다(https 확인은 Sora `resumeClip` 에만 있었다).\
|
|
7
|
+
이제 키 헤더가 실린 요청은 https 로만 가고, http 는 이 기기의 localhost 만 허용한다. 그 밖의 http endpoint 는 보내기 전에 막는다. Sora 영상 단계는 `invalid_request` 로 멈추고, 그 밖의 호출은 `HTTP 0 (EINSECURE)` 오류다. WAN endpoint 도 https 만 받는다.
|
|
8
|
+
- **리다이렉트로 키, 프롬프트, 유료 제출이 다른 곳에 가지 않는다.** 0.16.0 의 fetch 는 리다이렉트를 따라가며 `api-key` 헤더를 다른 출처에 넘겼고, 307, 308 에서는 프롬프트와 프레임을 다시 보냈다. 이제 리다이렉트를 직접 따라간다(최대 5회).\
|
|
9
|
+
다른 출처로는 키 헤더 없이 본문 없는 GET 만 보낸다. 본문이 있는 요청은 다른 출처로 따라가지 않아, 그곳의 401, 429 를 키의 답으로 읽고 다른 키로 다시 제출하는 일(이중 과금)이 없다. https 를 벗어나는 리다이렉트(예: 다운로드 URL 이 `http://169.254.169.254` 로 보내는 것)도 따라가지 않는다. Location 이 URL 이 아니면 그 제출을 "보내지 않음" 으로 보지 않는다.
|
|
10
|
+
- **`3d-intro-setup` 이 키를 채팅이나 명령줄로 받지 않는다.** 키는 사람이 `umask 077` 로 만든 키 파일에 직접 적거나, 콘솔 샘플을 비공개 파일로 저장하면 에이전트가 화면에 내지 않고 합친다.\
|
|
11
|
+
합칠 때는 `resolveCreds` 가 이미 읽는 키 파일(홈 fallback 포함)에 쓰고, 홈 키 파일이 있으면 프로젝트 키 파일을 새로 만들지 않는다(만들면 홈의 키가 가려진다). 파일의 다른 키를 바꾸면 길이로만 알린다. 샘플의 endpoint 가 다른데 샘플에 키가 없으면 저장하지 않는다(기존 키가 새 host 로 가지 않게). SDK 샘플의 `api_key=subscription_key` 같은 변수 이름은 키로 읽지 않는다.\
|
|
12
|
+
키가 대화나 명령에 드러났다면 Azure 포털의 Regenerate, Model Studio 콘솔의 API Key 관리에서 교체한다.
|
|
13
|
+
- 화면, 오류, 로그에 키가 실리지 않는다. `redact` 는 길이만 보인다(0.16.0 은 앞뒤 4자를 보였고, `sk-ws-` 키는 앞이 상수라 뒤 4자가 드러났다). URL 이 아닌 endpoint 값(붙여 넣은 키일 수 있음)은 원문 대신 `null` 이다. WAN 다운로드 실패 오류의 서명 URL 은 `err.videoUrl` 로만 읽히고 JSON 과 `console.error` 에는 실리지 않는다.
|
|
14
|
+
- WAN 풀 상태 파일은 무작위 이름의 임시 파일을 배타적으로 만들어 써서, 미리 심어 둔 링크를 따라 다른 파일을 덮지 않는다. 폴더는 0700, 파일은 0600 이고, 쓰기나 이름 바꾸기에 실패하면 임시 파일을 지운다.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- 서버의 `Retry-After` 를 1초에서 10분 사이로만 받는다. 0.16.0 은 `999999999` 면 키를 31년 쉬게 했고, `0` 이면 기다리지 않고 다시 시도했다. 쉬는 키의 기록은 `resetPoolState({ creds, labels })` 로 지운다.
|
|
18
|
+
- setup 프리플라이트가 status 0 의 이유(`EINSECURE`, `ERR_INVALID_URL` 등)를 보인다. FLUX 가 다른 리소스면 그 키를 `AZURE_IMAGE_SERVICES_API_KEY` 로 키 파일에 적는다.
|
|
19
|
+
|
|
3
20
|
## [0.16.0] - 2026-10-11
|
|
4
21
|
|
|
5
22
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaydash9999/banker-plugins",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"banker": "bin/banker.js"
|
|
@@ -60,7 +60,9 @@ if (creds.FFMPEG_PATH && !process.env.FFMPEG_PATH) process.env.FFMPEG_PATH = cre
|
|
|
60
60
|
- 둘 다 통과해야 다음 단계로 간다.
|
|
61
61
|
- 영상 provider 상태를 무료로 확인해 둔다.
|
|
62
62
|
`poolSummary({ creds })` 는 WAN 키별 상태(`ok`, `cooldown`, `exhausted`, `invalid`, `malformed`)를 키 원문 없이 돌려준다.
|
|
63
|
-
|
|
63
|
+
쉬는 키를 바로 다시 쓰려면 `resetPoolState({ creds, labels: ['WAN_2'] })` 로 그 키의 기록을 지운다(`labels` 를 빼면 전체). 서버의 `Retry-After` 는 1초에서 10분 사이로만 받는다.
|
|
64
|
+
`malformed` 는 endpoint 가 https URL 이 아니거나(http 는 이 기기의 localhost 만 허용) 모델이 한 id 가 아닌 항목이다(`reason` 이 `endpoint` 또는 `model`). 영상 단계는 이 항목을 건너뛰고, 모두 `malformed` 면 영상 단계에서 멈춘다. 그러니 유료 스틸을 만들기 전에 사용자에게 알리고 `3d-intro-setup` 으로 고친다.
|
|
65
|
+
Azure 호출도 같다. 키는 https 로만 보내고, `http://` endpoint 는 보내기 전에 막는다(Sora 영상 단계는 `invalid_request`, 이미지 생성 같은 그 밖의 호출은 `HTTP 0 (EINSECURE)` 오류).
|
|
64
66
|
WAN 항목(`WAN_<n>_ENDPOINT`, `WAN_<n>_API_KEY`)이 하나도 없으면 영상은 처음부터 Sora 로 만든다. 이때 정지와 비행 모드(Step 4B)는 쓸 수 없다.
|
|
65
67
|
- 독립 검증(Step 6)에는 Node 용 playwright 패키지와 Chromium 이 필요하다. `npx playwright install chromium`(`setup-playwright`)은 브라우저만 받으므로, 패키지도 `npm i -g playwright` 나 프로젝트의 `npm i -D playwright` 로 설치해 둔다.
|
|
66
68
|
|
|
@@ -134,6 +136,7 @@ const png = await generateImage({ endpoint, key, deployment, prompt, size: ORIEN
|
|
|
134
136
|
`generateImage` 는 서버의 `Retry-After` 만큼 기다렸다가 같은 경로로 다시 시도한다(기본 3회, `retry429`). 그래도 실패하면 씬 사이에 간격을 두고 다시 부른다.
|
|
135
137
|
- **변형(권장)** - 씬마다 테이크를 2개 만들면 사용자가 비교해 고를 수 있다. 스틸 비용이 대략 씬당 2배이니 Step 2 추정에 미리 넣는다.
|
|
136
138
|
- **FLUX 옵트인** - 씬 간 톤, 질감 일관성을 더 원하면 `generateImageFlux({ width, height })`(ORIENTATION 을 나눠 전달).
|
|
139
|
+
`endpoint` 는 `AZURE_IMAGE_SERVICES_ENDPOINT`, `key` 는 `AZURE_IMAGE_SERVICES_API_KEY` 이고 이 줄이 없으면 `AZURE_IMAGE_API_KEY` 다.
|
|
137
140
|
FLUX 크리덴셜을 요청했는데 미수집이면 조용히 실패하지 말고 `gpt-image-2` 로 **정직하게 폴백**한다("FLUX 미수집, gpt-image-2 로 진행").
|
|
138
141
|
- **N3 주의(씨앗 스틸 크기)** - `generateImage` 의 classic 폴백이 뜨면 반환 크기가 `1024x1536`(2:3)라 `720x1280` 이 아니다.
|
|
139
142
|
체인 씨앗이 되는 스틸은 `probeDims` 로 확인해 ORIENTATION 과 다르면 영상 전에 리사이즈(ffmpeg `scale`)하거나 재생성한다.
|
|
@@ -36,8 +36,9 @@ function originOf(url) {
|
|
|
36
36
|
const u = new URL(raw);
|
|
37
37
|
return `${u.protocol}//${u.host}`;
|
|
38
38
|
} catch {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// Not a URL: null, never the raw text, which may be a key pasted into the endpoint field.
|
|
40
|
+
const m = raw.match(/^(https?:\/\/[^/\s]+)/i);
|
|
41
|
+
return m ? m[1] : null;
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -50,6 +51,10 @@ function truncate(x, n = 300) {
|
|
|
50
51
|
return s.length > n ? `${s.slice(0, n)}…(+${s.length - n})` : s;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// Why an azFetch result has no HTTP answer, for error messages: " (EINSECURE)" and the like. The
|
|
55
|
+
// error's code or name only, never its text: fetch can quote a header value, a key included.
|
|
56
|
+
const why = (r) => (r.error ? ` (${r.error.cause?.code || r.error.code || r.error.name})` : '');
|
|
57
|
+
|
|
53
58
|
// Last n chars of ffmpeg stderr (the useful part) for error messages.
|
|
54
59
|
const tail = (s, n = 400) => { const str = String(s || ''); return str.length > n ? str.slice(-n) : str; };
|
|
55
60
|
|
|
@@ -93,9 +98,12 @@ export function parseConsoleSample(text) {
|
|
|
93
98
|
if (m) out.deployment = m[1];
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
// key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>"
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
// key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>", "api-key": "<k>",
|
|
102
|
+
// apiKey: '<k>' (python/JS SDK). An SDK value counts only in quotes: api_key=subscription_key or
|
|
103
|
+
// apiKey: process.env.X names a variable, and taking that name for the key would replace a real one.
|
|
104
|
+
m = s.match(/Authorization["']?\s*[:=]\s*["']?\s*Bearer\s+([A-Za-z0-9._-]+)/i)
|
|
105
|
+
|| s.match(/api-key\s*:\s*([A-Za-z0-9._-]+)/i)
|
|
106
|
+
|| s.match(/api[-_]?key["']?\s*[:=]\s*["']([A-Za-z0-9._-]+)["']/i);
|
|
99
107
|
if (m) out.apiKey = m[1];
|
|
100
108
|
|
|
101
109
|
return out;
|
|
@@ -164,18 +172,84 @@ function envLiteral(v) {
|
|
|
164
172
|
return v.includes('"') ? `'${v}'` : `"${v}"`;
|
|
165
173
|
}
|
|
166
174
|
|
|
167
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* Redact a secret to its length only, `…(len)`: no character of it, since keys share fixed
|
|
177
|
+
* prefixes (`sk-ws-`) and a shown tail narrows them down. Falsy -> "(missing)".
|
|
178
|
+
*/
|
|
168
179
|
export function redact(s) {
|
|
169
180
|
if (!s) return '(missing)';
|
|
170
|
-
|
|
171
|
-
if (str.length <= 8) return `…(${str.length})`;
|
|
172
|
-
return `${str.slice(0, 4)}…${str.slice(-4)}(${str.length})`;
|
|
181
|
+
return `…(${String(s).length})`;
|
|
173
182
|
}
|
|
174
183
|
|
|
175
184
|
// ---------------------------------------------------------------------------
|
|
176
185
|
// HTTP
|
|
177
186
|
// ---------------------------------------------------------------------------
|
|
178
187
|
|
|
188
|
+
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
|
189
|
+
const SECRET_HEADERS = new Set(['authorization', 'api-key', 'proxy-authorization', 'cookie']);
|
|
190
|
+
const MAX_HOPS = 5;
|
|
191
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
192
|
+
const localHttp = (u) => u.protocol === 'http:' && LOOPBACK.has(u.hostname);
|
|
193
|
+
const refused = (code, message) => Object.assign(new TypeError(`fetch failed: ${message}`), { cause: { code } });
|
|
194
|
+
const isSecret = (name) => SECRET_HEADERS.has(name.toLowerCase());
|
|
195
|
+
const withoutSecrets = (headers) => Object.fromEntries(Object.entries(headers).filter(([k]) => !isSecret(k)));
|
|
196
|
+
// Every form fetch takes (an object, [name, value] pairs, Headers) as one plain object, so a key is
|
|
197
|
+
// found in any of them. Pairs would otherwise read as headers named "0", "1".
|
|
198
|
+
const plainHeaders = (h) => (Array.isArray(h) || h instanceof Headers ? Object.fromEntries(new Headers(h)) : { ...h });
|
|
199
|
+
|
|
200
|
+
/** Whether a key may go to this URL: over https, or over http only to this machine (a local mock or proxy). */
|
|
201
|
+
export const safeForKey = (u) => u.protocol === 'https:' || localHttp(u);
|
|
202
|
+
|
|
203
|
+
// Where a redirect goes. The request was sent already, so a Location that is not a URL, or one that
|
|
204
|
+
// leaves https, ends it with a code that does not read as "nothing sent". http stays allowed only
|
|
205
|
+
// while the request is on this machine over http (a local mock); from anywhere else, an https
|
|
206
|
+
// localhost included, it would reach a service here or on the network.
|
|
207
|
+
function redirectTarget(location, from) {
|
|
208
|
+
let next;
|
|
209
|
+
try { next = new URL(location, from); } catch { throw refused('EBADREDIRECT', 'the redirect location is not a URL'); }
|
|
210
|
+
if (next.protocol === 'https:' || (localHttp(next) && localHttp(from))) return next;
|
|
211
|
+
throw refused('EINSECUREREDIRECT', 'a redirect away from https is not followed');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The request for the next hop of a redirect. A request with a body (a prompt, a frame, a paid submit)
|
|
215
|
+
// follows only a 307 or 308 within its origin, which send it again as it was. A 301, 302 or 303 would
|
|
216
|
+
// make it a GET, and another origin is another server: either way the next answer does not answer this
|
|
217
|
+
// request, and its 401 or 429 would read as this key's (a key retired, a submit sent again elsewhere).
|
|
218
|
+
// Without a body, a 303, and a 301 or 302 after a POST, go on as a GET, as browsers do, and a hop to
|
|
219
|
+
// another origin goes without the secret headers.
|
|
220
|
+
function nextHop(req, status, crossOrigin) {
|
|
221
|
+
if (req.body != null && (crossOrigin || status < 307)) {
|
|
222
|
+
throw refused(crossOrigin ? 'ECROSSREDIRECT' : 'EBODYREDIRECT', 'a request with a body follows only a 307 or 308 within its origin');
|
|
223
|
+
}
|
|
224
|
+
const toGet = status === 303 || ((status === 301 || status === 302) && req.method === 'POST');
|
|
225
|
+
const next = toGet ? { ...req, method: 'GET', body: undefined } : req;
|
|
226
|
+
return crossOrigin ? { ...next, headers: withoutSecrets(next.headers) } : next;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* fetch that follows redirects by hand (at most 5): a request without a body as fetch would, but to
|
|
231
|
+
* another origin without the key headers; a request with a body only by a 307 or 308 within its
|
|
232
|
+
* origin (fetch itself forwards a custom `api-key` header and re-sends the body on 307 and 308 to
|
|
233
|
+
* any origin, and turns a POST into a GET on 301 to 303). A key goes only where safeForKey allows: an http
|
|
234
|
+
* URL with one is refused before anything is sent (cause.code EINSECURE), and no redirect leaves
|
|
235
|
+
* https. Throws as fetch does on a network error.
|
|
236
|
+
*/
|
|
237
|
+
export async function fetchGuarded(url, init = {}) {
|
|
238
|
+
let target = new URL(String(url));
|
|
239
|
+
let req = { ...init, headers: plainHeaders(init.headers), redirect: 'manual' };
|
|
240
|
+
if (!safeForKey(target) && Object.keys(req.headers).some(isSecret)) throw refused('EINSECURE', 'a key is sent only over https');
|
|
241
|
+
for (let hop = 0; hop <= MAX_HOPS; hop++) {
|
|
242
|
+
const r = await globalThis.fetch(target.href, req);
|
|
243
|
+
const location = REDIRECT_STATUS.has(r.status) ? r.headers.get('location') : null;
|
|
244
|
+
if (!location) return r;
|
|
245
|
+
try { await r.body?.cancel(); } catch { /* the redirect body is not needed */ }
|
|
246
|
+
const next = redirectTarget(location, target);
|
|
247
|
+
req = nextHop(req, r.status, next.origin !== target.origin);
|
|
248
|
+
target = next;
|
|
249
|
+
}
|
|
250
|
+
throw refused('ETOOMANYREDIRECTS', 'too many redirects');
|
|
251
|
+
}
|
|
252
|
+
|
|
179
253
|
// Retry-After / retry-after-ms response header -> milliseconds (null when absent or unparsable).
|
|
180
254
|
function retryAfterMsOf(headers) {
|
|
181
255
|
const ms = Number(headers.get('retry-after-ms'));
|
|
@@ -202,7 +276,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
|
|
|
202
276
|
if (body !== undefined) opts.body = body;
|
|
203
277
|
let r;
|
|
204
278
|
try {
|
|
205
|
-
r = await
|
|
279
|
+
r = await fetchGuarded(url, opts);
|
|
206
280
|
} catch (e) {
|
|
207
281
|
last = { status: 0, ok: false, ct: '', body: null, authStyle: null, error: e };
|
|
208
282
|
break; // network error is not an auth problem — do not retry other styles
|
|
@@ -260,7 +334,7 @@ export async function generateImage({
|
|
|
260
334
|
});
|
|
261
335
|
}
|
|
262
336
|
const b64 = r.body?.data?.[0]?.b64_json;
|
|
263
|
-
if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status} ${truncate(r.body)}`);
|
|
337
|
+
if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
264
338
|
return Buffer.from(b64, 'base64');
|
|
265
339
|
}
|
|
266
340
|
|
|
@@ -278,7 +352,7 @@ export async function editImage({
|
|
|
278
352
|
key, method: 'POST', body: fd,
|
|
279
353
|
});
|
|
280
354
|
const b64 = r.body?.data?.[0]?.b64_json;
|
|
281
|
-
if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
355
|
+
if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
282
356
|
return Buffer.from(b64, 'base64');
|
|
283
357
|
}
|
|
284
358
|
|
|
@@ -289,7 +363,7 @@ export async function generateImageFlux({ endpoint, key, prompt, width, height,
|
|
|
289
363
|
body: JSON.stringify({ prompt, width, height }),
|
|
290
364
|
});
|
|
291
365
|
const b64 = r.body?.data?.[0]?.b64_json || r.body?.b64_json || r.body?.image;
|
|
292
|
-
if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
366
|
+
if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
293
367
|
return Buffer.from(b64, 'base64');
|
|
294
368
|
}
|
|
295
369
|
|
|
@@ -322,7 +396,7 @@ export async function createVideo({
|
|
|
322
396
|
const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
|
|
323
397
|
if (!(r.ok && r.body?.id)) {
|
|
324
398
|
// status 0 = network error; errno tells a refused connection (nothing sent) from a lost one
|
|
325
|
-
const err = new Error(`createVideo failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
399
|
+
const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
326
400
|
err.status = r.status;
|
|
327
401
|
err.errno = r.error?.cause?.code || r.error?.code || null;
|
|
328
402
|
throw err;
|
|
@@ -368,7 +442,7 @@ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview'
|
|
|
368
442
|
const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
|
|
369
443
|
key, method: 'GET', binary: true,
|
|
370
444
|
});
|
|
371
|
-
if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}`);
|
|
445
|
+
if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`);
|
|
372
446
|
return r.body;
|
|
373
447
|
}
|
|
374
448
|
|
|
@@ -413,7 +487,7 @@ export async function createVideoTwoImage({
|
|
|
413
487
|
key, method: 'POST', body: fd,
|
|
414
488
|
});
|
|
415
489
|
const id = r.body?.id || r.body?.job_id;
|
|
416
|
-
if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
490
|
+
if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
417
491
|
return r.body;
|
|
418
492
|
}
|
|
419
493
|
|
|
@@ -37,7 +37,7 @@ import path from 'node:path';
|
|
|
37
37
|
import os from 'node:os';
|
|
38
38
|
import crypto from 'node:crypto';
|
|
39
39
|
import cp from 'node:child_process';
|
|
40
|
-
import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact } from './azure-adapter.mjs';
|
|
40
|
+
import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey } from './azure-adapter.mjs';
|
|
41
41
|
|
|
42
42
|
export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
|
|
43
43
|
const WAN_CREATE_PATH = '/api/v1/services/aigc/video-generation/video-synthesis';
|
|
@@ -50,6 +50,8 @@ const PROBE_TASK_ID = '00000000-0000-0000-0000-000000000000';
|
|
|
50
50
|
|
|
51
51
|
const RATE_BASE_MS = 30_000;
|
|
52
52
|
const RATE_MAX_MS = 10 * 60_000;
|
|
53
|
+
// A server Retry-After is held to this range: 0 would retry at once, a huge value rest a key for years.
|
|
54
|
+
const RETRY_MIN_MS = 1000;
|
|
53
55
|
const QUOTA_STRIKES_TO_EXHAUST = 3;
|
|
54
56
|
const QUOTA_STRIKE_TTL_MS = 3_600_000;
|
|
55
57
|
const TRANSIENT_BASE_MS = 15_000;
|
|
@@ -72,7 +74,9 @@ const TRANSIENT_CODES = new Set([
|
|
|
72
74
|
'InternalError', 'InternalError.Timeout', 'ServiceUnavailable', 'ModelUnavailable', 'Throttling.ServiceOverloaded',
|
|
73
75
|
]);
|
|
74
76
|
// Errors that mean the request never left this machine, so resubmitting elsewhere cannot double-bill.
|
|
75
|
-
|
|
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']);
|
|
76
80
|
|
|
77
81
|
/**
|
|
78
82
|
* Map a WAN failure to a pool action.
|
|
@@ -115,11 +119,13 @@ function codeKind(code) {
|
|
|
115
119
|
|
|
116
120
|
// Endpoints are origins: a pasted console URL with a path would otherwise 404 every call. A value
|
|
117
121
|
// new URL() rejects (a bare host, a value with spaces, a key pasted into the wrong field) gives
|
|
118
|
-
// null. The raw value is never echoed, because it may be a key.
|
|
122
|
+
// null. The raw value is never echoed, because it may be a key. The key goes only over https: an
|
|
123
|
+
// http:// endpoint (one typo) would send it in the clear, so it counts as malformed too, except on
|
|
124
|
+
// this machine (a local mock or proxy). safeForKey is the same rule fetchGuarded enforces.
|
|
119
125
|
function originOf(url) {
|
|
120
126
|
try {
|
|
121
127
|
const u = new URL(String(url).trim());
|
|
122
|
-
return
|
|
128
|
+
return safeForKey(u) && u.host ? `${u.protocol}//${u.host}` : null;
|
|
123
129
|
} catch { return null; }
|
|
124
130
|
}
|
|
125
131
|
const fingerprint = (endpoint, key, model) => crypto.createHash('sha256').update(`${endpoint}\n${key}\n${model}`).digest('hex').slice(0, 16);
|
|
@@ -136,8 +142,8 @@ const malformedField = (e) => (e.badEndpoint ? 'endpoint' : e.badModel ? 'model'
|
|
|
136
142
|
|
|
137
143
|
/**
|
|
138
144
|
* Build the WAN pool from creds: every WAN_<n>_ENDPOINT with a matching WAN_<n>_API_KEY, by n.
|
|
139
|
-
* An entry whose endpoint is not an
|
|
140
|
-
* (`badModel`) is kept so the preflight can report it; generateClip skips it.
|
|
145
|
+
* An entry whose endpoint is not an https URL (`badEndpoint`; http only to this machine) or whose
|
|
146
|
+
* model is not one model id (`badModel`) is kept so the preflight can report it; generateClip skips it.
|
|
141
147
|
*/
|
|
142
148
|
export function loadWanPool(creds = {}) {
|
|
143
149
|
const pool = [];
|
|
@@ -171,11 +177,20 @@ export function loadPoolState(file = defaultStatePath()) {
|
|
|
171
177
|
return { version: 1, entries: {} };
|
|
172
178
|
}
|
|
173
179
|
|
|
180
|
+
// Written through a fresh random name opened exclusively ('wx'), so a link someone planted at a
|
|
181
|
+
// guessable name is never followed; the folder is private when this creates it. A failed write or
|
|
182
|
+
// rename removes the temp file.
|
|
174
183
|
export function savePoolState(state, file = defaultStatePath()) {
|
|
175
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
176
|
-
const tmp = `${file}.${
|
|
177
|
-
fs.
|
|
178
|
-
|
|
184
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
185
|
+
const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
186
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
187
|
+
try {
|
|
188
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`); } finally { fs.closeSync(fd); }
|
|
189
|
+
fs.renameSync(tmp, file);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
fs.rmSync(tmp, { force: true });
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
179
194
|
try { fs.chmodSync(file, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
|
|
180
195
|
return file;
|
|
181
196
|
}
|
|
@@ -258,9 +273,9 @@ function pickEntry(pool, state, t, limit) {
|
|
|
258
273
|
function parseRetryAfter(h) {
|
|
259
274
|
if (!h) return null;
|
|
260
275
|
const sec = Number(h);
|
|
261
|
-
if (Number.isFinite(sec)) return Math.max(0, sec * 1000);
|
|
262
276
|
const at = Date.parse(h);
|
|
263
|
-
|
|
277
|
+
const ms = Number.isFinite(sec) ? sec * 1000 : Number.isFinite(at) ? at - Date.now() : null;
|
|
278
|
+
return ms === null ? null : Math.min(RATE_MAX_MS, Math.max(RETRY_MIN_MS, ms));
|
|
264
279
|
}
|
|
265
280
|
|
|
266
281
|
const WAN_FETCH_DEFAULTS = { method: 'GET', binary: false, timeoutMs: API_TIMEOUT_MS };
|
|
@@ -269,7 +284,7 @@ async function wanFetch(url, opts = {}) {
|
|
|
269
284
|
const o = { ...WAN_FETCH_DEFAULTS, ...defined(opts) };
|
|
270
285
|
let r;
|
|
271
286
|
try {
|
|
272
|
-
r = await
|
|
287
|
+
r = await fetchGuarded(url, {
|
|
273
288
|
method: o.method, headers: wanHeaders(o), body: o.body === undefined ? undefined : JSON.stringify(o.body), signal: AbortSignal.timeout(o.timeoutMs),
|
|
274
289
|
});
|
|
275
290
|
} catch (e) {
|
|
@@ -391,7 +406,13 @@ export async function wanPollTask({ entry, taskId, intervalMs = 15_000, timeoutM
|
|
|
391
406
|
}
|
|
392
407
|
|
|
393
408
|
/** Download a finished WAN video (signed URL, no auth header). Returns an mp4 Buffer. */
|
|
409
|
+
// The video URL comes from the provider's answer: only https is fetched.
|
|
410
|
+
function httpsOnly(url) {
|
|
411
|
+
if (!/^https:\/\//i.test(String(url))) throw new Error('WAN video download refused: the video_url is not https');
|
|
412
|
+
}
|
|
413
|
+
|
|
394
414
|
export async function downloadWanVideo(url, { sleep = realSleep, tries = 3 } = {}) {
|
|
415
|
+
httpsOnly(url);
|
|
395
416
|
let last;
|
|
396
417
|
for (let i = 0; i < tries; i++) {
|
|
397
418
|
last = await wanFetch(url, { binary: true, timeoutMs: DOWNLOAD_TIMEOUT_MS });
|
|
@@ -436,9 +457,12 @@ export class ContentRejectedError extends Error {
|
|
|
436
457
|
|
|
437
458
|
// An error that must NOT be retried by resubmitting the leg: the task may exist and bill.
|
|
438
459
|
// kind: timeout | submit_unknown | download | no_video | task_lost. Carries taskId/label when known.
|
|
439
|
-
|
|
460
|
+
// A stop the caller can act on. `props` are plain properties (they show in logs); `hidden` ones (a
|
|
461
|
+
// signed download URL) read as err.<name> but stay out of JSON.stringify and console.error.
|
|
462
|
+
function stopError(kind, message, props = {}, hidden = {}) {
|
|
440
463
|
const err = new Error(message);
|
|
441
464
|
Object.assign(err, { kind, ...props });
|
|
465
|
+
for (const [k, v] of Object.entries(hidden)) Object.defineProperty(err, k, { value: v, enumerable: false });
|
|
442
466
|
return err;
|
|
443
467
|
}
|
|
444
468
|
|
|
@@ -530,8 +554,8 @@ async function succeededClip(ctx, e, taskId, p, seconds) {
|
|
|
530
554
|
try {
|
|
531
555
|
mp4 = await downloadWanVideo(videoUrl, { sleep: ctx.sleep });
|
|
532
556
|
} catch (err) {
|
|
533
|
-
// videoUrl is a signed URL:
|
|
534
|
-
throw stopError('download', `${err.message} for WAN task ${taskId} (${e.label}); resume with resumeClip within 24 h`, { provider: 'wan', taskId, label: e.label,
|
|
557
|
+
// videoUrl is a signed URL (valid 24 h): readable as err.videoUrl for recovery, never in the message or a log
|
|
558
|
+
throw stopError('download', `${err.message} for WAN task ${taskId} (${e.label}); resume with resumeClip within 24 h`, { provider: 'wan', taskId, label: e.label, seconds }, { videoUrl });
|
|
535
559
|
}
|
|
536
560
|
ctx.emit({ type: 'done', provider: 'wan', label: e.label, taskId });
|
|
537
561
|
return { mp4, provider: 'wan', label: e.label, model: shownModel(e), seconds: seconds ?? p.usage?.duration ?? null, taskId, usage: p.usage };
|
|
@@ -563,7 +587,7 @@ async function runWan(ctx) {
|
|
|
563
587
|
function usablePool(ctx) {
|
|
564
588
|
const all = loadWanPool(ctx.creds);
|
|
565
589
|
const bad = all.filter(malformedField);
|
|
566
|
-
for (const b of bad) ctx.emit({ type: 'skip', provider: 'wan', label: b.label, reason: `${malformedField(b)} is malformed (not an
|
|
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)` });
|
|
567
591
|
if (all.length && bad.length === all.length) {
|
|
568
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' });
|
|
569
593
|
}
|
|
@@ -722,7 +746,8 @@ const DEFAULTS = { size: '720x1280', seconds: 5, pollIntervalMs: 15_000, pollTim
|
|
|
722
746
|
*
|
|
723
747
|
* Throws (never resubmitting the leg) when retrying could pay twice or cannot help:
|
|
724
748
|
* ContentRejectedError moderation — change the prompt/frame
|
|
725
|
-
* kind 'invalid_request' malformed request, ModelNotFound
|
|
749
|
+
* kind 'invalid_request' malformed request, ModelNotFound, every WAN entry malformed or a
|
|
750
|
+
* Sora endpoint that is not https (config)
|
|
726
751
|
* kind 'timeout' | 'download' the task exists — call resumeClip({ ..., provider, label, taskId })
|
|
727
752
|
* kind 'submit_unknown' | 'task_lost' | 'no_video' | 'failed' outcome unknown — check the console
|
|
728
753
|
* ('failed': the task ended for a reason the pool does not know)
|
|
@@ -741,7 +766,7 @@ export async function generateClip(opts = {}) {
|
|
|
741
766
|
const r = await runWan(ctx);
|
|
742
767
|
if (r) return r;
|
|
743
768
|
} else if (p === 'sora') {
|
|
744
|
-
if (!
|
|
769
|
+
if (!soraReady(ctx)) continue;
|
|
745
770
|
tried.push('sora');
|
|
746
771
|
ctx.emit({ type: 'fallback', provider: 'sora', from: tried.slice(0, -1) });
|
|
747
772
|
return runSora(ctx);
|
|
@@ -750,6 +775,18 @@ export async function generateClip(opts = {}) {
|
|
|
750
775
|
throw new Error(`generateClip: no video provider available (tried: ${tried.join(', ') || 'none'})`);
|
|
751
776
|
}
|
|
752
777
|
|
|
778
|
+
// Whether Sora can take the leg. Not set up: skipped with an event. An endpoint that is set but not
|
|
779
|
+
// https (http only to this machine) stops the leg before the key is sent in the clear.
|
|
780
|
+
function soraReady(ctx) {
|
|
781
|
+
const { AZURE_SORA_ENDPOINT: endpoint, AZURE_SORA_API_KEY: key } = ctx.creds;
|
|
782
|
+
if (!endpoint || !key) {
|
|
783
|
+
ctx.emit({ type: 'skip', provider: 'sora', reason: 'AZURE_SORA_ENDPOINT/API_KEY missing' });
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
if (!originOf(endpoint)) throw stopError('invalid_request', 'AZURE_SORA_ENDPOINT is not an https URL; fix the env file — nothing was sent', { provider: 'sora' });
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
|
|
753
790
|
/**
|
|
754
791
|
* Finish a clip whose task already exists (after a 'timeout' or 'download' error) without
|
|
755
792
|
* submitting anything new. FREE except for the work already billed. Same return shape as
|
|
@@ -763,7 +800,7 @@ export async function resumeClip(opts = {}) {
|
|
|
763
800
|
const ctx = makeCtx(o);
|
|
764
801
|
if (o.provider !== 'sora') return resumeWan(ctx, o);
|
|
765
802
|
if (!originOf(o.creds.AZURE_SORA_ENDPOINT) || !o.creds.AZURE_SORA_API_KEY) {
|
|
766
|
-
throw stopError('invalid_request', 'resumeClip: resuming a Sora job needs AZURE_SORA_ENDPOINT as an
|
|
803
|
+
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 });
|
|
767
804
|
}
|
|
768
805
|
return finishSoraJob(ctx, o.taskId, o.seconds == null ? null : snapSoraSeconds(o.seconds));
|
|
769
806
|
}
|
|
@@ -772,7 +809,7 @@ async function resumeWan(ctx, o) {
|
|
|
772
809
|
const e = loadWanPool(o.creds).find((x) => x.label === o.label);
|
|
773
810
|
if (!e) throw new Error(`resumeClip: ${o.label} is not in the WAN pool (was the key removed?)`);
|
|
774
811
|
if (e.badEndpoint) {
|
|
775
|
-
throw stopError('invalid_request', `resumeClip: ${o.label} endpoint is not an
|
|
812
|
+
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 });
|
|
776
813
|
}
|
|
777
814
|
const out = await finishWanTask(ctx, e, o.taskId, { duration: Math.round(Number(o.seconds) || 0) || null });
|
|
778
815
|
if (out.clip) return out.clip;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: 3d-intro-setup
|
|
3
|
-
description: "`3d-intro-build` 에 필요한 Node 18 이상과 ffmpeg 를 갖추고 Azure 크레덴셜과 선택 항목인 WAN API 키 풀을 저장함.
|
|
3
|
+
description: "`3d-intro-build` 에 필요한 Node 18 이상과 ffmpeg 를 갖추고 Azure 크레덴셜과 선택 항목인 WAN API 키 풀을 저장함. 엔드포인트와 배포명은 대화로 받고, 키는 사람이 비공개 키 파일에 직접 적거나 콘솔 샘플 파일에서 화면에 내지 않고 합쳐(채팅과 명령줄에 키를 싣지 않음), 무과금 프리플라이트로 확인. 'setup-3d-intro'/'3d-intro-setup'/'3D 인트로 설정'/'Azure Sora 크레덴셜' 또는 `/banker:setup` 시 사용."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 3d-intro-setup — Azure Sora-2 / gpt-image-2 3D 인트로 제작 전제조건 설치
|
|
@@ -94,64 +94,97 @@ Sora(영상)와 gpt-image(이미지)는 **보통 서로 다른 Azure 리소스**
|
|
|
94
94
|
WAN 영상 풀(§3-WAN)은 선택이지만, 있으면 영상이 WAN 으로 먼저 만들어진다.
|
|
95
95
|
FLUX.2-pro(대체 이미지 생성)는 **opt-in**: 필요 없으면 완전히 건너뛴다(§3-FLUX 참조).
|
|
96
96
|
|
|
97
|
-
###
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
AI Foundry 리소스(`https://<resource>.services.ai.azure.com`)도 그대로 쓴다. 콘솔이 `.../models` 를 붙여 보여줘도 저장은 origin 까지만 한다(이미지 경로는 `/openai/v1/images/...` 이고 `/models/images/...` 는 404).
|
|
97
|
+
### 키는 사람이 파일에 적는다
|
|
98
|
+
**API key 는 채팅으로 받지 않고, 에이전트가 실행하는 명령에도 넣지 않는다.** `node -e "..."` 인자는 `ps` 출력과 대화 기록에 그대로 남는다.
|
|
99
|
+
키는 사람이 아래 비공개 키 파일에 편집기로 직접 적거나(모드 a), 콘솔 샘플을 파일로 저장해 에이전트가 화면에 내지 않고 합친다(모드 b).
|
|
100
|
+
엔드포인트, 배포명, API 버전은 비밀이 아니라 대화로 받아도 된다.
|
|
102
101
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
붙여넣은 원문은 세션 스크래치 임시 파일에 저장 → `parseConsoleSample()` 로 파싱 → **파싱 직후 임시 파일 즉시 삭제**(원문에 raw key 가 그대로 들어있다).
|
|
102
|
+
키 파일은 먼저 비공개로 만든다(이미 있으면 그대로 둔다). `echo >>` 로 새 파일을 만들면 0644 로 남는다.
|
|
103
|
+
홈 키 파일(아래 fallback)이 이미 있으면 프로젝트 파일을 만들지 않는다. 빈 프로젝트 파일이 먼저 읽혀 홈의 키가 가려진다.
|
|
106
104
|
```bash
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const fs = require('fs');
|
|
110
|
-
const { parseConsoleSample, redact } = await import('$ADAPTER');
|
|
111
|
-
const text = fs.readFileSync(process.argv[1], 'utf8');
|
|
112
|
-
const p = parseConsoleSample(text);
|
|
113
|
-
console.log(JSON.stringify({ endpoint: p.endpoint, deployment: p.deployment, apiVersion: p.apiVersion, apiKey: redact(p.apiKey) }));
|
|
114
|
-
})();
|
|
115
|
-
" "<붙여넣은 샘플을 저장한 임시 파일 절대경로로 치환>"
|
|
105
|
+
[ -e ~/.config/banker/3d-intro/env ] || (umask 077 && touch .env.3d-intro.local)
|
|
106
|
+
ls -l .env.3d-intro.local ~/.config/banker/3d-intro/env 2>/dev/null || true # 있는 파일이 -rw------- 면 된다
|
|
116
107
|
```
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
### 저장
|
|
121
|
-
아래 필드로 합쳐 `persistCreds()` 로 저장한다(0600 자동 적용, 원문 재출력 안 함):
|
|
108
|
+
여러 프로젝트에서 재사용하려면 프로젝트 파일 대신 홈 폴더 fallback 을 쓴다. `resolveCreds` 가 project-local 다음으로 찾는 경로다.
|
|
109
|
+
```bash
|
|
110
|
+
mkdir -p ~/.config/banker/3d-intro && chmod 700 ~/.config/banker/3d-intro && (umask 077 && touch ~/.config/banker/3d-intro/env)
|
|
122
111
|
```
|
|
123
|
-
|
|
124
|
-
|
|
112
|
+
그 프로젝트의 `.gitignore` 에 무시 규칙이 있는지 확인하고 없으면 추가한다(이 저장소는 `.env.*.local` 로 이미 커버된다):
|
|
113
|
+
```bash
|
|
114
|
+
grep -qE '^\.env\.3d-intro\.local$|^\.env\.\*\.local$' .gitignore 2>/dev/null || echo '.env.3d-intro.local' >> .gitignore
|
|
125
115
|
```
|
|
126
|
-
|
|
116
|
+
|
|
117
|
+
### 모드 (a) — 직접 입력
|
|
118
|
+
엔드포인트와 배포명을 리소스별로 대화로 묻는다: endpoint(예: `https://<resource>.openai.azure.com` 또는 `https://<resource>.cognitiveservices.azure.com`), 배포명(deployment).
|
|
119
|
+
- **Sora**: endpoint (+ 배포명, 기본 `sora-2` — 콘솔에서 다른 이름으로 배포했으면 그 이름).
|
|
120
|
+
- **Image(gpt-image 계열)**: endpoint + 배포명(예: `gpt-image-2`, `gpt-image-2.5-sunburst`. 필수 — 생성 호출의 `model` 필드로 그대로 쓰인다).
|
|
121
|
+
AI Foundry 리소스(`https://<resource>.services.ai.azure.com`)도 그대로 쓴다. 콘솔이 `.../models` 를 붙여 보여줘도 저장은 origin 까지만 한다(이미지 경로는 `/openai/v1/images/...` 이고 `/models/images/...` 는 404).
|
|
122
|
+
|
|
123
|
+
비밀이 아닌 값은 에이전트가 `persistCreds()` 로 합친다(파일 전체를 새로 쓰되 기존 값, 사람이 적은 키 포함, 은 그대로 둔다. 0600 자동 적용). 키 칸은 이 명령에 넣지 않는다.
|
|
124
|
+
쓰는 곳은 `resolveCreds` 가 이미 읽는 키 파일(홈 fallback 포함)이고, 없을 때만 프로젝트 파일이다. 홈 파일을 쓰는 사람에게 프로젝트 파일을 새로 만들면 그 파일이 먼저 읽혀 홈의 키가 가려진다:
|
|
127
125
|
```bash
|
|
128
126
|
node -e "
|
|
129
127
|
(async () => {
|
|
130
128
|
const { persistCreds, resolveCreds } = await import('$ADAPTER');
|
|
131
129
|
const path = require('path');
|
|
132
|
-
const target = path.join(process.cwd(), '.env.3d-intro.local'); // 프로젝트 전용(기본, 권장)
|
|
133
130
|
const prev = resolveCreds({ projectDir: process.cwd() });
|
|
134
|
-
const
|
|
135
|
-
const written = persistCreds({ ...
|
|
136
|
-
AZURE_SORA_ENDPOINT: '<입력값>',
|
|
137
|
-
|
|
138
|
-
AZURE_IMAGE_OPENAI_ENDPOINT: '<입력값>', AZURE_IMAGE_API_KEY: '<입력값>',
|
|
139
|
-
AZURE_GPT_IMAGE_DEPLOYMENT: '<입력값>', AZURE_GPT_IMAGE_API_VERSION: '<입력값 또는 preview>',
|
|
140
|
-
// WAN 풀(선택): 키 수만큼 WAN_<n>_ENDPOINT / WAN_<n>_API_KEY, 필요하면 WAN_MODEL
|
|
131
|
+
const target = prev._source || path.join(process.cwd(), '.env.3d-intro.local');
|
|
132
|
+
const written = persistCreds({ ...prev,
|
|
133
|
+
AZURE_SORA_ENDPOINT: '<입력값>', AZURE_SORA_DEPLOYMENT: '<입력값 또는 sora-2>', AZURE_SORA_API_VERSION: '<입력값 또는 preview>',
|
|
134
|
+
AZURE_IMAGE_OPENAI_ENDPOINT: '<입력값>', AZURE_GPT_IMAGE_DEPLOYMENT: '<입력값>', AZURE_GPT_IMAGE_API_VERSION: '<입력값 또는 preview>',
|
|
141
135
|
}, { target });
|
|
142
136
|
console.log('저장:', written);
|
|
143
137
|
})();
|
|
144
138
|
"
|
|
145
139
|
```
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
그다음 사람에게 키 파일을 편집기로 열어 키 줄을 직접 적어 달라고 한다(값은 대화에 붙이지 않는다):
|
|
141
|
+
```
|
|
142
|
+
AZURE_SORA_API_KEY=<Sora 리소스 키>
|
|
143
|
+
AZURE_IMAGE_API_KEY=<Image 리소스 키>
|
|
144
|
+
```
|
|
145
|
+
적었다고 하면 위 "이미 있으면" 확인을 다시 돌린다. 키는 길이로만 표시된다.
|
|
146
|
+
|
|
147
|
+
### 모드 (b) — 콘솔 샘플 파일
|
|
148
|
+
Azure AI Foundry/AI Hub 콘솔의 "Sample code"/"View code" 패널이 주는 curl 또는 Python 스니펫에는 원문 키가 들어 있다. 사람이 이것을 **채팅에 붙이지 않고** 비공개 파일에 저장한다:
|
|
148
149
|
```bash
|
|
149
|
-
|
|
150
|
+
mkdir -p ~/.config/banker/3d-intro && (umask 077 && touch ~/.config/banker/3d-intro/sample.txt) # 사람이 이 파일에 샘플을 붙여 저장
|
|
151
|
+
```
|
|
152
|
+
에이전트는 그 파일을 읽어 키를 화면에 내지 않고 키 파일에 바로 합친 뒤 샘플 파일을 지운다. 리소스마다 마지막 인자를 `sora`, `image`, `wan:<n>` 중 하나로 준다:
|
|
153
|
+
```bash
|
|
154
|
+
node -e "
|
|
155
|
+
(async () => {
|
|
156
|
+
const fs = require('fs'), path = require('path');
|
|
157
|
+
const { parseConsoleSample, persistCreds, resolveCreds, redact } = await import('$ADAPTER');
|
|
158
|
+
const [file, kind] = process.argv.slice(1);
|
|
159
|
+
const p = parseConsoleSample(fs.readFileSync(file, 'utf8'));
|
|
160
|
+
const names = kind === 'sora' ? { endpoint: 'AZURE_SORA_ENDPOINT', apiKey: 'AZURE_SORA_API_KEY', deployment: 'AZURE_SORA_DEPLOYMENT', apiVersion: 'AZURE_SORA_API_VERSION' }
|
|
161
|
+
: kind === 'image' ? { endpoint: 'AZURE_IMAGE_OPENAI_ENDPOINT', apiKey: 'AZURE_IMAGE_API_KEY', deployment: 'AZURE_GPT_IMAGE_DEPLOYMENT', apiVersion: 'AZURE_GPT_IMAGE_API_VERSION' }
|
|
162
|
+
: { endpoint: 'WAN_' + kind.slice(4) + '_ENDPOINT', apiKey: 'WAN_' + kind.slice(4) + '_API_KEY' };
|
|
163
|
+
const prev = resolveCreds({ projectDir: process.cwd() });
|
|
164
|
+
const target = prev._source || path.join(process.cwd(), '.env.3d-intro.local');
|
|
165
|
+
const add = Object.fromEntries(Object.entries(names).filter(([f]) => p[f]).map(([f, name]) => [name, p[f]]));
|
|
166
|
+
const was = (() => { try { return new URL(prev[names.endpoint]).origin; } catch { return null; } })();
|
|
167
|
+
if (p.endpoint && was && p.endpoint !== was && add[names.apiKey] === undefined) { // 다른 리소스인데 키가 없음
|
|
168
|
+
console.log(JSON.stringify({ file: target, endpointChanged: { from: was, to: p.endpoint } }));
|
|
169
|
+
process.exit(3);
|
|
170
|
+
}
|
|
171
|
+
const old = prev[names.apiKey];
|
|
172
|
+
persistCreds({ ...prev, ...add }, { target });
|
|
173
|
+
const replaced = old && add[names.apiKey] && old !== add[names.apiKey] ? redact(old) : null;
|
|
174
|
+
console.log(JSON.stringify({ file: target, endpoint: p.endpoint, deployment: p.deployment, apiVersion: p.apiVersion, apiKey: redact(p.apiKey), replaced }));
|
|
175
|
+
})();
|
|
176
|
+
" "$HOME/.config/banker/3d-intro/sample.txt" sora
|
|
177
|
+
rm -f "$HOME/.config/banker/3d-intro/sample.txt"
|
|
150
178
|
```
|
|
179
|
+
파싱 결과(저장한 파일, 엔드포인트, 배포명, apiVersion, 길이만 보이는 key)를 사람에게 보여 주고 확인받는다.
|
|
180
|
+
`replaced` 에 길이가 보이면 파일에 있던 다른 키를 이 샘플의 키로 바꾼 것이다. 교체(로테이션)가 아니었다면 사람이 키 파일의 키 줄을 다시 적는다.
|
|
181
|
+
출력에 `endpointChanged` 가 있으면(node 는 exit 3, 블록은 다음 `rm -f` 때문에 0) 샘플의 endpoint 가 키 파일의 것과 다른데 샘플에 키가 없다는 뜻이다. 기존 키가 새 host 로 가지 않도록 아무것도 저장하지 않았다. 다른 리소스로 바꾸는 것이면 사람이 키 파일의 endpoint 와 키 줄을 함께 고친다.
|
|
182
|
+
샘플의 SDK 코드에서 키는 따옴표 안의 값만 읽는다. `api_key=subscription_key` 처럼 변수 이름이면 키로 보지 않아 기존 키를 덮지 않는다.
|
|
183
|
+
필드가 `null` 이면(정규식 미매치, 또는 URL 이 아닌 endpoint) 그 필드만 모드 (a) 로 보완한다. 키가 샘플에 변수(`$AZURE_API_KEY` 등)로만 있으면 키 파일에 사람이 직접 적는다.
|
|
151
184
|
|
|
152
185
|
### WAN 영상 풀 (선택, 권장)
|
|
153
186
|
Alibaba Cloud Model Studio 의 WAN 영상 모델(기본 `wan3.0-video-prime`)을 여러 API 키로 돌린다. 키(workspace)마다 할당량이 따로라 키가 많을수록 오래 쓴다.
|
|
154
|
-
키마다 아래 두 줄을 번호를
|
|
187
|
+
키마다 아래 두 줄을 번호를 붙여, 위 비공개 키 파일에 사람이 직접 적는다(콘솔 샘플 파일이 있으면 모드 (b) 를 `wan:<n>` 으로 돌린다). 번호 개수 제한은 없다.
|
|
155
188
|
```
|
|
156
189
|
# endpoint 는 origin 만 (경로 제외)
|
|
157
190
|
WAN_1_ENDPOINT=https://ws-<workspace-id>.<region>.maas.aliyuncs.com
|
|
@@ -161,15 +194,15 @@ WAN_2_API_KEY=...
|
|
|
161
194
|
# 선택. 키 하나만 모델이 다르면 WAN_<n>_MODEL
|
|
162
195
|
WAN_MODEL=wan3.0-video-prime
|
|
163
196
|
```
|
|
164
|
-
- 콘솔 샘플(curl)
|
|
197
|
+
- 콘솔 샘플(curl)은 모드 (b) 로 파일에서 읽는다. 키는 샘플에 실제 값(`Bearer sk-ws-...`)이 있을 때만 뽑히고, `$DASHSCOPE_API_KEY` 같은 변수면 사람이 키 파일에 직접 적는다. JSON 본문의 `"model"` 은 뽑히지 않으니 `WAN_MODEL` 로 따로 넣는다.
|
|
165
198
|
- 엔드포인트, 키, 모델의 리전이 같아야 한다. 다르면 `401 InvalidApiKey` 가 난다.
|
|
166
199
|
- 값 뒤의 주석은 `#` 앞에 공백이 있어야 주석으로 읽힌다(`값 # 메모`). 공백 없이 붙은 `#` 는 값의 일부다. 값에 ` #` 이 들어가야 하면 따옴표로 감싼다.
|
|
167
|
-
- endpoint 가
|
|
200
|
+
- endpoint 가 https URL 이 아니거나(`http://` 는 키가 평문으로 가므로 막는다. 이 기기의 localhost 만 예외) 모델이 한 id 가 아닌 항목은 build 가 건너뛴다. 모든 WAN 항목이 그렇다면 build 는 Sora 로 넘어가지 않고 멈춘다.
|
|
168
201
|
- 선택 설정(모두 기본값이 있음): `VIDEO_PROVIDER_ORDER`(기본 `wan,sora`), `WAN_RESOLUTION`(기본은 출력 크기에서 계산), `WAN_PROMPT_EXTEND`(기본 `false`), `WAN_MAX_CONCURRENT`(기본 5), `WAN_EXHAUSTED_TTL_HOURS`(기본 24).
|
|
169
202
|
- 소진·쿨다운 기록은 `~/.config/banker/3d-intro/video-pool-state.json` 에 지문(엔드포인트, 키, 모델)으로만 남는다. 키나 모델을 바꾸면 지문이 바뀌어 기록이 새로 시작된다.
|
|
170
203
|
|
|
171
204
|
### FLUX.2-pro (opt-in)
|
|
172
|
-
필요할 때만: endpoint 하나 추가(`AZURE_IMAGE_SERVICES_ENDPOINT`) — 같은 리소스에 배포된 경우가 많아 키는 기본적으로 `AZURE_IMAGE_API_KEY` 를
|
|
205
|
+
필요할 때만: endpoint 하나 추가(`AZURE_IMAGE_SERVICES_ENDPOINT`) — 같은 리소스에 배포된 경우가 많아 키는 기본적으로 `AZURE_IMAGE_API_KEY` 를 재사용한다. 다른 리소스면 그 키를 `AZURE_IMAGE_SERVICES_API_KEY=` 줄로 사람이 키 파일에 직접 적는다(채팅으로 받지 않는다).
|
|
173
206
|
FLUX 는 이 스킬의 §4 프리플라이트 대상이 아니다(무과금으로 확인할 별도 엔드포인트가 없음).
|
|
174
207
|
저장만 하고 실제 연결 확인은 생략한다(graceful skip) — 첫 실제 사용 시점(3d-intro-build)으로 미룬다.
|
|
175
208
|
|
|
@@ -182,12 +215,13 @@ node -e "
|
|
|
182
215
|
const { resolveCreds, listVideos, azFetch, redact } = await import('$ADAPTER');
|
|
183
216
|
const c = resolveCreds({ projectDir: process.cwd() });
|
|
184
217
|
if (!c._source) { console.log('크레덴셜 없음 -- §3 먼저 실행'); process.exit(2); }
|
|
218
|
+
const how = (r) => (r.error ? r.error.cause?.code || r.error.code || r.error.name : r.authStyle); // status 0 이면 이유
|
|
185
219
|
const sora = await listVideos({ endpoint: c.AZURE_SORA_ENDPOINT, key: c.AZURE_SORA_API_KEY, apiVersion: c.AZURE_SORA_API_VERSION || 'preview' });
|
|
186
|
-
console.log('sora.listVideos ->', sora.status, '(' + sora
|
|
220
|
+
console.log('sora.listVideos ->', sora.status, '(' + how(sora) + ')');
|
|
187
221
|
const imgv = c.AZURE_GPT_IMAGE_API_VERSION || 'preview';
|
|
188
222
|
const imgUrl = c.AZURE_IMAGE_OPENAI_ENDPOINT + '/openai/v1/images/generations?api-version=' + imgv;
|
|
189
223
|
const img = await azFetch(imgUrl, { key: c.AZURE_IMAGE_API_KEY, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: c.AZURE_GPT_IMAGE_DEPLOYMENT }) });
|
|
190
|
-
console.log('images.badreq ->', img.status, '(' + img
|
|
224
|
+
console.log('images.badreq ->', img.status, '(' + how(img) + ')');
|
|
191
225
|
const { probeWanPool } = await import('$POOL');
|
|
192
226
|
const wan = await probeWanPool({ creds: c });
|
|
193
227
|
for (const w of wan) console.log('wan.probe', w.label, w.host, w.model, '->', w.status, w.code || '', '| key', w.key);
|
|
@@ -211,6 +245,7 @@ node -e "
|
|
|
211
245
|
| 둘 중 하나라도 401/403 | - | 인증 실패 — `azFetch` 가 `api-key`→`Bearer` 두 헤더 스타일을 이미 자동 재시도하므로, 그래도 401/403 이면 헤더 스타일이 아니라 키·엔드포인트 자체가 틀린 것(§3 재입력) |
|
|
212
246
|
| `images.badreq` 가 200 | - | 있을 수 없음(프롬프트 없이 생성이 성공할 리 없다) — 어댑터·API 버전 불일치 의심, 정직히 보고 |
|
|
213
247
|
| status 0 | - | 네트워크 오류(엔드포인트 오타·오프라인) |
|
|
248
|
+
| status 0 (`EINSECURE`) | - | endpoint 가 `http://` 다. 키를 평문으로 보내지 않으려고 보내기 전에 막았다 — `https://` 로 고침(이 기기의 localhost 만 http 허용) |
|
|
214
249
|
|
|
215
250
|
## 5. 검증 (보고 의무)
|
|
216
251
|
```bash
|
|
@@ -219,12 +254,13 @@ command -v ffmpeg >/dev/null && ffmpeg -version | head -1 || echo "시스템 ffm
|
|
|
219
254
|
# 위 §4 프리플라이트를 재실행해 PASS 문자열 + 두 status 코드를 그대로 증거로 남긴다
|
|
220
255
|
```
|
|
221
256
|
4-field 보고: 변경(Node/ffmpeg 확보 방식 · 크레덴셜 저장 경로 — 파일 내용 아님) / Evidence(§0 감지 출력 · §4 status 코드 전부) / 검증(`listVideos`=200 **AND** `images.badreq`=400 **AND** WAN 항목 전부 200 이어야 PASS) / Unknown(FLUX 미검증 · WAN 키별 남은 할당량(조회 API 없음 — 소진은 첫 생성에서 드러나고 풀이 자동으로 다음 키·Sora 로 넘어감) · 배포명이 실제 존재하는지는 이 무과금 프리플라이트로 확인 불가하며 첫 실제 생성 시점에만 드러남 · 관리자 권한 제약으로 ffmpeg 시스템 설치 불가 등, 가짜 성공 금지).
|
|
222
|
-
크레덴셜은 어떤 필드도 원문으로 보고하지 않고 항상 `redact()`
|
|
257
|
+
크레덴셜은 어떤 필드도 원문으로 보고하지 않고 항상 `redact()` 출력(길이만)만 남긴다.
|
|
223
258
|
|
|
224
259
|
## 함정
|
|
225
260
|
- **키·엔드포인트가 진짜 다른 이유**: Sora 와 gpt-image-2 는 같은 Azure OpenAI 리소스에 함께 배포될 수도, 완전히 별도 리소스일 수도 있다 — 하나로 퉁치지 말고 두 세트를 각각 확인한다.
|
|
226
261
|
- **`azFetch` 는 이미 이중 인증을 시도한다**: `api-key` 헤더로 먼저 시도하고 401/403 이면 자동으로 `Authorization: Bearer` 로 재시도한다 — 그래도 실패하면 헤더 스타일 문제가 아니라 키·엔드포인트 자체가 틀린 것이다.
|
|
227
|
-
-
|
|
262
|
+
- **키가 드러났으면 교체한다**: 키가 대화, 터미널 기록, 출력에 한 번이라도 드러났으면(예: 샘플을 채팅에 붙임) 그 키를 바꾼다. Azure 는 리소스의 Keys and Endpoint 에서 Regenerate 로 교체한다.
|
|
263
|
+
WAN 키는 Model Studio 콘솔의 API Key 관리에서 그 키를 지우고 새로 만들어 교체한다.
|
|
228
264
|
- **WAN 키 검증은 GET task 로만**: 존재하지 않는 task 조회는 과금이 없고 키 유효성(200 vs 401)을 가른다. 생성 요청(POST)은 과금될 수 있으니 점검용으로 쓰지 않는다.
|
|
229
265
|
- **이미지 배포 429**: 낮은 티어(S0) 이미지 배포는 연속 호출에 `429 RateLimitReached` 를 낸다. 프리플라이트를 짧은 간격으로 반복하지 않는다.
|
|
230
266
|
- **RHEL8/Rocky8 ffmpeg 미제공**: BaseOS/AppStream 에 없음(라이선스) → motion-graphic-setup 과 동일하게 RPM Fusion 또는 `ffmpeg-static` 폴백.
|
|
@@ -36,8 +36,9 @@ function originOf(url) {
|
|
|
36
36
|
const u = new URL(raw);
|
|
37
37
|
return `${u.protocol}//${u.host}`;
|
|
38
38
|
} catch {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// Not a URL: null, never the raw text, which may be a key pasted into the endpoint field.
|
|
40
|
+
const m = raw.match(/^(https?:\/\/[^/\s]+)/i);
|
|
41
|
+
return m ? m[1] : null;
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -50,6 +51,10 @@ function truncate(x, n = 300) {
|
|
|
50
51
|
return s.length > n ? `${s.slice(0, n)}…(+${s.length - n})` : s;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// Why an azFetch result has no HTTP answer, for error messages: " (EINSECURE)" and the like. The
|
|
55
|
+
// error's code or name only, never its text: fetch can quote a header value, a key included.
|
|
56
|
+
const why = (r) => (r.error ? ` (${r.error.cause?.code || r.error.code || r.error.name})` : '');
|
|
57
|
+
|
|
53
58
|
// Last n chars of ffmpeg stderr (the useful part) for error messages.
|
|
54
59
|
const tail = (s, n = 400) => { const str = String(s || ''); return str.length > n ? str.slice(-n) : str; };
|
|
55
60
|
|
|
@@ -93,9 +98,12 @@ export function parseConsoleSample(text) {
|
|
|
93
98
|
if (m) out.deployment = m[1];
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
// key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>"
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
// key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>", "api-key": "<k>",
|
|
102
|
+
// apiKey: '<k>' (python/JS SDK). An SDK value counts only in quotes: api_key=subscription_key or
|
|
103
|
+
// apiKey: process.env.X names a variable, and taking that name for the key would replace a real one.
|
|
104
|
+
m = s.match(/Authorization["']?\s*[:=]\s*["']?\s*Bearer\s+([A-Za-z0-9._-]+)/i)
|
|
105
|
+
|| s.match(/api-key\s*:\s*([A-Za-z0-9._-]+)/i)
|
|
106
|
+
|| s.match(/api[-_]?key["']?\s*[:=]\s*["']([A-Za-z0-9._-]+)["']/i);
|
|
99
107
|
if (m) out.apiKey = m[1];
|
|
100
108
|
|
|
101
109
|
return out;
|
|
@@ -164,18 +172,84 @@ function envLiteral(v) {
|
|
|
164
172
|
return v.includes('"') ? `'${v}'` : `"${v}"`;
|
|
165
173
|
}
|
|
166
174
|
|
|
167
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* Redact a secret to its length only, `…(len)`: no character of it, since keys share fixed
|
|
177
|
+
* prefixes (`sk-ws-`) and a shown tail narrows them down. Falsy -> "(missing)".
|
|
178
|
+
*/
|
|
168
179
|
export function redact(s) {
|
|
169
180
|
if (!s) return '(missing)';
|
|
170
|
-
|
|
171
|
-
if (str.length <= 8) return `…(${str.length})`;
|
|
172
|
-
return `${str.slice(0, 4)}…${str.slice(-4)}(${str.length})`;
|
|
181
|
+
return `…(${String(s).length})`;
|
|
173
182
|
}
|
|
174
183
|
|
|
175
184
|
// ---------------------------------------------------------------------------
|
|
176
185
|
// HTTP
|
|
177
186
|
// ---------------------------------------------------------------------------
|
|
178
187
|
|
|
188
|
+
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
|
189
|
+
const SECRET_HEADERS = new Set(['authorization', 'api-key', 'proxy-authorization', 'cookie']);
|
|
190
|
+
const MAX_HOPS = 5;
|
|
191
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
192
|
+
const localHttp = (u) => u.protocol === 'http:' && LOOPBACK.has(u.hostname);
|
|
193
|
+
const refused = (code, message) => Object.assign(new TypeError(`fetch failed: ${message}`), { cause: { code } });
|
|
194
|
+
const isSecret = (name) => SECRET_HEADERS.has(name.toLowerCase());
|
|
195
|
+
const withoutSecrets = (headers) => Object.fromEntries(Object.entries(headers).filter(([k]) => !isSecret(k)));
|
|
196
|
+
// Every form fetch takes (an object, [name, value] pairs, Headers) as one plain object, so a key is
|
|
197
|
+
// found in any of them. Pairs would otherwise read as headers named "0", "1".
|
|
198
|
+
const plainHeaders = (h) => (Array.isArray(h) || h instanceof Headers ? Object.fromEntries(new Headers(h)) : { ...h });
|
|
199
|
+
|
|
200
|
+
/** Whether a key may go to this URL: over https, or over http only to this machine (a local mock or proxy). */
|
|
201
|
+
export const safeForKey = (u) => u.protocol === 'https:' || localHttp(u);
|
|
202
|
+
|
|
203
|
+
// Where a redirect goes. The request was sent already, so a Location that is not a URL, or one that
|
|
204
|
+
// leaves https, ends it with a code that does not read as "nothing sent". http stays allowed only
|
|
205
|
+
// while the request is on this machine over http (a local mock); from anywhere else, an https
|
|
206
|
+
// localhost included, it would reach a service here or on the network.
|
|
207
|
+
function redirectTarget(location, from) {
|
|
208
|
+
let next;
|
|
209
|
+
try { next = new URL(location, from); } catch { throw refused('EBADREDIRECT', 'the redirect location is not a URL'); }
|
|
210
|
+
if (next.protocol === 'https:' || (localHttp(next) && localHttp(from))) return next;
|
|
211
|
+
throw refused('EINSECUREREDIRECT', 'a redirect away from https is not followed');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The request for the next hop of a redirect. A request with a body (a prompt, a frame, a paid submit)
|
|
215
|
+
// follows only a 307 or 308 within its origin, which send it again as it was. A 301, 302 or 303 would
|
|
216
|
+
// make it a GET, and another origin is another server: either way the next answer does not answer this
|
|
217
|
+
// request, and its 401 or 429 would read as this key's (a key retired, a submit sent again elsewhere).
|
|
218
|
+
// Without a body, a 303, and a 301 or 302 after a POST, go on as a GET, as browsers do, and a hop to
|
|
219
|
+
// another origin goes without the secret headers.
|
|
220
|
+
function nextHop(req, status, crossOrigin) {
|
|
221
|
+
if (req.body != null && (crossOrigin || status < 307)) {
|
|
222
|
+
throw refused(crossOrigin ? 'ECROSSREDIRECT' : 'EBODYREDIRECT', 'a request with a body follows only a 307 or 308 within its origin');
|
|
223
|
+
}
|
|
224
|
+
const toGet = status === 303 || ((status === 301 || status === 302) && req.method === 'POST');
|
|
225
|
+
const next = toGet ? { ...req, method: 'GET', body: undefined } : req;
|
|
226
|
+
return crossOrigin ? { ...next, headers: withoutSecrets(next.headers) } : next;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* fetch that follows redirects by hand (at most 5): a request without a body as fetch would, but to
|
|
231
|
+
* another origin without the key headers; a request with a body only by a 307 or 308 within its
|
|
232
|
+
* origin (fetch itself forwards a custom `api-key` header and re-sends the body on 307 and 308 to
|
|
233
|
+
* any origin, and turns a POST into a GET on 301 to 303). A key goes only where safeForKey allows: an http
|
|
234
|
+
* URL with one is refused before anything is sent (cause.code EINSECURE), and no redirect leaves
|
|
235
|
+
* https. Throws as fetch does on a network error.
|
|
236
|
+
*/
|
|
237
|
+
export async function fetchGuarded(url, init = {}) {
|
|
238
|
+
let target = new URL(String(url));
|
|
239
|
+
let req = { ...init, headers: plainHeaders(init.headers), redirect: 'manual' };
|
|
240
|
+
if (!safeForKey(target) && Object.keys(req.headers).some(isSecret)) throw refused('EINSECURE', 'a key is sent only over https');
|
|
241
|
+
for (let hop = 0; hop <= MAX_HOPS; hop++) {
|
|
242
|
+
const r = await globalThis.fetch(target.href, req);
|
|
243
|
+
const location = REDIRECT_STATUS.has(r.status) ? r.headers.get('location') : null;
|
|
244
|
+
if (!location) return r;
|
|
245
|
+
try { await r.body?.cancel(); } catch { /* the redirect body is not needed */ }
|
|
246
|
+
const next = redirectTarget(location, target);
|
|
247
|
+
req = nextHop(req, r.status, next.origin !== target.origin);
|
|
248
|
+
target = next;
|
|
249
|
+
}
|
|
250
|
+
throw refused('ETOOMANYREDIRECTS', 'too many redirects');
|
|
251
|
+
}
|
|
252
|
+
|
|
179
253
|
// Retry-After / retry-after-ms response header -> milliseconds (null when absent or unparsable).
|
|
180
254
|
function retryAfterMsOf(headers) {
|
|
181
255
|
const ms = Number(headers.get('retry-after-ms'));
|
|
@@ -202,7 +276,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
|
|
|
202
276
|
if (body !== undefined) opts.body = body;
|
|
203
277
|
let r;
|
|
204
278
|
try {
|
|
205
|
-
r = await
|
|
279
|
+
r = await fetchGuarded(url, opts);
|
|
206
280
|
} catch (e) {
|
|
207
281
|
last = { status: 0, ok: false, ct: '', body: null, authStyle: null, error: e };
|
|
208
282
|
break; // network error is not an auth problem — do not retry other styles
|
|
@@ -260,7 +334,7 @@ export async function generateImage({
|
|
|
260
334
|
});
|
|
261
335
|
}
|
|
262
336
|
const b64 = r.body?.data?.[0]?.b64_json;
|
|
263
|
-
if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status} ${truncate(r.body)}`);
|
|
337
|
+
if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
264
338
|
return Buffer.from(b64, 'base64');
|
|
265
339
|
}
|
|
266
340
|
|
|
@@ -278,7 +352,7 @@ export async function editImage({
|
|
|
278
352
|
key, method: 'POST', body: fd,
|
|
279
353
|
});
|
|
280
354
|
const b64 = r.body?.data?.[0]?.b64_json;
|
|
281
|
-
if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
355
|
+
if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
282
356
|
return Buffer.from(b64, 'base64');
|
|
283
357
|
}
|
|
284
358
|
|
|
@@ -289,7 +363,7 @@ export async function generateImageFlux({ endpoint, key, prompt, width, height,
|
|
|
289
363
|
body: JSON.stringify({ prompt, width, height }),
|
|
290
364
|
});
|
|
291
365
|
const b64 = r.body?.data?.[0]?.b64_json || r.body?.b64_json || r.body?.image;
|
|
292
|
-
if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
366
|
+
if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
293
367
|
return Buffer.from(b64, 'base64');
|
|
294
368
|
}
|
|
295
369
|
|
|
@@ -322,7 +396,7 @@ export async function createVideo({
|
|
|
322
396
|
const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
|
|
323
397
|
if (!(r.ok && r.body?.id)) {
|
|
324
398
|
// status 0 = network error; errno tells a refused connection (nothing sent) from a lost one
|
|
325
|
-
const err = new Error(`createVideo failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
399
|
+
const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
326
400
|
err.status = r.status;
|
|
327
401
|
err.errno = r.error?.cause?.code || r.error?.code || null;
|
|
328
402
|
throw err;
|
|
@@ -368,7 +442,7 @@ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview'
|
|
|
368
442
|
const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
|
|
369
443
|
key, method: 'GET', binary: true,
|
|
370
444
|
});
|
|
371
|
-
if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}`);
|
|
445
|
+
if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`);
|
|
372
446
|
return r.body;
|
|
373
447
|
}
|
|
374
448
|
|
|
@@ -413,7 +487,7 @@ export async function createVideoTwoImage({
|
|
|
413
487
|
key, method: 'POST', body: fd,
|
|
414
488
|
});
|
|
415
489
|
const id = r.body?.id || r.body?.job_id;
|
|
416
|
-
if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
490
|
+
if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
417
491
|
return r.body;
|
|
418
492
|
}
|
|
419
493
|
|
|
@@ -37,7 +37,7 @@ import path from 'node:path';
|
|
|
37
37
|
import os from 'node:os';
|
|
38
38
|
import crypto from 'node:crypto';
|
|
39
39
|
import cp from 'node:child_process';
|
|
40
|
-
import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact } from './azure-adapter.mjs';
|
|
40
|
+
import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey } from './azure-adapter.mjs';
|
|
41
41
|
|
|
42
42
|
export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
|
|
43
43
|
const WAN_CREATE_PATH = '/api/v1/services/aigc/video-generation/video-synthesis';
|
|
@@ -50,6 +50,8 @@ const PROBE_TASK_ID = '00000000-0000-0000-0000-000000000000';
|
|
|
50
50
|
|
|
51
51
|
const RATE_BASE_MS = 30_000;
|
|
52
52
|
const RATE_MAX_MS = 10 * 60_000;
|
|
53
|
+
// A server Retry-After is held to this range: 0 would retry at once, a huge value rest a key for years.
|
|
54
|
+
const RETRY_MIN_MS = 1000;
|
|
53
55
|
const QUOTA_STRIKES_TO_EXHAUST = 3;
|
|
54
56
|
const QUOTA_STRIKE_TTL_MS = 3_600_000;
|
|
55
57
|
const TRANSIENT_BASE_MS = 15_000;
|
|
@@ -72,7 +74,9 @@ const TRANSIENT_CODES = new Set([
|
|
|
72
74
|
'InternalError', 'InternalError.Timeout', 'ServiceUnavailable', 'ModelUnavailable', 'Throttling.ServiceOverloaded',
|
|
73
75
|
]);
|
|
74
76
|
// Errors that mean the request never left this machine, so resubmitting elsewhere cannot double-bill.
|
|
75
|
-
|
|
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']);
|
|
76
80
|
|
|
77
81
|
/**
|
|
78
82
|
* Map a WAN failure to a pool action.
|
|
@@ -115,11 +119,13 @@ function codeKind(code) {
|
|
|
115
119
|
|
|
116
120
|
// Endpoints are origins: a pasted console URL with a path would otherwise 404 every call. A value
|
|
117
121
|
// new URL() rejects (a bare host, a value with spaces, a key pasted into the wrong field) gives
|
|
118
|
-
// null. The raw value is never echoed, because it may be a key.
|
|
122
|
+
// null. The raw value is never echoed, because it may be a key. The key goes only over https: an
|
|
123
|
+
// http:// endpoint (one typo) would send it in the clear, so it counts as malformed too, except on
|
|
124
|
+
// this machine (a local mock or proxy). safeForKey is the same rule fetchGuarded enforces.
|
|
119
125
|
function originOf(url) {
|
|
120
126
|
try {
|
|
121
127
|
const u = new URL(String(url).trim());
|
|
122
|
-
return
|
|
128
|
+
return safeForKey(u) && u.host ? `${u.protocol}//${u.host}` : null;
|
|
123
129
|
} catch { return null; }
|
|
124
130
|
}
|
|
125
131
|
const fingerprint = (endpoint, key, model) => crypto.createHash('sha256').update(`${endpoint}\n${key}\n${model}`).digest('hex').slice(0, 16);
|
|
@@ -136,8 +142,8 @@ const malformedField = (e) => (e.badEndpoint ? 'endpoint' : e.badModel ? 'model'
|
|
|
136
142
|
|
|
137
143
|
/**
|
|
138
144
|
* Build the WAN pool from creds: every WAN_<n>_ENDPOINT with a matching WAN_<n>_API_KEY, by n.
|
|
139
|
-
* An entry whose endpoint is not an
|
|
140
|
-
* (`badModel`) is kept so the preflight can report it; generateClip skips it.
|
|
145
|
+
* An entry whose endpoint is not an https URL (`badEndpoint`; http only to this machine) or whose
|
|
146
|
+
* model is not one model id (`badModel`) is kept so the preflight can report it; generateClip skips it.
|
|
141
147
|
*/
|
|
142
148
|
export function loadWanPool(creds = {}) {
|
|
143
149
|
const pool = [];
|
|
@@ -171,11 +177,20 @@ export function loadPoolState(file = defaultStatePath()) {
|
|
|
171
177
|
return { version: 1, entries: {} };
|
|
172
178
|
}
|
|
173
179
|
|
|
180
|
+
// Written through a fresh random name opened exclusively ('wx'), so a link someone planted at a
|
|
181
|
+
// guessable name is never followed; the folder is private when this creates it. A failed write or
|
|
182
|
+
// rename removes the temp file.
|
|
174
183
|
export function savePoolState(state, file = defaultStatePath()) {
|
|
175
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
176
|
-
const tmp = `${file}.${
|
|
177
|
-
fs.
|
|
178
|
-
|
|
184
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
185
|
+
const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
186
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
187
|
+
try {
|
|
188
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`); } finally { fs.closeSync(fd); }
|
|
189
|
+
fs.renameSync(tmp, file);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
fs.rmSync(tmp, { force: true });
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
179
194
|
try { fs.chmodSync(file, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
|
|
180
195
|
return file;
|
|
181
196
|
}
|
|
@@ -258,9 +273,9 @@ function pickEntry(pool, state, t, limit) {
|
|
|
258
273
|
function parseRetryAfter(h) {
|
|
259
274
|
if (!h) return null;
|
|
260
275
|
const sec = Number(h);
|
|
261
|
-
if (Number.isFinite(sec)) return Math.max(0, sec * 1000);
|
|
262
276
|
const at = Date.parse(h);
|
|
263
|
-
|
|
277
|
+
const ms = Number.isFinite(sec) ? sec * 1000 : Number.isFinite(at) ? at - Date.now() : null;
|
|
278
|
+
return ms === null ? null : Math.min(RATE_MAX_MS, Math.max(RETRY_MIN_MS, ms));
|
|
264
279
|
}
|
|
265
280
|
|
|
266
281
|
const WAN_FETCH_DEFAULTS = { method: 'GET', binary: false, timeoutMs: API_TIMEOUT_MS };
|
|
@@ -269,7 +284,7 @@ async function wanFetch(url, opts = {}) {
|
|
|
269
284
|
const o = { ...WAN_FETCH_DEFAULTS, ...defined(opts) };
|
|
270
285
|
let r;
|
|
271
286
|
try {
|
|
272
|
-
r = await
|
|
287
|
+
r = await fetchGuarded(url, {
|
|
273
288
|
method: o.method, headers: wanHeaders(o), body: o.body === undefined ? undefined : JSON.stringify(o.body), signal: AbortSignal.timeout(o.timeoutMs),
|
|
274
289
|
});
|
|
275
290
|
} catch (e) {
|
|
@@ -391,7 +406,13 @@ export async function wanPollTask({ entry, taskId, intervalMs = 15_000, timeoutM
|
|
|
391
406
|
}
|
|
392
407
|
|
|
393
408
|
/** Download a finished WAN video (signed URL, no auth header). Returns an mp4 Buffer. */
|
|
409
|
+
// The video URL comes from the provider's answer: only https is fetched.
|
|
410
|
+
function httpsOnly(url) {
|
|
411
|
+
if (!/^https:\/\//i.test(String(url))) throw new Error('WAN video download refused: the video_url is not https');
|
|
412
|
+
}
|
|
413
|
+
|
|
394
414
|
export async function downloadWanVideo(url, { sleep = realSleep, tries = 3 } = {}) {
|
|
415
|
+
httpsOnly(url);
|
|
395
416
|
let last;
|
|
396
417
|
for (let i = 0; i < tries; i++) {
|
|
397
418
|
last = await wanFetch(url, { binary: true, timeoutMs: DOWNLOAD_TIMEOUT_MS });
|
|
@@ -436,9 +457,12 @@ export class ContentRejectedError extends Error {
|
|
|
436
457
|
|
|
437
458
|
// An error that must NOT be retried by resubmitting the leg: the task may exist and bill.
|
|
438
459
|
// kind: timeout | submit_unknown | download | no_video | task_lost. Carries taskId/label when known.
|
|
439
|
-
|
|
460
|
+
// A stop the caller can act on. `props` are plain properties (they show in logs); `hidden` ones (a
|
|
461
|
+
// signed download URL) read as err.<name> but stay out of JSON.stringify and console.error.
|
|
462
|
+
function stopError(kind, message, props = {}, hidden = {}) {
|
|
440
463
|
const err = new Error(message);
|
|
441
464
|
Object.assign(err, { kind, ...props });
|
|
465
|
+
for (const [k, v] of Object.entries(hidden)) Object.defineProperty(err, k, { value: v, enumerable: false });
|
|
442
466
|
return err;
|
|
443
467
|
}
|
|
444
468
|
|
|
@@ -530,8 +554,8 @@ async function succeededClip(ctx, e, taskId, p, seconds) {
|
|
|
530
554
|
try {
|
|
531
555
|
mp4 = await downloadWanVideo(videoUrl, { sleep: ctx.sleep });
|
|
532
556
|
} catch (err) {
|
|
533
|
-
// videoUrl is a signed URL:
|
|
534
|
-
throw stopError('download', `${err.message} for WAN task ${taskId} (${e.label}); resume with resumeClip within 24 h`, { provider: 'wan', taskId, label: e.label,
|
|
557
|
+
// videoUrl is a signed URL (valid 24 h): readable as err.videoUrl for recovery, never in the message or a log
|
|
558
|
+
throw stopError('download', `${err.message} for WAN task ${taskId} (${e.label}); resume with resumeClip within 24 h`, { provider: 'wan', taskId, label: e.label, seconds }, { videoUrl });
|
|
535
559
|
}
|
|
536
560
|
ctx.emit({ type: 'done', provider: 'wan', label: e.label, taskId });
|
|
537
561
|
return { mp4, provider: 'wan', label: e.label, model: shownModel(e), seconds: seconds ?? p.usage?.duration ?? null, taskId, usage: p.usage };
|
|
@@ -563,7 +587,7 @@ async function runWan(ctx) {
|
|
|
563
587
|
function usablePool(ctx) {
|
|
564
588
|
const all = loadWanPool(ctx.creds);
|
|
565
589
|
const bad = all.filter(malformedField);
|
|
566
|
-
for (const b of bad) ctx.emit({ type: 'skip', provider: 'wan', label: b.label, reason: `${malformedField(b)} is malformed (not an
|
|
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)` });
|
|
567
591
|
if (all.length && bad.length === all.length) {
|
|
568
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' });
|
|
569
593
|
}
|
|
@@ -722,7 +746,8 @@ const DEFAULTS = { size: '720x1280', seconds: 5, pollIntervalMs: 15_000, pollTim
|
|
|
722
746
|
*
|
|
723
747
|
* Throws (never resubmitting the leg) when retrying could pay twice or cannot help:
|
|
724
748
|
* ContentRejectedError moderation — change the prompt/frame
|
|
725
|
-
* kind 'invalid_request' malformed request, ModelNotFound
|
|
749
|
+
* kind 'invalid_request' malformed request, ModelNotFound, every WAN entry malformed or a
|
|
750
|
+
* Sora endpoint that is not https (config)
|
|
726
751
|
* kind 'timeout' | 'download' the task exists — call resumeClip({ ..., provider, label, taskId })
|
|
727
752
|
* kind 'submit_unknown' | 'task_lost' | 'no_video' | 'failed' outcome unknown — check the console
|
|
728
753
|
* ('failed': the task ended for a reason the pool does not know)
|
|
@@ -741,7 +766,7 @@ export async function generateClip(opts = {}) {
|
|
|
741
766
|
const r = await runWan(ctx);
|
|
742
767
|
if (r) return r;
|
|
743
768
|
} else if (p === 'sora') {
|
|
744
|
-
if (!
|
|
769
|
+
if (!soraReady(ctx)) continue;
|
|
745
770
|
tried.push('sora');
|
|
746
771
|
ctx.emit({ type: 'fallback', provider: 'sora', from: tried.slice(0, -1) });
|
|
747
772
|
return runSora(ctx);
|
|
@@ -750,6 +775,18 @@ export async function generateClip(opts = {}) {
|
|
|
750
775
|
throw new Error(`generateClip: no video provider available (tried: ${tried.join(', ') || 'none'})`);
|
|
751
776
|
}
|
|
752
777
|
|
|
778
|
+
// Whether Sora can take the leg. Not set up: skipped with an event. An endpoint that is set but not
|
|
779
|
+
// https (http only to this machine) stops the leg before the key is sent in the clear.
|
|
780
|
+
function soraReady(ctx) {
|
|
781
|
+
const { AZURE_SORA_ENDPOINT: endpoint, AZURE_SORA_API_KEY: key } = ctx.creds;
|
|
782
|
+
if (!endpoint || !key) {
|
|
783
|
+
ctx.emit({ type: 'skip', provider: 'sora', reason: 'AZURE_SORA_ENDPOINT/API_KEY missing' });
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
if (!originOf(endpoint)) throw stopError('invalid_request', 'AZURE_SORA_ENDPOINT is not an https URL; fix the env file — nothing was sent', { provider: 'sora' });
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
|
|
753
790
|
/**
|
|
754
791
|
* Finish a clip whose task already exists (after a 'timeout' or 'download' error) without
|
|
755
792
|
* submitting anything new. FREE except for the work already billed. Same return shape as
|
|
@@ -763,7 +800,7 @@ export async function resumeClip(opts = {}) {
|
|
|
763
800
|
const ctx = makeCtx(o);
|
|
764
801
|
if (o.provider !== 'sora') return resumeWan(ctx, o);
|
|
765
802
|
if (!originOf(o.creds.AZURE_SORA_ENDPOINT) || !o.creds.AZURE_SORA_API_KEY) {
|
|
766
|
-
throw stopError('invalid_request', 'resumeClip: resuming a Sora job needs AZURE_SORA_ENDPOINT as an
|
|
803
|
+
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 });
|
|
767
804
|
}
|
|
768
805
|
return finishSoraJob(ctx, o.taskId, o.seconds == null ? null : snapSoraSeconds(o.seconds));
|
|
769
806
|
}
|
|
@@ -772,7 +809,7 @@ async function resumeWan(ctx, o) {
|
|
|
772
809
|
const e = loadWanPool(o.creds).find((x) => x.label === o.label);
|
|
773
810
|
if (!e) throw new Error(`resumeClip: ${o.label} is not in the WAN pool (was the key removed?)`);
|
|
774
811
|
if (e.badEndpoint) {
|
|
775
|
-
throw stopError('invalid_request', `resumeClip: ${o.label} endpoint is not an
|
|
812
|
+
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 });
|
|
776
813
|
}
|
|
777
814
|
const out = await finishWanTask(ctx, e, o.taskId, { duration: Math.round(Number(o.seconds) || 0) || null });
|
|
778
815
|
if (out.clip) return out.clip;
|