@hypit/hypit 0.1.7 → 0.1.9
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 +35 -0
- package/packages/cli/src/arguments.ts +12 -1
- 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 +34 -18
- package/packages/cli/src/commands/results.ts +8 -3
- package/packages/cli/src/machine-view.ts +4 -2
- package/packages/cli/src/main.ts +25 -23
- package/packages/cli/src/observation.ts +7 -2
- package/packages/cli/src/output.ts +8 -0
- package/packages/cli/src/usage-error.ts +8 -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 +79 -74
- 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,6 +1,7 @@
|
|
|
1
1
|
import type { EndpointCredential } from "@hypit/endpoint-kit";
|
|
2
2
|
import { decodeOAuth2Credential, encodeOAuth2Credential } from "@hypit/runtime";
|
|
3
3
|
import { requestDeadline } from "@hypit/runtime-kit";
|
|
4
|
+
import { HypiHubHttpError } from "./errors.js";
|
|
4
5
|
|
|
5
6
|
const OAUTH_CLIENT_ID = "hyc_d5d5e8e7131b0c877756e66c";
|
|
6
7
|
const REFRESH_SKEW_MS = 60_000;
|
|
@@ -78,10 +79,12 @@ export function createHypiHubAuth(options: {
|
|
|
78
79
|
signal: deadline.signal,
|
|
79
80
|
}));
|
|
80
81
|
const text = await deadline.wait(response.text());
|
|
82
|
+
if (!response.ok) throw new HypiHubHttpError(response.status, response, text, {
|
|
83
|
+
method: "POST", url: tokenEndpoint,
|
|
84
|
+
});
|
|
81
85
|
let body: OAuthTokenResponse;
|
|
82
86
|
try { body = JSON.parse(text) as OAuthTokenResponse; }
|
|
83
87
|
catch { throw new Error(`HypiHub OAuth refresh returned invalid JSON (${response.status})`); }
|
|
84
|
-
if (!response.ok) throw new Error(`HypiHub OAuth refresh failed (${response.status}): ${text.slice(0, 200)}`);
|
|
85
88
|
assert(typeof body.access_token === "string" && body.access_token.length > 0,
|
|
86
89
|
"HypiHub OAuth refresh returned no access token");
|
|
87
90
|
accessToken = body.access_token;
|
|
@@ -16,10 +16,12 @@ import {
|
|
|
16
16
|
} from "@hypit/whisperx";
|
|
17
17
|
import type { WhisperXTranscriptResponse } from "@hypit/whisperx";
|
|
18
18
|
import { hypiHubRouteForCapability, hypiHubRoutes } from "./routes.js";
|
|
19
|
+
import type { HypiHubModelOperation } from "./routes.js";
|
|
19
20
|
import { HypiHubUploader } from "./upload.js";
|
|
20
21
|
import type { RuntimeDoctorDiagnostic } from "@hypit/runtime-kit";
|
|
21
22
|
import { createHypiHubAuth, hypiHubCredentialNeedsRefresh } from "./oauth.js";
|
|
22
23
|
import type { HypiHubAuth } from "./oauth.js";
|
|
24
|
+
import { HypiHubHttpError, HypiHubServiceError, hypiHubJobFailure } from "./errors.js";
|
|
23
25
|
|
|
24
26
|
export const hypiHubProviderModuleRef = { name: "@hypit/provider-hypihub", version: "1" } as const;
|
|
25
27
|
|
|
@@ -64,18 +66,15 @@ function apiBaseUrl(value: string): string {
|
|
|
64
66
|
}
|
|
65
67
|
function credential(credentials: Readonly<Record<string, EndpointCredential>>) {
|
|
66
68
|
const value = credentials.apiKey?.secret;
|
|
67
|
-
assert(typeof value === "string" && value.length > 0, "HypiHub
|
|
69
|
+
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
70
|
return credentials.apiKey!;
|
|
69
71
|
}
|
|
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;
|
|
72
|
+
function failureMessage(error: unknown): string {
|
|
73
|
+
return error instanceof Error ? error.message : String(error);
|
|
75
74
|
}
|
|
76
75
|
function failure(error: unknown): EndpointOutcome {
|
|
77
|
-
const message =
|
|
78
|
-
return { status: "failed", failure: { code: "HYPIHUB_ERROR", message } };
|
|
76
|
+
const message = failureMessage(error);
|
|
77
|
+
return { status: "failed", failure: { code: error instanceof HypiHubServiceError ? error.code : "HYPIHUB_ERROR", message } };
|
|
79
78
|
}
|
|
80
79
|
|
|
81
80
|
function jobId(value: Record<string, unknown>): string {
|
|
@@ -84,17 +83,13 @@ function jobId(value: Record<string, unknown>): string {
|
|
|
84
83
|
return id;
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
type HypiHubModelOperation = "images" | "image_edits" | "videos" | "audio_speech" | "transcriptions";
|
|
88
|
-
|
|
89
86
|
async function verifyModelRoute(client: HypiHubClient, auth: HypiHubAuth, model: string, operation: HypiHubModelOperation): Promise<void> {
|
|
90
87
|
const card = await client.json(`/models/${encodeURIComponent(model)}`, auth);
|
|
91
88
|
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); }
|
|
89
|
+
assert(Array.isArray(endpoints) && endpoints.every((value) => typeof value === "string"),
|
|
90
|
+
`HypiHub model ${model} returned no valid operation list; support for ${operation} is unknown`);
|
|
91
|
+
assert(endpoints.includes(operation),
|
|
92
|
+
`HypiHub model ${model} does not list operation ${operation}; listed operations: ${endpoints.join(", ") || "none"}`);
|
|
98
93
|
}
|
|
99
94
|
|
|
100
95
|
class HypiHubClient {
|
|
@@ -141,7 +136,13 @@ class HypiHubClient {
|
|
|
141
136
|
await auth.refresh();
|
|
142
137
|
return await this.json(path, auth, init, false, onResponse);
|
|
143
138
|
}
|
|
144
|
-
if (!response.ok)
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
const input = typeof init.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : undefined;
|
|
141
|
+
throw new HypiHubHttpError(response.status, response, text, {
|
|
142
|
+
method: init.method ?? "GET", url: `${this.baseUrl}${path}`,
|
|
143
|
+
...(typeof input?.model === "string" ? { model: input.model } : {}),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
145
146
|
try { body = text.length === 0 ? {} : JSON.parse(text); } catch { throw new Error(`HypiHub returned invalid JSON (${response.status})`); }
|
|
146
147
|
return object(body, "HypiHub response");
|
|
147
148
|
} finally { deadline.finish(); }
|
|
@@ -210,7 +211,10 @@ class HypiHubClient {
|
|
|
210
211
|
return await this.speech(auth, body, false);
|
|
211
212
|
}
|
|
212
213
|
if (!response.ok) {
|
|
213
|
-
throw new
|
|
214
|
+
throw new HypiHubHttpError(response.status, response, Buffer.from(bytes).toString("utf8"), {
|
|
215
|
+
method: "POST", url: `${this.baseUrl}/audio/speech`,
|
|
216
|
+
...(typeof body.model === "string" ? { model: body.model } : {}),
|
|
217
|
+
});
|
|
214
218
|
}
|
|
215
219
|
const responseType = response.headers.get("content-type")?.split(";", 1)[0] ?? "audio/mpeg";
|
|
216
220
|
if (!responseType.includes("json")) return [{ bytes, mediaType: responseType }];
|
|
@@ -308,8 +312,7 @@ function cardIdentifiers(card: Record<string, unknown>): readonly string[] {
|
|
|
308
312
|
return [card.id, card.canonical_name].filter((name): name is string => typeof name === "string");
|
|
309
313
|
}
|
|
310
314
|
|
|
311
|
-
/**
|
|
312
|
-
* routing names used by the Provider mapping table as aliases of one canonical card. */
|
|
315
|
+
/** Additional names explicitly published by the service for this card. */
|
|
313
316
|
function cardAliases(card: Record<string, unknown>): readonly string[] {
|
|
314
317
|
return Array.isArray(card.aliases)
|
|
315
318
|
? card.aliases.filter((name): name is string => typeof name === "string")
|
|
@@ -325,7 +328,7 @@ export async function diagnoseHypiHubProvider(
|
|
|
325
328
|
},
|
|
326
329
|
): Promise<readonly RuntimeDoctorDiagnostic[]> {
|
|
327
330
|
const apiKey = context.credentials.apiKey?.secret;
|
|
328
|
-
assert(typeof apiKey === "string" && apiKey.length > 0, "HypiHub
|
|
331
|
+
assert(typeof apiKey === "string" && apiKey.length > 0, "HypiHub credential is unavailable");
|
|
329
332
|
if (hypiHubCredentialNeedsRefresh(apiKey)) return [{
|
|
330
333
|
severity: "warning",
|
|
331
334
|
code: "HYPIHUB_OAUTH_REFRESH_UNCHECKED",
|
|
@@ -377,23 +380,13 @@ export async function diagnoseHypiHubProvider(
|
|
|
377
380
|
if (!available) diagnostics.push({
|
|
378
381
|
severity: "error",
|
|
379
382
|
code: "HYPIHUB_CAPABILITY_UNAVAILABLE",
|
|
380
|
-
message: `HypiHub
|
|
383
|
+
message: `The HypiHub catalogue returned for this credential does not list a route for ${capabilityKey(capability)}`,
|
|
381
384
|
subject: capabilityKey(capability),
|
|
382
385
|
});
|
|
383
386
|
}
|
|
384
387
|
return diagnostics;
|
|
385
388
|
}
|
|
386
389
|
|
|
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
390
|
async function complete(client: HypiHubClient, auth: HypiHubAuth, route: (typeof hypiHubRoutes)[number], id: string, artifacts: ResourceStore): Promise<EndpointOutcome> {
|
|
398
391
|
const response = await client.json(`/jobs/${encodeURIComponent(id)}/assets`, auth); const items = response.items;
|
|
399
392
|
assert(Array.isArray(items) && items.length > 0, "HypiHub job has no assets"); const blobs: BlobRef[] = [];
|
|
@@ -401,14 +394,44 @@ async function complete(client: HypiHubClient, auth: HypiHubAuth, route: (typeof
|
|
|
401
394
|
return { status: "completed", result: { value: route.packageResult(blobs) } };
|
|
402
395
|
}
|
|
403
396
|
|
|
404
|
-
async function
|
|
397
|
+
async function prepareGeneration(client: HypiHubClient, context: EndpointInvocationContext, publicAssetUrl: CreateHypiHubProviderOptions["publicAssetUrl"]) {
|
|
405
398
|
const route = hypiHubRouteForCapability(context.need.capability);
|
|
406
|
-
assert(route !== undefined
|
|
399
|
+
assert(route !== undefined, "HypiHub does not implement this exact capability");
|
|
400
|
+
const request = route.prepare(context.need.constraints);
|
|
407
401
|
const auth = authFor(context, client);
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
402
|
+
await context.reportProgress?.({ phase: `Reading HypiHub model catalogue: ${request.model} (${request.operation})` });
|
|
403
|
+
try {
|
|
404
|
+
await verifyModelRoute(client, auth, request.model, request.operation);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
throw new HypiHubServiceError(error instanceof HypiHubServiceError ? error.code : "HYPIHUB_ERROR",
|
|
407
|
+
`HypiHub model catalogue check failed; model=${request.model}; operation=${request.operation}; references uploaded=0; generation not submitted: ${failureMessage(error)}`);
|
|
408
|
+
}
|
|
409
|
+
await context.reportProgress?.({ phase: `Preparing HypiHub request: ${request.model} (${request.operation})` });
|
|
410
|
+
const uploaded = new Map<string, Promise<string>>();
|
|
411
|
+
const resolve = (artifact: BlobRef, fields?: Readonly<Record<string, string | number | boolean>>): Promise<string> => {
|
|
412
|
+
const key = JSON.stringify([artifact.resource, canonicalize(fields ?? {})]);
|
|
413
|
+
const existing = uploaded.get(key);
|
|
414
|
+
if (existing !== undefined) return existing;
|
|
415
|
+
const promise = publicAssetUrl === undefined
|
|
416
|
+
? client.upload(artifact, context.resources, auth, fields)
|
|
417
|
+
: publicAssetUrl(artifact, context.resources, fields);
|
|
418
|
+
uploaded.set(key, promise);
|
|
419
|
+
return promise;
|
|
420
|
+
};
|
|
421
|
+
let compiled;
|
|
422
|
+
try {
|
|
423
|
+
compiled = await request.compile(resolve);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
throw new HypiHubServiceError(error instanceof HypiHubServiceError ? error.code : "HYPIHUB_ERROR",
|
|
426
|
+
`HypiHub request preparation failed; model=${request.model}; operation=${request.operation}; generation not submitted: ${failureMessage(error)}`);
|
|
427
|
+
}
|
|
428
|
+
return { route, auth, compiled, operation: request.operation };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function synthesizeAudio(client: HypiHubClient, context: EndpointInvocationContext, publicAssetUrl: CreateHypiHubProviderOptions["publicAssetUrl"]): Promise<EndpointFulfillment> {
|
|
432
|
+
const { route, auth, compiled, operation } = await prepareGeneration(client, context, publicAssetUrl);
|
|
433
|
+
assert(route.media === "audio", "HypiHub audio capabilities use an immediate endpoint");
|
|
434
|
+
await context.reportProgress?.({ phase: `Submitting HypiHub request: ${compiled.model} (${operation})` });
|
|
412
435
|
const audio = await client.speech(auth, { model: compiled.model, ...(compiled.input as Record<string, unknown>) });
|
|
413
436
|
const artifacts = await Promise.all(audio.map(async (item) => await context.resources.put(item.bytes, item.mediaType)));
|
|
414
437
|
return { value: route.packageResult(artifacts) };
|
|
@@ -418,33 +441,20 @@ function endpoint(client: HypiHubClient, pollIntervalMs: number, maxOperationMs:
|
|
|
418
441
|
return {
|
|
419
442
|
async start(context: EndpointStartContext) {
|
|
420
443
|
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);
|
|
444
|
+
const { route, auth, compiled, operation } = await prepareGeneration(client, context, publicAssetUrl);
|
|
436
445
|
assert(route.media !== "audio", "HypiHub audio capabilities use an immediate endpoint");
|
|
437
446
|
const input = compiled.input as Record<string, unknown>;
|
|
438
|
-
const operation = generationOperation(route, input);
|
|
439
447
|
const path = operation === "image_edits" ? "/images/edits"
|
|
440
448
|
: operation === "images" ? "/images/generations" : "/videos";
|
|
441
|
-
await
|
|
449
|
+
await context.reportProgress?.({ phase: `Submitting HypiHub request: ${compiled.model} (${operation})` });
|
|
442
450
|
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
451
|
const status = response.status;
|
|
444
452
|
const remoteEnded = status === "succeeded" || status === "completed";
|
|
445
453
|
const handle: Handle = { contract: "hypit.hypihub-operation@1", jobId: jobId(response), route: capabilityKey(route.capability), startedAt: Date.now() };
|
|
446
454
|
const receipt = { id: handle.jobId };
|
|
447
455
|
await context.checkpoint?.({ handle: canonicalize(handle), receipt, ...(remoteEnded ? { remoteEnded: true as const } : {}) });
|
|
456
|
+
const rejected = hypiHubJobFailure(response, handle.jobId);
|
|
457
|
+
if (rejected !== undefined) return { ...failure(rejected), receipt };
|
|
448
458
|
return remoteEnded ? { status: "ready", handle: canonicalize(handle), receipt }
|
|
449
459
|
: { ...wakeAfter(canonicalize(handle), pollIntervalMs, Date.now(), { phase: "submitted" }), receipt };
|
|
450
460
|
} catch (error) {
|
|
@@ -457,15 +467,13 @@ function endpoint(client: HypiHubClient, pollIntervalMs: number, maxOperationMs:
|
|
|
457
467
|
assert(route !== undefined && handle.contract === "hypit.hypihub-operation@1" && handle.route === capabilityKey(route.capability), "HypiHub handle is invalid");
|
|
458
468
|
if (Date.now() - handle.startedAt > maxOperationMs) {
|
|
459
469
|
return { status: "failed",
|
|
460
|
-
|
|
470
|
+
receipt: { id: handle.jobId },
|
|
471
|
+
failure: { code: "HYPIHUB_OPERATION_TIMEOUT", message: `HypiHub job ${handle.jobId} exceeded this Provider's operationTimeoutMs (${maxOperationMs}); remote outcome is unknown` } };
|
|
461
472
|
}
|
|
462
473
|
const job = await client.json(`/jobs/${encodeURIComponent(handle.jobId)}`, authFor(context, client)); const status = job.status;
|
|
463
474
|
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
|
-
}
|
|
475
|
+
const rejected = hypiHubJobFailure(job, handle.jobId);
|
|
476
|
+
if (rejected !== undefined) return { ...failure(rejected), receipt: { id: handle.jobId } };
|
|
469
477
|
if (status !== "succeeded" && status !== "completed") throw new Error(`HypiHub returned unknown job status ${String(status)}`);
|
|
470
478
|
return { status: "ready", handle: context.handle, receipt: { id: handle.jobId } };
|
|
471
479
|
} catch (error) {
|
|
@@ -473,10 +481,12 @@ function endpoint(client: HypiHubClient, pollIntervalMs: number, maxOperationMs:
|
|
|
473
481
|
}
|
|
474
482
|
},
|
|
475
483
|
async collect(context) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
484
|
+
try {
|
|
485
|
+
const handle = object(context.handle, "HypiHub handle") as unknown as Handle;
|
|
486
|
+
const route = hypiHubRouteForCapability(context.need.capability);
|
|
487
|
+
assert(route !== undefined && handle.route === capabilityKey(route.capability), "HypiHub collection route differs");
|
|
488
|
+
return await complete(client, authFor(context, client), route, handle.jobId, context.resources);
|
|
489
|
+
} catch (error) { return failure(error); }
|
|
480
490
|
},
|
|
481
491
|
|
|
482
492
|
};
|
|
@@ -529,13 +539,8 @@ export function createHypiHubProvider(options: CreateHypiHubProviderOptions = {}
|
|
|
529
539
|
fetcher: options.fetch ?? globalThis.fetch,
|
|
530
540
|
});
|
|
531
541
|
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
|
-
};
|
|
542
|
+
const audioEndpoint: ImmediateEndpointHandler = async (context) =>
|
|
543
|
+
await synthesizeAudio(client, context, options.publicAssetUrl);
|
|
539
544
|
const transcriptionModel = options.transcriptionModel?.trim() || "victor-upmeet/whisperx";
|
|
540
545
|
const whisperXEndpoint: ImmediateEndpointHandler = async (context) => {
|
|
541
546
|
try {
|
|
@@ -564,7 +569,7 @@ export function createHypiHubProvider(options: CreateHypiHubProviderOptions = {}
|
|
|
564
569
|
await context.reportProgress?.({ phase: "Word timing ready" });
|
|
565
570
|
return { value: { kind: "inline", value: canonicalize(evidence) } };
|
|
566
571
|
} catch (error) {
|
|
567
|
-
throw new Error(
|
|
572
|
+
throw new Error(failureMessage(error), { cause: error });
|
|
568
573
|
}
|
|
569
574
|
};
|
|
570
575
|
const oauthOrigin = new URL(options.baseUrl ?? "https://hypit.ai").origin;
|
|
@@ -574,7 +579,7 @@ export function createHypiHubProvider(options: CreateHypiHubProviderOptions = {}
|
|
|
574
579
|
readPricing: hypiHubPricingReader(pricingClient, transcriptionModel),
|
|
575
580
|
credentials: { apiKey: options.apiKey ?? credentialRef("os", "hypihub.oauth") },
|
|
576
581
|
credentialInputs: { apiKey: {
|
|
577
|
-
label: "HypiHub
|
|
582
|
+
label: "HypiHub credential",
|
|
578
583
|
acquisition: {
|
|
579
584
|
kind: "oauth2-pkce",
|
|
580
585
|
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
|
+
}
|