litevision 0.1.0 → 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 +2034 -493
  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);
21969
+ }
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);
21877
21998
  }
21878
- function knownGoodOptions(jobs, model) {
21879
- const options = {};
21880
- for (const job of jobs) {
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
+ }
22026
+ }
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;
21886
22033
  }
21887
- return options;
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.`);
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.`);
21943
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,14 +23819,63 @@ 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;
23828
+ }
23829
+ async start(spec) {
23830
+ const { request } = spec.normalised;
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);
23840
+ await this.append(base, "running");
23841
+ const job = this.track(base, spec.credential, async (context) => {
23842
+ const submitted = await spec.adapter.submit(request, target, this.deps.http, context);
23843
+ if (submitted.resumeHandle !== undefined)
23844
+ await this.append(base, "running", { resume: submitted.resumeHandle });
23845
+ return submitted.wait();
23846
+ });
23847
+ return this.waitFor(base.model, base.id, this.register(base.id, job));
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));
22977
23876
  }
22978
- async start(spec) {
22979
- const { request } = spec.normalised;
22980
- const base = {
23877
+ baseFor(spec, modality, prompt, parameters) {
23878
+ return {
22981
23879
  type: "job",
22982
23880
  id: randomUUID2(),
22983
23881
  createdAt: this.deps.now().toISOString(),
@@ -22985,30 +23883,11 @@ class JobRunner {
22985
23883
  provider: spec.provider.id,
22986
23884
  protocol: spec.model.protocol,
22987
23885
  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
23886
+ modality,
23887
+ prompt,
23888
+ parameters,
23889
+ ...spec.comparison === undefined ? {} : { comparison: spec.comparison }
23003
23890
  };
23004
- await this.append(base, "running");
23005
- const job = this.track(base, spec.credential, async (context) => {
23006
- const submitted = await spec.adapter.submit(request, target, this.deps.http, context);
23007
- if (submitted.resumeHandle !== undefined)
23008
- await this.append(base, "running", { resume: submitted.resumeHandle });
23009
- return submitted.wait();
23010
- });
23011
- return this.waitFor(base.model, base.id, this.register(base.id, job));
23012
23891
  }
23013
23892
  async get(id) {
23014
23893
  const record = await readJob(this.deps.dirs, id);
@@ -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",
@@ -24047,110 +25024,153 @@ function renderPage(nonce) {
24047
25024
  <style>
24048
25025
  :root {
24049
25026
  color-scheme: light dark;
24050
- --bg: #f7f7f8; --card: #fff; --text: #1d1d1f; --muted: #6b6b73; --border: #dcdce0;
24051
- --accent: #2f6fde; --danger: #c43d3d; --ok: #1f8a4c; --warn: #a86b00;
25027
+ --bg: #fafafa; --surface: #fff; --field: #fff; --hover: #f4f4f5; --text: #18181b; --muted: #71717a; --border: #e4e4e7;
25028
+ --accent: #2563eb; --focus: rgb(37 99 235 / 0.22); --danger: #dc2626; --ok: #15803d; --warn: #b45309;
24052
25029
  }
24053
25030
  @media (prefers-color-scheme: dark) {
24054
- :root { --bg: #151517; --card: #1f1f22; --text: #ececf0; --muted: #a0a0aa; --border: #35353b;
24055
- --accent: #6f9cf0; --danger: #ec6b6b; --ok: #4cc283; --warn: #e0a640; }
25031
+ :root { --bg: #0f0f11; --surface: #18181b; --field: #121214; --hover: #26262a; --text: #f4f4f5; --muted: #a1a1aa; --border: #2e2e33;
25032
+ --accent: #3b82f6; --focus: rgb(59 130 246 / 0.35); --danger: #f87171; --ok: #4ade80; --warn: #fbbf24; }
24056
25033
  }
24057
25034
  * { box-sizing: border-box; }
24058
25035
  body { margin: 0; background: var(--bg); color: var(--text);
24059
- font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
24060
- main { max-width: 760px; margin: 0 auto; padding: 24px 16px 64px; }
24061
- h1 { font-size: 22px; margin: 0 0 4px; }
24062
- h2 { font-size: 17px; margin: 32px 0 12px; }
24063
- h3 { font-size: 15px; margin: 0; }
24064
- p.lead, .muted { color: var(--muted); }
24065
- p.lead { margin: 0 0 8px; }
24066
- .card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 16px; margin-bottom: 12px; }
24067
- .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
24068
- .row + .row { margin-top: 10px; }
24069
- .grow { flex: 1 1 220px; min-width: 0; }
24070
- label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
24071
- input, select, textarea, button { font: inherit; border-radius: 7px; }
24072
- input, select, textarea { width: 100%; padding: 7px 9px; border: 1px solid var(--border); background: var(--bg); color: var(--text); }
24073
- textarea { resize: vertical; min-height: 60px; }
24074
- .badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: var(--accent); color: #fff; }
24075
- .counter { font-size: 12px; color: var(--muted); text-align: right; }
24076
- .notes-history { font-size: 13px; }
24077
- button { padding: 7px 12px; border: 1px solid var(--border); background: var(--card); color: var(--text); cursor: pointer; white-space: nowrap; }
25036
+ font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
25037
+ main { max-width: 720px; margin: 0 auto; padding: 48px 20px 96px; }
25038
+ h1 { font-size: 20px; font-weight: 600; margin: 0 0 6px; }
25039
+ h2 { font-size: 15px; font-weight: 600; margin: 0; }
25040
+ h3 { font-size: 15px; font-weight: 600; margin: 0; flex: 1; min-width: 0; overflow-wrap: anywhere; }
25041
+ p { margin: 0; }
25042
+ .muted { color: var(--muted); }
25043
+ header p { color: var(--muted); }
25044
+ section { margin-top: 44px; }
25045
+ .section-head { margin-bottom: 14px; }
25046
+ .section-head p { color: var(--muted); font-size: 13px; margin-top: 2px; }
25047
+
25048
+ .card { display: grid; gap: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
25049
+ padding: 20px; margin-bottom: 12px; }
25050
+ .card-head { display: flex; align-items: center; gap: 10px; }
25051
+ .fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
25052
+ .field { min-width: 0; }
25053
+ .wide { grid-column: 1 / -1; }
25054
+ .inline { display: flex; flex-wrap: wrap; gap: 8px; }
25055
+ .inline > input, .inline > select { flex: 1 1 200px; width: auto; }
25056
+ .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding-top: 16px; border-top: 1px solid var(--border); }
25057
+ .actions > select { width: auto; flex: 0 1 180px; }
25058
+ .spacer { flex: 1; }
25059
+ .empty { color: var(--muted); padding: 20px; border: 1px dashed var(--border); border-radius: 12px; text-align: center; margin-bottom: 12px; }
25060
+
25061
+ label { display: block; font-size: 12px; font-weight: 500; color: var(--muted); margin-bottom: 6px; }
25062
+ input, select, textarea, button { font: inherit; color: inherit; }
25063
+ input, select, textarea { width: 100%; height: 34px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px;
25064
+ background: var(--field); color: var(--text); }
25065
+ textarea { height: auto; min-height: 72px; padding: 8px 10px; resize: vertical; }
25066
+ input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
25067
+ input::placeholder, textarea::placeholder { color: var(--muted); opacity: 0.8; }
25068
+ /* API keys are masked with CSS: a real key field would make the browser offer to save the key as a site login. */
25069
+ .secret { -webkit-text-security: disc; }
25070
+ .counter { font-size: 12px; color: var(--muted); text-align: right; margin-top: 4px; }
25071
+
25072
+ button { height: 34px; padding: 0 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface);
25073
+ font-weight: 500; cursor: pointer; white-space: nowrap; }
25074
+ button:hover:not(:disabled) { background: var(--hover); }
25075
+ button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus); }
24078
25076
  button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
24079
- button.danger { color: var(--danger); }
24080
- button:disabled { opacity: 0.6; cursor: default; }
24081
- code { font-size: 13px; overflow-wrap: anywhere; }
25077
+ button.primary:hover:not(:disabled) { background: var(--accent); filter: brightness(1.08); }
25078
+ button.danger { border-color: transparent; background: transparent; color: var(--danger); }
25079
+ button.small { height: 28px; padding: 0 10px; font-size: 13px; }
25080
+ button:disabled { opacity: 0.5; cursor: default; }
25081
+
25082
+ .chip { flex: none; font-size: 12px; font-weight: 500; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); }
25083
+ .chip.ok { color: var(--ok); border-color: currentColor; }
25084
+ .chip.accent { color: #fff; background: var(--accent); border-color: var(--accent); }
25085
+ .chip.failed { color: var(--danger); border-color: currentColor; }
25086
+ .chip.running { color: var(--warn); border-color: currentColor; }
24082
25087
  .key { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
24083
- .result { font-size: 14px; margin-top: 10px; }
25088
+
25089
+ .result { font-size: 13px; }
25090
+ .result:empty { display: none; }
25091
+ .field > .result { margin-top: 8px; }
24084
25092
  .result.success { color: var(--ok); } .result.failure { color: var(--danger); } .result.unsupported { color: var(--warn); }
24085
- #status { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); max-width: calc(100% - 32px);
24086
- background: var(--text); color: var(--bg); padding: 8px 14px; border-radius: 8px; font-size: 14px; display: none; }
24087
- #status.error { background: var(--danger); color: #fff; }
24088
- .job-meta { font-size: 13px; color: var(--muted); }
24089
- .prompt { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
24090
- .thumbs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
24091
- .thumb { width: 112px; height: 112px; object-fit: cover; border-radius: 7px; border: 1px solid var(--border); background: var(--bg); }
25093
+ .note { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 8px; margin-top: 8px; }
25094
+
25095
+ .job-meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
25096
+ .prompt { white-space: pre-wrap; overflow-wrap: anywhere; }
25097
+ .thumbs { display: flex; flex-wrap: wrap; gap: 8px; }
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; }
24092
25100
  .thumb.placeholder { display: flex; align-items: center; justify-content: center; text-align: center; padding: 6px;
24093
- font-size: 12px; color: var(--muted); border-style: dashed; }
24094
- .rating { font-size: 14px; }
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; }
25104
+ .actions.rate { display: grid; gap: 10px; }
25105
+ .inline > select.rate-target { flex: 0 0 auto; }
25106
+ .rating { font-size: 13px; }
24095
25107
  .rating.good { color: var(--ok); } .rating.bad { color: var(--danger); }
24096
- .outcome.failed { color: var(--danger); } .outcome.running { color: var(--warn); }
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; }
25113
+
25114
+ #status { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); max-width: calc(100% - 32px);
25115
+ background: var(--text); color: var(--bg); padding: 8px 14px; border-radius: 8px; font-size: 13px; display: none;
25116
+ box-shadow: 0 6px 24px rgb(0 0 0 / 0.18); }
25117
+ #status.error { background: var(--danger); color: #fff; }
24097
25118
  </style>
