@hypit/hypit 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +15 -4
  2. package/dist/public/endpoint-kit.d.ts +1 -1
  3. package/dist/public/runtime-kit.d.ts +3 -1
  4. package/examples/provider-package/README.md +12 -0
  5. package/examples/provider-package/packages/provider-images/src/provider.ts +37 -8
  6. package/package.json +1 -1
  7. package/packages/cli/README.md +27 -0
  8. package/packages/cli/src/command-hint.ts +20 -0
  9. package/packages/cli/src/commands/environment.ts +39 -18
  10. package/packages/cli/src/commands/execution.ts +27 -17
  11. package/packages/cli/src/commands/results.ts +2 -1
  12. package/packages/cli/src/machine-view.ts +4 -2
  13. package/packages/cli/src/main.ts +23 -22
  14. package/packages/cli/src/observation.ts +7 -2
  15. package/packages/cli/src/output.ts +3 -0
  16. package/packages/cli/src/view.ts +4 -1
  17. package/packages/driver-node/README.md +5 -0
  18. package/packages/driver-node/src/driver.ts +20 -15
  19. package/packages/endpoint-kit/README.md +11 -2
  20. package/packages/endpoint-kit/src/index.ts +1 -1
  21. package/packages/generation/README.md +10 -0
  22. package/packages/provider-hypihub/README.md +41 -6
  23. package/packages/provider-hypihub/src/errors.ts +61 -0
  24. package/packages/provider-hypihub/src/mapping.ts +10 -25
  25. package/packages/provider-hypihub/src/oauth.ts +4 -1
  26. package/packages/provider-hypihub/src/provider.ts +104 -75
  27. package/packages/provider-hypihub/src/routes.ts +24 -3
  28. package/packages/provider-hypihub/src/upload.ts +8 -18
  29. package/packages/provider-whisperx-local/README.md +7 -1
  30. package/packages/provider-whisperx-local/src/program.ts +2 -0
  31. package/packages/runtime-host-node/src/index.ts +4 -0
  32. package/packages/runtime-kit/README.md +3 -0
  33. package/packages/runtime-kit/src/index.ts +2 -0
  34. package/packages/runtime-local/README.md +17 -0
  35. package/packages/runtime-local/package.json +2 -1
  36. package/packages/runtime-local/src/program-lock.ts +40 -0
  37. package/packages/runtime-local/src/programs.ts +156 -79
  38. package/packages/video-cli/README.md +33 -6
  39. package/packages/video-cli/src/cli.ts +4 -1
  40. package/packages/video-cli/src/creation.ts +7 -2
  41. package/packages/video-cli/src/index.ts +2 -0
  42. package/packages/video-cli/src/version.ts +90 -0
  43. package/services/whisperx/README.md +10 -3
  44. package/services/whisperx/src/hypit_whisperx_service/engine.py +19 -1
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { requestDeadline } from "@hypit/runtime-kit";
2
3
  import type { AsyncEndpoint, EndpointCredential, EndpointFulfillment, EndpointInvocationContext, EndpointPollContext, EndpointPricingReader, EndpointStartContext, EndpointOutcome, ImmediateEndpointHandler } from "@hypit/endpoint-kit";
3
4
  import { defineEndpointPackage, wakeAfter } from "@hypit/endpoint-kit";
@@ -16,10 +17,35 @@ import {
16
17
  } from "@hypit/whisperx";
17
18
  import type { WhisperXTranscriptResponse } from "@hypit/whisperx";
18
19
  import { hypiHubRouteForCapability, hypiHubRoutes } from "./routes.js";
20
+ import type { HypiHubModelOperation } from "./routes.js";
19
21
  import { HypiHubUploader } from "./upload.js";
20
22
  import type { RuntimeDoctorDiagnostic } from "@hypit/runtime-kit";
21
23
  import { createHypiHubAuth, hypiHubCredentialNeedsRefresh } from "./oauth.js";
22
24
  import type { HypiHubAuth } from "./oauth.js";
25
+ import { HypiHubHttpError, HypiHubServiceError, hypiHubJobFailure } from "./errors.js";
26
+
27
+ function distributionVersion(): string {
28
+ try {
29
+ const manifest = JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")) as {
30
+ readonly name?: unknown; readonly version?: unknown;
31
+ };
32
+ if (manifest.name !== "@hypit/hypit" || typeof manifest.version !== "string" || manifest.version.length === 0) {
33
+ return "unknown";
34
+ }
35
+ return manifest.version;
36
+ } catch {
37
+ return "unknown";
38
+ }
39
+ }
40
+
41
+ const userAgent = `hypit/${distributionVersion()}`;
42
+
43
+ const identifiedFetch = (fetcher: typeof globalThis.fetch): typeof globalThis.fetch =>
44
+ async (input, init) => {
45
+ const headers: Record<string, string> = { "user-agent": userAgent };
46
+ new Headers(init?.headers).forEach((value, name) => { headers[name] = value; });
47
+ return await fetcher(input, { ...init, headers });
48
+ };
23
49
 
24
50
  export const hypiHubProviderModuleRef = { name: "@hypit/provider-hypihub", version: "1" } as const;
25
51
 
@@ -64,18 +90,15 @@ function apiBaseUrl(value: string): string {
64
90
  }
