litevision 0.1.1 → 0.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.
Files changed (3) hide show
  1. package/README.md +25 -11
  2. package/dist/cli.js +1879 -387
  3. package/package.json +4 -2
package/dist/cli.js CHANGED
@@ -14154,6 +14154,7 @@ function defaultDirs(home = homedir()) {
14154
14154
  var fetchTransport = (input, init) => fetch(input, init);
14155
14155
 
14156
14156
  // src/server.ts
14157
+ import { randomUUID as randomUUID3 } from "node:crypto";
14157
14158
  import { resolve as resolve2 } from "node:path";
14158
14159
 
14159
14160
  // node_modules/zod/v3/helpers/util.js
@@ -21697,6 +21698,23 @@ var EMPTY_COMPLETION_RESULT = {
21697
21698
  // src/config.ts
21698
21699
  import { readFile } from "node:fs/promises";
21699
21700
  import { join as join2 } from "node:path";
21701
+
21702
+ // src/protocols/protocol.ts
21703
+ var aspectRatios = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"];
21704
+ var modalities = ["image", "video"];
21705
+ function formatSize(size) {
21706
+ return `${size.width}x${size.height}`;
21707
+ }
21708
+
21709
+ class ProviderError extends Error {
21710
+ kind;
21711
+ constructor(kind, message) {
21712
+ super(message);
21713
+ this.kind = kind;
21714
+ }
21715
+ }
21716
+
21717
+ // src/config.ts
21700
21718
  var providerSchema = object({
21701
21719
  id: string2().min(1),
21702
21720
  baseUrl: string2().url()
@@ -21713,13 +21731,15 @@ var modelSchema = object({
21713
21731
  provider: string2().min(1),
21714
21732
  modelId: string2().min(1),
21715
21733
  protocol: string2().min(1),
21734
+ modality: _enum(modalities).default("image"),
21716
21735
  notes: string2().optional(),
21717
21736
  notesHistory: notesHistorySchema.optional()
21718
21737
  });
21719
21738
  var configSchema = object({
21720
21739
  providers: array(providerSchema).default([]),
21721
21740
  models: array(modelSchema).default([]),
21722
- defaultImageModel: string2().optional()
21741
+ defaultImageModel: string2().optional(),
21742
+ defaultVideoModel: string2().optional()
21723
21743
  });
21724
21744
  var credentialsSchema = record(string2(), object({ apiKey: string2().min(1) }));
21725
21745
 
@@ -21760,23 +21780,31 @@ async function requireCredential(dirs, provider) {
21760
21780
  throw needsSettings(`Provider "${provider.id}" has no Credential saved.`);
21761
21781
  return credential;
21762
21782
  }
21763
- function defaultModelAlias(config) {
21764
- const wanted = config.defaultImageModel;
21765
- return config.models.some((model) => model.alias === wanted) ? wanted : undefined;
21783
+ var defaultKey = { image: "defaultImageModel", video: "defaultVideoModel" };
21784
+ function defaultModelAlias(config, modality = "image") {
21785
+ const wanted = config[defaultKey[modality]];
21786
+ return config.models.some((model) => model.alias === wanted && model.modality === modality) ? wanted : undefined;
21766
21787
  }
21767
- function resolveModel(config, alias) {
21768
- if (config.models.length === 0)
21769
- throw needsSettings("litevision has no image Model configured yet.");
21770
- const known = config.models.map((model) => model.alias).join(", ");
21771
- const wanted = alias ?? config.defaultImageModel;
21788
+ function resolveModel(config, alias, modality = "image") {
21789
+ const ofModality = config.models.filter((model) => model.modality === modality);
21790
+ const wanted = alias ?? config[defaultKey[modality]];
21791
+ const model = config.models.find((candidate) => candidate.alias === wanted);
21792
+ if (ofModality.length === 0 && (alias === undefined || model === undefined))
21793
+ throw needsSettings(`litevision has no ${modality} Model configured yet.`);
21794
+ const known = ofModality.map((candidate) => candidate.alias).join(", ");
21772
21795
  if (wanted === undefined) {
21773
- throw needsSettings(`No default image Model is set. Pass \`model\` with one of: ${known}. Or:`);
21796
+ throw needsSettings(`No default ${modality} Model is set. Pass \`model\` with one of: ${known}. Or:`);
21774
21797
  }
21775
- const model = config.models.find((candidate) => candidate.alias === wanted);
21776
21798
  if (model === undefined) {
21777
21799
  if (alias === undefined)
21778
- throw needsSettings(`The default image Model "${wanted}" does not exist. Known Aliases: ${known}.`);
21779
- throw new ConfigError(`Unknown Model Alias "${wanted}". Known Aliases: ${known}.`);
21800
+ throw needsSettings(`The default ${modality} Model "${wanted}" does not exist. Known Aliases: ${known}.`);
21801
+ throw new ConfigError(`Unknown Model Alias "${wanted}". Known ${modality} Model Aliases: ${known}.`);
21802
+ }
21803
+ if (model.modality !== modality) {
21804
+ if (alias === undefined)
21805
+ throw needsSettings(`The default ${modality} Model "${wanted}" makes ${model.modality}s. Pass \`model\` with one of: ${known}. Or:`);
21806
+ const tool = model.modality === "video" ? "generate_video" : "generate_image";
21807
+ throw new ConfigError(`Model "${model.alias}" generates ${model.modality}s, not ${modality}s. Use \`${tool}\` for it${ofModality.length === 0 ? "" : `, or one of these ${modality} Models: ${known}`}.`);
21780
21808
  }
21781
21809
  const provider = config.providers.find((candidate) => candidate.id === model.provider);
21782
21810
  if (provider === undefined) {
@@ -21790,6 +21818,9 @@ import { appendFile, mkdir, readFile as readFile2 } from "node:fs/promises";
21790
21818
  import { join as join3 } from "node:path";
21791
21819
  var verdicts = ["good", "bad"];
21792
21820
  var ratingReasonMaxLength = 200;
21821
+ function ratingOf({ ratings }, result) {
21822
+ return ratings.find((rating) => rating.result === result);
21823
+ }
21793
21824
 
21794
21825
  class RatingError extends Error {
21795
21826
  kind;
@@ -21799,6 +21830,15 @@ class RatingError extends Error {
21799
21830
  this.name = "RatingError";
21800
21831
  }
21801
21832
  }
21833
+
21834
+ class PickError extends Error {
21835
+ kind;
21836
+ constructor(kind, message) {
21837
+ super(message);
21838
+ this.kind = kind;
21839
+ this.name = "PickError";
21840
+ }
21841
+ }
21802
21842
  var historyPath = (dirs) => join3(dirs.data, "jobs.jsonl");
21803
21843
  async function appendHistory(dirs, record) {
21804
21844
  await mkdir(dirs.data, { recursive: true });
@@ -21812,8 +21852,11 @@ async function readJob(dirs, id) {
21812
21852
  return (await readJobs(dirs)).find((job) => job.id === id);
21813
21853
  }
21814
21854
  async function readRatedJobs(dirs) {
21815
- const { jobs, ratings } = await readRecords(dirs);
21816
- return jobs.map((job) => ({ job, ...ratings.has(job.id) ? { rating: ratings.get(job.id) } : {} })).sort((a, b) => Date.parse(b.job.createdAt) - Date.parse(a.job.createdAt));
21855
+ const { jobs, ratings, picks } = await readRecords(dirs);
21856
+ return jobs.map((job) => {
21857
+ const pick = job.comparison === undefined ? undefined : picks.get(job.comparison);
21858
+ return { job, ratings: [...ratings.get(job.id)?.values() ?? []], ...pick === undefined ? {} : { pick } };
21859
+ }).sort((a, b) => Date.parse(b.job.createdAt) - Date.parse(a.job.createdAt));
21817
21860
  }
21818
21861
  async function rateJob(dirs, rating, now) {
21819
21862
  const reason = rating.reason.replace(/\s+/g, " ").trim();
@@ -21825,15 +21868,56 @@ async function rateJob(dirs, rating, now) {
21825
21868
  const job = await readJob(dirs, rating.jobId);
21826
21869
  if (job === undefined)
21827
21870
  throw new RatingError("unknown-job", `litevision has no Job "${rating.jobId}".`);
21871
+ const { result } = rating;
21872
+ const problem = whyNotChosen(job, result, "rate");
21873
+ if (problem !== undefined)
21874
+ throw new RatingError(problem.kind, problem.message);
21875
+ const record = {
21876
+ type: "rating",
21877
+ jobId: job.id,
21878
+ ...result === undefined ? {} : { result },
21879
+ verdict: rating.verdict,
21880
+ reason,
21881
+ ratedAt: now.toISOString()
21882
+ };
21883
+ await appendHistory(dirs, record);
21884
+ return record;
21885
+ }
21886
+ async function pickResult(dirs, pick, now) {
21887
+ const ofComparison = (await readJobs(dirs)).filter((job) => job.comparison === pick.comparison);
21888
+ if (ofComparison.length === 0)
21889
+ throw new PickError("unknown-comparison", `litevision has no Comparison "${pick.comparison}".`);
21890
+ const job = ofComparison.find((job) => job.id === pick.jobId);
21891
+ if (job === undefined) {
21892
+ const ids = ofComparison.map((job) => job.id).join(", ");
21893
+ throw new PickError("not-in-comparison", `Job "${pick.jobId}" is not part of Comparison ${pick.comparison}, whose Jobs are ${ids}.`);
21894
+ }
21895
+ const { result } = pick;
21896
+ const problem = whyNotChosen(job, result, "pick");
21897
+ if (problem !== undefined)
21898
+ throw new PickError(problem.kind, problem.message);
21899
+ const record = {
21900
+ type: "pick",
21901
+ comparison: pick.comparison,
21902
+ jobId: job.id,
21903
+ ...result === undefined ? {} : { result },
21904
+ pickedAt: now.toISOString()
21905
+ };
21906
+ await appendHistory(dirs, record);
21907
+ return record;
21908
+ }
21909
+ function whyNotChosen(job, result, act) {
21828
21910
  if (job.outcome === "running") {
21829
- throw new RatingError("not-completed", `Job ${job.id} is still running; it can be rated once it has completed.`);
21911
+ return { kind: "not-completed", message: `Job ${job.id} is still running; it can be ${act === "rate" ? "rated" : "picked"} once it has completed.` };
21830
21912
  }
21831
21913
  if (job.outcome === "failed") {
21832
- throw new RatingError("not-completed", `Job ${job.id} failed and saved no images, so there is nothing to rate.`);
21914
+ return { kind: "not-completed", message: `Job ${job.id} failed and saved no images, so there is nothing to ${act}.` };
21833
21915
  }
21834
- const record = { type: "rating", jobId: job.id, verdict: rating.verdict, reason, ratedAt: now.toISOString() };
21835
- await appendHistory(dirs, record);
21836
- return record;
21916
+ if (result !== undefined && !(Number.isInteger(result) && result >= 1 && result <= job.files.length)) {
21917
+ const count = job.files.length === 1 ? "1 result" : `${job.files.length} results`;
21918
+ return { kind: "invalid", message: `Job ${job.id} has ${count}, numbered from 1 in the order of its files.` };
21919
+ }
21920
+ return;
21837
21921
  }
21838
21922
  async function readRecords(dirs) {
21839
21923
  let text;
@@ -21841,11 +21925,12 @@ async function readRecords(dirs) {
21841
21925
  text = await readFile2(historyPath(dirs), "utf8");
21842
21926
  } catch (error) {
21843
21927
  if (error.code === "ENOENT")
21844
- return { jobs: [], ratings: new Map };
21928
+ return { jobs: [], ratings: new Map, picks: new Map };
21845
21929
  throw error;
21846
21930
  }
21847
21931
  const jobs = new Map;
21848
21932
  const ratings = new Map;
21933
+ const picks = new Map;
21849
21934
  for (const line of text.split(`
21850
21935
  `)) {
21851
21936
  if (line.trim() === "")
@@ -21857,7 +21942,14 @@ async function readRecords(dirs) {
21857
21942
  continue;
21858
21943
  }
21859
21944
  if (isRatingRecord(record)) {
21860
- ratings.set(record.jobId, record);
21945
+ const ofJob = ratings.get(record.jobId) ?? new Map;
21946
+ ratings.set(record.jobId, ofJob);
21947
+ ofJob.delete(record.result ?? 0);
21948
+ ofJob.set(record.result ?? 0, record);
21949
+ continue;
21950
+ }
21951
+ if (isPickRecord(record)) {
21952
+ picks.set(record.comparison, record);
21861
21953
  continue;
21862
21954
  }
21863
21955
  if (!isJobRecord(record))
@@ -21865,7 +21957,7 @@ async function readRecords(dirs) {
21865
21957
  jobs.delete(record.id);
21866
21958
  jobs.set(record.id, record);
21867
21959
  }
21868
- return { jobs: [...jobs.values()], ratings };
21960
+ return { jobs: [...jobs.values()], ratings, picks };
21869
21961
  }
21870
21962
  function isJobRecord(value) {
21871
21963
  const record = value;
@@ -21873,18 +21965,73 @@ function isJobRecord(value) {
21873
21965
  }
21874
21966
  function isRatingRecord(value) {
21875
21967
  const record = value;
21876
- return typeof record === "object" && record !== null && record.type === "rating" && typeof record.jobId === "string" && verdicts.includes(record.verdict) && typeof record.reason === "string";
21968
+ return typeof record === "object" && record !== null && record.type === "rating" && typeof record.jobId === "string" && verdicts.includes(record.verdict) && typeof record.reason === "string" && (record.result === undefined || Number.isInteger(record.result) && record.result >= 1);
21877
21969
  }
21878
- function knownGoodOptions(jobs, model) {
21879
- const options = {};
21880
- for (const job of jobs) {
21970
+ function isPickRecord(value) {
21971
+ const record = value;
21972
+ return typeof record === "object" && record !== null && record.type === "pick" && typeof record.comparison === "string" && typeof record.jobId === "string" && typeof record.pickedAt === "string" && (record.result === undefined || Number.isInteger(record.result) && record.result >= 1);
21973
+ }
21974
+ function ranOn(job, model) {
21975
+ return job.provider === model.provider && job.protocol === model.protocol && job.modelId === model.modelId;
21976
+ }
21977
+ function recentRatings(jobs, model, limit) {
21978
+ return jobs.filter(({ job }) => ranOn(job, model)).flatMap(({ job, ratings }) => ratings.map((rating) => ({ rating, job }))).sort((a, b) => Date.parse(b.rating.ratedAt) - Date.parse(a.rating.ratedAt)).slice(0, limit);
21979
+ }
21980
+ function recentPicks(jobs, model, limit) {
21981
+ const byId = new Map(jobs.map(({ job }) => [job.id, job]));
21982
+ const entries = jobs.flatMap(({ job, pick }) => {
21983
+ if (pick === undefined || !ranOn(job, model))
21984
+ return [];
21985
+ const picked = byId.get(pick.jobId);
21986
+ return [{ pick, job, ...picked === undefined ? {} : { picked } }];
21987
+ }).sort((a, b) => Date.parse(b.pick.pickedAt) - Date.parse(a.pick.pickedAt));
21988
+ const comparisons = new Set;
21989
+ return entries.filter(({ pick }) => {
21990
+ if (comparisons.size === limit && !comparisons.has(pick.comparison))
21991
+ return false;
21992
+ comparisons.add(pick.comparison);
21993
+ return true;
21994
+ });
21995
+ }
21996
+ function optionsRating({ job, ratings }) {
21997
+ return ratings.findLast((rating) => rating.result === undefined || rating.result === 1 && job.files.length === 1);
21998
+ }
21999
+ function optionsRecord(jobs, model) {
22000
+ const state = new Map;
22001
+ const ratedBad = new Map;
22002
+ const ordered = [...jobs].sort((a, b) => Date.parse(a.job.createdAt) - Date.parse(b.job.createdAt));
22003
+ for (const ratedJob of ordered) {
22004
+ const { job } = ratedJob;
21881
22005
  if (job.outcome !== "completed")
21882
22006
  continue;
21883
- if (job.provider !== model.provider || job.protocol !== model.protocol || job.modelId !== model.modelId)
22007
+ const rating = optionsRating(ratedJob);
22008
+ if (!ranOn(job, model))
21884
22009
  continue;
21885
- Object.assign(options, job.parameters?.providerOptions);
22010
+ for (const [key, value] of Object.entries(job.parameters?.providerOptions ?? {})) {
22011
+ const asJson = JSON.stringify(value);
22012
+ const badValues = ratedBad.get(key) ?? new Map;
22013
+ ratedBad.set(key, badValues);
22014
+ if (rating?.verdict === "bad")
22015
+ badValues.set(asJson, { value, cause: "rating", reason: rating.reason, jobId: job.id });
22016
+ if (rating?.verdict === "good")
22017
+ badValues.delete(asJson);
22018
+ const verdict = badValues.get(asJson);
22019
+ if (verdict !== undefined)
22020
+ state.set(key, { value, suspect: verdict });
22021
+ else if (job.mismatch !== undefined && rating?.verdict !== "good") {
22022
+ state.set(key, { value, suspect: { value, cause: "mismatch", reason: job.mismatch, jobId: job.id } });
22023
+ } else
22024
+ state.set(key, { value });
22025
+ }
21886
22026
  }
21887
- return options;
22027
+ const record = { knownGood: {}, suspect: {} };
22028
+ for (const [key, { value, suspect }] of state) {
22029
+ if (suspect === undefined)
22030
+ record.knownGood[key] = value;
22031
+ else
22032
+ record.suspect[key] = suspect;
22033
+ }
22034
+ return record;
21888
22035
  }
21889
22036
 
21890
22037
  // src/jobs.ts
@@ -21929,18 +22076,261 @@ async function readReferences(paths, cwd) {
21929
22076
  }));
21930
22077
  }
21931
22078
 
21932
- // src/protocols/protocol.ts
21933
- var aspectRatios = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"];
21934
- function formatSize(size) {
21935
- return `${size.width}x${size.height}`;
22079
+ // src/media.ts
22080
+ function fileTypeOf(bytes) {
22081
+ const image = imageTypeOf(bytes);
22082
+ if (image !== undefined)
22083
+ return image;
22084
+ if (ascii(bytes, 4, 8) === "ftyp") {
22085
+ const brand = ascii(bytes, 8, 12);
22086
+ if (brand === "avif" || brand === "avis")
22087
+ return { extension: "avif", mimeType: "image/avif" };
22088
+ if (["heic", "heix", "heim", "heis", "mif1", "msf1"].includes(brand))
22089
+ return { extension: "heic", mimeType: "image/heic" };
22090
+ return brand.startsWith("qt") ? { extension: "mov", mimeType: "video/quicktime" } : { extension: "mp4", mimeType: "video/mp4" };
22091
+ }
22092
+ if (bytes[0] === 26 && bytes[1] === 69 && bytes[2] === 223 && bytes[3] === 163)
22093
+ return { extension: "webm", mimeType: "video/webm" };
22094
+ return;
22095
+ }
22096
+ function mediaFactsOf(bytes) {
22097
+ try {
22098
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
22099
+ switch (fileTypeOf(bytes)?.extension) {
22100
+ case "png":
22101
+ return { width: view.getUint32(16), height: view.getUint32(20) };
22102
+ case "gif":
22103
+ return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
22104
+ case "jpg":
22105
+ return jpegSize(bytes, view);
22106
+ case "webp":
22107
+ return webpSize(bytes, view);
22108
+ case "mp4":
22109
+ case "mov":
22110
+ return mp4Facts(bytes, view);
22111
+ default:
22112
+ return;
22113
+ }
22114
+ } catch {
22115
+ return;
22116
+ }
22117
+ }
22118
+ function ascii(bytes, start, end) {
22119
+ return String.fromCharCode(...bytes.subarray(start, end));
22120
+ }
22121
+ function jpegSize(bytes, view) {
22122
+ let offset = 2;
22123
+ let turned = false;
22124
+ while (offset + 9 < bytes.length) {
22125
+ if (bytes[offset] !== 255)
22126
+ return;
22127
+ const marker = bytes[offset + 1];
22128
+ if (marker === 225 && ascii(bytes, offset + 4, offset + 10) === "Exif\x00\x00")
22129
+ turned = exifTurnsQuarter(view, offset + 10);
22130
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
22131
+ const width = view.getUint16(offset + 7);
22132
+ const height = view.getUint16(offset + 5);
22133
+ return turned ? { width: height, height: width } : { width, height };
22134
+ }
22135
+ offset += 2 + view.getUint16(offset + 2);
22136
+ }
22137
+ return;
22138
+ }
22139
+ function exifTurnsQuarter(view, tiff) {
22140
+ try {
22141
+ const little = view.getUint16(tiff) === 18761;
22142
+ const ifd = tiff + view.getUint32(tiff + 4, little);
22143
+ const entries = view.getUint16(ifd, little);
22144
+ for (let index = 0;index < entries; index++) {
22145
+ const entry = ifd + 2 + index * 12;
22146
+ if (view.getUint16(entry, little) !== 274)
22147
+ continue;
22148
+ const orientation = view.getUint16(entry + 8, little);
22149
+ return orientation >= 5 && orientation <= 8;
22150
+ }
22151
+ } catch {}
22152
+ return false;
22153
+ }
22154
+ function webpSize(bytes, view) {
22155
+ const chunk = ascii(bytes, 12, 16);
22156
+ if (chunk === "VP8X") {
22157
+ const read24 = (at) => bytes[at] | bytes[at + 1] << 8 | bytes[at + 2] << 16;
22158
+ return { width: read24(24) + 1, height: read24(27) + 1 };
22159
+ }
22160
+ if (chunk === "VP8 ")
22161
+ return { width: view.getUint16(26, true) & 16383, height: view.getUint16(28, true) & 16383 };
22162
+ if (chunk === "VP8L") {
22163
+ const bits = view.getUint32(21, true);
22164
+ return { width: (bits & 16383) + 1, height: (bits >> 14 & 16383) + 1 };
22165
+ }
22166
+ return;
22167
+ }
22168
+ function mp4Facts(bytes, view) {
22169
+ let seconds;
22170
+ let size;
22171
+ const walk = (start, end) => {
22172
+ let offset = start;
22173
+ while (offset + 8 <= end) {
22174
+ let length = view.getUint32(offset);
22175
+ const type = ascii(bytes, offset + 4, offset + 8);
22176
+ let header = 8;
22177
+ if (length === 1) {
22178
+ length = Number(view.getBigUint64(offset + 8));
22179
+ header = 16;
22180
+ } else if (length === 0) {
22181
+ length = end - offset;
22182
+ }
22183
+ if (length < header)
22184
+ return;
22185
+ const body = offset + header;
22186
+ if (type === "moov" || type === "trak")
22187
+ walk(body, Math.min(offset + length, end));
22188
+ if (type === "mvhd") {
22189
+ const version = bytes[body];
22190
+ const timescale = view.getUint32(body + (version === 1 ? 20 : 12));
22191
+ const duration = version === 1 ? Number(view.getBigUint64(body + 24)) : view.getUint32(body + 16);
22192
+ if (timescale > 0 && duration > 0)
22193
+ seconds = duration / timescale;
22194
+ }
22195
+ if (type === "tkhd" && size === undefined) {
22196
+ const at = body + (bytes[body] === 1 ? 88 : 76);
22197
+ const width = view.getUint32(at) / 65536;
22198
+ const height = view.getUint32(at + 4) / 65536;
22199
+ if (width >= 1 && height >= 1)
22200
+ size = { width: Math.round(width), height: Math.round(height) };
22201
+ }
22202
+ offset += length;
22203
+ }
22204
+ };
22205
+ walk(0, bytes.length);
22206
+ if (size === undefined)
22207
+ return;
22208
+ return { ...size, ...seconds === undefined ? {} : { seconds: Math.round(seconds * 100) / 100 } };
21936
22209
  }
21937
22210
 
21938
- class ProviderError extends Error {
21939
- kind;
21940
- constructor(kind, message) {
21941
- super(message);
21942
- this.kind = kind;
22211
+ // src/protocols/capabilities.ts
22212
+ function describeCapabilities(capabilities) {
22213
+ const ratios = capabilities.sizes.map((size) => `${size.aspectRatio} (${formatSize(size)})`).join(", ");
22214
+ const references = capabilities.maxReferences === 0 ? "no reference images" : `up to ${capabilities.maxReferences} reference image${capabilities.maxReferences === 1 ? "" : "s"}`;
22215
+ return `aspect ratios ${ratios}; up to ${capabilities.maxImages} image${capabilities.maxImages === 1 ? "" : "s"} per Job; ${references}.`;
22216
+ }
22217
+ function closestSize(sizes, wanted) {
22218
+ const exact = sizes.find((size) => size.aspectRatio === wanted);
22219
+ if (exact !== undefined)
22220
+ return { size: exact, adjusted: false };
22221
+ return { size: sizeClosestTo(sizes, ratioOf(wanted)), adjusted: true };
22222
+ }
22223
+ function sizeClosestTo(sizes, ratio) {
22224
+ const target = Math.log(ratio);
22225
+ const distance = (size) => Math.abs(Math.log(size.width / size.height) - target);
22226
+ const [first, ...rest] = sizes;
22227
+ if (first === undefined)
22228
+ throw new Error("A Protocol adapter declared no supported sizes.");
22229
+ return rest.reduce((best, size) => distance(size) < distance(best) ? size : best, first);
22230
+ }
22231
+ function ratioOf(aspectRatio) {
22232
+ const [width, height] = aspectRatio.split(":").map(Number);
22233
+ return width / height;
22234
+ }
22235
+ async function normaliseImageRequest(parameters, model, adapter, described, cwd) {
22236
+ const { capabilities } = described;
22237
+ const unsupported = (problem) => new ConfigError(`${problem} Model "${model.alias}" supports: ${describeCapabilities(capabilities)} Choose another Model or change the request.`);
22238
+ if (parameters.n > capabilities.maxImages) {
22239
+ throw unsupported(`Model "${model.alias}" cannot generate ${parameters.n} images in one Job.`);
22240
+ }
22241
+ if (parameters.references.length > 0 && capabilities.maxReferences === 0) {
22242
+ throw unsupported(`Model "${model.alias}" cannot take reference images.`);
22243
+ }
22244
+ if (parameters.references.length > capabilities.maxReferences) {
22245
+ throw unsupported(`Model "${model.alias}" cannot take ${parameters.references.length} reference images.`);
21943
22246
  }
22247
+ const reserved = Object.keys(parameters.providerOptions).filter((key) => adapter.reservedOptions.includes(key));
22248
+ if (reserved.length > 0) {
22249
+ throw new ConfigError(`provider_options may not set ${reserved.map((key) => `\`${key}\``).join(", ")}: litevision sets ${reserved.length === 1 ? "it" : "them"} from the \`generate_image\` parameters. Use those instead.`);
22250
+ }
22251
+ const { size, adjusted } = closestSize(capabilities.sizes, parameters.aspectRatio);
22252
+ const references = await readReferences(parameters.references, cwd);
22253
+ return {
22254
+ request: {
22255
+ prompt: parameters.prompt,
22256
+ n: parameters.n,
22257
+ size,
22258
+ references,
22259
+ providerOptions: parameters.providerOptions,
22260
+ model: { capabilities, ...described.parameterSchema === undefined ? {} : { parameterSchema: described.parameterSchema } }
22261
+ },
22262
+ requestedAspectRatio: parameters.aspectRatio,
22263
+ aspectRatioAdjusted: adjusted
22264
+ };
22265
+ }
22266
+ function describeVideoCapabilities(capabilities) {
22267
+ const tiers = capabilities.resolutions.map((tier) => {
22268
+ const ratios = tier.sizes.map((size) => `${size.aspectRatio} (${formatSize(size)})`).join(", ");
22269
+ return tier.name === undefined ? `aspect ratios ${ratios}` : `${tier.name}: ${ratios}`;
22270
+ }).join("; ");
22271
+ const { durations } = capabilities;
22272
+ const lengths = isRun(durations) && durations.length > 2 ? `${durations[0]}–${durations.at(-1)} s` : `${durations.join(", ")} s`;
22273
+ const frames = [capabilities.firstFrame && "a first frame", capabilities.lastFrame && "a last frame"].filter(Boolean).join(" and ");
22274
+ const references = capabilities.maxReferences === 0 ? "no reference images" : `up to ${capabilities.maxReferences} reference image${capabilities.maxReferences === 1 ? "" : "s"}`;
22275
+ const named = capabilities.resolutions.some((tier) => tier.name !== undefined);
22276
+ return `${named ? "resolutions " : ""}${tiers}; ${lengths} (default ${capabilities.defaultDuration} s); ${frames || "no first or last frame"}; ${references}.`;
22277
+ }
22278
+ function isRun(values) {
22279
+ return values.every((value, index) => index === 0 || value === values[index - 1] + 1);
22280
+ }
22281
+ async function normaliseVideoRequest(parameters, model, adapter, capabilities, cwd) {
22282
+ const unsupported = (problem) => new ConfigError(`${problem} Model "${model.alias}" supports: ${describeVideoCapabilities(capabilities)} Choose another Model or change the request.`);
22283
+ const [defaultTier] = capabilities.resolutions;
22284
+ if (defaultTier === undefined)
22285
+ throw new Error("A Protocol adapter declared no video resolutions.");
22286
+ let tier = defaultTier;
22287
+ if (parameters.resolution !== undefined) {
22288
+ const wanted = parameters.resolution.toLowerCase();
22289
+ const named = capabilities.resolutions.find((candidate) => candidate.name?.toLowerCase() === wanted);
22290
+ if (named === undefined)
22291
+ throw unsupported(`Model "${model.alias}" has no resolution "${parameters.resolution}".`);
22292
+ tier = named;
22293
+ }
22294
+ if (parameters.firstFrame !== undefined && !capabilities.firstFrame)
22295
+ throw unsupported(`Model "${model.alias}" cannot start from a given first frame.`);
22296
+ if (parameters.lastFrame !== undefined && !capabilities.lastFrame)
22297
+ throw unsupported(`Model "${model.alias}" cannot end on a given last frame.`);
22298
+ if (parameters.references.length > 0 && capabilities.maxReferences === 0)
22299
+ throw unsupported(`Model "${model.alias}" cannot take reference images.`);
22300
+ if (parameters.references.length > capabilities.maxReferences) {
22301
+ throw unsupported(`Model "${model.alias}" cannot take ${parameters.references.length} reference images.`);
22302
+ }
22303
+ const reservedOptions = adapter.reservedVideoOptions ?? [];
22304
+ const reserved = Object.keys(parameters.providerOptions).filter((key) => reservedOptions.includes(key));
22305
+ if (reserved.length > 0) {
22306
+ throw new ConfigError(`provider_options may not set ${reserved.map((key) => `\`${key}\``).join(", ")}: litevision sets ${reserved.length === 1 ? "it" : "them"} from the \`generate_video\` parameters. Use those instead.`);
22307
+ }
22308
+ const frame = async (path) => path === undefined ? undefined : (await readReferences([path], cwd))[0];
22309
+ const [firstFrame, lastFrame, references] = await Promise.all([
22310
+ frame(parameters.firstFrame),
22311
+ frame(parameters.lastFrame),
22312
+ readReferences(parameters.references, cwd)
22313
+ ]);
22314
+ const frameFacts = parameters.aspectRatio === undefined && firstFrame !== undefined ? mediaFactsOf(firstFrame.bytes) : undefined;
22315
+ const { size, adjusted } = frameFacts === undefined ? closestSize(tier.sizes, parameters.aspectRatio ?? "16:9") : { size: sizeClosestTo(tier.sizes, frameFacts.width / frameFacts.height), adjusted: false };
22316
+ const seconds = parameters.duration === undefined ? capabilities.defaultDuration : capabilities.durations.reduce((best, candidate) => Math.abs(candidate - parameters.duration) < Math.abs(best - parameters.duration) ? candidate : best);
22317
+ return {
22318
+ request: {
22319
+ prompt: parameters.prompt,
22320
+ size,
22321
+ ...tier.name === undefined ? {} : { resolution: tier.name },
22322
+ seconds,
22323
+ ...firstFrame === undefined ? {} : { firstFrame },
22324
+ ...lastFrame === undefined ? {} : { lastFrame },
22325
+ references,
22326
+ providerOptions: parameters.providerOptions
22327
+ },
22328
+ requestedAspectRatio: frameFacts === undefined ? parameters.aspectRatio ?? "16:9" : size.aspectRatio,
22329
+ aspectRatioAdjusted: adjusted,
22330
+ followsFirstFrame: frameFacts !== undefined || firstFrame !== undefined && adjusted,
22331
+ ...parameters.duration === undefined ? {} : { requestedDuration: parameters.duration },
22332
+ durationAdjusted: parameters.duration !== undefined && seconds !== parameters.duration
22333
+ };
21944
22334
  }
21945
22335
 
21946
22336
  // src/protocols/errors.ts
@@ -21953,6 +22343,7 @@ var explanations = {
21953
22343
  refusal: "refused the request on content grounds. Rephrase the prompt, change the reference images, or choose another Model.",
21954
22344
  rate_limit: "is rate limiting requests. Wait a little and try again, or choose another Model.",
21955
22345
  unsupported: "does not support this request. Choose another Model or change the request.",
22346
+ rejected_option: "rejected an option in the request. Fix it using what the Provider said and the Model's docs, and try again; if it names the prompt or the number of images, change that instead.",
21956
22347
  other: "failed to complete the Job."
21957
22348
  };
21958
22349
  function providerError(kind, providerId, detail) {
@@ -21977,20 +22368,87 @@ function kindOfStatus(status) {
21977
22368
  return "rate_limit";
21978
22369
  return;
21979
22370
  }
22371
+ function namedParameters(response) {
22372
+ if (response.status !== 400 && response.status !== 422)
22373
+ return [];
22374
+ const message = response.message ?? "";
22375
+ const mentioned = (response.options ?? []).filter((name) => new RegExp(`(^|[^\\w])${escapeRegExp(name)}($|[^\\w])`).test(message));
22376
+ return [...response.param ? [response.param] : [], ...mentioned];
22377
+ }
22378
+ function escapeRegExp(text) {
22379
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22380
+ }
21980
22381
  function classifyProviderError(response) {
21981
22382
  const codes = (response.codes ?? []).filter((code) => typeof code === "string");
21982
- return matchKind(codes) ?? kindOfStatus(response.status) ?? matchKind([response.message ?? ""]) ?? "other";
22383
+ const named = namedParameters(response);
22384
+ const wording = named.reduce((text, name) => text.split(name).join(""), response.message ?? "");
22385
+ return matchKind(codes) ?? kindOfStatus(response.status) ?? matchKind([wording]) ?? (named.length > 0 ? "rejected_option" : "other");
21983
22386
  }
21984
22387
  function httpProviderError(providerId, response) {
21985
22388
  const detail = response.message === undefined ? `HTTP ${response.status}` : `HTTP ${response.status}: ${response.message}`;
21986
22389
  return providerError(classifyProviderError(response), providerId, detail);
21987
22390
  }
21988
- async function sendToProvider(http, providerId, url, init) {
21989
- try {
21990
- return await http(url, init);
21991
- } catch (error) {
21992
- throw new ProviderError("other", `Could not reach ${providerLabel(providerId)}: ${error.message}`);
21993
- }
22391
+
22392
+ // src/protocols/send.ts
22393
+ var maxRetries = 2;
22394
+ async function sendToProvider(http, providerId, url, init, { context, poll = false }) {
22395
+ for (let attempt = 1;; attempt++) {
22396
+ const last = attempt > maxRetries;
22397
+ try {
22398
+ const response = await http(url, init);
22399
+ if (last || !retryableStatus(response.status, poll))
22400
+ return response;
22401
+ await response.body?.cancel().catch(() => {
22402
+ return;
22403
+ });
22404
+ } catch (error) {
22405
+ const stage = stageOf(error);
22406
+ if (last || !poll && stage !== "before-send")
22407
+ throw networkError(providerId, error, stage, poll, attempt);
22408
+ }
22409
+ await context.backoff(attempt);
22410
+ }
22411
+ }
22412
+ function retryableStatus(status, poll) {
22413
+ if (poll)
22414
+ return status >= 500 || status === 429;
22415
+ return status === 502 || status === 503 || status === 504;
22416
+ }
22417
+ var beforeSendCodes = new Set([
22418
+ "ECONNREFUSED",
22419
+ "ENOTFOUND",
22420
+ "EAI_AGAIN",
22421
+ "ENETUNREACH",
22422
+ "ENETDOWN",
22423
+ "EHOSTUNREACH",
22424
+ "EHOSTDOWN",
22425
+ "UND_ERR_CONNECT_TIMEOUT"
22426
+ ]);
22427
+ var timeoutCodes = new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "ETIMEDOUT", "TimeoutError", "Timeout"]);
22428
+ function stageOf(error) {
22429
+ const layers = [error, error?.cause].filter((layer) => typeof layer === "object" && layer !== null);
22430
+ const names = layers.flatMap((layer) => [layer.code, layer.name]).filter((name) => typeof name === "string");
22431
+ if (layers.some((layer) => layer.syscall === "connect" || layer.syscall === "getaddrinfo"))
22432
+ return "before-send";
22433
+ if (names.some((name) => beforeSendCodes.has(name)))
22434
+ return "before-send";
22435
+ if (names.some((name) => timeoutCodes.has(name)))
22436
+ return "timeout";
22437
+ return "unknown";
22438
+ }
22439
+ function describe2(error) {
22440
+ const message = error?.message ?? String(error);
22441
+ const cause = error?.cause;
22442
+ const reason = cause instanceof Error ? cause.message : undefined;
22443
+ return reason === undefined || reason === message ? message : `${message}: ${reason}`;
22444
+ }
22445
+ function networkError(providerId, error, stage, poll, attempts) {
22446
+ const label = providerLabel(providerId);
22447
+ const tries = attempts > 1 ? ` (tried ${attempts} times)` : "";
22448
+ if (poll || stage === "before-send")
22449
+ return new ProviderError("other", `Could not reach ${label}${tries}: ${describe2(error)}`);
22450
+ const what = stage === "timeout" ? `${label} did not answer in time` : `The connection to ${label} failed`;
22451
+ return new ProviderError("other", `${what} after the request was sent: ${describe2(error)}. The Provider may have processed the request and charged for it, so litevision did not send it again. Tell the User before trying again.`);
21994
22452
  }
21995
22453
 
21996
22454
  // src/protocols/apimart.ts
@@ -22096,7 +22554,7 @@ var unknownModel = {
22096
22554
  var known = (modelId) => knownModels.find((model) => model.matches.test(modelId));
22097
22555
  var headers = (credential) => ({ authorization: `Bearer ${credential}` });
22098
22556
  var baseUrlOf = (target) => target.baseUrl.replace(/\/+$/, "");
22099
- async function errorOf(response, providerId) {
22557
+ async function errorOf(response, providerId, options = []) {
22100
22558
  const parsed = errorSchema.safeParse(await response.json().catch(() => {
22101
22559
  return;
22102
22560
  }));
@@ -22104,17 +22562,19 @@ async function errorOf(response, providerId) {
22104
22562
  return httpProviderError(providerId, {
22105
22563
  status: response.status,
22106
22564
  codes: [error?.type],
22107
- message: error?.message ?? (response.statusText || undefined)
22565
+ message: error?.message ?? (response.statusText || undefined),
22566
+ options
22108
22567
  });
22109
22568
  }
22110
- async function submitTask(body, target, http) {
22111
- const response = await sendToProvider(http, target.providerId, `${baseUrlOf(target)}/images/generations`, {
22569
+ async function submitTask(body, options, target, http, context) {
22570
+ const init = {
22112
22571
  method: "POST",
22113
22572
  headers: { ...headers(target.credential), "content-type": "application/json" },
22114
22573
  body: JSON.stringify(body)
22115
- });
22574
+ };
22575
+ const response = await sendToProvider(http, target.providerId, `${baseUrlOf(target)}/images/generations`, init, { context });
22116
22576
  if (!response.ok)
22117
- throw await errorOf(response, target.providerId);
22577
+ throw await errorOf(response, target.providerId, options);
22118
22578
  const parsed = submittedSchema.safeParse(await response.json().catch(() => {
22119
22579
  return;
22120
22580
  }));
@@ -22122,50 +22582,35 @@ async function submitTask(body, target, http) {
22122
22582
  throw providerError("other", target.providerId, "its answer to the submission had no task id.");
22123
22583
  return parsed.data.data[0].task_id;
22124
22584
  }
22125
- var toleratedPollFailures = 5;
22126
22585
  async function pollTask(taskId, target, http, context) {
22127
22586
  const url = `${baseUrlOf(target)}/tasks/${encodeURIComponent(taskId)}?language=en`;
22128
- let failures = 0;
22129
22587
  for (;; ) {
22130
- let response;
22131
- try {
22132
- response = await sendToProvider(http, target.providerId, url, { headers: headers(target.credential) });
22133
- } catch (error) {
22134
- if (++failures > toleratedPollFailures)
22135
- throw error;
22136
- }
22137
- if (response !== undefined && !response.ok) {
22138
- const transient = response.status >= 500 || response.status === 429;
22139
- if (!transient || ++failures > toleratedPollFailures)
22140
- throw await errorOf(response, target.providerId);
22141
- response = undefined;
22142
- }
22143
- if (response !== undefined) {
22144
- failures = 0;
22145
- const parsed = taskSchema.safeParse(await response.json().catch(() => {
22146
- return;
22147
- }));
22148
- if (!parsed.success)
22149
- throw providerError("other", target.providerId, `its status for task ${taskId} could not be read.`);
22150
- const task = parsed.data.data;
22151
- if (task.status === "completed") {
22152
- const images = (task.result?.images ?? []).flatMap((image) => Array.isArray(image.url) ? image.url : [image.url]).map((url) => ({ url }));
22153
- if (images.length === 0)
22154
- throw providerError("other", target.providerId, `task ${taskId} completed without images.`);
22155
- return { images, ...task.cost === undefined ? {} : { cost: task.cost } };
22156
- }
22157
- if (task.status === "failed") {
22158
- const error = task.error ?? undefined;
22159
- const kind = classifyProviderError({
22160
- status: typeof error?.code === "number" ? error.code : 0,
22161
- codes: [error?.type],
22162
- message: error?.message
22163
- });
22164
- throw providerError(kind, target.providerId, error?.message ?? `task ${taskId} failed.`);
22165
- }
22166
- if (task.status === "cancelled")
22167
- throw providerError("other", target.providerId, `task ${taskId} was cancelled.`);
22588
+ const response = await sendToProvider(http, target.providerId, url, { headers: headers(target.credential) }, { context, poll: true });
22589
+ if (!response.ok)
22590
+ throw await errorOf(response, target.providerId);
22591
+ const parsed = taskSchema.safeParse(await response.json().catch(() => {
22592
+ return;
22593
+ }));
22594
+ if (!parsed.success)
22595
+ throw providerError("other", target.providerId, `its status for task ${taskId} could not be read.`);
22596
+ const task = parsed.data.data;
22597
+ if (task.status === "completed") {
22598
+ const images = (task.result?.images ?? []).flatMap((image) => Array.isArray(image.url) ? image.url : [image.url]).map((url) => ({ url }));
22599
+ if (images.length === 0)
22600
+ throw providerError("other", target.providerId, `task ${taskId} completed without images.`);
22601
+ return { images, ...task.cost === undefined ? {} : { cost: task.cost } };
22602
+ }
22603
+ if (task.status === "failed") {
22604
+ const error = task.error ?? undefined;
22605
+ const kind = classifyProviderError({
22606
+ status: typeof error?.code === "number" ? error.code : 0,
22607
+ codes: [error?.type],
22608
+ message: error?.message
22609
+ });
22610
+ throw providerError(kind, target.providerId, error?.message ?? `task ${taskId} failed.`);
22168
22611
  }
22612
+ if (task.status === "cancelled")
22613
+ throw providerError("other", target.providerId, `task ${taskId} was cancelled.`);
22169
22614
  await context.pause();
22170
22615
  }
22171
22616
  }
@@ -22278,7 +22723,7 @@ var apimart = {
22278
22723
  ...request.references.length === 0 ? {} : { image_urls: request.references.map((reference) => `data:${reference.mimeType};base64,${Buffer.from(reference.bytes).toString("base64")}`) },
22279
22724
  ...request.providerOptions
22280
22725
  };
22281
- const settled = await Promise.allSettled(Array.from({ length: batches ? 1 : request.n }, () => submitTask(body, target, http)));
22726
+ const settled = await Promise.allSettled(Array.from({ length: batches ? 1 : request.n }, () => submitTask(body, Object.keys(request.providerOptions), target, http, context)));
22282
22727
  const taskIds = settled.flatMap((outcome) => outcome.status === "fulfilled" ? [outcome.value] : []);
22283
22728
  const failures = settled.flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
22284
22729
  if (taskIds.length === 0)
@@ -22414,18 +22859,19 @@ function interactionBody(request, target) {
22414
22859
  function isObject2(value) {
22415
22860
  return typeof value === "object" && value !== null && !Array.isArray(value);
22416
22861
  }
22417
- function kindOf(status, codes, message) {
22862
+ function kindOf(status, codes, message, options = []) {
22418
22863
  const bare = codes.filter((code) => code !== undefined).map((code) => code.split(/[/#]/).pop().toLowerCase());
22419
22864
  if (bare.some((code) => blockedCodes.has(code)))
22420
22865
  return "refusal";
22421
- return classifyProviderError({ status, codes, message });
22866
+ return classifyProviderError({ status, codes, message, options });
22422
22867
  }
22423
- async function sendInteraction(request, target, http) {
22424
- const response = await sendToProvider(http, target.providerId, `${trimmed(target.baseUrl)}/interactions`, {
22868
+ async function sendInteraction(request, target, http, context) {
22869
+ const init = {
22425
22870
  method: "POST",
22426
22871
  headers: { "x-goog-api-key": target.credential, "content-type": "application/json" },
22427
22872
  body: JSON.stringify(interactionBody(request, target))
22428
- });
22873
+ };
22874
+ const response = await sendToProvider(http, target.providerId, `${trimmed(target.baseUrl)}/interactions`, init, { context });
22429
22875
  const body = await response.json().catch(() => {
22430
22876
  return;
22431
22877
  });
@@ -22435,7 +22881,7 @@ async function sendInteraction(request, target, http) {
22435
22881
  const message = error?.message ?? (response.statusText || undefined);
22436
22882
  const codes = [typeof error?.code === "string" ? error.code : undefined, error?.status, ...(error?.details ?? []).map((detail) => detail.reason)];
22437
22883
  const detail = message === undefined ? `HTTP ${response.status}` : `HTTP ${response.status}: ${message}`;
22438
- throw providerError(kindOf(response.status, codes, message), target.providerId, detail);
22884
+ throw providerError(kindOf(response.status, codes, message, Object.keys(request.providerOptions)), target.providerId, detail);
22439
22885
  }
22440
22886
  const parsed = interactionSchema.safeParse(body);
22441
22887
  if (!parsed.success) {
@@ -22457,10 +22903,10 @@ async function sendInteraction(request, target, http) {
22457
22903
  }
22458
22904
  var gemini = {
22459
22905
  builtInCapabilities: (modelId) => geminiModel(modelId).capabilities,
22460
- docsUrl: () => "https://ai.google.dev/api/interactions-api",
22906
+ docsUrl: () => "https://ai.google.dev/gemini-api/docs/image-generation",
22461
22907
  reservedOptions: ["model", "input"],
22462
- async submit(request, target, http) {
22463
- return { wait: () => inParallel(request.n, target.providerId, () => sendInteraction(request, target, http)) };
22908
+ async submit(request, target, http, context) {
22909
+ return { wait: () => inParallel(request.n, target.providerId, () => sendInteraction(request, target, http, context)) };
22464
22910
  },
22465
22911
  async testConnection(target, http) {
22466
22912
  const list = await fetchModelIds2(target, http);
@@ -22546,7 +22992,8 @@ var errorSchema3 = object({
22546
22992
  error: object({
22547
22993
  message: string2().optional(),
22548
22994
  code: string2().nullish(),
22549
- type: string2().nullish()
22995
+ type: string2().nullish(),
22996
+ param: string2().nullish()
22550
22997
  })
22551
22998
  });
22552
22999
  var gptImage = {
@@ -22602,9 +23049,9 @@ var openaiImages = {
22602
23049
  },
22603
23050
  docsUrl: () => "https://platform.openai.com/docs/api-reference/images/create",
22604
23051
  reservedOptions: ["model", "prompt", "n", "size", "image", "image[]"],
22605
- async submit(request, target, http) {
23052
+ async submit(request, target, http, context) {
22606
23053
  const { url, init } = buildRequest(request, target);
22607
- const response = await sendToProvider(http, target.providerId, url, init);
23054
+ const response = await sendToProvider(http, target.providerId, url, init, { context });
22608
23055
  const body = await response.json().catch(() => {
22609
23056
  return;
22610
23057
  });
@@ -22614,7 +23061,9 @@ var openaiImages = {
22614
23061
  throw httpProviderError(target.providerId, {
22615
23062
  status: response.status,
22616
23063
  codes: [error?.code, error?.type],
22617
- message: error?.message ?? (response.statusText || undefined)
23064
+ message: error?.message ?? (response.statusText || undefined),
23065
+ param: error?.param,
23066
+ options: Object.keys(request.providerOptions)
22618
23067
  });
22619
23068
  }
22620
23069
  const parsed = successSchema.safeParse(body);
@@ -22664,6 +23113,393 @@ async function fetchModelIds3(target, http) {
22664
23113
  return { ok: true, modelIds: parsed.data.data.map((model) => model.id).sort() };
22665
23114
  }
22666
23115
 
23116
+ // src/protocols/together.ts
23117
+ var docs = {
23118
+ flux: "https://docs.together.ai/docs/quickstart-flux",
23119
+ imageParameters: "https://docs.together.ai/docs/inference/images/parameters",
23120
+ referenceImages: "https://docs.together.ai/docs/inference/images/reference-images",
23121
+ wan: "https://docs.together.ai/docs/wan2.7-quickstart",
23122
+ seedance: "https://docs.together.ai/docs/seedance2.5-quickstart",
23123
+ videoParameters: "https://docs.together.ai/docs/inference/videos/parameters"
23124
+ };
23125
+ var sizes3 = (entries) => entries.map(([aspectRatio, width, height]) => ({ aspectRatio, width, height }));
23126
+ var megapixelSizes = sizes3([
23127
+ ["1:1", 1024, 1024],
23128
+ ["16:9", 1344, 768],
23129
+ ["9:16", 768, 1344],
23130
+ ["4:3", 1152, 896],
23131
+ ["3:4", 896, 1152],
23132
+ ["3:2", 1216, 832],
23133
+ ["2:3", 832, 1216]
23134
+ ]);
23135
+ var cappedSizes = sizes3([
23136
+ ["1:1", 1024, 1024],
23137
+ ["16:9", 1024, 576],
23138
+ ["9:16", 576, 1024],
23139
+ ["4:3", 1024, 768],
23140
+ ["3:4", 768, 1024],
23141
+ ["3:2", 1024, 680],
23142
+ ["2:3", 680, 1024]
23143
+ ]);
23144
+ var maxImagesPerJob2 = 4;
23145
+ var imageFamilies = [
23146
+ {
23147
+ matches: /^black-forest-labs\/FLUX\.1-kontext-/i,
23148
+ capabilities: { sizes: megapixelSizes, maxReferences: 1, maxImages: maxImagesPerJob2 },
23149
+ sizing: "ratio",
23150
+ references: "image_url",
23151
+ docsUrl: docs.flux
23152
+ },
23153
+ {
23154
+ matches: /^black-forest-labs\/FLUX\.2-(pro|flex|dev)$/i,
23155
+ capabilities: { sizes: megapixelSizes, maxReferences: 4, maxImages: maxImagesPerJob2 },
23156
+ sizing: "pixels",
23157
+ references: "reference_images",
23158
+ docsUrl: docs.flux
23159
+ },
23160
+ {
23161
+ matches: /^black-forest-labs\//i,
23162
+ capabilities: { sizes: megapixelSizes, maxReferences: 0, maxImages: maxImagesPerJob2 },
23163
+ sizing: "pixels",
23164
+ references: "reference_images",
23165
+ docsUrl: docs.flux
23166
+ },
23167
+ {
23168
+ matches: /^google\/gemini-3-pro-image$/i,
23169
+ capabilities: {
23170
+ sizes: sizes3([
23171
+ ["1:1", 1024, 1024],
23172
+ ["16:9", 1376, 768],
23173
+ ["9:16", 768, 1376],
23174
+ ["4:3", 1200, 896],
23175
+ ["3:4", 896, 1200],
23176
+ ["3:2", 1264, 848],
23177
+ ["2:3", 848, 1264]
23178
+ ]),
23179
+ maxReferences: 14,
23180
+ maxImages: maxImagesPerJob2
23181
+ },
23182
+ sizing: "pixels",
23183
+ references: "reference_images",
23184
+ docsUrl: docs.referenceImages
23185
+ },
23186
+ {
23187
+ matches: /^google\/flash-image-2\.5$/i,
23188
+ capabilities: {
23189
+ sizes: sizes3([
23190
+ ["1:1", 1024, 1024],
23191
+ ["16:9", 1344, 768],
23192
+ ["9:16", 768, 1344],
23193
+ ["4:3", 1184, 864],
23194
+ ["3:4", 864, 1184],
23195
+ ["3:2", 1248, 832],
23196
+ ["2:3", 832, 1248]
23197
+ ]),
23198
+ maxReferences: 3,
23199
+ maxImages: maxImagesPerJob2
23200
+ },
23201
+ sizing: "pixels",
23202
+ references: "reference_images",
23203
+ docsUrl: docs.referenceImages
23204
+ }
23205
+ ];
23206
+ var otherImages = {
23207
+ matches: /.*/,
23208
+ capabilities: { sizes: cappedSizes, maxReferences: 0, maxImages: maxImagesPerJob2 },
23209
+ sizing: "pixels",
23210
+ references: "reference_images",
23211
+ docsUrl: docs.imageParameters
23212
+ };
23213
+ var imageFamily = (modelId) => imageFamilies.find((family) => family.matches.test(modelId)) ?? otherImages;
23214
+ var range = (from, to) => Array.from({ length: to - from + 1 }, (_, index) => from + index);
23215
+ var wanResolutions = [
23216
+ {
23217
+ name: "1080P",
23218
+ sizes: sizes3([
23219
+ ["16:9", 1920, 1080],
23220
+ ["9:16", 1080, 1920],
23221
+ ["1:1", 1440, 1440],
23222
+ ["4:3", 1632, 1248],
23223
+ ["3:4", 1248, 1632]
23224
+ ])
23225
+ },
23226
+ {
23227
+ name: "720P",
23228
+ sizes: sizes3([
23229
+ ["16:9", 1280, 720],
23230
+ ["9:16", 720, 1280],
23231
+ ["1:1", 960, 960],
23232
+ ["4:3", 1088, 832],
23233
+ ["3:4", 832, 1088]
23234
+ ])
23235
+ }
23236
+ ];
23237
+ var wan = (capabilities) => ({
23238
+ resolutions: wanResolutions,
23239
+ defaultDuration: 5,
23240
+ ...capabilities
23241
+ });
23242
+ var videoFamilies = [
23243
+ {
23244
+ matches: /^Wan-AI\/wan2\.7-t2v$/i,
23245
+ capabilities: wan({ durations: range(2, 15), firstFrame: false, lastFrame: false, maxReferences: 0 }),
23246
+ sizing: "tier+ratio",
23247
+ docsUrl: docs.wan
23248
+ },
23249
+ {
23250
+ matches: /^Wan-AI\/wan2\.7-i2v$/i,
23251
+ capabilities: wan({ durations: range(2, 15), firstFrame: true, lastFrame: true, maxReferences: 0 }),
23252
+ sizing: "tier+ratio",
23253
+ docsUrl: docs.wan
23254
+ },
23255
+ {
23256
+ matches: /^Wan-AI\/wan2\.7-r2v$/i,
23257
+ capabilities: wan({ durations: range(2, 10), firstFrame: false, lastFrame: false, maxReferences: 5 }),
23258
+ sizing: "tier+ratio",
23259
+ docsUrl: docs.wan
23260
+ },
23261
+ {
23262
+ matches: /^ByteDance\/Seedance-2\.5$/i,
23263
+ capabilities: {
23264
+ resolutions: [
23265
+ { name: "720p", sizes: sizes3([["16:9", 1280, 720]]) },
23266
+ { name: "480p", sizes: sizes3([["16:9", 854, 480]]) }
23267
+ ],
23268
+ durations: range(4, 30),
23269
+ defaultDuration: 5,
23270
+ firstFrame: true,
23271
+ lastFrame: true,
23272
+ maxReferences: 30
23273
+ },
23274
+ sizing: "tier",
23275
+ docsUrl: docs.seedance
23276
+ }
23277
+ ];
23278
+ var otherVideos = {
23279
+ matches: /.*/,
23280
+ capabilities: {
23281
+ resolutions: [
23282
+ {
23283
+ sizes: sizes3([
23284
+ ["16:9", 1280, 720],
23285
+ ["9:16", 720, 1280],
23286
+ ["1:1", 960, 960],
23287
+ ["4:3", 1088, 816],
23288
+ ["3:4", 816, 1088],
23289
+ ["3:2", 1152, 768],
23290
+ ["2:3", 768, 1152]
23291
+ ])
23292
+ }
23293
+ ],
23294
+ durations: range(1, 10),
23295
+ defaultDuration: 5,
23296
+ firstFrame: true,
23297
+ lastFrame: true,
23298
+ maxReferences: 4
23299
+ },
23300
+ sizing: "pixels",
23301
+ docsUrl: docs.videoParameters
23302
+ };
23303
+ var videoFamily = (modelId) => videoFamilies.find((family) => family.matches.test(modelId)) ?? otherVideos;
23304
+ var errorSchema4 = object({
23305
+ error: object({
23306
+ message: string2().optional(),
23307
+ code: string2().nullish(),
23308
+ type: string2().nullish(),
23309
+ param: string2().nullish()
23310
+ })
23311
+ });
23312
+ var imagesSchema = object({
23313
+ data: array(object({ b64_json: string2().nullish(), url: string2().nullish() }))
23314
+ });
23315
+ var videoSchema = object({
23316
+ id: string2(),
23317
+ status: string2(),
23318
+ outputs: object({ video_url: string2().optional(), cost: number2().optional() }).nullish(),
23319
+ error: object({ message: string2().optional(), code: string2().nullish() }).nullish()
23320
+ });
23321
+ var resumeSchema2 = object({ videoId: string2().min(1) });
23322
+ var modelListSchema4 = array(object({ id: string2(), type: string2().optional() }));
23323
+ var rootOf = (target) => target.baseUrl.replace(/\/+$/, "").replace(/\/v[12]$/, "");
23324
+ var authorization = (credential) => ({ authorization: `Bearer ${credential}` });
23325
+ var dataUrl = (image) => `data:${image.mimeType};base64,${Buffer.from(image.bytes).toString("base64")}`;
23326
+ var passthroughBlocked = "third_party_data_sharing_blocked";
23327
+ function passthroughError(providerId) {
23328
+ return new ProviderError("unsupported", `${providerLabel(providerId)} runs this model through its maker, which the User's Together organization does not allow yet. ` + `Only the User can change that: in Together's dashboard (https://api.together.ai/settings), Organization → General → Privacy → "Passthrough models", which sends prompts for such models to their makers. ` + "Tell the User, or choose a model Together serves itself.");
23329
+ }
23330
+ async function errorOf2(response, providerId, options = []) {
23331
+ const text = await response.text().catch(() => "");
23332
+ let body;
23333
+ try {
23334
+ body = JSON.parse(text);
23335
+ } catch {
23336
+ body = undefined;
23337
+ }
23338
+ const parsed = errorSchema4.safeParse(body);
23339
+ const error = parsed.success ? parsed.data.error : undefined;
23340
+ if (error?.code === passthroughBlocked)
23341
+ return passthroughError(providerId);
23342
+ const plain = body === undefined ? text.trim().slice(0, 300) : "";
23343
+ return httpProviderError(providerId, {
23344
+ status: response.status,
23345
+ codes: [error?.code, error?.type],
23346
+ message: error?.message ?? (plain || response.statusText || undefined),
23347
+ param: error?.param,
23348
+ options
23349
+ });
23350
+ }
23351
+ async function postJson(url, body, providerOptions, target, http, context) {
23352
+ const init = {
23353
+ method: "POST",
23354
+ headers: { ...authorization(target.credential), "content-type": "application/json" },
23355
+ body: JSON.stringify(body)
23356
+ };
23357
+ const response = await sendToProvider(http, target.providerId, url, init, { context });
23358
+ if (!response.ok)
23359
+ throw await errorOf2(response, target.providerId, Object.keys(providerOptions));
23360
+ return response.json().catch(() => {
23361
+ return;
23362
+ });
23363
+ }
23364
+ function waitForVideo(videoId, target, http, context) {
23365
+ const url = `${rootOf(target)}/v2/videos/${encodeURIComponent(videoId)}`;
23366
+ return {
23367
+ resumeHandle: { videoId },
23368
+ async wait() {
23369
+ for (;; ) {
23370
+ const response = await sendToProvider(http, target.providerId, url, { headers: authorization(target.credential) }, { context, poll: true });
23371
+ if (!response.ok)
23372
+ throw await errorOf2(response, target.providerId);
23373
+ const parsed = videoSchema.safeParse(await response.json().catch(() => {
23374
+ return;
23375
+ }));
23376
+ if (!parsed.success)
23377
+ throw providerError("other", target.providerId, `its status for video ${videoId} could not be read.`);
23378
+ const job = parsed.data;
23379
+ if (job.status === "completed") {
23380
+ const link = job.outputs?.video_url;
23381
+ if (link === undefined)
23382
+ throw providerError("other", target.providerId, `video ${videoId} completed without a link to it.`);
23383
+ return { video: { url: link }, ...job.outputs?.cost === undefined ? {} : { cost: job.outputs.cost } };
23384
+ }
23385
+ if (job.status === "failed")
23386
+ throw videoFailure(job.error ?? undefined, target.providerId, videoId);
23387
+ if (job.status === "cancelled")
23388
+ throw providerError("other", target.providerId, `video ${videoId} was cancelled.`);
23389
+ await context.pause();
23390
+ }
23391
+ }
23392
+ };
23393
+ }
23394
+ function videoFailure(error, providerId, videoId) {
23395
+ if (error?.code === passthroughBlocked)
23396
+ return passthroughError(providerId);
23397
+ const rejected = /^(unsupported|invalid)/i.test(error?.code ?? "");
23398
+ const kind = rejected ? "unsupported" : classifyProviderError({ status: 0, codes: [error?.code], message: error?.message });
23399
+ const detail = [error?.code, error?.message].filter(Boolean).join(": ");
23400
+ return providerError(kind, providerId, detail || `video ${videoId} failed.`);
23401
+ }
23402
+ async function fetchModelIds4(target, http) {
23403
+ const label = providerLabel(target.providerId);
23404
+ let response;
23405
+ try {
23406
+ response = await http(`${rootOf(target)}/v1/models`, { headers: authorization(target.credential) });
23407
+ } catch (error) {
23408
+ return { ok: false, message: `Could not reach ${label}: ${error.message}` };
23409
+ }
23410
+ if (!response.ok)
23411
+ return { ok: false, message: (await errorOf2(response, target.providerId)).message };
23412
+ const parsed = modelListSchema4.safeParse(await response.json().catch(() => {
23413
+ return;
23414
+ }));
23415
+ if (!parsed.success)
23416
+ return { ok: false, message: `${label} answered, but not with a Together model list. Check the base URL.` };
23417
+ const modalities = {};
23418
+ for (const model of parsed.data) {
23419
+ if (model.type === "image" || model.type === "video")
23420
+ modalities[model.id] = model.type;
23421
+ }
23422
+ return { ok: true, modelIds: Object.keys(modalities).sort(), modalities };
23423
+ }
23424
+ var together = {
23425
+ builtInCapabilities: (modelId) => imageFamily(modelId).capabilities,
23426
+ builtInVideoCapabilities: (modelId) => videoFamily(modelId).capabilities,
23427
+ docsUrl: (modelId, modality = "image") => (modality === "video" ? videoFamily(modelId) : imageFamily(modelId)).docsUrl,
23428
+ reservedOptions: ["model", "prompt", "n", "width", "height", "aspect_ratio", "response_format", "reference_images", "image_url"],
23429
+ reservedVideoOptions: ["model", "prompt", "seconds", "resolution", "ratio", "media"],
23430
+ async submit(request, target, http, context) {
23431
+ const family = imageFamily(target.modelId);
23432
+ const { size, references } = request;
23433
+ const body = {
23434
+ model: target.modelId,
23435
+ prompt: request.prompt,
23436
+ n: request.n,
23437
+ ...family.sizing === "ratio" ? { aspect_ratio: size.aspectRatio } : { width: size.width, height: size.height },
23438
+ response_format: "base64",
23439
+ ...references.length === 0 ? {} : family.references === "image_url" ? { image_url: dataUrl(references[0]) } : { reference_images: references.map(dataUrl) },
23440
+ ...request.providerOptions
23441
+ };
23442
+ const parsed = imagesSchema.safeParse(await postJson(`${rootOf(target)}/v1/images/generations`, body, request.providerOptions, target, http, context));
23443
+ if (!parsed.success)
23444
+ throw providerError("other", target.providerId, "its response could not be read as Together images.");
23445
+ const images = parsed.data.data.map((item) => {
23446
+ if (item.b64_json)
23447
+ return { bytes: Buffer.from(item.b64_json, "base64") };
23448
+ if (item.url)
23449
+ return { url: item.url };
23450
+ throw providerError("other", target.providerId, "it returned an image with neither data nor a link.");
23451
+ });
23452
+ if (images.length === 0)
23453
+ throw providerError("other", target.providerId, "it returned no images.");
23454
+ const shortfall = images.length < request.n ? `${providerLabel(target.providerId)} returned ${images.length} of ${request.n} images without saying why.` : undefined;
23455
+ return { wait: async () => ({ images, ...shortfall === undefined ? {} : { shortfall } }) };
23456
+ },
23457
+ async submitVideo(request, target, http, context) {
23458
+ const family = videoFamily(target.modelId);
23459
+ const { size } = request;
23460
+ const frames = [
23461
+ ...request.firstFrame === undefined ? [] : [{ input_image: dataUrl(request.firstFrame), frame: "first" }],
23462
+ ...request.lastFrame === undefined ? [] : [{ input_image: dataUrl(request.lastFrame), frame: "last" }]
23463
+ ];
23464
+ const media = {
23465
+ ...frames.length === 0 ? {} : { frame_images: frames },
23466
+ ...request.references.length === 0 ? {} : { reference_images: request.references.map(dataUrl) }
23467
+ };
23468
+ const sizing = family.sizing === "pixels" ? { width: size.width, height: size.height } : { resolution: request.resolution, ...family.sizing === "tier+ratio" ? { ratio: size.aspectRatio } : {} };
23469
+ const body = {
23470
+ model: target.modelId,
23471
+ prompt: request.prompt,
23472
+ seconds: String(request.seconds),
23473
+ ...sizing,
23474
+ ...Object.keys(media).length === 0 ? {} : { media },
23475
+ ...request.providerOptions
23476
+ };
23477
+ const parsed = videoSchema.safeParse(await postJson(`${rootOf(target)}/v2/videos`, body, request.providerOptions, target, http, context));
23478
+ if (!parsed.success)
23479
+ throw providerError("other", target.providerId, "its answer to the submission had no video id.");
23480
+ if (parsed.data.status === "failed")
23481
+ throw videoFailure(parsed.data.error ?? undefined, target.providerId, parsed.data.id);
23482
+ return waitForVideo(parsed.data.id, target, http, context);
23483
+ },
23484
+ resumeVideo(handle, target, http, context) {
23485
+ const parsed = resumeSchema2.safeParse(handle);
23486
+ if (!parsed.success)
23487
+ throw providerError("other", target.providerId, "the Job history holds no video id to pick the Job up from.");
23488
+ return waitForVideo(parsed.data.videoId, target, http, context);
23489
+ },
23490
+ async testConnection(target, http) {
23491
+ const list = await fetchModelIds4(target, http);
23492
+ if (!list.ok)
23493
+ return list;
23494
+ const count = (modality) => Object.values(list.modalities ?? {}).filter((value) => value === modality).length;
23495
+ return {
23496
+ ok: true,
23497
+ message: `Connected to Provider "${target.providerId}". It offers ${count("image")} image and ${count("video")} video model id(s).`
23498
+ };
23499
+ },
23500
+ listModelIds: fetchModelIds4
23501
+ };
23502
+
22667
23503
  // src/protocols/venice.ts
22668
23504
  var generateDocs = "https://docs.venice.ai/api-reference/endpoint/image/generate";
22669
23505
  var editDocs = "https://docs.venice.ai/api-reference/endpoint/image/edit";
@@ -22675,7 +23511,7 @@ var constraintsSchema = object({
22675
23511
  maxInputImages: number2().int().positive().optional()
22676
23512
  });
22677
23513
  var sizingOf = (constraints) => (constraints.widthHeightDivisor ?? 1) > 1 ? "pixels" : "ratio";
22678
- var modelListSchema4 = object({
23514
+ var modelListSchema5 = object({
22679
23515
  data: array(object({
22680
23516
  id: string2(),
22681
23517
  type: string2().optional(),
@@ -22685,10 +23521,11 @@ var modelListSchema4 = object({
22685
23521
  }).passthrough().optional()
22686
23522
  }))
22687
23523
  });
22688
- var errorSchema4 = object({
23524
+ var errorSchema5 = object({
22689
23525
  error: union([string2(), object({ message: string2().optional(), code: string2().nullish() })]),
22690
23526
  code: string2().optional(),
22691
- suggested_prompt: string2().optional()
23527
+ suggested_prompt: string2().optional(),
23528
+ details: record(string2(), unknown()).optional()
22692
23529
  });
22693
23530
  var generateSchema = object({
22694
23531
  images: array(string2()),
@@ -22758,12 +23595,12 @@ function describeModel(listed, listType) {
22758
23595
  };
22759
23596
  }
22760
23597
  var apiUrl = (baseUrl, path) => `${baseUrl.replace(/\/+$/, "")}${path}`;
22761
- var authorization = (credential) => ({ authorization: `Bearer ${credential}` });
23598
+ var authorization2 = (credential) => ({ authorization: `Bearer ${credential}` });
22762
23599
  async function listModels(target, type, http) {
22763
23600
  const label = providerLabel(target.providerId);
22764
23601
  let response;
22765
23602
  try {
22766
- response = await http(apiUrl(target.baseUrl, `/models?type=${type}`), { headers: authorization(target.credential) });
23603
+ response = await http(apiUrl(target.baseUrl, `/models?type=${type}`), { headers: authorization2(target.credential) });
22767
23604
  } catch (error) {
22768
23605
  return { ok: false, message: `Could not reach ${label}: ${error.message}` };
22769
23606
  }
@@ -22773,7 +23610,7 @@ async function listModels(target, type, http) {
22773
23610
  if (!response.ok) {
22774
23611
  return { ok: false, message: `${label} returned HTTP ${response.status}: ${errorMessage(body) ?? response.statusText}` };
22775
23612
  }
22776
- const parsed = modelListSchema4.safeParse(body);
23613
+ const parsed = modelListSchema5.safeParse(body);
22777
23614
  if (!parsed.success) {
22778
23615
  return { ok: false, message: `${label} answered, but not with a Venice model list. Check the base URL.` };
22779
23616
  }
@@ -22799,28 +23636,38 @@ async function lookUpModel(target, http) {
22799
23636
  return;
22800
23637
  }
22801
23638
  function errorMessage(body) {
22802
- const parsed = errorSchema4.safeParse(body);
23639
+ const parsed = errorSchema5.safeParse(body);
22803
23640
  if (!parsed.success)
22804
23641
  return;
22805
23642
  return typeof parsed.data.error === "string" ? parsed.data.error : parsed.data.error.message;
22806
23643
  }
22807
- async function veniceError(response, providerId) {
23644
+ var fieldErrorsSchema = object({ _errors: array(string2()) });
23645
+ function fieldProblems(details) {
23646
+ return Object.entries(details ?? {}).flatMap(([field, value]) => {
23647
+ const parsed = fieldErrorsSchema.safeParse(value);
23648
+ return field === "_errors" || !parsed.success || parsed.data._errors.length === 0 ? [] : [`${field}: ${parsed.data._errors.join(", ")}`];
23649
+ });
23650
+ }
23651
+ async function veniceError(response, providerId, options) {
22808
23652
  const body = await response.json().catch(() => {
22809
23653
  return;
22810
23654
  });
22811
- const parsed = errorSchema4.safeParse(body);
23655
+ const parsed = errorSchema5.safeParse(body);
22812
23656
  const error = parsed.success ? parsed.data : undefined;
22813
23657
  const nested = typeof error?.error === "object" ? error.error : undefined;
22814
- const message = errorMessage(body) ?? (response.statusText || undefined);
23658
+ const problems = fieldProblems(error?.details);
23659
+ const said = errorMessage(body) ?? (response.statusText || undefined);
23660
+ const message = said === undefined ? undefined : problems.length === 0 ? said : `${said}: ${problems.join("; ")}`;
22815
23661
  const suggestion = error?.suggested_prompt === undefined ? "" : ` Venice suggests this prompt instead: ${error.suggested_prompt}`;
22816
23662
  return httpProviderError(providerId, {
22817
23663
  status: response.status,
22818
23664
  codes: [error?.code, nested?.code],
22819
- message: message === undefined ? undefined : `${message}${suggestion}`
23665
+ message: message === undefined ? undefined : `${message}${suggestion}`,
23666
+ options
22820
23667
  });
22821
23668
  }
22822
23669
  var sharedDefaults = { safe_mode: false, enhance_prompt: false };
22823
- async function generate(request, target, model, http) {
23670
+ async function generate(request, target, model, http, context) {
22824
23671
  const { size } = request;
22825
23672
  const sizing = model.sizing === "pixels" ? { width: size.width, height: size.height } : { aspect_ratio: size.aspectRatio };
22826
23673
  const body = {
@@ -22836,11 +23683,11 @@ async function generate(request, target, model, http) {
22836
23683
  };
22837
23684
  const response = await sendToProvider(http, target.providerId, apiUrl(target.baseUrl, "/image/generate"), {
22838
23685
  method: "POST",
22839
- headers: { ...authorization(target.credential), "content-type": "application/json" },
23686
+ headers: { ...authorization2(target.credential), "content-type": "application/json" },
22840
23687
  body: JSON.stringify(body)
22841
- });
23688
+ }, { context });
22842
23689
  if (!response.ok)
22843
- throw await veniceError(response, target.providerId);
23690
+ throw await veniceError(response, target.providerId, Object.keys(request.providerOptions));
22844
23691
  const parsed = generateSchema.safeParse(await response.json().catch(() => {
22845
23692
  return;
22846
23693
  }));
@@ -22858,7 +23705,7 @@ async function generate(request, target, model, http) {
22858
23705
  }
22859
23706
  return result;
22860
23707
  }
22861
- async function edit(request, target, http) {
23708
+ async function edit(request, target, http, context) {
22862
23709
  const encoded = request.references.map((reference) => Buffer.from(reference.bytes).toString("base64"));
22863
23710
  const [path, inputs] = encoded.length === 1 ? ["/image/edit", { model: target.modelId, image: encoded[0] }] : ["/image/multi-edit", { modelId: target.modelId, images: encoded }];
22864
23711
  const body = {
@@ -22871,11 +23718,11 @@ async function edit(request, target, http) {
22871
23718
  };
22872
23719
  const response = await sendToProvider(http, target.providerId, apiUrl(target.baseUrl, path), {
22873
23720
  method: "POST",
22874
- headers: { ...authorization(target.credential), "content-type": "application/json" },
23721
+ headers: { ...authorization2(target.credential), "content-type": "application/json" },
22875
23722
  body: JSON.stringify(body)
22876
- });
23723
+ }, { context });
22877
23724
  if (!response.ok)
22878
- throw await veniceError(response, target.providerId);
23725
+ throw await veniceError(response, target.providerId, Object.keys(request.providerOptions));
22879
23726
  if (response.headers.get("content-type")?.includes("json")) {
22880
23727
  throw providerError("other", target.providerId, "it answered an edit with JSON rather than an image.");
22881
23728
  }
@@ -22894,15 +23741,15 @@ var venice = {
22894
23741
  },
22895
23742
  docsUrl: (modelId) => looksLikeEdit(modelId) ? editDocs : generateDocs,
22896
23743
  reservedOptions: ["model", "modelId", "prompt", "variants", "return_binary", "width", "height", "aspect_ratio", "image", "images"],
22897
- async submit(request, target, http) {
23744
+ async submit(request, target, http, context) {
22898
23745
  const model = describedModel(request.model, target.modelId);
22899
23746
  if (request.references.length > 0) {
22900
- return { wait: () => inParallel(request.n, target.providerId, () => edit(request, target, http)) };
23747
+ return { wait: () => inParallel(request.n, target.providerId, () => edit(request, target, http, context)) };
22901
23748
  }
22902
23749
  if (model.kind === "edit") {
22903
23750
  throw providerError("unsupported", target.providerId, `"${target.modelId}" is an edit model and needs at least one reference image. Pass \`references\`, or choose a text-to-image Model.`);
22904
23751
  }
22905
- const images = await generate(request, target, model, http);
23752
+ const images = await generate(request, target, model, http, context);
22906
23753
  return { wait: async () => images };
22907
23754
  },
22908
23755
  async testConnection(target, http) {
@@ -22926,7 +23773,8 @@ var adapters = {
22926
23773
  "openai-images": openaiImages,
22927
23774
  gemini,
22928
23775
  apimart,
22929
- venice
23776
+ venice,
23777
+ together
22930
23778
  };
22931
23779
  function supportedProtocols() {
22932
23780
  return Object.keys(adapters);
@@ -22945,6 +23793,7 @@ function adapterFor(model) {
22945
23793
  // src/jobs.ts
22946
23794
  var defaultWaitLimitMs = 120000;
22947
23795
  var defaultPollIntervalMs = 3000;
23796
+ var defaultRetryDelayMs = 2000;
22948
23797
 
22949
23798
  class JobFailedError extends Error {
22950
23799
  jobId;
@@ -22961,7 +23810,7 @@ class JobAbandoned extends Error {
22961
23810
  super("litevision shut down while the Job was running.");
22962
23811
  }
22963
23812
  }
22964
- var lostMessage = "litevision stopped before this Job finished, so its result is lost; generate the images again.";
23813
+ var lostMessage = "litevision stopped before this Job finished, so its result is lost; start it again.";
22965
23814
 
22966
23815
  class JobRunner {
22967
23816
  deps;
@@ -22970,37 +23819,24 @@ class JobRunner {
22970
23819
  closed = false;
22971
23820
  waitLimitMs;
22972
23821
  pollIntervalMs;
23822
+ retryDelayMs;
22973
23823
  constructor(deps) {
22974
23824
  this.deps = deps;
22975
23825
  this.waitLimitMs = deps.waitLimitMs ?? defaultWaitLimitMs;
22976
23826
  this.pollIntervalMs = deps.pollIntervalMs ?? defaultPollIntervalMs;
23827
+ this.retryDelayMs = deps.retryDelayMs ?? defaultRetryDelayMs;
22977
23828
  }
22978
23829
  async start(spec) {
22979
23830
  const { request } = spec.normalised;
22980
- const base = {
22981
- type: "job",
22982
- id: randomUUID2(),
22983
- createdAt: this.deps.now().toISOString(),
22984
- model: spec.model.alias,
22985
- provider: spec.provider.id,
22986
- protocol: spec.model.protocol,
22987
- modelId: spec.model.modelId,
22988
- prompt: request.prompt,
22989
- parameters: {
22990
- n: request.n,
22991
- aspectRatio: spec.normalised.requestedAspectRatio,
22992
- size: request.size,
22993
- references: request.references.map((reference) => reference.path),
22994
- ...spec.outputDir === undefined ? {} : { outputPath: spec.outputDir },
22995
- providerOptions: request.providerOptions
22996
- }
22997
- };
22998
- const target = {
22999
- providerId: spec.provider.id,
23000
- baseUrl: spec.provider.baseUrl,
23001
- modelId: spec.model.modelId,
23002
- credential: spec.credential
23003
- };
23831
+ const base = this.baseFor(spec, "image", request.prompt, {
23832
+ n: request.n,
23833
+ aspectRatio: spec.normalised.requestedAspectRatio,
23834
+ size: request.size,
23835
+ references: request.references.map((reference) => reference.path),
23836
+ ...spec.outputDir === undefined ? {} : { outputPath: spec.outputDir },
23837
+ providerOptions: request.providerOptions
23838
+ });
23839
+ const target = targetOf(spec);
23004
23840
  await this.append(base, "running");
23005
23841
  const job = this.track(base, spec.credential, async (context) => {
23006
23842
  const submitted = await spec.adapter.submit(request, target, this.deps.http, context);
@@ -23010,6 +23846,49 @@ class JobRunner {
23010
23846
  });
23011
23847
  return this.waitFor(base.model, base.id, this.register(base.id, job));
23012
23848
  }
23849
+ async startVideo(spec) {
23850
+ const { request } = spec.normalised;
23851
+ const submitVideo = spec.adapter.submitVideo;
23852
+ if (submitVideo === undefined) {
23853
+ throw new ConfigError(`Model "${spec.model.alias}" uses Protocol "${spec.model.protocol}", which litevision cannot make videos with.`);
23854
+ }
23855
+ const base = this.baseFor(spec, "video", request.prompt, {
23856
+ n: 1,
23857
+ aspectRatio: spec.normalised.requestedAspectRatio,
23858
+ size: request.size,
23859
+ references: request.references.map((reference) => reference.path),
23860
+ seconds: request.seconds,
23861
+ ...request.resolution === undefined ? {} : { resolution: request.resolution },
23862
+ ...spec.normalised.followsFirstFrame ? { followsFirstFrame: true } : {},
23863
+ ...request.firstFrame === undefined ? {} : { firstFrame: request.firstFrame.path },
23864
+ ...request.lastFrame === undefined ? {} : { lastFrame: request.lastFrame.path },
23865
+ ...spec.outputDir === undefined ? {} : { outputPath: spec.outputDir },
23866
+ providerOptions: request.providerOptions
23867
+ });
23868
+ const target = targetOf(spec);
23869
+ await this.append(base, "running");
23870
+ const job = this.track(base, spec.credential, async (context) => {
23871
+ const submitted = await submitVideo.call(spec.adapter, request, target, this.deps.http, context);
23872
+ await this.append(base, "running", { resume: submitted.resumeHandle });
23873
+ return videoAsImages(await submitted.wait());
23874
+ });
23875
+ return this.waitFor(base.model, base.id, this.register(base.id, job));
23876
+ }
23877
+ baseFor(spec, modality, prompt, parameters) {
23878
+ return {
23879
+ type: "job",
23880
+ id: randomUUID2(),
23881
+ createdAt: this.deps.now().toISOString(),
23882
+ model: spec.model.alias,
23883
+ provider: spec.provider.id,
23884
+ protocol: spec.model.protocol,
23885
+ modelId: spec.model.modelId,
23886
+ modality,
23887
+ prompt,
23888
+ parameters,
23889
+ ...spec.comparison === undefined ? {} : { comparison: spec.comparison }
23890
+ };
23891
+ }
23013
23892
  async get(id) {
23014
23893
  const record = await readJob(this.deps.dirs, id);
23015
23894
  if (record === undefined) {
@@ -23023,7 +23902,9 @@ class JobRunner {
23023
23902
  id,
23024
23903
  model: record.model,
23025
23904
  files: record.files,
23026
- ...record.shortfall === undefined ? {} : { shortfall: record.shortfall }
23905
+ observed: record.observed ?? record.files.map(() => null),
23906
+ ...record.shortfall === undefined ? {} : { shortfall: record.shortfall },
23907
+ ...record.mismatch === undefined ? {} : { mismatch: record.mismatch }
23027
23908
  };
23028
23909
  }
23029
23910
  if (record.outcome === "failed") {
@@ -23040,7 +23921,11 @@ class JobRunner {
23040
23921
  this.abandons.clear();
23041
23922
  }
23042
23923
  context = {
23043
- pause: () => new Promise((resolve, reject) => {
23924
+ pause: () => this.sleep(this.pollIntervalMs),
23925
+ backoff: (retry) => this.sleep(this.retryDelayMs * 2 ** (retry - 1))
23926
+ };
23927
+ sleep(ms) {
23928
+ return new Promise((resolve, reject) => {
23044
23929
  if (this.closed)
23045
23930
  return reject(new JobAbandoned);
23046
23931
  const abandon = () => {
@@ -23050,10 +23935,10 @@ class JobRunner {
23050
23935
  const timer = setTimeout(() => {
23051
23936
  this.abandons.delete(abandon);
23052
23937
  resolve();
23053
- }, this.pollIntervalMs);
23938
+ }, ms);
23054
23939
  this.abandons.add(abandon);
23055
- })
23056
- };
23940
+ });
23941
+ }
23057
23942
  register(id, job) {
23058
23943
  this.jobs.set(id, job);
23059
23944
  job.catch((error) => {
@@ -23082,7 +23967,8 @@ class JobRunner {
23082
23967
  const handle = record.resume;
23083
23968
  const base = baseOf(record);
23084
23969
  const adapter = adapterForProtocol(record.protocol);
23085
- const resume = adapter?.resume;
23970
+ const video = record.modality === "video";
23971
+ const resume = video ? adapter?.resumeVideo : adapter?.resume;
23086
23972
  if (handle === undefined || adapter === undefined || resume === undefined) {
23087
23973
  await this.append(base, "failed", { error: { kind: "other", message: lostMessage } });
23088
23974
  throw new JobFailedError(record.id, "other", lostMessage);
@@ -23093,7 +23979,12 @@ class JobRunner {
23093
23979
  }
23094
23980
  const credential = await requireCredential(this.deps.dirs, provider);
23095
23981
  const target = { providerId: provider.id, baseUrl: provider.baseUrl, modelId: record.modelId, credential };
23096
- return this.track(base, credential, (context) => resume.call(adapter, handle, target, this.deps.http, context).wait());
23982
+ if (video) {
23983
+ const resumeVideo = adapter.resumeVideo;
23984
+ return this.track(base, credential, async (context) => videoAsImages(await resumeVideo.call(adapter, handle, target, this.deps.http, context).wait()));
23985
+ }
23986
+ const resumeImages = adapter.resume;
23987
+ return this.track(base, credential, (context) => resumeImages.call(adapter, handle, target, this.deps.http, context).wait());
23097
23988
  }
23098
23989
  async track(base, credential, run) {
23099
23990
  const safe = (text) => redact(text, credential);
@@ -23104,14 +23995,22 @@ class JobRunner {
23104
23995
  throw new JobAbandoned;
23105
23996
  const startedAt = new Date(base.createdAt);
23106
23997
  const folder = base.parameters.outputPath ?? join4(this.deps.dirs.output, localDate(startedAt));
23107
- const saved = await saveImages(this.deps.http, folder, base.id, startedAt, result.images, base.provider);
23998
+ const saved = await saveFiles(this.deps.http, folder, base.id, startedAt, result.images, base.provider);
23108
23999
  const reasons = [result.shortfall, ...saved.failures.map((failure) => failure.message)].filter((reason) => reason !== undefined);
23109
24000
  if (saved.files.length === 0) {
23110
- throw saved.failures[0] ?? providerError("other", base.provider, reasons[0] ?? "it returned no images.");
24001
+ throw saved.failures[0] ?? providerError("other", base.provider, reasons[0] ?? "it returned nothing.");
23111
24002
  }
24003
+ const observed = saved.facts;
24004
+ const mismatch = mismatchOf(base.parameters, observed);
23112
24005
  const shortfall = saved.files.length >= n ? undefined : safe(`Got ${saved.files.length} of ${n} images from ${providerLabel(base.provider)}.${reasons.length === 0 ? "" : ` ${reasons.join(" ")}`}`);
23113
- await this.append(base, "completed", { files: saved.files, shortfall, cost: result.cost });
23114
- return { id: base.id, files: saved.files, ...shortfall === undefined ? {} : { shortfall } };
24006
+ await this.append(base, "completed", { files: saved.files, shortfall, cost: result.cost, observed, mismatch });
24007
+ return {
24008
+ id: base.id,
24009
+ files: saved.files,
24010
+ observed,
24011
+ ...shortfall === undefined ? {} : { shortfall },
24012
+ ...mismatch === undefined ? {} : { mismatch }
24013
+ };
23115
24014
  } catch (error) {
23116
24015
  if (error instanceof JobAbandoned || this.closed)
23117
24016
  throw new JobAbandoned;
@@ -23135,18 +24034,50 @@ class JobRunner {
23135
24034
  }
23136
24035
  }
23137
24036
  function baseOf(record) {
23138
- const { type, id, createdAt, model, provider, protocol, modelId, prompt, parameters } = record;
23139
- return { type, id, createdAt, model, provider, protocol, modelId, prompt, parameters };
24037
+ const { type, id, createdAt, model, provider, protocol, modelId, modality, prompt, parameters, comparison } = record;
24038
+ return { type, id, createdAt, model, provider, protocol, modelId, ...modality === undefined ? {} : { modality }, prompt, parameters, ...comparison === undefined ? {} : { comparison } };
24039
+ }
24040
+ function targetOf(spec) {
24041
+ return { providerId: spec.provider.id, baseUrl: spec.provider.baseUrl, modelId: spec.model.modelId, credential: spec.credential };
24042
+ }
24043
+ function videoAsImages(result) {
24044
+ return { images: [result.video], ...result.cost === undefined ? {} : { cost: result.cost } };
24045
+ }
24046
+ var aspectTolerance = Math.log(1.05);
24047
+ var durationToleranceSeconds = 0.5;
24048
+ function expectedShape(parameters) {
24049
+ const { width, height } = parameters.providerOptions;
24050
+ if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) {
24051
+ return { ratio: width / height, asked: `${width}x${height} in provider_options` };
24052
+ }
24053
+ return { ratio: ratioOf(parameters.size.aspectRatio), asked: `${parameters.size.aspectRatio} (${formatSize(parameters.size)})` };
24054
+ }
24055
+ function mismatchOf(parameters, observed) {
24056
+ const { seconds, followsFirstFrame } = parameters;
24057
+ const expected = expectedShape(parameters);
24058
+ const problems = new Set;
24059
+ for (const facts of observed) {
24060
+ if (facts === null)
24061
+ continue;
24062
+ if (!followsFirstFrame && Math.abs(Math.log(facts.width / facts.height / expected.ratio)) > aspectTolerance) {
24063
+ problems.add(`asked for ${expected.asked}, got ${facts.width}x${facts.height}`);
24064
+ }
24065
+ if (seconds !== undefined && facts.seconds !== undefined && Math.abs(facts.seconds - seconds) > durationToleranceSeconds) {
24066
+ problems.add(`asked for ${seconds} s, got ${facts.seconds} s`);
24067
+ }
24068
+ }
24069
+ return problems.size === 0 ? undefined : [...problems].join("; ");
23140
24070
  }
23141
24071
  function redact(text, credential) {
23142
24072
  return text.split(credential).join("[redacted]");
23143
24073
  }
23144
- async function saveImages(http, folder, jobId, startedAt, images, providerId) {
24074
+ async function saveFiles(http, folder, jobId, startedAt, results, providerId) {
23145
24075
  await mkdir2(folder, { recursive: true });
23146
24076
  const base = `${localTime(startedAt)}-${jobId.slice(0, 8)}`;
23147
24077
  const files = [];
24078
+ const facts = [];
23148
24079
  const failures = [];
23149
- for (const [index, image] of images.entries()) {
24080
+ for (const [index, image] of results.entries()) {
23150
24081
  let bytes;
23151
24082
  try {
23152
24083
  bytes = "bytes" in image ? image.bytes : await download(http, image.url, providerId);
@@ -23156,12 +24087,13 @@ async function saveImages(http, folder, jobId, startedAt, images, providerId) {
23156
24087
  failures.push(error);
23157
24088
  continue;
23158
24089
  }
23159
- const suffix = images.length === 1 ? "" : `-${index + 1}`;
23160
- const file = join4(folder, `${base}${suffix}.${imageTypeOf(bytes)?.extension ?? "bin"}`);
24090
+ const suffix = results.length === 1 ? "" : `-${index + 1}`;
24091
+ const file = join4(folder, `${base}${suffix}.${fileTypeOf(bytes)?.extension ?? "bin"}`);
23161
24092
  await writeFile(file, bytes);
23162
24093
  files.push(file);
24094
+ facts.push(mediaFactsOf(bytes) ?? null);
23163
24095
  }
23164
- return { files, failures };
24096
+ return { files, facts, failures };
23165
24097
  }
23166
24098
  async function download(http, url, providerId) {
23167
24099
  const response = await http(url).catch((error) => {
@@ -23271,6 +24203,13 @@ function isPlainObject3(value) {
23271
24203
  }
23272
24204
 
23273
24205
  // src/model-info.ts
24206
+ function videoModelInfo({ model }) {
24207
+ const adapter = adapterFor(model);
24208
+ if (adapter.builtInVideoCapabilities === undefined || adapter.submitVideo === undefined) {
24209
+ throw new ConfigError(`Model "${model.alias}" is a video Model, but its Protocol "${model.protocol}" makes no videos in litevision.`);
24210
+ }
24211
+ return { capabilities: adapter.builtInVideoCapabilities(model.modelId), docsUrl: adapter.docsUrl(model.modelId, "video") };
24212
+ }
23274
24213
  var modelInfoTtlMs = 24 * 60 * 60 * 1000;
23275
24214
  var defaultModelInfoTimeoutMs = 5000;
23276
24215
  var sizeSchema = object({
@@ -23297,7 +24236,7 @@ async function modelInfo(deps, resolved, options = {}) {
23297
24236
  const builtIn = {
23298
24237
  capabilities: adapter.builtInCapabilities(resolved.model.modelId),
23299
24238
  source: "built-in",
23300
- docsUrl: adapter.docsUrl(resolved.model.modelId)
24239
+ docsUrl: adapter.docsUrl(resolved.model.modelId, "image")
23301
24240
  };
23302
24241
  const fetchModelInfo = adapter.fetchModelInfo;
23303
24242
  if (fetchModelInfo === undefined)
@@ -23427,7 +24366,7 @@ function updateNotesAsAgent(dirs, alias, text, at) {
23427
24366
  throw needsSettings(`The Models in config.json are invalid: ${prettifyError(parsed.error)}`);
23428
24367
  const stored = models;
23429
24368
  if (stored.length === 0)
23430
- throw needsSettings("litevision has no image Model configured yet.");
24369
+ throw needsSettings("litevision has no Model configured yet.");
23431
24370
  const model = stored.find((candidate) => candidate.alias === alias);
23432
24371
  if (model === undefined) {
23433
24372
  throw new ConfigError(`Unknown Model Alias "${alias}". Known Aliases: ${stored.map((candidate) => candidate.alias).join(", ")}.`);
@@ -23438,60 +24377,6 @@ function updateNotesAsAgent(dirs, alias, text, at) {
23438
24377
  });
23439
24378
  }
23440
24379
 
23441
- // src/protocols/capabilities.ts
23442
- function describeCapabilities(capabilities) {
23443
- const ratios = capabilities.sizes.map((size) => `${size.aspectRatio} (${formatSize(size)})`).join(", ");
23444
- const references = capabilities.maxReferences === 0 ? "no reference images" : `up to ${capabilities.maxReferences} reference image${capabilities.maxReferences === 1 ? "" : "s"}`;
23445
- return `aspect ratios ${ratios}; up to ${capabilities.maxImages} image${capabilities.maxImages === 1 ? "" : "s"} per Job; ${references}.`;
23446
- }
23447
- function closestSize(sizes, wanted) {
23448
- const exact = sizes.find((size) => size.aspectRatio === wanted);
23449
- if (exact !== undefined)
23450
- return { size: exact, adjusted: false };
23451
- const target = Math.log(ratioOf(wanted));
23452
- const distance = (size) => Math.abs(Math.log(size.width / size.height) - target);
23453
- const [first, ...rest] = sizes;
23454
- if (first === undefined)
23455
- throw new Error("A Protocol adapter declared no supported sizes.");
23456
- const size = rest.reduce((best, size) => distance(size) < distance(best) ? size : best, first);
23457
- return { size, adjusted: true };
23458
- }
23459
- function ratioOf(aspectRatio) {
23460
- const [width, height] = aspectRatio.split(":").map(Number);
23461
- return width / height;
23462
- }
23463
- async function normaliseImageRequest(parameters, model, adapter, described, cwd) {
23464
- const { capabilities } = described;
23465
- const unsupported = (problem) => new ConfigError(`${problem} Model "${model.alias}" supports: ${describeCapabilities(capabilities)} Choose another Model or change the request.`);
23466
- if (parameters.n > capabilities.maxImages) {
23467
- throw unsupported(`Model "${model.alias}" cannot generate ${parameters.n} images in one Job.`);
23468
- }
23469
- if (parameters.references.length > 0 && capabilities.maxReferences === 0) {
23470
- throw unsupported(`Model "${model.alias}" cannot take reference images.`);
23471
- }
23472
- if (parameters.references.length > capabilities.maxReferences) {
23473
- throw unsupported(`Model "${model.alias}" cannot take ${parameters.references.length} reference images.`);
23474
- }
23475
- const reserved = Object.keys(parameters.providerOptions).filter((key) => adapter.reservedOptions.includes(key));
23476
- if (reserved.length > 0) {
23477
- throw new ConfigError(`provider_options may not set ${reserved.map((key) => `\`${key}\``).join(", ")}: litevision sets ${reserved.length === 1 ? "it" : "them"} from the \`generate_image\` parameters. Use those instead.`);
23478
- }
23479
- const { size, adjusted } = closestSize(capabilities.sizes, parameters.aspectRatio);
23480
- const references = await readReferences(parameters.references, cwd);
23481
- return {
23482
- request: {
23483
- prompt: parameters.prompt,
23484
- n: parameters.n,
23485
- size,
23486
- references,
23487
- providerOptions: parameters.providerOptions,
23488
- model: { capabilities, ...described.parameterSchema === undefined ? {} : { parameterSchema: described.parameterSchema } }
23489
- },
23490
- requestedAspectRatio: parameters.aspectRatio,
23491
- aspectRatioAdjusted: adjusted
23492
- };
23493
- }
23494
-
23495
24380
  // src/settings/browser.ts
23496
24381
  import { spawn } from "node:child_process";
23497
24382
  var openInBrowser = (url) => {
@@ -23522,7 +24407,8 @@ var providerPresets = [
23522
24407
  { id: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1", protocol: "openai-images" },
23523
24408
  { id: "gemini", label: "Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta", protocol: "gemini" },
23524
24409
  { id: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1", protocol: "venice" },
23525
- { id: "apimart", label: "APIMart", baseUrl: "https://api.apimart.ai/v1", protocol: "apimart" }
24410
+ { id: "apimart", label: "APIMart", baseUrl: "https://api.apimart.ai/v1", protocol: "apimart" },
24411
+ { id: "together", label: "Together AI", baseUrl: "https://api.together.ai", protocol: "together" }
23526
24412
  ];
23527
24413
  function shortName(what) {
23528
24414
  return string2().trim().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/, `${what} is 1–64 letters, digits, '.', '_' or '-', starting with a letter or digit.`);
@@ -23632,7 +24518,7 @@ async function testConnection(dirs, http, providerId, protocol) {
23632
24518
  }
23633
24519
  return { result: outcome.ok ? "success" : "failure", message: call.redact(outcome.message) };
23634
24520
  }
23635
- async function fetchModelIds4(dirs, http, providerId, protocol) {
24521
+ async function fetchModelIds5(dirs, http, providerId, protocol) {
23636
24522
  const typeInstead = "You can still type the model id.";
23637
24523
  const call = await prepare(dirs, providerId, protocol, (adapter) => adapter.listModelIds);
23638
24524
  if (call === "unsupported") {
@@ -23644,7 +24530,7 @@ async function fetchModelIds4(dirs, http, providerId, protocol) {
23644
24530
  try {
23645
24531
  const list = await call.run(http);
23646
24532
  if (list.ok)
23647
- return { result: "success", modelIds: list.modelIds };
24533
+ return { result: "success", modelIds: list.modelIds, ...list.modalities === undefined ? {} : { modalities: list.modalities } };
23648
24534
  message = list.message;
23649
24535
  } catch (error) {
23650
24536
  message = unreachable(providerId, error);
@@ -23675,24 +24561,69 @@ function unreachable(providerId, error) {
23675
24561
  // src/settings/job-history.ts
23676
24562
  import { readFile as readFile6 } from "node:fs/promises";
23677
24563
  var rateJobInput = object({
24564
+ result: number2({ message: "Choose a result by its number." }).int("Choose a result by its number.").positive("Choose a result by its number.").optional(),
23678
24565
  verdict: _enum(verdicts, { message: "Choose good or bad." }),
23679
24566
  reason: string2().max(ratingReasonMaxLength, `Keep the reason to at most ${ratingReasonMaxLength} characters.`)
23680
24567
  });
24568
+ var pickInput = object({
24569
+ jobId: string2({ message: "Choose one of the Comparison's Jobs." }).min(1, "Choose one of the Comparison's Jobs."),
24570
+ result: number2({ message: "Choose a result by its number." }).int("Choose a result by its number.").positive("Choose a result by its number.").optional()
24571
+ });
23681
24572
  async function listJobHistory(dirs) {
23682
- return (await readRatedJobs(dirs)).map(({ job, rating }) => ({
23683
- id: job.id,
23684
- createdAt: job.createdAt,
23685
- model: job.model,
23686
- modelId: job.modelId,
23687
- provider: job.provider,
23688
- prompt: job.prompt,
23689
- durationMs: job.durationMs,
23690
- outcome: job.outcome,
23691
- files: job.files.map((path, index) => ({ path, thumbnail: `/api/jobs/${encodeURIComponent(job.id)}/files/${index}` })),
23692
- rating: rating === undefined ? null : listedRating(rating),
23693
- ...job.error === undefined ? {} : { error: job.error.message },
23694
- ...job.shortfall === undefined ? {} : { shortfall: job.shortfall }
23695
- }));
24573
+ const listed = (await readRatedJobs(dirs)).map((rated) => {
24574
+ const { job, pick } = rated;
24575
+ const listedRatingOf = (result) => {
24576
+ const current = ratingOf(rated, result);
24577
+ return current === undefined ? null : listedRating(current);
24578
+ };
24579
+ return {
24580
+ id: job.id,
24581
+ createdAt: job.createdAt,
24582
+ model: job.model,
24583
+ modelId: job.modelId,
24584
+ provider: job.provider,
24585
+ prompt: job.prompt,
24586
+ durationMs: job.durationMs,
24587
+ outcome: job.outcome,
24588
+ modality: job.modality ?? "image",
24589
+ files: job.files.map((path, index) => ({
24590
+ path,
24591
+ thumbnail: `/api/jobs/${encodeURIComponent(job.id)}/files/${index}`,
24592
+ rating: listedRatingOf(index + 1)
24593
+ })),
24594
+ rating: listedRatingOf(),
24595
+ ...job.error === undefined ? {} : { error: job.error.message },
24596
+ ...job.shortfall === undefined ? {} : { shortfall: job.shortfall },
24597
+ ...job.mismatch === undefined ? {} : { mismatch: `The result does not match the request: ${job.mismatch}.` },
24598
+ ...job.comparison === undefined ? {} : { comparison: { id: job.comparison, pick: pick === undefined ? null : listedPick(pick) } }
24599
+ };
24600
+ });
24601
+ const byComparison = new Map;
24602
+ for (const job of listed) {
24603
+ if (job.comparison === undefined)
24604
+ continue;
24605
+ const ofComparison = byComparison.get(job.comparison.id) ?? [];
24606
+ byComparison.set(job.comparison.id, ofComparison);
24607
+ ofComparison.unshift(job);
24608
+ }
24609
+ return listed.flatMap((job) => {
24610
+ if (job.comparison === undefined)
24611
+ return [job];
24612
+ const ofComparison = byComparison.get(job.comparison.id);
24613
+ if (ofComparison === undefined)
24614
+ return [];
24615
+ byComparison.delete(job.comparison.id);
24616
+ return ofComparison;
24617
+ });
24618
+ }
24619
+ async function pickFromPage(dirs, comparison, input, now) {
24620
+ try {
24621
+ return listedPick(await pickResult(dirs, { comparison, ...input }, now));
24622
+ } catch (error) {
24623
+ if (!(error instanceof PickError))
24624
+ throw error;
24625
+ throw new SettingsError({ "unknown-comparison": 404, "not-in-comparison": 400, "not-completed": 409, invalid: 400 }[error.kind], error.message);
24626
+ }
23696
24627
  }
23697
24628
  async function rateJobFromPage(dirs, jobId, input, now) {
23698
24629
  try {
@@ -23707,18 +24638,21 @@ async function jobImage(dirs, jobId, index) {
23707
24638
  const job = await readJob(dirs, jobId);
23708
24639
  const path = /^\d+$/.test(index) ? job?.files[Number(index)] : undefined;
23709
24640
  if (path === undefined)
23710
- throw new SettingsError(404, "No such image in the Job history.");
24641
+ throw new SettingsError(404, "No such file in the Job history.");
23711
24642
  let bytes;
23712
24643
  try {
23713
24644
  bytes = await readFile6(path);
23714
24645
  } catch {
23715
- throw new SettingsError(404, "The image is no longer on disk.");
24646
+ throw new SettingsError(404, "The file is no longer on disk.");
23716
24647
  }
23717
- const type = imageTypeOf(bytes);
24648
+ const type = fileTypeOf(bytes);
23718
24649
  if (type === undefined)
23719
- throw new SettingsError(404, "The file is no longer an image.");
24650
+ throw new SettingsError(404, "The file is no longer an image or a video.");
23720
24651
  return { bytes, contentType: type.mimeType };
23721
24652
  }
24653
+ function listedPick(pick) {
24654
+ return { jobId: pick.jobId, ...pick.result === undefined ? {} : { result: pick.result }, pickedAt: pick.pickedAt };
24655
+ }
23722
24656
  function listedRating(rating) {
23723
24657
  return { verdict: rating.verdict, reason: rating.reason, ratedAt: rating.ratedAt };
23724
24658
  }
@@ -23731,14 +24665,18 @@ var addModelInput = object({
23731
24665
  provider: string2().min(1, "Choose a Provider."),
23732
24666
  modelId: string2().trim().min(1, "Enter a model id.").max(200, "A model id is at most 200 characters."),
23733
24667
  protocol: string2().refine((value) => supportedProtocols().includes(value), { error: () => `Choose a supported Protocol: ${supportedProtocols().join(", ")}.` }),
24668
+ modality: _enum(modalities, { error: () => "Choose whether the Model makes images or videos." }).default("image"),
23734
24669
  notes: notes.optional()
23735
24670
  });
23736
- var updateModelInput = addModelInput.partial();
24671
+ var updateModelInput = addModelInput.omit({ modality: true }).partial().extend({ modality: addModelInput.shape.modality.unwrap().optional() });
23737
24672
  var storedModels = array(modelSchema);
23738
24673
  async function listModels2(dirs) {
23739
24674
  const config = await readConfigObject(dirs);
23740
- const defaultImageModel = typeof config.defaultImageModel === "string" ? config.defaultImageModel : null;
23741
- return { models: modelsOf(config).map((model) => view2(model, defaultImageModel)), defaultImageModel };
24675
+ const defaults = {
24676
+ defaultImageModel: typeof config.defaultImageModel === "string" ? config.defaultImageModel : null,
24677
+ defaultVideoModel: typeof config.defaultVideoModel === "string" ? config.defaultVideoModel : null
24678
+ };
24679
+ return { models: modelsOf(config).map((model) => view2(model, defaults)), ...defaults };
23742
24680
  }
23743
24681
  async function getModel(dirs, wanted) {
23744
24682
  const model = (await listModels2(dirs)).models.find((candidate) => candidate.alias === wanted);
@@ -23751,7 +24689,13 @@ async function addModel(dirs, input, at) {
23751
24689
  const models = modelsOf(config);
23752
24690
  requireFreeAlias(models, input.alias);
23753
24691
  requireProvider(config, input.provider);
23754
- const stored = { alias: input.alias, provider: input.provider, modelId: input.modelId, protocol: input.protocol };
24692
+ const stored = {
24693
+ alias: input.alias,
24694
+ provider: input.provider,
24695
+ modelId: input.modelId,
24696
+ protocol: input.protocol,
24697
+ modality: input.modality
24698
+ };
23755
24699
  if (input.notes) {
23756
24700
  stored.notes = input.notes;
23757
24701
  stored.notesHistory = { changedBy: "User", changedAt: at.toISOString() };
@@ -23769,8 +24713,14 @@ async function updateModel(dirs, wanted, input, at) {
23769
24713
  if (input.alias !== undefined && input.alias !== wanted) {
23770
24714
  requireFreeAlias(models, input.alias);
23771
24715
  model.alias = input.alias;
23772
- if (config.defaultImageModel === wanted)
23773
- config.defaultImageModel = input.alias;
24716
+ for (const key of Object.values(defaultKey))
24717
+ if (config[key] === wanted)
24718
+ config[key] = input.alias;
24719
+ }
24720
+ if (input.modality !== undefined && input.modality !== modalityOf(model)) {
24721
+ if (config[defaultKey[modalityOf(model)]] === model.alias)
24722
+ delete config[defaultKey[modalityOf(model)]];
24723
+ model.modality = input.modality;
23774
24724
  }
23775
24725
  if (input.provider !== undefined) {
23776
24726
  requireProvider(config, input.provider);
@@ -23801,22 +24751,28 @@ async function removeModel(dirs, wanted) {
23801
24751
  if (!models.some((model) => model.alias === wanted))
23802
24752
  throw notFound2(wanted);
23803
24753
  config.models = models.filter((model) => model.alias !== wanted);
23804
- if (config.defaultImageModel === wanted)
23805
- delete config.defaultImageModel;
24754
+ for (const key of Object.values(defaultKey))
24755
+ if (config[key] === wanted)
24756
+ delete config[key];
23806
24757
  });
23807
24758
  }
23808
24759
  var setDefaultInput = object({ alias: string2().nullable() });
23809
- async function setDefaultImageModel(dirs, wanted) {
24760
+ async function setDefaultModel(dirs, modality, wanted) {
24761
+ const key = defaultKey[modality];
23810
24762
  await updateConfig(dirs, (config) => {
23811
24763
  if (wanted === null) {
23812
- delete config.defaultImageModel;
24764
+ delete config[key];
23813
24765
  return;
23814
24766
  }
23815
- if (!modelsOf(config).some((model) => model.alias === wanted))
24767
+ const model = modelsOf(config).find((candidate) => candidate.alias === wanted);
24768
+ if (model === undefined)
23816
24769
  throw notFound2(wanted);
23817
- config.defaultImageModel = wanted;
24770
+ if (modalityOf(model) !== modality) {
24771
+ throw new SettingsError(400, `"${wanted}" is ${modalityOf(model) === "image" ? "an image" : "a video"} Model, so it cannot be the default ${modality} Model.`);
24772
+ }
24773
+ config[key] = wanted;
23818
24774
  });
23819
- return { defaultImageModel: wanted };
24775
+ return { alias: wanted };
23820
24776
  }
23821
24777
  function requireFreeAlias(models, wanted) {
23822
24778
  if (models.some((model) => model.alias === wanted)) {
@@ -23837,15 +24793,20 @@ function modelsOf(config) {
23837
24793
  throw new SettingsError(409, `The Models in config.json are invalid: ${prettifyError(parsed.error)}`);
23838
24794
  return config.models;
23839
24795
  }
23840
- function view2(model, defaultImageModel) {
24796
+ function modalityOf(model) {
24797
+ return model.modality ?? "image";
24798
+ }
24799
+ function view2(model, defaults) {
24800
+ const modality = modalityOf(model);
23841
24801
  return {
23842
24802
  alias: model.alias,
23843
24803
  provider: model.provider,
23844
24804
  modelId: model.modelId,
23845
24805
  protocol: model.protocol,
24806
+ modality,
23846
24807
  notes: model.notes ?? "",
23847
24808
  ...model.notesHistory && { notesHistory: model.notesHistory },
23848
- default: model.alias === defaultImageModel
24809
+ default: model.alias === (modality === "video" ? defaults.defaultVideoModel : defaults.defaultImageModel)
23849
24810
  };
23850
24811
  }
23851
24812
  function notFound2(wanted) {
@@ -23910,7 +24871,7 @@ var routes = [
23910
24871
  path: "/api/providers/:id/model-ids",
23911
24872
  handler: async ({ dirs, http, params, body }) => {
23912
24873
  const { protocol } = parse6(protocolInput, body);
23913
- return { body: await fetchModelIds4(dirs, http, params.id, protocol) };
24874
+ return { body: await fetchModelIds5(dirs, http, params.id, protocol) };
23914
24875
  }
23915
24876
  },
23916
24877
  {
@@ -23957,7 +24918,16 @@ var routes = [
23957
24918
  {
23958
24919
  method: "PUT",
23959
24920
  path: "/api/default-image-model",
23960
- handler: async ({ dirs, body }) => ({ body: await setDefaultImageModel(dirs, parse6(setDefaultInput, body).alias) })
24921
+ handler: async ({ dirs, body }) => ({
24922
+ body: { defaultImageModel: (await setDefaultModel(dirs, "image", parse6(setDefaultInput, body).alias)).alias }
24923
+ })
24924
+ },
24925
+ {
24926
+ method: "PUT",
24927
+ path: "/api/default-video-model",
24928
+ handler: async ({ dirs, body }) => ({
24929
+ body: { defaultVideoModel: (await setDefaultModel(dirs, "video", parse6(setDefaultInput, body).alias)).alias }
24930
+ })
23961
24931
  },
23962
24932
  {
23963
24933
  method: "GET",
@@ -23971,6 +24941,13 @@ var routes = [
23971
24941
  body: { rating: await rateJobFromPage(dirs, params.id, parse6(rateJobInput, body), now()) }
23972
24942
  })
23973
24943
  },
24944
+ {
24945
+ method: "POST",
24946
+ path: "/api/comparisons/:id/pick",
24947
+ handler: async ({ dirs, now, params, body }) => ({
24948
+ body: { pick: await pickFromPage(dirs, params.id, parse6(pickInput, body), now()) }
24949
+ })
24950
+ },
23974
24951
  {
23975
24952
  method: "GET",
23976
24953
  path: "/api/jobs/:id/files/:index",
@@ -24119,11 +25096,20 @@ function renderPage(nonce) {
24119
25096
  .prompt { white-space: pre-wrap; overflow-wrap: anywhere; }
24120
25097
  .thumbs { display: flex; flex-wrap: wrap; gap: 8px; }
24121
25098
  .thumb { width: 96px; height: 96px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); }
25099
+ .thumb.video { width: 192px; height: 108px; }
24122
25100
  .thumb.placeholder { display: flex; align-items: center; justify-content: center; text-align: center; padding: 6px;
24123
25101
  font-size: 11px; color: var(--muted); border-style: dashed; }
25102
+ .result-thumb { margin: 0; display: grid; gap: 4px; width: min-content; }
25103
+ .result-thumb figcaption { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
24124
25104
  .actions.rate { display: grid; gap: 10px; }
25105
+ .inline > select.rate-target { flex: 0 0 auto; }
24125
25106
  .rating { font-size: 13px; }
24126
25107
  .rating.good { color: var(--ok); } .rating.bad { color: var(--danger); }
25108
+ .comparison { display: grid; gap: 12px; border: 1px solid var(--border); border-left: 3px solid var(--accent);
25109
+ border-radius: 12px; padding: 16px; margin-bottom: 12px; }
25110
+ .comparison > .card { margin-bottom: 0; }
25111
+ .comparison-head p { font-size: 13px; color: var(--muted); margin-top: 2px; }
25112
+ .picked { color: var(--accent); font-weight: 500; }
24127
25113
 
24128
25114
  #status { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); max-width: calc(100% - 32px);
24129
25115
  background: var(--text); color: var(--bg); padding: 8px 14px; border-radius: 8px; font-size: 13px; display: none;
@@ -24166,6 +25152,7 @@ function renderPage(nonce) {
24166
25152
  <div class="fields">
24167
25153
  <div class="field"><label for="model-provider">Provider</label><select id="model-provider" required></select></div>
24168
25154
  <div class="field"><label for="model-protocol">Protocol</label><select id="model-protocol" required></select></div>
25155
+ <div class="field"><label for="model-modality">Makes</label><select id="model-modality" required><option value="image">Images</option><option value="video">Videos</option></select></div>
24169
25156
  <div class="field wide">
24170
25157
  <label for="model-id">Model id</label>
24171
25158
  <div class="inline"><input id="model-id" required spellcheck="false" list="model-ids" placeholder="gpt-image-1"><button id="fetch-models" type="button">Fetch model list</button></div>
@@ -24182,7 +25169,7 @@ function renderPage(nonce) {
24182
25169
  <section id="history">
24183
25170
  <div class="section-head">
24184
25171
  <h2>Job history</h2>
24185
- <p>Newest first. Rate a Job to keep your verdict on it; rating it again replaces the earlier Rating.</p>
25172
+ <p>Newest first. Rate a Job, or one of its results, to keep your verdict on it; rating it again replaces the earlier Rating. In a Comparison, pick the result you prefer; picking again replaces the earlier Pick.</p>
24186
25173
  </div>
24187
25174
  <div id="job-list"><p class="muted">Loading…</p></div>
24188
25175
  </section>
@@ -24395,20 +25382,21 @@ function modelCard(model) {
24395
25382
  const provider = el("select", {}, ...options(providerIds.includes(model.provider) ? providerIds : [model.provider, ...providerIds], model.provider));
24396
25383
  const modelId = el("input", { value: model.modelId, spellcheck: false });
24397
25384
  const protocol = el("select", {}, ...options(protocols.includes(model.protocol) ? protocols : [model.protocol, ...protocols], model.protocol));
25385
+ const modality = el("select", {}, ...options(["image", "video"], model.modality));
24398
25386
  const notes = notesField(model.notes);
24399
25387
  const path = "/api/models/" + encodeURIComponent(model.alias);
24400
25388
 
24401
25389
  const save = el("button", { type: "button", className: "primary", textContent: "Save" });
24402
25390
  save.onclick = () => act(save, async () => {
24403
- await api("PATCH", path, { alias: alias.value, provider: provider.value, modelId: modelId.value, protocol: protocol.value, notes: notes.textarea.value });
25391
+ await api("PATCH", path, { alias: alias.value, provider: provider.value, modelId: modelId.value, protocol: protocol.value, modality: modality.value, notes: notes.textarea.value });
24404
25392
  await loadModels();
24405
25393
  }, "Model saved.");
24406
25394
 
24407
25395
  const makeDefault = el("button", { type: "button", className: "small", textContent: "Make default" });
24408
25396
  makeDefault.onclick = () => act(makeDefault, async () => {
24409
- await api("PUT", "/api/default-image-model", { alias: model.alias });
25397
+ await api("PUT", "/api/default-" + model.modality + "-model", { alias: model.alias });
24410
25398
  await loadModels();
24411
- }, model.alias + " is now the default image Model.");
25399
+ }, model.alias + " is now the default " + model.modality + " Model.");
24412
25400
 
24413
25401
  const remove = el("button", { type: "button", className: "danger", textContent: "Remove Model" });
24414
25402
  remove.onclick = () => {
@@ -24423,9 +25411,9 @@ function modelCard(model) {
24423
25411
 
24424
25412
  return el("div", { className: "card" },
24425
25413
  el("div", { className: "card-head" }, el("h3", { textContent: model.alias }),
24426
- model.default ? el("span", { className: "chip accent", textContent: "Default" }) : makeDefault),
25414
+ model.default ? el("span", { className: "chip accent", textContent: "Default " + model.modality }) : makeDefault),
24427
25415
  el("div", { className: "fields" },
24428
- field("Alias", alias), field("Provider", provider), field("Model id", modelId), field("Protocol", protocol), notes.wrapper),
25416
+ field("Alias", alias), field("Provider", provider), field("Model id", modelId), field("Protocol", protocol), field("Makes", modality), notes.wrapper),
24429
25417
  el("div", { className: "actions" }, save, el("span", { className: "spacer" }), remove),
24430
25418
  );
24431
25419
  }
@@ -24447,6 +25435,9 @@ function setUpAddModelForm() {
24447
25435
  const fetchButton = document.getElementById("fetch-models");
24448
25436
  const fetchResult = document.getElementById("fetch-result");
24449
25437
  const ids = document.getElementById("model-ids");
25438
+ const modality = document.getElementById("model-modality");
25439
+ // What the fetched model list says each model id makes, when the Provider says.
25440
+ let knownModalities = {};
24450
25441
  protocol.replaceChildren(...options(protocols));
24451
25442
 
24452
25443
  // Suggest the Protocol a preset Provider's Models usually speak; the User can still change it.
@@ -24454,16 +25445,21 @@ function setUpAddModelForm() {
24454
25445
  const suggested = presets.find((p) => p.id === provider.value);
24455
25446
  if (suggested && protocols.includes(suggested.protocol)) protocol.value = suggested.protocol;
24456
25447
  ids.replaceChildren();
25448
+ knownModalities = {};
24457
25449
  fetchResult.textContent = "";
24458
25450
  };
24459
- protocol.onchange = () => ids.replaceChildren();
25451
+ protocol.onchange = () => {
25452
+ ids.replaceChildren();
25453
+ knownModalities = {};
25454
+ };
24460
25455
 
24461
25456
  fetchButton.onclick = () => act(fetchButton, async () => {
24462
25457
  fetchResult.textContent = "Fetching…";
24463
25458
  fetchResult.className = "result";
24464
25459
  const outcome = await api("POST", "/api/providers/" + encodeURIComponent(provider.value) + "/model-ids", { protocol: protocol.value });
24465
25460
  if (outcome.result === "success") {
24466
- ids.replaceChildren(...outcome.modelIds.map((id) => el("option", { value: id })));
25461
+ knownModalities = outcome.modalities || {};
25462
+ ids.replaceChildren(...outcome.modelIds.map((id) => el("option", { value: id, label: knownModalities[id] ? id + " (" + knownModalities[id] + ")" : id })));
24467
25463
  fetchResult.textContent = outcome.modelIds.length + " model id(s) found. Pick one in the Model id field, or type your own.";
24468
25464
  fetchResult.className = "result success";
24469
25465
  modelId.focus();
@@ -24477,13 +25473,15 @@ function setUpAddModelForm() {
24477
25473
  let aliasEdited = false;
24478
25474
  alias.oninput = () => (aliasEdited = alias.value !== "");
24479
25475
  modelId.oninput = () => {
25476
+ const known = knownModalities[modelId.value.trim()];
25477
+ if (known) modality.value = known;
24480
25478
  if (!aliasEdited) alias.value = modelId.value.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
24481
25479
  };
24482
25480
 
24483
25481
  const form = document.getElementById("add-model");
24484
25482
  form.onsubmit = (event) => {
24485
25483
  event.preventDefault();
24486
- const body = { alias: alias.value, provider: provider.value, modelId: modelId.value, protocol: protocol.value };
25484
+ const body = { alias: alias.value, provider: provider.value, modelId: modelId.value, protocol: protocol.value, modality: modality.value };
24487
25485
  if (notes.value.trim() !== "") body.notes = notes.value;
24488
25486
  act(form.querySelector("button[type=submit]"), async () => {
24489
25487
  await api("POST", "/api/models", body);
@@ -24524,61 +25522,147 @@ function setUpAddForm() {
24524
25522
  };
24525
25523
  }
24526
25524
 
24527
- /** An image of a Job, or a placeholder once the file is gone. */
24528
- function thumbnail(file) {
24529
- const img = el("img", { className: "thumb", src: file.thumbnail + "?token=" + encodeURIComponent(token), alt: file.path, title: file.path, loading: "lazy" });
24530
- img.onerror = () => img.replaceWith(el("div", { className: "thumb placeholder", title: file.path, textContent: "Image no longer on disk" }));
24531
- return img;
25525
+ /** An image or video of a Job, or a placeholder once the file is gone. */
25526
+ function thumbnail(job, file) {
25527
+ const src = file.thumbnail + "?token=" + encodeURIComponent(token);
25528
+ const media = job.modality === "video"
25529
+ ? el("video", { className: "thumb video", src, title: file.path, controls: true, muted: true, preload: "metadata" })
25530
+ : el("img", { className: "thumb", src, alt: file.path, title: file.path, loading: "lazy" });
25531
+ media.onerror = () => media.replaceWith(el("div", { className: "thumb placeholder", title: file.path, textContent: "File no longer on disk" }));
25532
+ return media;
24532
25533
  }
24533
25534
 
24534
25535
  function formatDuration(ms) {
24535
25536
  return ms < 1000 ? ms + " ms" : (ms / 1000).toFixed(1) + " s";
24536
25537
  }
24537
25538
 
24538
- /** The current Rating, and a one-line reason with Good and Bad buttons to rate the Job. Only completed Jobs can be rated. */
25539
+ function ratingText(rating) {
25540
+ return (rating.verdict === "good" ? "Good" : "Bad") + ": " + rating.reason;
25541
+ }
25542
+
25543
+ /** Whether this Job is its Comparison's Pick: as a whole, or with \`result\` that one result of it. */
25544
+ function isPicked(job, result) {
25545
+ const pick = job.comparison && job.comparison.pick;
25546
+ return !!pick && pick.jobId === job.id && (result === undefined || pick.result === result);
25547
+ }
25548
+
25549
+ /**
25550
+ * A thumbnail, captioned with its number, its own Rating and whether it is its Comparison's Pick, when the Job has
25551
+ * several results or this one is rated or picked.
25552
+ */
25553
+ function resultThumbnail(job, file, index) {
25554
+ const media = thumbnail(job, file);
25555
+ const picked = job.files.length > 1 && isPicked(job, index + 1);
25556
+ if (job.files.length === 1 && !file.rating) return media;
25557
+ const caption = el("figcaption", { textContent: "Result " + (index + 1) + (picked ? " · Picked" : "") + (file.rating ? " · " + ratingText(file.rating) : "") });
25558
+ if (file.rating) caption.className = "rating " + file.rating.verdict;
25559
+ else if (picked) caption.className = "picked";
25560
+ return el("figure", { className: "result-thumb" }, media, caption);
25561
+ }
25562
+
25563
+ /**
25564
+ * For a completed Job of a Comparison: a Pick button for the Job or, when it has several results, the one selected.
25565
+ * A Pick says which of the Comparison's results the User prefers; it is not a Rating.
25566
+ */
25567
+ function pickRow(job) {
25568
+ if (!job.comparison || job.outcome !== "completed") return [];
25569
+ const target = el("select", { className: "rate-target", title: "What you pick" },
25570
+ el("option", { value: "", textContent: "Whole Job" }),
25571
+ ...job.files.map((_, index) => el("option", { value: String(index + 1), textContent: "Result " + (index + 1) })));
25572
+ const button = el("button", { type: "button", textContent: "Pick" });
25573
+ button.onclick = () => act(button, async () => {
25574
+ const body = { jobId: job.id };
25575
+ if (target.value !== "") body.result = Number(target.value);
25576
+ await api("POST", "/api/comparisons/" + encodeURIComponent(job.comparison.id) + "/pick", body);
25577
+ await loadJobs();
25578
+ }, "Pick saved.");
25579
+ const controls = job.files.length > 1 ? [target, button] : [button];
25580
+ return [el("div", { className: "actions" }, el("span", { className: "spacer muted", textContent: "Prefer this one?" }), ...controls)];
25581
+ }
25582
+
25583
+ /**
25584
+ * The whole Job's Rating, and a one-line reason with Good and Bad buttons to rate the Job or, when it has several
25585
+ * results, one of them. Only completed Jobs can be rated.
25586
+ */
24539
25587
  function ratingRow(job) {
24540
25588
  if (job.outcome !== "completed") return [];
25589
+ const whole = job.files.length > 1 ? "Whole Job · " : "";
24541
25590
  const current = job.rating
24542
- ? el("span", { className: "rating " + job.rating.verdict, textContent: (job.rating.verdict === "good" ? "Good" : "Bad") + ": " + job.rating.reason })
24543
- : el("span", { className: "rating muted", textContent: "Not rated yet." });
25591
+ ? el("span", { className: "rating " + job.rating.verdict, textContent: whole + ratingText(job.rating) })
25592
+ : el("span", { className: "rating muted", textContent: whole + "Not rated yet." });
24544
25593
  const reason = el("input", { maxLength: reasonMax, placeholder: "One-line reason, e.g. the text is misspelled" });
25594
+ const target = el("select", { className: "rate-target", title: "What the Rating is about" },
25595
+ el("option", { value: "", textContent: "Whole Job" }),
25596
+ ...job.files.map((_, index) => el("option", { value: String(index + 1), textContent: "Result " + (index + 1) })));
24545
25597
  const rateAs = (verdict, label) => {
24546
25598
  const button = el("button", { type: "button", textContent: label });
24547
25599
  button.onclick = () => act(button, async () => {
24548
- await api("POST", "/api/jobs/" + encodeURIComponent(job.id) + "/rating", { verdict, reason: reason.value });
25600
+ const body = { verdict, reason: reason.value };
25601
+ if (target.value !== "") body.result = Number(target.value);
25602
+ await api("POST", "/api/jobs/" + encodeURIComponent(job.id) + "/rating", body);
24549
25603
  await loadJobs();
24550
25604
  }, "Rating saved.");
24551
25605
  return button;
24552
25606
  };
24553
- return [el("div", { className: "actions rate" }, current, el("div", { className: "inline" }, reason, rateAs("good", "Good"), rateAs("bad", "Bad")))];
25607
+ const controls = job.files.length > 1 ? [target, reason] : [reason];
25608
+ return [el("div", { className: "actions rate" }, current, el("div", { className: "inline" }, ...controls, rateAs("good", "Good"), rateAs("bad", "Bad")))];
24554
25609
  }
24555
25610
 
24556
25611
  function jobCard(job) {
24557
25612
  const when = new Date(job.createdAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
24558
25613
  const outcome = job.outcome === "completed"
24559
- ? job.files.length + " image(s)"
25614
+ ? (job.modality === "video" ? "Video" : job.files.length + " image(s)")
24560
25615
  : job.outcome === "running" ? "Running…" : "Failed";
24561
25616
  const details = [];
24562
25617
  if (job.error) details.push(el("p", { className: "result failure", textContent: job.error }));
24563
25618
  if (job.shortfall) details.push(el("p", { className: "result unsupported", textContent: job.shortfall }));
25619
+ if (job.mismatch) details.push(el("p", { className: "result unsupported", textContent: job.mismatch }));
24564
25620
  return el("div", { className: "card" },
24565
25621
  el("div", {},
24566
25622
  el("div", { className: "card-head" }, el("h3", { textContent: job.model }),
25623
+ ...(isPicked(job) ? [el("span", { className: "chip accent", textContent: "Picked" })] : []),
24567
25624
  el("span", { className: "chip " + (job.outcome === "completed" ? "ok" : job.outcome), textContent: outcome })),
24568
25625
  el("p", { className: "job-meta", textContent: job.modelId + " on " + job.provider + " · " + when + " · " + formatDuration(job.durationMs) })),
24569
25626
  el("p", { className: "prompt", textContent: job.prompt }),
24570
- ...(job.files.length === 0 ? [] : [el("div", { className: "thumbs" }, ...job.files.map(thumbnail))]),
25627
+ ...(job.files.length === 0 ? [] : [el("div", { className: "thumbs" }, ...job.files.map((file, index) => resultThumbnail(job, file, index)))]),
24571
25628
  ...details,
24572
25629
  ...ratingRow(job),
25630
+ ...pickRow(job),
24573
25631
  );
24574
25632
  }
24575
25633
 
25634
+ /** A Comparison's Jobs together, under what the User picked among them. */
25635
+ function comparisonCard(jobs) {
25636
+ const pick = jobs[0].comparison.pick;
25637
+ const picked = pick && jobs.find((job) => job.id === pick.jobId);
25638
+ const status = !pick ? "Not picked yet."
25639
+ : "Picked: " + (pick.result === undefined ? "" : "result " + pick.result + " of ") + (picked ? picked.model + "'s Job" : "Job " + pick.jobId) + ".";
25640
+ return el("div", { className: "comparison" },
25641
+ el("div", { className: "comparison-head" }, el("h3", { textContent: "Comparison · " + jobs.length + " Jobs" }),
25642
+ el("p", { textContent: status + " A Pick says which you prefer; it is not a Rating." })),
25643
+ ...jobs.map(jobCard));
25644
+ }
25645
+
25646
+ /** Job cards, each Comparison's Jobs (which the listing keeps together) in one Comparison card. */
25647
+ function jobEntries(jobs) {
25648
+ const entries = [];
25649
+ for (let i = 0; i < jobs.length; ) {
25650
+ const comparison = jobs[i].comparison;
25651
+ if (!comparison) { entries.push(jobCard(jobs[i])); i++; continue; }
25652
+ let end = i;
25653
+ while (end < jobs.length && jobs[end].comparison && jobs[end].comparison.id === comparison.id) end++;
25654
+ entries.push(comparisonCard(jobs.slice(i, end)));
25655
+ i = end;
25656
+ }
25657
+ return entries;
25658
+ }
25659
+
24576
25660
  async function loadJobs() {
24577
25661
  const list = document.getElementById("job-list");
24578
25662
  const { jobs } = await api("GET", "/api/jobs");
24579
25663
  list.replaceChildren(...(jobs.length === 0
24580
25664
  ? [emptyState("No Jobs yet. Images your Agent generates will appear here.")]
24581
- : jobs.map(jobCard)));
25665
+ : jobEntries(jobs)));
24582
25666
  }
24583
25667
 
24584
25668
  (async () => {
@@ -24654,7 +25738,7 @@ async function startSettingsServer(options) {
24654
25738
  const nonce = randomBytes2(16).toString("base64");
24655
25739
  response.writeHead(200, {
24656
25740
  "content-type": "text/html; charset=utf-8",
24657
- "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`,
25741
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; media-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`,
24658
25742
  ...commonHeaders
24659
25743
  });
24660
25744
  response.end(renderPage(nonce));
@@ -24747,58 +25831,112 @@ function settingsOpener(options) {
24747
25831
  };
24748
25832
  }
24749
25833
  // package.json
24750
- var version2 = "0.1.1";
25834
+ var version2 = "0.2.0";
24751
25835
 
24752
25836
  // src/server.ts
24753
- var instructions = `litevision generates images through Models the User has configured with their own Providers.
25837
+ var instructions = `litevision generates images and videos through Models the User has configured with their own Providers. Before writing a prompt, use the litevision Skill. If you do not have it, mention once that the User can install it with \`npx skills add levyxdev/litevision\`.
24754
25838
 
24755
- - Use the Model the User names (pass its Alias as \`model\`).
24756
- - When the User names none, omit \`model\` to use the default image Model, unless the request needs something the default lacks (e.g. legible Chinese text, reference images, an aspect ratio). Then call \`list_models\` and choose by each Model's capabilities and Model Notes.
24757
25839
  - Never ask the User for an API key or any other Credential. If litevision reports that nothing is configured, call \`open_settings\` so the User can set it up in their browser.
24758
- - \`generate_image\` returns file paths, not image data. Tell the User where the files are.
24759
- - A Job that takes longer than two minutes keeps running in the background: \`generate_image\` then returns its Job id with status "running". Tell the User it is still running, and call \`get_job\` with that Job id to wait for it again and get the files.
24760
- - Ask for an aspect ratio, not pixel sizes. If the Model does not support it, the closest supported one is used; when the result says it was adjusted, tell the User the actual size.
24761
- - If \`generate_image\` says a Model cannot do something (reference images, that many images), the error lists what the Model supports: choose another Model or change the request.
24762
- - Pass \`output_path\` when the images belong in the User's project (e.g. its assets folder); otherwise omit it.
24763
- - For Provider-specific \`provider_options\`, call \`list_models\` with \`model\` first: it gives the Model's docs link, the Provider's parameter schema when it publishes one, and options that already worked for this Model. litevision passes \`provider_options\` through unchecked; if the Provider rejects one, its error says why, so fix it and try again.
24764
- - If fewer images came back than you asked for, the result says why. Tell the User.
24765
- - When the User says what they think of a result ("this one's great", "the text is wrong"), call \`rate_job\` with that Job's id, their verdict (good or bad) and a one-line reason in their words. Call it only when the User actually states a verdict; never invent one, and never rate a result yourself.
24766
- - Model Notes say what each Model is good or bad at. When the User learns something worth keeping, you may propose new Model Notes: short, replacing the old text whole rather than adding to it. Before calling \`update_model_notes\`, show the User the old and new Model Notes and get their agreement; never change them without it.`;
25840
+ - Use the Model the User names. When they name none, omit \`model\` for the default, unless the request needs something the default lacks; then choose with \`list_models\`.
25841
+ - For \`provider_options\`, reuse the Model's Known-good Options. For any other option, or one marked suspect, read the Model's docs and parameter schema first (\`list_models\` with \`model\`).
25842
+ - If the Provider rejects an option (the error says it "rejected an option"), fix it from the Provider's message and the docs and try again, at most twice. If it is still rejected, stop and tell the User what the Provider said.
25843
+ - Tell the User where the files are, and pass on whatever a result says was adjusted, did not match the request, or came back short.
25844
+ - \`generate_comparison\` costs as much as all its Jobs. Start a Comparison only when the User asks for one or agrees to it.
25845
+ - When the User says what they think of a result, call \`rate_job\` with their verdict and a one-line reason in their words; when it is about one result of a Job with several, pass \`result\`. Never invent a verdict, and never rate a result yourself.
25846
+ - When the User chooses among a Comparison's results, call \`pick_result\`, only when the User actually chooses. A Pick is not a Rating: if they also give a verdict, call \`rate_job\` too.
25847
+ - Before calling \`update_model_notes\`, show the User the old and new Model Notes and get their agreement; never change them without it.`;
25848
+ var recentRatingsShown = 5;
25849
+ var recentPicksShown = 5;
25850
+ var promptStartLength = 80;
24767
25851
  var listedModelSchema = object({
24768
25852
  alias: string2(),
24769
25853
  provider: string2(),
24770
25854
  protocol: string2(),
24771
25855
  model_id: string2().describe("The Provider's own name for the model."),
24772
- default: boolean2(),
25856
+ modality: _enum(modalities).describe("What the Model generates: images with `generate_image`, videos with `generate_video`."),
25857
+ default: boolean2().describe("True for the default Model of its Modality."),
24773
25858
  notes: string2().describe("Model Notes: what the Model is good or bad at, agreed with the User."),
24774
25859
  capabilities: object({
24775
25860
  aspect_ratios: array(object({ aspect_ratio: _enum(aspectRatios), size: string2() })),
24776
25861
  reference_images: boolean2(),
24777
25862
  max_reference_images: number2(),
24778
25863
  max_images_per_job: number2()
24779
- }).nullable().describe("Null when the Model cannot be used; `problem` then says why."),
25864
+ }).nullable().describe("An image Model's capabilities; null for a video Model, or when the Model cannot be used (`problem` then says why)."),
25865
+ video_capabilities: object({
25866
+ resolutions: array(object({
25867
+ resolution: string2().nullable().describe("The tier's name to pass as `resolution`; null when the Model takes none."),
25868
+ aspect_ratios: array(object({ aspect_ratio: _enum(aspectRatios), size: string2() }))
25869
+ })),
25870
+ durations: array(number2()).describe("Every clip length in seconds the Model can make."),
25871
+ default_duration: number2(),
25872
+ first_frame: boolean2(),
25873
+ last_frame: boolean2(),
25874
+ max_reference_images: number2()
25875
+ }).optional().describe("A video Model's capabilities; the first resolution is the default."),
24780
25876
  docs_url: string2().nullable().describe("Where to read which `provider_options` this Model accepts."),
25877
+ known_good_options: record(string2(), unknown()).optional().describe("Known-good Options: `provider_options` whose results matched the request and that the User has not rated bad. Reuse them."),
25878
+ suspect_options: record(string2(), object({ value: unknown(), reason: string2(), job_id: string2(), cause: _enum(["rating", "mismatch"]) })).optional().describe('`provider_options` last used by a Job the User rated bad (cause "rating", with the User\'s reason) or whose result did not match the request (cause "mismatch"). Check the docs before sending them again.'),
24781
25879
  problem: string2().optional(),
24782
- parameter_schema: unknown().optional().describe("Only with `model`: the Provider's own description of the Model's parameters, when it publishes one."),
24783
- known_good_options: record(string2(), unknown()).optional().describe("Only with `model`: `provider_options` that completed Jobs on this Model have sent, with the latest value of each.")
24784
- });
24785
- async function listedModel(deps, config, model, isDefault, detailed) {
25880
+ recent_ratings: array(object({
25881
+ job_id: string2(),
25882
+ result: number2().optional().describe("Which result of the Job, from 1; absent when the Rating is about the whole Job."),
25883
+ verdict: _enum(verdicts),
25884
+ reason: string2().describe("The User's reason, in their words."),
25885
+ rated_at: string2(),
25886
+ prompt: string2().describe("The start of the Job's prompt.")
25887
+ })).optional().describe(`Only with \`model\`: the Model's ${recentRatingsShown} most recent Ratings, newest first. When they say something the Model Notes do not, propose new Model Notes to the User.`),
25888
+ recent_picks: array(object({
25889
+ comparison_id: string2(),
25890
+ job_id: string2().describe("This Model's Job in the Comparison."),
25891
+ picked: boolean2().describe("Whether the User picked this Model's Job."),
25892
+ result: number2().optional().describe("When picked: which result of the Job the User picked, from 1; absent when they picked the whole Job."),
25893
+ picked_model: string2().optional().describe("Alias of the Model whose Job the User picked."),
25894
+ picked_at: string2(),
25895
+ prompt: string2().describe("The start of this Model's Job's prompt.")
25896
+ })).optional().describe(`Only with \`model\`: the current Picks of up to ${recentPicksShown} Comparisons this Model took part in, most recently picked first, one entry per Job of this Model in them. A Pick is a relative preference among the Comparison's results, not a Rating.`),
25897
+ parameter_schema: unknown().optional().describe("Only with `model`: the Provider's own description of the Model's parameters, when it publishes one.")
25898
+ });
25899
+ async function listedModel(deps, config, model, isDefault, detailed, ratedJobs) {
24786
25900
  const base = {
24787
25901
  alias: model.alias,
24788
25902
  provider: model.provider,
24789
25903
  protocol: model.protocol,
24790
25904
  model_id: model.modelId,
25905
+ modality: model.modality,
24791
25906
  default: isDefault,
24792
25907
  notes: model.notes ?? ""
24793
25908
  };
24794
- const heading = `${model.alias}${isDefault ? " (default)" : ""}: ${model.modelId} on ${model.provider} via ${model.protocol}.`;
25909
+ const heading = `${model.alias}${isDefault ? ` (default ${model.modality})` : ""}: ${model.modality} Model ${model.modelId} on ${model.provider} via ${model.protocol}.`;
24795
25910
  const notes = model.notes ? ` Notes: ${model.notes}` : "";
24796
- let info;
25911
+ let described;
24797
25912
  try {
24798
25913
  const provider = config.providers.find((candidate) => candidate.id === model.provider);
24799
25914
  if (provider === undefined)
24800
25915
  throw needsSettings(`Model "${model.alias}" refers to Provider "${model.provider}", which is not configured.`);
24801
- info = await modelInfo(deps, { model, provider });
25916
+ if (model.modality === "video") {
25917
+ const info = videoModelInfo({ model, provider });
25918
+ described = {
25919
+ output: { capabilities: null, video_capabilities: videoCapabilitiesOutput(info.capabilities) },
25920
+ summary: describeVideoCapabilities(info.capabilities),
25921
+ docsUrl: info.docsUrl
25922
+ };
25923
+ } else {
25924
+ const info = await modelInfo(deps, { model, provider });
25925
+ const { capabilities } = info;
25926
+ described = {
25927
+ output: {
25928
+ capabilities: {
25929
+ aspect_ratios: capabilities.sizes.map((size) => ({ aspect_ratio: size.aspectRatio, size: formatSize(size) })),
25930
+ reference_images: capabilities.maxReferences > 0,
25931
+ max_reference_images: capabilities.maxReferences,
25932
+ max_images_per_job: capabilities.maxImages
25933
+ }
25934
+ },
25935
+ summary: describeCapabilities(capabilities),
25936
+ docsUrl: info.docsUrl,
25937
+ ...info.parameterSchema === undefined ? {} : { parameterSchema: info.parameterSchema }
25938
+ };
25939
+ }
24802
25940
  } catch (error) {
24803
25941
  if (!(error instanceof ConfigError))
24804
25942
  throw error;
@@ -24807,31 +25945,156 @@ async function listedModel(deps, config, model, isDefault, detailed) {
24807
25945
  text: `${heading} Cannot be used: ${error.message}${notes}`
24808
25946
  };
24809
25947
  }
24810
- const { capabilities } = info;
25948
+ const record2 = optionsOf(ratedJobs, model);
24811
25949
  const output = {
24812
25950
  ...base,
24813
- capabilities: {
24814
- aspect_ratios: capabilities.sizes.map((size) => ({ aspect_ratio: size.aspectRatio, size: formatSize(size) })),
24815
- reference_images: capabilities.maxReferences > 0,
24816
- max_reference_images: capabilities.maxReferences,
24817
- max_images_per_job: capabilities.maxImages
24818
- },
24819
- docs_url: info.docsUrl
25951
+ capabilities: null,
25952
+ ...described.output,
25953
+ docs_url: described.docsUrl,
25954
+ known_good_options: record2.knownGood,
25955
+ suspect_options: suspectOutput(record2)
24820
25956
  };
24821
- let text = `${heading} Supports ${describeCapabilities(capabilities)}${notes} provider_options: ${info.docsUrl}`;
25957
+ let text = `${heading} Supports ${described.summary}${notes} provider_options docs: ${described.docsUrl}`;
25958
+ const goodKeys = Object.keys(record2.knownGood);
25959
+ const suspectKeys = Object.keys(record2.suspect);
24822
25960
  if (detailed) {
24823
- const knownGood = knownGoodOptions(await readJobs(deps.dirs), { provider: model.provider, protocol: model.protocol, modelId: model.modelId });
24824
- output.known_good_options = knownGood;
24825
25961
  text += `
24826
- Known-good provider_options: ${Object.keys(knownGood).length === 0 ? "none recorded yet" : JSON.stringify(knownGood)}`;
24827
- if (info.parameterSchema !== undefined) {
24828
- output.parameter_schema = info.parameterSchema;
25962
+ Known-good Options: ${goodKeys.length === 0 ? "none recorded yet" : JSON.stringify(record2.knownGood)}`;
25963
+ for (const [key, suspect] of Object.entries(record2.suspect)) {
25964
+ text += `
25965
+ Suspect: ${key}=${JSON.stringify(suspect.value)}, used by Job ${suspect.jobId}, ${suspectWhy(suspect)}`;
25966
+ }
25967
+ const ratings = recentRatings(ratedJobs, model, recentRatingsShown);
25968
+ output.recent_ratings = ratings.map(recentRatingOutput);
25969
+ text += ratings.length === 0 ? `
25970
+ Recent Ratings: none recorded yet.` : `
25971
+ Recent Ratings, newest first:`;
25972
+ for (const rated of ratings)
25973
+ text += `
25974
+ - ${describeRecentRating(rated)}`;
25975
+ const picks = recentPicks(ratedJobs, model, recentPicksShown);
25976
+ output.recent_picks = picks.map(recentPickOutput);
25977
+ text += picks.length === 0 ? `
25978
+ Recent Picks: none recorded yet.` : `
25979
+ Recent Picks in Comparisons, newest first:`;
25980
+ for (const picked of picks)
24829
25981
  text += `
24830
- Parameter schema from the Provider: ${JSON.stringify(info.parameterSchema)}`;
25982
+ - ${describeRecentPick(picked)}`;
25983
+ if (described.parameterSchema !== undefined) {
25984
+ output.parameter_schema = described.parameterSchema;
25985
+ text += `
25986
+ Parameter schema from the Provider: ${JSON.stringify(described.parameterSchema)}`;
24831
25987
  }
25988
+ } else {
25989
+ if (goodKeys.length > 0)
25990
+ text += ` Known-good Options: ${goodKeys.join(", ")}.`;
25991
+ if (suspectKeys.length > 0)
25992
+ text += ` Suspect options: ${suspectKeys.join(", ")}.`;
24832
25993
  }
24833
25994
  return { output, text };
24834
25995
  }
25996
+ function ratingOutput(rating) {
25997
+ return {
25998
+ job_id: rating.jobId,
25999
+ ...rating.result === undefined ? {} : { result: rating.result },
26000
+ verdict: rating.verdict,
26001
+ reason: rating.reason,
26002
+ rated_at: rating.ratedAt
26003
+ };
26004
+ }
26005
+ function ratingSubject(rating) {
26006
+ return rating.result === undefined ? `Job ${rating.jobId}` : `result ${rating.result} of Job ${rating.jobId}`;
26007
+ }
26008
+ function recentRatingOutput({ rating, job }) {
26009
+ return { ...ratingOutput(rating), prompt: promptStart(job.prompt) };
26010
+ }
26011
+ function describeRecentRating({ rating, job }) {
26012
+ return `${rating.verdict} on ${ratingSubject(rating)} (${JSON.stringify(promptStart(job.prompt))}): ${rating.reason}`;
26013
+ }
26014
+ function pickOutput(pick) {
26015
+ return {
26016
+ comparison_id: pick.comparison,
26017
+ job_id: pick.jobId,
26018
+ ...pick.result === undefined ? {} : { result: pick.result },
26019
+ picked_at: pick.pickedAt
26020
+ };
26021
+ }
26022
+ function recentPickOutput({ pick, job, picked }) {
26023
+ const isPicked = pick.jobId === job.id;
26024
+ return {
26025
+ comparison_id: pick.comparison,
26026
+ job_id: job.id,
26027
+ picked: isPicked,
26028
+ ...isPicked && pick.result !== undefined ? { result: pick.result } : {},
26029
+ ...picked === undefined ? {} : { picked_model: picked.model },
26030
+ picked_at: pick.pickedAt,
26031
+ prompt: promptStart(job.prompt)
26032
+ };
26033
+ }
26034
+ function describeRecentPick({ pick, job, picked }) {
26035
+ const subject = `Job ${job.id} in Comparison ${pick.comparison} (${JSON.stringify(promptStart(job.prompt))})`;
26036
+ if (pick.jobId === job.id)
26037
+ return `${subject}: picked${pick.result === undefined ? "" : `, result ${pick.result}`}`;
26038
+ return `${subject}: not picked; the User picked ${picked === undefined ? `Job ${pick.jobId}` : `a Job on ${picked.model}`}`;
26039
+ }
26040
+ function promptStart(prompt) {
26041
+ const line = prompt.replace(/\s+/g, " ").trim();
26042
+ return line.length <= promptStartLength ? line : `${line.slice(0, promptStartLength).trimEnd()}…`;
26043
+ }
26044
+ function videoCapabilitiesOutput(capabilities) {
26045
+ return {
26046
+ resolutions: capabilities.resolutions.map((tier) => ({
26047
+ resolution: tier.name ?? null,
26048
+ aspect_ratios: tier.sizes.map((size) => ({ aspect_ratio: size.aspectRatio, size: formatSize(size) }))
26049
+ })),
26050
+ durations: capabilities.durations,
26051
+ default_duration: capabilities.defaultDuration,
26052
+ first_frame: capabilities.firstFrame,
26053
+ last_frame: capabilities.lastFrame,
26054
+ max_reference_images: capabilities.maxReferences
26055
+ };
26056
+ }
26057
+ function suspectOutput(record2) {
26058
+ return Object.fromEntries(Object.entries(record2.suspect).map(([key, suspect]) => [
26059
+ key,
26060
+ { value: suspect.value, reason: suspect.reason, job_id: suspect.jobId, cause: suspect.cause }
26061
+ ]));
26062
+ }
26063
+ async function modelOptions(deps, model) {
26064
+ return optionsOf(await readRatedJobs(deps.dirs), model);
26065
+ }
26066
+ function optionsOf(ratedJobs, model) {
26067
+ return optionsRecord(ratedJobs, { provider: model.provider, protocol: model.protocol, modelId: model.modelId });
26068
+ }
26069
+ async function jobDocs(dirs, jobId) {
26070
+ const job = await readJob(dirs, jobId);
26071
+ const docsUrl = job === undefined ? undefined : adapterForProtocol(job.protocol)?.docsUrl(job.modelId, job.modality ?? "image");
26072
+ if (job === undefined || docsUrl === undefined)
26073
+ return;
26074
+ return { docsUrl, options: optionsRecord(await readRatedJobs(dirs), job) };
26075
+ }
26076
+ function suspectWhy(suspect) {
26077
+ return suspect.cause === "rating" ? `which the User rated bad: "${suspect.reason}"` : `whose result did not match the request: ${suspect.reason}`;
26078
+ }
26079
+ function optionsHint(docsUrl, record2) {
26080
+ const good = Object.keys(record2.knownGood).length === 0 ? "none recorded yet" : JSON.stringify(record2.knownGood);
26081
+ const suspect = Object.entries(record2.suspect).map(([key, entry]) => ` Suspect: ${key}=${JSON.stringify(entry.value)}, used by a Job ${suspectWhy(entry)}.`).join("");
26082
+ return ` Docs for this Model's provider_options: ${docsUrl}. Known-good Options: ${good}.${suspect}`;
26083
+ }
26084
+ var observedSchema = array(object({ width: number2(), height: number2(), seconds: number2().optional() }).nullable()).describe("What each saved file turned out to be, in the order of `files`, read from the file itself; null where litevision could not tell.");
26085
+ var mismatchSchema = string2().optional().describe("Present when the result's shape or length differs from the request: an option may not have taken effect. Tell the User.");
26086
+ function observedText(observed) {
26087
+ const known = [...new Set(observed.flatMap((facts) => facts === null ? [] : [describeFacts(facts)]))];
26088
+ return known.length === 0 ? "" : ` Got ${known.join(", ")}.`;
26089
+ }
26090
+ function describeFacts(facts) {
26091
+ return `${facts.width}x${facts.height}${facts.seconds === undefined ? "" : `, ${facts.seconds} s`}`;
26092
+ }
26093
+ function mismatchText(mismatch, docsUrl) {
26094
+ if (mismatch === undefined)
26095
+ return "";
26096
+ return ` The result does not match the request: ${mismatch}. A provider_option may not have taken effect, so these options are not recorded as Known-good; check ${docsUrl} before trying again.`;
26097
+ }
24835
26098
  var jobStatusSchema = _enum(["completed", "running"]).describe('"running" when the Job outlasted the wait limit and continues in the background; call `get_job` with its `job_id`.');
24836
26099
  function jobOutput(job) {
24837
26100
  return {
@@ -24839,7 +26102,9 @@ function jobOutput(job) {
24839
26102
  status: job.status,
24840
26103
  model: job.model,
24841
26104
  files: job.status === "completed" ? job.files : [],
24842
- ...job.status === "completed" && job.shortfall !== undefined ? { shortfall: job.shortfall } : {}
26105
+ observed: job.status === "completed" ? job.observed : [],
26106
+ ...job.status === "completed" && job.shortfall !== undefined ? { shortfall: job.shortfall } : {},
26107
+ ...job.status === "completed" && job.mismatch !== undefined ? { mismatch: job.mismatch } : {}
24843
26108
  };
24844
26109
  }
24845
26110
  function jobText(job) {
@@ -24848,12 +26113,153 @@ function jobText(job) {
24848
26113
  function toolError(text) {
24849
26114
  return { content: [{ type: "text", text }], isError: true };
24850
26115
  }
26116
+ async function failure2(error, hint) {
26117
+ const reason = await failureReason(error, hint);
26118
+ return toolError(error instanceof JobFailedError ? `Job ${error.jobId} failed: ${reason}` : reason);
26119
+ }
26120
+ async function failureReason(error, hint) {
26121
+ if (error instanceof ConfigError)
26122
+ return error.message;
26123
+ if (error instanceof JobFailedError) {
26124
+ const helpful = hint !== undefined && (error.kind === "unsupported" || error.kind === "rejected_option" || error.kind === "other");
26125
+ return `${error.message}${helpful ? await hint() : ""}`;
26126
+ }
26127
+ throw error;
26128
+ }
26129
+ var imageJobShape = {
26130
+ job_id: string2(),
26131
+ status: jobStatusSchema,
26132
+ model: string2().describe("Alias of the Model that ran the Job."),
26133
+ files: array(string2()).describe("Paths of the saved images; empty while the Job is running."),
26134
+ aspect_ratio: _enum(aspectRatios).describe("Aspect ratio actually used."),
26135
+ size: string2().describe("Size actually requested from the Provider, e.g. 1536x1024."),
26136
+ requested_aspect_ratio: _enum(aspectRatios),
26137
+ aspect_ratio_adjusted: boolean2().describe("True when the Model does not support the requested aspect ratio and the closest one was used."),
26138
+ shortfall: string2().optional().describe("Present when fewer images than `n` were saved: how many, and why."),
26139
+ observed: observedSchema,
26140
+ mismatch: mismatchSchema
26141
+ };
26142
+ var videoJobShape = {
26143
+ job_id: string2(),
26144
+ status: jobStatusSchema,
26145
+ model: string2().describe("Alias of the Model that ran the Job."),
26146
+ files: array(string2()).describe("Path of the saved video; empty while the Job is running."),
26147
+ aspect_ratio: _enum(aspectRatios).describe("Aspect ratio actually used."),
26148
+ size: string2().describe("Size the request implies, e.g. 1280x720."),
26149
+ resolution: string2().nullable().describe("Resolution tier actually requested; null when the Model takes none."),
26150
+ duration: number2().describe("Length in seconds actually requested."),
26151
+ requested_aspect_ratio: _enum(aspectRatios),
26152
+ aspect_ratio_adjusted: boolean2(),
26153
+ duration_adjusted: boolean2().describe("True when the Model cannot make the requested length and the closest one was used."),
26154
+ observed: observedSchema,
26155
+ mismatch: mismatchSchema
26156
+ };
26157
+ var failedJobSchema = object({
26158
+ job_id: string2().nullable().describe("Null when the Job could not start."),
26159
+ status: literal("failed"),
26160
+ model: string2().describe("Alias of the Model the Job ran on."),
26161
+ files: array(string2()).describe("Always empty."),
26162
+ error: string2().describe("Why the Job failed.")
26163
+ });
26164
+ var maxComparisonVariants = 4;
26165
+ function comparisonModality(config, variants, wanted) {
26166
+ const named = variants.flatMap((variant) => config.models.filter((model) => model.alias === variant.model));
26167
+ const found = new Set([...wanted === undefined ? [] : [wanted], ...named.map((model) => model.modality)]);
26168
+ if (found.size > 1) {
26169
+ const makes = [...new Map(named.map((model) => [model.alias, `${model.alias} makes ${model.modality}s`])).values()].join(", ");
26170
+ const asked = wanted === undefined ? "" : ` and \`modality\` is ${wanted}`;
26171
+ throw new ConfigError(`A Comparison's Models must share one Modality, but ${makes}${asked}. Compare image Models or video Models, not both.`);
26172
+ }
26173
+ return [...found][0] ?? "image";
26174
+ }
24851
26175
  function createServer2(deps) {
24852
26176
  const server = new McpServer({ name: "litevision", version: version2 }, { instructions });
24853
26177
  const now = deps.now ?? (() => new Date);
24854
26178
  const cwd = deps.cwd ?? process.cwd();
24855
26179
  const infoDeps = { dirs: deps.dirs, http: deps.http, now };
24856
- const jobs = new JobRunner({ dirs: deps.dirs, http: deps.http, now, waitLimitMs: deps.waitLimitMs, pollIntervalMs: deps.pollIntervalMs });
26180
+ const jobs = new JobRunner({ dirs: deps.dirs, http: deps.http, now, waitLimitMs: deps.waitLimitMs, pollIntervalMs: deps.pollIntervalMs, retryDelayMs: deps.retryDelayMs });
26181
+ const prepareImage = async (resolved, args) => {
26182
+ const { model } = resolved;
26183
+ const adapter = adapterFor(model);
26184
+ const credential = await requireCredential(deps.dirs, resolved.provider);
26185
+ const info = await modelInfo(infoDeps, resolved);
26186
+ const normalised = await normaliseImageRequest({ prompt: args.prompt, n: args.n, aspectRatio: args.aspect_ratio, references: args.references, providerOptions: args.provider_options }, model, adapter, info, cwd);
26187
+ const outputDir = args.output_path === undefined ? undefined : resolve2(cwd, args.output_path);
26188
+ return {
26189
+ model,
26190
+ start: (comparison) => jobs.start({ ...resolved, adapter, credential, normalised, outputDir, ...comparison === undefined ? {} : { comparison } }),
26191
+ hint: async () => optionsHint(info.docsUrl, await modelOptions(deps, model)),
26192
+ report: (job) => {
26193
+ const { size } = normalised.request;
26194
+ const output = {
26195
+ ...jobOutput(job),
26196
+ aspect_ratio: size.aspectRatio,
26197
+ size: formatSize(size),
26198
+ requested_aspect_ratio: normalised.requestedAspectRatio,
26199
+ aspect_ratio_adjusted: normalised.aspectRatioAdjusted
26200
+ };
26201
+ const adjustment = normalised.aspectRatioAdjusted ? ` ${model.alias} does not support ${normalised.requestedAspectRatio}, so the closest supported aspect ratio was used.` : "";
26202
+ const text = job.status === "running" ? `${jobText(job)} It asks ${model.alias} for ${size.aspectRatio} (${formatSize(size)}).${adjustment}` : `Saved ${job.files.length} image(s) from ${model.alias} at ${size.aspectRatio} (${formatSize(size)}), Job ${job.id}.${adjustment}${observedText(job.observed)}${mismatchText(job.mismatch, info.docsUrl)}${job.shortfall === undefined ? "" : ` ${job.shortfall}`}
26203
+ ${job.files.join(`
26204
+ `)}`;
26205
+ return { output, text };
26206
+ }
26207
+ };
26208
+ };
26209
+ const prepareVideo = async (resolved, args) => {
26210
+ const { model } = resolved;
26211
+ const adapter = adapterFor(model);
26212
+ const info = videoModelInfo(resolved);
26213
+ const credential = await requireCredential(deps.dirs, resolved.provider);
26214
+ const normalised = await normaliseVideoRequest({
26215
+ prompt: args.prompt,
26216
+ ...args.aspect_ratio === undefined ? {} : { aspectRatio: args.aspect_ratio },
26217
+ ...args.duration === undefined ? {} : { duration: args.duration },
26218
+ ...args.resolution === undefined ? {} : { resolution: args.resolution },
26219
+ ...args.first_frame === undefined ? {} : { firstFrame: args.first_frame },
26220
+ ...args.last_frame === undefined ? {} : { lastFrame: args.last_frame },
26221
+ references: args.references,
26222
+ providerOptions: args.provider_options
26223
+ }, model, adapter, info.capabilities, cwd);
26224
+ const outputDir = args.output_path === undefined ? undefined : resolve2(cwd, args.output_path);
26225
+ return {
26226
+ model,
26227
+ start: (comparison) => jobs.startVideo({ ...resolved, adapter, credential, normalised, outputDir, ...comparison === undefined ? {} : { comparison } }),
26228
+ hint: async () => optionsHint(info.docsUrl, await modelOptions(deps, model)),
26229
+ report: (job) => {
26230
+ const { size, seconds } = normalised.request;
26231
+ const output = {
26232
+ ...jobOutput(job),
26233
+ aspect_ratio: size.aspectRatio,
26234
+ size: formatSize(size),
26235
+ resolution: normalised.request.resolution ?? null,
26236
+ duration: seconds,
26237
+ requested_aspect_ratio: normalised.requestedAspectRatio,
26238
+ aspect_ratio_adjusted: normalised.aspectRatioAdjusted,
26239
+ duration_adjusted: normalised.durationAdjusted
26240
+ };
26241
+ const asked = `${seconds} s at ${size.aspectRatio}${normalised.request.resolution === undefined ? "" : ` ${normalised.request.resolution}`} (${formatSize(size)})`;
26242
+ const adjustments = [
26243
+ normalised.aspectRatioAdjusted ? `${model.alias} does not support ${normalised.requestedAspectRatio}, so the closest supported aspect ratio was used.` : "",
26244
+ normalised.durationAdjusted ? `${model.alias} cannot make ${normalised.requestedDuration} s, so the closest length it can was used.` : ""
26245
+ ].filter(Boolean).map((sentence) => ` ${sentence}`).join("");
26246
+ const text = job.status === "running" ? `${jobText(job)} It asks ${model.alias} for ${asked}.${adjustments}` : `Saved a video from ${model.alias}, ${asked}, Job ${job.id}.${adjustments}${observedText(job.observed)}${mismatchText(job.mismatch, info.docsUrl)}
26247
+ ${job.files.join(`
26248
+ `)}`;
26249
+ return { output, text };
26250
+ }
26251
+ };
26252
+ };
26253
+ const runSingle = async (prepare) => {
26254
+ let prepared;
26255
+ try {
26256
+ prepared = await prepare();
26257
+ const { output, text } = prepared.report(await prepared.start());
26258
+ return { content: [{ type: "text", text }], structuredContent: output };
26259
+ } catch (error) {
26260
+ return failure2(error, prepared?.hint);
26261
+ }
26262
+ };
24857
26263
  server.registerTool("generate_image", {
24858
26264
  description: "Generate images from a text prompt with one of the User's configured Models. Saves the images locally and returns their file paths.",
24859
26265
  inputSchema: {
@@ -24865,94 +26271,137 @@ function createServer2(deps) {
24865
26271
  output_path: string2().min(1).optional().describe("Folder to save the images in, e.g. the project's assets folder; created if missing. Relative paths resolve against litevision's working directory. Omit to save under a dated folder in the User's home directory."),
24866
26272
  provider_options: record(string2(), unknown()).default({}).describe('Provider-specific request fields passed through untouched, e.g. { "quality": "high" } for OpenAI. Cannot override fields litevision sets from the other parameters.')
24867
26273
  },
26274
+ outputSchema: imageJobShape
26275
+ }, async ({ model: alias, ...args }) => runSingle(async () => prepareImage(resolveModel(await loadConfig(deps.dirs), alias), args)));
26276
+ server.registerTool("generate_video", {
26277
+ description: "Generate one video from a text prompt with one of the User's configured video Models, optionally from a first and last frame or reference images. Saves the video locally and returns its file path. Videos often take minutes: the Job then keeps running in the background.",
26278
+ inputSchema: {
26279
+ prompt: string2().min(1).describe("Sent to the Model verbatim. Describe motion and camera movement, not only the scene."),
26280
+ model: string2().optional().describe("Alias of the video Model to use. Omit to use the default video Model."),
26281
+ aspect_ratio: _enum(aspectRatios).optional().describe("Wanted aspect ratio. Omit for 16:9, or for the first frame's shape when `first_frame` is given. If the Model does not support it, the closest supported one is used and reported."),
26282
+ duration: number2().int().positive().optional().describe("Wanted length in seconds. Omit for the Model's default. If the Model cannot make it, the closest length it can is used and reported."),
26283
+ resolution: string2().min(1).optional().describe("A resolution tier the Model lists in `list_models`, e.g. 720P. Omit for the Model's default."),
26284
+ first_frame: string2().min(1).optional().describe("Local path of an image the video starts from."),
26285
+ last_frame: string2().min(1).optional().describe("Local path of an image the video ends on."),
26286
+ references: array(string2().min(1)).default([]).describe("Local paths of reference images for characters, objects or style. Relative paths resolve against litevision's working directory."),
26287
+ output_path: string2().min(1).optional().describe("Folder to save the video in; created if missing. Omit to save under a dated folder in the User's home directory."),
26288
+ provider_options: record(string2(), unknown()).default({}).describe('Provider-specific request fields passed through untouched, e.g. { "generate_audio": true }. Reuse the Model\'s Known-good Options from `list_models`.')
26289
+ },
26290
+ outputSchema: videoJobShape
26291
+ }, async ({ model: alias, ...args }) => runSingle(async () => prepareVideo(resolveModel(await loadConfig(deps.dirs), alias, "video"), args)));
26292
+ server.registerTool("generate_comparison", {
26293
+ description: `Start a Comparison: 2 to ${maxComparisonVariants} Jobs together for one request, differing in prompt, in Model, or both, so the User can choose among their results. All variants' Models must share one Modality. Start one only when the User asks for a Comparison or agrees to one: it costs as much as all its Jobs. Saves each Job's files locally and returns them under one Comparison id.`,
26294
+ inputSchema: {
26295
+ variants: array(object({
26296
+ prompt: string2().min(1).describe("Sent to this variant's Model verbatim."),
26297
+ model: string2().optional().describe("Alias of this variant's Model. Omit to use the default Model of the Comparison's Modality."),
26298
+ n: number2().int().positive().optional().describe("Image Comparisons only: how many images this variant's Job makes, up to its Model's maximum. Defaults to 1.")
26299
+ })).min(2).max(maxComparisonVariants).describe(`The Jobs to start, 2 to ${maxComparisonVariants}, one per variant.`),
26300
+ modality: _enum(modalities).optional().describe("image or video. Omit to take it from the variants' Models; when no variant names one, image."),
26301
+ aspect_ratio: _enum(aspectRatios).optional().describe("Wanted aspect ratio for every Job. Omit for 1:1 images or 16:9 videos. Each Model that does not support it uses its closest one."),
26302
+ references: array(string2().min(1)).default([]).describe("Local paths of reference images every Job gets. Relative paths resolve against litevision's working directory."),
26303
+ output_path: string2().min(1).optional().describe("Folder to save every Job's files in; created if missing. Omit to save under a dated folder in the User's home directory."),
26304
+ duration: number2().int().positive().optional().describe("Video Comparisons only: wanted length in seconds. Omit for each Model's default.")
26305
+ },
24868
26306
  outputSchema: {
24869
- job_id: string2(),
24870
- status: jobStatusSchema,
24871
- model: string2().describe("Alias of the Model that ran the Job."),
24872
- files: array(string2()).describe("Paths of the saved images; empty while the Job is running."),
24873
- aspect_ratio: _enum(aspectRatios).describe("Aspect ratio actually used."),
24874
- size: string2().describe("Size actually requested from the Provider, e.g. 1536x1024."),
24875
- requested_aspect_ratio: _enum(aspectRatios),
24876
- aspect_ratio_adjusted: boolean2().describe("True when the Model does not support the requested aspect ratio and the closest one was used."),
24877
- shortfall: string2().optional().describe("Present when fewer images than `n` were saved: how many, and why.")
24878
- }
24879
- }, async ({ prompt, model: alias, n, aspect_ratio, references, output_path, provider_options }) => {
26307
+ comparison_id: string2(),
26308
+ modality: _enum(modalities),
26309
+ jobs: array(union([object(imageJobShape), object(videoJobShape), failedJobSchema])).describe('One per variant, in the order given: each as `generate_image` or `generate_video` returns it, or status "failed" with why. A "running" Job continues in the background; call `get_job` with its `job_id`.')
26310
+ }
26311
+ }, async ({ variants, modality: wanted, aspect_ratio, references, output_path, duration }) => {
26312
+ let prepared;
26313
+ let modality;
24880
26314
  try {
24881
- const resolved = resolveModel(await loadConfig(deps.dirs), alias);
24882
- const { model } = resolved;
24883
- const adapter = adapterFor(model);
24884
- const credential = await requireCredential(deps.dirs, resolved.provider);
24885
- const normalised = await normaliseImageRequest({ prompt, n, aspectRatio: aspect_ratio, references, providerOptions: provider_options }, model, adapter, await modelInfo(infoDeps, resolved), cwd);
24886
- const outputDir = output_path === undefined ? undefined : resolve2(cwd, output_path);
24887
- const job = await jobs.start({ ...resolved, adapter, credential, normalised, outputDir });
24888
- const { size } = normalised.request;
24889
- const output = {
24890
- ...jobOutput(job),
24891
- aspect_ratio: size.aspectRatio,
24892
- size: formatSize(size),
24893
- requested_aspect_ratio: normalised.requestedAspectRatio,
24894
- aspect_ratio_adjusted: normalised.aspectRatioAdjusted
24895
- };
24896
- const adjustment = normalised.aspectRatioAdjusted ? ` ${model.alias} does not support ${normalised.requestedAspectRatio}, so the closest supported aspect ratio was used.` : "";
24897
- const text = job.status === "running" ? `${jobText(job)} It asks ${model.alias} for ${size.aspectRatio} (${formatSize(size)}).${adjustment}` : `Saved ${job.files.length} image(s) from ${model.alias} at ${size.aspectRatio} (${formatSize(size)}), Job ${job.id}.${adjustment}${job.shortfall === undefined ? "" : ` ${job.shortfall}`}
24898
- ${job.files.join(`
24899
- `)}`;
24900
- return { content: [{ type: "text", text }], structuredContent: output };
26315
+ const config = await loadConfig(deps.dirs);
26316
+ modality = comparisonModality(config, variants, wanted);
26317
+ if (modality === "image" && duration !== undefined) {
26318
+ throw new ConfigError("`duration` applies only to a video Comparison; leave it out for images.");
26319
+ }
26320
+ if (modality === "video" && variants.some((variant) => (variant.n ?? 1) > 1)) {
26321
+ throw new ConfigError("A video Job makes one video, so leave `n` out of a video Comparison's variants.");
26322
+ }
26323
+ prepared = await Promise.all(variants.map((variant) => {
26324
+ const resolved = resolveModel(config, variant.model, modality);
26325
+ const shared = { prompt: variant.prompt, references, provider_options: {}, ...output_path === undefined ? {} : { output_path } };
26326
+ return modality === "video" ? prepareVideo(resolved, { ...shared, ...aspect_ratio === undefined ? {} : { aspect_ratio }, ...duration === undefined ? {} : { duration } }) : prepareImage(resolved, { ...shared, n: variant.n ?? 1, aspect_ratio: aspect_ratio ?? "1:1" });
26327
+ }));
24901
26328
  } catch (error) {
24902
- if (error instanceof ConfigError)
24903
- return toolError(error.message);
24904
- if (error instanceof JobFailedError)
24905
- return toolError(`Job ${error.jobId} failed: ${error.message}`);
24906
- throw error;
24907
- }
26329
+ return failure2(error, undefined);
26330
+ }
26331
+ const comparisonId = randomUUID3();
26332
+ const settled = await Promise.allSettled(prepared.map((job) => job.start(comparisonId)));
26333
+ const results = await Promise.all(settled.map(async (outcome, index) => {
26334
+ const job = prepared[index];
26335
+ if (outcome.status === "fulfilled")
26336
+ return job.report(outcome.value);
26337
+ const reason = await failureReason(outcome.reason, job.hint).catch(() => `litevision could not run this Job: ${outcome.reason.message}`);
26338
+ const jobId = outcome.reason instanceof JobFailedError ? outcome.reason.jobId : null;
26339
+ return {
26340
+ output: { job_id: jobId, status: "failed", model: job.model.alias, files: [], error: reason },
26341
+ text: `Job${jobId === null ? "" : ` ${jobId}`} on ${job.model.alias} failed: ${reason}`
26342
+ };
26343
+ }));
26344
+ const text = [
26345
+ `Comparison ${comparisonId}: ${results.length} ${modality} Jobs. Show the User every result so they can choose; when they do, call \`pick_result\`.`,
26346
+ ...results.map((result, index) => `Variant ${index + 1}: ${result.text}`)
26347
+ ].join(`
26348
+
26349
+ `);
26350
+ return {
26351
+ content: [{ type: "text", text }],
26352
+ structuredContent: { comparison_id: comparisonId, modality, jobs: results.map((result) => result.output) }
26353
+ };
24908
26354
  });
24909
26355
  server.registerTool("get_job", {
24910
- description: "Get a Job's status, waiting up to two minutes while it runs; once it has completed, the paths of its saved images. Use it when `generate_image` returned a Job that is still running.",
26356
+ description: "Get a Job's status, waiting up to two minutes while it runs; once it has completed, the paths of its saved images or video. Use it when `generate_image`, `generate_video` or `generate_comparison` returned a Job that is still running.",
24911
26357
  inputSchema: {
24912
- job_id: string2().min(1).describe("The `job_id` that `generate_image` returned.")
26358
+ job_id: string2().min(1).describe("The `job_id` that `generate_image`, `generate_video` or `generate_comparison` returned.")
24913
26359
  },
24914
26360
  outputSchema: {
24915
26361
  job_id: string2(),
24916
26362
  status: jobStatusSchema,
24917
26363
  model: string2().describe("Alias of the Model that ran the Job."),
24918
- files: array(string2()).describe("Paths of the saved images; empty while the Job is running."),
24919
- shortfall: string2().optional().describe("Present when fewer images than asked for were saved: how many, and why.")
26364
+ files: array(string2()).describe("Paths of the saved images or video; empty while the Job is running."),
26365
+ shortfall: string2().optional().describe("Present when fewer images than asked for were saved: how many, and why."),
26366
+ observed: observedSchema,
26367
+ mismatch: mismatchSchema
24920
26368
  }
24921
26369
  }, async ({ job_id }) => {
24922
26370
  try {
24923
26371
  const job = await jobs.get(job_id);
24924
- const text = job.status === "running" ? jobText(job) : `Job ${job.id} completed: saved ${job.files.length} image(s) from ${job.model}.${job.shortfall === undefined ? "" : ` ${job.shortfall}`}
26372
+ const docsUrl = job.status === "completed" && job.mismatch !== undefined ? (await jobDocs(deps.dirs, job_id))?.docsUrl ?? "the Model's docs link in `list_models`" : "";
26373
+ const text = job.status === "running" ? jobText(job) : `Job ${job.id} completed: saved ${job.files.length} file(s) from ${job.model}.${observedText(job.observed)}${mismatchText(job.mismatch, docsUrl)}${job.shortfall === undefined ? "" : ` ${job.shortfall}`}
24925
26374
  ${job.files.join(`
24926
26375
  `)}`;
24927
26376
  return { content: [{ type: "text", text }], structuredContent: jobOutput(job) };
24928
26377
  } catch (error) {
24929
- if (error instanceof ConfigError)
24930
- return toolError(error.message);
24931
- if (error instanceof JobFailedError)
24932
- return toolError(`Job ${error.jobId} failed: ${error.message}`);
24933
- throw error;
26378
+ return failure2(error, async () => {
26379
+ const docs = await jobDocs(deps.dirs, job_id);
26380
+ return docs === undefined ? "" : optionsHint(docs.docsUrl, docs.options);
26381
+ });
24934
26382
  }
24935
26383
  });
24936
26384
  server.registerTool("rate_job", {
24937
- description: "Record the User's Rating of a completed Job: good or bad, plus a one-line reason. Call it only when the User has actually said what they think of the result; never invent a verdict. A later Rating of the same Job replaces the earlier one.",
26385
+ description: "Record the User's Rating of a completed Job, or of one result of it: good or bad, plus a one-line reason. Call it only when the User has actually said what they think of the result; never invent a verdict. A later Rating of the same Job and result replaces the earlier one; a Rating of the whole Job and one of a single result stand side by side.",
24938
26386
  inputSchema: {
24939
- job_id: string2().min(1).describe("The `job_id` that `generate_image` or `get_job` returned."),
26387
+ job_id: string2().min(1).describe("The `job_id` that `generate_image`, `generate_video` or `get_job` returned."),
26388
+ result: number2().int().positive().optional().describe("Which result the verdict is about, from 1 in the order of the Job's files. Omit when it is about the whole Job."),
24940
26389
  verdict: _enum(verdicts).describe("The User's verdict."),
24941
26390
  reason: string2().max(ratingReasonMaxLength).describe(`Why, in one line and in the User's words, e.g. "the text is misspelled". At most ${ratingReasonMaxLength} characters.`)
24942
26391
  },
24943
26392
  outputSchema: {
24944
26393
  job_id: string2(),
26394
+ result: number2().optional().describe("The result rated; absent for a Rating of the whole Job."),
24945
26395
  verdict: _enum(verdicts),
24946
26396
  reason: string2().describe("The reason as recorded, on one line."),
24947
26397
  rated_at: string2().describe("When the Rating was recorded, ISO 8601.")
24948
26398
  }
24949
- }, async ({ job_id, verdict, reason }) => {
26399
+ }, async ({ job_id, result, verdict, reason }) => {
24950
26400
  try {
24951
- const rating = await rateJob(deps.dirs, { jobId: job_id, verdict, reason }, now());
24952
- const output = { job_id: rating.jobId, verdict: rating.verdict, reason: rating.reason, rated_at: rating.ratedAt };
26401
+ const rating = await rateJob(deps.dirs, { jobId: job_id, result, verdict, reason }, now());
24953
26402
  return {
24954
- content: [{ type: "text", text: `Recorded a ${rating.verdict} Rating on Job ${rating.jobId}: ${rating.reason}` }],
24955
- structuredContent: output
26403
+ content: [{ type: "text", text: `Recorded a ${rating.verdict} Rating on ${ratingSubject(rating)}: ${rating.reason}` }],
26404
+ structuredContent: ratingOutput(rating)
24956
26405
  };
24957
26406
  } catch (error) {
24958
26407
  if (error instanceof RatingError)
@@ -24960,21 +26409,50 @@ ${job.files.join(`
24960
26409
  throw error;
24961
26410
  }
24962
26411
  });
26412
+ server.registerTool("pick_result", {
26413
+ description: "Record the User's Pick within a Comparison: the Job, or the one result of it, the User chose among the Comparison's results. Call it only when the User actually chooses; never pick for them. A later Pick for the same Comparison replaces the earlier one. A Pick is a relative preference, not a Rating: it does not mark the result good or the others bad.",
26414
+ inputSchema: {
26415
+ comparison_id: string2().min(1).describe("The `comparison_id` that `generate_comparison` returned."),
26416
+ job_id: string2().min(1).describe("The `job_id` of the chosen Job, one of the Comparison's Jobs."),
26417
+ result: number2().int().positive().optional().describe("Which result of that Job the User chose, from 1 in the order of its files. Omit when the Job made one result or the User chose the Job as a whole.")
26418
+ },
26419
+ outputSchema: {
26420
+ comparison_id: string2(),
26421
+ job_id: string2(),
26422
+ result: number2().optional().describe("The result picked; absent when the Job was picked as a whole."),
26423
+ picked_at: string2().describe("When the Pick was recorded, ISO 8601.")
26424
+ }
26425
+ }, async ({ comparison_id, job_id, result }) => {
26426
+ try {
26427
+ const pick = await pickResult(deps.dirs, { comparison: comparison_id, jobId: job_id, result }, now());
26428
+ const subject = pick.result === undefined ? `Job ${pick.jobId}` : `result ${pick.result} of Job ${pick.jobId}`;
26429
+ return {
26430
+ content: [{ type: "text", text: `Recorded the User's Pick in Comparison ${pick.comparison}: ${subject}.` }],
26431
+ structuredContent: pickOutput(pick)
26432
+ };
26433
+ } catch (error) {
26434
+ if (error instanceof PickError)
26435
+ return toolError(error.message);
26436
+ throw error;
26437
+ }
26438
+ });
24963
26439
  server.registerTool("list_models", {
24964
- description: "List the User's configured image Models: Alias, Provider, Protocol, capabilities, Model Notes and a docs link for `provider_options`, and which one is the default. Use it to choose a Model when the request needs something the default may lack. Pass `model` for one Model in detail, with the Provider's parameter schema and the `provider_options` known to work.",
26440
+ description: "List the User's configured Models: Alias, Modality (image or video), Provider, Protocol, capabilities, Model Notes, the Known-good Options for `provider_options` and a docs link, and which Models are the defaults. Use it to choose a Model when the request needs something the default may lack. Pass `model` for one Model in detail, with the values of its Known-good and suspect options, its five most recent Ratings, the Picks of up to five Comparisons it took part in (most recently picked first), and the Provider's parameter schema.",
24965
26441
  inputSchema: {
24966
26442
  model: string2().optional().describe("Alias of one Model to describe in detail. Omit to list every Model.")
24967
26443
  },
24968
26444
  outputSchema: {
24969
26445
  default_model: string2().nullable().describe("Alias of the default image Model, used when `generate_image` gets no `model`."),
26446
+ default_video_model: string2().nullable().describe("Alias of the default video Model, used when `generate_video` gets no `model`."),
24970
26447
  models: array(listedModelSchema)
24971
26448
  }
24972
26449
  }, async ({ model: alias }) => {
24973
26450
  try {
24974
26451
  const config = await loadConfig(deps.dirs);
24975
26452
  if (config.models.length === 0)
24976
- throw needsSettings("litevision has no image Model configured yet.");
24977
- const defaultModel = defaultModelAlias(config) ?? null;
26453
+ throw needsSettings("litevision has no Model configured yet.");
26454
+ const defaultModel = defaultModelAlias(config, "image") ?? null;
26455
+ const defaultVideoModel = defaultModelAlias(config, "video") ?? null;
24978
26456
  let models = config.models;
24979
26457
  if (alias !== undefined) {
24980
26458
  models = models.filter((model) => model.alias === alias);
@@ -24982,12 +26460,13 @@ ${job.files.join(`
24982
26460
  throw new ConfigError(`Unknown Model Alias "${alias}". Known Aliases: ${config.models.map((model) => model.alias).join(", ")}.`);
24983
26461
  }
24984
26462
  }
24985
- const entries = await Promise.all(models.map((model) => listedModel(infoDeps, config, model, model.alias === defaultModel, alias !== undefined)));
26463
+ const ratedJobs = await readRatedJobs(deps.dirs);
26464
+ const entries = await Promise.all(models.map((model) => listedModel(infoDeps, config, model, model.alias === (model.modality === "video" ? defaultVideoModel : defaultModel), alias !== undefined, ratedJobs)));
24986
26465
  const text = entries.map((entry) => entry.text).join(`
24987
26466
  `);
24988
26467
  return {
24989
26468
  content: [{ type: "text", text }],
24990
- structuredContent: { default_model: defaultModel, models: entries.map((entry) => entry.output) }
26469
+ structuredContent: { default_model: defaultModel, default_video_model: defaultVideoModel, models: entries.map((entry) => entry.output) }
24991
26470
  };
24992
26471
  } catch (error) {
24993
26472
  if (error instanceof ConfigError)
@@ -25054,6 +26533,19 @@ Now: ${change.notes || "(none)"}`;
25054
26533
  const text = "Opened the litevision settings page in the User's browser. Ask the User to finish setting up there and tell you when they are done. Never ask the User for a Credential in the conversation.";
25055
26534
  return { content: [{ type: "text", text }] };
25056
26535
  });
26536
+ server.registerPrompt("settings", {
26537
+ title: "litevision settings",
26538
+ description: "Open litevision's settings page to add Providers, enter Credentials and set up Models."
26539
+ }, async () => {
26540
+ let text;
26541
+ try {
26542
+ await settings.open();
26543
+ text = "I have already opened litevision's settings page in my browser. Do not call `open_settings`. Wait until I say I am done there. Never ask me for a Credential in the conversation.";
26544
+ } catch {
26545
+ text = "I tried to open litevision's settings page, but it could not open my browser. Tell me to run `litevision settings` in a terminal, which opens the page and prints its address.";
26546
+ }
26547
+ return { messages: [{ role: "user", content: { type: "text", text } }] };
26548
+ });
25057
26549
  return server;
25058
26550
  }
25059
26551