24098
25119
  </head>
24099
25120
  <body>
24100
25121
  <main>
24101
- <h1>litevision settings</h1>
24102
- <p class="lead">Credentials you enter here are saved on this computer only, in a file only you can read. They never pass through your Agent.</p>
25122
+ <header>
25123
+ <h1>litevision settings</h1>
25124
+ <p>Credentials you enter here are saved on this computer only, in a file only you can read. They never pass through your Agent.</p>
25125
+ </header>
24103
25126
 
24104
25127
  <section id="providers">
24105
- <h2>Providers</h2>
25128
+ <div class="section-head"><h2>Providers</h2></div>
24106
25129
  <div id="provider-list"><p class="muted">Loading…</p></div>
24107
25130
 
24108
25131
  <form id="add-provider" class="card" autocomplete="off">
24109
25132
  <h3>Add a Provider</h3>
24110
- <div class="row">
24111
- <div class="grow"><label for="add-preset">Preset</label><select id="add-preset"></select></div>
24112
- <div class="grow"><label for="add-id">Name</label><input id="add-id" required spellcheck="false" placeholder="openai"></div>
24113
- </div>
24114
- <div class="row">
24115
- <div class="grow"><label for="add-base-url">Base URL</label><input id="add-base-url" required spellcheck="false" placeholder="https://relay.example/v1"></div>
24116
- </div>
24117
- <div class="row">
24118
- <div class="grow"><label for="add-credential">API key</label><input id="add-credential" type="password" autocomplete="new-password" spellcheck="false" placeholder="Paste your API key"></div>
25133
+ <div class="fields">
25134
+ <div class="field"><label for="add-preset">Preset</label><select id="add-preset"></select></div>
25135
+ <div class="field"><label for="add-id">Name</label><input id="add-id" required spellcheck="false" placeholder="openai"></div>
25136
+ <div class="field wide"><label for="add-base-url">Base URL</label><input id="add-base-url" required spellcheck="false" placeholder="https://relay.example/v1"></div>
25137
+ <div class="field wide"><label for="add-credential">API key</label><input id="add-credential" class="secret" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore spellcheck="false" placeholder="Paste your API key"></div>
24119
25138
  </div>
