@hypit/hypit 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
- <a href="https://github.com/hypit-ai/hypit/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/hypit-ai/hypit?style=flat-square&color=FFD700&logo=github&logoColor=white&label=Stars"></a>
19
+ <img alt="Stars" src="https://img.shields.io/github/stars/hypit-ai/hypit?style=flat-square&color=FFD700&logo=github&logoColor=white&label=Stars">
20
20
  <a href="https://github.com/hypit-ai/hypit/blob/main/package.json"><img alt="Node 22.15+" src="https://img.shields.io/badge/Node.js-22.15%2B-5FA04E?style=flat-square&logo=nodedotjs&logoColor=white"></a>
21
21
  <a href="https://github.com/hypit-ai/hypit/blob/main/package.json"><img alt="pnpm 10.33" src="https://img.shields.io/badge/pnpm-10.33-F69220?style=flat-square&logo=pnpm&logoColor=white"></a>
22
22
  <a href="https://github.com/hypit-ai/hypit/blob/main/package.json"><img alt="TypeScript 5.9" src="https://img.shields.io/badge/TypeScript-5.9-3178C6?style=flat-square&logo=typescript&logoColor=white"></a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypit/hypit",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "homepage": "https://hypit.ai",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,6 +14,11 @@ invent targets, candidates or Provider choices.
14
14
  Human and JSON output answer the same command-specific question. `--json` changes encoding;
15
15
  `--verbose` expands scope. Compiler, Runtime and Repository objects are not default reports.
16
16
 
17
+ Argument errors use `CLI_USAGE` and point to the relevant `hypit help <command>`; JSON retains that
18
+ command in `error.help`. Unknown help topics fail explicitly. Runtime and execution failures retain
19
+ their own diagnostics and optional `--debug` trace. Result pagination changes only the `--before`
20
+ cursor on the current query, preserving its project, Source filter and other options.
21
+
17
22
  | Command | Default scope | Explicit detail |
18
23
  | --- | --- | --- |
19
24
  | `check` | Validation, targets and counts | `--verbose`: exported names/types and historical references |
@@ -113,6 +118,9 @@ flow.
113
118
  execution phases and Provider diagnostics. It reads a finished Result directly, without opening the
114
119
  Runtime; an active Build is read through Runtime control. The selected Repository handles file access.
115
120
  `--lines` limits the tail and the report states the omitted count; JSON carries records plus that count.
121
+ An unavailable log reports `source: "unavailable"` and exits unsuccessfully; a readable log with zero
122
+ records is a successful empty result. The human report distinguishes a finished Result with no log
123
+ from a lookup that still needs the Build's Runtime or correct project selection.
116
124
  `inspect` exposes an available log separately from authored Outputs. `hypit runtime logs` reads the
117
125
  Worker process log instead, for Runtime startup or process-level failures.
118
126
 
@@ -13,6 +13,7 @@ import type {
13
13
  RuntimeSelectionCommand,
14
14
  } from "./command.js";
15
15
  import type { CliColorMode, CliOutputOptions } from "./output.js";
16
+ import { CliUsageError } from "./usage-error.js";
16
17
 
