@aithos/sdk 0.1.0-alpha.43 → 0.1.0-alpha.44
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 +34 -0
- package/dist/src/compute.d.ts +218 -0
- package/dist/src/compute.js +457 -0
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +2 -1
- package/dist/src/react/index.d.ts +1 -0
- package/dist/src/react/index.js +1 -0
- package/dist/src/react/use-transcribe-pending.d.ts +21 -0
- package/dist/src/react/use-transcribe-pending.js +47 -0
- package/dist/src/transcribe-resilience.d.ts +57 -0
- package/dist/src/transcribe-resilience.js +203 -0
- package/dist/test/transcribe-invoke.test.d.ts +2 -0
- package/dist/test/transcribe-invoke.test.js +204 -0
- package/dist/test/transcribe.test.d.ts +2 -0
- package/dist/test/transcribe.test.js +186 -0
- package/package.json +1 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
const LS_KEY = "aithos:transcribe:pending";
|
|
4
|
+
/**
|
|
5
|
+
* Framework-agnostic observable registry of in-flight transcription jobs.
|
|
6
|
+
* Persisted to localStorage when available (so it survives reloads), with
|
|
7
|
+
* an in-memory fallback otherwise. Subscribe with `subscribe(listener)`;
|
|
8
|
+
* read with `getSnapshot()` (stable reference between mutations, so it
|
|
9
|
+
* plugs directly into React's `useSyncExternalStore`).
|
|
10
|
+
*/
|
|
11
|
+
export class LocalPendingTranscribeTracker {
|
|
12
|
+
#listeners = new Set();
|
|
13
|
+
#mem = [];
|
|
14
|
+
#snapshot = [];
|
|
15
|
+
constructor() {
|
|
16
|
+
this.#snapshot = this.#read();
|
|
17
|
+
// Cross-tab sync: when another tab updates the key, re-emit.
|
|
18
|
+
try {
|
|
19
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
20
|
+
window.addEventListener("storage", (e) => {
|
|
21
|
+
if (e.key === LS_KEY) {
|
|
22
|
+
this.#snapshot = this.#read();
|
|
23
|
+
this.#emit();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* no window — fine */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
#ls() {
|
|
33
|
+
try {
|
|
34
|
+
return typeof localStorage !== "undefined" ? localStorage : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
#read() {
|
|
41
|
+
const ls = this.#ls();
|
|
42
|
+
if (!ls)
|
|
43
|
+
return [...this.#mem];
|
|
44
|
+
try {
|
|
45
|
+
const raw = ls.getItem(LS_KEY);
|
|
46
|
+
return raw ? JSON.parse(raw) : [];
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
#write(list) {
|
|
53
|
+
const ls = this.#ls();
|
|
54
|
+
if (ls) {
|
|
55
|
+
try {
|
|
56
|
+
ls.setItem(LS_KEY, JSON.stringify(list));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* quota / private mode — keep in-memory copy authoritative */
|
|
60
|
+
this.#mem = list;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
this.#mem = list;
|
|
65
|
+
}
|
|
66
|
+
this.#snapshot = list;
|
|
67
|
+
this.#emit();
|
|
68
|
+
}
|
|
69
|
+
#emit() {
|
|
70
|
+
for (const l of this.#listeners)
|
|
71
|
+
l();
|
|
72
|
+
}
|
|
73
|
+
/** Current entries. Stable reference until the next mutation. */
|
|
74
|
+
getSnapshot() {
|
|
75
|
+
return this.#snapshot;
|
|
76
|
+
}
|
|
77
|
+
list() {
|
|
78
|
+
this.#snapshot = this.#read();
|
|
79
|
+
return this.#snapshot;
|
|
80
|
+
}
|
|
81
|
+
/** Subscribe to changes. Returns an unsubscribe function. */
|
|
82
|
+
subscribe(listener) {
|
|
83
|
+
this.#listeners.add(listener);
|
|
84
|
+
return () => {
|
|
85
|
+
this.#listeners.delete(listener);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
upsert(jobId, status, meta) {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const list = this.#read();
|
|
91
|
+
const idx = list.findIndex((e) => e.jobId === jobId);
|
|
92
|
+
if (idx >= 0) {
|
|
93
|
+
const prev = list[idx];
|
|
94
|
+
list[idx] = {
|
|
95
|
+
...prev,
|
|
96
|
+
status,
|
|
97
|
+
updatedAt: now,
|
|
98
|
+
...(meta ? { meta: { ...prev.meta, ...meta } } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
list.push({
|
|
103
|
+
jobId,
|
|
104
|
+
status,
|
|
105
|
+
createdAt: now,
|
|
106
|
+
updatedAt: now,
|
|
107
|
+
...(meta ? { meta } : {}),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
this.#write(list);
|
|
111
|
+
}
|
|
112
|
+
remove(jobId) {
|
|
113
|
+
const list = this.#read().filter((e) => e.jobId !== jobId);
|
|
114
|
+
this.#write(list);
|
|
115
|
+
}
|
|
116
|
+
clear() {
|
|
117
|
+
this.#write([]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const DB_NAME = "aithos-transcribe";
|
|
121
|
+
const DB_VERSION = 1;
|
|
122
|
+
const STORE = "drafts";
|
|
123
|
+
export class TranscribeDraftUnavailableError extends Error {
|
|
124
|
+
constructor() {
|
|
125
|
+
super("IndexedDB is not available in this environment — the transcription draft queue is browser-only.");
|
|
126
|
+
this.name = "TranscribeDraftUnavailableError";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function getIndexedDB() {
|
|
130
|
+
try {
|
|
131
|
+
if (typeof indexedDB !== "undefined")
|
|
132
|
+
return indexedDB;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* fall through */
|
|
136
|
+
}
|
|
137
|
+
throw new TranscribeDraftUnavailableError();
|
|
138
|
+
}
|
|
139
|
+
function openDb() {
|
|
140
|
+
const idb = getIndexedDB();
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const req = idb.open(DB_NAME, DB_VERSION);
|
|
143
|
+
req.onupgradeneeded = () => {
|
|
144
|
+
const db = req.result;
|
|
145
|
+
if (!db.objectStoreNames.contains(STORE)) {
|
|
146
|
+
db.createObjectStore(STORE, { keyPath: "draftId" });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
req.onsuccess = () => resolve(req.result);
|
|
150
|
+
req.onerror = () => reject(req.error ?? new Error("indexedDB open failed"));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function tx(db, mode, fn) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const t = db.transaction(STORE, mode);
|
|
156
|
+
const req = fn(t.objectStore(STORE));
|
|
157
|
+
req.onsuccess = () => resolve(req.result);
|
|
158
|
+
req.onerror = () => reject(req.error ?? new Error("indexedDB request failed"));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* IndexedDB-backed queue of recorded audio Blobs. Save a recording the
|
|
163
|
+
* instant it finishes (before any network), then `upload` it when the
|
|
164
|
+
* user confirms — so a flaky network or a closed tab never loses audio.
|
|
165
|
+
* Browser-only: methods reject with {@link TranscribeDraftUnavailableError}
|
|
166
|
+
* when IndexedDB is absent.
|
|
167
|
+
*/
|
|
168
|
+
export class TranscribeDraftStore {
|
|
169
|
+
async save(blob, meta) {
|
|
170
|
+
const db = await openDb();
|
|
171
|
+
const draftId = `draft_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
172
|
+
const record = {
|
|
173
|
+
draftId,
|
|
174
|
+
blob,
|
|
175
|
+
metadata: {
|
|
176
|
+
...(meta ?? {}),
|
|
177
|
+
...(meta?.contentType ? {} : { contentType: blob.type || "audio/webm" }),
|
|
178
|
+
},
|
|
179
|
+
createdAt: Date.now(),
|
|
180
|
+
};
|
|
181
|
+
await tx(db, "readwrite", (s) => s.put(record));
|
|
182
|
+
db.close();
|
|
183
|
+
return { draftId };
|
|
184
|
+
}
|
|
185
|
+
async list() {
|
|
186
|
+
const db = await openDb();
|
|
187
|
+
const out = await tx(db, "readonly", (s) => s.getAll());
|
|
188
|
+
db.close();
|
|
189
|
+
return out.sort((a, b) => a.createdAt - b.createdAt);
|
|
190
|
+
}
|
|
191
|
+
async get(draftId) {
|
|
192
|
+
const db = await openDb();
|
|
193
|
+
const rec = await tx(db, "readonly", (s) => s.get(draftId));
|
|
194
|
+
db.close();
|
|
195
|
+
return rec ?? null;
|
|
196
|
+
}
|
|
197
|
+
async delete(draftId) {
|
|
198
|
+
const db = await openDb();
|
|
199
|
+
await tx(db, "readwrite", (s) => s.delete(draftId));
|
|
200
|
+
db.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=transcribe-resilience.js.map
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Tests for the high-level invokeTranscribe flow (prepare -> upload -> start
|
|
4
|
+
// -> poll) and the framework-agnostic resilience helpers (local pending
|
|
5
|
+
// tracker + IndexedDB draft store). fake-indexeddb/auto installs an
|
|
6
|
+
// in-memory IndexedDB for the draft tests.
|
|
7
|
+
import "fake-indexeddb/auto";
|
|
8
|
+
import { strict as assert } from "node:assert";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import { createBrowserIdentity } from "@aithos/protocol-client";
|
|
11
|
+
import { AithosAuth, AithosSDK, LocalPendingTranscribeTracker, TranscribeDraftStore, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
12
|
+
import { serializeRecoveryFile } from "../src/internal/recovery-file.js";
|
|
13
|
+
const APP_DID = "did:aithos:app:test";
|
|
14
|
+
const UPLOAD_URL = "https://s3.example.test/upload?sig=abc";
|
|
15
|
+
async function makeSdk(fetchImpl) {
|
|
16
|
+
const id = createBrowserIdentity("test-handle", "Test User");
|
|
17
|
+
const auth = new AithosAuth({
|
|
18
|
+
authBaseUrl: "https://auth.test",
|
|
19
|
+
fetch: (() => {
|
|
20
|
+
throw new Error("auth not used");
|
|
21
|
+
}),
|
|
22
|
+
sessionStore: noopStore(),
|
|
23
|
+
keyStore: memoryKeyStore(),
|
|
24
|
+
});
|
|
25
|
+
const { text } = serializeRecoveryFile(id);
|
|
26
|
+
await auth.signInWithRecovery({ file: text });
|
|
27
|
+
return new AithosSDK({
|
|
28
|
+
auth,
|
|
29
|
+
appDid: APP_DID,
|
|
30
|
+
endpoints: { compute: "https://compute.example.test" },
|
|
31
|
+
fetch: fetchImpl,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function jsonRpc(result) {
|
|
35
|
+
return new Response(JSON.stringify({ result }), {
|
|
36
|
+
status: 200,
|
|
37
|
+
headers: { "content-type": "application/json" },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
describe("compute.invokeTranscribe — full flow", () => {
|
|
41
|
+
it("prepares, uploads to S3, starts, polls and returns the transcript", async () => {
|
|
42
|
+
const calls = [];
|
|
43
|
+
let putBytes = 0;
|
|
44
|
+
let statusPolls = 0;
|
|
45
|
+
const fetchImpl = async (input, init) => {
|
|
46
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
47
|
+
if (init?.method === "PUT" && url.startsWith("https://s3.example.test/")) {
|
|
48
|
+
// S3 upload.
|
|
49
|
+
calls.push("s3-put");
|
|
50
|
+
putBytes = init.body.size;
|
|
51
|
+
return new Response("", { status: 200 });
|
|
52
|
+
}
|
|
53
|
+
const method = JSON.parse(init?.body).method;
|
|
54
|
+
calls.push(method);
|
|
55
|
+
if (method === "aithos.compute_transcribe_prepare") {
|
|
56
|
+
return jsonRpc({
|
|
57
|
+
job_id: "tj_FLOW",
|
|
58
|
+
upload_url: UPLOAD_URL,
|
|
59
|
+
s3_object_key: "uploads/2026/05/tj_FLOW.webm",
|
|
60
|
+
expires_at: 1717000000,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (method === "aithos.compute_transcribe_start") {
|
|
64
|
+
return jsonRpc({
|
|
65
|
+
job_id: "tj_FLOW",
|
|
66
|
+
status: "running",
|
|
67
|
+
estimated_credits: 60,
|
|
68
|
+
walletBalance: 49_940,
|
|
69
|
+
fundedBy: "purchase",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (method === "aithos.compute_transcribe_status") {
|
|
73
|
+
statusPolls += 1;
|
|
74
|
+
if (statusPolls < 2) {
|
|
75
|
+
return jsonRpc({ job_id: "tj_FLOW", status: "running", elapsed_sec: 1 });
|
|
76
|
+
}
|
|
77
|
+
return jsonRpc({
|
|
78
|
+
job_id: "tj_FLOW",
|
|
79
|
+
status: "completed",
|
|
80
|
+
text: "Bonjour le monde",
|
|
81
|
+
segments: [{ start_sec: 0, end_sec: 1.4, text: "Bonjour le monde" }],
|
|
82
|
+
words: [{ start_sec: 0, end_sec: 0.4, content: "Bonjour", confidence: 0.99 }],
|
|
83
|
+
duration_sec_actual: 30,
|
|
84
|
+
language_code: "fr-FR",
|
|
85
|
+
creditsCharged: 60,
|
|
86
|
+
walletBalance: 49_940,
|
|
87
|
+
auditId: "audit_flow",
|
|
88
|
+
fundedBy: "purchase",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`unexpected method ${method}`);
|
|
92
|
+
};
|
|
93
|
+
const sdk = await makeSdk(fetchImpl);
|
|
94
|
+
const phases = [];
|
|
95
|
+
const audio = new Blob([new Uint8Array(2048)], { type: "audio/webm" });
|
|
96
|
+
const result = await sdk.compute.invokeTranscribe({
|
|
97
|
+
audio,
|
|
98
|
+
durationSecOverride: 30,
|
|
99
|
+
pollIntervalMs: 1,
|
|
100
|
+
onProgress: (s) => phases.push(s.phase),
|
|
101
|
+
});
|
|
102
|
+
assert.equal(result.text, "Bonjour le monde");
|
|
103
|
+
assert.equal(result.durationSec, 30);
|
|
104
|
+
assert.equal(result.creditsCharged, 60);
|
|
105
|
+
assert.equal(result.jobId, "tj_FLOW");
|
|
106
|
+
assert.equal(result.fundedBy, "purchase");
|
|
107
|
+
assert.equal(result.segments.length, 1);
|
|
108
|
+
// The flow visited every step in order.
|
|
109
|
+
assert.deepEqual(calls, [
|
|
110
|
+
"aithos.compute_transcribe_prepare",
|
|
111
|
+
"s3-put",
|
|
112
|
+
"aithos.compute_transcribe_start",
|
|
113
|
+
"aithos.compute_transcribe_status",
|
|
114
|
+
"aithos.compute_transcribe_status",
|
|
115
|
+
]);
|
|
116
|
+
assert.equal(putBytes, 2048);
|
|
117
|
+
assert.ok(phases.includes("queued"));
|
|
118
|
+
assert.ok(phases.includes("uploading"));
|
|
119
|
+
assert.ok(phases.includes("starting"));
|
|
120
|
+
assert.ok(phases.includes("completed"));
|
|
121
|
+
// Job cleared from the local pending tracker on success.
|
|
122
|
+
assert.equal(sdk.compute.listLocalPendingTranscribes().length, 0);
|
|
123
|
+
});
|
|
124
|
+
it("throws an AithosSDKError when the job fails, leaving the job flagged failed", async () => {
|
|
125
|
+
const fetchImpl = async (input, init) => {
|
|
126
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
127
|
+
if (init?.method === "PUT")
|
|
128
|
+
return new Response("", { status: 200 });
|
|
129
|
+
const method = JSON.parse(init?.body).method;
|
|
130
|
+
if (method === "aithos.compute_transcribe_prepare") {
|
|
131
|
+
return jsonRpc({ job_id: "tj_F", upload_url: UPLOAD_URL, s3_object_key: "k", expires_at: 1 });
|
|
132
|
+
}
|
|
133
|
+
if (method === "aithos.compute_transcribe_start") {
|
|
134
|
+
return jsonRpc({ job_id: "tj_F", status: "running", estimated_credits: 60, walletBalance: 1 });
|
|
135
|
+
}
|
|
136
|
+
return jsonRpc({
|
|
137
|
+
job_id: "tj_F",
|
|
138
|
+
status: "failed",
|
|
139
|
+
error: { code: "transcribe_provider_error", message: "bad audio" },
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
const sdk = await makeSdk(fetchImpl);
|
|
143
|
+
const audio = new Blob([new Uint8Array(10)], { type: "audio/webm" });
|
|
144
|
+
await assert.rejects(() => sdk.compute.invokeTranscribe({ audio, durationSecOverride: 30, pollIntervalMs: 1 }), (e) => {
|
|
145
|
+
assert.ok(e instanceof Error);
|
|
146
|
+
assert.match(e.message, /bad audio/);
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
const pending = sdk.compute.listLocalPendingTranscribes();
|
|
150
|
+
assert.equal(pending.find((p) => p.jobId === "tj_F")?.status, "failed");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe("LocalPendingTranscribeTracker (vanilla, framework-agnostic)", () => {
|
|
154
|
+
it("upserts, lists, removes and notifies subscribers", () => {
|
|
155
|
+
const t = new LocalPendingTranscribeTracker();
|
|
156
|
+
t.clear();
|
|
157
|
+
let notifications = 0;
|
|
158
|
+
const unsub = t.subscribe(() => {
|
|
159
|
+
notifications += 1;
|
|
160
|
+
});
|
|
161
|
+
t.upsert("tj_1", "uploading", { model: "transcribe:aws-fr-standard" });
|
|
162
|
+
assert.equal(t.list().length, 1);
|
|
163
|
+
assert.equal(t.list()[0]?.status, "uploading");
|
|
164
|
+
t.upsert("tj_1", "running");
|
|
165
|
+
assert.equal(t.list().length, 1, "same job id updates in place");
|
|
166
|
+
assert.equal(t.list()[0]?.status, "running");
|
|
167
|
+
t.upsert("tj_2", "running");
|
|
168
|
+
assert.equal(t.list().length, 2);
|
|
169
|
+
t.remove("tj_1");
|
|
170
|
+
assert.equal(t.list().length, 1);
|
|
171
|
+
assert.equal(t.list()[0]?.jobId, "tj_2");
|
|
172
|
+
assert.ok(notifications >= 4, "subscriber fired on each mutation");
|
|
173
|
+
unsub();
|
|
174
|
+
const before = notifications;
|
|
175
|
+
t.upsert("tj_3", "running");
|
|
176
|
+
assert.equal(notifications, before, "no notification after unsubscribe");
|
|
177
|
+
});
|
|
178
|
+
it("getSnapshot returns a stable reference between mutations", () => {
|
|
179
|
+
const t = new LocalPendingTranscribeTracker();
|
|
180
|
+
t.clear();
|
|
181
|
+
const a = t.getSnapshot();
|
|
182
|
+
const b = t.getSnapshot();
|
|
183
|
+
assert.equal(a, b);
|
|
184
|
+
t.upsert("tj_x", "running");
|
|
185
|
+
assert.notEqual(t.getSnapshot(), a, "new reference after a mutation");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe("TranscribeDraftStore (IndexedDB via fake-indexeddb)", () => {
|
|
189
|
+
it("saves, lists, gets and deletes a draft", async () => {
|
|
190
|
+
const store = new TranscribeDraftStore();
|
|
191
|
+
const blob = new Blob([new Uint8Array(64)], { type: "audio/webm" });
|
|
192
|
+
const { draftId } = await store.save(blob, { title: "séance 1" });
|
|
193
|
+
assert.ok(draftId.startsWith("draft_"));
|
|
194
|
+
const list = await store.list();
|
|
195
|
+
assert.ok(list.some((d) => d.draftId === draftId));
|
|
196
|
+
const got = await store.get(draftId);
|
|
197
|
+
assert.equal(got?.metadata.title, "séance 1");
|
|
198
|
+
assert.equal(got?.metadata.contentType, "audio/webm");
|
|
199
|
+
assert.equal(got?.blob.size, 64);
|
|
200
|
+
await store.delete(draftId);
|
|
201
|
+
assert.equal(await store.get(draftId), null);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
//# sourceMappingURL=transcribe-invoke.test.js.map
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Unit tests for the transcription low-level API (sdk.compute.{prepare,
|
|
4
|
+
// start,getStatus,listPending}Transcribe) with a mock fetch. A real
|
|
5
|
+
// BrowserIdentity signs the envelopes, so any signing/canonicalization
|
|
6
|
+
// regression surfaces here too.
|
|
7
|
+
import { strict as assert } from "node:assert";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import { createBrowserIdentity } from "@aithos/protocol-client";
|
|
10
|
+
import { AithosAuth, AithosSDK, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
11
|
+
import { serializeRecoveryFile } from "../src/internal/recovery-file.js";
|
|
12
|
+
const APP_DID = "did:aithos:app:test";
|
|
13
|
+
async function makeSdk(fetchImpl) {
|
|
14
|
+
const id = createBrowserIdentity("test-handle", "Test User");
|
|
15
|
+
const auth = new AithosAuth({
|
|
16
|
+
authBaseUrl: "https://auth.test",
|
|
17
|
+
fetch: (() => {
|
|
18
|
+
throw new Error("auth not used in compute tests");
|
|
19
|
+
}),
|
|
20
|
+
sessionStore: noopStore(),
|
|
21
|
+
keyStore: memoryKeyStore(),
|
|
22
|
+
});
|
|
23
|
+
const { text } = serializeRecoveryFile(id);
|
|
24
|
+
await auth.signInWithRecovery({ file: text });
|
|
25
|
+
return new AithosSDK({
|
|
26
|
+
auth,
|
|
27
|
+
appDid: APP_DID,
|
|
28
|
+
endpoints: { compute: "https://compute.example.test" },
|
|
29
|
+
fetch: fetchImpl,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function jsonRpc(result) {
|
|
33
|
+
return new Response(JSON.stringify({ result }), {
|
|
34
|
+
status: 200,
|
|
35
|
+
headers: { "content-type": "application/json" },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function bodyParams(init) {
|
|
39
|
+
return JSON.parse(init?.body).params;
|
|
40
|
+
}
|
|
41
|
+
function bodyMethod(init) {
|
|
42
|
+
return JSON.parse(init?.body).method;
|
|
43
|
+
}
|
|
44
|
+
describe("compute.prepareTranscribe", () => {
|
|
45
|
+
it("posts transcribe_prepare with app_did + content_type and maps the result", async () => {
|
|
46
|
+
let method;
|
|
47
|
+
let params;
|
|
48
|
+
const sdk = await makeSdk(async (_url, init) => {
|
|
49
|
+
method = bodyMethod(init);
|
|
50
|
+
params = bodyParams(init);
|
|
51
|
+
return jsonRpc({
|
|
52
|
+
job_id: "tj_ABC",
|
|
53
|
+
upload_url: "https://s3.example/upload?sig=1",
|
|
54
|
+
s3_object_key: "uploads/2026/05/tj_ABC.webm",
|
|
55
|
+
expires_at: 1717000000,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
const out = await sdk.compute.prepareTranscribe({
|
|
59
|
+
contentType: "audio/webm",
|
|
60
|
+
durationSecEstimate: 120,
|
|
61
|
+
});
|
|
62
|
+
assert.equal(method, "aithos.compute_transcribe_prepare");
|
|
63
|
+
assert.equal(params?.app_did, APP_DID);
|
|
64
|
+
assert.equal(params?.content_type, "audio/webm");
|
|
65
|
+
assert.equal(params?.duration_sec_estimate, 120);
|
|
66
|
+
assert.ok(params._envelope, "must carry an envelope");
|
|
67
|
+
assert.deepEqual(out, {
|
|
68
|
+
jobId: "tj_ABC",
|
|
69
|
+
uploadUrl: "https://s3.example/upload?sig=1",
|
|
70
|
+
s3ObjectKey: "uploads/2026/05/tj_ABC.webm",
|
|
71
|
+
expiresAt: 1717000000,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe("compute.startTranscribe", () => {
|
|
76
|
+
it("posts transcribe_start with the wire params and maps the result", async () => {
|
|
77
|
+
let method;
|
|
78
|
+
let params;
|
|
79
|
+
const sdk = await makeSdk(async (_url, init) => {
|
|
80
|
+
method = bodyMethod(init);
|
|
81
|
+
params = bodyParams(init);
|
|
82
|
+
return jsonRpc({
|
|
83
|
+
job_id: "tj_ABC",
|
|
84
|
+
status: "running",
|
|
85
|
+
estimated_credits: 240,
|
|
86
|
+
walletBalance: 49_760,
|
|
87
|
+
fundedBy: "purchase",
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
const out = await sdk.compute.startTranscribe({
|
|
91
|
+
jobId: "tj_ABC",
|
|
92
|
+
model: "transcribe:aws-fr-standard",
|
|
93
|
+
durationSec: 120,
|
|
94
|
+
languageCode: "fr-FR",
|
|
95
|
+
diarization: false,
|
|
96
|
+
idempotencyKey: "ik_1",
|
|
97
|
+
});
|
|
98
|
+
assert.equal(method, "aithos.compute_transcribe_start");
|
|
99
|
+
assert.equal(params?.job_id, "tj_ABC");
|
|
100
|
+
assert.equal(params?.model, "transcribe:aws-fr-standard");
|
|
101
|
+
assert.equal(params?.duration_sec, 120);
|
|
102
|
+
assert.equal(params?.language_code, "fr-FR");
|
|
103
|
+
assert.equal(params?.diarization, false);
|
|
104
|
+
assert.equal(params?.idempotency_key, "ik_1");
|
|
105
|
+
assert.ok(typeof params?.mandate_id === "string" && params.mandate_id.length > 0);
|
|
106
|
+
assert.deepEqual(out, {
|
|
107
|
+
jobId: "tj_ABC",
|
|
108
|
+
status: "running",
|
|
109
|
+
estimatedCredits: 240,
|
|
110
|
+
walletBalance: 49_760,
|
|
111
|
+
fundedBy: "purchase",
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe("compute.getTranscribeStatus", () => {
|
|
116
|
+
it("maps a running response", async () => {
|
|
117
|
+
const sdk = await makeSdk(async () => jsonRpc({ job_id: "tj_ABC", status: "running", elapsed_sec: 12 }));
|
|
118
|
+
const out = await sdk.compute.getTranscribeStatus({ jobId: "tj_ABC" });
|
|
119
|
+
assert.deepEqual(out, { jobId: "tj_ABC", status: "running", elapsedSec: 12 });
|
|
120
|
+
});
|
|
121
|
+
it("maps a completed response (duration_sec_actual -> durationSec)", async () => {
|
|
122
|
+
const sdk = await makeSdk(async () => jsonRpc({
|
|
123
|
+
job_id: "tj_ABC",
|
|
124
|
+
status: "completed",
|
|
125
|
+
text: "Bonjour le monde",
|
|
126
|
+
segments: [{ start_sec: 0, end_sec: 1.4, text: "Bonjour le monde" }],
|
|
127
|
+
words: [{ start_sec: 0, end_sec: 0.4, content: "Bonjour", confidence: 0.99 }],
|
|
128
|
+
duration_sec_actual: 127,
|
|
129
|
+
language_code: "fr-FR",
|
|
130
|
+
creditsCharged: 254,
|
|
131
|
+
walletBalance: 49_746,
|
|
132
|
+
auditId: "audit_1",
|
|
133
|
+
fundedBy: "purchase",
|
|
134
|
+
}));
|
|
135
|
+
const out = await sdk.compute.getTranscribeStatus({ jobId: "tj_ABC" });
|
|
136
|
+
assert.equal(out.status, "completed");
|
|
137
|
+
if (out.status !== "completed")
|
|
138
|
+
return;
|
|
139
|
+
assert.equal(out.text, "Bonjour le monde");
|
|
140
|
+
assert.equal(out.durationSec, 127);
|
|
141
|
+
assert.equal(out.languageCode, "fr-FR");
|
|
142
|
+
assert.equal(out.creditsCharged, 254);
|
|
143
|
+
assert.equal(out.auditId, "audit_1");
|
|
144
|
+
assert.equal(out.fundedBy, "purchase");
|
|
145
|
+
assert.equal(out.segments.length, 1);
|
|
146
|
+
assert.equal(out.words[0]?.content, "Bonjour");
|
|
147
|
+
});
|
|
148
|
+
it("maps a failed response", async () => {
|
|
149
|
+
const sdk = await makeSdk(async () => jsonRpc({
|
|
150
|
+
job_id: "tj_ABC",
|
|
151
|
+
status: "failed",
|
|
152
|
+
error: { code: "transcribe_provider_error", message: "boom" },
|
|
153
|
+
}));
|
|
154
|
+
const out = await sdk.compute.getTranscribeStatus({ jobId: "tj_ABC" });
|
|
155
|
+
assert.equal(out.status, "failed");
|
|
156
|
+
if (out.status !== "failed")
|
|
157
|
+
return;
|
|
158
|
+
assert.equal(out.error.code, "transcribe_provider_error");
|
|
159
|
+
assert.equal(out.error.message, "boom");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe("compute.listPendingTranscribes", () => {
|
|
163
|
+
it("forwards include_completed and maps job summaries", async () => {
|
|
164
|
+
let params;
|
|
165
|
+
const sdk = await makeSdk(async (_url, init) => {
|
|
166
|
+
params = bodyParams(init);
|
|
167
|
+
return jsonRpc({
|
|
168
|
+
jobs: [
|
|
169
|
+
{ job_id: "tj_1", status: "running", created_at: 1717000000, estimated_credits: 240 },
|
|
170
|
+
{ job_id: "tj_2", status: "completed", created_at: 1717000100, creditsCharged: 250 },
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
const out = await sdk.compute.listPendingTranscribes({ includeCompleted: true });
|
|
175
|
+
assert.equal(params?.include_completed, true);
|
|
176
|
+
assert.equal(out.jobs.length, 2);
|
|
177
|
+
assert.deepEqual(out.jobs[0], {
|
|
178
|
+
jobId: "tj_1",
|
|
179
|
+
status: "running",
|
|
180
|
+
createdAt: 1717000000,
|
|
181
|
+
estimatedCredits: 240,
|
|
182
|
+
});
|
|
183
|
+
assert.equal(out.jobs[1]?.creditsCharged, 250);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
//# sourceMappingURL=transcribe.test.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aithos/sdk",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.44",
|
|
4
4
|
"description": "Aithos SDK — high-level TypeScript developer kit for building agentic apps on the Aithos protocol. Wraps @aithos/protocol-client and exposes the Aithos compute proxy and wallet (Stripe top-up) endpoints.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"aithos",
|