24120
- <div class="row"><button class="primary" type="submit">Add Provider</button></div>
25139
+ <div class="actions"><button class="primary" type="submit">Add Provider</button></div>
24121
25140
  </form>
24122
25141
  </section>
24123
25142
 
24124
25143
  <section id="models">
24125
- <h2>Models</h2>
24126
- <p class="muted">Your Agent picks a Model by its Alias. Model Notes tell it what each Model is good or bad at.</p>
25144
+ <div class="section-head">
25145
+ <h2>Models</h2>
25146
+ <p>Your Agent picks a Model by its Alias. Model Notes tell it what each Model is good or bad at.</p>
25147
+ </div>
24127
25148
  <div id="model-list"><p class="muted">Loading…</p></div>
24128
25149
 
24129
25150
  <form id="add-model" class="card" autocomplete="off">
24130
25151
  <h3>Add a Model</h3>
24131
- <div class="row">
24132
- <div class="grow"><label for="model-provider">Provider</label><select id="model-provider" required></select></div>
24133
- <div class="grow"><label for="model-protocol">Protocol</label><select id="model-protocol" required></select></div>
25152
+ <div class="fields">
25153
+ <div class="field"><label for="model-provider">Provider</label><select id="model-provider" required></select></div>
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>
25156
+ <div class="field wide">
25157
+ <label for="model-id">Model id</label>
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>
25159
+ <datalist id="model-ids"></datalist>
25160
+ <div id="fetch-result" class="result"></div>
25161
+ </div>
25162
+ <div class="field wide"><label for="model-alias">Alias</label><input id="model-alias" required spellcheck="false" placeholder="gpt-image-cheap"></div>
25163
+ <div class="field wide"><label for="model-notes">Model Notes</label><textarea id="model-notes" maxlength="${modelNotesMaxLength}" placeholder="photoreal, cheap, bad at Chinese text"></textarea></div>
24134
25164
  </div>
24135
- <div class="row">
24136
- <div class="grow"><label for="model-id">Model id</label><input id="model-id" required spellcheck="false" list="model-ids" placeholder="gpt-image-1"></div>
24137
- <button id="fetch-models" type="button">Fetch model list</button>
24138
- </div>
24139
- <datalist id="model-ids"></datalist>
24140
- <div id="fetch-result" class="result"></div>
24141
- <div class="row">
24142
- <div class="grow"><label for="model-alias">Alias</label><input id="model-alias" required spellcheck="false" placeholder="gpt-image-cheap"></div>
24143
- </div>
24144
- <div class="row">
24145
- <div class="grow"><label for="model-notes">Model Notes</label><textarea id="model-notes" maxlength="${modelNotesMaxLength}" placeholder="photoreal, cheap, bad at Chinese text"></textarea></div>
24146
- </div>
24147
- <div class="row"><button class="primary" type="submit">Add Model</button></div>
25165
+ <div class="actions"><button class="primary" type="submit">Add Model</button></div>
24148
25166
  </form>
24149
25167
  </section>
24150
25168
 
24151
25169
  <section id="history">
24152
- <h2>Job history</h2>
24153
- <p class="muted">Newest first. Rate a Job to keep your verdict on it; rating it again replaces the earlier Rating.</p>
25170
+ <div class="section-head">
25171
+ <h2>Job history</h2>
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>
25173
+ </div>
24154
25174
  <div id="job-list"><p class="muted">Loading…</p></div>
24155
25175
  </section>
24156
25176
  </main>
@@ -24193,6 +25213,25 @@ function el(tag, props, ...children) {
24193
25213
  return node;
24194
25214
  }
24195
25215
 
25216
+ let fieldCount = 0;
25217
+ /** A labelled control, with any buttons on the same line. */
25218
+ function field(text, control, ...buttons) {
25219
+ control.id ||= "field-" + ++fieldCount;
25220
+ return el("div", { className: "field" }, el("label", { htmlFor: control.id, textContent: text }),
25221
+ buttons.length === 0 ? control : el("div", { className: "inline" }, control, ...buttons));
25222
+ }
25223
+
25224
+ /** An API key input, masked with CSS (see .secret) and marked for 1Password, LastPass and Bitwarden to leave alone. */
25225
+ function secretInput(placeholder) {
25226
+ const input = el("input", { className: "secret", autocomplete: "off", spellcheck: false, placeholder });
25227
+ for (const name of ["data-1p-ignore", "data-lpignore", "data-bwignore"]) input.setAttribute(name, "true");
25228
+ return input;
25229
+ }
25230
+
25231
+ function emptyState(text) {
25232
+ return el("p", { className: "empty", textContent: text });
25233
+ }
25234
+
24196
25235
  let statusTimer;
