ql-agent-sdk 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,7 +74,7 @@ Create keys in your Quanton Labs dashboard → **API Keys** page. Each key is sc
74
74
 
75
75
  | Scope | Grants access to |
76
76
  |-------|-----------------|
77
- | `workspace` | `ql.workspace.get()` |
77
+ | `workspace` | `ql.workspace.get()`, `ql.workspace.getModels()`, `ql.workspace.update()`, `ql.workspace.updateModels()`, `ql.workspace.listCatalogModels()`, `ql.workspace.verifyOpenRouterKey()`, `ql.workspace.listKeys()` |
78
78
  | `agents` | `ql.agents.list()`, `ql.agents.get()` |
79
79
  | `capabilities` | `ql.agents.capabilities()`, `ql.agents.runCapability()` |
80
80
  | `chat` | `ql.agents.chat()`, `ql.agents.chatStream()` |
@@ -133,6 +133,53 @@ ws.plan; // "standard" | "premium" | "enterprise" | undefined
133
133
  ws.createdAt; // string (ISO 8601)
134
134
  ```
135
135
 
136
+ #### `ql.workspace.getModels()` → `Promise<WorkspaceModelsResult>`
137
+
138
+ **Scope:** `workspace`
139
+
140
+ Returns the per-role model configuration (director panel): defaults for
141
+ `reasoning`, `instruct`, `response`, `vision`, `image`, `video` plus ordered
142
+ fallback lists. The OpenRouter key is always masked (`keyMasked`).
143
+
144
+ #### `ql.workspace.update(input)` / `ql.workspace.updateModels(input)` → `Promise<Workspace>`
145
+
146
+ **Scope:** `workspace`
147
+
148
+ Partially updates the workspace. `update()` accepts `{ name?, config?: { openrouter? } }`;
149
+ `updateModels()` is a shortcut that only touches `config.openrouter`. Model fields may be
150
+ set to `""` to clear a default; each `fallbacks.<role>` list holds at most 3 model ids.
151
+
152
+ ```ts
153
+ await ql.workspace.update({ name: "Robinson Mfg. Co., Inc." });
154
+ await ql.workspace.updateModels({
155
+ reasoning: "deepseek/deepseek-v4-flash-0731",
156
+ vision: "anthropic/claude-sonnet-5.5",
157
+ fallbacks: { reasoning: ["openai/gpt-oss-20b"] },
158
+ });
159
+ ```
160
+
161
+ #### `ql.workspace.listCatalogModels()` → `Promise<CatalogResult>`
162
+
163
+ **Scope:** `workspace`
164
+
165
+ OpenRouter model catalog (`{ id, name, contextLength, promptPrice, completionPrice }[]`),
166
+ cached server-side for 10 minutes. May return `stale: true` when upstream is down.
167
+
168
+ #### `ql.workspace.verifyOpenRouterKey(key, model?, baseUrl?)` → `Promise<VerifyKeyResult>`
169
+
170
+ **Scope:** `workspace`
171
+
172
+ Checks an OpenRouter key with a 1-token completion (default model
173
+ `openai/gpt-oss-20b`). Returns `{ valid: true }` or `{ valid: false, error }`.
174
+
175
+ #### `ql.workspace.listKeys()` → `Promise<KeysResult>`
176
+
177
+ **Scope:** `workspace`
178
+
179
+ Lists the workspace's v1 API keys with rotation metadata. Every key is auto-deleted
180
+ `lifetimeDays` (90) days after creation; `rotatesAt`/`daysLeft` show time remaining.
181
+ Mint replacements from the Quanton Labs site when one expires.
182
+
136
183
  ---
137
184
 
138
185
  ### Agents
@@ -633,6 +680,127 @@ try {
633
680
 
634
681
  ---
635
682
 
683
+ ### Videos (Tiered Generation)
684
+
685
+ Async video generation: submit a job, then poll until it completes. Reference images may be base64 data URLs or https URLs; reference (style) videos must be https URLs — upload local files with `ql.files.upload` first.
686
+
687
+ #### `ql.videos.listModels()` → `Promise<VideoModelsResult>`
688
+
689
+ Lists the tiered video models plus runtime engine availability: `{ models: VideoModelInfo[], engine: { available, detail } }`. Each model reports its tier, supported aspect ratios/resolutions, duration range, reference-image/video limits, voiceover support, and pricing.
690
+
691
+ **Example:**
692
+ ```ts
693
+ const { models, engine } = await ql.videos.listModels();
694
+ if (!engine.available) console.warn("Video engine unavailable:", engine.detail);
695
+ const recommended = models.find((m) => m.recommended && m.available);
696
+ ```
697
+
698
+ #### `ql.videos.generate(agentId, input)` → `Promise<VideoTaskCreated>`
699
+
700
+ Starts a video job (HTTP 202) and returns `{ taskId, status: "queued" }`.
701
+
702
+ | Field | Description |
703
+ |-------|-------------|
704
+ | `prompt` | The creative brief / script for the video |
705
+ | `model`? | Explicit registry model id (wins over `tier`) |
706
+ | `tier`? | Tier to resolve a model from when `model` is omitted |
707
+ | `images_list`? | Product/reference images — base64 data URLs or https URLs (order defines IMG_0..N) |
708
+ | `video_files`? | Reference (style) videos — https URLs only |
709
+ | `aspect_ratio`? | e.g. `"16:9"`, `"9:16"`, `"1:1"` (default `"16:9"`) |
710
+ | `duration`? | Seconds, within the model's `durationRange` |
711
+ | `resolution`? | e.g. `"720p"` / `"1080p"` (default `"720p"`) |
712
+ | `generate_audio`? | Bake a voiceover into the render (requires `narration`) |
713
+ | `narration`? | Voiceover text (max 3000 chars) |
714
+
715
+ **Example:**
716
+ ```ts
717
+ const created = await ql.videos.generate("marketing", {
718
+ prompt: "A 10-second product reel for the new sneaker drop, studio lighting.",
719
+ model: "motion/reel",
720
+ images_list: ["data:image/png;base64,..."], // or https URLs
721
+ video_files: ["https://cdn.example.com/style-ref.mp4"],
722
+ aspect_ratio: "9:16",
723
+ duration: 10,
724
+ resolution: "1080p",
725
+ generate_audio: true,
726
+ narration: "Introducing the all-new AirFlex.",
727
+ });
728
+ ```
729
+
730
+ #### `ql.videos.getTask(taskId, signal?)` → `Promise<VideoTask>`
731
+
732
+ Fetches the current job snapshot: `{ taskId, status, progress?, outputUrl?, mime?, ext?, durationSec?, error?, model?, tier?, latencyMs?, createdAt, completedAt }`.
733
+
734
+ #### `ql.videos.cancelTask(taskId)` → `Promise<VideoTask>`
735
+
736
+ Cancels a queued/rendering job and returns the updated snapshot. Throws a `QLError` with code `"conflict"` (HTTP 409) if the job is already terminal.
737
+
738
+ #### `ql.videos.handle(taskId)` → `VideoTaskHandle`
739
+
740
+ Same `poll()` / `wait(opts?)` pattern as `TaskHandle`, polling `/api/v1/video-tasks/:taskId`. Terminal statuses: `completed` (check `outputUrl`), `failed` (check `error`), `cancelled`. Wait options and non-terminal exits (`timeout` / `aborted`) are identical to tasks.
741
+
742
+ **Example:**
743
+ ```ts
744
+ const done = await ql.videos.handle(created.taskId).wait({
745
+ intervalMs: 2000,
746
+ timeoutMs: 10 * 60_000,
747
+ onTick: (job) => {
748
+ if (job.progress?.message) process.stdout.write(`\r${job.progress.message}`);
749
+ },
750
+ });
751
+ if (done.status === "completed" && done.outputUrl) {
752
+ console.log("Video ready:", `${ql.baseUrl}${done.outputUrl}`);
753
+ }
754
+ ```
755
+
756
+ ### Audio (Text-to-Speech)
757
+
758
+ #### `ql.audio.voices()` → `Promise<VoicesResult>`
759
+
760
+ Lists available TTS voices: `{ voices: { id, name }[], source: "live" | "none", note? }`. `source === "none"` means no provider key is configured server-side.
761
+
762
+ #### `ql.audio.textToSpeech(agentId, input)` → `Promise<TtsResult>`
763
+
764
+ Synchronous synthesis (no polling). Returns `{ url, voiceId, modelId, bytes, latencyMs }` where `url` is a served media path (e.g. `/generated/tts-….mp3`).
765
+
766
+ | Field | Description |
767
+ |-------|-------------|
768
+ | `text` | Text to synthesize (max 3000 chars) |
769
+ | `voiceId`? | Voice id from `ql.audio.voices()` (defaults to the platform voice) |
770
+ | `stability`? | Voice stability 0..1 (default 0.5) |
771
+ | `similarityBoost`? | Similarity boost 0..1 (default 0.75) |
772
+
773
+ **Example:**
774
+ ```ts
775
+ const { voices } = await ql.audio.voices();
776
+ const result = await ql.audio.textToSpeech("marketing", {
777
+ text: "Say hello to the all-new AirFlex.",
778
+ voiceId: voices[0]?.id,
779
+ });
780
+ console.log(`${ql.baseUrl}${result.url}`);
781
+ ```
782
+
783
+ ### Files (Media Upload)
784
+
785
+ #### `ql.files.upload(file, opts?)` → `Promise<UploadedFile>`
786
+
787
+ Uploads a media file as multipart form data (field `"file"`) and returns `{ url, filename, size, contentType }`. The returned `url` is a stable served path (e.g. `/uploads/<workspace>/<file>`) that can be used directly as a media input URL — e.g. in `videos.generate({ video_files: [res.url] })`.
788
+
789
+ | Option | Description |
790
+ |--------|-------------|
791
+ | `filename`? | Override the stored display name (extension is kept from the file) |
792
+ | `onProgress`? | Coarse progress callback — fetch does not expose byte progress, so it reports 0 at start and 100 on completion only |
793
+
794
+ **Example:**
795
+ ```ts
796
+ const blob = await file.arrayBuffer(); // or any Blob source
797
+ const uploaded = await ql.files.upload(new Blob([blob], { type: "video/mp4" }), {
798
+ filename: "style-ref.mp4",
799
+ });
800
+ ```
801
+
802
+ ---
803
+
636
804
  ## Error Handling
637
805
 
638
806
  All API failures throw a typed **`QLError`**:
package/dist/index.cjs CHANGED
@@ -22,12 +22,16 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  API_ERROR_CODES: () => API_ERROR_CODES,
24
24
  AgentsApi: () => AgentsApi,
25
+ AudioApi: () => AudioApi,
25
26
  DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
27
+ FilesApi: () => FilesApi,
26
28
  ImagesApi: () => ImagesApi,
27
29
  QLClient: () => QLClient,
28
30
  QLError: () => QLError,
29
31
  TaskHandle: () => TaskHandle,
30
32
  TasksApi: () => TasksApi,
33
+ VideoTaskHandle: () => VideoTaskHandle,
34
+ VideosApi: () => VideosApi,
31
35
  WorkflowsApi: () => WorkflowsApi,
32
36
  WorkspaceApi: () => WorkspaceApi,
33
37
  isTerminalTaskStatus: () => isTerminalTaskStatus
@@ -135,6 +139,12 @@ function createTransport(opts) {
135
139
  if (!res.ok) throw toHttpError(res.status, text, { method, url: res.url });
136
140
  return unwrap(text, { method, url: res.url });
137
141
  },
142
+ async form(path, reqOpts) {
143
+ const res = await doFormFetch(path, reqOpts);
144
+ const text = await res.text();
145
+ if (!res.ok) throw toHttpError(res.status, text, { method: "POST", url: res.url });
146
+ return unwrap(text, { method: "POST", url: res.url });
147
+ },
138
148
  async stream(method, path, reqOpts = {}) {
139
149
  const res = await doFetch(method, path, {
140
150
  ...reqOpts,
@@ -175,6 +185,22 @@ function createTransport(opts) {
175
185
  throw toNetworkError(err, { method, url });
176
186
  });
177
187
  }
188
+ function doFormFetch(path, reqOpts) {
189
+ const url = buildUrl(baseUrl, path);
190
+ const headers = {
191
+ authorization: `Bearer ${apiKey}`,
192
+ accept: "application/json",
193
+ ...reqOpts.headers ?? {}
194
+ };
195
+ return fetchImpl(url, {
196
+ method: "POST",
197
+ headers,
198
+ body: reqOpts.form,
199
+ signal: reqOpts.signal
200
+ }).catch((err) => {
201
+ throw toNetworkError(err, { method: "POST", url });
202
+ });
203
+ }
178
204
  }
179
205
  function unwrap(text, ctx) {
180
206
  const parsed = safeParse(text);
@@ -313,6 +339,27 @@ var TERMINAL_TASK_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "ca
313
339
  function isTerminalTaskStatus(status) {
314
340
  return TERMINAL_TASK_STATUSES.has(status);
315
341
  }
342
+ async function pollUntilTerminal(fetchSnapshot, isTerminal, onTick, opts) {
343
+ const intervalMs = opts.intervalMs ?? 2e3;
344
+ const maxIntervalMs = opts.maxIntervalMs ?? 15e3;
345
+ const signal = opts.signal;
346
+ const startedAt = Date.now();
347
+ let delay = intervalMs;
348
+ for (; ; ) {
349
+ if (signal?.aborted) throw abortError();
350
+ if (opts.timeoutMs !== void 0 && Date.now() - startedAt >= opts.timeoutMs) {
351
+ throw timeoutError(opts.timeoutMs);
352
+ }
353
+ const snapshot = await fetchSnapshot(signal);
354
+ onTick?.(snapshot);
355
+ if (isTerminal(snapshot)) return snapshot;
356
+ if (opts.timeoutMs !== void 0 && Date.now() - startedAt >= opts.timeoutMs) {
357
+ throw timeoutError(opts.timeoutMs);
358
+ }
359
+ await sleep(delay, signal);
360
+ delay = Math.min(maxIntervalMs, Math.round(delay * 1.5));
361
+ }
362
+ }
316
363
  var TaskHandle = class {
317
364
  constructor(transport, taskId) {
318
365
  this.transport = transport;
@@ -326,33 +373,37 @@ var TaskHandle = class {
326
373
  poll(signal) {
327
374
  return this.get(signal);
328
375
  }
329
- /**
330
- * Poll until the task reaches a terminal status.
331
- *
332
- * - Uses exponential-ish backoff from `intervalMs` up to `maxIntervalMs`.
333
- * - Throws a QLError with code "timeout" if `timeoutMs` elapses first.
334
- * - Throws a QLError with code "aborted" if `signal` aborts.
335
- */
376
+ /** Poll until the task reaches a terminal status (see pollUntilTerminal for semantics). */
336
377
  async wait(opts = {}) {
337
- const intervalMs = opts.intervalMs ?? 2e3;
338
- const maxIntervalMs = opts.maxIntervalMs ?? 15e3;
339
- const signal = opts.signal;
340
- const startedAt = Date.now();
341
- let delay = intervalMs;
342
- for (; ; ) {
343
- if (signal?.aborted) throw abortError();
344
- if (opts.timeoutMs !== void 0 && Date.now() - startedAt >= opts.timeoutMs) {
345
- throw timeoutError(opts.timeoutMs);
346
- }
347
- const task = await this.get(signal);
348
- opts.onTick?.(task);
349
- if (isTerminalTaskStatus(task.status)) return task;
350
- if (opts.timeoutMs !== void 0 && Date.now() - startedAt >= opts.timeoutMs) {
351
- throw timeoutError(opts.timeoutMs);
352
- }
353
- await sleep(delay, signal);
354
- delay = Math.min(maxIntervalMs, Math.round(delay * 1.5));
355
- }
378
+ return pollUntilTerminal(
379
+ (signal) => this.get(signal),
380
+ (task) => isTerminalTaskStatus(task.status),
381
+ opts.onTick,
382
+ opts
383
+ );
384
+ }
385
+ };
386
+ var VideoTaskHandle = class {
387
+ constructor(transport, taskId) {
388
+ this.transport = transport;
389
+ this.taskId = taskId;
390
+ }
391
+ /** Fetch the current job snapshot (single request). */
392
+ get(signal) {
393
+ return this.transport.json("GET", `/api/v1/video-tasks/${encodeURIComponent(this.taskId)}`, { signal });
394
+ }
395
+ /** Alias of `get()` — perform one poll and return the latest snapshot. */
396
+ poll(signal) {
397
+ return this.get(signal);
398
+ }
399
+ /** Poll until the job reaches a terminal status (completed/failed/cancelled). */
400
+ async wait(opts = {}) {
401
+ return pollUntilTerminal(
402
+ (signal) => this.get(signal),
403
+ (task) => isTerminalTaskStatus(task.status),
404
+ opts.onTick,
405
+ opts
406
+ );
356
407
  }
357
408
  };
358
409
  function sleep(ms, signal) {
@@ -387,6 +438,30 @@ var WorkspaceApi = class {
387
438
  get() {
388
439
  return this.t.json("GET", "/api/v1/workspace");
389
440
  }
441
+ /** GET /api/v1/workspace/models — current model config (OpenRouter key masked). */
442
+ getModels() {
443
+ return this.t.json("GET", "/api/v1/workspace/models");
444
+ }
445
+ /** PATCH /api/v1/workspace — update the workspace name and/or OpenRouter model config (partial). */
446
+ update(input) {
447
+ return this.t.json("PATCH", "/api/v1/workspace", { body: input });
448
+ }
449
+ /** PATCH /api/v1/workspace — convenience wrapper that only touches `config.openrouter`. */
450
+ updateModels(input) {
451
+ return this.t.json("PATCH", "/api/v1/workspace", { body: { config: { openrouter: input } } });
452
+ }
453
+ /** GET /api/v1/workspace/models/catalog — OpenRouter model catalog (server-side cached). */
454
+ listCatalogModels() {
455
+ return this.t.json("GET", "/api/v1/workspace/models/catalog");
456
+ }
457
+ /** POST /api/v1/workspace/models/verify — check an OpenRouter key with a 1-token completion. */
458
+ verifyOpenRouterKey(key, model, baseUrl) {
459
+ return this.t.json("POST", "/api/v1/workspace/models/verify", { body: { key, model, baseUrl } });
460
+ }
461
+ /** GET /api/v1/workspace/keys — API keys with rotation metadata (rotatesAt, daysLeft). */
462
+ listKeys() {
463
+ return this.t.json("GET", "/api/v1/workspace/keys");
464
+ }
390
465
  };
391
466
  var AgentsApi = class {
392
467
  constructor(t) {
@@ -490,6 +565,62 @@ var ImagesApi = class {
490
565
  return this.t.json("POST", `/api/v1/agents/${encodeURIComponent(agentId)}/images`, { body: input });
491
566
  }
492
567
  };
568
+ var VideosApi = class {
569
+ constructor(t) {
570
+ this.t = t;
571
+ }
572
+ /** GET /api/v1/videos/models — list tiered video models + engine availability. */
573
+ listModels() {
574
+ return this.t.json("GET", "/api/v1/videos/models");
575
+ }
576
+ /** POST /api/v1/agents/:agentId/videos — start a video job (returns the queued task, HTTP 202). */
577
+ generate(agentId, input) {
578
+ return this.t.json("POST", `/api/v1/agents/${encodeURIComponent(agentId)}/videos`, { body: input });
579
+ }
580
+ /** GET /api/v1/video-tasks/:taskId — fetch the current job snapshot. */
581
+ getTask(taskId, signal) {
582
+ return this.t.json("GET", `/api/v1/video-tasks/${encodeURIComponent(taskId)}`, { signal });
583
+ }
584
+ /** DELETE /api/v1/video-tasks/:taskId — cancel a queued/rendering job (409 if already terminal). */
585
+ cancelTask(taskId) {
586
+ return this.t.json("DELETE", `/api/v1/video-tasks/${encodeURIComponent(taskId)}`);
587
+ }
588
+ /** Get a handle for polling / waiting on a video job (poll(), wait()). */
589
+ handle(taskId) {
590
+ return new VideoTaskHandle(this.t, taskId);
591
+ }
592
+ };
593
+ var AudioApi = class {
594
+ constructor(t) {
595
+ this.t = t;
596
+ }
597
+ /** GET /api/v1/audio/voices — list available TTS voices. */
598
+ voices() {
599
+ return this.t.json("GET", "/api/v1/audio/voices");
600
+ }
601
+ /** POST /api/v1/agents/:agentId/tts — synthesize speech (synchronous). */
602
+ textToSpeech(agentId, input) {
603
+ return this.t.json("POST", `/api/v1/agents/${encodeURIComponent(agentId)}/tts`, { body: input });
604
+ }
605
+ };
606
+ var FilesApi = class {
607
+ constructor(t) {
608
+ this.t = t;
609
+ }
610
+ /** POST /api/v1/files — upload a media file (multipart field "file"). */
611
+ async upload(file, opts = {}) {
612
+ const form = new FormData();
613
+ if (opts.filename !== void 0) {
614
+ form.append("file", file, opts.filename);
615
+ } else {
616
+ form.append("file", file);
617
+ }
618
+ opts.onProgress?.(0);
619
+ const result = await this.t.form("/api/v1/files", { form });
620
+ opts.onProgress?.(100);
621
+ return result;
622
+ }
623
+ };
493
624
  function readEnvBaseUrl() {
494
625
  try {
495
626
  if (typeof process !== "undefined" && process?.env) {
@@ -512,6 +643,9 @@ var QLClient = class {
512
643
  this.workflows = new WorkflowsApi(this.transport);
513
644
  this.tasks = new TasksApi(this.transport);
514
645
  this.images = new ImagesApi(this.transport);
646
+ this.videos = new VideosApi(this.transport);
647
+ this.audio = new AudioApi(this.transport);
648
+ this.files = new FilesApi(this.transport);
515
649
  }
516
650
  /** The normalized base URL in use (not secret). */
517
651
  get baseUrl() {