@vibedev-si/dsh-vibedev 0.2.7 → 0.3.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/lib/index.js CHANGED
@@ -53,12 +53,14 @@ var AccountService = class {
53
53
  const credential = await this.options.chain.resolve().catch(() => void 0);
54
54
  const source = credential === void 0 ? "none" : credential.kind === "account" ? "host" : credential.kind;
55
55
  const pending = this.options.login.pendingSignIn();
56
+ const attempt = this.options.login.signInAttempt();
56
57
  const base = {
57
58
  source,
58
59
  models: this.options.models(),
59
60
  links: this.links(),
60
61
  primary: this.options.primary === true,
61
- ...pending === void 0 ? {} : { pending }
62
+ ...pending === void 0 ? {} : { pending },
63
+ ...attempt === void 0 ? {} : { attempt }
62
64
  };
63
65
  if (credential === void 0) return base;
64
66
  const grantUser = credential.kind === "plugin" ? await this.options.login.user().catch(() => void 0) : void 0;
@@ -82,7 +84,8 @@ var AccountService = class {
82
84
  authorization: `Bearer ${token}`,
83
85
  "user-agent": this.options.userAgent
84
86
  },
85
- signal: AbortSignal.timeout(15e3)
87
+ signal: AbortSignal.timeout(15e3),
88
+ redirect: "error"
86
89
  });
87
90
  if (!response.ok) return {};
88
91
  const body = record$8(await response.json().catch(() => void 0));
@@ -166,9 +169,11 @@ function json(status, body) {
166
169
  });
167
170
  }
168
171
  function failure(error) {
172
+ const candidate = record$8(error)?.code;
173
+ const code = candidate === "SIGN_IN_REFUSED" || candidate === "SIGN_IN_TIMEOUT" || candidate === "SIGN_IN_CANCELLED" ? candidate : "INTERNAL";
169
174
  return json(500, { error: {
170
- code: text$8(record$8(error)?.code) ?? "INTERNAL",
171
- message: error instanceof Error ? error.message : String(error)
175
+ code,
176
+ message: code === "SIGN_IN_CANCELLED" ? "Sign-in was cancelled. 登录已取消。" : code === "SIGN_IN_TIMEOUT" ? "Sign-in expired. 登录已超时,请重新登录。" : "The account request could not be completed. 账号请求未完成,请查看登录诊断后重试。"
172
177
  } });
173
178
  }