24197
25236
  function showStatus(message, isError) {
24198
25237
  const status = document.getElementById("status");
@@ -24230,8 +25269,7 @@ function protocolSelect(selected) {
24230
25269
 
24231
25270
  function providerCard(provider) {
24232
25271
  const baseUrl = el("input", { value: provider.baseUrl, spellcheck: false });
24233
- const credential = el("input", { type: "password", autocomplete: "new-password", spellcheck: false,
24234
- placeholder: provider.credential ? "New API key to replace the saved one" : "Paste your API key" });
25272
+ const credential = secretInput(provider.credential ? "New API key to replace the saved one" : "Paste your API key");
24235
25273
  const protocol = protocolSelect(protocolFor(provider));
24236
25274
  const result = el("div", { className: "result" });
24237
25275
 
@@ -24248,7 +25286,7 @@ function providerCard(provider) {
24248
25286
  await loadProviders();
24249
25287
  }, "API key saved.");
24250
25288
 
24251
- const removeKey = el("button", { type: "button", className: "danger", textContent: "Remove key", disabled: !provider.credential });
25289
+ const removeKey = el("button", { type: "button", className: "danger", textContent: "Remove key" });
24252
25290
  removeKey.onclick = () => {
24253
25291
  if (!confirm("Remove the saved API key for " + provider.id + "?")) return;
24254
25292
  act(removeKey, async () => {
@@ -24275,15 +25313,16 @@ function providerCard(provider) {
24275
25313
  }, "Provider removed.");
24276
25314
  };
24277
25315
 
24278
- const keyText = provider.credential
24279
- ? el("span", { className: "key", textContent: "API key ••••" + provider.credential.last4 })
24280
- : el("span", { className: "muted", textContent: "No API key saved" });
25316
+ const keyChip = provider.credential
25317
+ ? el("span", { className: "chip ok key", textContent: "Key ••••" + provider.credential.last4 })
25318
+ : el("span", { className: "chip", textContent: "No API key" });
24281
25319
 
24282
25320
  return el("div", { className: "card" },
24283
- el("div", { className: "row" }, el("h3", { className: "grow", textContent: provider.id }), keyText),
24284
- el("div", { className: "row" }, el("div", { className: "grow" }, baseUrl), saveUrl),
24285
- el("div", { className: "row" }, el("div", { className: "grow" }, credential), saveKey, removeKey),
24286
- el("div", { className: "row" }, el("div", { className: "grow" }, protocol), test, remove),
25321
+ el("div", { className: "card-head" }, el("h3", { textContent: provider.id }), keyChip),
25322
+ el("div", { className: "fields" },
25323
+ el("div", { className: "wide" }, field("Base URL", baseUrl, saveUrl)),
25324
+ el("div", { className: "wide" }, provider.credential ? field("API key", credential, saveKey, removeKey) : field("API key", credential, saveKey))),
25325
+ el("div", { className: "actions" }, protocol, test, el("span", { className: "spacer" }), remove),
24287
25326
  result,
24288
25327
  );
24289
25328
  }
@@ -24292,7 +25331,7 @@ async function loadProviders() {
24292
25331
  const list = document.getElementById("provider-list");
24293
25332
  const { providers } = await api("GET", "/api/providers");
24294
25333
  list.replaceChildren(...(providers.length === 0
24295
- ? [el("p", { className: "muted", textContent: "No Providers yet. Add one below." })]
25334
+ ? [emptyState("No Providers yet. Add one below.")]
24296
25335
  : providers.map(providerCard)));
24297
25336
  providerIds = providers.map((p) => p.id);
24298
25337
  const select = document.getElementById("model-provider");
@@ -24314,7 +25353,10 @@ function notesField(value) {
24314
25353
  const update = () => (counter.textContent = textarea.value.length + " / " + notesMax);
24315
25354
  textarea.oninput = update;
24316
25355
  update();
24317
- return { textarea, wrapper: el("div", { className: "grow" }, el("label", { textContent: "Model Notes" }), textarea, counter) };
25356
+ const wrapper = field("Model Notes", textarea);
25357
+ wrapper.classList.add("wide");
25358
+ wrapper.append(counter);
25359
+ return { textarea, wrapper };
24318
25360
  }
24319
25361
 
24320
25362
  /** "Last changed by the Agent, 30 Oct 2026, 10:00", plus an undo button when there is a previous version. */
@@ -24323,20 +25365,16 @@ function notesHistoryRow(model, path) {
24323
25365
  if (!history) return [];
24324
25366
  const who = history.changedBy === "Agent" ? "your Agent" : "you";
24325
25367
  const when = new Date(history.changedAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
24326
- const parts = [el("span", { className: "grow muted", textContent: "Model Notes last changed by " + who + ", " + when + "." })];
25368
+ const parts = [el("span", { textContent: "Last changed by " + who + ", " + when + "." })];
24327
25369
  if (history.previous !== undefined) {
24328
- const undo = el("button", { type: "button", textContent: "Undo", title: "Back to: " + (history.previous || "(no Model Notes)") });
25370
+ const undo = el("button", { type: "button", className: "small", textContent: "Undo", title: "Back to: " + (history.previous || "(no Model Notes)") });
24329
25371
  undo.onclick = () => act(undo, async () => {
24330
25372
  await api("POST", path + "/notes/undo");
24331
25373
  await loadModels();
24332
25374
  }, "Model Notes restored. Undo again to redo.");
24333
25375
  parts.push(undo);
24334
25376
  }
24335
- return [el("div", { className: "row notes-history" }, ...parts)];
24336
- }
24337
-
24338
- function labelled(text, control) {
24339
- return el("div", { className: "grow" }, el("label", { textContent: text }), control);
25377
+ return [el("div", { className: "note" }, ...parts)];
24340
25378
  }
24341
25379
 
24342
25380
  function modelCard(model) {
@@ -24344,20 +25382,21 @@ function modelCard(model) {
24344
25382
  const provider = el("select", {}, ...options(providerIds.includes(model.provider) ? providerIds : [model.provider, ...providerIds], model.provider));
24345
25383
  const modelId = el("input", { value: model.modelId, spellcheck: false });
24346
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));
24347
25386
  const notes = notesField(model.notes);
24348
25387
  const path = "/api/models/" + encodeURIComponent(model.alias);
24349
25388
 
24350
25389
  const save = el("button", { type: "button", className: "primary", textContent: "Save" });
24351
25390
  save.onclick = () => act(save, async () => {
24352
- 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 });
24353
25392
  await loadModels();
24354
25393
  }, "Model saved.");
24355
25394
 
24356
- const makeDefault = el("button", { type: "button", textContent: "Make default" });
25395
+ const makeDefault = el("button", { type: "button", className: "small", textContent: "Make default" });
24357
25396
  makeDefault.onclick = () => act(makeDefault, async () => {
24358
- await api("PUT", "/api/default-image-model", { alias: model.alias });
25397
+ await api("PUT", "/api/default-" + model.modality + "-model", { alias: model.alias });
24359
25398
  await loadModels();
24360
- }, model.alias + " is now the default image Model.");
25399
+ }, model.alias + " is now the default " + model.modality + " Model.");
24361
25400
 