17
18
  type RawOptions = {
18
19
  readonly presentation: CliOutputOptions;
@@ -48,6 +49,15 @@ type RawOptions = {
48
49
  const commonOptions = ["--json", "--color", "--no-color", "--verbose", "--debug"] as const;
49
50
 
50
51
  export function parseCommand(argv: readonly string[]): CliCommand {
52
+ try { return parseArguments(argv); }
53
+ catch (error) {
54
+ if (error instanceof CliUsageError) throw error;
55
+ throw new CliUsageError(error instanceof Error ? error.message : String(error),
56
+ argv[0] === undefined ? "hypit help" : `hypit help ${argv[0]}`, { cause: error });
57
+ }
58
+ }
59
+
60
+ function parseArguments(argv: readonly string[]): CliCommand {
51
61
  const [command, ...tail] = argv;
52
62
  switch (command) {
53
63
  case "check": {
@@ -246,7 +256,8 @@ export function parseCommand(argv: readonly string[]): CliCommand {
246
256
  ...optionalPackageRoot(options),
247
257
  };
248
258
  }
249
- default: throw new Error(usage());
259
+ default: throw new CliUsageError(command === undefined ? "A command is required"
260
+ : `Unknown command ${JSON.stringify(command)}`, "hypit help");
250
261
  }
251
262
  }
252
263
 
@@ -83,13 +83,19 @@ export async function runExecutionCommand(input: {
83
83
  const records = view?.records ?? [];
84
84
  const omitted = (view?.total ?? 0) - records.length;
85
85
  write({ format: "hypit.cli-logs@1", build: args.build, source, records, omittedRecords: omitted },
86
- view === undefined ? "No execution log recorded" : "Build execution log", "info",
86
+ view === undefined ? "Execution log unavailable" : "Build execution log", view === undefined ? "warning" : "info",
87
87
  [["Build", args.build], ["Source", source]], [
88
+ ...(view !== undefined ? [] : [finished
89
+ ? "This Result has no saved execution log."
90
+ : activeProfile === undefined
91
+ ? "No saved log found in this project. Select the Build's Runtime with --runtime <profile> to check active execution."
92
+ : "No log found in this project's Results or the selected Runtime. Check the Build id and project selection."]),
88
93
  ...(omitted > 0 ? [`Showing last ${records.length} records; ${omitted} earlier records omitted. Use --lines to read more.`] : []),
89
94
  ...records.map((record) => `${new Date(record.time).toISOString()} ${record.endpoint} ${record.command} ${
90
95
  record.kind === "phase" ? record.phase : record.kind === "diagnostic" || record.kind === "failed"
91
96
  ? `${record.kind}: ${record.message}` : record.kind}`),
92
97
  ]);
98
+ if (view === undefined) io.setExitCode?.(1);
93
99
  return;
94
100
  }
95
101
 
@@ -54,7 +54,9 @@ export async function runProjectResultCommand(input: {
54
54
  const label = item.title === undefined ? item.id : `${item.title} · ${item.id}`;
55
55
  const run = item.run === undefined ? "" : ` · ${item.run}`;
56
56
  return `${label}: ${item.outcome} · ${new Date(item.createdAt).toLocaleString()}${run} · ${item.targetCount} target${item.targetCount === 1 ? "" : "s"}`;
57
- }).concat(page.next === undefined ? [] : [`Older hypit builds --before ${page.next}`]));
57
+ }).concat(page.next === undefined ? [] : [
58
+ `Older Repeat this command with --before ${page.next}, keeping the other options.`,
59
+ ]));
58
60
  return;
59
61
  }
60
62
 
@@ -89,7 +91,9 @@ export async function runProjectResultCommand(input: {
89
91
  const label = item.title === undefined ? item.build : `${item.title} · ${item.build}`;
90
92
  return `${label}: ${item.outcome} · ${new Date(item.createdAt).toLocaleString()}`
91
93
  + (args.presentation.verbose ? ` · ${item.output.kind} · ${item.output.type}` : "");
92
- }).concat(page.next === undefined ? [] : [`Older hypit history ${args.outputName} --before ${page.next}`]));
94
+ }).concat(page.next === undefined ? [] : [
95
+ `Older Repeat this command with --before ${page.next}, keeping the other options.`,
96
+ ]));
93
97
  return;
94
98
  }
95
99
 