174
179
  /**
@@ -511,6 +516,32 @@ async function downloadTo(response, temporary, maxBytes, signal) {
511
516
  }
512
517
  }
513
518
  //#endregion
519
+ //#region src/gateway/origin.ts
520
+ /** Origin boundaries for accounts and durable media state. No endpoint fallback is defined here. */
521
+ /** Production API origin. Storage URLs are returned by the server, not derived from this address. */
522
+ const DEFAULT_GATEWAY_ORIGIN = "https://api.vibedev.studio";
523
+ /** Validate a bare HTTP(S) origin and canonicalize default ports, casing and trailing slash. */
524
+ function normalizeGatewayOrigin(value) {
525
+ const url = new URL(value.trim());
526
+ if (!["https:", "http:"].includes(url.protocol) || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") throw new Error("gatewayOrigin must be a bare HTTP(S) origin, without credentials, path, query or fragment.");
527
+ return url.origin;
528
+ }
529
+ /** Stable, filesystem-safe identity; ports and schemes remain separate boundaries. */
530
+ function gatewayId(origin) {
531
+ return createHash("sha256").update(normalizeGatewayOrigin(origin)).digest("hex");
532
+ }
533
+ /** Every gateway uses its own namespace; never read the unmarked historical root. */
534
+ function gatewayStateDirectory(root, origin) {
535
+ return join(root, "gateways", gatewayId(origin));
536
+ }
537
+ /** Ignore the old shared key variable; development requires an explicitly named, gateway-specific variable. */
538
+ function developmentKey(origin, name, env = process.env) {
539
+ normalizeGatewayOrigin(origin);
540
+ const key = name.trim();
541
+ if (key === "" || key === "VIBEDEV_GATEWAY_API_KEY") return void 0;
542
+ return env[key]?.trim() || void 0;
543
+ }
544
+ //#endregion
514
545
  //#region src/auth/credentials.ts
515
546
  /**
516
547
  * Which VibeDev credential a gateway request carries, in order: the host's
@@ -555,7 +586,10 @@ var CredentialChain = class {
555
586
  else if (credential.kind === "plugin") await this.options.plugin?.reject(credential.token);
556
587
  }
557
588
  };
558
- const GRANT_KEY = credentialKey("dsh-media", "vibedev-session");
589
+ /** Credential identity for one gateway; never consult another gateway's record. */
590
+ function gatewayGrantKey(origin) {
591
+ return credentialKey("dsh-vibedev", `gateway-${gatewayId(origin)}`);
592
+ }
559
593
  function isGrant(value) {
560
594
  const grant = value;
561
595
  return typeof grant === "object" && grant !== null && typeof grant.accessToken === "string" && typeof grant.refreshToken === "string" && typeof grant.expiresAt === "number" && typeof grant.refreshExpiresAt === "number";
@@ -564,11 +598,23 @@ function isGrant(value) {
564
598
  * Grant storage: the host's credential store when one is loaded (a grant
565
599
  * record the store keeps verbatim), else a private file in the state
566
600
  * directory. The device id is not a secret and lives in the state directory.
567
- * @param stateDir - the plugin state directory.
601
+ * @param root - the plugin state root; the storage selects an origin-specific directory itself.
568
602
  * @param credentials - the host's credential store, when loaded.
603
+ * @param selectedOrigin - the issuing gateway; unmarked historical state is never adopted.
569
604
  * @returns the storage.
570
605
  */
571
- function grantStorage(stateDir, credentials) {
606
+ function grantStorage(root, credentials, selectedOrigin = DEFAULT_GATEWAY_ORIGIN) {
607
+ const origin = normalizeGatewayOrigin(selectedOrigin);
608
+ const key = gatewayGrantKey(origin);
609
+ const stateDir = gatewayStateDirectory(root, origin);
610
+ const readGrant = (value) => isGrant(value) && value.gatewayOrigin === origin ? value : void 0;
611
+ const bind = (grant) => {
612
+ if (grant.gatewayOrigin !== void 0 && grant.gatewayOrigin !== origin) throw new Error("Cannot store a grant issued by another gateway.");
613
+ return {
614
+ ...grant,
615
+ gatewayOrigin: origin
616
+ };
617
+ };
572
618
  const sessionFile = join(stateDir, "session.json");
573
619
  const deviceFile = join(stateDir, "device.json");
574
620
  let device;
@@ -581,9 +627,9 @@ function grantStorage(stateDir, credentials) {
581
627
  throw error;
582
628
  }
583
629
  const parsed = JSON.parse(text);
584
- return isGrant(parsed) ? parsed : void 0;
630
+ return readGrant(parsed);
585
631
  };
586
- const writeFileGrant = (grant) => writeFileAtomic(sessionFile, new TextEncoder().encode(grant === void 0 ? "null" : JSON.stringify(grant)), { mode: 384 });
632
+ const writeFileGrant = (grant) => writeFileAtomic(sessionFile, new TextEncoder().encode(grant === void 0 ? "null" : JSON.stringify(bind(grant))), { mode: 384 });
587
633
  const clearFileGrant = async () => {
588
634
  try {
589
635
  await stat(sessionFile);
@@ -599,9 +645,9 @@ function grantStorage(stateDir, credentials) {
599
645
  } catch {}
600
646
  };
601
647
  const readHostGrant = async (store) => {
602
- const stored = await store.readRecord(GRANT_KEY);
648
+ const stored = await store.readRecord(key);
603
649
  if (stored !== void 0) {
604
- const grant = stored.kind === "grant" && isGrant(stored.payload) ? stored.payload : void 0;
650
+ const grant = stored.kind === "grant" ? readGrant(stored.payload) : void 0;
605
651
  if (grant !== void 0) await clearFileGrantQuietly();
606
652
  return grant;
607
653
  }
@@ -612,15 +658,15 @@ function grantStorage(stateDir, credentials) {
612
658
  throw error;
613
659
  }
614
660
  let adopted;
615
- await store.modifyRecord(GRANT_KEY, async (current) => {
661
+ await store.modifyRecord(key, async (current) => {
616
662
  if (current !== void 0) {
617
- adopted = current.kind === "grant" && isGrant(current.payload) ? current.payload : void 0;
663
+ adopted = current.kind === "grant" ? readGrant(current.payload) : void 0;
618
664
  return;
619
665
  }
620
666
  adopted = await readFileGrant();
621
667
  return adopted === void 0 ? void 0 : {
622
668
  kind: "grant",
623
- payload: adopted
669
+ payload: bind(adopted)
624
670
  };
625
671
  });
626
672
  if (adopted !== void 0) await clearFileGrantQuietly();
@@ -636,11 +682,11 @@ function grantStorage(stateDir, credentials) {
636
682
  if (store !== void 0) {
637
683
  if (grant === void 0) {
638
684
  await clearFileGrant();
639
- await store.deleteRecord(GRANT_KEY);
685
+ await store.deleteRecord(key);
640
686
  } else {
641
- await store.modifyRecord(GRANT_KEY, () => Promise.resolve({
687
+ await store.modifyRecord(key, () => Promise.resolve({
642
688
  kind: "grant",
643
- payload: grant
689
+ payload: bind(grant)
644
690
  }));
645
691
  await clearFileGrantQuietly();
646
692
  }
@@ -653,17 +699,17 @@ function grantStorage(stateDir, credentials) {
653
699
  if (store !== void 0) {
654
700
  await readHostGrant(store);
655
701
  let remove = false;
656
- await store.modifyRecord(GRANT_KEY, async (current) => {
657
- const result = await change(current?.kind === "grant" && isGrant(current.payload) ? current.payload : void 0);
702
+ await store.modifyRecord(key, async (current) => {
703
+ const result = await change(current?.kind === "grant" ? readGrant(current.payload) : void 0);
658
704
  if (result.kind === "set") return {
659
705
  kind: "grant",
660
- payload: result.grant
706
+ payload: bind(result.grant)
661
707
  };
662
708
  remove = result.kind === "delete";
663
709
  });
664
710
  if (remove) {
665
711
  await clearFileGrant();
666
- await store.deleteRecord(GRANT_KEY);
712
+ await store.deleteRecord(key);
667
713
  }
668
714
  return;
669
715
  }
@@ -686,6 +732,100 @@ function grantStorage(stateDir, credentials) {
686
732
  };
687
733
  }
688
734
  //#endregion
735
+ //#region src/auth/sign-in-state.ts
736
+ const REDEEM_PATH = "/api/v1/vibedev/link/redeem";
737
+ const NETWORK_CODES = /* @__PURE__ */ new Set([
738
+ "ECONNREFUSED",
739
+ "ECONNRESET",
740
+ "ECONNABORTED",
741
+ "ENOTFOUND",
742
+ "EAI_AGAIN",
743
+ "ENETUNREACH",
744
+ "EHOSTUNREACH",
745
+ "EPIPE",
746
+ "ETIMEDOUT",
747
+ "UND_ERR_CONNECT_TIMEOUT",
748
+ "UND_ERR_HEADERS_TIMEOUT",
749
+ "UND_ERR_BODY_TIMEOUT",
750
+ "UND_ERR_SOCKET",
751
+ "CERT_HAS_EXPIRED",
752
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
753
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
754
+ "ERR_TLS_CERT_ALTNAME_INVALID"
755
+ ]);
756
+ const TIMEOUT_CODES = /* @__PURE__ */ new Set([
757
+ "ETIMEDOUT",
758
+ "UND_ERR_CONNECT_TIMEOUT",
759
+ "UND_ERR_HEADERS_TIMEOUT",
760
+ "UND_ERR_BODY_TIMEOUT"
761
+ ]);
762
+ /** Read only fixed transport codes, never error text, stack, body or arbitrary fields. */
763
+ function transportFailure(error) {
764
+ let cause = error;
765
+ let timeout = false;
766
+ let networkCode;
767
+ for (let depth = 0; depth < 5 && typeof cause === "object" && cause !== null; depth++) {
768
+ const value = cause;
769
+ if (value.name === "TimeoutError") timeout = true;
770
+ if (typeof value.code === "string" && NETWORK_CODES.has(value.code)) {
771
+ networkCode ??= value.code;
772
+ if (TIMEOUT_CODES.has(value.code)) timeout = true;
773
+ }
774
+ cause = value.cause;
775
+ }
776
+ return {
777
+ errorCode: timeout ? "timeout" : "network",
778
+ ...networkCode === void 0 ? {} : { networkCode }
779
+ };
780
+ }
781
+ const EXPLANATIONS = {
782
+ network: ["无法连接登录网关,请检查网络后重新登录。", "The sign-in gateway could not be reached. Check the connection and start sign-in again."],
783
+ timeout: ["登录等待或网关请求已超时,请重新登录。", "Sign-in or the gateway request timed out. Start sign-in again."],
784
+ "gateway-refused": ["登录网关拒绝了请求,请重新登录。", "The gateway refused the sign-in request. Start sign-in again."],
785
+ "gateway-unavailable": ["登录网关暂时不可用,请稍后重新登录。", "The sign-in gateway is temporarily unavailable. Start sign-in again later."],
786
+ protocol: ["登录网关返回了无效的凭据格式,请稍后重新登录。", "The gateway returned an invalid credential response. Start sign-in again later."],
787
+ storage: ["无法准备或保存本地登录凭据,请检查存储权限后重新登录。", "Local sign-in credentials could not be prepared or saved. Check storage permissions and start sign-in again."],
788
+ callback: ["无法启动或使用本地登录回调,请重新登录。", "The local sign-in callback could not be started or used. Start sign-in again."]
789
+ };
790
+ const STAGES = {
791
+ listen: ["本地回调监听", "Local callback listener"],
792
+ device: ["准备本地设备标识", "Preparing the local device identity"],
793
+ redeem: ["交换登录凭据", "Exchanging sign-in credentials"],
794
+ persist: ["保存本地凭据", "Saving local credentials"]
795
+ };
796
+ /** A fixed bilingual explanation suitable for exposed rejection messages. */
797
+ function failureMessage(attempt) {
798
+ const [chinese, english] = EXPLANATIONS[attempt.errorCode ?? "callback"];
799
+ const stage = attempt.stage === void 0 ? "" : ` ${STAGES[attempt.stage].join(" / ")}; stage=${attempt.stage}.`;
800
+ const status = attempt.httpStatus === void 0 ? "" : ` HTTP ${attempt.httpStatus}.`;
801
+ return `${english} ${chinese} errorCode=${attempt.errorCode ?? "callback"}.${stage}${status}`;
802
+ }
803
+ const escapeHtml = (text) => text.replace(/[&<>"']/g, (character) => ({
804
+ "&": "&amp;",
805
+ "<": "&lt;",
806
+ ">": "&gt;",
807
+ "\"": "&quot;",
808
+ "'": "&#39;"
809
+ })[character]);
810
+ /** Only our fixed explanation and escaped safe enum/status fields reach the browser. */
811
+ function failurePage(attempt) {
812
+ return `<h1>Sign-in failed.</h1><p>${escapeHtml(failureMessage(attempt))}</p><p>请回到对话重新登录。</p>`;
813
+ }
814
+ /** Explicit log allowlist; adding fields to the public attempt never expands log output. */
815
+ function attemptLog(event, attempt) {
816
+ return `dsh-vibedev sign-in ${JSON.stringify({
817
+ event,
818
+ id: attempt.id,
819
+ phase: attempt.phase,
820
+ gatewayOrigin: attempt.gatewayOrigin,
821
+ path: REDEEM_PATH,
822
+ ...attempt.stage === void 0 ? {} : { stage: attempt.stage },
823
+ ...attempt.errorCode === void 0 ? {} : { errorCode: attempt.errorCode },
824
+ ...attempt.httpStatus === void 0 ? {} : { httpStatus: attempt.httpStatus },
825
+ ...attempt.networkCode === void 0 ? {} : { networkCode: attempt.networkCode }
826
+ })}`;
827
+ }
828
+ //#endregion
689
829
  //#region src/auth/login.ts
690
830
  /**
691
831
  * The plugin's own VibeDev sign-in, for hosts whose account service does not
@@ -772,6 +912,7 @@ var PluginLogin = class {
772
912
  readFailureReported = false;
773
913
  refreshing;
774
914
  pending;
915
+ attempt;
775
916
  listeners = /* @__PURE__ */ new Set();
776
917
  now;
777
918
  origin;
@@ -781,7 +922,7 @@ var PluginLogin = class {
781
922
  constructor(options) {
782
923
  this.options = options;
783
924
  this.now = options.now ?? Date.now;
784
- this.origin = options.origin.trim().replace(/\/+$/, "");
925
+ this.origin = normalizeGatewayOrigin(options.origin);
785
926
  }
786
927
  readStored(notify) {
787
928
  if (this.reading !== void 0) return this.reading;
@@ -816,10 +957,32 @@ var PluginLogin = class {
816
957
  const changed = this.grant?.accessToken !== grant?.accessToken;
817
958
  this.grant = grant;
818
959
  if (!changed) return;
960
+ this.notify();
961
+ }
962
+ notify() {
819
963
  for (const listener of this.listeners) try {
820
964
  listener();
821
965
  } catch {}
822
966
  }
967
+ /** A copied safe snapshot, retained after completion until the next sign-in. */
968
+ signInAttempt() {
969
+ return this.attempt === void 0 ? void 0 : Object.freeze({ ...this.attempt });
970
+ }
971
+ logAttempt(event) {
972
+ if (this.attempt === void 0) return;
973
+ try {
974
+ this.options.log?.(attemptLog(event, this.attempt));
975
+ } catch {}
976
+ }
977
+ transition(id, change, event = "phase") {
978
+ if (this.attempt?.id !== id) return;
979
+ this.attempt = {
980
+ ...this.attempt,
981
+ ...change
982
+ };
983
+ this.logAttempt(event);
984
+ this.notify();
985
+ }
823
986
  async save(grant) {
824
987
  await this.options.storage.write(grant);
825
988
  this.adopt(grant);
@@ -842,9 +1005,10 @@ var PluginLogin = class {
842
1005
  }
843
1006
  /** Whether a sign-in is waiting for the browser, and its link. */
844
1007
  pendingSignIn() {
845
- return this.pending === void 0 ? void 0 : {
846
- url: this.pending.url,
847
- expiresAt: this.pending.expiresAt
1008
+ const pending = this.pending;
1009
+ return pending?.url === void 0 ? void 0 : {
1010
+ url: pending.url,
1011
+ expiresAt: pending.expiresAt
848
1012
  };
849
1013
  }
850
1014
  /**
@@ -900,7 +1064,7 @@ var PluginLogin = class {
900
1064
  });
901
1065
  const body = await response.json().catch(() => void 0);
902
1066
  if (response.status === 401 || response.status === 403) {
903
- this.options.log?.(`dsh-vibedev: the VibeDev session ended (${text$6(record$6(body)?.reason) ?? response.status}); signed out`);
1067
+ this.options.log?.(`dsh-vibedev: the VibeDev session ended (HTTP ${response.status}); signed out`);
904
1068
  outcome = void 0;
905
1069
  return { kind: "delete" };
906
1070
  }
@@ -927,7 +1091,7 @@ var PluginLogin = class {
927
1091
  })();
928
1092
  return this.refreshing;
929
1093
  }
930
- post(path, json, token) {
1094
+ post(path, json, token, signal = AbortSignal.timeout(3e4)) {
931
1095
  return (this.options.fetch ?? globalThis.fetch)(`${this.origin}${path}`, {
932
1096
  method: "POST",
933
1097
  headers: {
@@ -937,7 +1101,8 @@ var PluginLogin = class {
937
1101
  ...token === void 0 ? {} : { authorization: `Bearer ${token}` }
938
1102
  },
939
1103
  body: JSON.stringify(json),
940
- signal: AbortSignal.timeout(3e4)
1104
+ redirect: "error",
1105
+ signal
941
1106
  });
942
1107
  }
943
1108
  /**
@@ -948,19 +1113,22 @@ var PluginLogin = class {
948
1113
  * @returns the authorization link, whether a browser was opened, and the completion.
949
1114
  */
950
1115
  async startSignIn(request = {}) {
951
- const open = (url) => request.open === false ? Promise.resolve(false) : (this.options.openBrowser ?? openInBrowser)(url);
952
- if (this.pending !== void 0 && this.pending.expiresAt > this.now()) {
953
- const opened = await open(this.pending.url);
954
- return {
955
- url: this.pending.url,
956
- expiresAt: this.pending.expiresAt,
957
- done: this.pending.done,
958
- opened
959
- };
960
- }
1116
+ if (this.pending !== void 0 && !this.pending.committing && this.pending.expiresAt <= this.now()) this.pending.cancel("expired");
1117
+ const active = this.pending ?? this.beginSignIn();
1118
+ const started = await active.ready;
1119
+ const opened = request.open === false || this.pending !== active ? false : await (this.options.openBrowser ?? openInBrowser)(started.url).catch(() => false);
1120
+ return {
1121
+ ...started,
1122
+ opened
1123
+ };
1124
+ }
1125
+ beginSignIn() {
961
1126
  const verifier = base64url(randomBytes(32));
962
1127
  const challenge = base64url(createHash("sha256").update(verifier).digest());
963
1128
  const state = base64url(randomBytes(18));
1129
+ const id = randomUUID();
1130
+ const expiresAt = this.now() + (this.options.signInTimeoutMs ?? 3e5);
1131
+ const controller = new AbortController();
964
1132
  let resolveDone;
965
1133
  let rejectDone;
966
1134
  const done = new Promise((resolve, reject) => {
@@ -968,8 +1136,73 @@ var PluginLogin = class {
968
1136
  rejectDone = reject;
969
1137
  });
970
1138
  done.catch(() => void 0);
971
- let settled = false;
972
- const server = createServer((request, response) => {
1139
+ let resolveReady;
1140
+ let rejectReady;
1141
+ const ready = new Promise((resolve, reject) => {
1142
+ resolveReady = resolve;
1143
+ rejectReady = reject;
1144
+ });
1145
+ ready.catch(() => void 0);
1146
+ let finished = false;
1147
+ let claimed = false;
1148
+ let server;
1149
+ let timer;
1150
+ const finish = () => {
1151
+ finished = true;
1152
+ clearTimeout(timer);
1153
+ server?.close();
1154
+ server?.closeAllConnections?.();
1155
+ if (this.pending === active) this.pending = void 0;
1156
+ };
1157
+ const fail = (failure, phase = "failed") => {
1158
+ const attempt = {
1159
+ ...this.attempt,
1160
+ ...failure,
1161
+ phase
1162
+ };
1163
+ const error = new MediaError(phase === "cancelled" ? "The sign-in was cancelled." : failureMessage(attempt), phase === "expired" ? "SIGN_IN_TIMEOUT" : phase === "cancelled" ? "SIGN_IN_CANCELLED" : "SIGN_IN_REFUSED", attempt.httpStatus === void 0 ? {} : { status: attempt.httpStatus });
1164
+ finish();
1165
+ controller.abort();
1166
+ this.transition(id, {
1167
+ ...failure,
1168
+ phase
1169
+ }, "terminal");
1170
+ rejectReady(error);
1171
+ rejectDone(error);
1172
+ return error;
1173
+ };
1174
+ const active = {
1175
+ id,
1176
+ expiresAt,
1177
+ ready,
1178
+ done,
1179
+ committing: false,
1180
+ cancel: (phase) => {
1181
+ if (!finished && !active.committing) fail(phase === "expired" ? { errorCode: "timeout" } : {}, phase);
1182
+ }
1183
+ };
1184
+ this.pending = active;
1185
+ this.attempt = {
1186
+ id,
1187
+ expiresAt,
1188
+ gatewayOrigin: this.origin,
1189
+ phase: "preparing",
1190
+ stage: "listen"
1191
+ };
1192
+ this.logAttempt("request");
1193
+ this.notify();
1194
+ const live = () => {
1195
+ if (finished || this.pending !== active) return false;
1196
+ if (!active.committing && this.now() >= expiresAt) {
1197
+ active.cancel("expired");
1198
+ return false;
1199
+ }
1200
+ return true;
1201
+ };
1202
+ if (!live()) return active;
1203
+ timer = setTimeout(() => active.cancel("expired"), Math.max(0, expiresAt - this.now()));
1204
+ timer.unref();
1205
+ const callback = (request, response) => {
973
1206
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
974
1207
  if (url.pathname !== CALLBACK_PATH) {
975
1208
  response.writeHead(404).end();
@@ -981,107 +1214,170 @@ var PluginLogin = class {
981
1214
  "cache-control": "no-store"
982
1215
  }).end(PAGE(title, body));
983
1216
  };
984
- if (settled) {
985
- answer(410, "VibeDev", "<h1>This sign-in link has already been used.</h1><p>此登录链接已使用过。</p>");
1217
+ if (request.method !== "GET") {
1218
+ response.setHeader("allow", "GET");
1219
+ answer(405, "VibeDev", "<h1>Use GET for sign-in.</h1><p>请使用浏览器登录回调。</p>");
1220
+ return;
1221
+ }
1222
+ if (claimed || !live()) {
1223
+ answer(410, "VibeDev", "<h1>This sign-in link has already been used.</h1><p>此登录链接已使用过或已失效。</p>");
986
1224
  return;
987
1225
  }
988
- if (url.searchParams.get("state") !== state || url.searchParams.get("code") === null) {
1226
+ const states = url.searchParams.getAll("state");
1227
+ const codes = url.searchParams.getAll("code");
1228
+ const code = codes[0];
1229
+ if (states.length !== 1 || states[0] !== state || codes.length !== 1 || code === void 0 || code.trim() === "") {
989
1230
  answer(400, "VibeDev", "<h1>Sign-in could not be completed.</h1><p>登录未完成:链接无效,请回到对话重新登录。</p>");
990
1231
  return;
991
1232
  }
992
- settled = true;
993
- const code = url.searchParams.get("code");
994
- this.redeem(code, verifier).then((grant) => {
995
- const who = grant.user?.email ?? grant.user?.nickname;
996
- answer(200, "VibeDev", `<h1>Signed in to VibeDev${who === void 0 ? "" : ` as ${who.replace(/[<>&"]/g, "")}`}.</h1><p>已登录 VibeDev,可以关闭此页面,回到对话继续。</p><p>You can close this tab and return to the conversation.</p>`);
1233
+ claimed = true;
1234
+ this.transition(id, {
1235
+ phase: "exchanging",
1236
+ stage: "device"
1237
+ }, "request");
1238
+ (async () => {
1239
+ let grant;
1240
+ try {
1241
+ if (!live()) return;
1242
+ const deviceId = await this.options.storage.deviceId();
1243
+ if (!live()) return;
1244
+ this.transition(id, { stage: "redeem" }, "request");
1245
+ if (!live()) return;
1246
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(3e4)]);
1247
+ const response = await this.post(REDEEM_PATH, {
1248
+ code,
1249
+ code_verifier: verifier,
1250
+ token_type: "app",
1251
+ client: this.options.client ?? "vibedev-plugin",
1252
+ device_id: deviceId,
1253
+ device_model: `${platform()}-${arch()}`.slice(0, 128),
1254
+ os_version: release().slice(0, 128)
1255
+ }, void 0, signal);
1256
+ if (!live()) {
1257
+ response.body?.cancel().catch(() => void 0);
1258
+ return;
1259
+ }
1260
+ this.transition(id, { httpStatus: response.status }, "response");
1261
+ if (!live()) {
1262
+ response.body?.cancel().catch(() => void 0);
1263
+ return;
1264
+ }
1265
+ if (!response.ok) {
1266
+ response.body?.cancel().catch(() => void 0);
1267
+ const errorCode = response.status === 429 || response.status >= 500 ? "gateway-unavailable" : response.status >= 400 && response.status < 500 ? "gateway-refused" : "protocol";
1268
+ answer(502, "VibeDev", failurePage({
1269
+ ...this.attempt,
1270
+ errorCode
1271
+ }));
1272
+ fail({ errorCode });
1273
+ return;
1274
+ }
1275
+ let body;
1276
+ try {
1277
+ body = await response.json();
1278
+ } catch (error) {
1279
+ if (!live()) return;
1280
+ const transport = transportFailure(error);
1281
+ const failure = transport.networkCode !== void 0 || transport.errorCode === "timeout" ? transport : { errorCode: "protocol" };
1282
+ answer(502, "VibeDev", failurePage({
1283
+ ...this.attempt,
1284
+ ...failure
1285
+ }));
1286
+ fail(failure);
1287
+ return;
1288
+ }
1289
+ if (!live()) return;
1290
+ const envelope = record$6(body);
1291
+ const parsed = envelope !== void 0 && (envelope.code === void 0 || envelope.code === 0) && (envelope.data === void 0 || record$6(envelope.data) !== void 0) ? parseGrant(body, this.now()) : void 0;
1292
+ if (parsed === void 0) {
1293
+ answer(502, "VibeDev", failurePage({
1294
+ ...this.attempt,
1295
+ errorCode: "protocol"
1296
+ }));
1297
+ fail({ errorCode: "protocol" });
1298
+ return;
1299
+ }
1300
+ grant = parsed;
1301
+ active.committing = true;
1302
+ clearTimeout(timer);
1303
+ this.transition(id, {
1304
+ phase: "committing",
1305
+ stage: "persist"
1306
+ });
1307
+ await this.save(grant);
1308
+ } catch (error) {
1309
+ if (finished || this.pending !== active) return;
1310
+ const failure = this.attempt?.stage === "device" || this.attempt?.stage === "persist" ? { errorCode: "storage" } : transportFailure(error);
1311
+ answer(502, "VibeDev", failurePage({
1312
+ ...this.attempt,
1313
+ ...failure
1314
+ }));
1315
+ fail(failure);
1316
+ return;
1317
+ }
1318
+ answer(200, "VibeDev", "<h1>Signed in to VibeDev.</h1><p>已登录 VibeDev,可以关闭此页面,回到对话继续。</p><p>You can close this tab and return to the conversation.</p>");
997
1319
  finish();
1320
+ this.transition(id, { phase: "succeeded" }, "terminal");
998
1321
  resolveDone(grant);
999
- }, (error) => {
1000
- answer(502, "VibeDev", "<h1>Sign-in failed.</h1><p>登录失败,请回到对话重新登录。</p>");
1001
- finish();
1002
- rejectDone(error instanceof Error ? error : new Error(String(error)));
1322
+ })();
1323
+ };
1324
+ try {
1325
+ server = createServer(callback);
1326
+ server.on("error", () => {
1327
+ if (finished || active.committing) return;
1328
+ this.transition(id, { stage: "listen" });
1329
+ if (!finished) fail({ errorCode: "callback" });
1003
1330
  });
1004
- });
1005
- await new Promise((resolve, reject) => {
1006
- server.once("error", reject);
1007
1331
  server.listen(0, "127.0.0.1", () => {
1008
- server.off("error", reject);
1009
- resolve();
1010
- });
1011
- });
1012
- const callback = `http://127.0.0.1:${server.address().port}${CALLBACK_PATH}`;
1013
- const url = `${this.origin}/vibedev-link?${new URLSearchParams({
1014
- callback,
1015
- state,
1016
- response_type: "code",
1017
- code_challenge: challenge,
1018
- code_challenge_method: "S256",
1019
- client: this.options.client ?? "vibedev-plugin"
1020
- }).toString()}`;
1021
- const timeoutMs = this.options.signInTimeoutMs ?? 3e5;
1022
- const timer = setTimeout(() => {
1023
- if (settled) return;
1024
- settled = true;
1025
- finish();
1026
- rejectDone(new MediaError("The VibeDev sign-in was not completed in time.", "SIGN_IN_TIMEOUT"));
1027
- }, timeoutMs);
1028
- timer.unref();
1029
- const finish = () => {
1030
- clearTimeout(timer);
1031
- server.close();
1032
- server.closeAllConnections?.();
1033
- if (this.pending?.server === server) this.pending = void 0;
1034
- };
1035
- this.pending = {
1036
- url,
1037
- expiresAt: this.now() + timeoutMs,
1038
- done,
1039
- server,
1040
- cancel: (reason) => {
1041
- if (!settled) {
1042
- settled = true;
1043
- finish();
1044
- rejectDone(reason);
1332
+ if (!live()) {
1333
+ server?.close();
1334
+ server?.closeAllConnections?.();
1335
+ return;
1045
1336
  }
1046
- }
1047
- };
1048
- const opened = await open(url);
1049
- return {
1050
- url,
1051
- expiresAt: this.now() + timeoutMs,
1052
- done,
1053
- opened
1054
- };
1055
- }
1056
- async redeem(code, verifier) {
1057
- const response = await this.post("/api/v1/vibedev/link/redeem", {
1058
- code,
1059
- code_verifier: verifier,
1060
- token_type: "app",
1061
- client: this.options.client ?? "vibedev-plugin",
1062
- device_id: await this.options.storage.deviceId(),
1063
- device_model: `${platform()}-${arch()}`.slice(0, 128),
1064
- os_version: release().slice(0, 128)
1065
- });
1066
- const body = await response.json().catch(() => void 0);
1067
- if (!response.ok) throw new MediaError(`The VibeDev gateway refused the sign-in (HTTP ${response.status}${text$6(record$6(body)?.reason) === void 0 ? "" : `, ${text$6(record$6(body)?.reason)}`}).`, "SIGN_IN_REFUSED", { status: response.status });
1068
- const grant = parseGrant(body, this.now());
1069
- if (grant === void 0) throw new MediaError("The VibeDev gateway returned no tokens for the sign-in.", "SIGN_IN_REFUSED");
1070
- await this.save(grant);
1071
- return grant;
1337
+ const port = (server?.address()).port;
1338
+ const url = `${this.origin}/vibedev-link?${new URLSearchParams({
1339
+ callback: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
1340
+ state,
1341
+ response_type: "code",
1342
+ code_challenge: challenge,
1343
+ code_challenge_method: "S256",
1344
+ client: this.options.client ?? "vibedev-plugin"
1345
+ }).toString()}`;
1346
+ active.url = url;
1347
+ this.transition(id, {
1348
+ phase: "waiting-browser",
1349
+ stage: void 0
1350
+ });
1351
+ if (live()) resolveReady({
1352
+ url,
1353
+ expiresAt,
1354
+ done
1355
+ });
1356
+ });
1357
+ } catch {
1358
+ if (!finished) fail({ errorCode: "callback" });
1359
+ }
1360
+ return active;
1072
1361
  }
1073
1362
  /** Sign out: revoke the session at the gateway (best effort) and forget it. */
1074
1363
  async signOut() {
1364
+ const attemptId = this.attempt?.id;
1365
+ const pending = this.pending;
1366
+ if (pending?.committing) await pending.done.catch(() => void 0);
1367
+ else pending?.cancel("cancelled");
1075
1368
  await this.load();
1076
1369
  const grant = this.grant;
1077
- this.pending?.cancel(new MediaError("The sign-in was cancelled.", "SIGN_IN_CANCELLED"));
1370
+ if (grant !== void 0) await this.save(void 0);
1371
+ if (this.attempt?.id === attemptId && this.pending === void 0) {
1372
+ this.attempt = void 0;
1373
+ this.notify();
1374
+ }
1078
1375
  if (grant === void 0) return;
1079
- await this.save(void 0);
1080
1376
  await this.post("/api/v1/vibedev/app-token/revoke", { refresh_token: grant.refreshToken }, grant.accessToken).then((response) => response.body?.cancel(), () => void 0);
1081
1377
  }
1082
1378
  /** Stop a waiting sign-in. */
1083
1379
  dispose() {
1084
- this.pending?.cancel(new MediaError("The plugin stopped before the sign-in finished.", "SIGN_IN_CANCELLED"));
1380
+ this.pending?.cancel("cancelled");
1085
1381
  }
1086
1382
  };
1087
1383
  //#endregion
@@ -2495,7 +2791,12 @@ var GatewayAdapter = class extends LlmAdapter {
2495
2791
  const token = credential.token;
2496
2792
  let response;
2497
2793
  const observingFetch = async (input, init) => {
2498
- const received = await fetch(input, init);
2794
+ const url = input instanceof Request ? input.url : String(input);
2795
+ if (new URL(url).origin !== snapshot.origin) throw new Error("Refusing a model request to another gateway origin.");
2796
+ const received = await fetch(input, {
2797
+ ...init,
2798
+ redirect: "error"
2799
+ });
2499
2800
  response = {
2500
2801
  status: received.status,
2501
2802
  headers: Object.fromEntries(received.headers.entries())
@@ -3141,7 +3442,7 @@ var GatewayHttp = class {
3141
3442
  method: request.method ?? (body === void 0 ? "GET" : "POST"),
3142
3443
  headers,
3143
3444
  ...body === void 0 ? {} : { body },
3144
- redirect: "follow",
3445
+ redirect: "error",
3145
3446
  signal
3146
3447
  });
3147
3448
  } catch (error) {
@@ -3213,6 +3514,13 @@ const VIDEO_INPUTS = [
3213
3514
  "referenceVideos",
3214
3515
  "referenceAudios"
3215
3516
  ];
3517
+ /** What an audio model does, as the catalog declares it (`audio.generation_kind`). */
3518
+ const AUDIO_GENERATION_KINDS = [
3519
+ "music",
3520
+ "podcast",
3521
+ "speech",
3522
+ "transcription"
3523
+ ];
3216
3524
  const record$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
3217
3525
  const text$4 = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
3218
3526
  const strings = (value) => Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
@@ -3446,10 +3754,144 @@ function effectiveVideoModes(video) {
3446
3754
  }
3447
3755
  return modes;
3448
3756
  }
3449
- function parsePricing(value) {
3757
+ const PRICE_UNITS = [
3758
+ "generation",
3759
+ "second",
3760
+ "original_second",
3761
+ "minute"
3762
+ ];
3763
+ /** The audio block: `entry.audio`, else `capabilities.audio`, else the fields flattened into `capabilities`. */
3764
+ function audioBlock(entry, capabilities) {
3765
+ const own = record$3(entry.audio) ?? record$3(capabilities.audio);
3766
+ if (own !== void 0) return own;
3767
+ return field(capabilities, "generation_kind") !== void 0 ? capabilities : void 0;
3768
+ }
3769
+ function stringChoices(raw) {
3770
+ const choices = record$3(raw);
3771
+ if (choices === void 0) return void 0;
3772
+ const out = {};
3773
+ for (const [name, values] of Object.entries(choices)) {
3774
+ const items = strings(values)?.map((item) => item.trim()).filter((item) => item !== "");
3775
+ if (items !== void 0 && items.length > 0) out[name] = items;
3776
+ }
3777
+ return Object.keys(out).length === 0 ? void 0 : out;
3778
+ }
3779
+ function parseReferenceLimits(raw) {
3780
+ if (raw === void 0) return void 0;
3781
+ const max = count(field(raw, "max_count")) ?? count(raw.max);
3782
+ if (max === void 0 || max === 0) return void 0;
3783
+ const min = count(field(raw, "min_count")) ?? count(raw.min) ?? 0;
3784
+ const limits = {
3785
+ min: Math.min(min, max),
3786
+ max
3787
+ };
3788
+ for (const [key, ...names] of [
3789
+ [
3790
+ "maxBytes",
3791
+ "max_bytes",
3792
+ "max_size_bytes"
3793
+ ],
3794
+ [
3795
+ "minSeconds",
3796
+ "min_duration_seconds",
3797
+ "min_seconds"
3798
+ ],
3799
+ [
3800
+ "maxSeconds",
3801
+ "max_duration_seconds",
3802
+ "max_seconds"
3803
+ ],
3804
+ ["maxTotalSeconds", "max_total_duration_seconds"]
3805
+ ]) {
3806
+ const value = names.map((name) => positive(field(raw, name))).find((found) => found !== void 0);
3807
+ if (value !== void 0) limits[key] = value;
3808
+ }
3809
+ const mimes = list(raw, "allowed_mimes") ?? list(raw, "mimes");
3810
+ if (mimes !== void 0 && mimes.length > 0) limits.mimes = mimes;
3811
+ const exclusive = list(raw, "mutually_exclusive_with") ?? list(raw, "exclusive_with");
3812
+ if (exclusive !== void 0 && exclusive.length > 0) limits.exclusiveWith = exclusive;
3813
+ const purpose = text$4(field(raw, "asset_purpose")) ?? text$4(raw.purpose);
3814
+ if (purpose !== void 0) limits.purpose = purpose;
3815
+ return limits;
3816
+ }
3817
+ /**
3818
+ * The output formats, in either declared shape: `output_formats: [{format, sample_rates}]`,
3819
+ * or `output_formats: ['wav','mp3']` with `sample_rates: {wav: [...], mp3: [...]}`.
3820
+ */
3821
+ function parseOutputFormats(raw) {
3822
+ const declared = field(raw, "output_formats");
3823
+ if (!Array.isArray(declared)) return void 0;
3824
+ const rates = record$3(field(raw, "sample_rates"));
3825
+ const formats = [];
3826
+ for (const item of declared) {
3827
+ const entry = record$3(item);
3828
+ const format = (entry === void 0 ? text$4(item) : text$4(entry.format))?.toLowerCase();
3829
+ if (format === void 0 || formats.some((known) => known.format === format)) continue;
3830
+ const sampleRates = (entry === void 0 ? void 0 : field(entry, "sample_rates")) ?? rates?.[format];
3831
+ const valid = Array.isArray(sampleRates) ? sampleRates.filter((rate) => positive(rate) !== void 0 && Number.isInteger(rate)) : [];
3832
+ const fallback = entry === void 0 ? void 0 : positive(field(entry, "default_sample_rate"));
3833
+ formats.push({
3834
+ format,
3835
+ sampleRates: valid,
3836
+ ...fallback !== void 0 && valid.includes(fallback) ? { defaultSampleRate: fallback } : {}
3837
+ });
3838
+ }
3839
+ return formats.length === 0 ? void 0 : formats;
3840
+ }
3841
+ /**
3842
+ * Parse an audio capability block.
3843
+ * @param raw - the block.
3844
+ * @returns the declared capabilities; `generationKind` is absent unless declared.
3845
+ */
3846
+ function parseAudioCapabilities(raw) {
3847
+ const kindRaw = text$4(field(raw, "generation_kind"))?.toLowerCase();
3848
+ const generationKind = AUDIO_GENERATION_KINDS.includes(kindRaw ?? "") ? kindRaw : void 0;
3849
+ const billingRaw = text$4(field(raw, "billing_unit"))?.toLowerCase();
3850
+ const billingUnit = billingRaw === "generation" || billingRaw === "original_second" || billingRaw === "minute" ? billingRaw : void 0;
3851
+ const promptField = text$4(field(raw, "prompt_field"));
3852
+ const requestFields = list(raw, "request_fields") ?? [];
3853
+ const references = parseReferenceLimits(record$3(field(raw, "references")) ?? record$3(field(raw, "reference_audio")));
3854
+ const outputFormats = parseOutputFormats(raw);
3855
+ const defaultFormat = text$4(field(raw, "default_format"))?.toLowerCase();
3856
+ const choices = stringChoices(field(raw, "choices") ?? field(raw, "field_options"));
3857
+ const result = { requestFields };
3858
+ if (generationKind !== void 0) result.generationKind = generationKind;
3859
+ if (promptField !== void 0) result.promptField = promptField;
3860
+ if (billingUnit !== void 0) result.billingUnit = billingUnit;
3861
+ if (references !== void 0) result.references = references;
3862
+ if (outputFormats !== void 0) result.outputFormats = outputFormats;
3863
+ if (defaultFormat !== void 0 && outputFormats?.some((format) => format.format === defaultFormat) === true) result.defaultFormat = defaultFormat;
3864
+ if (choices !== void 0) result.choices = choices;
3865
+ for (const [key, ...names] of [
3866
+ [
3867
+ "maxPromptChars",
3868
+ "max_prompt_chars",
3869
+ "max_text_chars"
3870
+ ],
3871
+ [
3872
+ "maxInputBytes",
3873
+ "max_input_bytes",
3874
+ "max_file_bytes"
3875
+ ],
3876
+ [
3877
+ "maxInputSeconds",
3878
+ "max_input_seconds",
3879
+ "max_duration_seconds"
3880
+ ]
3881
+ ]) {
3882
+ const value = names.map((name) => positive(field(raw, name))).find((found) => found !== void 0);
3883
+ if (value !== void 0) result[key] = value;
3884
+ }
3885
+ const inputMimes = list(raw, "input_mimes") ?? list(raw, "allowed_input_mimes");
3886
+ if (inputMimes !== void 0 && inputMimes.length > 0) result.inputMimes = inputMimes;
3887
+ const languages = list(raw, "languages");
3888
+ if (languages !== void 0 && languages.length > 0) result.languages = languages;
3889
+ return result;
3890
+ }
3891
+ function parsePricing(value, audioUnit) {
3450
3892
  const raw = record$3(value);
3451
3893
  if (raw === void 0) return void 0;
3452
- const unit = (item) => item === "generation" || item === "second" ? item : void 0;
3894
+ const unit = (item) => PRICE_UNITS.includes(item) ? item : item === void 0 && audioUnit !== void 0 ? audioUnit : void 0;
3453
3895
  const tiers = [];
3454
3896
  if (Array.isArray(raw.tiers)) for (const entry of raw.tiers) {
3455
3897
  const tier = record$3(entry);
@@ -3476,8 +3918,10 @@ function parsePricing(value) {
3476
3918
  };
3477
3919
  }
3478
3920
  /** Which kind of media model a catalog entry is, or undefined for a chat or internal model. */
3479
- function kindOf(entry, capabilities) {
3921
+ function kindOf(entry, capabilities, audio) {
3480
3922
  if (text$4(entry.internal_role) !== void 0) return void 0;
3923
+ if (audio?.generationKind === "transcription") return "transcription";
3924
+ if (audio?.generationKind !== void 0) return "audio";
3481
3925
  const mediaType = text$4(entry.media_type);
3482
3926
  if (mediaType === "video" || mediaType === "image" || mediaType === "audio" || mediaType === "transcription") return mediaType;
3483
3927
  if (capabilities.transcription === true || capabilities.realtime_transcription === true) return "transcription";
@@ -3506,29 +3950,35 @@ function parseMediaCatalog(body) {
3506
3950
  const id = text$4(entry?.id);
3507
3951
  if (entry === void 0 || id === void 0 || seen.has(id)) continue;
3508
3952
  const capabilities = record$3(entry.capabilities) ?? {};
3509
- const kind = kindOf(entry, capabilities);
3953
+ const audioRaw = audioBlock(entry, capabilities);
3954
+ const audio = audioRaw === void 0 ? void 0 : parseAudioCapabilities(audioRaw);
3955
+ const kind = kindOf(entry, capabilities, audio);
3510
3956
  if (kind === void 0) continue;
3511
3957
  seen.add(id);
3512
3958
  const name = text$4(entry.display_name) ?? id;
3513
3959
  const description = text$4(entry.description);
3514
- const pricing = parsePricing(entry.media_pricing);
3960
+ const pricing = parsePricing(entry.media_pricing, audio?.billingUnit);
3515
3961
  const inputModalities = strings(capabilities.input_modalities) ?? strings(entry.input_modalities) ?? [];
3516
3962
  const endpoints = strings(capabilities.allowed_endpoints) ?? [];
3963
+ const aliases = (strings(entry.aliases) ?? []).map((alias) => alias.trim()).filter((alias) => alias !== "" && alias !== id);
3517
3964
  let video;
3518
3965
  if (kind === "video") video = {
3519
3966
  ...parseVideoCapabilities(capabilities),
3520
3967
  ...parseVideoCapabilities(record$3(entry.video) ?? {})
3521
3968
  };
3969
+ const audioKind = audio?.generationKind === void 0 || audio.generationKind === "transcription" ? void 0 : audio.generationKind;
3522
3970
  models.push({
3523
3971
  id,
3524
3972
  name,
3525
3973
  kind,
3526
3974
  endpoints,
3527
3975
  inputModalities,
3976
+ ...aliases.length === 0 ? {} : { aliases },
3528
3977
  ...description === void 0 ? {} : { description },
3529
3978
  ...pricing === void 0 ? {} : { pricing },
3530
3979
  ...video === void 0 ? {} : { video },
3531
- ...kind === "audio" ? { audioKind: /podcast/i.test(id) || /播客/.test(name) ? "podcast" : "music" } : {}
3980
+ ...audio === void 0 ? {} : { audio },
3981
+ ...audioKind === void 0 ? {} : { audioKind }
3532
3982
  });
3533
3983
  }
3534
3984
  return models;
@@ -3820,6 +4270,27 @@ async function loadChatReference(reference, want, messages, store, maxBytes, sig
3820
4270
  * spending confirmation.
3821
4271
  * @module dsh-vibedev/runtime
3822
4272
  */
4273
+ /**
4274
+ * Whether a model serves a slot. Audio models serve the kind their catalog
4275
+ * entry declares, and only that: an undeclared kind serves none.
4276
+ * @param model - the model.
4277
+ * @param slot - the slot.
4278
+ * @returns whether the model can be chosen for it.
4279
+ */
4280
+ function servesSlot(model, slot) {
4281
+ if (slot === "music" || slot === "podcast" || slot === "speech") return model.kind === "audio" && model.audioKind === slot;
4282
+ return model.kind === slot;
4283
+ }
4284
+ /**
4285
+ * The model a name refers to: its id, else one of its aliases (case-insensitive).
4286
+ * @param models - the catalog.
4287
+ * @param wanted - the id or alias.
4288
+ * @returns the model, or undefined.
4289
+ */
4290
+ function modelNamed(models, wanted) {
4291
+ const lower = wanted.toLowerCase();
4292
+ return models.find((model) => model.id === wanted) ?? models.find((model) => model.id.toLowerCase() === lower) ?? models.find((model) => (model.aliases ?? []).some((alias) => alias.toLowerCase() === lower));
4293
+ }
3823
4294
  const CATALOG_TTL_MS = 12e4;
3824
4295
  /** Wait for a shared promise under one caller's signal without cancelling it for the others. */
3825
4296
  function abortable$1(promise, signal) {
@@ -3923,20 +4394,36 @@ var MediaRuntime = class {
3923
4394
  */
3924
4395
  async pickModel(kind, slot, requested, accept, signal) {
3925
4396
  const models = await this.models(signal);
3926
- const ofKind = models.filter((model) => model.kind === kind && (slot === "music" ? model.audioKind !== "podcast" : slot === "podcast" ? model.audioKind === "podcast" : true));
4397
+ const ofKind = models.filter((model) => model.kind === kind && servesSlot(model, slot));
3927
4398
  const wanted = requested?.trim();
3928
4399
  if (wanted !== void 0 && wanted !== "") {
3929
- const found = models.find((model) => model.id === wanted) ?? models.find((model) => model.id.toLowerCase() === wanted.toLowerCase());
4400
+ const found = modelNamed(models, wanted);
3930
4401
  if (found === void 0) throw new MediaError(`There is no model "${wanted}" on this VibeDev account. ${slotLabel(slot)} models: ${ofKind.map((model) => model.id).join(", ") || "none"}.`, "MODEL_NOT_FOUND", { field: "model" });
3931
- if (!ofKind.includes(found)) throw new MediaError(`${found.id} is not a ${slotLabel(slot).toLowerCase()} model. ${slotLabel(slot)} models: ${ofKind.map((model) => model.id).join(", ") || "none"}.`, "MODEL_KIND_MISMATCH", { field: "model" });
4402
+ if (!ofKind.includes(found)) {
4403
+ if (found.kind === "audio" && found.audioKind === void 0) throw new MediaError(`${found.id} does not declare what kind of audio it makes, so it cannot be used for ${slotLabel(slot).toLowerCase()}.`, "AUDIO_KIND_UNDECLARED", { field: "model" });
4404
+ throw new MediaError(`${found.id} is not a ${slotLabel(slot).toLowerCase()} model. ${slotLabel(slot)} models: ${ofKind.map((model) => model.id).join(", ") || "none"}.`, "MODEL_KIND_MISMATCH", { field: "model" });
4405
+ }
3932
4406
  return found;
3933
4407
  }
3934
4408
  if (ofKind.length === 0) throw new MediaError(`This VibeDev account has no ${slotLabel(slot).toLowerCase()} model available right now.`, "NO_MODEL_AVAILABLE");
3935
- const pinned = this.settings.defaultModel(slot).trim();
3936
- const ordered = pinned === "" ? ofKind : [...ofKind.filter((model) => model.id === pinned), ...ofKind.filter((model) => model.id !== pinned)];
4409
+ const pinnedName = this.settings.defaultModel(slot).trim();
4410
+ const pinned = pinnedName === "" ? void 0 : modelNamed(ofKind, pinnedName);
4411
+ const ordered = pinned === void 0 ? ofKind : [pinned, ...ofKind.filter((model) => model !== pinned)];
3937
4412
  return (accept === void 0 ? void 0 : ordered.find((model) => accept(model))) ?? ordered[0];
3938
4413
  }
3939
4414
  /**
4415
+ * The model a name refers to, whatever its kind.
4416
+ * @param requested - an id or alias.
4417
+ * @param signal - cancels the catalog request.
4418
+ * @returns the model.
4419
+ * @throws {@link MediaError} when the account has no such model.
4420
+ */
4421
+ async findModel(requested, signal) {
4422
+ const found = modelNamed(await this.models(signal), requested.trim());
4423
+ if (found === void 0) throw new MediaError(`There is no model "${requested}" on this VibeDev account.`, "MODEL_NOT_FOUND", { field: "model" });
4424
+ return found;
4425
+ }
4426
+ /**
3940
4427
  * The workspace of the calling session.
3941
4428
  * @param call - the tool call or host call.
3942
4429
  * @returns its absolute working directory, when it has one.
@@ -4073,6 +4560,7 @@ function slotLabel(slot) {
4073
4560
  video: "Video",
4074
4561
  music: "Music",
4075
4562
  podcast: "Podcast",
4563
+ speech: "Speech",
4076
4564
  transcription: "Transcription"
4077
4565
  }[slot];
4078
4566
  }
@@ -4316,14 +4804,23 @@ var GatewaySearchProvider = class {
4316
4804
  };
4317
4805
  //#endregion
4318
4806
  //#region src/pricing.ts
4807
+ /** Shown wherever the catalog gives no price. */
4808
+ const UNPRICED = {
4809
+ en: "not priced",
4810
+ zh: "未定价"
4811
+ };
4319
4812
  /** Seconds the gateway bills when a request names no duration. */
4320
4813
  const DEFAULT_BILLED_SECONDS = 8;
4321
4814
  /**
4322
- * Format an amount in yuan.
4815
+ * Format an amount in yuan: two decimals, more when a small unit price needs them (`¥0.015`).
4323
4816
  * @param amount - the amount.
4324
4817
  * @returns `¥4.95`.
4325
4818
  */
4326
4819
  function yuan(amount) {
4820
+ if (amount !== 0 && Math.abs(amount) < .1) {
4821
+ const precise = amount.toFixed(4).replace(/0+$/, "");
4822
+ if (precise.split(".")[1].length > 2) return `¥${precise}`;
4823
+ }
4327
4824
  return `¥${amount.toFixed(2)}`;
4328
4825
  }
4329
4826
  function tierFor(pricing, wanted) {
@@ -4335,17 +4832,47 @@ function tierFor(pricing, wanted) {
4335
4832
  return pricing.tiers.length === 1 ? pricing.tiers[0] : void 0;
4336
4833
  }
4337
4834
  /**
4338
- * A one-line price for listings: `¥0.10 per image`, `¥0.99/s (720p)`.
4835
+ * The unit after a price: `/s`, `/原始秒`, `/次`.
4836
+ * @param unit - the price unit.
4837
+ * @param kind - the kind of model, which names a generation (an image, a video, a request).
4838
+ * @returns the suffix in both languages.
4839
+ */
4840
+ function unitSuffix(unit, kind) {
4841
+ switch (unit) {
4842
+ case "second": return {
4843
+ en: "/s",
4844
+ zh: "/秒"
4845
+ };
4846
+ case "original_second": return {
4847
+ en: "/s of original audio",
4848
+ zh: "/原始秒"
4849
+ };
4850
+ case "minute": return {
4851
+ en: "/min",
4852
+ zh: "/分钟"
4853
+ };
4854
+ default: return kind === "image" ? {
4855
+ en: " per image",
4856
+ zh: "/张"
4857
+ } : {
4858
+ en: " per generation",
4859
+ zh: "/次"
4860
+ };
4861
+ }
4862
+ }
4863
+ /**
4864
+ * A one-line price for listings: `¥0.10 per image`, `¥0.99/s (720p)`; `not priced` without a price.
4339
4865
  * @param model - the model.
4340
- * @returns the price, or undefined when the catalog gives none.
4866
+ * @param lang - the language.
4867
+ * @returns the price line.
4341
4868
  */
4342
- function describePrice(model) {
4869
+ function describePrice(model, lang = "en") {
4343
4870
  const pricing = model.pricing;
4344
- if (pricing === void 0) return void 0;
4345
- const per = (unit) => unit === "second" ? "/s" : model.kind === "image" ? " per image" : " per generation";
4871
+ if (pricing === void 0) return UNPRICED[lang];
4872
+ const per = (unit) => unitSuffix(unit, model.kind)[lang];
4346
4873
  const parts = pricing.tiers.map((tier) => `${yuan(tier.amount)}${per(tier.unit)} (${tier.tier})`);
4347
- if (pricing.default !== void 0 && (pricing.tiers.length === 0 || !pricing.tiers.some((tier) => tier.amount === pricing.default?.amount && tier.unit === pricing.default.unit))) parts.push(`${yuan(pricing.default.amount)}${per(pricing.default.unit)}${pricing.tiers.length === 0 ? "" : " otherwise"}`);
4348
- return parts.length === 0 ? void 0 : parts.join(", ");
4874
+ if (pricing.default !== void 0 && (pricing.tiers.length === 0 || !pricing.tiers.some((tier) => tier.amount === pricing.default?.amount && tier.unit === pricing.default.unit))) parts.push(`${yuan(pricing.default.amount)}${per(pricing.default.unit)}${pricing.tiers.length === 0 ? "" : lang === "zh" ? "(其他)" : " otherwise"}`);
4875
+ return parts.length === 0 ? UNPRICED[lang] : parts.join(", ");
4349
4876
  }
4350
4877
  /**
4351
4878
  * Estimate an image request.
@@ -4355,7 +4882,7 @@ function describePrice(model) {
4355
4882
  */
4356
4883
  function estimateImages(model, count) {
4357
4884
  const price = model.pricing === void 0 ? void 0 : tierFor(model.pricing, void 0);
4358
- if (price === void 0) return void 0;
4885
+ if (price === void 0 || price.unit !== "generation") return void 0;
4359
4886
  return {
4360
4887
  amountCny: price.amount * count,
4361
4888
  basis: count === 1 ? `${yuan(price.amount)} per image` : `${yuan(price.amount)} × ${count} images`
@@ -4378,6 +4905,7 @@ function estimateVideo(model, duration, resolution, referenceMs = 0) {
4378
4905
  amountCny: price.amount,
4379
4906
  basis: `${yuan(price.amount)} per video`
4380
4907
  };
4908
+ if (price.unit !== "second") return void 0;
4381
4909
  const output = duration ?? DEFAULT_BILLED_SECONDS;
4382
4910
  const reference = Math.ceil(referenceMs / 1e3);
4383
4911
  const seconds = output + reference;
@@ -4387,16 +4915,429 @@ function estimateVideo(model, duration, resolution, referenceMs = 0) {
4387
4915
  };
4388
4916
  }
4389
4917
  /**
4390
- * Estimate a music or podcast request.
4391
- * @param model - the audio model.
4392
- * @returns the estimate, or undefined without a price.
4918
+ * Quote an audio request: music, podcast and speech generation, or a transcription.
4919
+ * @param model - the audio or transcription model.
4920
+ * @param inputSeconds - for a transcription, the measured length of the input, when known.
4921
+ * @returns the quote; `priced: false` (未定价) when the catalog gives no usable price.
4393
4922
  */
4394
- function estimateAudio(model) {
4923
+ function quoteAudio(model, inputSeconds) {
4395
4924
  const price = model.pricing === void 0 ? void 0 : tierFor(model.pricing, void 0);
4396
- if (price === void 0 || price.unit !== "generation") return void 0;
4397
- return {
4925
+ const unit = price?.unit ?? model.audio?.billingUnit;
4926
+ if (price === void 0 || unit === void 0 || unit === "second") return {
4927
+ priced: false,
4928
+ ...unit === void 0 ? {} : { unit },
4929
+ label: UNPRICED,
4930
+ basis: {
4931
+ en: "The catalog states no price for this model; the gateway receipt shows the charge.",
4932
+ zh: "目录未给出价格,以网关结算为准。"
4933
+ }
4934
+ };
4935
+ const suffix = unitSuffix(unit, model.kind);
4936
+ const label = {
4937
+ en: `${yuan(price.amount)}${suffix.en}`,
4938
+ zh: `${yuan(price.amount)}${suffix.zh}`
4939
+ };
4940
+ const base = {
4941
+ priced: true,
4942
+ unit,
4943
+ unitPriceCny: price.amount,
4944
+ label
4945
+ };
4946
+ if (unit === "generation") return {
4947
+ ...base,
4398
4948
  amountCny: price.amount,
4399
- basis: `${yuan(price.amount)} per request`
4949
+ basis: {
4950
+ en: `${label.en}, charged only if it succeeds`,
4951
+ zh: `${label.zh},成功才扣费`
4952
+ }
4953
+ };
4954
+ if (unit === "original_second") return {
4955
+ ...base,
4956
+ basis: {
4957
+ en: `${label.en}: billed on the original length the provider reports, which can differ from the playback length`,
4958
+ zh: `${label.zh}:按服务商返回的原始时长计费,可能与播放时长不同`
4959
+ }
4960
+ };
4961
+ if (inputSeconds !== void 0 && inputSeconds > 0) {
4962
+ const minutes = inputSeconds / 60;
4963
+ return {
4964
+ ...base,
4965
+ amountCny: price.amount * minutes,
4966
+ basis: {
4967
+ en: `${label.en} × ${minutes.toFixed(2)} min`,
4968
+ zh: `${label.zh} × ${minutes.toFixed(2)} 分钟`
4969
+ }
4970
+ };
4971
+ }
4972
+ return {
4973
+ ...base,
4974
+ basis: {
4975
+ en: `${label.en} of input audio`,
4976
+ zh: `${label.zh},按音频时长计费`
4977
+ }
4978
+ };
4979
+ }
4980
+ //#endregion
4981
+ //#region src/audio/request.ts
4982
+ /**
4983
+ * Audio generation requests — music, podcasts and speech — checked against what
4984
+ * the chosen model declares in the catalog's `audio` block, and turned into the
4985
+ * body of `POST /v1/audio/generations`.
4986
+ *
4987
+ * Each kind sends only its own fields:
4988
+ *
4989
+ * | kind | wire fields |
4990
+ * | ------- | --------------------------------------------------------------- |
4991
+ * | music | model, prompt, lyrics, genre, mood, gender |
4992
+ * | podcast | model, prompt, source_url |
4993
+ * | speech | as declared; Seed: model, text_prompt, references, audio_config, watermark |
4994
+ *
4995
+ * The main text goes in the model's `prompt_field`. A field the model does not
4996
+ * declare in `request_fields` is refused before anything is uploaded or charged;
4997
+ * music and podcasts never take speech references, output formats, sample rates
4998
+ * or a duration. Speech references, formats and sample rates come from the
4999
+ * catalog only (count, size, length, mutual exclusions, the sample rates of
5000
+ * each format), never from a fixed list.
5001
+ * @module dsh-vibedev/audio/request
5002
+ */
5003
+ const AUDIO_MAKE_KINDS = [
5004
+ "music",
5005
+ "podcast",
5006
+ "speech"
5007
+ ];
5008
+ /** The standard wire fields of a kind, for a model that declares none (speech declares its own). */
5009
+ const STANDARD_FIELDS = {
5010
+ music: [
5011
+ "model",
5012
+ "prompt",
5013
+ "lyrics",
5014
+ "genre",
5015
+ "mood",
5016
+ "gender"
5017
+ ],
5018
+ podcast: [
5019
+ "model",
5020
+ "prompt",
5021
+ "source_url"
5022
+ ],
5023
+ speech: []
5024
+ };
5025
+ /** Fields that belong to speech only: references, the output format and sample rate, a duration. */
5026
+ const SPEECH_ONLY = [
5027
+ "references",
5028
+ "format",
5029
+ "sampleRate",
5030
+ "duration"
5031
+ ];
5032
+ /** Default limit on one reference file when the catalog states none. */
5033
+ const DEFAULT_REFERENCE_BYTES = 20971520;
5034
+ const OPERATION_ID = /^[\w-]{1,80}$/;
5035
+ const has = (value) => value !== void 0 && value.trim() !== "";
5036
+ /**
5037
+ * What an audio model makes, from its declaration only.
5038
+ * @param model - the model.
5039
+ * @returns the kind, or undefined when the catalog does not declare it.
5040
+ */
5041
+ function audioKindOf(model) {
5042
+ return model.kind === "audio" ? model.audioKind : void 0;
5043
+ }
5044
+ /** The wire fields a model takes. */
5045
+ function requestFieldsOf(model) {
5046
+ const kind = audioKindOf(model);
5047
+ const declared = model.audio?.requestFields ?? [];
5048
+ if (declared.length > 0) return declared;
5049
+ return kind === void 0 ? [] : STANDARD_FIELDS[kind];
5050
+ }
5051
+ /** The field the main text goes in. */
5052
+ function promptFieldOf(model) {
5053
+ const declared = model.audio?.promptField;
5054
+ if (declared !== void 0) return declared;
5055
+ const fields = requestFieldsOf(model);
5056
+ return [
5057
+ "prompt",
5058
+ "text_prompt",
5059
+ "text",
5060
+ "input"
5061
+ ].find((name) => fields.includes(name));
5062
+ }
5063
+ function refuse(message, field) {
5064
+ throw new MediaError(message, "AUDIO_OPTION_INVALID", field === void 0 ? {} : { field });
5065
+ }
5066
+ /** Check one value against the model's declared choices for a field. */
5067
+ function checkChoice(audio, wire, value) {
5068
+ const choices = audio?.choices?.[wire];
5069
+ if (choices !== void 0 && !choices.some((choice) => choice.toLowerCase() === value.toLowerCase())) refuse(`${wire} "${value}" is not offered by this model; it takes ${choices.join(", ")}.`, wire);
5070
+ }
5071
+ /** The output format and sample rate a speech request uses, from the declared formats only. */
5072
+ function audioConfigOf(model, format, sampleRate) {
5073
+ const formats = model.audio?.outputFormats ?? [];
5074
+ if (formats.length === 0) {
5075
+ if (format !== void 0 || sampleRate !== void 0) refuse(`${model.id} declares no output formats, so format and sample_rate cannot be chosen.`, "format");
5076
+ return;
5077
+ }
5078
+ const wanted = format?.trim().toLowerCase();
5079
+ const chosen = wanted === void 0 || wanted === "" ? formats.find((item) => item.format === model.audio?.defaultFormat) ?? formats[0] : formats.find((item) => item.format === wanted);
5080
+ if (chosen === void 0) refuse(`${model.id} writes ${formats.map((item) => item.format).join(", ")}, not "${format}".`, "format");
5081
+ if (sampleRate !== void 0) {
5082
+ if (chosen.sampleRates.length > 0 && !chosen.sampleRates.includes(sampleRate)) refuse(`${chosen.format.toUpperCase()} from ${model.id} takes a sample rate of ${chosen.sampleRates.join(", ")} Hz, not ${sampleRate}.`, "sample_rate");
5083
+ return {
5084
+ format: chosen.format,
5085
+ sample_rate: sampleRate
5086
+ };
5087
+ }
5088
+ const fallback = chosen.defaultSampleRate;
5089
+ return {
5090
+ format: chosen.format,
5091
+ ...fallback === void 0 ? {} : { sample_rate: fallback }
5092
+ };
5093
+ }
5094
+ /**
5095
+ * Check a request against the model and build the body, without references
5096
+ * (they are uploaded first; their URLs are added by {@link prepareAudioTask}).
5097
+ * @param model - the chosen model.
5098
+ * @param request - what to generate.
5099
+ * @returns the body without references, and the wire fields the request uses.
5100
+ * @throws {@link MediaError} for a field the model does not take, a missing text, or a value outside the declared choices.
5101
+ */
5102
+ function audioRequestBody(model, request) {
5103
+ const kind = audioKindOf(model);
5104
+ if (kind === void 0) throw new MediaError(`${model.id} does not declare what kind of audio it makes, so it cannot be used for generation.`, "AUDIO_KIND_UNDECLARED", { field: "model" });
5105
+ const audio = model.audio;
5106
+ const fields = requestFieldsOf(model);
5107
+ if (fields.length === 0) throw new MediaError(`${model.id} declares no request fields, so no request can be built for it.`, "AUDIO_FIELDS_UNDECLARED", { field: "model" });
5108
+ if (kind !== "speech") for (const name of SPEECH_ONLY) {
5109
+ const value = request[name];
5110
+ if (value !== void 0 && !(Array.isArray(value) && value.length === 0)) refuse(`${name === "sampleRate" ? "sample_rate" : name} is for speech; ${kind === "music" ? "music" : "a podcast"} does not take it.`, name);
5111
+ }
5112
+ const takes = (wire) => fields.includes(wire);
5113
+ const body = { model: model.id };
5114
+ const promptField = promptFieldOf(model);
5115
+ const prompt = request.prompt?.trim();
5116
+ if (has(prompt)) {
5117
+ if (promptField === void 0) refuse(`${model.id} takes no text prompt.`, "prompt");
5118
+ if (audio?.maxPromptChars !== void 0 && [...prompt].length > audio.maxPromptChars) refuse(`The text is ${[...prompt].length} characters; ${model.id} takes at most ${audio.maxPromptChars}.`, promptField);
5119
+ body[promptField] = prompt;
5120
+ }
5121
+ for (const [key, wire] of [
5122
+ ["lyrics", "lyrics"],
5123
+ ["genre", "genre"],
5124
+ ["mood", "mood"],
5125
+ ["gender", "gender"],
5126
+ ["sourceUrl", "source_url"]
5127
+ ]) {
5128
+ const value = request[key]?.trim();
5129
+ if (!has(value)) continue;
5130
+ if (!takes(wire)) refuse(`${model.id} does not take ${wire}; it takes ${fields.filter((name) => name !== "model").join(", ")}.`, wire);
5131
+ checkChoice(audio, wire, value);
5132
+ if (wire === "source_url" && !/^https?:\/\//i.test(value)) refuse("source_url must be an http(s) link.", wire);
5133
+ body[wire] = value;
5134
+ }
5135
+ if (request.duration !== void 0) {
5136
+ if (!takes("duration")) refuse(`${model.id} does not take a duration.`, "duration");
5137
+ body.duration = request.duration;
5138
+ }
5139
+ if (request.watermark !== void 0) {
5140
+ if (!takes("watermark")) refuse(`${model.id} does not take watermark.`, "watermark");
5141
+ body.watermark = request.watermark;
5142
+ }
5143
+ if (request.format !== void 0 || request.sampleRate !== void 0 || takes("audio_config")) {
5144
+ if (!takes("audio_config")) refuse(`${model.id} does not take an output format or sample rate.`, "format");
5145
+ const config = audioConfigOf(model, request.format, request.sampleRate);
5146
+ if (config !== void 0) body.audio_config = config;
5147
+ }
5148
+ if ((request.references?.length ?? 0) > 0 && !takes("references")) refuse(`${model.id} does not take reference audio.`, "references");
5149
+ if (kind === "music" && !has(prompt)) throw new MediaError("Music needs a prompt describing the song.", "AUDIO_PROMPT_REQUIRED", { field: "prompt" });
5150
+ if (kind === "podcast" && !has(prompt) && body.source_url === void 0) throw new MediaError("A podcast needs a prompt (the topic), a source_url, or both.", "AUDIO_PROMPT_REQUIRED", { field: "prompt" });
5151
+ if (kind === "speech" && !has(prompt)) throw new MediaError("Speech needs the text to speak.", "AUDIO_PROMPT_REQUIRED", { field: promptField ?? "prompt" });
5152
+ return body;
5153
+ }
5154
+ /** Load, check and upload the reference audio of a speech request; their measured lengths are checked too. */
5155
+ async function uploadReferences(runtime, call, model, sources, body) {
5156
+ const limits = model.audio?.references;
5157
+ if (limits === void 0) refuse(`${model.id} does not take reference audio.`, "references");
5158
+ if (sources.length < limits.min || sources.length > limits.max) refuse(limits.min === limits.max ? `${model.id} takes exactly ${limits.max} reference audio file(s); ${sources.length} were given.` : `${model.id} takes ${limits.min}–${limits.max} reference audio file(s); ${sources.length} were given.`, "references");
5159
+ const clash = (limits.exclusiveWith ?? []).filter((name) => body[name] !== void 0);
5160
+ if (clash.length > 0) refuse(`Reference audio cannot be combined with ${clash.join(", ")} on ${model.id}.`, "references");
5161
+ const maxBytes = limits.maxBytes ?? DEFAULT_REFERENCE_BYTES;
5162
+ const loaded = [];
5163
+ for (const source of sources) {
5164
+ const media = await runtime.load(call, source, "audio", maxBytes);
5165
+ if (limits.mimes !== void 0 && !limits.mimes.includes(media.mime)) throw new MediaError(`"${media.source}" is ${media.mime}; ${model.id} takes reference audio as ${limits.mimes.join(", ")}.`, "REFERENCE_MEDIA_TYPE_UNSUPPORTED", { field: "references" });
5166
+ loaded.push(media);
5167
+ }
5168
+ const checked = [];
5169
+ let totalSeconds = 0;
5170
+ for (const media of loaded) {
5171
+ const asset = await runtime.library.upload(media, limits.purpose ?? "audio_reference", call.signal);
5172
+ const seconds = asset.durationMs === void 0 ? void 0 : asset.durationMs / 1e3;
5173
+ if (seconds === void 0 && (limits.minSeconds !== void 0 || limits.maxSeconds !== void 0 || limits.maxTotalSeconds !== void 0)) throw new MediaError(`The gateway could not measure the length of "${media.source}". Export it as a standard WAV or MP3 and try again.`, "REFERENCE_DURATION_UNKNOWN", { field: "references" });
5174
+ if (seconds !== void 0) {
5175
+ if (limits.minSeconds !== void 0 && seconds < limits.minSeconds) throw new MediaError(`"${media.source}" is ${seconds.toFixed(1)} s; ${model.id} needs reference audio of at least ${limits.minSeconds} s.`, "REFERENCE_AUDIO_TOO_SHORT", { field: "references" });
5176
+ if (limits.maxSeconds !== void 0 && seconds > limits.maxSeconds) throw new MediaError(`"${media.source}" is ${seconds.toFixed(1)} s; ${model.id} takes reference audio of at most ${limits.maxSeconds} s.`, "REFERENCE_AUDIO_TOO_LONG", { field: "references" });
5177
+ totalSeconds += seconds;
5178
+ }
5179
+ checked.push({
5180
+ source: media.source,
5181
+ asset
5182
+ });
5183
+ }
5184
+ if (limits.maxTotalSeconds !== void 0 && totalSeconds > limits.maxTotalSeconds) throw new MediaError(`The reference audio adds up to ${totalSeconds.toFixed(1)} s; ${model.id} takes at most ${limits.maxTotalSeconds} s in total.`, "REFERENCE_TOTAL_TOO_LONG", { field: "references" });
5185
+ return checked;
5186
+ }
5187
+ /**
5188
+ * Choose the model for a request: the one named (an id or an alias), else the
5189
+ * pinned or first model of the kind. A named model must make the kind asked for.
5190
+ */
5191
+ async function chooseAudioModel(runtime, request, signal) {
5192
+ if (request.kind === void 0 && !has(request.model)) throw new MediaError("Say what to make (music, podcast or speech), or name a model.", "AUDIO_KIND_REQUIRED", { field: "type" });
5193
+ if (request.kind !== void 0) return runtime.pickModel("audio", request.kind, request.model, void 0, signal);
5194
+ const model = await runtime.findModel(request.model, signal);
5195
+ if (model.kind !== "audio") throw new MediaError(`${model.id} is not an audio generation model.`, "MODEL_KIND_MISMATCH", { field: "model" });
5196
+ if (audioKindOf(model) === void 0) throw new MediaError(`${model.id} does not declare what kind of audio it makes, so it cannot be used for generation.`, "AUDIO_KIND_UNDECLARED", { field: "model" });
5197
+ return model;
5198
+ }
5199
+ /**
5200
+ * Everything before an audio submission: choose the model, check every field
5201
+ * against its declaration, upload and measure speech references, price it,
5202
+ * confirm the spending, and build the task under the operation's key.
5203
+ * @param runtime - the media runtime.
5204
+ * @param call - the tool call or host call.
5205
+ * @param request - what to generate.
5206
+ * @param save - where the result goes.
5207
+ * @returns the prepared task.
5208
+ */
5209
+ async function prepareAudioTask(runtime, call, request, save) {
5210
+ if (request.operationId !== void 0 && !OPERATION_ID.test(request.operationId)) throw new MediaError("operationId may hold only letters, digits, \"-\" and \"_\" (at most 80).", "AUDIO_OPTION_INVALID", { field: "operationId" });
5211
+ const model = await chooseAudioModel(runtime, request, call.signal);
5212
+ const kind = audioKindOf(model);
5213
+ const body = audioRequestBody(model, request);
5214
+ const references = request.references ?? [];
5215
+ if (references.length > 0) body.references = (await uploadReferences(runtime, call, model, references, body)).map((item) => item.asset.referenceUrl);
5216
+ else if ((model.audio?.references?.min ?? 0) > 0) refuse(`${model.id} needs at least ${model.audio?.references?.min} reference audio file(s).`, "references");
5217
+ const quote = quoteAudio(model);
5218
+ const what = {
5219
+ music: {
5220
+ en: "a song",
5221
+ zh: "一首音乐"
5222
+ },
5223
+ podcast: {
5224
+ en: "a podcast episode",
5225
+ zh: "一期播客"
5226
+ },
5227
+ speech: {
5228
+ en: "speech",
5229
+ zh: "一段语音"
5230
+ }
5231
+ }[kind];
5232
+ await runtime.confirmSpending(call, "audio_generate", {
5233
+ en: `Generate ${what.en} with ${model.id}: ${quote.basis.en}.`,
5234
+ zh: `用 ${model.id} 生成${what.zh}:${quote.basis.zh}。`
5235
+ });
5236
+ const now = runtime.now();
5237
+ const prompt = request.prompt?.trim();
5238
+ return {
5239
+ model,
5240
+ kind,
5241
+ quote,
5242
+ draft: {
5243
+ id: request.operationId ?? randomUUID(),
5244
+ kind: "audio",
5245
+ model: model.id,
5246
+ label: excerptOf(prompt ?? request.sourceUrl ?? kind),
5247
+ createdAt: now,
5248
+ updatedAt: now,
5249
+ ...call.agent === void 0 ? {} : { owner: call.agent.id },
5250
+ outputDir: save.folder,
5251
+ stem: save.stem,
5252
+ endpoint: "/v1/audio/generations",
5253
+ body,
5254
+ status: "submitting",
5255
+ audioKind: kind,
5256
+ ...quote.amountCny === void 0 ? {} : { estimatedCny: quote.amountCny.toFixed(2) },
5257
+ ...quote.unit === void 0 ? {} : { billing: {
5258
+ unit: quote.unit,
5259
+ ...quote.unitPriceCny === void 0 ? {} : { unitPriceCny: String(quote.unitPriceCny) }
5260
+ } }
5261
+ }
5262
+ };
5263
+ }
5264
+ function excerptOf(text, max = 60) {
5265
+ const flat = text.replace(/\s+/g, " ").trim();
5266
+ const chars = [...flat];
5267
+ return chars.length <= max ? flat : `${chars.slice(0, max - 1).join("")}…`;
5268
+ }
5269
+ /**
5270
+ * The form an interface shows for one model: only the fields the model
5271
+ * declares, with their declared choices and limits.
5272
+ * @param model - the model.
5273
+ * @returns the form, or undefined for a model that cannot generate audio.
5274
+ */
5275
+ function audioFormOf(model) {
5276
+ const kind = audioKindOf(model);
5277
+ if (kind === void 0) return void 0;
5278
+ const fields = requestFieldsOf(model);
5279
+ if (fields.length === 0) return void 0;
5280
+ const audio = model.audio;
5281
+ const out = [];
5282
+ const promptField = promptFieldOf(model);
5283
+ if (promptField !== void 0) out.push({
5284
+ key: "prompt",
5285
+ wire: promptField,
5286
+ required: kind !== "podcast",
5287
+ ...audio?.maxPromptChars === void 0 ? {} : { maxChars: audio.maxPromptChars }
5288
+ });
5289
+ for (const [key, wire] of [
5290
+ ["lyrics", "lyrics"],
5291
+ ["genre", "genre"],
5292
+ ["mood", "mood"],
5293
+ ["gender", "gender"],
5294
+ ["sourceUrl", "source_url"]
5295
+ ]) {
5296
+ if (!fields.includes(wire)) continue;
5297
+ const choices = audio?.choices?.[wire];
5298
+ out.push({
5299
+ key,
5300
+ wire,
5301
+ required: false,
5302
+ ...choices === void 0 ? {} : { choices }
5303
+ });
5304
+ }
5305
+ if (kind === "speech") {
5306
+ if (fields.includes("references") && audio?.references !== void 0) out.push({
5307
+ key: "references",
5308
+ wire: "references",
5309
+ required: audio.references.min > 0,
5310
+ limits: audio.references
5311
+ });
5312
+ const formats = audio?.outputFormats ?? [];
5313
+ if (fields.includes("audio_config") && formats.length > 0) {
5314
+ out.push({
5315
+ key: "format",
5316
+ wire: "audio_config.format",
5317
+ required: false,
5318
+ choices: formats.map((item) => item.format)
5319
+ });
5320
+ const rates = Object.fromEntries(formats.filter((item) => item.sampleRates.length > 0).map((item) => [item.format, item.sampleRates]));
5321
+ if (Object.keys(rates).length > 0) out.push({
5322
+ key: "sampleRate",
5323
+ wire: "audio_config.sample_rate",
5324
+ required: false,
5325
+ sampleRates: rates
5326
+ });
5327
+ }
5328
+ if (fields.includes("watermark")) out.push({
5329
+ key: "watermark",
5330
+ wire: "watermark",
5331
+ required: false
5332
+ });
5333
+ }
5334
+ return {
5335
+ model: model.id,
5336
+ name: model.name,
5337
+ kind,
5338
+ fields: out,
5339
+ price: quoteAudio(model),
5340
+ ...audio?.defaultFormat === void 0 ? {} : { defaultFormat: audio.defaultFormat }
4400
5341
  };
4401
5342
  }
4402
5343
  //#endregion
@@ -4420,10 +5361,12 @@ function excerpt(prompt, max = 60) {
4420
5361
  * @returns `Charged ¥4.95.`, an estimate, or the empty string.
4421
5362
  */
4422
5363
  function moneyLine(record) {
4423
- if (record.status !== "completed") return record.status === "failed" ? " Failed tasks are not charged." : "";
4424
- if (record.chargedCny !== void 0) return ` Charged ${yuan(Number(record.chargedCny))}.`;
4425
- if (record.estimatedCny !== void 0) return ` Estimated ${yuan(Number(record.estimatedCny))} (the gateway settles the charge shortly).`;
4426
- return "";
5364
+ if (record.status !== "completed") return record.status === "failed" ? " Failed and cancelled tasks are not charged." : "";
5365
+ const billed = record.billing?.originalSeconds === void 0 ? "" : ` Billed on ${record.billing.originalSeconds.toFixed(1)} s of original audio.`;
5366
+ if (record.settlement === "pending") return ` The result is saved; the charge is still being settled (it is never generated again — media_tasks shows the charge once settled).${billed}`;
5367
+ if (record.chargedCny !== void 0) return ` Charged ${yuan(Number(record.chargedCny))}.${billed}`;
5368
+ if (record.estimatedCny !== void 0) return ` Estimated ${yuan(Number(record.estimatedCny))} (the gateway settles the charge shortly).${billed}`;
5369
+ return billed;
4427
5370
  }
4428
5371
  /**
4429
5372
  * The model-facing account of a task's current state.
@@ -4591,7 +5534,7 @@ const text$2 = (value) => typeof value === "string" && value.trim() !== "" ? val
4591
5534
  const noPaths = { readPath: () => Promise.reject(new MediaError("The gateway returned an unexpected image reference.", "GATEWAY_BAD_RESPONSE")) };
4592
5535
  /**
4593
5536
  * Read the images of an `/v1/images/*` answer: `b64_json`, a `data:` URL, or a link.
4594
- * @param runtime - for authenticated downloads of gateway links.
5537
+ * @param runtime - for downloading the gateway's full signed result URLs.
4595
5538
  * @param body - the parsed answer.
4596
5539
  * @param signal - cancels downloads.
4597
5540
  * @returns the images and the revised prompt, when the model returned one.
@@ -4611,7 +5554,7 @@ async function decodeImages(runtime, body, signal) {
4611
5554
  else if (url?.startsWith("data:") === true) data = (await loadMedia(url, noPaths, MAX_OUTPUT_BYTES)).data;
4612
5555
  else if (url !== void 0 && /^https?:\/\//i.test(url)) {
4613
5556
  const response = await runtime.http.send(url, {
4614
- anonymous: !runtime.http.isGatewayUrl(url),
5557
+ anonymous: true,
4615
5558
  headers: { accept: "*/*" },
4616
5559
  timeoutMs: 12e4,
4617
5560
  signal
@@ -5495,19 +6438,44 @@ function videoGenerateTool(runtime) {
5495
6438
  }
5496
6439
  //#endregion
5497
6440
  //#region src/service.ts
6441
+ /**
6442
+ * The media service other plugins call, registered as `vibedevMedia`: the
6443
+ * film workbench's storyboard generates through it when someone presses a
6444
+ * generate button. It takes the same paths as the agent tools — model
6445
+ * choice, reference checks and uploads, idempotent submission, a background
6446
+ * follower that saves the result — with the caller naming the workspace and
6447
+ * where results go. The caller has already shown the price and the user chose
6448
+ * to go ahead, so the spending confirmation setting does not ask again.
6449
+ *
6450
+ * Every task, video or audio, is read the same way: {@link MediaHostService.task}
6451
+ * (what is recorded), {@link MediaHostService.refreshTask} (ask the gateway again:
6452
+ * fresh links, settlement — a query only, it never submits),
6453
+ * {@link MediaHostService.onTask} (each change) and
6454
+ * {@link MediaHostService.result} (the files to play or download).
6455
+ * @module dsh-vibedev/service
6456
+ */
5498
6457
  const view = (record) => ({
5499
6458
  id: record.id,
5500
6459
  kind: record.kind,
6460
+ ...record.audioKind === void 0 ? {} : { audioKind: record.audioKind },
5501
6461
  model: record.model,
5502
6462
  status: record.status,
5503
6463
  ...record.progress === void 0 ? {} : { progress: record.progress },
5504
- ...record.outputs === void 0 ? {} : { outputs: record.outputs.map((output) => ({ ...output })) },
6464
+ ...record.outputs === void 0 ? {} : { outputs: record.outputs.map(({ index: _index, bytes: _bytes, ...output }) => ({ ...output })) },
5505
6465
  ...record.error === void 0 ? {} : { error: { ...record.error } },
5506
6466
  ...record.estimatedCny === void 0 ? {} : { estimatedCny: record.estimatedCny },
5507
6467
  ...record.chargedCny === void 0 ? {} : { chargedCny: record.chargedCny },
6468
+ ...record.settlement === void 0 ? {} : { settlement: record.settlement },
6469
+ ...record.billing === void 0 ? {} : { billing: { ...record.billing } },
5508
6470
  createdAt: record.createdAt,
5509
6471
  updatedAt: record.updatedAt
5510
6472
  });
6473
+ /** The parts of a request that decide what is generated, to tell a repeated operation from a different one. */
6474
+ function sameRequest(record, draft) {
6475
+ const { references: _a, ...left } = record.body;
6476
+ const { references: _b, ...right } = draft.body;
6477
+ return record.kind === draft.kind && record.model === draft.model && JSON.stringify(left) === JSON.stringify(right);
6478
+ }
5511
6479
  var MediaHostService = class {
5512
6480
  runtime;
5513
6481
  version;
@@ -5523,7 +6491,7 @@ var MediaHostService = class {
5523
6491
  };
5524
6492
  }
5525
6493
  /**
5526
- * The media models this account can use (cached briefly).
6494
+ * The media models this account can use (cached briefly; renewed after a sign-in, sign-out or account switch).
5527
6495
  * @param signal - cancels the request.
5528
6496
  * @returns the models in catalog order.
5529
6497
  */
@@ -5531,6 +6499,28 @@ var MediaHostService = class {
5531
6499
  return this.runtime.models(signal);
5532
6500
  }
5533
6501
  /**
6502
+ * The forms to show for the audio models this account can use: one per
6503
+ * model that declares what it makes, with only the fields it takes, their
6504
+ * declared choices and limits, and its price.
6505
+ * @param signal - cancels the catalog request.
6506
+ * @returns the forms, in catalog order.
6507
+ */
6508
+ async audioForms(signal) {
6509
+ return (await this.runtime.models(signal)).flatMap((model) => {
6510
+ const form = audioFormOf(model);
6511
+ return form === void 0 ? [] : [form];
6512
+ });
6513
+ }
6514
+ /**
6515
+ * The price of one audio or transcription model: `¥0.50/次`, `¥0.02/原始秒`, `¥0.30/分钟`, or 未定价.
6516
+ * @param model - the model id or alias.
6517
+ * @param signal - cancels the catalog request.
6518
+ * @returns the quote.
6519
+ */
6520
+ async audioPrice(model, signal) {
6521
+ return quoteAudio(await this.runtime.findModel(model, signal));
6522
+ }
6523
+ /**
5534
6524
  * Generate images now and save them.
5535
6525
  * @param request - what to generate.
5536
6526
  * @param target - the workspace and where the files go.
@@ -5563,18 +6553,53 @@ var MediaHostService = class {
5563
6553
  folder: target.folder,
5564
6554
  stem: target.stem
5565
6555
  });
6556
+ return this.submit(prepared.draft, signal);
6557
+ }
6558
+ /**
6559
+ * Submit music, a podcast episode or speech. Fields are checked against
6560
+ * what the model declares before anything is uploaded or charged; speech
6561
+ * references are uploaded to the gateway media library first.
6562
+ *
6563
+ * One user operation, one task: pass the same `operationId` when the same
6564
+ * click is retried and the task already recorded under it is returned —
6565
+ * nothing is submitted twice. Without one, every call is a new operation.
6566
+ * @param request - what to generate.
6567
+ * @param target - the workspace and where the audio goes.
6568
+ * @param signal - cancels the preparation and the submission.
6569
+ * @returns the task.
6570
+ */
6571
+ async startAudio(request, target, signal) {
6572
+ if (request.operationId !== void 0) {
6573
+ const earlier = await this.runtime.store.get(request.operationId).catch(() => void 0);
6574
+ if (earlier !== void 0) {
6575
+ if (earlier.kind !== "audio") throw new MediaError(`Operation ${request.operationId} already belongs to a ${earlier.kind} task.`, "OPERATION_CONFLICT", { field: "operationId" });
6576
+ return view(earlier);
6577
+ }
6578
+ }
6579
+ const prepared = await prepareAudioTask(this.runtime, this.call(target, signal), request, {
6580
+ folder: target.folder,
6581
+ stem: target.stem
6582
+ });
6583
+ const raced = await this.runtime.store.get(prepared.draft.id).catch(() => void 0);
6584
+ if (raced !== void 0) {
6585
+ if (!sameRequest(raced, prepared.draft)) throw new MediaError(`Operation ${prepared.draft.id} already belongs to a different request.`, "OPERATION_CONFLICT", { field: "operationId" });
6586
+ return view(raced);
6587
+ }
6588
+ return this.submit(prepared.draft, signal);
6589
+ }
6590
+ async submit(draft, signal) {
5566
6591
  try {
5567
- return view(await this.runtime.tracker.submit(prepared.draft, signal));
6592
+ return view(await this.runtime.tracker.submit(draft, signal));
5568
6593
  } catch (error) {
5569
6594
  if (error instanceof MediaError && error.code === "SUBMISSION_UNCONFIRMED") {
5570
- const recorded = await this.runtime.store.get(prepared.draft.id);
6595
+ const recorded = await this.runtime.store.get(draft.id);
5571
6596
  if (recorded !== void 0) return view(recorded);
5572
6597
  }
5573
6598
  throw error;
5574
6599
  }
5575
6600
  }
5576
6601
  /**
5577
- * A task's current state.
6602
+ * A task's current state, as recorded (no request to the gateway).
5578
6603
  * @param id - the task id.
5579
6604
  * @returns the task, or `undefined` when there is none.
5580
6605
  */
@@ -5583,6 +6608,31 @@ var MediaHostService = class {
5583
6608
  return record === void 0 ? void 0 : view(record);
5584
6609
  }
5585
6610
  /**
6611
+ * Ask the gateway about a task again — a query, never a submission: a
6612
+ * finished task gets fresh links and its missing files are downloaded
6613
+ * again, and a pending settlement is read again. A running task is followed.
6614
+ * @param id - the task id.
6615
+ * @param signal - cancels the query.
6616
+ * @returns the task after the query, or `undefined` when there is none.
6617
+ */
6618
+ async refreshTask(id, signal) {
6619
+ if (await this.runtime.store.get(id) === void 0) return void 0;
6620
+ return view(await this.runtime.tracker.refresh(id, signal));
6621
+ }
6622
+ /**
6623
+ * The files of a finished task, ready to play or download: saved files by
6624
+ * path, and links renewed by a query when one is needed (a missing file, an
6625
+ * expired link). Never generates again.
6626
+ * @param id - the task id.
6627
+ * @param signal - cancels the query.
6628
+ * @returns the outputs, or `undefined` while the task has none.
6629
+ */
6630
+ async result(id, signal) {
6631
+ const record = await this.runtime.store.get(id);
6632
+ if (record === void 0 || record.status !== "completed") return void 0;
6633
+ return (((record.outputs ?? []).length > 0 && (record.outputs ?? []).every((output) => output.path !== void 0) ? record : await this.runtime.tracker.refresh(id, signal).catch(() => record)).outputs ?? []).map(({ index: _index, bytes: _bytes, ...output }) => ({ ...output }));
6634
+ }
6635
+ /**
5586
6636
  * Hear about every change to one task.
5587
6637
  * @param id - the task id.
5588
6638
  * @param listener - called with the task after each change.
@@ -5794,7 +6844,8 @@ const COMPLETED = /* @__PURE__ */ new Set([
5794
6844
  "succeeded",
5795
6845
  "success",
5796
6846
  "done",
5797
- "finished"
6847
+ "finished",
6848
+ "settlement_pending"
5798
6849
  ]);
5799
6850
  const FAILED = /* @__PURE__ */ new Set([
5800
6851
  "failed",
@@ -5805,12 +6856,40 @@ const FAILED = /* @__PURE__ */ new Set([
5805
6856
  "expired",
5806
6857
  "rejected"
5807
6858
  ]);
6859
+ const CANCELLED = /* @__PURE__ */ new Set(["cancelled", "canceled"]);
6860
+ /** The result exists; only the charge is still being settled. */
6861
+ const SETTLEMENT_PENDING = "settlement_pending";
5808
6862
  function stateOf(status) {
5809
6863
  const word = status.toLowerCase();
5810
6864
  if (COMPLETED.has(word)) return "completed";
5811
6865
  if (FAILED.has(word)) return "failed";
5812
6866
  return "pending";
5813
6867
  }
6868
+ const seconds = (value) => {
6869
+ const number = typeof value === "string" ? Number(value) : value;
6870
+ return typeof number === "number" && Number.isFinite(number) && number > 0 ? number : void 0;
6871
+ };
6872
+ /** The billing facts of a task: whether the charge settled, and the billed length. */
6873
+ function billing(raw, status) {
6874
+ const blocks = [
6875
+ record$1(raw.billing),
6876
+ record$1(raw.usage),
6877
+ record$1(raw.effective),
6878
+ raw
6879
+ ].filter((block) => block !== void 0);
6880
+ const pick = (...names) => {
6881
+ for (const block of blocks) for (const name of names) if (block[name] !== void 0) return block[name];
6882
+ };
6883
+ const originalSeconds = seconds(pick("original_seconds", "billed_original_seconds", "original_duration_seconds"));
6884
+ const minutes = seconds(pick("billed_minutes", "minutes"));
6885
+ const stateRaw = text$1(pick("settlement_state", "settlement_status"))?.toLowerCase();
6886
+ const settlement = status.toLowerCase() === SETTLEMENT_PENDING || stateRaw === "pending" ? "pending" : stateRaw === "settled" || stateRaw === "completed" ? "settled" : void 0;
6887
+ return {
6888
+ ...settlement === void 0 ? {} : { settlement },
6889
+ ...originalSeconds === void 0 ? {} : { billedOriginalSeconds: originalSeconds },
6890
+ ...minutes === void 0 ? {} : { billedMinutes: minutes }
6891
+ };
6892
+ }
5814
6893
  function errorOf(raw) {
5815
6894
  const nested = record$1(raw.error);
5816
6895
  const message = text$1(nested?.message) ?? text$1(raw.error) ?? text$1(raw.failure_reason) ?? text$1(raw.error_message) ?? text$1(raw.fail_reason);
@@ -5890,19 +6969,25 @@ function parseAudioTask(body) {
5890
6969
  const url = httpUrl(output.content_url) ?? httpUrl(output.url);
5891
6970
  const contentType = text$1(output.content_type);
5892
6971
  const title = text$1(output.title);
5893
- const duration = typeof output.duration_seconds === "number" && output.duration_seconds > 0 ? output.duration_seconds : void 0;
6972
+ const duration = seconds(output.duration_seconds);
6973
+ const original = seconds(output.original_duration_seconds) ?? seconds(output.original_seconds);
5894
6974
  outputs.push({
5895
6975
  index,
5896
6976
  ...url === void 0 ? {} : { url },
5897
6977
  ...contentType === void 0 ? {} : { contentType },
5898
6978
  ...title === void 0 ? {} : { title },
5899
- ...duration === void 0 ? {} : { durationSeconds: duration }
6979
+ ...duration === void 0 ? {} : { durationSeconds: duration },
6980
+ ...original === void 0 ? {} : { originalSeconds: original }
5900
6981
  });
5901
6982
  }
5902
- const error = state === "failed" ? errorOf(raw) ?? {
6983
+ const cancelled = CANCELLED.has(status.toLowerCase());
6984
+ const error = state === "failed" ? errorOf(raw) ?? (cancelled ? {
6985
+ code: "CANCELLED",
6986
+ message: "The task was cancelled; cancelled tasks are not charged."
6987
+ } : {
5903
6988
  code: "GENERATION_FAILED",
5904
6989
  message: `the task ended with status "${status}"`
5905
- } : void 0;
6990
+ }) : void 0;
5906
6991
  const progress = progressOf(raw.progress);
5907
6992
  return {
5908
6993
  id,
@@ -5911,6 +6996,7 @@ function parseAudioTask(body) {
5911
6996
  ...progress === void 0 ? {} : { progress },
5912
6997
  ...error === void 0 ? {} : { error },
5913
6998
  ...receipt(raw),
6999
+ ...billing(raw, status),
5914
7000
  outputs: state === "completed" ? outputs : []
5915
7001
  };
5916
7002
  }
@@ -6416,12 +7502,25 @@ var TaskTracker = class {
6416
7502
  }
6417
7503
  return Math.min(6e4, 5e3 * 2 ** Math.min(failures - 1, 4));
6418
7504
  }
6419
- /** Record what the gateway says about a task; save the outputs of a finished one. */
6420
- async apply(record, task) {
6421
- const money = {
7505
+ /** The money and billing facts of a gateway answer, merged into what the record already knows. */
7506
+ moneyOf(record, task) {
7507
+ const charged = task.settlement === "pending" ? void 0 : task.chargedCny;
7508
+ const settlement = task.settlement ?? (charged !== void 0 ? "settled" : void 0);
7509
+ const billing = task.billedOriginalSeconds !== void 0 || task.billedMinutes !== void 0 ? { billing: {
7510
+ ...record.billing,
7511
+ ...task.billedOriginalSeconds === void 0 ? {} : { originalSeconds: task.billedOriginalSeconds },
7512
+ ...task.billedMinutes === void 0 ? {} : { minutes: task.billedMinutes }
7513
+ } } : {};
7514
+ return {
6422
7515
  ...task.estimatedCny === void 0 ? {} : { estimatedCny: task.estimatedCny },
6423
- ...task.chargedCny === void 0 ? {} : { chargedCny: task.chargedCny }
7516
+ ...charged === void 0 ? {} : { chargedCny: charged },
7517
+ ...settlement === void 0 ? {} : { settlement },
7518
+ ...billing
6424
7519
  };
7520
+ }
7521
+ /** Record what the gateway says about a task; save the outputs of a finished one. */
7522
+ async apply(record, task) {
7523
+ const money = this.moneyOf(record, task);
6425
7524
  if (task.state === "pending") return this.update(record.id, {
6426
7525
  gatewayId: task.id,
6427
7526
  status: "pending",
@@ -6447,14 +7546,14 @@ var TaskTracker = class {
6447
7546
  if (attempts < (this.options.downloadAttempts ?? 5)) throw new MediaError(`${missing} result file(s) could not be downloaded yet.`, "DOWNLOAD_RETRY", { retryable: true });
6448
7547
  }
6449
7548
  let charged = money;
6450
- if (task.chargedCny === void 0) {
7549
+ if (money.chargedCny === void 0 && task.settlement !== "pending") {
6451
7550
  const settled = await this.options.http.json(SPECS[record.kind].poll(task.id), {
6452
7551
  timeoutMs: 15e3,
6453
7552
  signal: this.signal
6454
7553
  }).then((body) => SPECS[record.kind].parse(body), () => void 0);
6455
- if (settled?.chargedCny !== void 0) charged = {
7554
+ if (settled !== void 0) charged = {
6456
7555
  ...money,
6457
- chargedCny: settled.chargedCny
7556
+ ...this.moneyOf(current, settled)
6458
7557
  };
6459
7558
  }
6460
7559
  return this.finish(record.id, {
@@ -6469,6 +7568,50 @@ var TaskTracker = class {
6469
7568
  });
6470
7569
  }
6471
7570
  /**
7571
+ * Ask the gateway about a task again, by query only: a finished task gets
7572
+ * fresh links (signed links expire), files that are missing locally are
7573
+ * downloaded again, and a pending settlement is read again. Nothing is ever
7574
+ * submitted from here, so a failed query or download cannot create a task.
7575
+ * A task still running is followed as usual.
7576
+ * @param id - the local task id.
7577
+ * @param signal - cancels the query.
7578
+ * @returns the task after the query.
7579
+ */
7580
+ async refresh(id, signal) {
7581
+ const record = await this.options.store.get(id);
7582
+ if (record === void 0) throw new MediaError(`dsh-vibedev has no task ${id}.`, "UNKNOWN_TASK");
7583
+ if (!isFinished(record)) {
7584
+ this.followQuietly(id);
7585
+ return record;
7586
+ }
7587
+ if (record.gatewayId === void 0) return record;
7588
+ const spec = SPECS[record.kind];
7589
+ const task = spec.parse(await this.options.http.json(spec.poll(record.gatewayId), {
7590
+ timeoutMs: 3e4,
7591
+ ...signal === void 0 ? {} : { signal }
7592
+ }));
7593
+ if (task === void 0) throw new MediaError("The VibeDev gateway returned an unreadable task.", "GATEWAY_BAD_RESPONSE");
7594
+ const money = this.moneyOf(record, task);
7595
+ if (task.state !== "completed") return this.update(id, money);
7596
+ const fresh = new Map(task.outputs.map((output) => [output.index, output]));
7597
+ const known = (record.outputs ?? []).filter((output) => output.path !== void 0 && output.index !== void 0).map((output) => ({
7598
+ ...output,
7599
+ ...fresh.get(output.index)?.url === void 0 ? {} : { url: fresh.get(output.index)?.url }
7600
+ }));
7601
+ const { outputs, missing } = await this.saveOutputs({
7602
+ ...record,
7603
+ outputs: known
7604
+ }, task);
7605
+ return this.update(id, {
7606
+ ...money,
7607
+ outputs,
7608
+ error: missing === 0 ? void 0 : {
7609
+ code: "DOWNLOAD_FAILED",
7610
+ message: `${missing} of ${outputs.length} result file(s) could not be downloaded; the gateway links are listed instead.`
7611
+ }
7612
+ });
7613
+ }
7614
+ /**
6472
7615
  * Save every output not saved yet, recording each one as it lands. A
6473
7616
  * shutdown mid-download throws, so the task stays open and is resumed.
6474
7617
  */
@@ -6488,7 +7631,8 @@ var TaskTracker = class {
6488
7631
  ...output.url === void 0 ? {} : { url: output.url },
6489
7632
  ...output.contentType === void 0 ? {} : { contentType: output.contentType },
6490
7633
  ...output.title === void 0 ? {} : { title: output.title },
6491
- ...output.durationSeconds === void 0 ? {} : { durationSeconds: output.durationSeconds }
7634
+ ...output.durationSeconds === void 0 ? {} : { durationSeconds: output.durationSeconds },
7635
+ ...output.originalSeconds === void 0 ? {} : { originalSeconds: output.originalSeconds }
6492
7636
  };
6493
7637
  const fromUrl = urlExtension(output.url);
6494
7638
  const extension = output.contentType === void 0 ? fromUrl ?? extensionFor(spec.fallbackType) : extensionFor(output.contentType, fromUrl ?? extensionFor(spec.fallbackType));
@@ -6516,19 +7660,15 @@ var TaskTracker = class {
6516
7660
  missing
6517
7661
  };
6518
7662
  }
6519
- /** Download one output: the authenticated `/content` route, then the gateway's no-login link. */
7663
+ /** Use a returned signed URL verbatim and without the app token; old gateways without a URL use `/content`. */
6520
7664
  async fetchOutput(record, task, output, temporary) {
6521
7665
  const spec = SPECS[record.kind];
6522
7666
  const sources = [];
6523
- if (record.kind === "video") sources.push({
6524
- url: spec.content(task.id, output.index),
6525
- anonymous: false
6526
- });
6527
7667
  if (output.url !== void 0) sources.push({
6528
7668
  url: output.url,
6529
7669
  anonymous: true
6530
7670
  });
6531
- if (record.kind === "audio") sources.push({
7671
+ else sources.push({
6532
7672
  url: spec.content(task.id, output.index),
6533
7673
  anonymous: false
6534
7674
  });
@@ -6561,8 +7701,6 @@ var TaskTracker = class {
6561
7701
  * Settings → VibeDev 账号; this is the agent's way to the same sign-in.
6562
7702
  * @module dsh-vibedev/tools/account
6563
7703
  */
6564
- /** Where new users register. */
6565
- const REGISTER_URL = "https://vibedev.jzsaas.com";
6566
7704
  /**
6567
7705
  * Build the tool.
6568
7706
  * @param runtime - the media runtime.
@@ -6643,7 +7781,7 @@ function mediaAccountTool(runtime, chain, login) {
6643
7781
  return {
6644
7782
  source,
6645
7783
  signInUrl: started.url,
6646
- message: `${started.opened ? "Opened the VibeDev sign-in page in the user's browser." : "Could not open a browser."} If it did not appear, give the user this link: ${started.url} — new users can register on that page (or at ${REGISTER_URL}). The sign-in completes in the background within ${minutes} minutes; once the user says they are done, retry the request.`
7784
+ message: `${started.opened ? "Opened the VibeDev sign-in page in the user's browser." : "Could not open a browser."} If it did not appear, give the user this link: ${started.url} — new users can register on that page (or at ${runtime.http.origin}). The sign-in completes in the background within ${minutes} minutes; once the user says they are done, retry the request.`
6647
7785
  };
6648
7786
  }
6649
7787
  });
@@ -6651,8 +7789,9 @@ function mediaAccountTool(runtime, chain, login) {
6651
7789
  //#endregion
6652
7790
  //#region src/tools/audio.ts
6653
7791
  /**
6654
- * `audio_generate`: a song or a podcast episode, as a background task whose
6655
- * results are saved in the workspace.
7792
+ * `audio_generate`: music, a podcast episode or speech, as a background task
7793
+ * whose results are saved in the workspace. Every field is checked against
7794
+ * what the chosen model declares (see `audio/request`).
6656
7795
  * @module dsh-vibedev/tools/audio
6657
7796
  */
6658
7797
  /**
@@ -6663,17 +7802,17 @@ function mediaAccountTool(runtime, chain, login) {
6663
7802
  function audioGenerateTool(runtime) {
6664
7803
  return defineTool({
6665
7804
  name: "audio_generate",
6666
- description: "Generate music (a song with vocals or an instrumental) or a podcast episode (a spoken conversation about a topic or a web page) with a VibeDev audio model. It runs as a background job (usually 1-5 minutes): this call returns once the gateway accepts the task, you are notified when it finishes, and the files are saved in the workspace (media/audio/). Charged per request only when it succeeds (about ¥0.5 for music, ¥1 for a podcast; media_models lists prices). The length cannot be chosen.",
7805
+ description: "Generate music (a song with vocals or an instrumental), a podcast episode (a spoken conversation about a topic or a web page) or speech (text read aloud, optionally in the voice of reference audio) with a VibeDev audio model. It runs as a background job: this call returns once the gateway accepts the task, you are notified when it finishes, and the files are saved in the workspace (media/audio/). Each model takes only the fields it declares; media_models lists them with prices (music and podcasts per request, speech per second of original audio). Charged only when it succeeds.",
6667
7806
  parameters: {
6668
7807
  type: {
6669
7808
  type: "string",
6670
- enum: ["music", "podcast"],
7809
+ enum: [...AUDIO_MAKE_KINDS],
6671
7810
  required: true,
6672
7811
  description: "What to make."
6673
7812
  },
6674
7813
  prompt: {
6675
7814
  type: "string",
6676
- description: "Music: style, mood, instruments, tempo, theme. Podcast: the topic and angle to discuss. Required for music; for a podcast give a prompt, a source_url, or both."
7815
+ description: "Music: style, mood, instruments, tempo, theme (required). Podcast: the topic and angle; give a prompt, a source_url, or both. Speech: the exact text to speak (required)."
6677
7816
  },
6678
7817
  lyrics: {
6679
7818
  type: "string",
@@ -6689,16 +7828,32 @@ function audioGenerateTool(runtime) {
6689
7828
  },
6690
7829
  gender: {
6691
7830
  type: "string",
6692
- enum: ["male", "female"],
6693
- description: "Music only: the singer's voice."
7831
+ description: "Music only: the singer's voice, such as male or female."
6694
7832
  },
6695
7833
  source_url: {
6696
7834
  type: "string",
6697
7835
  description: "Podcast only: an http(s) page for the hosts to discuss."
6698
7836
  },
7837
+ references: {
7838
+ type: "array",
7839
+ items: { type: "string" },
7840
+ description: `Speech only: reference audio whose voice to imitate, each ${SOURCE_HELP}. The model states how many, how large and how long.`
7841
+ },
7842
+ format: {
7843
+ type: "string",
7844
+ description: "Speech only: an output format the model offers (media_models lists them, such as wav or mp3)."
7845
+ },
7846
+ sample_rate: {
7847
+ type: "integer",
7848
+ description: "Speech only: a sample rate the model offers for that format (Hz)."
7849
+ },
7850
+ watermark: {
7851
+ type: "boolean",
7852
+ description: "Speech only, when the model takes it: add the provider's audio watermark."
7853
+ },
6699
7854
  model: {
6700
7855
  type: "string",
6701
- description: "Audio model id from media_models. Omit to use the default for the type."
7856
+ description: "Audio model id (or alias) from media_models. Omit to use the default for the type."
6702
7857
  },
6703
7858
  filename: {
6704
7859
  type: "string",
@@ -6724,6 +7879,7 @@ function audioGenerateTool(runtime) {
6724
7879
  },
6725
7880
  jobId: { type: "string" },
6726
7881
  estimatedCny: { type: "string" },
7882
+ price: { type: "string" },
6727
7883
  outputs: {
6728
7884
  type: "array",
6729
7885
  items: {
@@ -6733,7 +7889,8 @@ function audioGenerateTool(runtime) {
6733
7889
  path: { type: "string" },
6734
7890
  url: { type: "string" },
6735
7891
  title: { type: "string" },
6736
- durationSeconds: { type: "number" }
7892
+ durationSeconds: { type: "number" },
7893
+ originalSeconds: { type: "number" }
6737
7894
  }
6738
7895
  }
6739
7896
  },
@@ -6749,75 +7906,44 @@ function audioGenerateTool(runtime) {
6749
7906
  }]
6750
7907
  },
6751
7908
  async execute(args, exec) {
7909
+ const kind = args.type;
6752
7910
  const prompt = args.prompt?.trim();
6753
- const sourceUrl = args.source_url?.trim();
6754
- let body;
6755
- if (args.type === "music") {
6756
- if (prompt === void 0 || prompt === "") throw new MediaError("Music needs a prompt describing the song.", "AUDIO_PROMPT_REQUIRED", { field: "prompt" });
6757
- if (sourceUrl !== void 0 && sourceUrl !== "") throw new MediaError("source_url is for podcasts; describe the song in the prompt instead.", "AUDIO_OPTION_INVALID", { field: "source_url" });
6758
- body = {
6759
- prompt,
6760
- ...args.lyrics?.trim() ? { lyrics: args.lyrics.trim() } : {},
6761
- ...args.genre?.trim() ? { genre: args.genre.trim() } : {},
6762
- ...args.mood?.trim() ? { mood: args.mood.trim() } : {},
6763
- ...args.gender === void 0 ? {} : { gender: args.gender }
6764
- };
6765
- } else {
6766
- if ((prompt === void 0 || prompt === "") && (sourceUrl === void 0 || sourceUrl === "")) throw new MediaError("A podcast needs a prompt (the topic), a source_url, or both.", "AUDIO_PROMPT_REQUIRED", { field: "prompt" });
6767
- if (sourceUrl !== void 0 && sourceUrl !== "" && !/^https?:\/\//i.test(sourceUrl)) throw new MediaError("source_url must be an http(s) link.", "AUDIO_OPTION_INVALID", { field: "source_url" });
6768
- if (args.lyrics !== void 0 || args.genre !== void 0 || args.mood !== void 0 || args.gender !== void 0) throw new MediaError("lyrics, genre, mood and gender are for music; describe the episode in the prompt instead.", "AUDIO_OPTION_INVALID");
6769
- body = {
6770
- ...prompt ? { prompt } : {},
6771
- ...sourceUrl ? { source_url: sourceUrl } : {}
6772
- };
6773
- }
6774
- const model = await runtime.pickModel("audio", args.type, args.model, void 0, exec.signal);
6775
- const estimate = estimateAudio(model);
6776
- const what = args.type === "music" ? {
6777
- en: "a song",
6778
- zh: "一首音乐"
6779
- } : {
6780
- en: "a podcast episode",
6781
- zh: "一期播客"
6782
- };
6783
- await runtime.confirmSpending(exec, "audio_generate", {
6784
- en: `Generate ${what.en} with ${model.id}${estimate === void 0 ? "" : `, about ${yuan(estimate.amountCny)}`}. Charged only if it succeeds.`,
6785
- zh: `用 ${model.id} 生成${what.zh}${estimate === void 0 ? "" : `,约 ${yuan(estimate.amountCny)}`},成功才扣费。`
6786
- });
6787
- const now = runtime.now();
6788
- const label = excerpt(prompt ?? sourceUrl);
6789
- const { record: accepted, jobId, unconfirmed } = await submitTask(runtime, exec, {
6790
- id: randomUUID(),
6791
- kind: "audio",
6792
- model: model.id,
6793
- label,
6794
- createdAt: now,
6795
- updatedAt: now,
6796
- ...exec.agent === void 0 ? {} : { owner: exec.agent.id },
6797
- outputDir: runtime.outputFolder(exec, "audio"),
6798
- stem: runtime.stem(args.filename, prompt ?? args.type),
6799
- endpoint: "/v1/audio/generations",
6800
- body: {
6801
- model: model.id,
6802
- ...body
6803
- },
6804
- status: "submitting",
6805
- ...estimate === void 0 ? {} : { estimatedCny: estimate.amountCny.toFixed(2) }
7911
+ const prepared = await prepareAudioTask(runtime, exec, {
7912
+ kind,
7913
+ ...args.model === void 0 ? {} : { model: args.model },
7914
+ ...prompt === void 0 || prompt === "" ? {} : { prompt },
7915
+ ...args.lyrics === void 0 ? {} : { lyrics: args.lyrics },
7916
+ ...args.genre === void 0 ? {} : { genre: args.genre },
7917
+ ...args.mood === void 0 ? {} : { mood: args.mood },
7918
+ ...args.gender === void 0 ? {} : { gender: args.gender },
7919
+ ...args.source_url === void 0 ? {} : { sourceUrl: args.source_url },
7920
+ ...args.references === void 0 ? {} : { references: args.references },
7921
+ ...args.format === void 0 ? {} : { format: args.format },
7922
+ ...args.sample_rate === void 0 ? {} : { sampleRate: args.sample_rate },
7923
+ ...args.watermark === void 0 ? {} : { watermark: args.watermark }
7924
+ }, {
7925
+ folder: runtime.outputFolder(exec, "audio"),
7926
+ stem: runtime.stem(args.filename, prompt ?? args.source_url ?? kind)
6806
7927
  });
7928
+ const { model, quote } = prepared;
7929
+ const { record: accepted, jobId, unconfirmed } = await submitTask(runtime, exec, prepared.draft);
6807
7930
  const base = {
6808
7931
  taskId: accepted.id,
6809
7932
  model: model.id,
7933
+ price: quote.label.en,
6810
7934
  ...accepted.estimatedCny === void 0 ? {} : { estimatedCny: accepted.estimatedCny }
6811
7935
  };
6812
- if (jobId !== void 0) {
6813
- const kind = args.type === "music" ? "Music" : "Podcast";
6814
- return {
6815
- ...base,
6816
- status: accepted.status,
6817
- jobId,
6818
- message: `${unconfirmed ? `${kind} task ${accepted.id} (${model.id}): ${UNCONFIRMED_NOTE}` : `${kind} task submitted to ${model.id}.`} Background job ${jobId} reports when it finishes; files are saved under ${runtime.display(exec, accepted.outputDir)}. ${accepted.estimatedCny === void 0 ? "" : `Estimated ${yuan(Number(accepted.estimatedCny))}, charged only if it succeeds. `}Carry on with other work meanwhile; there is no need to poll.`
6819
- };
6820
- }
7936
+ const name = {
7937
+ music: "Music",
7938
+ podcast: "Podcast",
7939
+ speech: "Speech"
7940
+ }[kind];
7941
+ if (jobId !== void 0) return {
7942
+ ...base,
7943
+ status: accepted.status,
7944
+ jobId,
7945
+ message: `${unconfirmed ? `${name} task ${accepted.id} (${model.id}): ${UNCONFIRMED_NOTE}` : `${name} task submitted to ${model.id}.`} Background job ${jobId} reports when it finishes; files are saved under ${runtime.display(exec, accepted.outputDir)}. ${accepted.estimatedCny === void 0 ? `Price: ${quote.basis.en}. ` : `Estimated ${yuan(Number(accepted.estimatedCny))}, charged only if it succeeds. `}Carry on with other work meanwhile; there is no need to poll.`
7946
+ };
6821
7947
  const final = await waitForTask(runtime, exec, accepted);
6822
7948
  return {
6823
7949
  ...base,
@@ -6826,7 +7952,8 @@ function audioGenerateTool(runtime) {
6826
7952
  ...output.path === void 0 ? {} : { path: runtime.display(exec, output.path) },
6827
7953
  ...output.url === void 0 ? {} : { url: output.url },
6828
7954
  ...output.title === void 0 ? {} : { title: output.title },
6829
- ...output.durationSeconds === void 0 ? {} : { durationSeconds: output.durationSeconds }
7955
+ ...output.durationSeconds === void 0 ? {} : { durationSeconds: output.durationSeconds },
7956
+ ...output.originalSeconds === void 0 ? {} : { originalSeconds: output.originalSeconds }
6830
7957
  })) },
6831
7958
  message: describeTask(runtime, exec, final)
6832
7959
  };
@@ -6843,14 +7970,47 @@ function audioGenerateTool(runtime) {
6843
7970
  const KIND_LABEL = {
6844
7971
  image: "Image models (image_generate)",
6845
7972
  video: "Video models (video_generate)",
6846
- audio: "Music and podcast models (audio_generate)",
7973
+ audio: "Music, podcast and speech models (audio_generate)",
6847
7974
  transcription: "Transcription models (audio_transcribe)"
6848
7975
  };
6849
7976
  function slotOf(model) {
6850
- if (model.kind === "audio") return model.audioKind === "podcast" ? "podcast" : "music";
7977
+ if (model.kind === "audio") return model.audioKind;
6851
7978
  return model.kind;
6852
7979
  }
6853
7980
  /**
7981
+ * What an audio model takes, from its declaration: the fields, speech references, output formats and sample rates.
7982
+ * @param model - an audio or transcription model.
7983
+ * @returns one line, or undefined when nothing is declared.
7984
+ */
7985
+ function describeAudioModel(model) {
7986
+ const audio = model.audio;
7987
+ if (model.kind === "transcription") {
7988
+ const facts = [
7989
+ audio?.maxInputBytes === void 0 ? void 0 : `at most ${(audio.maxInputBytes / 1e6).toFixed(0)} MB`,
7990
+ audio?.maxInputSeconds === void 0 ? void 0 : `at most ${Math.round(audio.maxInputSeconds / 60)} min`,
7991
+ audio?.languages === void 0 ? void 0 : `languages ${audio.languages.join(", ")}`
7992
+ ].filter(Boolean);
7993
+ return facts.length === 0 ? void 0 : facts.join("; ");
7994
+ }
7995
+ if (model.audioKind === void 0) return "does not declare what kind of audio it makes; it cannot be used for generation";
7996
+ const fields = requestFieldsOf(model).filter((name) => name !== "model");
7997
+ const parts = [`fields: ${fields.join(", ") || "none declared"}`];
7998
+ const prompt = promptFieldOf(model);
7999
+ if (prompt !== void 0 && prompt !== "prompt") parts.push(`the text goes in ${prompt}`);
8000
+ const references = audio?.references;
8001
+ if (references !== void 0 && fields.includes("references")) {
8002
+ const count = references.min === references.max ? `${references.max}` : `${references.min}-${references.max}`;
8003
+ parts.push([
8004
+ `${count} reference audio`,
8005
+ references.maxBytes === void 0 ? void 0 : `each at most ${(references.maxBytes / 1048576).toFixed(0)} MiB`,
8006
+ references.minSeconds === void 0 && references.maxSeconds === void 0 ? void 0 : `each ${references.minSeconds ?? 0}-${references.maxSeconds ?? "∞"} s`,
8007
+ references.exclusiveWith === void 0 ? void 0 : `not with ${references.exclusiveWith.join(", ")}`
8008
+ ].filter(Boolean).join(", "));
8009
+ }
8010
+ for (const format of audio?.outputFormats ?? []) parts.push(`format ${format.format}${format.sampleRates.length === 0 ? "" : ` at ${format.sampleRates.join("/")} Hz`}`);
8011
+ return parts.join("; ");
8012
+ }
8013
+ /**
6854
8014
  * Build the tool.
6855
8015
  * @param runtime - the media runtime.
6856
8016
  * @returns the tool definition.
@@ -6858,7 +8018,7 @@ function slotOf(model) {
6858
8018
  function mediaModelsTool(runtime) {
6859
8019
  return defineTool({
6860
8020
  name: "media_models",
6861
- description: "List the image, video, music/podcast and transcription models this VibeDev account can use, with prices. For video models it lists each generation mode and the inputs it accepts (reference images, videos and audio, first and last frames), durations, aspect ratios and resolutions. Use it to choose a model when a request needs particular inputs or options; the generation tools pick a suitable model themselves when none is named.",
8021
+ description: "List the image, video, music/podcast/speech and transcription models this VibeDev account can use, with prices (\"not priced\" when the catalog states none). For video models it lists each generation mode and the inputs it accepts (reference images, videos and audio, first and last frames), durations, aspect ratios and resolutions; for audio models the fields each takes, speech references, output formats and sample rates. Use it to choose a model when a request needs particular inputs or options; the generation tools pick a suitable model themselves when none is named.",
6862
8022
  parameters: { kind: {
6863
8023
  type: "string",
6864
8024
  enum: [
@@ -6937,16 +8097,20 @@ function mediaModelsTool(runtime) {
6937
8097
  isConcurrencySafe: () => true,
6938
8098
  async execute(args, exec) {
6939
8099
  return { models: (await runtime.models(exec.signal)).filter((model) => args.kind === void 0 || model.kind === args.kind).map((model) => {
6940
- const price = describePrice(model);
6941
- const pinned = runtime.settings.defaultModel(slotOf(model)).trim() === model.id;
8100
+ const audioLike = model.kind === "audio" || model.kind === "transcription";
8101
+ const price = audioLike ? quoteAudio(model).label.en : describePrice(model);
8102
+ const slot = slotOf(model);
8103
+ const pinned = slot !== void 0 && runtime.settings.defaultModel(slot).trim() === model.id;
8104
+ const audioFacts = audioLike ? describeAudioModel(model) : void 0;
6942
8105
  return {
6943
8106
  id: model.id,
6944
8107
  name: model.name,
6945
8108
  kind: model.kind,
6946
- ...model.audioKind === void 0 ? {} : { produces: model.audioKind },
8109
+ ...model.kind !== "audio" ? {} : { produces: model.audioKind ?? "undeclared" },
6947
8110
  ...model.description === void 0 ? {} : { description: model.description },
6948
- ...price === void 0 ? {} : { price },
8111
+ price,
6949
8112
  ...model.kind === "video" ? { capabilities: describeVideoModel(model) } : {},
8113
+ ...audioFacts === void 0 ? {} : { capabilities: audioFacts },
6950
8114
  ...pinned ? { pinned } : {}
6951
8115
  };
6952
8116
  }) };
@@ -7034,7 +8198,7 @@ function mediaTasksTool(runtime) {
7034
8198
  const limit = Math.min(50, Math.max(1, Math.trunc(args.limit ?? 10)));
7035
8199
  const shown = tasks.slice(0, limit);
7036
8200
  for (const task of shown) if (!isFinished(task)) runtime.tracker.follow(task.id).catch(() => void 0);
7037
- return { tasks: shown.map((task) => ({
8201
+ return { tasks: (await Promise.all(shown.map((task) => task.status === "completed" && task.settlement === "pending" ? runtime.tracker.refresh(task.id, exec.signal).catch(() => task) : task))).map((task) => ({
7038
8202
  id: task.id,
7039
8203
  kind: task.kind,
7040
8204
  status: task.status,
@@ -7116,7 +8280,7 @@ function sleep(ms, signal) {
7116
8280
  function audioTranscribeTool(runtime) {
7117
8281
  return defineTool({
7118
8282
  name: "audio_transcribe",
7119
- description: "Transcribe speech in an audio file to text with a VibeDev transcription model. Mandarin Chinese (zh) only for now; at most 20 MB and 10 minutes; 16 kHz mono WAV works best. Charged per minute of audio.",
8283
+ description: "Transcribe speech in an audio file to text with a VibeDev transcription model (uploaded as a multipart file). Each model states its languages and limits (media_models; commonly Mandarin Chinese, at most 20 MB and 10 minutes); 16 kHz mono WAV works best. Charged per minute of audio.",
7120
8284
  parameters: {
7121
8285
  file: {
7122
8286
  type: "string",
@@ -7167,15 +8331,19 @@ function audioTranscribeTool(runtime) {
7167
8331
  async execute(args, exec) {
7168
8332
  const language = (args.language ?? "zh").trim().toLowerCase() || "zh";
7169
8333
  const model = await runtime.pickModel("transcription", "transcription", args.model, void 0, exec.signal);
7170
- const media = await runtime.load(exec, args.file, "audio", MAX_BYTES);
7171
- if (!ACCEPTED.includes(media.mime)) throw new MediaError(`"${media.source}" is ${media.mime}, not an audio file the gateway transcribes; convert it to 16 kHz mono WAV first.`, "AUDIO_FORMAT_UNSUPPORTED", { field: "file" });
8334
+ const declared = model.audio;
8335
+ const maxBytes = declared?.maxInputBytes ?? MAX_BYTES;
8336
+ const maxSeconds = declared?.maxInputSeconds ?? MAX_SECONDS;
8337
+ const accepted = declared?.inputMimes ?? ACCEPTED;
8338
+ if (declared?.languages !== void 0 && !declared.languages.includes(language)) throw new MediaError(`${model.id} transcribes ${declared.languages.join(", ")}, not "${language}".`, "AUDIO_OPTION_INVALID", { field: "language" });
8339
+ const media = await runtime.load(exec, args.file, "audio", maxBytes);
8340
+ if (!accepted.includes(media.mime)) throw new MediaError(`"${media.source}" is ${media.mime}, not an audio file the gateway transcribes; convert it to 16 kHz mono WAV first.`, "AUDIO_FORMAT_UNSUPPORTED", { field: "file" });
7172
8341
  const seconds = media.mime === "audio/wav" ? wavSeconds(media.data) : void 0;
7173
- if (seconds !== void 0 && seconds > MAX_SECONDS) throw new MediaError(`"${media.source}" is ${(seconds / 60).toFixed(1)} minutes long; the limit is 10 minutes. Split it first.`, "AUDIO_TOO_LONG", { field: "file" });
7174
- const tier = model.pricing?.default ?? model.pricing?.tiers[0];
7175
- const estimate = tier?.unit === "second" && seconds !== void 0 ? tier.amount * seconds : void 0;
8342
+ if (seconds !== void 0 && seconds > maxSeconds) throw new MediaError(`"${media.source}" is ${(seconds / 60).toFixed(1)} minutes long; the limit is ${(maxSeconds / 60).toFixed(0)} minutes. Split it first.`, "AUDIO_TOO_LONG", { field: "file" });
8343
+ const quote = quoteAudio(model, seconds);
7176
8344
  await runtime.confirmSpending(exec, "audio_transcribe", {
7177
- en: `Transcribe ${media.name} with ${model.id}${estimate === void 0 ? " (billed per minute)" : `, about ${yuan(estimate)}`}.`,
7178
- zh: `用 ${model.id} 转写 ${media.name}${estimate === void 0 ? "(按分钟计费)" : `,约 ${yuan(estimate)}`}。`
8345
+ en: `Transcribe ${media.name} with ${model.id}: ${quote.amountCny === void 0 ? quote.basis.en : `about ${yuan(quote.amountCny)} (${quote.basis.en})`}.`,
8346
+ zh: `用 ${model.id} 转写 ${media.name}:${quote.amountCny === void 0 ? quote.basis.zh : `约 ${yuan(quote.amountCny)}(${quote.basis.zh})`}。`
7179
8347
  });
7180
8348
  const body = new FormData();
7181
8349
  body.append("file", new Blob([media.data], { type: media.mime }), media.name);
@@ -7262,11 +8430,12 @@ const Config = Schema.object({
7262
8430
  videoModel: Schema.string().default("").volatile(),
7263
8431
  musicModel: Schema.string().default("").volatile(),
7264
8432
  podcastModel: Schema.string().default("").volatile(),
8433
+ speechModel: Schema.string().default("").volatile(),
7265
8434
  transcriptionModel: Schema.string().default("").volatile(),
7266
- gatewayOrigin: Schema.string().default("https://vibedev.jzsaas.com"),
8435
+ gatewayOrigin: Schema.string().default(DEFAULT_GATEWAY_ORIGIN),
7267
8436
  client: Schema.string().default("vibedev-plugin"),
7268
8437
  primary: Schema.boolean().default(false),
7269
- apiKeyEnv: Schema.string().default("VIBEDEV_GATEWAY_API_KEY"),
8438
+ apiKeyEnv: Schema.string().default(""),
7270
8439
  stateDir: Schema.string().default("")
7271
8440
  }).i18n({
7272
8441
  "zh-CN": {
@@ -7280,6 +8449,7 @@ const Config = Schema.object({
7280
8449
  videoModel: "默认视频模型(留空由 Agent 按目录选择)",
7281
8450
  musicModel: "默认音乐模型(留空自动选择)",
7282
8451
  podcastModel: "默认播客模型(留空自动选择)",
8452
+ speechModel: "默认语音合成模型(留空自动选择)",
7283
8453
  transcriptionModel: "默认语音转写模型(留空自动选择)",
7284
8454
  gatewayOrigin: "VibeDev 网关地址",
7285
8455
  client: "登录时报给网关的客户端名称(决定用量记在哪个密钥下)",
@@ -7298,6 +8468,7 @@ const Config = Schema.object({
7298
8468
  videoModel: "Default video model (empty: the agent chooses from the catalog)",
7299
8469
  musicModel: "Default music model (empty: chosen automatically)",
7300
8470
  podcastModel: "Default podcast model (empty: chosen automatically)",
8471
+ speechModel: "Default speech model (empty: chosen automatically)",
7301
8472
  transcriptionModel: "Default transcription model (empty: chosen automatically)",
7302
8473
  gatewayOrigin: "VibeDev gateway address",
7303
8474
  client: "Client name reported to the gateway at sign-in (decides which key the usage is recorded under)",
@@ -7306,15 +8477,15 @@ const Config = Schema.object({
7306
8477
  stateDir: "Plugin data folder (empty for the default)"
7307
8478
  }
7308
8479
  });
7309
- const GUIDANCE = "VibeDev media tools: image_generate, video_generate, audio_generate (music or a podcast) and audio_transcribe create and transcribe media with the user's VibeDev account; media_models lists models and prices. Use them on your own judgment whenever generated media serves the request (illustrations, product shots, storyboard frames, short clips, background music, a podcast episode), not only when a tool is named. They spend the user's balance, so make what the request needs rather than speculative variations. Results are saved in the workspace under media/. Pass files the user attached in this chat as chat:1 (the most recent), chat:2, and so on. Video and audio run as background jobs: carry on and let the job notification report the result.";
8480
+ const GUIDANCE = "VibeDev media tools: image_generate, video_generate, audio_generate (music, a podcast or speech) and audio_transcribe create and transcribe media with the user's VibeDev account; media_models lists models and prices. Use them on your own judgment whenever generated media serves the request (illustrations, product shots, storyboard frames, short clips, background music, a podcast episode, a voice-over), not only when a tool is named. They spend the user's balance, so make what the request needs rather than speculative variations. Results are saved in the workspace under media/. Pass files the user attached in this chat as chat:1 (the most recent), chat:2, and so on. Video and audio run as background jobs: carry on and let the job notification report the result.";
7310
8481
  function apply(ctx, config) {
7311
- const origin = config.gatewayOrigin.trim().replace(/\/+$/, "");
8482
+ const origin = normalizeGatewayOrigin(config.gatewayOrigin);
7312
8483
  const stateDir = stateDirectory(config.stateDir);
7313
8484
  const userAgent = `vibedev-plugin/${version}`;
7314
8485
  const warn = (message) => {
7315
8486
  ctx.logger.warn(message);
7316
8487
  };
7317
- const storage = grantStorage(stateDir, () => ctx.get("credentials"));
8488
+ const storage = grantStorage(stateDir, () => ctx.get("credentials"), origin);
7318
8489
  const login = new PluginLogin({
7319
8490
  origin,
7320
8491
  userAgent,
@@ -7325,7 +8496,7 @@ function apply(ctx, config) {
7325
8496
  });
7326
8497
  ctx.inject(["credentials"], (scoped) => {
7327
8498
  scoped.on("credentials/record-updated", (key) => {
7328
- if (key === GRANT_KEY) login.restore();
8499
+ if (key === gatewayGrantKey(origin)) login.restore();
7329
8500
  });
7330
8501
  scoped.effect(async () => {
7331
8502
  await login.restore();
@@ -7336,7 +8507,7 @@ function apply(ctx, config) {
7336
8507
  origin,
7337
8508
  plugin: login,
7338
8509
  account: () => ctx.get("deepseekAccount"),
7339
- apiKey: () => config.apiKeyEnv.trim() === "" ? void 0 : process.env[config.apiKeyEnv.trim()]?.trim()
8510
+ apiKey: () => developmentKey(origin, config.apiKeyEnv)
7340
8511
  });
7341
8512
  const http = new GatewayHttp({
7342
8513
  origin,
@@ -7344,7 +8515,7 @@ function apply(ctx, config) {
7344
8515
  resolveCredential: () => chain.resolve(),
7345
8516
  rejectToken: (credential) => chain.reject(credential)
7346
8517
  });
7347
- const store = new TaskStore(stateDir);
8518
+ const store = new TaskStore(gatewayStateDirectory(stateDir, origin));
7348
8519
  const tracker = new TaskTracker({
7349
8520
  http,
7350
8521
  store,
@@ -7355,6 +8526,7 @@ function apply(ctx, config) {
7355
8526
  video: config.videoModel,
7356
8527
  music: config.musicModel,
7357
8528
  podcast: config.podcastModel,
8529
+ speech: config.speechModel,
7358
8530
  transcription: config.transcriptionModel
7359
8531
  };
7360
8532
  const runtime = new MediaRuntime({