24362
25401
  const remove = el("button", { type: "button", className: "danger", textContent: "Remove Model" });
24363
25402
  remove.onclick = () => {
@@ -24368,14 +25407,14 @@ function modelCard(model) {
24368
25407
  }, "Model removed.");
24369
25408
  };
24370
25409
 
25410
+ notes.wrapper.append(...notesHistoryRow(model, path));
25411
+
24371
25412
  return el("div", { className: "card" },
24372
- el("div", { className: "row" }, el("h3", { className: "grow", textContent: model.alias }),
24373
- model.default ? el("span", { className: "badge", textContent: "Default" }) : makeDefault),
24374
- el("div", { className: "row" }, labelled("Alias", alias), labelled("Provider", provider)),
24375
- el("div", { className: "row" }, labelled("Model id", modelId), labelled("Protocol", protocol)),
24376
- el("div", { className: "row" }, notes.wrapper),
24377
- ...notesHistoryRow(model, path),
24378
- el("div", { className: "row" }, save, remove),
25413
+ el("div", { className: "card-head" }, el("h3", { textContent: model.alias }),
25414
+ model.default ? el("span", { className: "chip accent", textContent: "Default " + model.modality }) : makeDefault),
25415
+ el("div", { className: "fields" },
25416
+ field("Alias", alias), field("Provider", provider), field("Model id", modelId), field("Protocol", protocol), field("Makes", modality), notes.wrapper),
25417
+ el("div", { className: "actions" }, save, el("span", { className: "spacer" }), remove),
24379
25418
  );
24380
25419
  }
24381
25420
 
@@ -24383,7 +25422,7 @@ async function loadModels() {
24383
25422
  const list = document.getElementById("model-list");
24384
25423
  const { models } = await api("GET", "/api/models");
24385
25424
  list.replaceChildren(...(models.length === 0
24386
- ? [el("p", { className: "muted", textContent: providerIds.length === 0 ? "Add a Provider first, then add its Models below." : "No Models yet. Add one below." })]
25425
+ ? [emptyState(providerIds.length === 0 ? "Add a Provider first, then add its Models below." : "No Models yet. Add one below.")]
24387
25426
  : models.map(modelCard)));
24388
25427
  }
24389
25428
 
@@ -24396,6 +25435,9 @@ function setUpAddModelForm() {
24396
25435
  const fetchButton = document.getElementById("fetch-models");
24397
25436
  const fetchResult = document.getElementById("fetch-result");
24398
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 = {};
24399
25441
  protocol.replaceChildren(...options(protocols));
24400
25442
 
24401
25443
  // Suggest the Protocol a preset Provider's Models usually speak; the User can still change it.
@@ -24403,16 +25445,21 @@ function setUpAddModelForm() {
24403
25445
  const suggested = presets.find((p) => p.id === provider.value);
24404
25446
  if (suggested && protocols.includes(suggested.protocol)) protocol.value = suggested.protocol;
24405
25447
  ids.replaceChildren();
25448
+ knownModalities = {};
24406
25449
  fetchResult.textContent = "";
24407
25450
  };
24408
- protocol.onchange = () => ids.replaceChildren();
25451
+ protocol.onchange = () => {
25452
+ ids.replaceChildren();
25453
+ knownModalities = {};
25454
+ };
24409
25455
 
24410
25456
  fetchButton.onclick = () => act(fetchButton, async () => {
24411
25457
  fetchResult.textContent = "Fetching…";
24412
25458
  fetchResult.className = "result";
24413
25459
  const outcome = await api("POST", "/api/providers/" + encodeURIComponent(provider.value) + "/model-ids", { protocol: protocol.value });
24414
25460
  if (outcome.result === "success") {
24415
- 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 })));
24416
25463
  fetchResult.textContent = outcome.modelIds.length + " model id(s) found. Pick one in the Model id field, or type your own.";
24417
25464
  fetchResult.className = "result success";
24418
25465
  modelId.focus();
