@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.
- package/README.md +15 -4
- package/dist/public/endpoint-kit.d.ts +1 -1
- package/dist/public/runtime-kit.d.ts +3 -1
- package/examples/provider-package/README.md +12 -0
- package/examples/provider-package/packages/provider-images/src/provider.ts +37 -8
- package/package.json +1 -1
- package/packages/cli/README.md +27 -0
- package/packages/cli/src/command-hint.ts +20 -0
- package/packages/cli/src/commands/environment.ts +39 -18
- package/packages/cli/src/commands/execution.ts +27 -17
- package/packages/cli/src/commands/results.ts +2 -1
- package/packages/cli/src/machine-view.ts +4 -2
- package/packages/cli/src/main.ts +23 -22
- package/packages/cli/src/observation.ts +7 -2
- package/packages/cli/src/output.ts +3 -0
- package/packages/cli/src/view.ts +4 -1
- package/packages/driver-node/README.md +5 -0
- package/packages/driver-node/src/driver.ts +20 -15
- package/packages/endpoint-kit/README.md +11 -2
- package/packages/endpoint-kit/src/index.ts +1 -1
- package/packages/generation/README.md +10 -0
- package/packages/provider-hypihub/README.md +41 -6
- package/packages/provider-hypihub/src/errors.ts +61 -0
- package/packages/provider-hypihub/src/mapping.ts +10 -25
- package/packages/provider-hypihub/src/oauth.ts +4 -1
- package/packages/provider-hypihub/src/provider.ts +104 -75
- package/packages/provider-hypihub/src/routes.ts +24 -3
- package/packages/provider-hypihub/src/upload.ts +8 -18
- package/packages/provider-whisperx-local/README.md +7 -1
- package/packages/provider-whisperx-local/src/program.ts +2 -0
- package/packages/runtime-host-node/src/index.ts +4 -0
- package/packages/runtime-kit/README.md +3 -0
- package/packages/runtime-kit/src/index.ts +2 -0
- package/packages/runtime-local/README.md +17 -0
- package/packages/runtime-local/package.json +2 -1
- package/packages/runtime-local/src/program-lock.ts +40 -0
- package/packages/runtime-local/src/programs.ts +156 -79
- package/packages/video-cli/README.md +33 -6
- package/packages/video-cli/src/cli.ts +4 -1
- package/packages/video-cli/src/creation.ts +7 -2
- package/packages/video-cli/src/index.ts +2 -0
- package/packages/video-cli/src/version.ts +90 -0
- package/services/whisperx/README.md +10 -3
- 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
|
|
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
|
|
71
|
-
|
|
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 =
|
|
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.
|
|
93
|
-
`HypiHub model ${model}
|
|
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)
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
421
|
+
async function prepareGeneration(client: HypiHubClient, context: EndpointInvocationContext, publicAssetUrl: CreateHypiHubProviderOptions["publicAssetUrl"]) {
|
|
405
422
|
const route = hypiHubRouteForCapability(context.need.capability);
|
|
406
|
-
assert(route !== undefined
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|