65
91
  function credential(credentials: Readonly<Record<string, EndpointCredential>>) {
66
92
  const value = credentials.apiKey?.secret;
67
- assert(typeof value === "string" && value.length > 0, "HypiHub login is unavailable; run hypit auth login for HypiHub");
93
+ assert(typeof value === "string" && value.length > 0, "HypiHub apiKey credential is unavailable; configure this Endpoint's credential with a HypiHub API key or OAuth login");
68
94
  return credentials.apiKey!;
69
95
  }
70
- function guidedMessage(error: unknown): string {
71
- const message = error instanceof Error ? error.message : String(error);
72
- return /API key is unavailable|HypiHub login is unavailable|HTTP 401\b/iu.test(message)
73
- ? `${message}. Sign in to HypiHub at https://hypit.ai with hypit auth login`
74
- : message;
96
+ function failureMessage(error: unknown): string {
97
+ return error instanceof Error ? error.message : String(error);
75
98
  }
76
99
  function failure(error: unknown): EndpointOutcome {
77
- const message = guidedMessage(error);
78
- return { status: "failed", failure: { code: "HYPIHUB_ERROR", message } };
100
+ const message = failureMessage(error);
101
+ return { status: "failed", failure: { code: error instanceof HypiHubServiceError ? error.code : "HYPIHUB_ERROR", message } };
79
102
  }
80
103
 
81
104
  function jobId(value: Record<string, unknown>): string {
@@ -84,17 +107,13 @@ function jobId(value: Record<string, unknown>): string {
84
107
  return id;
85
108
  }
86
109
 
87
- type HypiHubModelOperation = "images" | "image_edits" | "videos" | "audio_speech" | "transcriptions";
88
-
89
110
  async function verifyModelRoute(client: HypiHubClient, auth: HypiHubAuth, model: string, operation: HypiHubModelOperation): Promise<void> {
90
111
  const card = await client.json(`/models/${encodeURIComponent(model)}`, auth);
91
112
  const endpoints = card.endpoints;
92
- assert(Array.isArray(endpoints) && endpoints.includes(operation),
93
- `HypiHub model ${model} is not enabled for ${operation} with this API key`);
94
- }
95
-
96
- class HypiHubHttpError extends Error {
97
- constructor(readonly status: number, message: string) { super(message); }
113
+ assert(Array.isArray(endpoints) && endpoints.every((value) => typeof value === "string"),
114
+ `HypiHub model ${model} returned no valid operation list; support for ${operation} is unknown`);
115
+ assert(endpoints.includes(operation),
116
+ `HypiHub model ${model} does not list operation ${operation}; listed operations: ${endpoints.join(", ") || "none"}`);
98
117
  }
99
118
 
100
119
  class HypiHubClient {
@@ -118,7 +137,7 @@ class HypiHubClient {
118
137
  this.timeout = options.timeout;
119
138
  this.oauthTimeout = options.oauthTimeout;
120
139
  this.downloadAttempts = options.downloadAttempts;
121
- this.fetcher = options.fetcher;
140
+ this.fetcher = identifiedFetch(options.fetcher);
122
141
  this.uploader = new HypiHubUploader({
123
142
  baseUrl: this.baseUrl,
124
143
  requestTimeoutMs: this.timeout,
@@ -141,7 +160,13 @@ class HypiHubClient {
141
160
  await auth.refresh();
142
161
  return await this.json(path, auth, init, false, onResponse);
143
162
  }
144
- if (!response.ok) throw new HypiHubHttpError(response.status, `HypiHub returned HTTP ${response.status}: ${text.slice(0, 300)}`);
163
+ if (!response.ok) {
164
+ const input = typeof init.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : undefined;
165
+ throw new HypiHubHttpError(response.status, response, text, {
166
+ method: init.method ?? "GET", url: `${this.baseUrl}${path}`,
167
+ ...(typeof input?.model === "string" ? { model: input.model } : {}),
168
+ });
169
+ }
145
170
  try { body = text.length === 0 ? {} : JSON.parse(text); } catch { throw new Error(`HypiHub returned invalid JSON (${response.status})`); }
146
171
  return object(body, "HypiHub response");
147
172
  } finally { deadline.finish(); }
@@ -210,7 +235,10 @@ class HypiHubClient {
210
235
  return await this.speech(auth, body, false);
211
236
  }
212
237
  if (!response.ok) {
213
- throw new Error(`HypiHub returned HTTP ${response.status}: ${Buffer.from(bytes).toString("utf8").slice(0, 300)}`);
238
+ throw new HypiHubHttpError(response.status, response, Buffer.from(bytes).toString("utf8"), {
239
+ method: "POST", url: `${this.baseUrl}/audio/speech`,
240
+ ...(typeof body.model === "string" ? { model: body.model } : {}),
241
+ });
214
242
  }
215
243
  const responseType = response.headers.get("content-type")?.split(";", 1)[0] ?? "audio/mpeg";
216
244
  if (!responseType.includes("json")) return [{ bytes, mediaType: responseType }];
@@ -308,8 +336,7 @@ function cardIdentifiers(card: Record<string, unknown>): readonly string[] {
308
336
  return [card.id, card.canonical_name].filter((name): name is string => typeof name === "string");
309
337
  }
310
338
 
311
- /** The additional model names a card answers to. HypiHub publishes the per-operation
312
- * routing names used by the Provider mapping table as aliases of one canonical card. */
339
+ /** Additional names explicitly published by the service for this card. */
313
340
  function cardAliases(card: Record<string, unknown>): readonly string[] {
314
341
  return Array.isArray(card.aliases)
315
342
  ? card.aliases.filter((name): name is string => typeof name === "string")
@@ -325,7 +352,7 @@ export async function diagnoseHypiHubProvider(
325
352
  },
326
353
  ): Promise<readonly RuntimeDoctorDiagnostic[]> {
327
354
  const apiKey = context.credentials.apiKey?.secret;
328
- assert(typeof apiKey === "string" && apiKey.length > 0, "HypiHub login is unavailable");
355
+ assert(typeof apiKey === "string" && apiKey.length > 0, "HypiHub credential is unavailable");
329
356
  if (hypiHubCredentialNeedsRefresh(apiKey)) return [{
330
357
  severity: "warning",
331
358
  code: "HYPIHUB_OAUTH_REFRESH_UNCHECKED",
@@ -377,23 +404,13 @@ export async function diagnoseHypiHubProvider(
377
404
  if (!available) diagnostics.push({
378
405
  severity: "error",
379
406
  code: "HYPIHUB_CAPABILITY_UNAVAILABLE",
380
- message: `HypiHub login cannot currently route ${capabilityKey(capability)}`,
407
+ message: `The HypiHub catalogue returned for this credential does not list a route for ${capabilityKey(capability)}`,
381
408
  subject: capabilityKey(capability),
382
409
  });
383
410
  }
384
411
  return diagnostics;
385
412
  }
386
413
 
387
- function generationOperation(route: (typeof hypiHubRoutes)[number], input: Record<string, unknown>): HypiHubModelOperation {
388
- if (route.media === "audio") return "audio_speech";
389
- if (route.media === "video") return "videos";
390
- const hasReferences = Object.entries(input).some(([key, value]) => {
391
- if (!["images", "reference_images", "reference_image_urls", "reference_videos", "reference_audios", "first_frame", "last_frame"].includes(key)) return false;
392
- return Array.isArray(value) ? value.length > 0 : typeof value === "string" && value.length > 0;
393
- });
394
- return hasReferences ? "image_edits" : "images";
395
- }
396
-
397
414
  async function complete(client: HypiHubClient, auth: HypiHubAuth, route: (typeof hypiHubRoutes)[number], id: string, artifacts: ResourceStore): Promise<EndpointOutcome> {
398
415
  const response = await client.json(`/jobs/${encodeURIComponent(id)}/assets`, auth); const items = response.items;
399
416
  assert(Array.isArray(items) && items.length > 0, "HypiHub job has no assets"); const blobs: BlobRef[] = [];
@@ -401,14 +418,44 @@ async function complete(client: HypiHubClient, auth: HypiHubAuth, route: (typeof
401
418
  return { status: "completed", result: { value: route.packageResult(blobs) } };
402
419
  }
403
420
 
404
- async function synthesizeAudio(client: HypiHubClient, context: EndpointInvocationContext, publicAssetUrl: CreateHypiHubProviderOptions["publicAssetUrl"]): Promise<EndpointFulfillment> {
421
+ async function prepareGeneration(client: HypiHubClient, context: EndpointInvocationContext, publicAssetUrl: CreateHypiHubProviderOptions["publicAssetUrl"]) {
405
422
  const route = hypiHubRouteForCapability(context.need.capability);
406
- assert(route !== undefined && route.media === "audio", "HypiHub does not implement this exact capability");
423
+ assert(route !== undefined, "HypiHub does not implement this exact capability");
424
+ const request = route.prepare(context.need.constraints);
407
425
  const auth = authFor(context, client);
408
- const compiled = await route.compile(context.need.constraints, async (artifact, fields) => publicAssetUrl === undefined
409
- ? await client.upload(artifact, context.resources, auth, fields)
410
- : await publicAssetUrl(artifact, context.resources, fields));
411
- await verifyModelRoute(client, auth, compiled.model, "audio_speech");
426
+ await context.reportProgress?.({ phase: `Reading HypiHub model catalogue: ${request.model} (${request.operation})` });
427
+ try {
428
+ await verifyModelRoute(client, auth, request.model, request.operation);
429
+ } catch (error) {
430
+ throw new HypiHubServiceError(error instanceof HypiHubServiceError ? error.code : "HYPIHUB_ERROR",
431
+ `HypiHub model catalogue check failed; model=${request.model}; operation=${request.operation}; references uploaded=0; generation not submitted: ${failureMessage(error)}`);
432
+ }
433
+ await context.reportProgress?.({ phase: `Preparing HypiHub request: ${request.model} (${request.operation})` });
434
+ const uploaded = new Map<string, Promise<string>>();
435
+ const resolve = (artifact: BlobRef, fields?: Readonly<Record<string, string | number | boolean>>): Promise<string> => {
436
+ const key = JSON.stringify([artifact.resource, canonicalize(fields ?? {})]);
437
+ const existing = uploaded.get(key);
438
+ if (existing !== undefined) return existing;
439
+ const promise = publicAssetUrl === undefined
440
+ ? client.upload(artifact, context.resources, auth, fields)
441
+ : publicAssetUrl(artifact, context.resources, fields);
442
+ uploaded.set(key, promise);
443
+ return promise;
444
+ };
445
+ let compiled;
446
+ try {
447
+ compiled = await request.compile(resolve);
448
+ } catch (error) {
449
+ throw new HypiHubServiceError(error instanceof HypiHubServiceError ? error.code : "HYPIHUB_ERROR",
450
+ `HypiHub request preparation failed; model=${request.model}; operation=${request.operation}; generation not submitted: ${failureMessage(error)}`);
451
+ }
452
+ return { route, auth, compiled, operation: request.operation };
453
+ }
454
+
455
+ async function synthesizeAudio(client: HypiHubClient, context: EndpointInvocationContext, publicAssetUrl: CreateHypiHubProviderOptions["publicAssetUrl"]): Promise<EndpointFulfillment> {
456
+ const { route, auth, compiled, operation } = await prepareGeneration(client, context, publicAssetUrl);
457
+ assert(route.media === "audio", "HypiHub audio capabilities use an immediate endpoint");
458
+ await context.reportProgress?.({ phase: `Submitting HypiHub request: ${compiled.model} (${operation})` });
412
459
  const audio = await client.speech(auth, { model: compiled.model, ...(compiled.input as Record<string, unknown>) });
413
460
  const artifacts = await Promise.all(audio.map(async (item) => await context.resources.put(item.bytes, item.mediaType)));
414
461
  return { value: route.packageResult(artifacts) };
@@ -418,33 +465,20 @@ function endpoint(client: HypiHubClient, pollIntervalMs: number, maxOperationMs:
418
465
  return {
419
466
  async start(context: EndpointStartContext) {
420
467
  try {
421
- const route = hypiHubRouteForCapability(context.need.capability);
422
- assert(route !== undefined, "HypiHub does not implement this exact capability");
423
- const auth = authFor(context, client);
424
- const uploaded = new Map<string, Promise<string>>();
425
- const resolve = (artifact: BlobRef, fields?: Readonly<Record<string, string | number | boolean>>): Promise<string> => {
426
- const key = JSON.stringify([artifact.resource, canonicalize(fields ?? {})]);
427
- const existing = uploaded.get(key);
428
- if (existing !== undefined) return existing;
429
- const promise = publicAssetUrl === undefined
430
- ? client.upload(artifact, context.resources, auth, fields)
431
- : publicAssetUrl(artifact, context.resources, fields);
432
- uploaded.set(key, promise);
433
- return promise;
434
- };
435
- const compiled = await route.compile(context.need.constraints, resolve);
468
+ const { route, auth, compiled, operation } = await prepareGeneration(client, context, publicAssetUrl);
436
469
  assert(route.media !== "audio", "HypiHub audio capabilities use an immediate endpoint");
437
470
  const input = compiled.input as Record<string, unknown>;
438
- const operation = generationOperation(route, input);
439
471
  const path = operation === "image_edits" ? "/images/edits"
440
472
  : operation === "images" ? "/images/generations" : "/videos";
441
- await verifyModelRoute(client, auth, compiled.model, operation);
473
+ await context.reportProgress?.({ phase: `Submitting HypiHub request: ${compiled.model} (${operation})` });
442
474
  const response = await client.json(path, auth, { method: "POST", headers: { "content-type": "application/json", "idempotency-key": context.operation }, body: JSON.stringify({ model: compiled.model, ...input }) });
443
475
  const status = response.status;
444
476
  const remoteEnded = status === "succeeded" || status === "completed";
445
477
  const handle: Handle = { contract: "hypit.hypihub-operation@1", jobId: jobId(response), route: capabilityKey(route.capability), startedAt: Date.now() };
446
478
  const receipt = { id: handle.jobId };
447
479
  await context.checkpoint?.({ handle: canonicalize(handle), receipt, ...(remoteEnded ? { remoteEnded: true as const } : {}) });
480
+ const rejected = hypiHubJobFailure(response, handle.jobId);
481
+ if (rejected !== undefined) return { ...failure(rejected), receipt };
448
482
  return remoteEnded ? { status: "ready", handle: canonicalize(handle), receipt }
449
483
  : { ...wakeAfter(canonicalize(handle), pollIntervalMs, Date.now(), { phase: "submitted" }), receipt };
450
484
  } catch (error) {
@@ -457,15 +491,13 @@ function endpoint(client: HypiHubClient, pollIntervalMs: number, maxOperationMs:
457
491
  assert(route !== undefined && handle.contract === "hypit.hypihub-operation@1" && handle.route === capabilityKey(route.capability), "HypiHub handle is invalid");
458
492
  if (Date.now() - handle.startedAt > maxOperationMs) {
459
493
  return { status: "failed",
460
- failure: { code: "HYPIHUB_OPERATION_TIMEOUT", message: "HypiHub operation timed out" } };
494
+ receipt: { id: handle.jobId },
495
+ failure: { code: "HYPIHUB_OPERATION_TIMEOUT", message: `HypiHub job ${handle.jobId} exceeded this Provider's operationTimeoutMs (${maxOperationMs}); remote outcome is unknown` } };
461
496
  }
462
497
  const job = await client.json(`/jobs/${encodeURIComponent(handle.jobId)}`, authFor(context, client)); const status = job.status;
463
498
  if (status === "queued" || status === "running" || status === "in_progress") return wakeAfter(canonicalize(handle), pollIntervalMs, Date.now(), { phase: String(status) });
464
- if (status === "failed" || status === "queue_expired" || status === "cancelled") {
465
- const detail = [job.error, job.message, job.reason, job.detail]
466
- .find((value) => typeof value === "string" && value.length > 0);
467
- throw new Error(`HypiHub job ${status}${typeof detail === "string" ? `: ${detail}` : ""}`);
468
- }
499
+ const rejected = hypiHubJobFailure(job, handle.jobId);
500
+ if (rejected !== undefined) return { ...failure(rejected), receipt: { id: handle.jobId } };
469
501
  if (status !== "succeeded" && status !== "completed") throw new Error(`HypiHub returned unknown job status ${String(status)}`);
470
502
  return { status: "ready", handle: context.handle, receipt: { id: handle.jobId } };
471
503
  } catch (error) {
@@ -473,10 +505,12 @@ function endpoint(client: HypiHubClient, pollIntervalMs: number, maxOperationMs:
473
505
  }
474
506
  },
475
507
  async collect(context) {
476
- const handle = object(context.handle, "HypiHub handle") as unknown as Handle;
477
- const route = hypiHubRouteForCapability(context.need.capability);
478
- assert(route !== undefined && handle.route === capabilityKey(route.capability), "HypiHub collection route differs");
479
- return await complete(client, authFor(context, client), route, handle.jobId, context.resources);
508
+ try {
509
+ const handle = object(context.handle, "HypiHub handle") as unknown as Handle;
510
+ const route = hypiHubRouteForCapability(context.need.capability);
511
+ assert(route !== undefined && handle.route === capabilityKey(route.capability), "HypiHub collection route differs");
512
+ return await complete(client, authFor(context, client), route, handle.jobId, context.resources);
513
+ } catch (error) { return failure(error); }
480
514
  },
481
515
 
482
516
  };
@@ -529,13 +563,8 @@ export function createHypiHubProvider(options: CreateHypiHubProviderOptions = {}
529
563
  fetcher: options.fetch ?? globalThis.fetch,
530
564
  });
531
565
  const asyncEndpoint = endpoint(client, options.pollIntervalMs ?? 10_000, operationTimeoutMs, options.publicAssetUrl);
532
- const audioEndpoint: ImmediateEndpointHandler = async (context) => {
533
- try {
534
- return await synthesizeAudio(client, context, options.publicAssetUrl);
535
- } catch (error) {
536
- throw new Error(guidedMessage(error), { cause: error });
537
- }
538
- };
566
+ const audioEndpoint: ImmediateEndpointHandler = async (context) =>
567
+ await synthesizeAudio(client, context, options.publicAssetUrl);
539
568
  const transcriptionModel = options.transcriptionModel?.trim() || "victor-upmeet/whisperx";
540
569
  const whisperXEndpoint: ImmediateEndpointHandler = async (context) => {
541
570
  try {
@@ -564,7 +593,7 @@ export function createHypiHubProvider(options: CreateHypiHubProviderOptions = {}
564
593
  await context.reportProgress?.({ phase: "Word timing ready" });
565
594
  return { value: { kind: "inline", value: canonicalize(evidence) } };
566
595
  } catch (error) {
567
- throw new Error(guidedMessage(error), { cause: error });
596
+ throw new Error(failureMessage(error), { cause: error });
568
597
  }
569
598
  };
570
599
  const oauthOrigin = new URL(options.baseUrl ?? "https://hypit.ai").origin;
@@ -574,7 +603,7 @@ export function createHypiHubProvider(options: CreateHypiHubProviderOptions = {}
574
603
  readPricing: hypiHubPricingReader(pricingClient, transcriptionModel),
575
604
  credentials: { apiKey: options.apiKey ?? credentialRef("os", "hypihub.oauth") },
576
605
  credentialInputs: { apiKey: {
577
- label: "HypiHub login",
606
+ label: "HypiHub credential",
578
607
  acquisition: {
579
608
  kind: "oauth2-pkce",
580
609
  authorizationEndpoint: new URL("/oauth/consent", oauthOrigin).toString(),
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  compileWireRequest,
3
+ selectWireModelForRequest,
3
4
  generationTypes,
4
5
  sealGeneratedAudioSet,
5
6
  sealGeneratedImageSet,
@@ -11,12 +12,21 @@ import type { BlobRef, CapabilityRef, CanonicalValue, StoredValue, TypeRef } fro
11
12
  import type { EndpointRequest, EndpointSupport } from "@hypit/endpoint-kit";
12
13
  import { hypiHubMappings } from "./mapping.js";
13
14
 
15
+ export type HypiHubModelOperation = "images" | "image_edits" | "videos" | "audio_speech" | "transcriptions";
16
+
17
+ /** Request identity is available before resolving any reference into a service URL. */
18
+ export type HypiHubPreparedRequest = {
19
+ readonly model: string;
20
+ readonly operation: HypiHubModelOperation;
21
+ readonly compile: (resolve: GenerationArtifactUrlResolver) => Promise<{ readonly model: string; readonly input: CanonicalValue }>;
22
+ };
23
+
14
24
  export type HypiHubRoute = (typeof hypiHubMappings)[number] & {
15
25
  readonly key: string;
16
26
  readonly returns: TypeRef;
17
27
  readonly media: "image" | "video" | "audio";
18
28
  readonly supports?: (request: EndpointRequest) => EndpointSupport;
19
- readonly compile: (constraints: CanonicalValue, resolve: GenerationArtifactUrlResolver) => Promise<{ readonly model: string; readonly input: CanonicalValue }>;
29
+ readonly prepare: (constraints: CanonicalValue) => HypiHubPreparedRequest;
20
30
  readonly packageResult: (artifacts: readonly BlobRef[]) => StoredValue;
21
31
  };
22
32
 
@@ -60,11 +70,22 @@ export const hypiHubRoutes: readonly HypiHubRoute[] = hypiHubMappings.map((mappi
60
70
  return reason === undefined ? { status: "supported" } : { status: "unsupported", reason };
61
71
  },
62
72
  } : {}),
63
- compile: async (constraints, resolve) => {
73
+ prepare: (constraints) => {
64
74
  const request = constraints as unknown as GenerationRequest;
65
75
  const rejection = hypiHubGenerationRejection(mapping, request);
66
76
  if (rejection !== undefined) throw new Error(rejection);
67
- return normalizeHypiHubRequest(mapping, await compileWireRequest(mapping, request, resolve));
77
+ const model = selectWireModelForRequest(mapping, request);
78
+ // Image editing is determined by authored media ports, before their URLs exist.
79
+ const hasReferences = Object.entries(mapping.fields).some(([port, field]) =>
80
+ (field.as === "url" || field.as === "urlArray" || field.as === "itemObject")
81
+ && (request.ports[port]?.length ?? 0) > 0);
82
+ const operation = mapping.result === "audio" ? "audio_speech" : mapping.result === "video" ? "videos"
83
+ : hasReferences ? "image_edits" : "images";
84
+ return {
85
+ model,
86
+ operation,
87
+ compile: async (resolve) => normalizeHypiHubRequest(mapping, await compileWireRequest(mapping, request, resolve)),
88
+ };
68
89
  },
69
90
  packageResult: (artifacts) => ({
70
91
  kind: "inline",
@@ -2,6 +2,7 @@ import { requestDeadline } from "@hypit/runtime-kit";
2
2
  import { createHash } from "node:crypto";
3
3
 
4
4
  import type { HypiHubAuth } from "./oauth.js";
5
+ import { HypiHubHttpError, HypiHubServiceError, safeHypiHubReason } from "./errors.js";
5
6
 
6
7
  type UploadAuth = HypiHubAuth | string;
7
8
 
@@ -47,12 +48,6 @@ function apiBaseUrl(value: string): string {
47
48
  return `${trimmed.replace(/\/(?:v1beta|v1)$/iu, "")}/v1`;
48
49
  }
49
50
 
50
- class HypiHubHTTPError extends Error {
51
- constructor(readonly status: number, message: string, readonly retryAfterMs?: number, readonly code?: string) {
52
- super(message);
53
- }
54
- }
55
-
56
51
  export type HypiHubUploaderOptions = {
57
52
  readonly baseUrl: string;
58
53
  readonly requestTimeoutMs: number;
@@ -149,7 +144,7 @@ export class HypiHubUploader {
149
144
 
150
145
  private safeReason(error: unknown): string {
151
146
  const message = error instanceof Error ? error.message : String(error);
152
- return message.replace(/https?:\/\/\S+/giu, "[redacted-url]").slice(0, 300);
147
+ return safeHypiHubReason(message);
153
148
  }
154
149
 
155
150
  private async json(path: string, auth: UploadAuth, init: RequestInit = {}): Promise<Record<string, unknown>> {
@@ -159,7 +154,7 @@ export class HypiHubUploader {
159
154
  try {
160
155
  return await this.jsonOnce(path, auth, init, Math.max(1, deadline - Date.now()));
161
156
  } catch (error) {
162
- const http = error instanceof HypiHubHTTPError ? error : undefined;
157
+ const http = error instanceof HypiHubHttpError ? error : undefined;
163
158
  // Existing-session operations are idempotent. A new session may only
164
159
  // be retried after an explicit 429 rejection, never an unknown result.
165
160
  const retryable = http !== undefined
@@ -200,14 +195,9 @@ export class HypiHubUploader {
200
195
  if (response.ok) throw new Error(`HypiHub returned invalid JSON (${response.status})`);
201
196
  }
202
197
  if (!response.ok) {
203
- const errorBody = body !== null && typeof body === "object" && "error" in body ? body.error : undefined;
204
- const code = errorBody !== null && typeof errorBody === "object" && "code" in errorBody
205
- && typeof errorBody.code === "string" ? errorBody.code : undefined;
206
- const retryAfter = response.headers.get("retry-after");
207
- const parsedRetry = retryAfter === null ? NaN : /^\d+$/u.test(retryAfter.trim())
208
- ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now();
209
- throw new HypiHubHTTPError(response.status, `HypiHub returned HTTP ${response.status}: ${this.safeReason(text)}`,
210
- Number.isFinite(parsedRetry) ? Math.max(0, parsedRetry) : undefined, code);
198
+ throw new HypiHubHttpError(response.status, response, text, {
199
+ method: init.method ?? "GET", url: `${this.baseUrl}${path}`,
200
+ });
211
201
  }
212
202
  return object(body, "HypiHub response");
213
203
  } finally {
@@ -365,12 +355,12 @@ export class HypiHubUploader {
365
355
  this.log(`multipart upload failed upload=${uploadId} reason=${this.safeReason(error)}`);
366
356
  try { await this.json(`/files/uploads/${encodeURIComponent(uploadId)}`, auth, { method: "DELETE" }); }
367
357
  catch (cancelError) {
368
- if (!(cancelError instanceof HypiHubHTTPError && (cancelError.status === 404
358
+ if (!(cancelError instanceof HypiHubHttpError && (cancelError.status === 404
369
359
  || (cancelError.status === 409 && cancelError.code === "upload_completed")))) {
370
360
  this.log(`upload cancellation still pending upload=${uploadId} reason=${this.safeReason(cancelError)}`);
371
361
  }
372
362
  }
373
- throw new Error(this.safeReason(error));
363
+ throw error instanceof HypiHubServiceError ? error : new Error(this.safeReason(error));
374
364
  }
375
365
  }
376
366
 
@@ -85,8 +85,14 @@ Program needs restarting, and account for active work using it.
85
85
 
86
86
  Preparation commands write `install.log`; the running service writes `program.log`, with stderr in
87
87
  `program.err.log` on Windows. Inspect the stderr file for Python model-loading and download messages.
88
+ `programs status` reports these files as `installationLogPath`, `logPath` and `errorLogPath` when they
89
+ exist, even before installation finishes. Preparation notices name the Python environment and NLTK
90
+ commands separately. The service logs the start and completion of ASR loading, transcription,
91
+ language-alignment model loading and alignment, with elapsed times. Loading may include a download;
92
+ transfer details come from the underlying client, not an estimated percentage from the Provider.
88
93
  The service health endpoint becomes available after ASR loading. A startup readiness wait expiring
89
- can leave that process still loading; check its reported PID and logs before starting another process.
94
+ can leave that process still loading. Its PID is recorded when spawned; repeated `up` observes it,
95
+ and `programs down` can stop it during loading. PID liveness and service readiness are separate facts.
90
96
 
91
97
  Python installation, Python packages, NLTK sentence data, ASR weights and language-alignment weights
92
98
  are separate downloads. `UV_PYTHON_INSTALL_MIRROR` configures a Python distribution mirror;
@@ -126,10 +126,12 @@ export function localWhisperXProgram(options: LocalWhisperXProgramOptions): Mana
126
126
  probe: installationProbe,
127
127
  prepareBeforeStart: true,
128
128
  commands: [{
129
+ label: "Prepare the locked Python environment",
129
130
  command: "uv",
130
131
  args: ["sync", "--project", localWhisperXManagedProject, "--frozen", "--no-editable"],
131
132
  env: { UV_PROJECT_ENVIRONMENT: environment },
132
133
  }, {
134
+ label: "Prepare NLTK sentence data",
133
135
  command: pythonEnvironmentCommand(environment, "hypit-whisperx-prepare"),
134
136
  args: ["--nltk-data", nltkData],
135
137
  env: { HYPIT_WHISPERX_NLTK_DATA: nltkData },
@@ -203,6 +203,8 @@ export type RuntimeHostProviderQuery = {
203
203
  export type ManagedProgramProgress = {
204
204
  readonly id: string;
205
205
  readonly phase: "checking" | "installing" | "starting" | "waiting" | "ready";
206
+ readonly logPath?: string;
207
+ readonly detail?: string;
206
208
  };
207
209
 
208
210
  export type ManagedProgramReport = {
@@ -215,6 +217,8 @@ export type ManagedProgramReport = {
215
217
  | { readonly state: "mismatch"; readonly detail: string };
216
218
  readonly detail?: string;
217
219
  readonly logPath?: string;
220
+ readonly installationLogPath?: string;
221
+ readonly errorLogPath?: string;
218
222
  readonly pid?: number;
219
223
  };
220
224
 
@@ -45,3 +45,6 @@ video package by name.
45
45
  before a cold start, even when the installation probe already passes. The declared commands use the
46
46
  Provider's ordinary package manager. A healthy running Program is reused before this preparation is
47
47
  considered; Runtime does not inspect source files or infer implementation versions.
48
+ `ManagedProgramCommand.label` optionally names a command's purpose for progress and log headings.
49
+ It is display text supplied by the Program owner, not a phase to persist or interpret. Runtime
50
+ reports generic process/probe facts; the service itself owns domain-specific progress in its logs.
@@ -33,6 +33,8 @@ export type RuntimeDoctorDiagnostic = {
33
33
  };
34
34
 
35
35
  export type ManagedProgramCommand = {
36
+ /** Optional human-readable purpose, supplied by the program owner. Not a lifecycle state. */
37
+ readonly label?: string;
36
38
  readonly command: string;
37
39
  readonly args: readonly string[];
38
40
  readonly cwd?: string;
@@ -6,12 +6,29 @@ worklist. Project-owned Build Results hold finished public Outputs.
6
6
  Managed Program preparation writes subprocess stdout and stderr directly to that Program's
7
7
  `install.log`, so dependency-download output is readable before installation finishes. Installation
8
8
  and startup progress expose `logPath`; failed installation reports retain it with a short error.
9
+ Each preparation command adds its owner-supplied purpose (or executable name) and start/end time to
10
+ the log. Command arguments and environment values are not copied into these headings.
9
11
  The files belong to the Program's configured state directory. Service output uses `program.log`
10
12
  and, on Windows, a separate `program.err.log` for stderr. Keeping installation output separate
11
13
  preserves it when Windows opens fresh service logs at startup.
12
14
  The CLI retains Program failure reasons, PIDs and log paths in `programs` and `runtime up` reports.
13
15
  Human output stays compact for successful preparation; `programs status --verbose` also shows
14
16
  ready helpers, and JSON retains the reported details independently of verbosity.
17
+ With `--json`, preparation notices use stderr; stdout remains the final JSON result. Status reports
18
+ existing `installationLogPath`, `logPath` and Windows `errorLogPath` separately. These paths identify
19
+ historical files, not currently running phases.
20
+
21
+ Each Program Home has OS-owned exclusion for preparation, spawning and stopping. Concurrent lifecycle
22
+ commands for that home return the observed facts and a busy explanation; other Programs remain
23
+ independent. The empty `lifecycle.lock` file is only a lock address. The OS releases ownership on
24
+ command exit; no phase record, expiry, stale-lock deletion or recovery procedure is attached to it.
25
+ This uses the Distribution's existing native binding dependency for POSIX `flock` and Windows
26
+ exclusive file handles, rather than coordinating all services in a central table.
27
+
28
+ Startup publishes `process.pid` before waiting for the probe, then releases exclusion. Repeated `up`
29
+ observes that live process; `down` can stop it while it is still loading. A readiness observation
30
+ timeout leaves the process alone and reports that it remains alive. This is Program lifecycle
31
+ coordination, separate from Build execution; it does not retry or recover Builds.
15
32
 
16
33
  A Runtime Profile selects only the environmental parts that genuinely vary:
17
34
 
@@ -24,7 +24,8 @@
24
24
  "@hypit/runtime-kit": "workspace:*",
25
25
  "@hypit/runtime-host-node": "workspace:*",
26
26
  "@hypit/store-sqlite": "workspace:*",
27
- "@hypit/validation": "workspace:*"
27
+ "@hypit/validation": "workspace:*",
28
+ "koffi": "3.2.1"
28
29
  },
29
30
  "devDependencies": {
30
31
  "@hypit/build-result": "workspace:*",
@@ -0,0 +1,40 @@
1
+ import { closeSync, openSync } from "node:fs";
2
+ import { constants } from "node:os";
3
+ import { join, toNamespacedPath } from "node:path";
4
+ import koffi from "koffi";
5
+
6
+ // The OS owns exclusion and releases it when the calling process exits. The empty file
7
+ // is only an address: never unlink it, infer a phase from it, or expire another owner's lock.
8
+ const library = koffi.load(process.platform === "win32" ? "kernel32.dll" : null);
9
+ const flock = process.platform === "win32" ? undefined : library.func("int flock(int fd, int operation)");
10
+ const createFile = process.platform === "win32"
11
+ ? library.func("intptr_t __stdcall CreateFileW(const char16_t *path, uint32_t access, uint32_t share, void *security, uint32_t disposition, uint32_t attributes, intptr_t templateFile)") : undefined;
12
+ const lastError = process.platform === "win32" ? library.func("uint32_t __stdcall GetLastError()") : undefined;
13
+ const closeHandle = process.platform === "win32" ? library.func("int __stdcall CloseHandle(intptr_t file)") : undefined;
14
+
15
+ /** Try once. An occupied program returns immediately; unrelated programs remain independent. */
16
+ export function tryProgramLock(directory: string): (() => void) | undefined {
17
+ const path = join(directory, "lifecycle.lock");
18
+ let close: () => void;
19
+ if (createFile !== undefined) {
20
+ // OPEN_ALWAYS, no sharing: exclusive while the handle is open, including across CLI processes.
21
+ const handle = createFile(toNamespacedPath(path), 0xc0000000, 0, null, 4, 0x80, 0);
22
+ if (handle === -1 || handle === -1n) {
23
+ const code = lastError!();
24
+ if (code === 32 || code === 33) return undefined;
25
+ throw new Error(`Cannot acquire Program ownership at ${path}: Windows error ${code}`);
26
+ }
27
+ close = () => { closeHandle!(handle); };
28
+ } else {
29
+ const fd = openSync(path, "a");
30
+ if (flock!(fd, 2 | 4) !== 0) { // LOCK_EX | LOCK_NB
31
+ const code = koffi.errno();
32
+ closeSync(fd);
33
+ if (code === constants.errno.EAGAIN || code === constants.errno.EWOULDBLOCK) return undefined;
34
+ throw new Error(`Cannot acquire Program ownership at ${path}: errno ${code}`);
35
+ }
36
+ close = () => { closeSync(fd); };
37
+ }
38
+ let released = false;
39
+ return () => { if (!released) { released = true; close(); } };
40
+ }