@@ -24426,13 +25473,15 @@ function setUpAddModelForm() {
24426
25473
  let aliasEdited = false;
24427
25474
  alias.oninput = () => (aliasEdited = alias.value !== "");
24428
25475
  modelId.oninput = () => {
25476
+ const known = knownModalities[modelId.value.trim()];
25477
+ if (known) modality.value = known;
24429
25478
  if (!aliasEdited) alias.value = modelId.value.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
24430
25479
  };
24431
25480
 
24432
25481
  const form = document.getElementById("add-model");
24433
25482
  form.onsubmit = (event) => {
24434
25483
  event.preventDefault();
24435
- 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 };
24436
25485
  if (notes.value.trim() !== "") body.notes = notes.value;
24437
25486
  act(form.querySelector("button[type=submit]"), async () => {
24438
25487
  await api("POST", "/api/models", body);
@@ -24473,63 +25522,147 @@ function setUpAddForm() {
24473
25522
  };
24474
25523
  }
24475
25524
 
24476
- /** An image of a Job, or a placeholder once the file is gone. */
24477
- function thumbnail(file) {
24478
- const img = el("img", { className: "thumb", src: file.thumbnail + "?token=" + encodeURIComponent(token), alt: file.path, title: file.path, loading: "lazy" });
24479
- img.onerror = () => img.replaceWith(el("div", { className: "thumb placeholder", title: file.path, textContent: "Image no longer on disk" }));
24480
- 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;
24481
25533
  }
24482
25534
 
24483
25535
  function formatDuration(ms) {
24484
25536
  return ms < 1000 ? ms + " ms" : (ms / 1000).toFixed(1) + " s";
24485
25537
  }
24486
25538
 
24487
- /** The current Rating, and a one-line reason with Good and Bad buttons to rate the Job. Only completed Jobs can be rated. */
24488
- function ratingRows(job) {
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
+ */
25587
+ function ratingRow(job) {
24489
25588
  if (job.outcome !== "completed") return [];
25589
+ const whole = job.files.length > 1 ? "Whole Job · " : "";
24490
25590
  const current = job.rating
24491
- ? el("span", { className: "grow rating " + job.rating.verdict, textContent: (job.rating.verdict === "good" ? "Good" : "Bad") + ": " + job.rating.reason })
24492
- : el("span", { className: "grow 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." });
24493
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) })));
24494
25597
  const rateAs = (verdict, label) => {
24495
25598
  const button = el("button", { type: "button", textContent: label });
24496
25599
  button.onclick = () => act(button, async () => {
24497
- 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);
24498
25603
  await loadJobs();
24499
25604
  }, "Rating saved.");
24500
25605
  return button;
24501
25606
  };
24502
- return [
24503
- el("div", { className: "row" }, current),
24504
- el("div", { className: "row" }, el("div", { className: "grow" }, reason), rateAs("good", "Good"), rateAs("bad", "Bad")),
24505
- ];
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")))];
24506
25609
  }
24507
25610
 
24508
25611
  function jobCard(job) {
24509
25612
  const when = new Date(job.createdAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
24510
25613
  const outcome = job.outcome === "completed"
24511
- ? job.files.length + " image(s)"
25614
+ ? (job.modality === "video" ? "Video" : job.files.length + " image(s)")
24512
25615
  : job.outcome === "running" ? "Running…" : "Failed";
24513
25616
  const details = [];
24514
25617
  if (job.error) details.push(el("p", { className: "result failure", textContent: job.error }));
24515
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 }));
24516
25620
  return el("div", { className: "card" },
24517
- el("div", { className: "row job-meta" },
24518
- el("span", { className: "grow", textContent: job.model + " (" + job.modelId + " on " + job.provider + ") · " + when + " · " + formatDuration(job.durationMs) }),
24519
- el("span", { className: "outcome " + job.outcome, textContent: outcome })),
25621
+ el("div", {},
25622
+ el("div", { className: "card-head" }, el("h3", { textContent: job.model }),
25623
+ ...(isPicked(job) ? [el("span", { className: "chip accent", textContent: "Picked" })] : []),
25624
+ el("span", { className: "chip " + (job.outcome === "completed" ? "ok" : job.outcome), textContent: outcome })),
25625
+ el("p", { className: "job-meta", textContent: job.modelId + " on " + job.provider + " · " + when + " · " + formatDuration(job.durationMs) })),
24520
25626
  el("p", { className: "prompt", textContent: job.prompt }),
24521
- ...(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)))]),
24522
25628
  ...details,
24523
- ...ratingRows(job),
25629
+ ...ratingRow(job),
25630
+ ...pickRow(job),
24524
25631
  );
24525
25632
  }
24526
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
+
24527
25660
  async function loadJobs() {
24528
25661
  const list = document.getElementById("job-list");
24529
25662
  const { jobs } = await api("GET", "/api/jobs");
24530
25663
  list.replaceChildren(...(jobs.length === 0
24531
- ? [el("p", { className: "muted", textContent: "No Jobs yet. Images your Agent generates will appear here." })]
24532
- : jobs.map(jobCard)));
25664
+ ? [emptyState("No Jobs yet. Images your Agent generates will appear here.")]
25665
+ : jobEntries(jobs)));
24533
25666
  }
24534
25667
 