@@ -62,7 +62,8 @@ export async function runCli(
62
62
  distribution: CliDistribution,
63
63
  ): Promise<void> {
64
64
  if (argv.length === 0 || argv[0] === "help" || argv.includes("--help")) {
65
- const topic = argv[0] === "help" ? argv[1] : argv.includes("--help") ? argv[0] : undefined;
65
+ const topic = argv[0] === "help" ? argv[1] : argv[0] === "--help" ? undefined
66
+ : argv.includes("--help") ? argv[0] : undefined;
66
67
  writeCliHelp(io, topic);
67
68
  return;
68
69
  }
@@ -3,6 +3,7 @@ import { relative, resolve } from "node:path";
3
3
  import type { CanonicalValue } from "@hypit/protocol";
4
4
 
5
5
  import type { OperationalMachineView } from "./machine-view.js";
6
+ import { CliUsageError } from "./usage-error.js";
6
7
 
7
8
  export type CliTerminal = {
8
9
  readonly isTTY: boolean;
@@ -986,6 +987,7 @@ export function writeCliHelp(io: CliIo, topic?: string): void {
986
987
  io.write(`${selected.join("\n")}\n`);
987
988
  return;
988
989
  }
990
+ throw new CliUsageError(`Unknown help topic ${JSON.stringify(topic)}`, "hypit help");
989
991
  }
990
992
  io.write([
991
993
  colors.accent(colors.strong("Hypit")),
@@ -1044,6 +1046,7 @@ export function renderCliError(error: unknown, options: {
1044
1046
  error: {
1045
1047
  code,
1046
1048
  message: source.message,
1049
+ ...(source instanceof CliUsageError ? { help: source.help } : {}),
1047
1050
  ...(options.debug && trace !== undefined && trace.length > 0 ? { trace } : {}),
1048
1051
  },
1049
1052
  }, null, 2)}\n`;
@@ -1058,6 +1061,8 @@ export function renderCliError(error: unknown, options: {
1058
1061
  ];
1059
1062
  if (options.debug && trace !== undefined && trace.length > 0) {
1060
1063
  lines.push("", colors.dim(trace));
1064
+ } else if (source instanceof CliUsageError) {
1065
+ lines.push("", `Usage ${source.help}`);
1061
1066
  } else {
1062
1067
  lines.push("", colors.dim("Run with --debug to include the internal stack trace."));
1063
1068
  }
@@ -0,0 +1,8 @@
1
+ /** Argument errors point to public usage rather than an internal stack trace. */
2
+ export class CliUsageError extends Error {
3
+ readonly code = "CLI_USAGE";
4
+ constructor(message: string, readonly help: string, options?: ErrorOptions) {
5
+ super(message, options);
6
+ this.name = "CliUsageError";
7
+ }
8
+ }
@@ -74,9 +74,13 @@ to the selected deployment's origin or an existing `/v1`/`/v1beta` base. The Run
74
74
  normalizes it to `/v1`; missing or insufficient user
75
75
  credentials should be resolved at [hypit.ai](https://hypit.ai). Referenced image, audio and video
76
76
  Resources are uploaded through a session from `POST /v1/files/uploads`, followed by the private
77
- regional multipart instructions returned by HypiHub. The Provider follows the server-selected part
77
+ regional multipart instructions or `api_multipart` file POST selected by HypiHub. The latter sends
78
+ one multipart/form-data file to the selected service's `/v1/files`, preserving reference purpose
79
+ and person classification; an uncertain file POST is not repeated automatically.
80
+ For direct multipart uploads, the Provider follows the server-selected part
78
81
  size and part concurrency, retries a failed part with a fresh signed URL, completes or cancels that
79
- one upload, and then passes the returned HTTPS URL to generation or transcription. One
82
+ one upload, and then passes the returned HTTPS URL to generation or transcription. Signing requests
83
+ contain at most the service's 128-part limit; all batches belong to the same upload. One
80
84
  Resource identity with the same declared person-reference classification is uploaded once within one Runtime operation. Hypit keeps no upload catalog or
81
85
  cross-Build cache. Seedance visual references can carry `personReference` in their media fields;
82
86
  the mapping declares it as a resource-transport field and the upload session receives
@@ -97,13 +101,23 @@ selected Store is writable. Its Endpoint receives only the credential slot it de
97
101
  operation for replacing that same slot; it cannot enumerate the Store, choose another key or read
98
102
  another Endpoint's credentials. A raw credential remains an ordinary static API key.
99
103
 
104
+ Refresh uses `oauthRequestTimeoutMs` during generation, transcription, uploads and pricing too.
105
+ It completes before the subsequent API request starts its own deadline. A stalled refresh reports
106
+ an OAuth refresh timeout without starting that API request.
107
+
100
108
  The service currently requires whole-file and per-part SHA-256 values as fields of its signed upload
101
109
  protocol. They exist only while transferring bytes; Hypit never uses them as Resource identity,
102
110
  Result metadata, lookup keys or reuse evidence. Signed URLs and their query credentials are removed
103
111
  from surfaced upload errors.
104
112
 
105
113
  The default remote alignment model is `victor-upmeet/whisperx`; `transcriptionModel` may select another
106
- HypiHub model that exposes the `transcriptions` route. `hypit doctor` reads the authenticated model
114
+ HypiHub model that exposes the `transcriptions` route. When transcription response headers include
115
+ `X-Request-Id`, the Provider records it in the existing execution diagnostics before reading the body.
116
+ A matching same-service `Location` is retained as the authenticated result lookup URL, including on
117
+ an HTTP failure or interrupted response body. This is a receipt for investigation, not automatic
118
+ resubmission or Build restoration. No receipt can be recorded if no response headers arrive.
119
+
120
+ `hypit doctor` reads the authenticated model
107
121
  catalog to verify configured capabilities; ordinary preflight never makes that request. The package
108
122
  declares HypiHub's public pricing page, `https://hypit.ai/commercial/pricing/`, as its price source.
109
123
  For each selected Need, `readPricing` resolves the corresponding HypiHub model and returns the service's
@@ -65,7 +65,7 @@ export function createHypiHubAuth(options: {
65
65
  throw new Error("HypiHub OAuth credential is read-only; run hypit auth login with a writable Credential Store");
66
66
  }
67
67
  refreshing = (async () => {
68
- const deadline = requestDeadline(options.requestTimeoutMs);
68
+ const deadline = requestDeadline(options.requestTimeoutMs, () => new Error("HypiHub OAuth refresh timed out"));
69
69
  try {
70
70
  const response = await deadline.wait(options.fetch(tokenEndpoint, {
71
71
  method: "POST",
@@ -100,12 +100,14 @@ class HypiHubHttpError extends Error {
100
100
  class HypiHubClient {
101
101
  readonly baseUrl: string;
102
102
  readonly timeout: number;
103
+ readonly oauthTimeout: number;
103
104
  readonly downloadAttempts: number;
104
105
  readonly fetcher: typeof globalThis.fetch;
105
106
  readonly uploader: HypiHubUploader;
106
107
  constructor(options: {
107
108
  readonly baseUrl: string;
108
109
  readonly timeout: number;
110
+ readonly oauthTimeout: number;
109
111
  readonly uploadConcurrency: number;
110
112
  readonly uploadPartTimeout: number;
111
113
  readonly uploadPartAttempts: number;
@@ -114,6 +116,7 @@ class HypiHubClient {
114
116
  }) {
115
117
  this.baseUrl = options.baseUrl.replace(/\/$/u, "");
116
118
  this.timeout = options.timeout;
119
+ this.oauthTimeout = options.oauthTimeout;
117
120
  this.downloadAttempts = options.downloadAttempts;
118
121
  this.fetcher = options.fetcher;
119
122
  this.uploader = new HypiHubUploader({
@@ -125,14 +128,18 @@ class HypiHubClient {
125
128
  fetch: this.fetcher,
126
129
  });
127
130
  }
128
- async json(path: string, auth: HypiHubAuth, init: RequestInit = {}, refreshOnUnauthorized = true): Promise<Record<string, unknown>> {
131
+ async json(path: string, auth: HypiHubAuth, init: RequestInit = {}, refreshOnUnauthorized = true,
132
+ onResponse?: (response: Response) => Promise<void>): Promise<Record<string, unknown>> {
133
+ const token = await auth.token();
129
134
  const deadline = requestDeadline(this.timeout);
130
135
  try {
131
- const response = await deadline.wait(this.fetcher(`${this.baseUrl}${path}`, { ...init, signal: deadline.signal, headers: { authorization: `Bearer ${await auth.token()}`, ...(init.headers ?? {}) } }));
136
+ const response = await deadline.wait(this.fetcher(`${this.baseUrl}${path}`, { ...init, signal: deadline.signal, headers: { authorization: `Bearer ${token}`, ...(init.headers ?? {}) } }));
137
+ if (response.status !== 401 && onResponse !== undefined) await deadline.wait(onResponse(response));
132
138
  const text = await deadline.wait(response.text()); let body: unknown = {};
133
139
  if (response.status === 401 && refreshOnUnauthorized && auth.canRefresh()) {
140
+ deadline.finish();
134
141
  await auth.refresh();
135
- return await this.json(path, auth, init, false);
142
+ return await this.json(path, auth, init, false, onResponse);
136
143
  }
137
144
  if (!response.ok) throw new HypiHubHttpError(response.status, `HypiHub returned HTTP ${response.status}: ${text.slice(0, 300)}`);
138
145
  try { body = text.length === 0 ? {} : JSON.parse(text); } catch { throw new Error(`HypiHub returned invalid JSON (${response.status})`); }
@@ -165,24 +172,40 @@ class HypiHubClient {
165
172
  ...(personReference === undefined ? {} : { isPersonReference: personReference }) }, auth);
166
173
  }
167
174
 
168
- async transcribe(body: Record<string, unknown>, auth: HypiHubAuth): Promise<Record<string, unknown>> {
175
+ async transcribe(body: Record<string, unknown>, auth: HypiHubAuth,
176
+ report?: (message: string) => Promise<void>): Promise<Record<string, unknown>> {
169
177
  return await this.json("/audio/transcriptions", auth, {
170
178
  method: "POST",
171
179
  headers: { "content-type": "application/json" },
172
180
  body: JSON.stringify(body),
181
+ }, true, async (response) => {
182
+ const requestId = response.headers.get("x-request-id");
183
+ if (requestId === null || !/^[A-Za-z0-9_-]{1,200}$/u.test(requestId)) return;
184
+ let retrieval = "";
185
+ const location = response.headers.get("location");
186
+ if (location !== null) {
187
+ try {
188
+ const url = new URL(location, this.baseUrl);
189
+ const expected = new URL(`${this.baseUrl}/audio/transcriptions/${encodeURIComponent(requestId)}`);
190
+ if (url.href === expected.href) retrieval = `; result lookup: ${url.href}`;
191
+ } catch { /* A malformed Location does not erase the request identifier. */ }
192
+ }
193
+ await report?.(`HypiHub transcription request ${requestId} (HTTP ${response.status})${retrieval}`);
173
194
  });
174
195
  }
175
196
  async speech(auth: HypiHubAuth, body: Record<string, unknown>, refreshOnUnauthorized = true): Promise<readonly { readonly bytes: Uint8Array; readonly mediaType: string }[]> {
197
+ const token = await auth.token();
176
198
  const deadline = requestDeadline(this.timeout);
177
199
  try {
178
200
  const response = await deadline.wait(this.fetcher(`${this.baseUrl}/audio/speech`, {
179
201
  method: "POST",
180
- headers: { authorization: `Bearer ${await auth.token()}`, "content-type": "application/json" },
202
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
181
203
  body: JSON.stringify({ ...body, output: "b64_json" }),
182
204
  signal: deadline.signal,
183
205
  }));
184
206
  const bytes = new Uint8Array(await deadline.wait(response.arrayBuffer()));
185
207
  if (response.status === 401 && refreshOnUnauthorized && auth.canRefresh()) {
208
+ deadline.finish();
186
209
  await auth.refresh();
187
210
  return await this.speech(auth, body, false);
188
211
  }
@@ -226,7 +249,7 @@ function authFor(context: EndpointInvocationContext, client: HypiHubClient): Hyp
226
249
  return createHypiHubAuth({
227
250
  credential: credential(context.credentials),
228
251
  baseUrl: client.baseUrl,
229
- requestTimeoutMs: client.timeout,
252
+ requestTimeoutMs: client.oauthTimeout,
230
253
  fetch: client.fetcher,
231
254
  });
232
255
  }
@@ -235,7 +258,7 @@ function pricingAuth(credentials: Readonly<Record<string, EndpointCredential>>,
235
258
  return createHypiHubAuth({
236
259
  credential: credential(credentials),
237
260
  baseUrl: client.baseUrl,
238
- requestTimeoutMs: client.timeout,
261
+ requestTimeoutMs: client.oauthTimeout,
239
262
  fetch: client.fetcher,
240
263
  });
241
264
  }
@@ -311,6 +334,7 @@ export async function diagnoseHypiHubProvider(
311
334
  const client = new HypiHubClient({
312
335
  baseUrl: apiBaseUrl(options.baseUrl ?? "https://hypit.ai/v1"),
313
336
  timeout: options.requestTimeoutMs ?? 30_000,
337
+ oauthTimeout: options.oauthRequestTimeoutMs ?? 30_000,
314
338
  uploadConcurrency: options.uploadConcurrency ?? 8,
315
339
  uploadPartTimeout: options.uploadPartTimeoutMs ?? 5 * 60_000,
316
340
  uploadPartAttempts: options.uploadPartAttempts ?? 3,
@@ -320,7 +344,7 @@ export async function diagnoseHypiHubProvider(
320
344
  const auth = createHypiHubAuth({
321
345
  credential: { secret: apiKey },
322
346
  baseUrl: client.baseUrl,
323
- requestTimeoutMs: client.timeout,
347
+ requestTimeoutMs: client.oauthTimeout,
324
348
  fetch: client.fetcher,
325
349
  });
326
350
  const response = await client.json("/models", auth);
@@ -487,6 +511,7 @@ export function createHypiHubProvider(options: CreateHypiHubProviderOptions = {}
487
511
  const client = new HypiHubClient({
488
512
  baseUrl: apiBaseUrl(options.baseUrl ?? "https://hypit.ai/v1"),
489
513
  timeout: requestTimeoutMs,
514
+ oauthTimeout: oauthRequestTimeoutMs,
490
515
  uploadConcurrency: options.uploadConcurrency ?? 8,
491
516
  uploadPartTimeout: uploadPartTimeoutMs,
492
517
  uploadPartAttempts,
@@ -496,6 +521,7 @@ export function createHypiHubProvider(options: CreateHypiHubProviderOptions = {}
496
521
  const pricingClient = new HypiHubClient({
497
522
  baseUrl: apiBaseUrl(options.baseUrl ?? "https://hypit.ai/v1"),
498
523
  timeout: pricingRequestTimeoutMs,
524
+ oauthTimeout: oauthRequestTimeoutMs,
499
525
  uploadConcurrency: options.uploadConcurrency ?? 8,
500
526
  uploadPartTimeout: uploadPartTimeoutMs,
501
527
  uploadPartAttempts,
@@ -531,7 +557,7 @@ export function createHypiHubProvider(options: CreateHypiHubProviderOptions = {}
531
557
  response_format: "verbose_json",
532
558
  language: request.language,
533
559
  timestamp_granularities: ["segment", "word"],
534
- }, auth);
560
+ }, auth, async (message) => { await context.reportDiagnostic?.({ level: "info", message }); });
535
561
  const evidence = sealAlignedTranscriptEvidence({
536
562
  passages: interpretWhisperXTranscript(response as WhisperXTranscriptResponse, request.sampleFrames),
537
563
  });
@@ -120,7 +120,7 @@ async function acquireUploadSlot(origin: string, auth: UploadAuth, limit: number
120
120
  };
121
121
  }
122
122
 
123
- /** Uploads media through HypiHub's session-negotiated private regional S3 multipart flow. */
123
+ /** Uploads media using the transfer mode negotiated by the selected HypiHub service. */
124
124
  export class HypiHubUploader {
125
125
  readonly baseUrl: string;
126
126
  readonly requestTimeout: number;
@@ -179,16 +179,18 @@ export class HypiHubUploader {
179
179
  }
180
180
 
181
181
  private async jsonOnce(path: string, auth: UploadAuth, init: RequestInit, timeoutMs: number, retryAuth = true): Promise<Record<string, unknown>> {
182
+ const token = await uploadToken(auth);
182
183
  const request = requestDeadline(timeoutMs);
183
184
  const deadline = Date.now() + timeoutMs;
184
185
  try {
185
186
  const response = await request.wait(this.fetcher(`${this.baseUrl}${path}`, {
186
187
  ...init,
187
188
  signal: request.signal,
188
- headers: { authorization: `Bearer ${await uploadToken(auth)}`, ...(init.headers ?? {}) },
189
+ headers: { authorization: `Bearer ${token}`, ...(init.headers ?? {}) },
189
190
  }));
190
191
  const text = await request.wait(response.text());
191
192
  if (response.status === 401 && retryAuth && uploadCanRefresh(auth)) {
193
+ request.finish();
192
194
  await (auth as HypiHubAuth).refresh();
193
195
  return await this.jsonOnce(path, auth, init, Math.max(1, deadline - Date.now()), false);
194
196
  }
@@ -214,6 +216,16 @@ export class HypiHubUploader {
214
216
  }
215
217
 
216
218
  private async signParts(uploadId: string, declarations: readonly PartDeclaration[], auth: UploadAuth): Promise<Map<number, Record<string, unknown>>> {
219
+ // HypiHub accepts at most 128 part declarations per signing request.
220
+ if (declarations.length > 128) {
221
+ const signed = new Map<number, Record<string, unknown>>();
222
+ for (let offset = 0; offset < declarations.length; offset += 128) {
223
+ for (const [number, part] of await this.signParts(uploadId, declarations.slice(offset, offset + 128), auth)) {
224
+ signed.set(number, part);
225
+ }
226
+ }
227
+ return signed;
228
+ }
217
229
  const startedAt = Date.now();
218
230
  this.log(`part signing started upload=${uploadId} parts=${declarations.length}`);
219
231
  const response = await this.json(`/files/uploads/${encodeURIComponent(uploadId)}/parts`, auth, {
@@ -396,9 +408,30 @@ export class HypiHubUploader {
396
408
  this.log(`upload session request failed elapsed=${this.elapsed(policyStartedAt)} reason=${this.safeReason(error)}`);
397
409
  throw error;
398
410
  }
399
- assert(policy.upload_mode === "s3_multipart", "HypiHub returned an unknown upload mode");
400
- const result = await this.uploadDirect(input.bytes, auth, policy);
411
+ assert(policy.upload_mode === "s3_multipart" || policy.upload_mode === "api_multipart",
412
+ "HypiHub returned an unknown upload mode");
413
+ const result = policy.upload_mode === "api_multipart"
414
+ ? await this.uploadApi(input, auth, policy)
415
+ : await this.uploadDirect(input.bytes, auth, policy);
401
416
  this.log(`upload finished bytes=${input.bytes.byteLength} mime=${input.mediaType} elapsed=${this.elapsed(startedAt)}`);
402
417
  return result;
403
418
  }
419
+
420
+ private async uploadApi(input: HypiHubUploadInput, auth: UploadAuth, policy: Record<string, unknown>): Promise<string> {
421
+ const endpoint = new URL(requiredString(policy.endpoint, "HypiHub file upload endpoint"), this.baseUrl);
422
+ assert(endpoint.href === `${this.baseUrl}/files`, "HypiHub file upload endpoint must belong to the selected service");
423
+ if (policy.max_bytes !== undefined) {
424
+ assert(input.bytes.byteLength <= requiredInteger(policy.max_bytes, "HypiHub file upload byte limit"),
425
+ "HypiHub reference exceeds the negotiated file upload byte limit");
426
+ }
427
+ const form = new FormData();
428
+ form.set("file", new Blob([new Uint8Array(input.bytes)], { type: input.mediaType }),
429
+ input.filename ?? `reference.${extension(input.mediaType)}`);
430
+ form.set("purpose", input.purpose ?? "reference");
431
+ if (input.isPersonReference !== undefined) form.set("is_person_reference", String(input.isPersonReference));
432
+ const response = await this.json("/files", auth, { method: "POST", body: form });
433
+ const url = requiredString(response.url, "HypiHub file upload URL");
434
+ assertHTTPS(url, "HypiHub file upload URL");
435
+ return url;
436
+ }
404
437
  }