24535
25668
  (async () => {
@@ -24605,7 +25738,7 @@ async function startSettingsServer(options) {
24605
25738
  const nonce = randomBytes2(16).toString("base64");
24606
25739
  response.writeHead(200, {
24607
25740
  "content-type": "text/html; charset=utf-8",
24608
- "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'`,
24609
25742
  ...commonHeaders
24610
25743
  });
24611
25744
  response.end(renderPage(nonce));
@@ -24698,58 +25831,112 @@ function settingsOpener(options) {
24698
25831
  };
24699
25832
  }
24700
25833
  // package.json
24701
- var version2 = "0.1.0";
25834
+ var version2 = "0.2.0";
24702
25835
 
24703
25836
  // src/server.ts
24704
- 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\`.
24705
25838
 
24706
- - Use the Model the User names (pass its Alias as \`model\`).
24707
- - 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.
24708
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.
24709
- - \`generate_image\` returns file paths, not image data. Tell the User where the files are.
24710
- - 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.
24711
- - 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.
24712
- - 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.
24713
- - Pass \`output_path\` when the images belong in the User's project (e.g. its assets folder); otherwise omit it.
24714
- - 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.
24715
- - If fewer images came back than you asked for, the result says why. Tell the User.
24716
- - 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.
24717
- - 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;
24718
25851
  var listedModelSchema = object({
24719
25852
  alias: string2(),
24720
25853
  provider: string2(),
24721
25854
  protocol: string2(),
24722
25855
  model_id: string2().describe("The Provider's own name for the model."),
24723
- 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."),
24724
25858
  notes: string2().describe("Model Notes: what the Model is good or bad at, agreed with the User."),
24725
25859
  capabilities: object({
24726
25860
  aspect_ratios: array(object({ aspect_ratio: _enum(aspectRatios), size: string2() })),
24727
25861
  reference_images: boolean2(),
24728
25862
  max_reference_images: number2(),
24729
25863
  max_images_per_job: number2()
24730
- }).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."),
24731
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.'),
24732
25879
  problem: string2().optional(),
24733
- parameter_schema: unknown().optional().describe("Only with `model`: the Provider's own description of the Model's parameters, when it publishes one."),
24734
- 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.")
24735
- });
24736
- 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) {
24737
25900
  const base = {
24738
25901
  alias: model.alias,
24739
25902
  provider: model.provider,
24740
25903
  protocol: model.protocol,
24741
25904
  model_id: model.modelId,
25905
+ modality: model.modality,
24742
25906
  default: isDefault,
24743
25907
  notes: model.notes ?? ""
24744
25908
  };
24745
- 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}.`;
24746
25910
  const notes = model.notes ? ` Notes: ${model.notes}` : "";
24747
- let info;
25911
+ let described;
24748
25912
  try {
24749
25913
  const provider = config.providers.find((candidate) => candidate.id === model.provider);
24750
25914
  if (provider === undefined)
24751
25915
  throw needsSettings(`Model "${model.alias}" refers to Provider "${model.provider}", which is not configured.`);
24752
- 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
+ }
24753
25940
  } catch (error) {
24754
25941
  if (!(error instanceof ConfigError))
24755
25942
  throw error;
@@ -24758,31 +25945,156 @@ async function listedModel(deps, config, model, isDefault, detailed) {
24758
25945
  text: `${heading} Cannot be used: ${error.message}${notes}`
24759
25946
  };
24760
25947
  }
24761
- const { capabilities } = info;
25948
+ const record2 = optionsOf(ratedJobs, model);
24762
25949
  const output = {
24763
25950
  ...base,
24764
- capabilities: {
24765
- aspect_ratios: capabilities.sizes.map((size) => ({ aspect_ratio: size.aspectRatio, size: formatSize(size) })),
24766
- reference_images: capabilities.maxReferences > 0,
24767
- max_reference_images: capabilities.maxReferences,
24768
- max_images_per_job: capabilities.maxImages
24769
- },
24770
- 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)
24771
25956
  };
24772
- 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);
24773
25960
  if (detailed) {
24774
- const knownGood = knownGoodOptions(await readJobs(deps.dirs), { provider: model.provider, protocol: model.protocol, modelId: model.modelId });
24775
- output.known_good_options = knownGood;
24776
25961
  text += `
24777
- Known-good provider_options: ${Object.keys(knownGood).length === 0 ? "none recorded yet" : JSON.stringify(knownGood)}`;
24778
- if (info.parameterSchema !== undefined) {
24779
- 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)
24780
25981
  text += `
24781
- 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)}`;
24782
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(", ")}.`;
24783
25993
  }
24784
25994
  return { output, text };
24785
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
+ }
24786
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`.');
24787
26099
  function jobOutput(job) {
24788
26100
  return {
@@ -24790,7 +26102,9 @@ function jobOutput(job) {
24790
26102
  status: job.status,
24791
26103
  model: job.model,
24792
26104
  files: job.status === "completed" ? job.files : [],
24793
- ...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 } : {}
24794
26108
  };
24795
26109
  }
24796
26110
  function jobText(job) {
@@ -24799,12 +26113,153 @@ function jobText(job) {
24799
26113
  function toolError(text) {
24800
26114
  return { content: [{ type: "text", text }], isError: true };
24801
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
+ }
24802
26175
  function createServer2(deps) {
24803
26176
  const server = new McpServer({ name: "litevision", version: version2 }, { instructions });
24804
26177
  const now = deps.now ?? (() => new Date);
24805
26178
  const cwd = deps.cwd ?? process.cwd();
24806
26179
  const infoDeps = { dirs: deps.dirs, http: deps.http, now };
24807
- 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
+ };
24808
26263
  server.registerTool("generate_image", {
24809
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.",
24810
26265
  inputSchema: {
@@ -24816,94 +26271,137 @@ function createServer2(deps) {
24816
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."),
24817
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.')
24818
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
+ },
24819
26306
  outputSchema: {
24820
- job_id: string2(),
24821
- status: jobStatusSchema,
24822
- model: string2().describe("Alias of the Model that ran the Job."),
24823
- files: array(string2()).describe("Paths of the saved images; empty while the Job is running."),
24824
- aspect_ratio: _enum(aspectRatios).describe("Aspect ratio actually used."),
24825
- size: string2().describe("Size actually requested from the Provider, e.g. 1536x1024."),
24826
- requested_aspect_ratio: _enum(aspectRatios),
24827
- aspect_ratio_adjusted: boolean2().describe("True when the Model does not support the requested aspect ratio and the closest one was used."),
24828
- shortfall: string2().optional().describe("Present when fewer images than `n` were saved: how many, and why.")
24829
- }
24830
- }, 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;
24831
26314
  try {
24832
- const resolved = resolveModel(await loadConfig(deps.dirs), alias);
24833
- const { model } = resolved;
24834
- const adapter = adapterFor(model);
24835
- const credential = await requireCredential(deps.dirs, resolved.provider);
24836
- const normalised = await normaliseImageRequest({ prompt, n, aspectRatio: aspect_ratio, references, providerOptions: provider_options }, model, adapter, await modelInfo(infoDeps, resolved), cwd);
24837
- const outputDir = output_path === undefined ? undefined : resolve2(cwd, output_path);
24838
- const job = await jobs.start({ ...resolved, adapter, credential, normalised, outputDir });
24839
- const { size } = normalised.request;
24840
- const output = {
24841
- ...jobOutput(job),
24842
- aspect_ratio: size.aspectRatio,
24843
- size: formatSize(size),
24844
- requested_aspect_ratio: normalised.requestedAspectRatio,
24845
- aspect_ratio_adjusted: normalised.aspectRatioAdjusted
24846
- };
24847
- const adjustment = normalised.aspectRatioAdjusted ? ` ${model.alias} does not support ${normalised.requestedAspectRatio}, so the closest supported aspect ratio was used.` : "";
24848
- 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}`}
24849
- ${job.files.join(`
24850
- `)}`;
24851
- 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
+ }));
24852
26328
  } catch (error) {
24853
- if (error instanceof ConfigError)
24854
- return toolError(error.message);
24855
- if (error instanceof JobFailedError)
24856
- return toolError(`Job ${error.jobId} failed: ${error.message}`);
24857
- throw error;
24858
- }
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
+ };
24859
26354
  });
24860
26355
  server.registerTool("get_job", {
24861
- 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.",
24862
26357
  inputSchema: {
24863
- 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.")
24864
26359
  },
24865
26360
  outputSchema: {
24866
26361
  job_id: string2(),
24867
26362
  status: jobStatusSchema,
24868
26363
  model: string2().describe("Alias of the Model that ran the Job."),
24869
- files: array(string2()).describe("Paths of the saved images; empty while the Job is running."),
24870
- 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
24871
26368
  }
24872
26369
  }, async ({ job_id }) => {
24873
26370
  try {
24874
26371
  const job = await jobs.get(job_id);
24875
- 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}`}
24876
26374
  ${job.files.join(`
24877
26375
  `)}`;
24878
26376
  return { content: [{ type: "text", text }], structuredContent: jobOutput(job) };
24879
26377
  } catch (error) {
24880
- if (error instanceof ConfigError)
24881
- return toolError(error.message);
24882
- if (error instanceof JobFailedError)
24883
- return toolError(`Job ${error.jobId} failed: ${error.message}`);
24884
- 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
+ });
24885
26382
  }
24886
26383
  });
24887
26384
  server.registerTool("rate_job", {
24888
- 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.",
24889
26386
  inputSchema: {
24890
- 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."),
24891
26389
  verdict: _enum(verdicts).describe("The User's verdict."),
24892
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.`)
24893
26391
  },
24894
26392
  outputSchema: {
24895
26393
  job_id: string2(),
26394
+ result: number2().optional().describe("The result rated; absent for a Rating of the whole Job."),
24896
26395
  verdict: _enum(verdicts),
24897
26396
  reason: string2().describe("The reason as recorded, on one line."),
24898
26397
  rated_at: string2().describe("When the Rating was recorded, ISO 8601.")
24899
26398
  }
24900
- }, async ({ job_id, verdict, reason }) => {
26399
+ }, async ({ job_id, result, verdict, reason }) => {
24901
26400
  try {
24902
- const rating = await rateJob(deps.dirs, { jobId: job_id, verdict, reason }, now());
24903
- 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());
24904
26402
  return {
24905
- content: [{ type: "text", text: `Recorded a ${rating.verdict} Rating on Job ${rating.jobId}: ${rating.reason}` }],
24906
- structuredContent: output
26403
+ content: [{ type: "text", text: `Recorded a ${rating.verdict} Rating on ${ratingSubject(rating)}: ${rating.reason}` }],
26404
+ structuredContent: ratingOutput(rating)
24907
26405
  };
24908
26406
  } catch (error) {
24909
26407
  if (error instanceof RatingError)
@@ -24911,21 +26409,50 @@ ${job.files.join(`
24911
26409
  throw error;
24912
26410
  }
24913
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
+ });
24914
26439
  server.registerTool("list_models", {
24915
- 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.",
24916
26441
  inputSchema: {
24917
26442
  model: string2().optional().describe("Alias of one Model to describe in detail. Omit to list every Model.")
24918
26443
  },
24919
26444
  outputSchema: {
24920
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`."),
24921
26447
  models: array(listedModelSchema)
24922
26448
  }
24923
26449
  }, async ({ model: alias }) => {
24924
26450
  try {
24925
26451
  const config = await loadConfig(deps.dirs);
24926
26452
  if (config.models.length === 0)
24927
- throw needsSettings("litevision has no image Model configured yet.");
24928
- 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;
24929
26456
  let models = config.models;
24930
26457
  if (alias !== undefined) {
24931
26458
  models = models.filter((model) => model.alias === alias);
@@ -24933,12 +26460,13 @@ ${job.files.join(`
24933
26460
  throw new ConfigError(`Unknown Model Alias "${alias}". Known Aliases: ${config.models.map((model) => model.alias).join(", ")}.`);
24934
26461
  }
24935
26462
  }
24936
- 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)));
24937
26465
  const text = entries.map((entry) => entry.text).join(`
24938
26466
  `);
24939
26467
  return {
24940
26468
  content: [{ type: "text", text }],
24941
- 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) }
24942
26470
  };
24943
26471
  } catch (error) {
24944
26472
  if (error instanceof ConfigError)
@@ -25005,6 +26533,19 @@ Now: ${change.notes || "(none)"}`;
25005
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.";
25006
26534
  return { content: [{ type: "text", text }] };
25007
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
+ });
25008
26549
  return server;
25009
26550
  }
25010
26551