litevision 0.1.1 → 0.3.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 +27 -12
  2. package/dist/cli.js +2643 -742
  3. package/package.json +4 -2
package/dist/cli.js CHANGED
@@ -12866,6 +12866,14 @@ function object(shape, params) {
12866
12866
  };
12867
12867
  return new ZodObject(def);
12868
12868
  }
12869
+ function strictObject(shape, params) {
12870
+ return new ZodObject({
12871
+ type: "object",
12872
+ shape,
12873
+ catchall: never(),
12874
+ ...normalizeParams(params)
12875
+ });
12876
+ }
12869
12877
  function looseObject(shape, params) {
12870
12878
  return new ZodObject({
12871
12879
  type: "object",
@@ -14154,6 +14162,7 @@ function defaultDirs(home = homedir()) {
14154
14162
  var fetchTransport = (input, init) => fetch(input, init);
14155
14163
 
14156
14164
  // src/server.ts
14165
+ import { randomUUID as randomUUID3 } from "node:crypto";
14157
14166
  import { resolve as resolve2 } from "node:path";
14158
14167
 
14159
14168
  // node_modules/zod/v3/helpers/util.js
@@ -21697,6 +21706,19 @@ var EMPTY_COMPLETION_RESULT = {
21697
21706
  // src/config.ts
21698
21707
  import { readFile } from "node:fs/promises";
21699
21708
  import { join as join2 } from "node:path";
21709
+
21710
+ // src/protocols/protocol.ts
21711
+ var modalities = ["image", "video"];
21712
+
21713
+ class ProviderError extends Error {
21714
+ kind;
21715
+ constructor(kind, message) {
21716
+ super(message);
21717
+ this.kind = kind;
21718
+ }
21719
+ }
21720
+
21721
+ // src/config.ts
21700
21722
  var providerSchema = object({
21701
21723
  id: string2().min(1),
21702
21724
  baseUrl: string2().url()
@@ -21713,13 +21735,16 @@ var modelSchema = object({
21713
21735
  provider: string2().min(1),
21714
21736
  modelId: string2().min(1),
21715
21737
  protocol: string2().min(1),
21738
+ modality: _enum(modalities).default("image"),
21716
21739
  notes: string2().optional(),
21717
- notesHistory: notesHistorySchema.optional()
21740
+ notesHistory: notesHistorySchema.optional(),
21741
+ standingOptions: record(string2(), unknown()).optional()
21718
21742
  });
21719
21743
  var configSchema = object({
21720
21744
  providers: array(providerSchema).default([]),
21721
21745
  models: array(modelSchema).default([]),
21722
- defaultImageModel: string2().optional()
21746
+ defaultImageModel: string2().optional(),
21747
+ defaultVideoModel: string2().optional()
21723
21748
  });
21724
21749
  var credentialsSchema = record(string2(), object({ apiKey: string2().min(1) }));
21725
21750
 
@@ -21760,23 +21785,31 @@ async function requireCredential(dirs, provider) {
21760
21785
  throw needsSettings(`Provider "${provider.id}" has no Credential saved.`);
21761
21786
  return credential;
21762
21787
  }
21763
- function defaultModelAlias(config) {
21764
- const wanted = config.defaultImageModel;
21765
- return config.models.some((model) => model.alias === wanted) ? wanted : undefined;
21788
+ var defaultKey = { image: "defaultImageModel", video: "defaultVideoModel" };
21789
+ function defaultModelAlias(config, modality = "image") {
21790
+ const wanted = config[defaultKey[modality]];
21791
+ return config.models.some((model) => model.alias === wanted && model.modality === modality) ? wanted : undefined;
21766
21792
  }
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;
21793
+ function resolveModel(config, alias, modality = "image") {
21794
+ const ofModality = config.models.filter((model) => model.modality === modality);
21795
+ const wanted = alias ?? config[defaultKey[modality]];
21796
+ const model = config.models.find((candidate) => candidate.alias === wanted);
21797
+ if (ofModality.length === 0 && (alias === undefined || model === undefined))
21798
+ throw needsSettings(`litevision has no ${modality} Model configured yet.`);
21799
+ const known = ofModality.map((candidate) => candidate.alias).join(", ");
21772
21800
  if (wanted === undefined) {
21773
- throw needsSettings(`No default image Model is set. Pass \`model\` with one of: ${known}. Or:`);
21801
+ throw needsSettings(`No default ${modality} Model is set. Pass \`model\` with one of: ${known}. Or:`);
21774
21802
  }
21775
- const model = config.models.find((candidate) => candidate.alias === wanted);
21776
21803
  if (model === undefined) {
21777
21804
  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}.`);
21805
+ throw needsSettings(`The default ${modality} Model "${wanted}" does not exist. Known Aliases: ${known}.`);
21806
+ throw new ConfigError(`Unknown Model Alias "${wanted}". Known ${modality} Model Aliases: ${known}.`);
21807
+ }
21808
+ if (model.modality !== modality) {
21809
+ if (alias === undefined)
21810
+ throw needsSettings(`The default ${modality} Model "${wanted}" makes ${model.modality}s. Pass \`model\` with one of: ${known}. Or:`);
21811
+ const tool = model.modality === "video" ? "generate_video" : "generate_image";
21812
+ 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
21813
  }
21781
21814
  const provider = config.providers.find((candidate) => candidate.id === model.provider);
21782
21815
  if (provider === undefined) {
@@ -21790,6 +21823,9 @@ import { appendFile, mkdir, readFile as readFile2 } from "node:fs/promises";
21790
21823
  import { join as join3 } from "node:path";
21791
21824
  var verdicts = ["good", "bad"];
21792
21825
  var ratingReasonMaxLength = 200;
21826
+ function ratingOf({ ratings }, result) {
21827
+ return ratings.find((rating) => rating.result === result);
21828
+ }
21793
21829
 
21794
21830
  class RatingError extends Error {
21795
21831
  kind;
@@ -21799,6 +21835,15 @@ class RatingError extends Error {
21799
21835
  this.name = "RatingError";
21800
21836
  }
21801
21837
  }
21838
+
21839
+ class PickError extends Error {
21840
+ kind;
21841
+ constructor(kind, message) {
21842
+ super(message);
21843
+ this.kind = kind;
21844
+ this.name = "PickError";
21845
+ }
21846
+ }
21802
21847
  var historyPath = (dirs) => join3(dirs.data, "jobs.jsonl");
21803
21848
  async function appendHistory(dirs, record) {
21804
21849
  await mkdir(dirs.data, { recursive: true });
@@ -21812,8 +21857,11 @@ async function readJob(dirs, id) {
21812
21857
  return (await readJobs(dirs)).find((job) => job.id === id);
21813
21858
  }
21814
21859
  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));
21860
+ const { jobs, ratings, picks } = await readRecords(dirs);
21861
+ return jobs.map((job) => {
21862
+ const pick = job.comparison === undefined ? undefined : picks.get(job.comparison);
21863
+ return { job, ratings: [...ratings.get(job.id)?.values() ?? []], ...pick === undefined ? {} : { pick } };
21864
+ }).sort((a, b) => Date.parse(b.job.createdAt) - Date.parse(a.job.createdAt));
21817
21865
  }
21818
21866
  async function rateJob(dirs, rating, now) {
21819
21867
  const reason = rating.reason.replace(/\s+/g, " ").trim();
@@ -21825,15 +21873,56 @@ async function rateJob(dirs, rating, now) {
21825
21873
  const job = await readJob(dirs, rating.jobId);
21826
21874
  if (job === undefined)
21827
21875
  throw new RatingError("unknown-job", `litevision has no Job "${rating.jobId}".`);
21876
+ const { result } = rating;
21877
+ const problem = whyNotChosen(job, result, "rate");
21878
+ if (problem !== undefined)
21879
+ throw new RatingError(problem.kind, problem.message);
21880
+ const record = {
21881
+ type: "rating",
21882
+ jobId: job.id,
21883
+ ...result === undefined ? {} : { result },
21884
+ verdict: rating.verdict,
21885
+ reason,
21886
+ ratedAt: now.toISOString()
21887
+ };
21888
+ await appendHistory(dirs, record);
21889
+ return record;
21890
+ }
21891
+ async function pickResult(dirs, pick, now) {
21892
+ const ofComparison = (await readJobs(dirs)).filter((job) => job.comparison === pick.comparison);
21893
+ if (ofComparison.length === 0)
21894
+ throw new PickError("unknown-comparison", `litevision has no Comparison "${pick.comparison}".`);
21895
+ const job = ofComparison.find((job) => job.id === pick.jobId);
21896
+ if (job === undefined) {
21897
+ const ids = ofComparison.map((job) => job.id).join(", ");
21898
+ throw new PickError("not-in-comparison", `Job "${pick.jobId}" is not part of Comparison ${pick.comparison}, whose Jobs are ${ids}.`);
21899
+ }
21900
+ const { result } = pick;
21901
+ const problem = whyNotChosen(job, result, "pick");
21902
+ if (problem !== undefined)
21903
+ throw new PickError(problem.kind, problem.message);
21904
+ const record = {
21905
+ type: "pick",
21906
+ comparison: pick.comparison,
21907
+ jobId: job.id,
21908
+ ...result === undefined ? {} : { result },
21909
+ pickedAt: now.toISOString()
21910
+ };
21911
+ await appendHistory(dirs, record);
21912
+ return record;
21913
+ }
21914
+ function whyNotChosen(job, result, act) {
21828
21915
  if (job.outcome === "running") {
21829
- throw new RatingError("not-completed", `Job ${job.id} is still running; it can be rated once it has completed.`);
21916
+ return { kind: "not-completed", message: `Job ${job.id} is still running; it can be ${act === "rate" ? "rated" : "picked"} once it has completed.` };
21830
21917
  }
21831
21918
  if (job.outcome === "failed") {
21832
- throw new RatingError("not-completed", `Job ${job.id} failed and saved no images, so there is nothing to rate.`);
21919
+ return { kind: "not-completed", message: `Job ${job.id} failed and saved no images, so there is nothing to ${act}.` };
21833
21920
  }
21834
- const record = { type: "rating", jobId: job.id, verdict: rating.verdict, reason, ratedAt: now.toISOString() };
21835
- await appendHistory(dirs, record);
21836
- return record;
21921
+ if (result !== undefined && !(Number.isInteger(result) && result >= 1 && result <= job.files.length)) {
21922
+ const count = job.files.length === 1 ? "1 result" : `${job.files.length} results`;
21923
+ return { kind: "invalid", message: `Job ${job.id} has ${count}, numbered from 1 in the order of its files.` };
21924
+ }
21925
+ return;
21837
21926
  }
21838
21927
  async function readRecords(dirs) {
21839
21928
  let text;
@@ -21841,11 +21930,12 @@ async function readRecords(dirs) {
21841
21930
  text = await readFile2(historyPath(dirs), "utf8");
21842
21931
  } catch (error) {
21843
21932
  if (error.code === "ENOENT")
21844
- return { jobs: [], ratings: new Map };
21933
+ return { jobs: [], ratings: new Map, picks: new Map };
21845
21934
  throw error;
21846
21935
  }
21847
21936
  const jobs = new Map;
21848
21937
  const ratings = new Map;
21938
+ const picks = new Map;
21849
21939
  for (const line of text.split(`
21850
21940
  `)) {
21851
21941
  if (line.trim() === "")
@@ -21857,7 +21947,14 @@ async function readRecords(dirs) {
21857
21947
  continue;
21858
21948
  }
21859
21949
  if (isRatingRecord(record)) {
21860
- ratings.set(record.jobId, record);
21950
+ const ofJob = ratings.get(record.jobId) ?? new Map;
21951
+ ratings.set(record.jobId, ofJob);
21952
+ ofJob.delete(record.result ?? 0);
21953
+ ofJob.set(record.result ?? 0, record);
21954
+ continue;
21955
+ }
21956
+ if (isPickRecord(record)) {
21957
+ picks.set(record.comparison, record);
21861
21958
  continue;
21862
21959
  }
21863
21960
  if (!isJobRecord(record))
@@ -21865,7 +21962,7 @@ async function readRecords(dirs) {
21865
21962
  jobs.delete(record.id);
21866
21963
  jobs.set(record.id, record);
21867
21964
  }
21868
- return { jobs: [...jobs.values()], ratings };
21965
+ return { jobs: [...jobs.values()], ratings, picks };
21869
21966
  }
21870
21967
  function isJobRecord(value) {
21871
21968
  const record = value;
@@ -21873,18 +21970,73 @@ function isJobRecord(value) {
21873
21970
  }
21874
21971
  function isRatingRecord(value) {
21875
21972
  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";
21973
+ 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);
21974
+ }
21975
+ function isPickRecord(value) {
21976
+ const record = value;
21977
+ 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);
21978
+ }
21979
+ function ranOn(job, model) {
21980
+ return job.provider === model.provider && job.protocol === model.protocol && job.modelId === model.modelId;
21981
+ }
21982
+ function recentRatings(jobs, model, limit) {
21983
+ 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);
21984
+ }
21985
+ function recentPicks(jobs, model, limit) {
21986
+ const byId = new Map(jobs.map(({ job }) => [job.id, job]));
21987
+ const entries = jobs.flatMap(({ job, pick }) => {
21988
+ if (pick === undefined || !ranOn(job, model))
21989
+ return [];
21990
+ const picked = byId.get(pick.jobId);
21991
+ return [{ pick, job, ...picked === undefined ? {} : { picked } }];
21992
+ }).sort((a, b) => Date.parse(b.pick.pickedAt) - Date.parse(a.pick.pickedAt));
21993
+ const comparisons = new Set;
21994
+ return entries.filter(({ pick }) => {
21995
+ if (comparisons.size === limit && !comparisons.has(pick.comparison))
21996
+ return false;
21997
+ comparisons.add(pick.comparison);
21998
+ return true;
21999
+ });
22000
+ }
22001
+ function optionsRating({ job, ratings }) {
22002
+ return ratings.findLast((rating) => rating.result === undefined || rating.result === 1 && job.files.length === 1);
21877
22003
  }
21878
- function knownGoodOptions(jobs, model) {
21879
- const options = {};
21880
- for (const job of jobs) {
22004
+ function optionsRecord(jobs, model) {
22005
+ const state = new Map;
22006
+ const ratedBad = new Map;
22007
+ const ordered = [...jobs].sort((a, b) => Date.parse(a.job.createdAt) - Date.parse(b.job.createdAt));
22008
+ for (const ratedJob of ordered) {
22009
+ const { job } = ratedJob;
21881
22010
  if (job.outcome !== "completed")
21882
22011
  continue;
21883
- if (job.provider !== model.provider || job.protocol !== model.protocol || job.modelId !== model.modelId)
22012
+ const rating = optionsRating(ratedJob);
22013
+ if (!ranOn(job, model))
21884
22014
  continue;
21885
- Object.assign(options, job.parameters?.providerOptions);
22015
+ for (const [key, value] of Object.entries(job.parameters?.providerOptions ?? {})) {
22016
+ const asJson = JSON.stringify(value);
22017
+ const badValues = ratedBad.get(key) ?? new Map;
22018
+ ratedBad.set(key, badValues);
22019
+ if (rating?.verdict === "bad")
22020
+ badValues.set(asJson, { value, cause: "rating", reason: rating.reason, jobId: job.id });
22021
+ if (rating?.verdict === "good")
22022
+ badValues.delete(asJson);
22023
+ const verdict = badValues.get(asJson);
22024
+ if (verdict !== undefined)
22025
+ state.set(key, { value, suspect: verdict });
22026
+ else if (job.mismatch !== undefined && rating?.verdict !== "good") {
22027
+ state.set(key, { value, suspect: { value, cause: "mismatch", reason: job.mismatch, jobId: job.id } });
22028
+ } else
22029
+ state.set(key, { value });
22030
+ }
21886
22031
  }
21887
- return options;
22032
+ const record = { knownGood: {}, suspect: {} };
22033
+ for (const [key, { value, suspect }] of state) {
22034
+ if (suspect === undefined)
22035
+ record.knownGood[key] = value;
22036
+ else
22037
+ record.suspect[key] = suspect;
22038
+ }
22039
+ return record;
21888
22040
  }
21889
22041
 
21890
22042
  // src/jobs.ts
@@ -21929,18 +22081,223 @@ async function readReferences(paths, cwd) {
21929
22081
  }));
21930
22082
  }
21931
22083
 
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}`;
22084
+ // src/media.ts
22085
+ function fileTypeOf(bytes) {
22086
+ const image = imageTypeOf(bytes);
22087
+ if (image !== undefined)
22088
+ return image;
22089
+ if (ascii(bytes, 4, 8) === "ftyp") {
22090
+ const brand = ascii(bytes, 8, 12);
22091
+ if (brand === "avif" || brand === "avis")
22092
+ return { extension: "avif", mimeType: "image/avif" };
22093
+ if (["heic", "heix", "heim", "heis", "mif1", "msf1"].includes(brand))
22094
+ return { extension: "heic", mimeType: "image/heic" };
22095
+ return brand.startsWith("qt") ? { extension: "mov", mimeType: "video/quicktime" } : { extension: "mp4", mimeType: "video/mp4" };
22096
+ }
22097
+ if (bytes[0] === 26 && bytes[1] === 69 && bytes[2] === 223 && bytes[3] === 163)
22098
+ return { extension: "webm", mimeType: "video/webm" };
22099
+ return;
22100
+ }
22101
+ function mediaFactsOf(bytes) {
22102
+ try {
22103
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
22104
+ switch (fileTypeOf(bytes)?.extension) {
22105
+ case "png":
22106
+ return { width: view.getUint32(16), height: view.getUint32(20) };
22107
+ case "gif":
22108
+ return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
22109
+ case "jpg":
22110
+ return jpegSize(bytes, view);
22111
+ case "webp":
22112
+ return webpSize(bytes, view);
22113
+ case "mp4":
22114
+ case "mov":
22115
+ return mp4Facts(bytes, view);
22116
+ default:
22117
+ return;
22118
+ }
22119
+ } catch {
22120
+ return;
22121
+ }
22122
+ }
22123
+ function ascii(bytes, start, end) {
22124
+ return String.fromCharCode(...bytes.subarray(start, end));
22125
+ }
22126
+ function jpegSize(bytes, view) {
22127
+ let offset = 2;
22128
+ let turned = false;
22129
+ while (offset + 9 < bytes.length) {
22130
+ if (bytes[offset] !== 255)
22131
+ return;
22132
+ const marker = bytes[offset + 1];
22133
+ if (marker === 225 && ascii(bytes, offset + 4, offset + 10) === "Exif\x00\x00")
22134
+ turned = exifTurnsQuarter(view, offset + 10);
22135
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
22136
+ const width = view.getUint16(offset + 7);
22137
+ const height = view.getUint16(offset + 5);
22138
+ return turned ? { width: height, height: width } : { width, height };
22139
+ }
22140
+ offset += 2 + view.getUint16(offset + 2);
22141
+ }
22142
+ return;
22143
+ }
22144
+ function exifTurnsQuarter(view, tiff) {
22145
+ try {
22146
+ const little = view.getUint16(tiff) === 18761;
22147
+ const ifd = tiff + view.getUint32(tiff + 4, little);
22148
+ const entries = view.getUint16(ifd, little);
22149
+ for (let index = 0;index < entries; index++) {
22150
+ const entry = ifd + 2 + index * 12;
22151
+ if (view.getUint16(entry, little) !== 274)
22152
+ continue;
22153
+ const orientation = view.getUint16(entry + 8, little);
22154
+ return orientation >= 5 && orientation <= 8;
22155
+ }
22156
+ } catch {}
22157
+ return false;
22158
+ }
22159
+ function webpSize(bytes, view) {
22160
+ const chunk = ascii(bytes, 12, 16);
22161
+ if (chunk === "VP8X") {
22162
+ const read24 = (at) => bytes[at] | bytes[at + 1] << 8 | bytes[at + 2] << 16;
22163
+ return { width: read24(24) + 1, height: read24(27) + 1 };
22164
+ }
22165
+ if (chunk === "VP8 ")
22166
+ return { width: view.getUint16(26, true) & 16383, height: view.getUint16(28, true) & 16383 };
22167
+ if (chunk === "VP8L") {
22168
+ const bits = view.getUint32(21, true);
22169
+ return { width: (bits & 16383) + 1, height: (bits >> 14 & 16383) + 1 };
22170
+ }
22171
+ return;
22172
+ }
22173
+ function mp4Facts(bytes, view) {
22174
+ let seconds;
22175
+ let size;
22176
+ const walk = (start, end) => {
22177
+ let offset = start;
22178
+ while (offset + 8 <= end) {
22179
+ let length = view.getUint32(offset);
22180
+ const type = ascii(bytes, offset + 4, offset + 8);
22181
+ let header = 8;
22182
+ if (length === 1) {
22183
+ length = Number(view.getBigUint64(offset + 8));
22184
+ header = 16;
22185
+ } else if (length === 0) {
22186
+ length = end - offset;
22187
+ }
22188
+ if (length < header)
22189
+ return;
22190
+ const body = offset + header;
22191
+ if (type === "moov" || type === "trak")
22192
+ walk(body, Math.min(offset + length, end));
22193
+ if (type === "mvhd") {
22194
+ const version = bytes[body];
22195
+ const timescale = view.getUint32(body + (version === 1 ? 20 : 12));
22196
+ const duration = version === 1 ? Number(view.getBigUint64(body + 24)) : view.getUint32(body + 16);
22197
+ if (timescale > 0 && duration > 0)
22198
+ seconds = duration / timescale;
22199
+ }
22200
+ if (type === "tkhd" && size === undefined) {
22201
+ const at = body + (bytes[body] === 1 ? 88 : 76);
22202
+ const width = view.getUint32(at) / 65536;
22203
+ const height = view.getUint32(at + 4) / 65536;
22204
+ if (width >= 1 && height >= 1)
22205
+ size = { width: Math.round(width), height: Math.round(height) };
22206
+ }
22207
+ offset += length;
22208
+ }
22209
+ };
22210
+ walk(0, bytes.length);
22211
+ if (size === undefined)
22212
+ return;
22213
+ return { ...size, ...seconds === undefined ? {} : { seconds: Math.round(seconds * 100) / 100 } };
21936
22214
  }
21937
22215
 
21938
- class ProviderError extends Error {
21939
- kind;
21940
- constructor(kind, message) {
21941
- super(message);
21942
- this.kind = kind;
22216
+ // src/protocols/parameter-guide.ts
22217
+ function guideSummary(guide) {
22218
+ return `Parameter Guide for ${guide.family} (checked ${guide.checked}): ${guide.parameters.map((parameter) => parameter.name).join(", ")}.`;
22219
+ }
22220
+ function guideText(guide) {
22221
+ const lines = [
22222
+ `Parameter Guide for ${guide.family}, checked ${guide.checked} (sources: ${guide.sources.join(", ")}):`,
22223
+ ...guide.parameters.map((parameter) => `- ${parameter.name}: ${parameter.values} Default: ${parameter.default}. Price: ${parameter.price}`),
22224
+ ...(guide.notes ?? []).map((note) => `Note: ${note}`)
22225
+ ];
22226
+ return lines.join(`
22227
+ `);
22228
+ }
22229
+ function guideOutput(guide) {
22230
+ return {
22231
+ family: guide.family,
22232
+ checked: guide.checked,
22233
+ sources: guide.sources,
22234
+ parameters: guide.parameters.map((parameter) => ({ ...parameter })),
22235
+ notes: guide.notes ?? []
22236
+ };
22237
+ }
22238
+ function guideFor(entries, modelId) {
22239
+ const bare = modelId.replace(/^.*\//, "");
22240
+ return entries.find((entry) => entry.matches.test(modelId) || entry.matches.test(bare))?.guide;
22241
+ }
22242
+ function fieldsToRead(adapter, modelId, modality) {
22243
+ const guide = adapter.parameterGuide?.(modelId, modality);
22244
+ const fallback = (modality === "video" ? adapter.requestFields.video : adapter.requestFields.image) ?? { shape: [] };
22245
+ if (guide === undefined)
22246
+ return fallback;
22247
+ return {
22248
+ shape: guide.shapeFields,
22249
+ ...guide.durationField === undefined ? {} : { duration: guide.durationField },
22250
+ ...fallback.resolution === undefined ? {} : { resolution: fallback.resolution }
22251
+ };
22252
+ }
22253
+ function valueAt(body, path) {
22254
+ return path.split(".").reduce((value, key) => typeof value === "object" && value !== null ? value[key] : undefined, body);
22255
+ }
22256
+ var positive = (value) => {
22257
+ const number = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
22258
+ return typeof number === "number" && Number.isFinite(number) && number > 0 ? number : undefined;
22259
+ };
22260
+ function parseShape(text) {
22261
+ const match = /^\s*(\d+(?:\.\d+)?)\s*([x×:])\s*(\d+(?:\.\d+)?)\s*$/i.exec(text);
22262
+ if (match === null)
22263
+ return;
22264
+ const width = Number(match[1]);
22265
+ const height = Number(match[3]);
22266
+ if (!(width > 0 && height > 0))
22267
+ return;
22268
+ return { label: `${match[1]}${match[2] === ":" ? ":" : "x"}${match[3]}`, ratio: width / height };
22269
+ }
22270
+ function readShape(fields, body) {
22271
+ for (const field of fields) {
22272
+ if (typeof field === "string") {
22273
+ const value = valueAt(body, field);
22274
+ if (value === undefined)
22275
+ continue;
22276
+ return typeof value === "string" ? parseShape(value)?.label ?? null : null;
22277
+ }
22278
+ const width = valueAt(body, field.width);
22279
+ const height = valueAt(body, field.height);
22280
+ if (width === undefined && height === undefined)
22281
+ continue;
22282
+ const w = positive(width);
22283
+ const h = positive(height);
22284
+ return w === undefined || h === undefined ? null : `${w}x${h}`;
21943
22285
  }
22286
+ return null;
22287
+ }
22288
+ function readResolution(field, body) {
22289
+ const value = field === undefined ? undefined : valueAt(body, field);
22290
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
22291
+ }
22292
+ function readSeconds(field, body) {
22293
+ return field === undefined ? null : positive(valueAt(body, field)) ?? null;
22294
+ }
22295
+ function readRequested(fields, body, modality) {
22296
+ return {
22297
+ shape: readShape(fields.shape, body),
22298
+ resolution: readResolution(fields.resolution, body),
22299
+ ...modality === "video" ? { seconds: readSeconds(fields.duration, body) } : {}
22300
+ };
21944
22301
  }
21945
22302
 
21946
22303
  // src/protocols/errors.ts
@@ -21953,6 +22310,7 @@ var explanations = {
21953
22310
  refusal: "refused the request on content grounds. Rephrase the prompt, change the reference images, or choose another Model.",
21954
22311
  rate_limit: "is rate limiting requests. Wait a little and try again, or choose another Model.",
21955
22312
  unsupported: "does not support this request. Choose another Model or change the request.",
22313
+ 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
22314
  other: "failed to complete the Job."
21957
22315
  };
21958
22316
  function providerError(kind, providerId, detail) {
@@ -21977,25 +22335,328 @@ function kindOfStatus(status) {
21977
22335
  return "rate_limit";
21978
22336
  return;
21979
22337
  }
22338
+ function namedParameters(response) {
22339
+ if (response.status !== 400 && response.status !== 422)
22340
+ return [];
22341
+ const message = response.message ?? "";
22342
+ const mentioned = (response.options ?? []).filter((name) => new RegExp(`(^|[^\\w])${escapeRegExp(name)}($|[^\\w])`).test(message));
22343
+ return [...response.param ? [response.param] : [], ...mentioned];
22344
+ }
22345
+ function escapeRegExp(text) {
22346
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22347
+ }
21980
22348
  function classifyProviderError(response) {
21981
22349
  const codes = (response.codes ?? []).filter((code) => typeof code === "string");
21982
- return matchKind(codes) ?? kindOfStatus(response.status) ?? matchKind([response.message ?? ""]) ?? "other";
22350
+ const named = namedParameters(response);
22351
+ const wording = named.reduce((text, name) => text.split(name).join(""), response.message ?? "");
22352
+ return matchKind(codes) ?? kindOfStatus(response.status) ?? matchKind([wording]) ?? (named.length > 0 ? "rejected_option" : "other");
21983
22353
  }
21984
22354
  function httpProviderError(providerId, response) {
21985
22355
  const detail = response.message === undefined ? `HTTP ${response.status}` : `HTTP ${response.status}: ${response.message}`;
21986
22356
  return providerError(classifyProviderError(response), providerId, detail);
21987
22357
  }
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}`);
22358
+
22359
+ // src/protocols/guides/apimart.ts
22360
+ var checked = "2026-11-05";
22361
+ var page = (path) => `https://docs.apimart.ai/en/api-reference/images/${path}`;
22362
+ var pricing = "https://apimart.ai/pricing";
22363
+ var pricingNote = "APIMart publishes prices only on its pricing page, which depends on the account tier; figures here are approximate.";
22364
+ var references = (count) => ({
22365
+ name: "image_urls",
22366
+ values: `${count}; litevision uploads \`references\` through APIMart's upload endpoint and fills this with the links.`,
22367
+ default: "none",
22368
+ price: "not documented."
22369
+ });
22370
+ var count = (range) => ({
22371
+ name: "n",
22372
+ values: `${range} images per task; litevision sends \`n\` here, or one task per image where the model makes one at a time.`,
22373
+ default: "1",
22374
+ price: "each image is billed."
22375
+ });
22376
+ var fifteenRatios = "1:1, 3:2, 2:3, 4:3, 3:4, 5:4, 4:5, 16:9, 9:16, 2:1, 1:2, 3:1, 1:3, 21:9, 9:21";
22377
+ var exactPixels = "or exact WIDTHxHEIGHT under OpenAI's rule (multiples of 16, at most 3840 a side and 3:1, 655,360–8,294,400 pixels)";
22378
+ var gptTiers = "1k, 2k, 4k (16:9 is 1536x864, 2048x1152 or 3840x2160).";
22379
+ var gptImage25 = {
22380
+ family: "GPT Image 2.5 / 2.6 on APIMart",
22381
+ parameters: [
22382
+ { name: "size", values: `auto, ${fifteenRatios}, ${exactPixels}.`, default: "auto", price: "set by resolution." },
22383
+ { name: "resolution", values: `${gptTiers} Ignored for exact pixels.`, default: "1k", price: "billed by output tokens, which grow with the tier and the quality." },
22384
+ { name: "quality", values: "auto, low, medium, high, xhigh, max.", default: "auto", price: "at 1024x1024 about $0.006 low to $0.21 max ($30 per 1M output tokens)." },
22385
+ { name: "background", values: "auto, opaque, transparent.", default: "auto", price: "no effect." },
22386
+ { name: "moderation", values: "auto, low.", default: "low", price: "no effect." },
22387
+ references("up to 16 public links"),
22388
+ count("1–4")
22389
+ ],
22390
+ sources: [page("gpt-image-2.5/generation"), page("gpt-image-2.6/generation"), pricing],
22391
+ checked,
22392
+ notes: ["GPT Image 2.6 adds complexity, markup_url, GIF and SVG output.", pricingNote],
22393
+ shapeFields: ["size"]
22394
+ };
22395
+ var gptImage2 = {
22396
+ family: "GPT Image 2 on APIMart",
22397
+ parameters: [
22398
+ { name: "size", values: `auto (= 1:1), ${fifteenRatios}, ${exactPixels}.`, default: "1:1", price: "set by resolution." },
22399
+ { name: "resolution", values: gptTiers, default: "1k", price: "about $0.0085 at 1K, $0.015 at 2K, $0.022 at 4K per image." },
22400
+ references("up to 15 links or data URIs (gpt-image-2-official: up to 16 public links)"),
22401
+ count("1 (gpt-image-2-official: 1–4)")
22402
+ ],
22403
+ sources: [page("gpt-image-2/generation"), page("gpt-image-2/official"), pricing],
22404
+ checked,
22405
+ notes: [
22406
+ "Without size, the output takes the input image's resolution.",
22407
+ "The gpt-image-2 page gives two pixel sizes for many 1k ratios (1:1 is 1024x1024 or 1254x1254), so the exact 1k output is uncertain.",
22408
+ pricingNote
22409
+ ],
22410
+ shapeFields: ["size"]
22411
+ };
22412
+ var gptImageExt = {
22413
+ family: "GPT Image 2.5 / 2.6 Ext on APIMart",
22414
+ parameters: [
22415
+ { name: "size", values: "auto, 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 5:4, 4:5, 21:9; no exact pixels.", default: "auto", price: "no effect." },
22416
+ { name: "resolution", values: "1K, 2K, 4K (gpt-image-2.6-ext: only 1k today).", default: "1K", price: "about $0.0085 at 1K, $0.03 at 2K, $0.04 at 4K per image." },
22417
+ { name: "version", values: "flare, sunburst.", default: "the Provider picks", price: "billed by version × resolution × images delivered." },
22418
+ references("up to 16 links or data URIs"),
22419
+ count("1–4 (gpt-image-2.6-ext: 1)")
22420
+ ],
22421
+ sources: [page("gpt-image-2.5-ext/generation"), page("gpt-image-2.6-ext/generation"), pricing],
22422
+ checked,
22423
+ notes: [pricingNote],
22424
+ shapeFields: ["size"]
22425
+ };
22426
+ var gptImage1 = {
22427
+ family: "GPT Image 1 / 1.5 on APIMart",
22428
+ parameters: [
22429
+ { name: "size", values: "1:1, 3:2, 2:3 (1024x1024, 1536x1024, 1024x1536).", default: "1:1", price: "not documented." },
22430
+ { name: "quality", values: "auto, low, medium, high.", default: "auto", price: "not documented." },
22431
+ { name: "background", values: "auto, opaque, transparent.", default: "auto", price: "no effect." },
22432
+ references("up to 15 public links"),
22433
+ count("1–4")
22434
+ ],
22435
+ sources: [page("gpt-image-1/generation")],
22436
+ checked,
22437
+ notes: ["OpenAI shut gpt-image-1 down on 2026-10-23."],
22438
+ shapeFields: ["size"]
22439
+ };
22440
+ var geminiRatios10 = "auto, 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, 21:9";
22441
+ var gemini = (family, path, ratios, tiers, refs, n, notes) => ({
22442
+ family,
22443
+ parameters: [
22444
+ { name: "size", values: `${ratios}.`, default: "auto: the model decides, and image-to-image follows the reference", price: "no effect." },
22445
+ { name: "resolution", values: tiers, default: "1K", price: "higher tiers cost more." },
22446
+ references(refs),
22447
+ count(n)
22448
+ ],
22449
+ sources: [page(path), pricing],
22450
+ checked,
22451
+ notes: [...notes, pricingNote],
22452
+ shapeFields: ["size"]
22453
+ });
22454
+ var seedream = (family, path, ratios, tiers, defaultTier, refs, n, notes, defaultSize = "1:1") => ({
22455
+ family,
22456
+ parameters: [
22457
+ { name: "size", values: `${ratios}.`, default: defaultSize, price: "no effect." },
22458
+ { name: "resolution", values: tiers, default: defaultTier, price: "higher tiers cost more." },
22459
+ references(refs),
22460
+ count(n)
22461
+ ],
22462
+ sources: [page(path), pricing],
22463
+ checked,
22464
+ notes: [...notes, pricingNote],
22465
+ shapeFields: ["size"]
22466
+ });
22467
+ var seedreamRatios = "1:1, 4:3, 3:4, 16:9, 9:16, 3:2, 2:3, 2:1, 1:2, 21:9, 9:21, auto (needs reference images)";
22468
+ var flux2 = {
22469
+ family: "FLUX.2 on APIMart",
22470
+ parameters: [
22471
+ { name: "size", values: "1:1, 4:3, 3:4, 16:9, 9:16, 3:2, 2:3, 21:9, 9:21, auto, or WIDTHxHEIGHT.", default: "1:1", price: "set by resolution." },
22472
+ { name: "resolution", values: "1MP, 2MP, 3MP, 4MP (1K, 2K, 4K are taken as 2MP, 3MP, 4MP; 16:9 is 1344x752 at 1MP, 2720x1536 at 4MP).", default: "2MP", price: "flux-2-pro about $0.024, $0.036, $0.048, $0.06." },
22473
+ { name: "width", values: "with height, in place of size: at least 64 each, at most 4,194,304 pixels.", default: "none", price: "by megapixels." },
22474
+ { name: "height", values: "with width, as above.", default: "none", price: "by megapixels." },
22475
+ { name: "safety_tolerance", values: "0–5.", default: "the Provider picks", price: "no effect." },
22476
+ { name: "seed", values: "any integer.", default: "random", price: "no effect." },
22477
+ references("up to 8"),
22478
+ count("1")
22479
+ ],
22480
+ sources: [page("flux-2/generation"), pricing],
22481
+ checked,
22482
+ notes: ["steps (1–50) and guidance (1.5–10) apply to flux-2-flex only.", pricingNote],
22483
+ shapeFields: [{ width: "width", height: "height" }, "size"]
22484
+ };
22485
+ var fluxKontext = {
22486
+ family: "FLUX.1 Kontext on APIMart",
22487
+ parameters: [
22488
+ { name: "size", values: "1:1, 4:3, 3:4, 16:9, 9:16, 3:2, 2:3, 21:9, 9:21, auto; about a megapixel (16:9 is 1392x752). Pixels are mapped to the nearest ratio; width and height fail.", default: "1:1", price: "not documented." },
22489
+ references("up to 4"),
22490
+ count("1")
22491
+ ],
22492
+ sources: [page("flux-kontext/generation")],
22493
+ checked,
22494
+ shapeFields: ["size"]
22495
+ };
22496
+ var flux3 = {
22497
+ family: "FLUX 3 Image on APIMart",
22498
+ parameters: [
22499
+ { name: "aspect_ratio", values: "21:9, 2:1, 16:9, 3:2, 7:5, 4:3, 5:4, 1:1, 4:5, 3:4, 5:7, 2:3, 9:16, 1:2, 9:21, auto; also taken as size. Pixels are rejected.", default: "auto", price: "no effect." },
22500
+ { name: "resolution", values: "768sq, 1k, 1.5k, 2k, 4k (about 0.6, 1, 2, 4 and 16 MP).", default: "1k", price: "about $0.033, $0.038, $0.056, $0.08, $0.49." },
22501
+ { name: "safety_tolerance", values: "0–4.", default: "the Provider picks", price: "no effect." },
22502
+ references("up to 10"),
22503
+ count("1")
22504
+ ],
22505
+ sources: [page("flux-3-image/generation"), pricing],
22506
+ checked,
22507
+ notes: ["seed, steps, guidance and negative_prompt are rejected.", pricingNote],
22508
+ shapeFields: ["aspect_ratio", "size"]
22509
+ };
22510
+ var entries = [
22511
+ { matches: /^gpt-image-2\.[56]-ext$/, guide: gptImageExt },
22512
+ { matches: /^gpt-image-2\.[56]-(flare|sunburst)$/, guide: gptImage25 },
22513
+ { matches: /^gpt-image-2(-ext|-official)?$/, guide: gptImage2 },
22514
+ { matches: /^gpt-image-1(\.5)?(-official)?$/, guide: gptImage1 },
22515
+ {
22516
+ matches: /^gemini-nano-banana-2\.1(-ext)?$/,
22517
+ guide: gemini("Nano Banana 2.1 on APIMart", "gemini-nano-banana-2.1/generation", `${geminiRatios10}, 1:4, 4:1, 1:8, 8:1`, "1K, 2K, 4K; 0.5K and 512 are rejected, an unknown tier is taken as 1K.", "no fixed count, links or data URIs, up to 20 MB each", "1–4 (Ext: 1)", [])
22518
+ },
22519
+ {
22520
+ matches: /^(gemini-3\.1-flash-image-preview(-official)?|nano-banana-2(-ext)?)$/,
22521
+ guide: gemini("Nano Banana 2 (Gemini 3.1 Flash Image) on APIMart", "gemini-3.1-flash/generation", `${geminiRatios10}, 1:4, 4:1, 1:8, 8:1`, "0.5K, 1K, 2K, 4K.", "up to 14", "1", ["APIMart calls the small tier 0.5K where Google's own API says 512."])
22522
+ },
22523
+ {
22524
+ matches: /^(gemini-3\.1-flash-lite-image(-ext)?|nano-banana-2-lite)$/,
22525
+ guide: gemini("Nano Banana 2 Lite on APIMart", "gemini-3.1-flash/generation-lite", geminiRatios10, "1K only; 2K, 4K and 0.5K are silently taken as 1K.", "up to 14", "1–4", [])
22526
+ },
22527
+ {
22528
+ matches: /^(gemini-3-pro-image-preview(-official)?|nano-banana-pro(-ext)?)$/,
22529
+ guide: gemini("Nano Banana Pro on APIMart", "gemini-3-pro/generation", geminiRatios10, "1K, 2K, 4K.", "up to 14", "1", [
22530
+ "With size auto and no reference, the docs say the result is 1:1 or 16:9."
22531
+ ])
22532
+ },
22533
+ {
22534
+ matches: /^(gemini-2\.5-flash-image-preview(-official)?|nano-banana(-ext)?)$/,
22535
+ guide: gemini("Nano Banana on APIMart", "gemini-2.5-flash/generation", geminiRatios10, "1K only.", "up to 14", "1", [
22536
+ "APIMart allows 14 reference images; Google says the model works best with up to 3.",
22537
+ "Prompts of at most 1000 characters."
22538
+ ])
22539
+ },
22540
+ {
22541
+ matches: /^seedream-4[.-]0$/i,
22542
+ guide: seedream("Seedream 4.0 on APIMart", "seedream-4/generation", seedreamRatios, "1K, 2K, 4K.", "2K", "references and n together at most 15", "1–15", [])
22543
+ },
22544
+ {
22545
+ matches: /^seedream-4[.-]5$/i,
22546
+ guide: seedream("Seedream 4.5 on APIMart", "seedream-4.5/generation", seedreamRatios, "2K, 4K; no 1K (16:9 is 2560x1440 at 2K, 5404x3040 at 4K).", "2K", "references and n together at most 15", "1–15", [
22547
+ "More than one image switches to sequential mode; text-to-image alone cannot make several."
22548
+ ])
22549
+ },
22550
+ {
22551
+ matches: /^seedream-5[.-]0-lite$/i,
22552
+ guide: seedream("Seedream 5.0 Lite on APIMart", "seedream-5-lite/generation", seedreamRatios.replace(", 9:21", ""), "2K, 3K, 4K; no 1K.", "2K", "references and n together at most 15", "1–15", [
22553
+ 'The page header says "2K / 3K" while the field lists 2K, 3K and 4K.'
22554
+ ])
22555
+ },
22556
+ {
22557
+ matches: /^seedream-5[.-]0-(pro|flash)$/i,
22558
+ guide: {
22559
+ ...seedream("Seedream 5.0 Pro / Flash on APIMart", "seedream-5-0-pro/generation", "auto, 1:1, 4:3, 3:4, 16:9, 9:16, 3:2, 2:3, 2:1, 1:2, 21:9, or exact pixels (921,600–4,624,220 pixels, ratio 1/16–16)", "1K, 1.5K, 2K; 3K and 4K are rejected.", "1K", "up to 10, the first free and each further one a fixed surcharge", "1", ['1.5K costs the same as 1K. The Flash page says "also accepted: seedream-5.0-pro", probably a copy error.'], "auto"),
22560
+ sources: [page("seedream-5-0-pro/generation"), page("seedream-5-0-flash/generation"), pricing]
22561
+ }
22562
+ },
22563
+ { matches: /^flux-2-(flex|pro|max)$/, guide: flux2 },
22564
+ { matches: /^flux-kontext-(pro|max)$/, guide: fluxKontext },
22565
+ { matches: /^flux-3-image$/, guide: flux3 }
22566
+ ];
22567
+ var apimartGuide = (modelId) => guideFor(entries, modelId);
22568
+
22569
+ // src/protocols/multipart.ts
22570
+ import { randomUUID } from "node:crypto";
22571
+ function multipartBody(fields, files) {
22572
+ const boundary = `litevision-${randomUUID()}`;
22573
+ const encoder = new TextEncoder;
22574
+ const quote = (value) => value.replace(/["\r\n\\]/g, (char) => encodeURIComponent(char));
22575
+ const parts = [];
22576
+ for (const [name, value] of fields) {
22577
+ parts.push(encoder.encode(`--${boundary}\r
22578
+ Content-Disposition: form-data; name="${quote(name)}"\r
22579
+ \r
22580
+ ${value}\r
22581
+ `));
22582
+ }
22583
+ for (const file of files) {
22584
+ parts.push(encoder.encode(`--${boundary}\r
22585
+ Content-Disposition: form-data; name="${quote(file.name)}"; filename="${quote(file.fileName)}"\r
22586
+ Content-Type: ${file.mimeType}\r
22587
+ \r
22588
+ `), file.bytes, encoder.encode(`\r
22589
+ `));
21993
22590
  }
22591
+ parts.push(encoder.encode(`--${boundary}--\r
22592
+ `));
22593
+ return { body: Buffer.concat(parts), contentType: `multipart/form-data; boundary=${boundary}` };
22594
+ }
22595
+
22596
+ // src/protocols/send.ts
22597
+ var maxRetries = 2;
22598
+ async function sendToProvider(http, providerId, url, init, { context, poll = false }) {
22599
+ for (let attempt = 1;; attempt++) {
22600
+ const last = attempt > maxRetries;
22601
+ try {
22602
+ const response = await http(url, init);
22603
+ if (last || !retryableStatus(response.status, poll))
22604
+ return response;
22605
+ await response.body?.cancel().catch(() => {
22606
+ return;
22607
+ });
22608
+ } catch (error) {
22609
+ const stage = stageOf(error);
22610
+ if (last || !poll && stage !== "before-send")
22611
+ throw networkError(providerId, error, stage, poll, attempt);
22612
+ }
22613
+ await context.backoff(attempt);
22614
+ }
22615
+ }
22616
+ function retryableStatus(status, poll) {
22617
+ if (poll)
22618
+ return status >= 500 || status === 429;
22619
+ return status === 502 || status === 503 || status === 504;
22620
+ }
22621
+ var beforeSendCodes = new Set([
22622
+ "ECONNREFUSED",
22623
+ "ENOTFOUND",
22624
+ "EAI_AGAIN",
22625
+ "ENETUNREACH",
22626
+ "ENETDOWN",
22627
+ "EHOSTUNREACH",
22628
+ "EHOSTDOWN",
22629
+ "UND_ERR_CONNECT_TIMEOUT"
22630
+ ]);
22631
+ var timeoutCodes = new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "ETIMEDOUT", "TimeoutError", "Timeout"]);
22632
+ function stageOf(error) {
22633
+ const layers = [error, error?.cause].filter((layer) => typeof layer === "object" && layer !== null);
22634
+ const names = layers.flatMap((layer) => [layer.code, layer.name]).filter((name) => typeof name === "string");
22635
+ if (layers.some((layer) => layer.syscall === "connect" || layer.syscall === "getaddrinfo"))
22636
+ return "before-send";
22637
+ if (names.some((name) => beforeSendCodes.has(name)))
22638
+ return "before-send";
22639
+ if (names.some((name) => timeoutCodes.has(name)))
22640
+ return "timeout";
22641
+ return "unknown";
22642
+ }
22643
+ function describe2(error) {
22644
+ const message = error?.message ?? String(error);
22645
+ const cause = error?.cause;
22646
+ const reason = cause instanceof Error ? cause.message : undefined;
22647
+ return reason === undefined || reason === message ? message : `${message}: ${reason}`;
22648
+ }
22649
+ function networkError(providerId, error, stage, poll, attempts) {
22650
+ const label = providerLabel(providerId);
22651
+ const tries = attempts > 1 ? ` (tried ${attempts} times)` : "";
22652
+ if (poll || stage === "before-send")
22653
+ return new ProviderError("other", `Could not reach ${label}${tries}: ${describe2(error)}`);
22654
+ const what = stage === "timeout" ? `${label} did not answer in time` : `The connection to ${label} failed`;
22655
+ 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
22656
  }
21995
22657
 
21996
22658
  // src/protocols/apimart.ts
21997
22659
  var docsBase = "https://docs.apimart.ai/en/api-reference/images";
21998
- var maxImagesPerJob = 4;
21999
22660
  var errorSchema = object({
22000
22661
  error: object({
22001
22662
  code: union([number2(), string2()]).nullish(),
@@ -22017,86 +22678,23 @@ var taskSchema = object({
22017
22678
  })
22018
22679
  });
22019
22680
  var resumeSchema = object({ taskIds: array(string2()).min(1) });
22020
- var sizes = (entries) => entries.map(([aspectRatio, width, height]) => ({ aspectRatio, width, height }));
22021
22681
  var knownModels = [
22022
- {
22023
- matches: /^gpt-image-2(-ext|-official)?$/,
22024
- docsPage: "gpt-image-2/generation",
22025
- capabilities: {
22026
- sizes: sizes([
22027
- ["1:1", 1024, 1024],
22028
- ["3:2", 1536, 1024],
22029
- ["2:3", 1024, 1536],
22030
- ["4:3", 1024, 768],
22031
- ["3:4", 768, 1024],
22032
- ["16:9", 1536, 864],
22033
- ["9:16", 864, 1536]
22034
- ]),
22035
- maxReferences: 15,
22036
- maxImages: maxImagesPerJob
22037
- },
22038
- batches: false
22039
- },
22040
- {
22041
- matches: /^gpt-image-1(\.5)?(-official)?$/,
22042
- docsPage: "gpt-image-1/generation",
22043
- capabilities: {
22044
- sizes: sizes([
22045
- ["1:1", 1024, 1024],
22046
- ["3:2", 1536, 1024],
22047
- ["2:3", 1024, 1536]
22048
- ]),
22049
- maxReferences: 0,
22050
- maxImages: maxImagesPerJob
22051
- },
22052
- batches: true
22053
- },
22054
- {
22055
- matches: /^(gemini-3-pro-image-preview(-official)?|nano-banana-pro(-ext)?)$/,
22056
- docsPage: "gemini-3-pro/generation",
22057
- capabilities: {
22058
- sizes: sizes([
22059
- ["1:1", 1024, 1024],
22060
- ["3:2", 1264, 848],
22061
- ["2:3", 848, 1264],
22062
- ["4:3", 1200, 896],
22063
- ["3:4", 896, 1200],
22064
- ["16:9", 1376, 768],
22065
- ["9:16", 768, 1376]
22066
- ]),
22067
- maxReferences: 14,
22068
- maxImages: maxImagesPerJob
22069
- },
22070
- batches: false
22071
- },
22072
- {
22073
- matches: /^seedream-4[.-]5$/i,
22074
- docsPage: "seedream-4.5/generation",
22075
- capabilities: {
22076
- sizes: sizes([
22077
- ["1:1", 2048, 2048],
22078
- ["3:2", 2496, 1664],
22079
- ["2:3", 1664, 2496],
22080
- ["4:3", 2304, 1728],
22081
- ["3:4", 1728, 2304],
22082
- ["16:9", 2560, 1440],
22083
- ["9:16", 1440, 2560]
22084
- ]),
22085
- maxReferences: 14,
22086
- maxImages: maxImagesPerJob
22087
- },
22088
- batches: false
22089
- }
22682
+ { matches: /^gpt-image-2-official$/, docsPage: "gpt-image-2/official", batches: true },
22683
+ { matches: /^gpt-image-2(-ext)?$/, docsPage: "gpt-image-2/generation", batches: false },
22684
+ { matches: /^gpt-image-2\.5-(flare|sunburst)$/, docsPage: "gpt-image-2.5/generation", batches: true },
22685
+ { matches: /^gpt-image-2\.6-(flare|sunburst)$/, docsPage: "gpt-image-2.6/generation", batches: true },
22686
+ { matches: /^gpt-image-2\.5-ext$/, docsPage: "gpt-image-2.5-ext/generation", batches: true },
22687
+ { matches: /^gpt-image-2\.6-ext$/, docsPage: "gpt-image-2.6-ext/generation", batches: false },
22688
+ { matches: /^gpt-image-1(\.5)?(-official)?$/, docsPage: "gpt-image-1/generation", batches: true },
22689
+ { matches: /^gemini-nano-banana-2\.1$/, docsPage: "gemini-nano-banana-2.1/generation", batches: true },
22690
+ { matches: /^(gemini-3\.1-flash-lite-image(-ext)?|nano-banana-2-lite)$/, docsPage: "gemini-3.1-flash/generation-lite", batches: true },
22691
+ { matches: /^(gemini-3-pro-image-preview(-official)?|nano-banana-pro(-ext)?)$/, docsPage: "gemini-3-pro/generation", batches: false },
22692
+ { matches: /^seedream-4[.-]5$/i, docsPage: "seedream-4.5/generation", batches: false }
22090
22693
  ];
22091
- var unknownModel = {
22092
- sizes: sizes([["1:1", 1024, 1024]]),
22093
- maxReferences: 0,
22094
- maxImages: maxImagesPerJob
22095
- };
22096
22694
  var known = (modelId) => knownModels.find((model) => model.matches.test(modelId));
22097
22695
  var headers = (credential) => ({ authorization: `Bearer ${credential}` });
22098
22696
  var baseUrlOf = (target) => target.baseUrl.replace(/\/+$/, "");
22099
- async function errorOf(response, providerId) {
22697
+ async function errorOf(response, providerId, options = []) {
22100
22698
  const parsed = errorSchema.safeParse(await response.json().catch(() => {
22101
22699
  return;
22102
22700
  }));
@@ -22104,17 +22702,39 @@ async function errorOf(response, providerId) {
22104
22702
  return httpProviderError(providerId, {
22105
22703
  status: response.status,
22106
22704
  codes: [error?.type],
22107
- message: error?.message ?? (response.statusText || undefined)
22705
+ message: error?.message ?? (response.statusText || undefined),
22706
+ options
22108
22707
  });
22109
22708
  }
22110
- async function submitTask(body, target, http) {
22111
- const response = await sendToProvider(http, target.providerId, `${baseUrlOf(target)}/images/generations`, {
22709
+ var uploadedSchema = object({ url: string2().min(1) });
22710
+ async function uploadReference(reference, target, http, context) {
22711
+ const { body, contentType } = multipartBody([], [{ name: "file", ...reference }]);
22712
+ const init = { method: "POST", headers: { ...headers(target.credential), "content-type": contentType }, body };
22713
+ const failed = (error) => new ProviderError(error instanceof ProviderError ? error.kind : "other", `Could not upload the reference image ${reference.fileName} to APIMart. ${error.message}`);
22714
+ let response;
22715
+ try {
22716
+ response = await sendToProvider(http, target.providerId, `${baseUrlOf(target)}/uploads/images`, init, { context });
22717
+ } catch (error) {
22718
+ throw error instanceof ProviderError ? failed(error) : error;
22719
+ }
22720
+ if (!response.ok)
22721
+ throw failed(await errorOf(response, target.providerId));
22722
+ const parsed = uploadedSchema.safeParse(await response.json().catch(() => {
22723
+ return;
22724
+ }));
22725
+ if (!parsed.success)
22726
+ throw failed(providerError("other", target.providerId, "its answer to the upload had no link."));
22727
+ return parsed.data.url;
22728
+ }
22729
+ async function submitTask(body, options, target, http, context) {
22730
+ const init = {
22112
22731
  method: "POST",
22113
22732
  headers: { ...headers(target.credential), "content-type": "application/json" },
22114
22733
  body: JSON.stringify(body)
22115
- });
22734
+ };
22735
+ const response = await sendToProvider(http, target.providerId, `${baseUrlOf(target)}/images/generations`, init, { context });
22116
22736
  if (!response.ok)
22117
- throw await errorOf(response, target.providerId);
22737
+ throw await errorOf(response, target.providerId, options);
22118
22738
  const parsed = submittedSchema.safeParse(await response.json().catch(() => {
22119
22739
  return;
22120
22740
  }));
@@ -22122,50 +22742,35 @@ async function submitTask(body, target, http) {
22122
22742
  throw providerError("other", target.providerId, "its answer to the submission had no task id.");
22123
22743
  return parsed.data.data[0].task_id;
22124
22744
  }
22125
- var toleratedPollFailures = 5;
22126
22745
  async function pollTask(taskId, target, http, context) {
22127
22746
  const url = `${baseUrlOf(target)}/tasks/${encodeURIComponent(taskId)}?language=en`;
22128
- let failures = 0;
22129
22747
  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.`);
22748
+ const response = await sendToProvider(http, target.providerId, url, { headers: headers(target.credential) }, { context, poll: true });
22749
+ if (!response.ok)
22750
+ throw await errorOf(response, target.providerId);
22751
+ const parsed = taskSchema.safeParse(await response.json().catch(() => {
22752
+ return;
22753
+ }));
22754
+ if (!parsed.success)
22755
+ throw providerError("other", target.providerId, `its status for task ${taskId} could not be read.`);
22756
+ const task = parsed.data.data;
22757
+ if (task.status === "completed") {
22758
+ const images = (task.result?.images ?? []).flatMap((image) => Array.isArray(image.url) ? image.url : [image.url]).map((url) => ({ url }));
22759
+ if (images.length === 0)
22760
+ throw providerError("other", target.providerId, `task ${taskId} completed without images.`);
22761
+ return { images, ...task.cost === undefined ? {} : { cost: task.cost } };
22762
+ }
22763
+ if (task.status === "failed") {
22764
+ const error = task.error ?? undefined;
22765
+ const kind = classifyProviderError({
22766
+ status: typeof error?.code === "number" ? error.code : 0,
22767
+ codes: [error?.type],
22768
+ message: error?.message
22769
+ });
22770
+ throw providerError(kind, target.providerId, error?.message ?? `task ${taskId} failed.`);
22168
22771
  }
22772
+ if (task.status === "cancelled")
22773
+ throw providerError("other", target.providerId, `task ${taskId} was cancelled.`);
22169
22774
  await context.pause();
22170
22775
  }
22171
22776
  }
@@ -22192,32 +22797,6 @@ function waitForTasks(taskIds, target, http, context, submissionFailures = []) {
22192
22797
  }
22193
22798
  };
22194
22799
  }
22195
- function capabilitiesFromSchema(schema, modelId) {
22196
- const properties = object({
22197
- properties: object({
22198
- size: object({ enum: array(unknown()) }).optional(),
22199
- image_urls: object({ maxItems: number2().int().nonnegative().optional() }).optional()
22200
- })
22201
- }).safeParse(schema);
22202
- if (!properties.success)
22203
- return;
22204
- const { size, image_urls } = properties.data.properties;
22205
- const ratios = aspectRatios.filter((ratio) => size?.enum.includes(ratio));
22206
- if (ratios.length === 0)
22207
- return;
22208
- const builtIn = known(modelId)?.capabilities ?? unknownModel;
22209
- return {
22210
- sizes: ratios.map((ratio) => builtIn.sizes.find((candidate) => candidate.aspectRatio === ratio) ?? nominalSize(ratio)),
22211
- maxReferences: image_urls === undefined ? 0 : image_urls.maxItems ?? Math.max(builtIn.maxReferences, 1),
22212
- maxImages: maxImagesPerJob
22213
- };
22214
- }
22215
- function nominalSize(aspectRatio) {
22216
- const [width, height] = aspectRatio.split(":").map(Number);
22217
- const scale = Math.sqrt(1024 * 1024 / (width * height));
22218
- const round = (value) => Math.round(value * scale / 16) * 16;
22219
- return { aspectRatio, width: round(width), height: round(height) };
22220
- }
22221
22800
  var schemaResponse = object({ parameters: object({ input_schema: unknown() }).optional() });
22222
22801
  var modelListSchema = object({
22223
22802
  data: array(object({ id: string2(), category: string2().optional() }))
@@ -22246,7 +22825,6 @@ async function fetchModelIds(target, http) {
22246
22825
  return { ok: true, modelIds: ids.sort() };
22247
22826
  }
22248
22827
  var apimart = {
22249
- builtInCapabilities: (modelId) => known(modelId)?.capabilities ?? unknownModel,
22250
22828
  async fetchModelInfo(target, http) {
22251
22829
  const response = await http(`${baseUrlOf(target)}/models/${encodeURIComponent(target.modelId)}/schema`, {
22252
22830
  headers: headers(target.credential)
@@ -22258,27 +22836,26 @@ var apimart = {
22258
22836
  const body = await response.json();
22259
22837
  const item = schemaResponse.safeParse(body?.data ?? body);
22260
22838
  const parameterSchema = item.success ? item.data.parameters?.input_schema : undefined;
22261
- if (parameterSchema === undefined)
22262
- return;
22263
- const capabilities = capabilitiesFromSchema(parameterSchema, target.modelId);
22264
- return { parameterSchema, ...capabilities === undefined ? {} : { capabilities } };
22839
+ return parameterSchema === undefined ? undefined : { parameterSchema };
22265
22840
  },
22266
22841
  docsUrl: (modelId) => {
22267
22842
  const model = known(modelId);
22268
22843
  return model === undefined ? "https://docs.apimart.ai/en" : `${docsBase}/${model.docsPage}`;
22269
22844
  },
22270
- reservedOptions: ["model", "prompt", "size", "n", "image_urls"],
22845
+ parameterGuide: (modelId, modality) => modality === "image" ? apimartGuide(modelId) : undefined,
22846
+ requestFields: { image: { shape: ["size"], resolution: "resolution" } },
22847
+ reservedOptions: ["model", "prompt", "image_urls"],
22271
22848
  async submit(request, target, http, context) {
22272
22849
  const batches = known(target.modelId)?.batches ?? false;
22850
+ const imageUrls = await Promise.all(request.references.map((reference) => uploadReference(reference, target, http, context)));
22273
22851
  const body = {
22274
22852
  model: target.modelId,
22275
22853
  prompt: request.prompt,
22276
- size: request.size.aspectRatio,
22277
22854
  n: batches ? request.n : 1,
22278
- ...request.references.length === 0 ? {} : { image_urls: request.references.map((reference) => `data:${reference.mimeType};base64,${Buffer.from(reference.bytes).toString("base64")}`) },
22855
+ ...imageUrls.length === 0 ? {} : { image_urls: imageUrls },
22279
22856
  ...request.providerOptions
22280
22857
  };
22281
- const settled = await Promise.allSettled(Array.from({ length: batches ? 1 : request.n }, () => submitTask(body, target, http)));
22858
+ const settled = await Promise.allSettled(Array.from({ length: batches ? 1 : request.n }, () => submitTask(body, Object.keys(request.providerOptions), target, http, context)));
22282
22859
  const taskIds = settled.flatMap((outcome) => outcome.status === "fulfilled" ? [outcome.value] : []);
22283
22860
  const failures = settled.flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
22284
22861
  if (taskIds.length === 0)
@@ -22300,15 +22877,105 @@ var apimart = {
22300
22877
  listModelIds: fetchModelIds
22301
22878
  };
22302
22879
 
22303
- // src/protocols/parallel.ts
22304
- async function inParallel(n, providerId, send) {
22305
- const settled = await Promise.allSettled(Array.from({ length: n }, () => send()));
22306
- const images = settled.flatMap((outcome) => outcome.status === "fulfilled" ? outcome.value : []);
22307
- const failures = settled.flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
22308
- if (images.length === 0)
22309
- throw failures[0] ?? providerError("other", providerId, "it returned no images.");
22310
- if (failures.length === 0)
22311
- return { images };
22880
+ // src/protocols/guides/gemini.ts
22881
+ var checked2 = "2026-11-05";
22882
+ var sources = [
22883
+ "https://ai.google.dev/gemini-api/docs/image-generation",
22884
+ "https://aistudio.google.com/api-reference/interactions-api/create-interaction",
22885
+ "https://ai.google.dev/gemini-api/docs/pricing"
22886
+ ];
22887
+ var tenRatios = "1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, 21:9";
22888
+ var fourteenRatios = `${tenRatios}, 1:4, 4:1, 1:8, 8:1`;
22889
+ var ratio = (ratios) => ({
22890
+ name: "response_format.aspect_ratio",
22891
+ values: `${ratios}.`,
22892
+ default: "the shape of the input image, else 1:1",
22893
+ price: "no effect: every ratio of a tier costs the same."
22894
+ });
22895
+ var mimeType = { name: "response_format.mime_type", values: "image/jpeg.", default: "the Provider picks", price: "no effect." };
22896
+ var imageSize = (values, price) => ({
22897
+ name: "response_format.image_size",
22898
+ values: `${values} Uppercase K: a lowercase 1k is rejected.`,
22899
+ default: "1K",
22900
+ price
22901
+ });
22902
+ var shape = ["response_format.aspect_ratio"];
22903
+ var nanoBanana21 = {
22904
+ family: "Nano Banana 2.1 (gemini-nano-banana-2.1)",
22905
+ parameters: [
22906
+ ratio(fourteenRatios),
22907
+ imageSize("1K, 2K, 4K (16:9: 1376x768, 2752x1536, 5504x3072).", "about $0.034 at 1K, $0.050 at 2K, $0.113 at 4K ($30 per 1M image tokens)."),
22908
+ mimeType
22909
+ ],
22910
+ sources: [...sources, "https://ai.google.dev/gemini-api/docs/models/gemini-nano-banana-2.1"],
22911
+ checked: checked2,
22912
+ notes: ["4K tokens: the image guide says 2520, the pricing page 3780 ($0.113); the price above follows the pricing page."],
22913
+ shapeFields: shape
22914
+ };
22915
+ var flash31 = {
22916
+ family: "Nano Banana 2 (gemini-3.1-flash-image)",
22917
+ parameters: [
22918
+ ratio(fourteenRatios),
22919
+ imageSize("512, 1K, 2K, 4K (16:9: 688x384, 1376x768, 2752x1536, 5504x3072).", "about $0.045 at 512, $0.067 at 1K, $0.101 at 2K, $0.151 at 4K."),
22920
+ mimeType
22921
+ ],
22922
+ sources: [...sources, "https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-image"],
22923
+ checked: checked2,
22924
+ notes: [
22925
+ `The small tier's value is "512" (the guide also calls it 0.5K).`,
22926
+ "The guide prints 21:9 at 512 as 792x168, which is not 21:9; probably 792x336."
22927
+ ],
22928
+ shapeFields: shape
22929
+ };
22930
+ var flashLite31 = {
22931
+ family: "Nano Banana 2 Lite (gemini-3.1-flash-lite-image)",
22932
+ parameters: [ratio(tenRatios), imageSize("1K only; 2K and 4K are not supported.", "about $0.034 per image."), mimeType],
22933
+ sources: [...sources, "https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-lite-image"],
22934
+ checked: checked2,
22935
+ notes: ['The model page speaks of "14 aspect ratios" but lists the 10 above.', "Not optimized for several reference images."],
22936
+ shapeFields: shape
22937
+ };
22938
+ var pro3 = {
22939
+ family: "Nano Banana Pro (gemini-3-pro-image)",
22940
+ parameters: [
22941
+ ratio(tenRatios),
22942
+ imageSize("1K, 2K, 4K (16:9: 1376x768, 2752x1536, 5504x3072).", "about $0.134 at 1K, the same at 2K, $0.24 at 4K."),
22943
+ mimeType
22944
+ ],
22945
+ sources: [...sources, "https://ai.google.dev/gemini-api/docs/models/gemini-3-pro-image"],
22946
+ checked: checked2,
22947
+ notes: ["Up to 14 reference images, 5 of them with high fidelity."],
22948
+ shapeFields: shape
22949
+ };
22950
+ var flash25 = {
22951
+ family: "Nano Banana (gemini-2.5-flash-image)",
22952
+ parameters: [
22953
+ { ...ratio(tenRatios), values: `${tenRatios}; one size each (16:9 is 1344x768).` },
22954
+ mimeType
22955
+ ],
22956
+ sources: [...sources, "https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash-image"],
22957
+ checked: checked2,
22958
+ notes: ["Takes no image_size. Works best with up to 3 reference images.", "Costs $0.039 per image."],
22959
+ shapeFields: shape
22960
+ };
22961
+ var entries2 = [
22962
+ { matches: /gemini-nano-banana-2\.1/, guide: nanoBanana21 },
22963
+ { matches: /gemini-3\.1-flash-lite-image/, guide: flashLite31 },
22964
+ { matches: /gemini-3\.1-flash-image/, guide: flash31 },
22965
+ { matches: /gemini-3-pro-image/, guide: pro3 },
22966
+ { matches: /gemini-2\.5-flash-image/, guide: flash25 }
22967
+ ];
22968
+ var geminiGuide = (modelId) => guideFor(entries2, modelId);
22969
+
22970
+ // src/protocols/parallel.ts
22971
+ async function inParallel(n, providerId, send) {
22972
+ const settled = await Promise.allSettled(Array.from({ length: n }, () => send()));
22973
+ const images = settled.flatMap((outcome) => outcome.status === "fulfilled" ? outcome.value : []);
22974
+ const failures = settled.flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
22975
+ if (images.length === 0)
22976
+ throw failures[0] ?? providerError("other", providerId, "it returned no images.");
22977
+ if (failures.length === 0)
22978
+ return { images };
22312
22979
  return {
22313
22980
  images,
22314
22981
  shortfall: `${failures.length} of ${n} requests to ${providerLabel(providerId)} failed: ${failures[0].message}`
@@ -22348,50 +23015,8 @@ var blockedCodes = new Set([
22348
23015
  "image_other",
22349
23016
  "content_blocked"
22350
23017
  ]);
22351
- var sizes2 = (entries) => entries.map(([aspectRatio, width, height]) => ({ aspectRatio, width, height }));
22352
- var gemini3 = {
22353
- capabilities: {
22354
- sizes: sizes2([
22355
- ["1:1", 1024, 1024],
22356
- ["16:9", 1376, 768],
22357
- ["9:16", 768, 1376],
22358
- ["4:3", 1200, 896],
22359
- ["3:4", 896, 1200],
22360
- ["3:2", 1264, 848],
22361
- ["2:3", 848, 1264]
22362
- ]),
22363
- maxReferences: 14,
22364
- maxImages: 4
22365
- },
22366
- imageSize: "1K"
22367
- };
22368
- var gemini25 = {
22369
- capabilities: {
22370
- sizes: sizes2([
22371
- ["1:1", 1024, 1024],
22372
- ["16:9", 1344, 768],
22373
- ["9:16", 768, 1344],
22374
- ["4:3", 1184, 864],
22375
- ["3:4", 864, 1184],
22376
- ["3:2", 1248, 832],
22377
- ["2:3", 832, 1248]
22378
- ]),
22379
- maxReferences: 3,
22380
- maxImages: 4
22381
- }
22382
- };
22383
- var unknownModel2 = { capabilities: { ...gemini3.capabilities, maxReferences: 3 } };
22384
- var gemini3Ids = ["gemini-nano-banana-2.1", "gemini-3.1-flash-image", "gemini-3.1-flash-lite-image", "gemini-3-pro-image"];
22385
- function geminiModel(modelId) {
22386
- if (modelId.includes("gemini-2.5-flash-image"))
22387
- return gemini25;
22388
- if (gemini3Ids.some((id) => modelId.includes(id)))
22389
- return gemini3;
22390
- return unknownModel2;
22391
- }
22392
23018
  function interactionBody(request, target) {
22393
23019
  const { response_format: extraFormat, ...options } = request.providerOptions;
22394
- const { imageSize } = geminiModel(target.modelId);
22395
23020
  return {
22396
23021
  model: target.modelId,
22397
23022
  input: [
@@ -22403,29 +23028,25 @@ function interactionBody(request, target) {
22403
23028
  }))
22404
23029
  ],
22405
23030
  ...options,
22406
- response_format: {
22407
- ...imageSize === undefined ? {} : { image_size: imageSize },
22408
- ...isObject2(extraFormat) ? extraFormat : {},
22409
- type: "image",
22410
- aspect_ratio: request.size.aspectRatio
22411
- }
23031
+ response_format: { ...isObject2(extraFormat) ? extraFormat : {}, type: "image" }
22412
23032
  };
22413
23033
  }
22414
23034
  function isObject2(value) {
22415
23035
  return typeof value === "object" && value !== null && !Array.isArray(value);
22416
23036
  }
22417
- function kindOf(status, codes, message) {
23037
+ function kindOf(status, codes, message, options = []) {
22418
23038
  const bare = codes.filter((code) => code !== undefined).map((code) => code.split(/[/#]/).pop().toLowerCase());
22419
23039
  if (bare.some((code) => blockedCodes.has(code)))
22420
23040
  return "refusal";
22421
- return classifyProviderError({ status, codes, message });
23041
+ return classifyProviderError({ status, codes, message, options });
22422
23042
  }
22423
- async function sendInteraction(request, target, http) {
22424
- const response = await sendToProvider(http, target.providerId, `${trimmed(target.baseUrl)}/interactions`, {
23043
+ async function sendInteraction(request, target, http, context) {
23044
+ const init = {
22425
23045
  method: "POST",
22426
23046
  headers: { "x-goog-api-key": target.credential, "content-type": "application/json" },
22427
23047
  body: JSON.stringify(interactionBody(request, target))
22428
- });
23048
+ };
23049
+ const response = await sendToProvider(http, target.providerId, `${trimmed(target.baseUrl)}/interactions`, init, { context });
22429
23050
  const body = await response.json().catch(() => {
22430
23051
  return;
22431
23052
  });
@@ -22435,7 +23056,7 @@ async function sendInteraction(request, target, http) {
22435
23056
  const message = error?.message ?? (response.statusText || undefined);
22436
23057
  const codes = [typeof error?.code === "string" ? error.code : undefined, error?.status, ...(error?.details ?? []).map((detail) => detail.reason)];
22437
23058
  const detail = message === undefined ? `HTTP ${response.status}` : `HTTP ${response.status}: ${message}`;
22438
- throw providerError(kindOf(response.status, codes, message), target.providerId, detail);
23059
+ throw providerError(kindOf(response.status, codes, message, Object.keys(request.providerOptions)), target.providerId, detail);
22439
23060
  }
22440
23061
  const parsed = interactionSchema.safeParse(body);
22441
23062
  if (!parsed.success) {
@@ -22455,12 +23076,13 @@ async function sendInteraction(request, target, http) {
22455
23076
  const said = content.flatMap((block) => block.text === undefined ? [] : [block.text]).join(" ");
22456
23077
  throw providerError("other", target.providerId, `the Interaction ended "${interaction.status}" with no image.${said === "" ? "" : ` The model wrote: ${said}`}`);
22457
23078
  }
22458
- var gemini = {
22459
- builtInCapabilities: (modelId) => geminiModel(modelId).capabilities,
22460
- docsUrl: () => "https://ai.google.dev/api/interactions-api",
22461
- reservedOptions: ["model", "input"],
22462
- async submit(request, target, http) {
22463
- return { wait: () => inParallel(request.n, target.providerId, () => sendInteraction(request, target, http)) };
23079
+ var gemini2 = {
23080
+ docsUrl: () => "https://ai.google.dev/gemini-api/docs/image-generation",
23081
+ parameterGuide: (modelId, modality) => modality === "image" ? geminiGuide(modelId) : undefined,
23082
+ requestFields: { image: { shape: ["response_format.aspect_ratio"], resolution: "response_format.image_size" } },
23083
+ reservedOptions: ["model", "input", "response_format.type"],
23084
+ async submit(request, target, http, context) {
23085
+ return { wait: () => inParallel(request.n, target.providerId, () => sendInteraction(request, target, http, context)) };
22464
23086
  },
22465
23087
  async testConnection(target, http) {
22466
23088
  const list = await fetchModelIds2(target, http);
@@ -22511,32 +23133,121 @@ function trimmed(baseUrl) {
22511
23133
  return baseUrl.replace(/\/+$/, "");
22512
23134
  }
22513
23135
 
22514
- // src/protocols/multipart.ts
22515
- import { randomUUID } from "node:crypto";
22516
- function multipartBody(fields, files) {
22517
- const boundary = `litevision-${randomUUID()}`;
22518
- const encoder = new TextEncoder;
22519
- const quote = (value) => value.replace(/["\r\n\\]/g, (char) => encodeURIComponent(char));
22520
- const parts = [];
22521
- for (const [name, value] of fields) {
22522
- parts.push(encoder.encode(`--${boundary}\r
22523
- Content-Disposition: form-data; name="${quote(name)}"\r
22524
- \r
22525
- ${value}\r
22526
- `));
22527
- }
22528
- for (const file of files) {
22529
- parts.push(encoder.encode(`--${boundary}\r
22530
- Content-Disposition: form-data; name="${quote(file.name)}"; filename="${quote(file.fileName)}"\r
22531
- Content-Type: ${file.mimeType}\r
22532
- \r
22533
- `), file.bytes, encoder.encode(`\r
22534
- `));
22535
- }
22536
- parts.push(encoder.encode(`--${boundary}--\r
22537
- `));
22538
- return { body: Buffer.concat(parts), contentType: `multipart/form-data; boundary=${boundary}` };
22539
- }
23136
+ // src/protocols/guides/openai-images.ts
23137
+ var checked3 = "2026-11-05";
23138
+ var sources2 = [
23139
+ "https://developers.openai.com/api/reference/resources/images/methods/generate",
23140
+ "https://developers.openai.com/api/reference/resources/images/methods/edit",
23141
+ "https://developers.openai.com/api/docs/guides/image-generation"
23142
+ ];
23143
+ var shared = {
23144
+ background: {
23145
+ name: "background",
23146
+ values: "auto, opaque, transparent (transparent needs output_format png or webp).",
23147
+ default: "auto",
23148
+ price: "no effect."
23149
+ },
23150
+ outputFormat: {
23151
+ name: "output_format",
23152
+ values: "png, jpeg, webp.",
23153
+ default: "png",
23154
+ price: "no effect."
23155
+ },
23156
+ outputCompression: {
23157
+ name: "output_compression",
23158
+ values: "0–100, for jpeg and webp.",
23159
+ default: "100",
23160
+ price: "no effect."
23161
+ },
23162
+ moderation: { name: "moderation", values: "auto, low.", default: "auto", price: "no effect." },
23163
+ n: { name: "n", values: "1–10 images per request; litevision sends `n` here.", default: "1", price: "each image is billed." }
23164
+ };
23165
+ var arbitrarySize = "auto, or any WIDTHxHEIGHT: both sides multiples of 16, the long side at most 3:1 to the short, at most 3840 a side, 655,360–8,294,400 pixels in all (above 2560x1440 is experimental). Popular: 1024x1024, 1536x1024, 1024x1536, 2048x2048, 2048x1152, 3840x2160, 2160x3840.";
23166
+ var sizePrice = "billed by output tokens, which grow with the size and the quality; a larger non-square size can cost fewer tokens than a square.";
23167
+ var gptImage252 = {
23168
+ family: "GPT Image 2.5",
23169
+ parameters: [
23170
+ { name: "size", values: arbitrarySize, default: "auto", price: sizePrice },
23171
+ {
23172
+ name: "quality",
23173
+ values: "auto, low, medium, high, xhigh, max.",
23174
+ default: "auto",
23175
+ price: "the main price lever: at 1024x1024 about $0.006 low, $0.013 medium, $0.053 high, up to $0.21 max ($30 per 1M image output tokens)."
23176
+ },
23177
+ shared.background,
23178
+ shared.outputFormat,
23179
+ shared.outputCompression,
23180
+ shared.moderation,
23181
+ shared.n
23182
+ ],
23183
+ sources: [...sources2, "https://developers.openai.com/api/docs/models/gpt-image-2.5-flare"],
23184
+ checked: checked3,
23185
+ shapeFields: ["size"]
23186
+ };
23187
+ var gptImage26 = {
23188
+ ...gptImage252,
23189
+ family: "GPT Image 2.6",
23190
+ parameters: [
23191
+ ...gptImage252.parameters,
23192
+ { name: "complexity", values: "low, medium, high.", default: "high", price: "no separate fee." }
23193
+ ],
23194
+ sources: [...sources2, "https://developers.openai.com/api/docs/models/gpt-image-2.6-sunburst"],
23195
+ notes: ["The size rule above is GPT Image 2.5's: OpenAI does not say outright that 2.6 still images take arbitrary sizes."]
23196
+ };
23197
+ var gptImage22 = {
23198
+ family: "GPT Image 2",
23199
+ parameters: [
23200
+ { name: "size", values: arbitrarySize, default: "auto", price: sizePrice },
23201
+ {
23202
+ name: "quality",
23203
+ values: "auto, low, medium, high.",
23204
+ default: "auto",
23205
+ price: "1024x1024 costs $0.006 low, $0.053 medium, $0.211 high; 1536x1024 and 1024x1536 cost $0.005, $0.041, $0.165."
23206
+ },
23207
+ { ...shared.background, values: "auto, opaque, transparent (transparent is a preview on gpt-image-2)." },
23208
+ shared.outputFormat,
23209
+ shared.outputCompression,
23210
+ shared.moderation,
23211
+ shared.n
23212
+ ],
23213
+ sources: [...sources2, "https://developers.openai.com/api/docs/models/gpt-image-2"],
23214
+ checked: checked3,
23215
+ shapeFields: ["size"]
23216
+ };
23217
+ var gptImage12 = {
23218
+ family: "GPT Image 1.x",
23219
+ parameters: [
23220
+ {
23221
+ name: "size",
23222
+ values: "auto, 1024x1024, 1536x1024, 1024x1536.",
23223
+ default: "auto",
23224
+ price: "gpt-image-1.5: portrait and landscape cost about 1.5 times the square."
23225
+ },
23226
+ {
23227
+ name: "quality",
23228
+ values: "auto, low, medium, high.",
23229
+ default: "auto",
23230
+ price: "gpt-image-1.5 at 1024x1024: $0.009 low, $0.034 medium, $0.133 high; gpt-image-1-mini: $0.005, $0.011, $0.036."
23231
+ },
23232
+ { name: "input_fidelity", values: "high, low (gpt-image-1-mini takes low only).", default: "low", price: "not documented." },
23233
+ shared.background,
23234
+ shared.outputFormat,
23235
+ shared.outputCompression,
23236
+ shared.moderation,
23237
+ shared.n
23238
+ ],
23239
+ sources: [...sources2, "https://developers.openai.com/api/docs/models/gpt-image-1.5"],
23240
+ checked: checked3,
23241
+ notes: ["OpenAI marks gpt-image-1.5 deprecated and shut gpt-image-1 down on 2026-10-23; a relay may still serve them."],
23242
+ shapeFields: ["size"]
23243
+ };
23244
+ var entries3 = [
23245
+ { matches: /^gpt-image-2\.6(-|$)/, guide: gptImage26 },
23246
+ { matches: /^gpt-image-2\.5(-|$)/, guide: gptImage252 },
23247
+ { matches: /^gpt-image-2(-\d{4}-\d{2}-\d{2})?$/, guide: gptImage22 },
23248
+ { matches: /^(gpt-image-1(\.5|-mini)?|chatgpt-image-latest)$/, guide: gptImage12 }
23249
+ ];
23250
+ var openaiImagesGuide = (modelId) => guideFor(entries3, modelId);
22540
23251
 
22541
23252
  // src/protocols/openai-images.ts
22542
23253
  var successSchema = object({
@@ -22546,32 +23257,10 @@ var errorSchema3 = object({
22546
23257
  error: object({
22547
23258
  message: string2().optional(),
22548
23259
  code: string2().nullish(),
22549
- type: string2().nullish()
23260
+ type: string2().nullish(),
23261
+ param: string2().nullish()
22550
23262
  })
22551
23263
  });
22552
- var gptImage = {
22553
- sizes: [
22554
- { aspectRatio: "1:1", width: 1024, height: 1024 },
22555
- { aspectRatio: "3:2", width: 1536, height: 1024 },
22556
- { aspectRatio: "2:3", width: 1024, height: 1536 }
22557
- ],
22558
- maxReferences: 16,
22559
- maxImages: 10
22560
- };
22561
- var dalle3 = {
22562
- sizes: [
22563
- { aspectRatio: "1:1", width: 1024, height: 1024 },
22564
- { aspectRatio: "16:9", width: 1792, height: 1024 },
22565
- { aspectRatio: "9:16", width: 1024, height: 1792 }
22566
- ],
22567
- maxReferences: 0,
22568
- maxImages: 1
22569
- };
22570
- var dalle2 = {
22571
- sizes: [{ aspectRatio: "1:1", width: 1024, height: 1024 }],
22572
- maxReferences: 1,
22573
- maxImages: 10
22574
- };
22575
23264
  function buildRequest(request, target) {
22576
23265
  const baseUrl = target.baseUrl.replace(/\/+$/, "");
22577
23266
  const headers = { authorization: `Bearer ${target.credential}` };
@@ -22579,7 +23268,6 @@ function buildRequest(request, target) {
22579
23268
  model: target.modelId,
22580
23269
  prompt: request.prompt,
22581
23270
  n: request.n,
22582
- size: formatSize(request.size),
22583
23271
  ...request.providerOptions
22584
23272
  };
22585
23273
  if (request.references.length === 0) {
@@ -22593,18 +23281,13 @@ function buildRequest(request, target) {
22593
23281
  return { url: `${baseUrl}/images/edits`, init: { method: "POST", headers: { ...headers, "content-type": contentType }, body } };
22594
23282
  }
22595
23283
  var openaiImages = {
22596
- builtInCapabilities(modelId) {
22597
- if (modelId.startsWith("dall-e-3"))
22598
- return dalle3;
22599
- if (modelId.startsWith("dall-e-2"))
22600
- return dalle2;
22601
- return gptImage;
22602
- },
22603
23284
  docsUrl: () => "https://platform.openai.com/docs/api-reference/images/create",
22604
- reservedOptions: ["model", "prompt", "n", "size", "image", "image[]"],
22605
- async submit(request, target, http) {
23285
+ parameterGuide: (modelId, modality) => modality === "image" ? openaiImagesGuide(modelId) : undefined,
23286
+ requestFields: { image: { shape: ["size"] } },
23287
+ reservedOptions: ["model", "prompt", "image", "image[]"],
23288
+ async submit(request, target, http, context) {
22606
23289
  const { url, init } = buildRequest(request, target);
22607
- const response = await sendToProvider(http, target.providerId, url, init);
23290
+ const response = await sendToProvider(http, target.providerId, url, init, { context });
22608
23291
  const body = await response.json().catch(() => {
22609
23292
  return;
22610
23293
  });
@@ -22614,7 +23297,9 @@ var openaiImages = {
22614
23297
  throw httpProviderError(target.providerId, {
22615
23298
  status: response.status,
22616
23299
  codes: [error?.code, error?.type],
22617
- message: error?.message ?? (response.statusText || undefined)
23300
+ message: error?.message ?? (response.statusText || undefined),
23301
+ param: error?.param,
23302
+ options: Object.keys(request.providerOptions)
22618
23303
  });
22619
23304
  }
22620
23305
  const parsed = successSchema.safeParse(body);
@@ -22664,106 +23349,557 @@ async function fetchModelIds3(target, http) {
22664
23349
  return { ok: true, modelIds: parsed.data.data.map((model) => model.id).sort() };
22665
23350
  }
22666
23351
 
23352
+ // src/protocols/guides/together.ts
23353
+ var checked4 = "2026-11-05";
23354
+ var imageSources = [
23355
+ "https://docs.together.ai/docs/inference/images/parameters",
23356
+ "https://docs.together.ai/docs/inference/images/reference-images",
23357
+ "https://docs.together.ai/docs/serverless/models"
23358
+ ];
23359
+ var videoSources = [
23360
+ "https://docs.together.ai/docs/inference/videos/parameters",
23361
+ "https://docs.together.ai/docs/inference/videos/reference-and-keyframes",
23362
+ "https://docs.together.ai/docs/serverless/models#video-models"
23363
+ ];
23364
+ var pixels = (range, price) => [
23365
+ { name: "width", values: `${range}, a multiple of 8.`, default: "1024", price },
23366
+ { name: "height", values: `${range}, a multiple of 8.`, default: "1024", price }
23367
+ ];
23368
+ var common = [
23369
+ { name: "n", values: "1–4 images per request; litevision sends `n` here.", default: "1", price: "each image is billed." },
23370
+ { name: "seed", values: "any integer.", default: "random", price: "no effect." },
23371
+ { name: "output_format", values: "jpeg, png.", default: "jpeg", price: "no effect." }
23372
+ ];
23373
+ var imageShape = [{ width: "width", height: "height" }, "aspect_ratio"];
23374
+ var flux22 = {
23375
+ family: "FLUX.2 [pro], [flex], [dev]",
23376
+ parameters: [
23377
+ ...pixels("256–1920", "the [pro] and [flex] estimate is $0.03+ per image, [dev] $0.0154+; larger images cost more."),
23378
+ {
23379
+ name: "reference_images",
23380
+ values: "an array of image links; litevision fills it from `references`. Together gives no count; BFL says up to 10.",
23381
+ default: "none",
23382
+ price: "not documented."
23383
+ },
23384
+ { name: "prompt_upsampling", values: "true, false.", default: "true", price: "no effect." },
23385
+ { name: "steps", values: "[dev] and [flex] only.", default: "the Provider picks", price: "not documented." },
23386
+ { name: "guidance", values: "[dev] and [flex] only.", default: "the Provider picks", price: "not documented." },
23387
+ ...common
23388
+ ],
23389
+ sources: [...imageSources, "https://docs.together.ai/docs/quickstart-flux", "https://docs.bfl.ml/flux_2/flux2_overview"],
23390
+ checked: checked4,
23391
+ notes: [
23392
+ "Default size: the quickstart says 1024x768, the API reference 1024x1024.",
23393
+ "Most output: Together's quickstart says up to 9 MP for [pro], BFL says up to 4 MP."
23394
+ ],
23395
+ shapeFields: imageShape
23396
+ };
23397
+ var flux2Max = {
23398
+ family: "FLUX.2 [max]",
23399
+ parameters: [...pixels("assumed 256–1920 as for the other FLUX.2 models", "$0.07 per megapixel at 50 steps: doubling the pixels doubles the price."), ...common],
23400
+ sources: [...imageSources, "https://docs.together.ai/docs/quickstart-flux"],
23401
+ checked: checked4,
23402
+ notes: ["Together documents neither its size range nor reference images."],
23403
+ shapeFields: imageShape
23404
+ };
23405
+ var kontext = {
23406
+ family: "FLUX.1 Kontext [pro], [max]",
23407
+ parameters: [
23408
+ {
23409
+ name: "aspect_ratio",
23410
+ values: 'a ratio such as "1:1", "16:9", "9:16", "4:3", "3:2" (examples Together gives).',
23411
+ default: "the Provider picks",
23412
+ price: "[pro] $0.04 and [max] $0.08 per megapixel."
23413
+ },
23414
+ ...pixels("multiples of 8, in place of aspect_ratio", "billed per megapixel."),
23415
+ { name: "image_url", values: "one image link; litevision fills it from the first of `references`.", default: "none", price: "not documented." },
23416
+ { name: "steps", values: "an integer; more than the default costs proportionally more.", default: "28", price: "price × steps ÷ 28 above the default." },
23417
+ ...common
23418
+ ],
23419
+ sources: [...imageSources, "https://docs.together.ai/docs/quickstart-flux"],
23420
+ checked: checked4,
23421
+ notes: ["`aspect_ratio` is in Together's docs but not in its OpenAPI schema."],
23422
+ shapeFields: imageShape
23423
+ };
23424
+ var flux11 = {
23425
+ family: "FLUX1.1 [pro]",
23426
+ parameters: [...pixels("multiples of 8", "$0.04 per megapixel: doubling the pixels doubles the price."), ...common],
23427
+ sources: imageSources,
23428
+ checked: checked4,
23429
+ notes: ["Takes no reference images."],
23430
+ shapeFields: imageShape
23431
+ };
23432
+ var gemini3Pro = {
23433
+ family: "Gemini 3 Pro Image on Together",
23434
+ parameters: [
23435
+ {
23436
+ name: "width",
23437
+ values: "with height, one of Together's sizes: 1K 1024x1024, 1264x848, 848x1264, 1200x896, 896x1200, 928x1152, 1152x928, 768x1376, 1376x768, 1584x672; 2K 2048x2048, 2528x1696, 2752x1536 and the like; 4K 4096x4096, 5504x3072 and the like.",
23438
+ default: "1024",
23439
+ price: "$0.134 at 1K or 2K (the same), about $0.24 at 4K."
23440
+ },
23441
+ { name: "height", values: "with width, as above.", default: "1024", price: "as width." },
23442
+ { name: "reference_images", values: "an array of image links; litevision fills it from `references`. Count not documented by Together (Google: up to 14).", default: "none", price: "not documented." },
23443
+ ...common
23444
+ ],
23445
+ sources: imageSources,
23446
+ checked: checked4,
23447
+ notes: ['Together gives some sizes as alternatives ("1548x672 or 1584x672", "5096x3392 or 5056x3392"); Google says 1584x672 and 5056x3392.'],
23448
+ shapeFields: imageShape
23449
+ };
23450
+ var flash252 = {
23451
+ family: "Gemini 2.5 Flash Image on Together",
23452
+ parameters: [
23453
+ ...pixels("not documented by Together", "estimated at $0.039+ per image."),
23454
+ { name: "reference_images", values: "an array of image links; litevision fills it from `references`. Count not documented.", default: "none", price: "not documented." },
23455
+ ...common
23456
+ ],
23457
+ sources: imageSources,
23458
+ checked: checked4,
23459
+ notes: ["Together documents no sizes for this model; Google's own sizes are about a megapixel at each ratio."],
23460
+ shapeFields: imageShape
23461
+ };
23462
+ var imageEntries = [
23463
+ { matches: /^black-forest-labs\/FLUX\.2-max$/i, guide: flux2Max },
23464
+ { matches: /^black-forest-labs\/FLUX\.2-(pro|flex|dev)$/i, guide: flux22 },
23465
+ { matches: /^black-forest-labs\/FLUX\.1-kontext-(pro|max)$/i, guide: kontext },
23466
+ { matches: /^black-forest-labs\/FLUX\.1\.1-pro$/i, guide: flux11 },
23467
+ { matches: /^google\/gemini-3-pro-image$/i, guide: gemini3Pro },
23468
+ { matches: /^google\/flash-image-2\.5$/i, guide: flash252 }
23469
+ ];
23470
+ var videoCommon = [
23471
+ { name: "seed", values: "0–2,147,483,647.", default: "random", price: "no effect." },
23472
+ { name: "negative_prompt", values: "text, at most 500 characters.", default: "none", price: "no effect." },
23473
+ {
23474
+ name: "media.frame_images",
23475
+ values: 'litevision fills it from `first_frame` and `last_frame` as { input_image, frame: "first" | "last" }.',
23476
+ default: "none",
23477
+ price: "not documented."
23478
+ }
23479
+ ];
23480
+ var wan = (variant, seconds, media) => ({
23481
+ family: `Wan 2.7 ${variant}`,
23482
+ parameters: [
23483
+ {
23484
+ name: "resolution",
23485
+ values: "720P, 1080P (16:9 is 1280x720 or 1920x1080; 4:3 is 1104x832 or 1648x1248).",
23486
+ default: "1080P",
23487
+ price: "$0.10 per video in Together's catalog, which gives no resolution or length."
23488
+ },
23489
+ { name: "ratio", values: "16:9, 9:16, 1:1, 4:3, 3:4.", default: "16:9", price: "no effect." },
23490
+ { name: "seconds", values: `${seconds}, as a string.`, default: '"5"', price: "not documented." },
23491
+ ...videoCommon
23492
+ ],
23493
+ sources: [...videoSources, "https://docs.together.ai/docs/wan2.7-quickstart"],
23494
+ checked: checked4,
23495
+ notes: [media, "30 fps MP4."],
23496
+ shapeFields: ["ratio"],
23497
+ durationField: "seconds"
23498
+ });
23499
+ var seedance25 = {
23500
+ family: "Seedance 2.5",
23501
+ parameters: [
23502
+ { name: "resolution", values: "480p, 720p.", default: "720p", price: "480p $0.115 per second; 720p from $0.249 per second." },
23503
+ { name: "seconds", values: "4–30 whole seconds, as a string.", default: '"5"', price: "billed per second of output." },
23504
+ { name: "media.reference_images", values: "up to 30 image links; litevision fills it from `references`.", default: "none", price: "not documented." },
23505
+ ...videoCommon
23506
+ ],
23507
+ sources: [...videoSources, "https://docs.together.ai/docs/seedance2.5-quickstart"],
23508
+ checked: checked4,
23509
+ notes: [
23510
+ "Takes no ratio, width or height: the shape follows the first frame, else the model picks. Audio is always on.",
23511
+ "Frames and references cannot be combined.",
23512
+ 'The catalog lists $0.115 under "Price per video"; the quickstart says per second, which is what is given here.'
23513
+ ],
23514
+ shapeFields: [],
23515
+ durationField: "seconds"
23516
+ };
23517
+ var videoEntries = [
23518
+ { matches: /^Wan-AI\/wan2\.7-t2v$/i, guide: wan("text-to-video", "2–15", "Takes no frames or reference images.") },
23519
+ { matches: /^Wan-AI\/wan2\.7-i2v$/i, guide: wan("image-to-video", "2–15", "Takes a first and/or last frame.") },
23520
+ {
23521
+ matches: /^Wan-AI\/wan2\.7-r2v$/i,
23522
+ guide: wan("reference-to-video", "2–10", "Takes several reference images in media.reference_images; Together gives no count.")
23523
+ },
23524
+ { matches: /^ByteDance\/Seedance-2\.5$/i, guide: seedance25 }
23525
+ ];
23526
+ var togetherGuide = (modelId, modality) => guideFor(modality === "video" ? videoEntries : imageEntries, modelId);
23527
+
23528
+ // src/protocols/together.ts
23529
+ var docs = {
23530
+ flux: "https://docs.together.ai/docs/quickstart-flux",
23531
+ imageParameters: "https://docs.together.ai/docs/inference/images/parameters",
23532
+ referenceImages: "https://docs.together.ai/docs/inference/images/reference-images",
23533
+ wan: "https://docs.together.ai/docs/wan2.7-quickstart",
23534
+ seedance: "https://docs.together.ai/docs/seedance2.5-quickstart",
23535
+ videoParameters: "https://docs.together.ai/docs/inference/videos/parameters"
23536
+ };
23537
+ var imageFamilies = [
23538
+ { matches: /^black-forest-labs\/FLUX\.1-kontext-/i, references: "image_url", docsUrl: docs.flux },
23539
+ { matches: /^black-forest-labs\//i, references: "reference_images", docsUrl: docs.flux },
23540
+ { matches: /^google\/(gemini-3-pro-image|flash-image-2\.5)$/i, references: "reference_images", docsUrl: docs.referenceImages }
23541
+ ];
23542
+ var otherImages = { matches: /.*/, references: "reference_images", docsUrl: docs.imageParameters };
23543
+ var imageFamily = (modelId) => imageFamilies.find((family) => family.matches.test(modelId)) ?? otherImages;
23544
+ var videoFamilies = [
23545
+ { matches: /^Wan-AI\/wan2\.7-/i, docsUrl: docs.wan },
23546
+ { matches: /^ByteDance\/Seedance-2\.5$/i, docsUrl: docs.seedance }
23547
+ ];
23548
+ var videoDocsUrl = (modelId) => videoFamilies.find((family) => family.matches.test(modelId))?.docsUrl ?? docs.videoParameters;
23549
+ var errorSchema4 = object({
23550
+ error: object({
23551
+ message: string2().optional(),
23552
+ code: string2().nullish(),
23553
+ type: string2().nullish(),
23554
+ param: string2().nullish()
23555
+ })
23556
+ });
23557
+ var imagesSchema = object({
23558
+ data: array(object({ b64_json: string2().nullish(), url: string2().nullish() }))
23559
+ });
23560
+ var videoSchema = object({
23561
+ id: string2(),
23562
+ status: string2(),
23563
+ outputs: object({ video_url: string2().optional(), cost: number2().optional() }).nullish(),
23564
+ error: object({ message: string2().optional(), code: string2().nullish() }).nullish()
23565
+ });
23566
+ var resumeSchema2 = object({ videoId: string2().min(1) });
23567
+ var modelListSchema4 = array(object({ id: string2(), type: string2().optional() }));
23568
+ var rootOf = (target) => target.baseUrl.replace(/\/+$/, "").replace(/\/v[12]$/, "");
23569
+ var authorization = (credential) => ({ authorization: `Bearer ${credential}` });
23570
+ var dataUrl = (image) => `data:${image.mimeType};base64,${Buffer.from(image.bytes).toString("base64")}`;
23571
+ var passthroughBlocked = "third_party_data_sharing_blocked";
23572
+ function passthroughError(providerId) {
23573
+ 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.");
23574
+ }
23575
+ async function errorOf2(response, providerId, options = []) {
23576
+ const text = await response.text().catch(() => "");
23577
+ let body;
23578
+ try {
23579
+ body = JSON.parse(text);
23580
+ } catch {
23581
+ body = undefined;
23582
+ }
23583
+ const parsed = errorSchema4.safeParse(body);
23584
+ const error = parsed.success ? parsed.data.error : undefined;
23585
+ if (error?.code === passthroughBlocked)
23586
+ return passthroughError(providerId);
23587
+ const plain = body === undefined ? text.trim().slice(0, 300) : "";
23588
+ return httpProviderError(providerId, {
23589
+ status: response.status,
23590
+ codes: [error?.code, error?.type],
23591
+ message: error?.message ?? (plain || response.statusText || undefined),
23592
+ param: error?.param,
23593
+ options
23594
+ });
23595
+ }
23596
+ async function postJson(url, body, providerOptions, target, http, context) {
23597
+ const init = {
23598
+ method: "POST",
23599
+ headers: { ...authorization(target.credential), "content-type": "application/json" },
23600
+ body: JSON.stringify(body)
23601
+ };
23602
+ const response = await sendToProvider(http, target.providerId, url, init, { context });
23603
+ if (!response.ok)
23604
+ throw await errorOf2(response, target.providerId, Object.keys(providerOptions));
23605
+ return response.json().catch(() => {
23606
+ return;
23607
+ });
23608
+ }
23609
+ function waitForVideo(videoId, target, http, context) {
23610
+ const url = `${rootOf(target)}/v2/videos/${encodeURIComponent(videoId)}`;
23611
+ return {
23612
+ resumeHandle: { videoId },
23613
+ async wait() {
23614
+ for (;; ) {
23615
+ const response = await sendToProvider(http, target.providerId, url, { headers: authorization(target.credential) }, { context, poll: true });
23616
+ if (!response.ok)
23617
+ throw await errorOf2(response, target.providerId);
23618
+ const parsed = videoSchema.safeParse(await response.json().catch(() => {
23619
+ return;
23620
+ }));
23621
+ if (!parsed.success)
23622
+ throw providerError("other", target.providerId, `its status for video ${videoId} could not be read.`);
23623
+ const job = parsed.data;
23624
+ if (job.status === "completed") {
23625
+ const link = job.outputs?.video_url;
23626
+ if (link === undefined)
23627
+ throw providerError("other", target.providerId, `video ${videoId} completed without a link to it.`);
23628
+ return { video: { url: link }, ...job.outputs?.cost === undefined ? {} : { cost: job.outputs.cost } };
23629
+ }
23630
+ if (job.status === "failed")
23631
+ throw videoFailure(job.error ?? undefined, target.providerId, videoId);
23632
+ if (job.status === "cancelled")
23633
+ throw providerError("other", target.providerId, `video ${videoId} was cancelled.`);
23634
+ await context.pause();
23635
+ }
23636
+ }
23637
+ };
23638
+ }
23639
+ function videoFailure(error, providerId, videoId) {
23640
+ if (error?.code === passthroughBlocked)
23641
+ return passthroughError(providerId);
23642
+ const rejected = /^(unsupported|invalid)/i.test(error?.code ?? "");
23643
+ const kind = rejected ? "unsupported" : classifyProviderError({ status: 0, codes: [error?.code], message: error?.message });
23644
+ const detail = [error?.code, error?.message].filter(Boolean).join(": ");
23645
+ return providerError(kind, providerId, detail || `video ${videoId} failed.`);
23646
+ }
23647
+ async function fetchModelIds4(target, http) {
23648
+ const label = providerLabel(target.providerId);
23649
+ let response;
23650
+ try {
23651
+ response = await http(`${rootOf(target)}/v1/models`, { headers: authorization(target.credential) });
23652
+ } catch (error) {
23653
+ return { ok: false, message: `Could not reach ${label}: ${error.message}` };
23654
+ }
23655
+ if (!response.ok)
23656
+ return { ok: false, message: (await errorOf2(response, target.providerId)).message };
23657
+ const parsed = modelListSchema4.safeParse(await response.json().catch(() => {
23658
+ return;
23659
+ }));
23660
+ if (!parsed.success)
23661
+ return { ok: false, message: `${label} answered, but not with a Together model list. Check the base URL.` };
23662
+ const modalities = {};
23663
+ for (const model of parsed.data) {
23664
+ if (model.type === "image" || model.type === "video")
23665
+ modalities[model.id] = model.type;
23666
+ }
23667
+ return { ok: true, modelIds: Object.keys(modalities).sort(), modalities };
23668
+ }
23669
+ var together = {
23670
+ parameterGuide: (modelId, modality) => togetherGuide(modelId, modality),
23671
+ docsUrl: (modelId, modality = "image") => modality === "video" ? videoDocsUrl(modelId) : imageFamily(modelId).docsUrl,
23672
+ requestFields: {
23673
+ image: { shape: [{ width: "width", height: "height" }, "aspect_ratio"] },
23674
+ video: { shape: [{ width: "width", height: "height" }, "ratio"], duration: "seconds", resolution: "resolution" }
23675
+ },
23676
+ reservedOptions: ["model", "prompt", "response_format", "reference_images", "image_url"],
23677
+ reservedVideoOptions: ["model", "prompt", "media"],
23678
+ async submit(request, target, http, context) {
23679
+ const family = imageFamily(target.modelId);
23680
+ const { references } = request;
23681
+ const body = {
23682
+ model: target.modelId,
23683
+ prompt: request.prompt,
23684
+ n: request.n,
23685
+ response_format: "base64",
23686
+ ...references.length === 0 ? {} : family.references === "image_url" ? { image_url: dataUrl(references[0]) } : { reference_images: references.map(dataUrl) },
23687
+ ...request.providerOptions
23688
+ };
23689
+ const parsed = imagesSchema.safeParse(await postJson(`${rootOf(target)}/v1/images/generations`, body, request.providerOptions, target, http, context));
23690
+ if (!parsed.success)
23691
+ throw providerError("other", target.providerId, "its response could not be read as Together images.");
23692
+ const images = parsed.data.data.map((item) => {
23693
+ if (item.b64_json)
23694
+ return { bytes: Buffer.from(item.b64_json, "base64") };
23695
+ if (item.url)
23696
+ return { url: item.url };
23697
+ throw providerError("other", target.providerId, "it returned an image with neither data nor a link.");
23698
+ });
23699
+ if (images.length === 0)
23700
+ throw providerError("other", target.providerId, "it returned no images.");
23701
+ const shortfall = images.length < request.n ? `${providerLabel(target.providerId)} returned ${images.length} of ${request.n} images without saying why.` : undefined;
23702
+ return { wait: async () => ({ images, ...shortfall === undefined ? {} : { shortfall } }) };
23703
+ },
23704
+ async submitVideo(request, target, http, context) {
23705
+ const frames = [
23706
+ ...request.firstFrame === undefined ? [] : [{ input_image: dataUrl(request.firstFrame), frame: "first" }],
23707
+ ...request.lastFrame === undefined ? [] : [{ input_image: dataUrl(request.lastFrame), frame: "last" }]
23708
+ ];
23709
+ const media = {
23710
+ ...frames.length === 0 ? {} : { frame_images: frames },
23711
+ ...request.references.length === 0 ? {} : { reference_images: request.references.map(dataUrl) }
23712
+ };
23713
+ const body = {
23714
+ model: target.modelId,
23715
+ prompt: request.prompt,
23716
+ ...Object.keys(media).length === 0 ? {} : { media },
23717
+ ...request.providerOptions
23718
+ };
23719
+ const parsed = videoSchema.safeParse(await postJson(`${rootOf(target)}/v2/videos`, body, request.providerOptions, target, http, context));
23720
+ if (!parsed.success)
23721
+ throw providerError("other", target.providerId, "its answer to the submission had no video id.");
23722
+ if (parsed.data.status === "failed")
23723
+ throw videoFailure(parsed.data.error ?? undefined, target.providerId, parsed.data.id);
23724
+ return waitForVideo(parsed.data.id, target, http, context);
23725
+ },
23726
+ resumeVideo(handle, target, http, context) {
23727
+ const parsed = resumeSchema2.safeParse(handle);
23728
+ if (!parsed.success)
23729
+ throw providerError("other", target.providerId, "the Job history holds no video id to pick the Job up from.");
23730
+ return waitForVideo(parsed.data.videoId, target, http, context);
23731
+ },
23732
+ async testConnection(target, http) {
23733
+ const list = await fetchModelIds4(target, http);
23734
+ if (!list.ok)
23735
+ return list;
23736
+ const count = (modality) => Object.values(list.modalities ?? {}).filter((value) => value === modality).length;
23737
+ return {
23738
+ ok: true,
23739
+ message: `Connected to Provider "${target.providerId}". It offers ${count("image")} image and ${count("video")} video model id(s).`
23740
+ };
23741
+ },
23742
+ listModelIds: fetchModelIds4
23743
+ };
23744
+
23745
+ // src/protocols/guides/venice.ts
23746
+ var checked5 = "2026-11-05";
23747
+ var generateSources = [
23748
+ "https://docs.venice.ai/api-reference/endpoint/image/generate",
23749
+ "https://docs.venice.ai/models/image",
23750
+ "https://api.venice.ai/api/v1/models?type=image"
23751
+ ];
23752
+ var editSources = [
23753
+ "https://docs.venice.ai/api-reference/endpoint/image/edit",
23754
+ "https://docs.venice.ai/api-reference/endpoint/image/multi-edit",
23755
+ "https://api.venice.ai/api/v1/models?type=inpaint"
23756
+ ];
23757
+ var generateCommon = [
23758
+ { name: "variants", values: "1–4 images per request; litevision sends `n` here.", default: "1", price: "each image is billed." },
23759
+ { name: "format", values: "jpeg, png, webp.", default: "png (litevision's choice; Venice's own is webp)", price: "no effect." },
23760
+ { name: "negative_prompt", values: "text.", default: "none", price: "no effect." },
23761
+ { name: "seed", values: "−999,999,999 to 999,999,999.", default: "random", price: "no effect." },
23762
+ { name: "safe_mode", values: "true, false.", default: "false (litevision's choice; Venice's own is true)", price: "no effect." },
23763
+ { name: "hide_watermark", values: "true, false.", default: "true (litevision's choice)", price: "no effect." }
23764
+ ];
23765
+ var ratioShape = [{ width: "width", height: "height" }, "aspect_ratio"];
23766
+ var pixelModels = {
23767
+ family: "Venice pixel-sized models (venice-sd35, wai-Illustrious, lustify, z-image-turbo, chroma)",
23768
+ parameters: [
23769
+ {
23770
+ name: "width",
23771
+ values: "1–1280, divisible by the model's widthHeightDivisor (16 for venice-sd35 and wai-Illustrious, 8 for the rest).",
23772
+ default: "1024",
23773
+ price: "$0.01 per image whatever the size."
23774
+ },
23775
+ { name: "height", values: "as width.", default: "1024", price: "as width." },
23776
+ { name: "cfg_scale", values: "0–20.", default: "the Provider picks", price: "no effect." },
23777
+ { name: "steps", values: "per model (venice-sd35: up to 30).", default: "per model (venice-sd35: 25)", price: "no effect." },
23778
+ ...generateCommon
23779
+ ],
23780
+ sources: generateSources,
23781
+ checked: checked5,
23782
+ shapeFields: ratioShape
23783
+ };
23784
+ var tierModels = {
23785
+ family: "Venice models sized by aspect ratio and resolution tier",
23786
+ parameters: [
23787
+ {
23788
+ name: "aspect_ratio",
23789
+ values: "per model, most of 1:1, 3:2, 16:9, 21:9, 9:16, 2:3, 3:4, 4:5 (grok-imagine-* lack 21:9 and 4:5 in part; 4:3 only on seedream-v5-*).",
23790
+ default: "1:1",
23791
+ price: "no effect."
23792
+ },
23793
+ {
23794
+ name: "resolution",
23795
+ values: "1K, 2K, and on some models 4K; per model.",
23796
+ default: "1K",
23797
+ price: "per model at 1K / 2K / 4K: gpt-image-2-5-flare and -sunburst $0.07 / $0.15 / $0.23; gpt-image-2 $0.27 / $0.51 / $0.84; nano-banana-2 and -2-1 $0.10 / $0.14 / $0.19; nano-banana-pro $0.18 / $0.23 / $0.35; flux-3-image $0.12 / $0.29 / $0.98; krea-2-turbo $0.04 / $0.06; grok-imagine-image $0.03 / $0.04; seedream-v5-pro $0.06 / $0.11; qwen-image-3-pro $0.05 / $0.09; the same at 1K and 2K on ideogram-v4-5 ($0.26), qwen-image-3 ($0.04), qwen-image-2-1-turbo ($0.02) and qwen-image-2-1-pro ($0.05)."
23798
+ },
23799
+ {
23800
+ name: "quality",
23801
+ values: "low, medium, high on models that list qualities (gpt-image-2*, ideogram-v4-5; grok-imagine-image-2-0 low, medium).",
23802
+ default: "high",
23803
+ price: "gpt-image-2 at 1K: $0.02 low, $0.07 medium, $0.26 high; gpt-image-2.5 at 1K: $0.03, $0.04, $0.07."
23804
+ },
23805
+ ...generateCommon
23806
+ ],
23807
+ sources: generateSources,
23808
+ checked: checked5,
23809
+ notes: ["Venice publishes no pixel sizes for these models.", "gpt-image-2's headline prices are $0.01 above its per-quality high prices; both are in Venice's model list."],
23810
+ shapeFields: ratioShape
23811
+ };
23812
+ var ratioModels = {
23813
+ family: "Venice models sized by aspect ratio",
23814
+ parameters: [
23815
+ {
23816
+ name: "aspect_ratio",
23817
+ values: "per model, most of 1:1, 3:2, 16:9, 21:9, 9:16, 2:3, 3:4, 4:5 (gpt-image-1-5: 1:1, 3:2, 2:3; flux-2-max also auto).",
23818
+ default: "1:1 (flux-2-max: auto)",
23819
+ price: "no effect; per image: flux-2-pro $0.03, flux-2-max $0.09, gpt-image-1-5 $0.26, hunyuan-image-v3 $0.09, ideogram-v4 $0.06, krea-v2-large $0.07, krea-v2-medium $0.04, luma-uni-1 $0.05, luma-uni-1-max $0.12, muse-image $0.02, nano-banana-2-lite $0.06, recraft-v4 $0.05, recraft-v4-pro $0.29, seedream-v4 $0.05, seedream-v5-lite $0.05, qwen-image $0.03, qwen-image-2 $0.05, qwen-image-2-pro $0.10, wan-2-7-text-to-image $0.0375, wan-2-7-pro-text-to-image $0.094."
23820
+ },
23821
+ ...generateCommon
23822
+ ],
23823
+ sources: generateSources,
23824
+ checked: checked5,
23825
+ notes: ["The Qwen Image models reject width and height with a 400.", "krea-v2-* and luma-uni-1* take up to 3 style_references."],
23826
+ shapeFields: ratioShape
23827
+ };
23828
+ var editModels = {
23829
+ family: "Venice edit models (`-edit`)",
23830
+ parameters: [
23831
+ {
23832
+ name: "aspect_ratio",
23833
+ values: "auto, 1:1, 3:2, 16:9, 21:9, 9:16, 2:3, 3:4, 4:3, 4:5; a subset per model.",
23834
+ default: "auto",
23835
+ price: "no effect."
23836
+ },
23837
+ {
23838
+ name: "resolution",
23839
+ values: "1K, 2K, and on some models 4K; per model.",
23840
+ default: "1K",
23841
+ price: "per model, e.g. nano-banana-pro-edit $0.18 / $0.23 / $0.35, gpt-image-2-5-flare-edit $0.08 / $0.15 / $0.24."
23842
+ },
23843
+ {
23844
+ name: "quality",
23845
+ values: "low, medium, high; only on /image/multi-edit (several references), where the model lists qualities. /image/edit rejects it.",
23846
+ default: "the model's default tier",
23847
+ price: "per model."
23848
+ },
23849
+ { name: "output_format", values: "jpeg, png, webp.", default: "png (litevision's choice)", price: "no effect." }
23850
+ ],
23851
+ sources: editSources,
23852
+ checked: checked5,
23853
+ notes: [
23854
+ "Reference images per Job: the model list's maxInputImages, else 3, or 1 when combineImages is false. The multi-edit docs name capabilities.maxInputImages, the model list puts it in constraints.",
23855
+ "One reference image goes to /image/edit, several to /image/multi-edit; each image of a Job is its own request."
23856
+ ],
23857
+ shapeFields: ["aspect_ratio"]
23858
+ };
23859
+ var entries4 = [
23860
+ { matches: /(^|-)edit(-|$)/, guide: editModels },
23861
+ { matches: /^(venice-sd35|wai-Illustrious|lustify-(sdxl|v7|v8)|z-image-turbo|chroma)$/, guide: pixelModels },
23862
+ {
23863
+ matches: /^(gpt-image-2(-5-(flare|sunburst))?|nano-banana-(2|2-1|pro)|flux-3-image|krea-2-turbo|grok-imagine-image(-2-0)?|ideogram-v4-5|seedream-v5-pro|qwen-image-3(-pro)?|qwen-image-2-1-(turbo|pro))$/,
23864
+ guide: tierModels
23865
+ },
23866
+ {
23867
+ matches: /^(flux-2-(pro|max)|gpt-image-1-5|hunyuan-image-v3|ideogram-v4|krea-v2-(large|medium)|luma-uni-1(-max)?|muse-image|nano-banana-2-lite|recraft-v4(-pro)?|seedream-v4|seedream-v5-lite|qwen-image(-2(-pro)?)?|wan-2-7(-pro)?-text-to-image)$/,
23868
+ guide: ratioModels
23869
+ }
23870
+ ];
23871
+ var veniceGuide = (modelId) => guideFor(entries4, modelId);
23872
+
22667
23873
  // src/protocols/venice.ts
22668
23874
  var generateDocs = "https://docs.venice.ai/api-reference/endpoint/image/generate";
22669
23875
  var editDocs = "https://docs.venice.ai/api-reference/endpoint/image/edit";
22670
- var maxImages = 4;
22671
- var constraintsSchema = object({
22672
- aspectRatios: array(string2()).optional(),
22673
- widthHeightDivisor: number2().positive().optional(),
22674
- combineImages: boolean2().optional(),
22675
- maxInputImages: number2().int().positive().optional()
22676
- });
22677
- var sizingOf = (constraints) => (constraints.widthHeightDivisor ?? 1) > 1 ? "pixels" : "ratio";
22678
- var modelListSchema4 = object({
23876
+ var modelListSchema5 = object({
22679
23877
  data: array(object({
22680
23878
  id: string2(),
22681
23879
  type: string2().optional(),
22682
- model_spec: object({
22683
- constraints: record(string2(), unknown()).optional(),
22684
- capabilities: object({ maxInputImages: number2().int().positive().optional() }).passthrough().optional()
22685
- }).passthrough().optional()
23880
+ model_spec: object({ constraints: record(string2(), unknown()).optional() }).passthrough().optional()
22686
23881
  }))
22687
23882
  });
22688
- var errorSchema4 = object({
23883
+ var errorSchema5 = object({
22689
23884
  error: union([string2(), object({ message: string2().optional(), code: string2().nullish() })]),
22690
23885
  code: string2().optional(),
22691
- suggested_prompt: string2().optional()
23886
+ suggested_prompt: string2().optional(),
23887
+ details: record(string2(), unknown()).optional()
22692
23888
  });
22693
23889
  var generateSchema = object({
22694
23890
  images: array(string2()),
22695
23891
  content_policy_violation_count: number2().int().nonnegative().optional()
22696
23892
  });
22697
- function sizeFor(aspectRatio, divisor, maxSide = Infinity) {
22698
- const [w, h] = aspectRatio.split(":").map(Number);
22699
- const area = 1024 * 1024;
22700
- let width = Math.sqrt(area * w / h);
22701
- let height = Math.sqrt(area * h / w);
22702
- const scale = Math.min(1, maxSide / width, maxSide / height);
22703
- width *= scale;
22704
- height *= scale;
22705
- const snap = (value) => Math.max(divisor, Math.round(value / divisor) * divisor);
22706
- return { aspectRatio, width: snap(width), height: snap(height) };
22707
- }
22708
- function pixelSizes(divisor) {
22709
- return aspectRatios.map((ratio) => sizeFor(ratio, divisor, 1280));
22710
- }
22711
- function ratioSizes(ratios) {
22712
- return aspectRatios.filter((ratio) => ratios.includes(ratio)).map((ratio) => sizeFor(ratio, 16));
22713
- }
22714
23893
  function looksLikeEdit(modelId) {
22715
23894
  return /(^|-)edit(-|$)/.test(modelId);
22716
23895
  }
22717
- var pixelModels = ["venice-sd35", "hidream", "lustify-sdxl", "flux-dev", "flux-dev-uncensored", "pony-realism"];
22718
- function builtInModel(modelId) {
22719
- if (looksLikeEdit(modelId)) {
22720
- return { kind: "edit", sizing: "ratio", capabilities: { sizes: ratioSizes(aspectRatios), maxReferences: 3, maxImages } };
22721
- }
22722
- if (pixelModels.includes(modelId)) {
22723
- return { kind: "generate", sizing: "pixels", capabilities: { sizes: pixelSizes(16), maxReferences: 0, maxImages } };
22724
- }
22725
- return {
22726
- kind: "generate",
22727
- sizing: "ratio",
22728
- capabilities: { sizes: ratioSizes(["1:1", "16:9", "9:16", "3:2", "2:3"]), maxReferences: 0, maxImages }
22729
- };
22730
- }
22731
- function describeModel(listed, listType) {
22732
- const raw = listed.model_spec?.constraints;
22733
- const parsed = constraintsSchema.safeParse(raw ?? {});
22734
- const constraints = parsed.success ? parsed.data : {};
22735
- const nonEmpty = (capabilities) => capabilities.sizes.length === 0 ? undefined : capabilities;
22736
- if ((listed.type ?? listType) === "inpaint") {
22737
- const maxReferences = constraints.maxInputImages ?? listed.model_spec?.capabilities?.maxInputImages ?? (constraints.combineImages === false ? 1 : 3);
22738
- return {
22739
- kind: "edit",
22740
- sizing: "ratio",
22741
- capabilities: nonEmpty({ sizes: ratioSizes(constraints.aspectRatios ?? []), maxReferences, maxImages }),
22742
- constraints: raw
22743
- };
22744
- }
22745
- if (sizingOf(constraints) === "pixels") {
22746
- return {
22747
- kind: "generate",
22748
- sizing: "pixels",
22749
- capabilities: { sizes: pixelSizes(constraints.widthHeightDivisor), maxReferences: 0, maxImages },
22750
- constraints: raw
22751
- };
22752
- }
22753
- return {
22754
- kind: "generate",
22755
- sizing: "ratio",
22756
- capabilities: nonEmpty({ sizes: ratioSizes(constraints.aspectRatios ?? []), maxReferences: 0, maxImages }),
22757
- constraints: raw
22758
- };
22759
- }
22760
23896
  var apiUrl = (baseUrl, path) => `${baseUrl.replace(/\/+$/, "")}${path}`;
22761
- var authorization = (credential) => ({ authorization: `Bearer ${credential}` });
23897
+ var authorization2 = (credential) => ({ authorization: `Bearer ${credential}` });
22762
23898
  async function listModels(target, type, http) {
22763
23899
  const label = providerLabel(target.providerId);
22764
23900
  let response;
22765
23901
  try {
22766
- response = await http(apiUrl(target.baseUrl, `/models?type=${type}`), { headers: authorization(target.credential) });
23902
+ response = await http(apiUrl(target.baseUrl, `/models?type=${type}`), { headers: authorization2(target.credential) });
22767
23903
  } catch (error) {
22768
23904
  return { ok: false, message: `Could not reach ${label}: ${error.message}` };
22769
23905
  }
@@ -22773,19 +23909,12 @@ async function listModels(target, type, http) {
22773
23909
  if (!response.ok) {
22774
23910
  return { ok: false, message: `${label} returned HTTP ${response.status}: ${errorMessage(body) ?? response.statusText}` };
22775
23911
  }
22776
- const parsed = modelListSchema4.safeParse(body);
23912
+ const parsed = modelListSchema5.safeParse(body);
22777
23913
  if (!parsed.success) {
22778
23914
  return { ok: false, message: `${label} answered, but not with a Venice model list. Check the base URL.` };
22779
23915
  }
22780
23916
  return { ok: true, models: parsed.data.data };
22781
23917
  }
22782
- function describedModel(described, modelId) {
22783
- const kind = described.capabilities.maxReferences > 0 ? "edit" : "generate";
22784
- const constraints = constraintsSchema.safeParse(described.parameterSchema);
22785
- if (described.parameterSchema === undefined || !constraints.success)
22786
- return { kind, sizing: builtInModel(modelId).sizing };
22787
- return { kind, sizing: sizingOf(constraints.data) };
22788
- }
22789
23918
  async function lookUpModel(target, http) {
22790
23919
  const types = looksLikeEdit(target.modelId) ? ["inpaint", "image"] : ["image", "inpaint"];
22791
23920
  for (const type of types) {
@@ -22794,41 +23923,48 @@ async function lookUpModel(target, http) {
22794
23923
  throw new Error(list.message);
22795
23924
  const listed = list.models.find((model) => model.id === target.modelId);
22796
23925
  if (listed !== undefined)
22797
- return describeModel(listed, type);
23926
+ return listed.model_spec?.constraints === undefined ? {} : { constraints: listed.model_spec.constraints };
22798
23927
  }
22799
23928
  return;
22800
23929
  }
22801
23930
  function errorMessage(body) {
22802
- const parsed = errorSchema4.safeParse(body);
23931
+ const parsed = errorSchema5.safeParse(body);
22803
23932
  if (!parsed.success)
22804
23933
  return;
22805
23934
  return typeof parsed.data.error === "string" ? parsed.data.error : parsed.data.error.message;
22806
23935
  }
22807
- async function veniceError(response, providerId) {
23936
+ var fieldErrorsSchema = object({ _errors: array(string2()) });
23937
+ function fieldProblems(details) {
23938
+ return Object.entries(details ?? {}).flatMap(([field, value]) => {
23939
+ const parsed = fieldErrorsSchema.safeParse(value);
23940
+ return field === "_errors" || !parsed.success || parsed.data._errors.length === 0 ? [] : [`${field}: ${parsed.data._errors.join(", ")}`];
23941
+ });
23942
+ }
23943
+ async function veniceError(response, providerId, options) {
22808
23944
  const body = await response.json().catch(() => {
22809
23945
  return;
22810
23946
  });
22811
- const parsed = errorSchema4.safeParse(body);
23947
+ const parsed = errorSchema5.safeParse(body);
22812
23948
  const error = parsed.success ? parsed.data : undefined;
22813
23949
  const nested = typeof error?.error === "object" ? error.error : undefined;
22814
- const message = errorMessage(body) ?? (response.statusText || undefined);
23950
+ const problems = fieldProblems(error?.details);
23951
+ const said = errorMessage(body) ?? (response.statusText || undefined);
23952
+ const message = said === undefined ? undefined : problems.length === 0 ? said : `${said}: ${problems.join("; ")}`;
22815
23953
  const suggestion = error?.suggested_prompt === undefined ? "" : ` Venice suggests this prompt instead: ${error.suggested_prompt}`;
22816
23954
  return httpProviderError(providerId, {
22817
23955
  status: response.status,
22818
23956
  codes: [error?.code, nested?.code],
22819
- message: message === undefined ? undefined : `${message}${suggestion}`
23957
+ message: message === undefined ? undefined : `${message}${suggestion}`,
23958
+ options
22820
23959
  });
22821
23960
  }
22822
23961
  var sharedDefaults = { safe_mode: false, enhance_prompt: false };
22823
- async function generate(request, target, model, http) {
22824
- const { size } = request;
22825
- const sizing = model.sizing === "pixels" ? { width: size.width, height: size.height } : { aspect_ratio: size.aspectRatio };
23962
+ async function generate(request, target, http, context) {
22826
23963
  const body = {
22827
23964
  model: target.modelId,
22828
23965
  prompt: request.prompt,
22829
23966
  variants: request.n,
22830
23967
  return_binary: false,
22831
- ...sizing,
22832
23968
  ...sharedDefaults,
22833
23969
  hide_watermark: true,
22834
23970
  format: "png",
@@ -22836,11 +23972,11 @@ async function generate(request, target, model, http) {
22836
23972
  };
22837
23973
  const response = await sendToProvider(http, target.providerId, apiUrl(target.baseUrl, "/image/generate"), {
22838
23974
  method: "POST",
22839
- headers: { ...authorization(target.credential), "content-type": "application/json" },
23975
+ headers: { ...authorization2(target.credential), "content-type": "application/json" },
22840
23976
  body: JSON.stringify(body)
22841
- });
23977
+ }, { context });
22842
23978
  if (!response.ok)
22843
- throw await veniceError(response, target.providerId);
23979
+ throw await veniceError(response, target.providerId, Object.keys(request.providerOptions));
22844
23980
  const parsed = generateSchema.safeParse(await response.json().catch(() => {
22845
23981
  return;
22846
23982
  }));
@@ -22858,51 +23994,44 @@ async function generate(request, target, model, http) {
22858
23994
  }
22859
23995
  return result;
22860
23996
  }
22861
- async function edit(request, target, http) {
23997
+ async function edit(request, target, http, context) {
22862
23998
  const encoded = request.references.map((reference) => Buffer.from(reference.bytes).toString("base64"));
22863
23999
  const [path, inputs] = encoded.length === 1 ? ["/image/edit", { model: target.modelId, image: encoded[0] }] : ["/image/multi-edit", { modelId: target.modelId, images: encoded }];
22864
24000
  const body = {
22865
24001
  ...inputs,
22866
24002
  prompt: request.prompt,
22867
- aspect_ratio: request.size.aspectRatio,
22868
24003
  ...sharedDefaults,
22869
24004
  output_format: "png",
22870
24005
  ...request.providerOptions
22871
24006
  };
22872
24007
  const response = await sendToProvider(http, target.providerId, apiUrl(target.baseUrl, path), {
22873
24008
  method: "POST",
22874
- headers: { ...authorization(target.credential), "content-type": "application/json" },
24009
+ headers: { ...authorization2(target.credential), "content-type": "application/json" },
22875
24010
  body: JSON.stringify(body)
22876
- });
24011
+ }, { context });
22877
24012
  if (!response.ok)
22878
- throw await veniceError(response, target.providerId);
24013
+ throw await veniceError(response, target.providerId, Object.keys(request.providerOptions));
22879
24014
  if (response.headers.get("content-type")?.includes("json")) {
22880
24015
  throw providerError("other", target.providerId, "it answered an edit with JSON rather than an image.");
22881
24016
  }
22882
24017
  return [{ bytes: new Uint8Array(await response.arrayBuffer()) }];
22883
24018
  }
22884
24019
  var venice = {
22885
- builtInCapabilities: (modelId) => builtInModel(modelId).capabilities,
22886
24020
  async fetchModelInfo(target, http) {
22887
24021
  const model = await lookUpModel(target, http);
22888
24022
  if (model === undefined)
22889
24023
  return;
22890
- return {
22891
- ...model.capabilities === undefined ? {} : { capabilities: model.capabilities },
22892
- ...model.constraints === undefined ? {} : { parameterSchema: model.constraints }
22893
- };
24024
+ return model.constraints === undefined ? {} : { parameterSchema: model.constraints };
22894
24025
  },
22895
24026
  docsUrl: (modelId) => looksLikeEdit(modelId) ? editDocs : generateDocs,
22896
- reservedOptions: ["model", "modelId", "prompt", "variants", "return_binary", "width", "height", "aspect_ratio", "image", "images"],
22897
- async submit(request, target, http) {
22898
- const model = describedModel(request.model, target.modelId);
24027
+ parameterGuide: (modelId, modality) => modality === "image" ? veniceGuide(modelId) : undefined,
24028
+ requestFields: { image: { shape: [{ width: "width", height: "height" }, "aspect_ratio"], resolution: "resolution" } },
24029
+ reservedOptions: ["model", "modelId", "prompt", "return_binary", "image", "images"],
24030
+ async submit(request, target, http, context) {
22899
24031
  if (request.references.length > 0) {
22900
- return { wait: () => inParallel(request.n, target.providerId, () => edit(request, target, http)) };
22901
- }
22902
- if (model.kind === "edit") {
22903
- 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.`);
24032
+ return { wait: () => inParallel(request.n, target.providerId, () => edit(request, target, http, context)) };
22904
24033
  }
22905
- const images = await generate(request, target, model, http);
24034
+ const images = await generate(request, target, http, context);
22906
24035
  return { wait: async () => images };
22907
24036
  },
22908
24037
  async testConnection(target, http) {
@@ -22924,9 +24053,10 @@ var venice = {
22924
24053
  // src/protocols/index.ts
22925
24054
  var adapters = {
22926
24055
  "openai-images": openaiImages,
22927
- gemini,
24056
+ gemini: gemini2,
22928
24057
  apimart,
22929
- venice
24058
+ venice,
24059
+ together
22930
24060
  };
22931
24061
  function supportedProtocols() {
22932
24062
  return Object.keys(adapters);
@@ -22945,6 +24075,7 @@ function adapterFor(model) {
22945
24075
  // src/jobs.ts
22946
24076
  var defaultWaitLimitMs = 120000;
22947
24077
  var defaultPollIntervalMs = 3000;
24078
+ var defaultRetryDelayMs = 2000;
22948
24079
 
22949
24080
  class JobFailedError extends Error {
22950
24081
  jobId;
@@ -22961,7 +24092,7 @@ class JobAbandoned extends Error {
22961
24092
  super("litevision shut down while the Job was running.");
22962
24093
  }
22963
24094
  }
22964
- var lostMessage = "litevision stopped before this Job finished, so its result is lost; generate the images again.";
24095
+ var lostMessage = "litevision stopped before this Job finished, so its result is lost; start it again.";
22965
24096
 
22966
24097
  class JobRunner {
22967
24098
  deps;
@@ -22970,37 +24101,23 @@ class JobRunner {
22970
24101
  closed = false;
22971
24102
  waitLimitMs;
22972
24103
  pollIntervalMs;
24104
+ retryDelayMs;
22973
24105
  constructor(deps) {
22974
24106
  this.deps = deps;
22975
24107
  this.waitLimitMs = deps.waitLimitMs ?? defaultWaitLimitMs;
22976
24108
  this.pollIntervalMs = deps.pollIntervalMs ?? defaultPollIntervalMs;
24109
+ this.retryDelayMs = deps.retryDelayMs ?? defaultRetryDelayMs;
22977
24110
  }
22978
24111
  async start(spec) {
22979
24112
  const { request } = spec.normalised;
22980
- const base = {
22981
- type: "job",
22982
- id: randomUUID2(),
22983
- createdAt: this.deps.now().toISOString(),
22984
- model: spec.model.alias,
22985
- provider: spec.provider.id,
22986
- protocol: spec.model.protocol,
22987
- modelId: spec.model.modelId,
22988
- prompt: request.prompt,
22989
- parameters: {
22990
- n: request.n,
22991
- aspectRatio: spec.normalised.requestedAspectRatio,
22992
- size: request.size,
22993
- references: request.references.map((reference) => reference.path),
22994
- ...spec.outputDir === undefined ? {} : { outputPath: spec.outputDir },
22995
- providerOptions: request.providerOptions
22996
- }
22997
- };
22998
- const target = {
22999
- providerId: spec.provider.id,
23000
- baseUrl: spec.provider.baseUrl,
23001
- modelId: spec.model.modelId,
23002
- credential: spec.credential
23003
- };
24113
+ const base = this.baseFor(spec, "image", request.prompt, {
24114
+ n: request.n,
24115
+ requested: spec.normalised.requested,
24116
+ references: request.references.map((reference) => reference.path),
24117
+ ...spec.outputDir === undefined ? {} : { outputPath: spec.outputDir },
24118
+ providerOptions: request.providerOptions
24119
+ });
24120
+ const target = targetOf(spec);
23004
24121
  await this.append(base, "running");
23005
24122
  const job = this.track(base, spec.credential, async (context) => {
23006
24123
  const submitted = await spec.adapter.submit(request, target, this.deps.http, context);
@@ -23010,6 +24127,45 @@ class JobRunner {
23010
24127
  });
23011
24128
  return this.waitFor(base.model, base.id, this.register(base.id, job));
23012
24129
  }
24130
+ async startVideo(spec) {
24131
+ const { request } = spec.normalised;
24132
+ const submitVideo = spec.adapter.submitVideo;
24133
+ if (submitVideo === undefined) {
24134
+ throw new ConfigError(`Model "${spec.model.alias}" uses Protocol "${spec.model.protocol}", which litevision cannot make videos with.`);
24135
+ }
24136
+ const base = this.baseFor(spec, "video", request.prompt, {
24137
+ n: 1,
24138
+ requested: spec.normalised.requested,
24139
+ references: request.references.map((reference) => reference.path),
24140
+ ...request.firstFrame === undefined ? {} : { firstFrame: request.firstFrame.path },
24141
+ ...request.lastFrame === undefined ? {} : { lastFrame: request.lastFrame.path },
24142
+ ...spec.outputDir === undefined ? {} : { outputPath: spec.outputDir },
24143
+ providerOptions: request.providerOptions
24144
+ });
24145
+ const target = targetOf(spec);
24146
+ await this.append(base, "running");
24147
+ const job = this.track(base, spec.credential, async (context) => {
24148
+ const submitted = await submitVideo.call(spec.adapter, request, target, this.deps.http, context);
24149
+ await this.append(base, "running", { resume: submitted.resumeHandle });
24150
+ return videoAsImages(await submitted.wait());
24151
+ });
24152
+ return this.waitFor(base.model, base.id, this.register(base.id, job));
24153
+ }
24154
+ baseFor(spec, modality, prompt, parameters) {
24155
+ return {
24156
+ type: "job",
24157
+ id: randomUUID2(),
24158
+ createdAt: this.deps.now().toISOString(),
24159
+ model: spec.model.alias,
24160
+ provider: spec.provider.id,
24161
+ protocol: spec.model.protocol,
24162
+ modelId: spec.model.modelId,
24163
+ modality,
24164
+ prompt,
24165
+ parameters,
24166
+ ...spec.comparison === undefined ? {} : { comparison: spec.comparison }
24167
+ };
24168
+ }
23013
24169
  async get(id) {
23014
24170
  const record = await readJob(this.deps.dirs, id);
23015
24171
  if (record === undefined) {
@@ -23023,7 +24179,10 @@ class JobRunner {
23023
24179
  id,
23024
24180
  model: record.model,
23025
24181
  files: record.files,
23026
- ...record.shortfall === undefined ? {} : { shortfall: record.shortfall }
24182
+ observed: record.observed ?? record.files.map(() => null),
24183
+ ...record.shortfall === undefined ? {} : { shortfall: record.shortfall },
24184
+ ...record.mismatch === undefined ? {} : { mismatch: record.mismatch },
24185
+ ...record.parameters.requested === undefined ? {} : { requested: record.parameters.requested }
23027
24186
  };
23028
24187
  }
23029
24188
  if (record.outcome === "failed") {
@@ -23040,7 +24199,11 @@ class JobRunner {
23040
24199
  this.abandons.clear();
23041
24200
  }
23042
24201
  context = {
23043
- pause: () => new Promise((resolve, reject) => {
24202
+ pause: () => this.sleep(this.pollIntervalMs),
24203
+ backoff: (retry) => this.sleep(this.retryDelayMs * 2 ** (retry - 1))
24204
+ };
24205
+ sleep(ms) {
24206
+ return new Promise((resolve, reject) => {
23044
24207
  if (this.closed)
23045
24208
  return reject(new JobAbandoned);
23046
24209
  const abandon = () => {
@@ -23050,10 +24213,10 @@ class JobRunner {
23050
24213
  const timer = setTimeout(() => {
23051
24214
  this.abandons.delete(abandon);
23052
24215
  resolve();
23053
- }, this.pollIntervalMs);
24216
+ }, ms);
23054
24217
  this.abandons.add(abandon);
23055
- })
23056
- };
24218
+ });
24219
+ }
23057
24220
  register(id, job) {
23058
24221
  this.jobs.set(id, job);
23059
24222
  job.catch((error) => {
@@ -23082,7 +24245,8 @@ class JobRunner {
23082
24245
  const handle = record.resume;
23083
24246
  const base = baseOf(record);
23084
24247
  const adapter = adapterForProtocol(record.protocol);
23085
- const resume = adapter?.resume;
24248
+ const video = record.modality === "video";
24249
+ const resume = video ? adapter?.resumeVideo : adapter?.resume;
23086
24250
  if (handle === undefined || adapter === undefined || resume === undefined) {
23087
24251
  await this.append(base, "failed", { error: { kind: "other", message: lostMessage } });
23088
24252
  throw new JobFailedError(record.id, "other", lostMessage);
@@ -23093,7 +24257,12 @@ class JobRunner {
23093
24257
  }
23094
24258
  const credential = await requireCredential(this.deps.dirs, provider);
23095
24259
  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());
24260
+ if (video) {
24261
+ const resumeVideo = adapter.resumeVideo;
24262
+ return this.track(base, credential, async (context) => videoAsImages(await resumeVideo.call(adapter, handle, target, this.deps.http, context).wait()));
24263
+ }
24264
+ const resumeImages = adapter.resume;
24265
+ return this.track(base, credential, (context) => resumeImages.call(adapter, handle, target, this.deps.http, context).wait());
23097
24266
  }
23098
24267
  async track(base, credential, run) {
23099
24268
  const safe = (text) => redact(text, credential);
@@ -23104,14 +24273,23 @@ class JobRunner {
23104
24273
  throw new JobAbandoned;
23105
24274
  const startedAt = new Date(base.createdAt);
23106
24275
  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);
24276
+ const saved = await saveFiles(this.deps.http, folder, base.id, startedAt, result.images, base.provider);
23108
24277
  const reasons = [result.shortfall, ...saved.failures.map((failure) => failure.message)].filter((reason) => reason !== undefined);
23109
24278
  if (saved.files.length === 0) {
23110
- throw saved.failures[0] ?? providerError("other", base.provider, reasons[0] ?? "it returned no images.");
24279
+ throw saved.failures[0] ?? providerError("other", base.provider, reasons[0] ?? "it returned nothing.");
23111
24280
  }
24281
+ const observed = saved.facts;
24282
+ const mismatch = mismatchOf(base.parameters, observed);
23112
24283
  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 } };
24284
+ await this.append(base, "completed", { files: saved.files, shortfall, cost: result.cost, observed, mismatch });
24285
+ return {
24286
+ id: base.id,
24287
+ files: saved.files,
24288
+ observed,
24289
+ ...shortfall === undefined ? {} : { shortfall },
24290
+ ...mismatch === undefined ? {} : { mismatch },
24291
+ ...base.parameters.requested === undefined ? {} : { requested: base.parameters.requested }
24292
+ };
23115
24293
  } catch (error) {
23116
24294
  if (error instanceof JobAbandoned || this.closed)
23117
24295
  throw new JobAbandoned;
@@ -23135,18 +24313,57 @@ class JobRunner {
23135
24313
  }
23136
24314
  }
23137
24315
  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 };
24316
+ const { type, id, createdAt, model, provider, protocol, modelId, modality, prompt, parameters, comparison } = record;
24317
+ return { type, id, createdAt, model, provider, protocol, modelId, ...modality === undefined ? {} : { modality }, prompt, parameters, ...comparison === undefined ? {} : { comparison } };
24318
+ }
24319
+ function targetOf(spec) {
24320
+ return { providerId: spec.provider.id, baseUrl: spec.provider.baseUrl, modelId: spec.model.modelId, credential: spec.credential };
24321
+ }
24322
+ function videoAsImages(result) {
24323
+ return { images: [result.video], ...result.cost === undefined ? {} : { cost: result.cost } };
24324
+ }
24325
+ var aspectTolerance = Math.log(1.05);
24326
+ var durationToleranceSeconds = 0.5;
24327
+ function expectedShape(parameters) {
24328
+ if (parameters.requested !== undefined) {
24329
+ const parsed = parameters.requested.shape === null ? undefined : parseShape(parameters.requested.shape);
24330
+ return parsed === undefined ? undefined : { ratio: parsed.ratio, asked: parsed.label };
24331
+ }
24332
+ const { width, height } = parameters.providerOptions;
24333
+ if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) {
24334
+ return { ratio: width / height, asked: `${width}x${height} in provider_options` };
24335
+ }
24336
+ const { size } = parameters;
24337
+ const ratio = size === undefined ? undefined : parseShape(size.aspectRatio);
24338
+ return size === undefined || ratio === undefined ? undefined : { ratio: ratio.ratio, asked: `${size.aspectRatio} (${size.width}x${size.height})` };
24339
+ }
24340
+ function mismatchOf(parameters, observed) {
24341
+ const { followsFirstFrame } = parameters;
24342
+ const seconds = parameters.requested !== undefined ? parameters.requested.seconds ?? undefined : parameters.seconds;
24343
+ const expected = expectedShape(parameters);
24344
+ const problems = new Set;
24345
+ for (const facts of observed) {
24346
+ if (facts === null)
24347
+ continue;
24348
+ if (!followsFirstFrame && expected !== undefined && Math.abs(Math.log(facts.width / facts.height / expected.ratio)) > aspectTolerance) {
24349
+ problems.add(`asked for ${expected.asked}, got ${facts.width}x${facts.height}`);
24350
+ }
24351
+ if (seconds !== undefined && facts.seconds !== undefined && Math.abs(facts.seconds - seconds) > durationToleranceSeconds) {
24352
+ problems.add(`asked for ${seconds} s, got ${facts.seconds} s`);
24353
+ }
24354
+ }
24355
+ return problems.size === 0 ? undefined : [...problems].join("; ");
23140
24356
  }
23141
24357
  function redact(text, credential) {
23142
24358
  return text.split(credential).join("[redacted]");
23143
24359
  }
23144
- async function saveImages(http, folder, jobId, startedAt, images, providerId) {
24360
+ async function saveFiles(http, folder, jobId, startedAt, results, providerId) {
23145
24361
  await mkdir2(folder, { recursive: true });
23146
24362
  const base = `${localTime(startedAt)}-${jobId.slice(0, 8)}`;
23147
24363
  const files = [];
24364
+ const facts = [];
23148
24365
  const failures = [];
23149
- for (const [index, image] of images.entries()) {
24366
+ for (const [index, image] of results.entries()) {
23150
24367
  let bytes;
23151
24368
  try {
23152
24369
  bytes = "bytes" in image ? image.bytes : await download(http, image.url, providerId);
@@ -23156,12 +24373,13 @@ async function saveImages(http, folder, jobId, startedAt, images, providerId) {
23156
24373
  failures.push(error);
23157
24374
  continue;
23158
24375
  }
23159
- const suffix = images.length === 1 ? "" : `-${index + 1}`;
23160
- const file = join4(folder, `${base}${suffix}.${imageTypeOf(bytes)?.extension ?? "bin"}`);
24376
+ const suffix = results.length === 1 ? "" : `-${index + 1}`;
24377
+ const file = join4(folder, `${base}${suffix}.${fileTypeOf(bytes)?.extension ?? "bin"}`);
23161
24378
  await writeFile(file, bytes);
23162
24379
  files.push(file);
24380
+ facts.push(mediaFactsOf(bytes) ?? null);
23163
24381
  }
23164
- return { files, failures };
24382
+ return { files, facts, failures };
23165
24383
  }
23166
24384
  async function download(http, url, providerId) {
23167
24385
  const response = await http(url).catch((error) => {
@@ -23271,21 +24489,21 @@ function isPlainObject3(value) {
23271
24489
  }
23272
24490
 
23273
24491
  // src/model-info.ts
24492
+ function videoModelInfo({ model }) {
24493
+ const adapter = adapterFor(model);
24494
+ if (adapter.submitVideo === undefined) {
24495
+ throw new ConfigError(`Model "${model.alias}" is a video Model, but its Protocol "${model.protocol}" makes no videos in litevision.`);
24496
+ }
24497
+ const guide = adapter.parameterGuide?.(model.modelId, "video");
24498
+ return {
24499
+ docsUrl: adapter.docsUrl(model.modelId, "video"),
24500
+ ...guide === undefined ? {} : { guide }
24501
+ };
24502
+ }
23274
24503
  var modelInfoTtlMs = 24 * 60 * 60 * 1000;
23275
24504
  var defaultModelInfoTimeoutMs = 5000;
23276
- var sizeSchema = object({
23277
- aspectRatio: _enum(aspectRatios),
23278
- width: number2().int().positive(),
23279
- height: number2().int().positive()
23280
- });
23281
- var capabilitiesSchema = object({
23282
- sizes: array(sizeSchema).min(1),
23283
- maxReferences: number2().int().nonnegative(),
23284
- maxImages: number2().int().positive()
23285
- });
23286
24505
  var entrySchema = object({
23287
24506
  fetchedAt: string2(),
23288
- capabilities: capabilitiesSchema.nullable(),
23289
24507
  parameterSchema: unknown().optional()
23290
24508
  });
23291
24509
  var cachePath = (dirs) => join6(dirs.data, "capabilities.json");
@@ -23294,10 +24512,10 @@ function cacheKey({ model, provider }) {
23294
24512
  }
23295
24513
  async function modelInfo(deps, resolved, options = {}) {
23296
24514
  const adapter = adapterFor(resolved.model);
24515
+ const guide = adapter.parameterGuide?.(resolved.model.modelId, "image");
23297
24516
  const builtIn = {
23298
- capabilities: adapter.builtInCapabilities(resolved.model.modelId),
23299
- source: "built-in",
23300
- docsUrl: adapter.docsUrl(resolved.model.modelId)
24517
+ docsUrl: adapter.docsUrl(resolved.model.modelId, "image"),
24518
+ ...guide === undefined ? {} : { guide }
23301
24519
  };
23302
24520
  const fetchModelInfo = adapter.fetchModelInfo;
23303
24521
  if (fetchModelInfo === undefined)
@@ -23322,13 +24540,9 @@ async function modelInfo(deps, resolved, options = {}) {
23322
24540
  entry = cached;
23323
24541
  }
23324
24542
  }
23325
- if (entry === undefined)
24543
+ if (entry?.parameterSchema === undefined)
23326
24544
  return builtIn;
23327
- return {
23328
- ...builtIn,
23329
- ...entry.capabilities === null ? {} : { capabilities: entry.capabilities, source: "provider" },
23330
- ...entry.parameterSchema === undefined ? {} : { parameterSchema: entry.parameterSchema }
23331
- };
24545
+ return { ...builtIn, parameterSchema: entry.parameterSchema };
23332
24546
  }
23333
24547
  async function refreshModelInfo(deps, alias) {
23334
24548
  await refreshMatching(deps, (resolved) => resolved.model.alias === alias);
@@ -23349,12 +24563,7 @@ async function refreshMatching(deps, wanted) {
23349
24563
  } catch {}
23350
24564
  }
23351
24565
  function toEntry(info, now) {
23352
- const capabilities = capabilitiesSchema.safeParse(info?.capabilities);
23353
- return {
23354
- fetchedAt: now.toISOString(),
23355
- capabilities: capabilities.success ? capabilities.data : null,
23356
- ...info?.parameterSchema === undefined ? {} : { parameterSchema: info.parameterSchema }
23357
- };
24566
+ return { fetchedAt: now.toISOString(), ...info?.parameterSchema === undefined ? {} : { parameterSchema: info.parameterSchema } };
23358
24567
  }
23359
24568
  async function readCache(dirs) {
23360
24569
  let value;
@@ -23427,7 +24636,7 @@ function updateNotesAsAgent(dirs, alias, text, at) {
23427
24636
  throw needsSettings(`The Models in config.json are invalid: ${prettifyError(parsed.error)}`);
23428
24637
  const stored = models;
23429
24638
  if (stored.length === 0)
23430
- throw needsSettings("litevision has no image Model configured yet.");
24639
+ throw needsSettings("litevision has no Model configured yet.");
23431
24640
  const model = stored.find((candidate) => candidate.alias === alias);
23432
24641
  if (model === undefined) {
23433
24642
  throw new ConfigError(`Unknown Model Alias "${alias}". Known Aliases: ${stored.map((candidate) => candidate.alias).join(", ")}.`);
@@ -23438,60 +24647,85 @@ function updateNotesAsAgent(dirs, alias, text, at) {
23438
24647
  });
23439
24648
  }
23440
24649
 
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);
24650
+ // src/protocols/requests.ts
24651
+ function reservedConflicts(options, reservedFields) {
24652
+ const conflictOf = (field) => {
24653
+ const [parent, child] = field.split(".");
24654
+ if (!Object.hasOwn(options, parent))
24655
+ return;
24656
+ const value = options[parent];
24657
+ if (child === undefined)
24658
+ return `\`${field}\``;
24659
+ if (!isPlainObject3(value))
24660
+ return `\`${parent}\` as anything but an object (litevision sets \`${field}\` in it)`;
24661
+ return Object.hasOwn(value, child) ? `\`${field}\`` : undefined;
24662
+ };
24663
+ const order = Object.keys(options);
24664
+ return [...reservedFields].sort((a, b) => order.indexOf(a.split(".")[0]) - order.indexOf(b.split(".")[0])).map(conflictOf).filter((conflict) => conflict !== undefined);
24665
+ }
24666
+ function refuseReserved(providerOptions, reservedFields, fromWhat) {
24667
+ const conflicts = reservedConflicts(providerOptions, reservedFields);
24668
+ if (conflicts.length === 0)
24669
+ return;
24670
+ throw new ConfigError(`provider_options, and the Model's Standing Options they are merged over, may not set ${conflicts.join(", ")}: litevision fills ${conflicts.length === 1 ? "that field" : "those fields"} itself, from ${fromWhat}. Use those instead.`);
24671
+ }
24672
+ async function normaliseImageRequest(parameters, model, adapter, cwd) {
24673
+ refuseReserved(parameters.providerOptions, adapter.reservedOptions, "the prompt, `references` and `model`");
23480
24674
  const references = await readReferences(parameters.references, cwd);
24675
+ return {
24676
+ request: { prompt: parameters.prompt, n: parameters.n, references, providerOptions: parameters.providerOptions },
24677
+ requested: readRequested(fieldsToRead(adapter, model.modelId, "image"), parameters.providerOptions, "image")
24678
+ };
24679
+ }
24680
+ async function normaliseVideoRequest(parameters, model, adapter, cwd) {
24681
+ refuseReserved(parameters.providerOptions, adapter.reservedVideoOptions ?? [], "the prompt, `first_frame`, `last_frame`, `references` and `model`");
24682
+ const frame = async (path) => path === undefined ? undefined : (await readReferences([path], cwd))[0];
24683
+ const [firstFrame, lastFrame, references] = await Promise.all([
24684
+ frame(parameters.firstFrame),
24685
+ frame(parameters.lastFrame),
24686
+ readReferences(parameters.references, cwd)
24687
+ ]);
23481
24688
  return {
23482
24689
  request: {
23483
24690
  prompt: parameters.prompt,
23484
- n: parameters.n,
23485
- size,
24691
+ ...firstFrame === undefined ? {} : { firstFrame },
24692
+ ...lastFrame === undefined ? {} : { lastFrame },
23486
24693
  references,
23487
- providerOptions: parameters.providerOptions,
23488
- model: { capabilities, ...described.parameterSchema === undefined ? {} : { parameterSchema: described.parameterSchema } }
24694
+ providerOptions: parameters.providerOptions
23489
24695
  },
23490
- requestedAspectRatio: parameters.aspectRatio,
23491
- aspectRatioAdjusted: adjusted
24696
+ requested: readRequested(fieldsToRead(adapter, model.modelId, "video"), parameters.providerOptions, "video")
23492
24697
  };
23493
24698
  }
23494
24699
 
24700
+ // src/standing-options.ts
24701
+ function mergeOptions(standing, agent) {
24702
+ const merged = { ...standing };
24703
+ for (const [key, value] of Object.entries(agent)) {
24704
+ if (value === null)
24705
+ delete merged[key];
24706
+ else if (isPlainObject3(value))
24707
+ merged[key] = mergeOptions(isPlainObject3(merged[key]) ? merged[key] : {}, value);
24708
+ else
24709
+ merged[key] = value;
24710
+ }
24711
+ return merged;
24712
+ }
24713
+ function reservedFields(protocol, modality) {
24714
+ const adapter = adapterForProtocol(protocol);
24715
+ if (adapter === undefined)
24716
+ return [];
24717
+ return modality === "video" ? adapter.reservedVideoOptions ?? [] : adapter.reservedOptions;
24718
+ }
24719
+ function standingOptionsProblem(value, protocol, modality) {
24720
+ if (!isPlainObject3(value)) {
24721
+ return 'Standing Options must be a JSON object of request fields, such as { "quality": "high" }.';
24722
+ }
24723
+ const conflicts = reservedConflicts(value, reservedFields(protocol, modality));
24724
+ if (conflicts.length === 0)
24725
+ return;
24726
+ return `Standing Options may not set ${conflicts.join(", ")}: litevision fills ${conflicts.length === 1 ? "that field" : "those fields"} itself for Protocol "${protocol}".`;
24727
+ }
24728
+
23495
24729
  // src/settings/browser.ts
23496
24730
  import { spawn } from "node:child_process";
23497
24731
  var openInBrowser = (url) => {
@@ -23522,7 +24756,8 @@ var providerPresets = [
23522
24756
  { id: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1", protocol: "openai-images" },
23523
24757
  { id: "gemini", label: "Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta", protocol: "gemini" },
23524
24758
  { 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" }
24759
+ { id: "apimart", label: "APIMart", baseUrl: "https://api.apimart.ai/v1", protocol: "apimart" },
24760
+ { id: "together", label: "Together AI", baseUrl: "https://api.together.ai", protocol: "together" }
23526
24761
  ];
23527
24762
  function shortName(what) {
23528
24763
  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 +24867,7 @@ async function testConnection(dirs, http, providerId, protocol) {
23632
24867
  }
23633
24868
  return { result: outcome.ok ? "success" : "failure", message: call.redact(outcome.message) };
23634
24869
  }
23635
- async function fetchModelIds4(dirs, http, providerId, protocol) {
24870
+ async function fetchModelIds5(dirs, http, providerId, protocol) {
23636
24871
  const typeInstead = "You can still type the model id.";
23637
24872
  const call = await prepare(dirs, providerId, protocol, (adapter) => adapter.listModelIds);
23638
24873
  if (call === "unsupported") {
@@ -23644,7 +24879,7 @@ async function fetchModelIds4(dirs, http, providerId, protocol) {
23644
24879
  try {
23645
24880
  const list = await call.run(http);
23646
24881
  if (list.ok)
23647
- return { result: "success", modelIds: list.modelIds };
24882
+ return { result: "success", modelIds: list.modelIds, ...list.modalities === undefined ? {} : { modalities: list.modalities } };
23648
24883
  message = list.message;
23649
24884
  } catch (error) {
23650
24885
  message = unreachable(providerId, error);
@@ -23675,24 +24910,69 @@ function unreachable(providerId, error) {
23675
24910
  // src/settings/job-history.ts
23676
24911
  import { readFile as readFile6 } from "node:fs/promises";
23677
24912
  var rateJobInput = object({
24913
+ 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
24914
  verdict: _enum(verdicts, { message: "Choose good or bad." }),
23679
24915
  reason: string2().max(ratingReasonMaxLength, `Keep the reason to at most ${ratingReasonMaxLength} characters.`)
23680
24916
  });
24917
+ var pickInput = object({
24918
+ jobId: string2({ message: "Choose one of the Comparison's Jobs." }).min(1, "Choose one of the Comparison's Jobs."),
24919
+ result: number2({ message: "Choose a result by its number." }).int("Choose a result by its number.").positive("Choose a result by its number.").optional()
24920
+ });
23681
24921
  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
- }));
24922
+ const listed = (await readRatedJobs(dirs)).map((rated) => {
24923
+ const { job, pick } = rated;
24924
+ const listedRatingOf = (result) => {
24925
+ const current = ratingOf(rated, result);
24926
+ return current === undefined ? null : listedRating(current);
24927
+ };
24928
+ return {
24929
+ id: job.id,
24930
+ createdAt: job.createdAt,
24931
+ model: job.model,
24932
+ modelId: job.modelId,
24933
+ provider: job.provider,
24934
+ prompt: job.prompt,
24935
+ durationMs: job.durationMs,
24936
+ outcome: job.outcome,
24937
+ modality: job.modality ?? "image",
24938
+ files: job.files.map((path, index) => ({
24939
+ path,
24940
+ thumbnail: `/api/jobs/${encodeURIComponent(job.id)}/files/${index}`,
24941
+ rating: listedRatingOf(index + 1)
24942
+ })),
24943
+ rating: listedRatingOf(),
24944
+ ...job.error === undefined ? {} : { error: job.error.message },
24945
+ ...job.shortfall === undefined ? {} : { shortfall: job.shortfall },
24946
+ ...job.mismatch === undefined ? {} : { mismatch: `The result does not match the request: ${job.mismatch}.` },
24947
+ ...job.comparison === undefined ? {} : { comparison: { id: job.comparison, pick: pick === undefined ? null : listedPick(pick) } }
24948
+ };
24949
+ });
24950
+ const byComparison = new Map;
24951
+ for (const job of listed) {
24952
+ if (job.comparison === undefined)
24953
+ continue;
24954
+ const ofComparison = byComparison.get(job.comparison.id) ?? [];
24955
+ byComparison.set(job.comparison.id, ofComparison);
24956
+ ofComparison.unshift(job);
24957
+ }
24958
+ return listed.flatMap((job) => {
24959
+ if (job.comparison === undefined)
24960
+ return [job];
24961
+ const ofComparison = byComparison.get(job.comparison.id);
24962
+ if (ofComparison === undefined)
24963
+ return [];
24964
+ byComparison.delete(job.comparison.id);
24965
+ return ofComparison;
24966
+ });
24967
+ }
24968
+ async function pickFromPage(dirs, comparison, input, now) {
24969
+ try {
24970
+ return listedPick(await pickResult(dirs, { comparison, ...input }, now));
24971
+ } catch (error) {
24972
+ if (!(error instanceof PickError))
24973
+ throw error;
24974
+ throw new SettingsError({ "unknown-comparison": 404, "not-in-comparison": 400, "not-completed": 409, invalid: 400 }[error.kind], error.message);
24975
+ }
23696
24976
  }
23697
24977
  async function rateJobFromPage(dirs, jobId, input, now) {
23698
24978
  try {
@@ -23707,18 +24987,21 @@ async function jobImage(dirs, jobId, index) {
23707
24987
  const job = await readJob(dirs, jobId);
23708
24988
  const path = /^\d+$/.test(index) ? job?.files[Number(index)] : undefined;
23709
24989
  if (path === undefined)
23710
- throw new SettingsError(404, "No such image in the Job history.");
24990
+ throw new SettingsError(404, "No such file in the Job history.");
23711
24991
  let bytes;
23712
24992
  try {
23713
24993
  bytes = await readFile6(path);
23714
24994
  } catch {
23715
- throw new SettingsError(404, "The image is no longer on disk.");
24995
+ throw new SettingsError(404, "The file is no longer on disk.");
23716
24996
  }
23717
- const type = imageTypeOf(bytes);
24997
+ const type = fileTypeOf(bytes);
23718
24998
  if (type === undefined)
23719
- throw new SettingsError(404, "The file is no longer an image.");
24999
+ throw new SettingsError(404, "The file is no longer an image or a video.");
23720
25000
  return { bytes, contentType: type.mimeType };
23721
25001
  }
25002
+ function listedPick(pick) {
25003
+ return { jobId: pick.jobId, ...pick.result === undefined ? {} : { result: pick.result }, pickedAt: pick.pickedAt };
25004
+ }
23722
25005
  function listedRating(rating) {
23723
25006
  return { verdict: rating.verdict, reason: rating.reason, ratedAt: rating.ratedAt };
23724
25007
  }
@@ -23731,14 +25014,19 @@ var addModelInput = object({
23731
25014
  provider: string2().min(1, "Choose a Provider."),
23732
25015
  modelId: string2().trim().min(1, "Enter a model id.").max(200, "A model id is at most 200 characters."),
23733
25016
  protocol: string2().refine((value) => supportedProtocols().includes(value), { error: () => `Choose a supported Protocol: ${supportedProtocols().join(", ")}.` }),
23734
- notes: notes.optional()
25017
+ modality: _enum(modalities, { error: () => "Choose whether the Model makes images or videos." }).default("image"),
25018
+ notes: notes.optional(),
25019
+ standingOptions: unknown().optional()
23735
25020
  });
23736
- var updateModelInput = addModelInput.partial();
25021
+ var updateModelInput = addModelInput.omit({ modality: true }).partial().extend({ modality: addModelInput.shape.modality.unwrap().optional() });
23737
25022
  var storedModels = array(modelSchema);
23738
25023
  async function listModels2(dirs) {
23739
25024
  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 };
25025
+ const defaults = {
25026
+ defaultImageModel: typeof config.defaultImageModel === "string" ? config.defaultImageModel : null,
25027
+ defaultVideoModel: typeof config.defaultVideoModel === "string" ? config.defaultVideoModel : null
25028
+ };
25029
+ return { models: modelsOf(config).map((model) => view2(model, defaults)), ...defaults };
23742
25030
  }
23743
25031
  async function getModel(dirs, wanted) {
23744
25032
  const model = (await listModels2(dirs)).models.find((candidate) => candidate.alias === wanted);
@@ -23751,11 +25039,19 @@ async function addModel(dirs, input, at) {
23751
25039
  const models = modelsOf(config);
23752
25040
  requireFreeAlias(models, input.alias);
23753
25041
  requireProvider(config, input.provider);
23754
- const stored = { alias: input.alias, provider: input.provider, modelId: input.modelId, protocol: input.protocol };
25042
+ const stored = {
25043
+ alias: input.alias,
25044
+ provider: input.provider,
25045
+ modelId: input.modelId,
25046
+ protocol: input.protocol,
25047
+ modality: input.modality
25048
+ };
23755
25049
  if (input.notes) {
23756
25050
  stored.notes = input.notes;
23757
25051
  stored.notesHistory = { changedBy: "User", changedAt: at.toISOString() };
23758
25052
  }
25053
+ if (input.standingOptions !== undefined)
25054
+ setStandingOptions(stored, input.standingOptions);
23759
25055
  config.models = [...models, stored];
23760
25056
  });
23761
25057
  return getModel(dirs, input.alias);
@@ -23769,8 +25065,14 @@ async function updateModel(dirs, wanted, input, at) {
23769
25065
  if (input.alias !== undefined && input.alias !== wanted) {
23770
25066
  requireFreeAlias(models, input.alias);
23771
25067
  model.alias = input.alias;
23772
- if (config.defaultImageModel === wanted)
23773
- config.defaultImageModel = input.alias;
25068
+ for (const key of Object.values(defaultKey))
25069
+ if (config[key] === wanted)
25070
+ config[key] = input.alias;
25071
+ }
25072
+ if (input.modality !== undefined && input.modality !== modalityOf(model)) {
25073
+ if (config[defaultKey[modalityOf(model)]] === model.alias)
25074
+ delete config[defaultKey[modalityOf(model)]];
25075
+ model.modality = input.modality;
23774
25076
  }
23775
25077
  if (input.provider !== undefined) {
23776
25078
  requireProvider(config, input.provider);
@@ -23782,6 +25084,8 @@ async function updateModel(dirs, wanted, input, at) {
23782
25084
  model.protocol = input.protocol;
23783
25085
  if (input.notes !== undefined)
23784
25086
  replaceNotes(model, input.notes, "User", at);
25087
+ if (input.standingOptions !== undefined)
25088
+ setStandingOptions(model, input.standingOptions);
23785
25089
  });
23786
25090
  return getModel(dirs, input.alias ?? wanted);
23787
25091
  }
@@ -23801,22 +25105,38 @@ async function removeModel(dirs, wanted) {
23801
25105
  if (!models.some((model) => model.alias === wanted))
23802
25106
  throw notFound2(wanted);
23803
25107
  config.models = models.filter((model) => model.alias !== wanted);
23804
- if (config.defaultImageModel === wanted)
23805
- delete config.defaultImageModel;
25108
+ for (const key of Object.values(defaultKey))
25109
+ if (config[key] === wanted)
25110
+ delete config[key];
23806
25111
  });
23807
25112
  }
23808
25113
  var setDefaultInput = object({ alias: string2().nullable() });
23809
- async function setDefaultImageModel(dirs, wanted) {
25114
+ async function setDefaultModel(dirs, modality, wanted) {
25115
+ const key = defaultKey[modality];
23810
25116
  await updateConfig(dirs, (config) => {
23811
25117
  if (wanted === null) {
23812
- delete config.defaultImageModel;
25118
+ delete config[key];
23813
25119
  return;
23814
25120
  }
23815
- if (!modelsOf(config).some((model) => model.alias === wanted))
25121
+ const model = modelsOf(config).find((candidate) => candidate.alias === wanted);
25122
+ if (model === undefined)
23816
25123
  throw notFound2(wanted);
23817
- config.defaultImageModel = wanted;
25124
+ if (modalityOf(model) !== modality) {
25125
+ throw new SettingsError(400, `"${wanted}" is ${modalityOf(model) === "image" ? "an image" : "a video"} Model, so it cannot be the default ${modality} Model.`);
25126
+ }
25127
+ config[key] = wanted;
23818
25128
  });
23819
- return { defaultImageModel: wanted };
25129
+ return { alias: wanted };
25130
+ }
25131
+ function setStandingOptions(model, value) {
25132
+ if (value === null || isPlainObject3(value) && Object.keys(value).length === 0) {
25133
+ delete model.standingOptions;
25134
+ return;
25135
+ }
25136
+ const problem = standingOptionsProblem(value, model.protocol, modalityOf(model));
25137
+ if (problem !== undefined)
25138
+ throw new SettingsError(400, problem);
25139
+ model.standingOptions = value;
23820
25140
  }
23821
25141
  function requireFreeAlias(models, wanted) {
23822
25142
  if (models.some((model) => model.alias === wanted)) {
@@ -23837,15 +25157,21 @@ function modelsOf(config) {
23837
25157
  throw new SettingsError(409, `The Models in config.json are invalid: ${prettifyError(parsed.error)}`);
23838
25158
  return config.models;
23839
25159
  }
23840
- function view2(model, defaultImageModel) {
25160
+ function modalityOf(model) {
25161
+ return model.modality ?? "image";
25162
+ }
25163
+ function view2(model, defaults) {
25164
+ const modality = modalityOf(model);
23841
25165
  return {
23842
25166
  alias: model.alias,
23843
25167
  provider: model.provider,
23844
25168
  modelId: model.modelId,
23845
25169
  protocol: model.protocol,
25170
+ modality,
23846
25171
  notes: model.notes ?? "",
23847
25172
  ...model.notesHistory && { notesHistory: model.notesHistory },
23848
- default: model.alias === defaultImageModel
25173
+ ...model.standingOptions && { standingOptions: model.standingOptions },
25174
+ default: model.alias === (modality === "video" ? defaults.defaultVideoModel : defaults.defaultImageModel)
23849
25175
  };
23850
25176
  }
23851
25177
  function notFound2(wanted) {
@@ -23910,7 +25236,7 @@ var routes = [
23910
25236
  path: "/api/providers/:id/model-ids",
23911
25237
  handler: async ({ dirs, http, params, body }) => {
23912
25238
  const { protocol } = parse6(protocolInput, body);
23913
- return { body: await fetchModelIds4(dirs, http, params.id, protocol) };
25239
+ return { body: await fetchModelIds5(dirs, http, params.id, protocol) };
23914
25240
  }
23915
25241
  },
23916
25242
  {
@@ -23957,7 +25283,16 @@ var routes = [
23957
25283
  {
23958
25284
  method: "PUT",
23959
25285
  path: "/api/default-image-model",
23960
- handler: async ({ dirs, body }) => ({ body: await setDefaultImageModel(dirs, parse6(setDefaultInput, body).alias) })
25286
+ handler: async ({ dirs, body }) => ({
25287
+ body: { defaultImageModel: (await setDefaultModel(dirs, "image", parse6(setDefaultInput, body).alias)).alias }
25288
+ })
25289
+ },
25290
+ {
25291
+ method: "PUT",
25292
+ path: "/api/default-video-model",
25293
+ handler: async ({ dirs, body }) => ({
25294
+ body: { defaultVideoModel: (await setDefaultModel(dirs, "video", parse6(setDefaultInput, body).alias)).alias }
25295
+ })
23961
25296
  },
23962
25297
  {
23963
25298
  method: "GET",
@@ -23971,6 +25306,13 @@ var routes = [
23971
25306
  body: { rating: await rateJobFromPage(dirs, params.id, parse6(rateJobInput, body), now()) }
23972
25307
  })
23973
25308
  },
25309
+ {
25310
+ method: "POST",
25311
+ path: "/api/comparisons/:id/pick",
25312
+ handler: async ({ dirs, now, params, body }) => ({
25313
+ body: { pick: await pickFromPage(dirs, params.id, parse6(pickInput, body), now()) }
25314
+ })
25315
+ },
23974
25316
  {
23975
25317
  method: "GET",
23976
25318
  path: "/api/jobs/:id/files/:index",
@@ -24119,11 +25461,20 @@ function renderPage(nonce) {
24119
25461
  .prompt { white-space: pre-wrap; overflow-wrap: anywhere; }
24120
25462
  .thumbs { display: flex; flex-wrap: wrap; gap: 8px; }
24121
25463
  .thumb { width: 96px; height: 96px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); }
25464
+ .thumb.video { width: 192px; height: 108px; }
24122
25465
  .thumb.placeholder { display: flex; align-items: center; justify-content: center; text-align: center; padding: 6px;
24123
25466
  font-size: 11px; color: var(--muted); border-style: dashed; }
25467
+ .result-thumb { margin: 0; display: grid; gap: 4px; width: min-content; }
25468
+ .result-thumb figcaption { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
24124
25469
  .actions.rate { display: grid; gap: 10px; }
25470
+ .inline > select.rate-target { flex: 0 0 auto; }
24125
25471
  .rating { font-size: 13px; }
24126
25472
  .rating.good { color: var(--ok); } .rating.bad { color: var(--danger); }
25473
+ .comparison { display: grid; gap: 12px; border: 1px solid var(--border); border-left: 3px solid var(--accent);
25474
+ border-radius: 12px; padding: 16px; margin-bottom: 12px; }
25475
+ .comparison > .card { margin-bottom: 0; }
25476
+ .comparison-head p { font-size: 13px; color: var(--muted); margin-top: 2px; }
25477
+ .picked { color: var(--accent); font-weight: 500; }
24127
25478
 
24128
25479
  #status { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); max-width: calc(100% - 32px);
24129
25480
  background: var(--text); color: var(--bg); padding: 8px 14px; border-radius: 8px; font-size: 13px; display: none;
@@ -24166,6 +25517,7 @@ function renderPage(nonce) {
24166
25517
  <div class="fields">
24167
25518
  <div class="field"><label for="model-provider">Provider</label><select id="model-provider" required></select></div>
24168
25519
  <div class="field"><label for="model-protocol">Protocol</label><select id="model-protocol" required></select></div>
25520
+ <div class="field"><label for="model-modality">Makes</label><select id="model-modality" required><option value="image">Images</option><option value="video">Videos</option></select></div>
24169
25521
  <div class="field wide">
24170
25522
  <label for="model-id">Model id</label>
24171
25523
  <div class="inline"><input id="model-id" required spellcheck="false" list="model-ids" placeholder="gpt-image-1"><button id="fetch-models" type="button">Fetch model list</button></div>
@@ -24182,7 +25534,7 @@ function renderPage(nonce) {
24182
25534
  <section id="history">
24183
25535
  <div class="section-head">
24184
25536
  <h2>Job history</h2>
24185
- <p>Newest first. Rate a Job to keep your verdict on it; rating it again replaces the earlier Rating.</p>
25537
+ <p>Newest first. Rate a Job, or one of its results, to keep your verdict on it; rating it again replaces the earlier Rating. In a Comparison, pick the result you prefer; picking again replaces the earlier Pick.</p>
24186
25538
  </div>
24187
25539
  <div id="job-list"><p class="muted">Loading…</p></div>
24188
25540
  </section>
@@ -24390,25 +25742,52 @@ function notesHistoryRow(model, path) {
24390
25742
  return [el("div", { className: "note" }, ...parts)];
24391
25743
  }
24392
25744
 
25745
+ /** A JSON textarea for a Model's Standing Options: request fields every Job on it sends unless the Agent overrides them. */
25746
+ function standingOptionsField(value) {
25747
+ const textarea = el("textarea", {
25748
+ value: value ? JSON.stringify(value, null, 2) : "",
25749
+ spellcheck: false,
25750
+ placeholder: '{ "quality": "high" }',
25751
+ });
25752
+ const wrapper = field("Standing Options", textarea);
25753
+ wrapper.classList.add("wide");
25754
+ wrapper.append(el("div", { className: "note", textContent: "A JSON object of the Provider's own request fields, sent with every Job on this Model. Your Agent's provider_options override them for a Job. Leave empty for none." }));
25755
+ /** The value to save: null when empty; throws when the text is not a JSON object. */
25756
+ const read = () => {
25757
+ const text = textarea.value.trim();
25758
+ if (text === "") return null;
25759
+ let parsed;
25760
+ try {
25761
+ parsed = JSON.parse(text);
25762
+ } catch {
25763
+ throw new Error('Standing Options must be a JSON object, such as { "quality": "high" }. This is not valid JSON: ' + text.slice(0, 40));
25764
+ }
25765
+ return parsed;
25766
+ };
25767
+ return { wrapper, read };
25768
+ }
25769
+
24393
25770
  function modelCard(model) {
24394
25771
  const alias = el("input", { value: model.alias, spellcheck: false });
24395
25772
  const provider = el("select", {}, ...options(providerIds.includes(model.provider) ? providerIds : [model.provider, ...providerIds], model.provider));
24396
25773
  const modelId = el("input", { value: model.modelId, spellcheck: false });
24397
25774
  const protocol = el("select", {}, ...options(protocols.includes(model.protocol) ? protocols : [model.protocol, ...protocols], model.protocol));
25775
+ const modality = el("select", {}, ...options(["image", "video"], model.modality));
24398
25776
  const notes = notesField(model.notes);
25777
+ const standing = standingOptionsField(model.standingOptions);
24399
25778
  const path = "/api/models/" + encodeURIComponent(model.alias);
24400
25779
 
24401
25780
  const save = el("button", { type: "button", className: "primary", textContent: "Save" });
24402
25781
  save.onclick = () => act(save, async () => {
24403
- await api("PATCH", path, { alias: alias.value, provider: provider.value, modelId: modelId.value, protocol: protocol.value, notes: notes.textarea.value });
25782
+ await api("PATCH", path, { alias: alias.value, provider: provider.value, modelId: modelId.value, protocol: protocol.value, modality: modality.value, notes: notes.textarea.value, standingOptions: standing.read() });
24404
25783
  await loadModels();
24405
25784
  }, "Model saved.");
24406
25785
 
24407
25786
  const makeDefault = el("button", { type: "button", className: "small", textContent: "Make default" });
24408
25787
  makeDefault.onclick = () => act(makeDefault, async () => {
24409
- await api("PUT", "/api/default-image-model", { alias: model.alias });
25788
+ await api("PUT", "/api/default-" + model.modality + "-model", { alias: model.alias });
24410
25789
  await loadModels();
24411
- }, model.alias + " is now the default image Model.");
25790
+ }, model.alias + " is now the default " + model.modality + " Model.");
24412
25791
 
24413
25792
  const remove = el("button", { type: "button", className: "danger", textContent: "Remove Model" });
24414
25793
  remove.onclick = () => {
@@ -24423,16 +25802,23 @@ function modelCard(model) {
24423
25802
 
24424
25803
  return el("div", { className: "card" },
24425
25804
  el("div", { className: "card-head" }, el("h3", { textContent: model.alias }),
24426
- model.default ? el("span", { className: "chip accent", textContent: "Default" }) : makeDefault),
25805
+ model.default ? el("span", { className: "chip accent", textContent: "Default " + model.modality }) : makeDefault),
24427
25806
  el("div", { className: "fields" },
24428
- field("Alias", alias), field("Provider", provider), field("Model id", modelId), field("Protocol", protocol), notes.wrapper),
25807
+ field("Alias", alias), field("Provider", provider), field("Model id", modelId), field("Protocol", protocol), field("Makes", modality), notes.wrapper, standing.wrapper),
24429
25808
  el("div", { className: "actions" }, save, el("span", { className: "spacer" }), remove),
24430
25809
  );
24431
25810
  }
24432
25811
 
25812
+ // The saved Models, so the fetched model list can say which model ids are already added.
25813
+ let savedModels = [];
25814
+ // Redraws the fetched model list; set by setUpAddModelForm.
25815
+ let showModelIds = () => {};
25816
+
24433
25817
  async function loadModels() {
24434
25818
  const list = document.getElementById("model-list");
24435
25819
  const { models } = await api("GET", "/api/models");
25820
+ savedModels = models;
25821
+ showModelIds();
24436
25822
  list.replaceChildren(...(models.length === 0
24437
25823
  ? [emptyState(providerIds.length === 0 ? "Add a Provider first, then add its Models below." : "No Models yet. Add one below.")]
24438
25824
  : models.map(modelCard)));
@@ -24447,27 +25833,57 @@ function setUpAddModelForm() {
24447
25833
  const fetchButton = document.getElementById("fetch-models");
24448
25834
  const fetchResult = document.getElementById("fetch-result");
24449
25835
  const ids = document.getElementById("model-ids");
25836
+ const modality = document.getElementById("model-modality");
25837
+ // The model ids the last fetch found, and what each one makes when the Provider says.
25838
+ let fetchedIds = [];
25839
+ let knownModalities = {};
24450
25840
  protocol.replaceChildren(...options(protocols));
24451
25841
 
25842
+ const forgetModelIds = () => {
25843
+ fetchedIds = [];
25844
+ knownModalities = {};
25845
+ showModelIds();
25846
+ };
25847
+
25848
+ // Lists the fetched model ids that make what Makes says (and those the Provider says nothing about),
25849
+ // marking the ones already added as a Model on this Provider.
25850
+ showModelIds = () => {
25851
+ const shown = fetchedIds.filter((id) => (knownModalities[id] ?? modality.value) === modality.value);
25852
+ ids.replaceChildren(...shown.map((id) => {
25853
+ const aliases = savedModels.filter((m) => m.provider === provider.value && m.modelId === id).map((m) => m.alias);
25854
+ return el("option", aliases.length === 0 ? { value: id } : { value: id, label: "Already added as " + aliases.join(", ") });
25855
+ }));
25856
+ if (fetchedIds.length === 0) return;
25857
+ const hidden = fetchedIds.length - shown.length;
25858
+ const other = modality.value === "image" ? "video" : "image";
25859
+ fetchResult.textContent = shown.length + (hidden === 0 ? "" : " " + modality.value) + " model id(s) found"
25860
+ + (hidden === 0 ? "" : ", and " + hidden + " " + other + " model id(s) hidden until Makes says " + (other === "image" ? "Images" : "Videos"))
25861
+ + ". Pick one in the Model id field, or type your own.";
25862
+ };
25863
+
24452
25864
  // Suggest the Protocol a preset Provider's Models usually speak; the User can still change it.
24453
25865
  provider.onchange = () => {
24454
25866
  const suggested = presets.find((p) => p.id === provider.value);
24455
25867
  if (suggested && protocols.includes(suggested.protocol)) protocol.value = suggested.protocol;
24456
- ids.replaceChildren();
25868
+ forgetModelIds();
24457
25869
  fetchResult.textContent = "";
24458
25870
  };
24459
- protocol.onchange = () => ids.replaceChildren();
25871
+ protocol.onchange = forgetModelIds;
25872
+ modality.onchange = () => showModelIds();
24460
25873
 
24461
25874
  fetchButton.onclick = () => act(fetchButton, async () => {
24462
25875
  fetchResult.textContent = "Fetching…";
24463
25876
  fetchResult.className = "result";
24464
25877
  const outcome = await api("POST", "/api/providers/" + encodeURIComponent(provider.value) + "/model-ids", { protocol: protocol.value });
24465
25878
  if (outcome.result === "success") {
24466
- ids.replaceChildren(...outcome.modelIds.map((id) => el("option", { value: id })));
24467
- fetchResult.textContent = outcome.modelIds.length + " model id(s) found. Pick one in the Model id field, or type your own.";
25879
+ fetchedIds = outcome.modelIds;
25880
+ knownModalities = outcome.modalities || {};
24468
25881
  fetchResult.className = "result success";
25882
+ showModelIds();
25883
+ if (fetchedIds.length === 0) fetchResult.textContent = "No model ids found. Type the model id.";
24469
25884
  modelId.focus();
24470
25885
  } else {
25886
+ forgetModelIds();
24471
25887
  fetchResult.textContent = outcome.message;
24472
25888
  fetchResult.className = "result " + outcome.result;
24473
25889
  }
@@ -24477,13 +25893,18 @@ function setUpAddModelForm() {
24477
25893
  let aliasEdited = false;
24478
25894
  alias.oninput = () => (aliasEdited = alias.value !== "");
24479
25895
  modelId.oninput = () => {
25896
+ const known = knownModalities[modelId.value.trim()];
25897
+ if (known && known !== modality.value) {
25898
+ modality.value = known;
25899
+ showModelIds();
25900
+ }
24480
25901
  if (!aliasEdited) alias.value = modelId.value.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
24481
25902
  };
24482
25903
 
24483
25904
  const form = document.getElementById("add-model");
24484
25905
  form.onsubmit = (event) => {
24485
25906
  event.preventDefault();
24486
- const body = { alias: alias.value, provider: provider.value, modelId: modelId.value, protocol: protocol.value };
25907
+ const body = { alias: alias.value, provider: provider.value, modelId: modelId.value, protocol: protocol.value, modality: modality.value };
24487
25908
  if (notes.value.trim() !== "") body.notes = notes.value;
24488
25909
  act(form.querySelector("button[type=submit]"), async () => {
24489
25910
  await api("POST", "/api/models", body);
@@ -24524,61 +25945,147 @@ function setUpAddForm() {
24524
25945
  };
24525
25946
  }
24526
25947
 
24527
- /** An image of a Job, or a placeholder once the file is gone. */
24528
- function thumbnail(file) {
24529
- const img = el("img", { className: "thumb", src: file.thumbnail + "?token=" + encodeURIComponent(token), alt: file.path, title: file.path, loading: "lazy" });
24530
- img.onerror = () => img.replaceWith(el("div", { className: "thumb placeholder", title: file.path, textContent: "Image no longer on disk" }));
24531
- return img;
25948
+ /** An image or video of a Job, or a placeholder once the file is gone. */
25949
+ function thumbnail(job, file) {
25950
+ const src = file.thumbnail + "?token=" + encodeURIComponent(token);
25951
+ const media = job.modality === "video"
25952
+ ? el("video", { className: "thumb video", src, title: file.path, controls: true, muted: true, preload: "metadata" })
25953
+ : el("img", { className: "thumb", src, alt: file.path, title: file.path, loading: "lazy" });
25954
+ media.onerror = () => media.replaceWith(el("div", { className: "thumb placeholder", title: file.path, textContent: "File no longer on disk" }));
25955
+ return media;
24532
25956
  }
24533
25957
 
24534
25958
  function formatDuration(ms) {
24535
25959
  return ms < 1000 ? ms + " ms" : (ms / 1000).toFixed(1) + " s";
24536
25960
  }
24537
25961
 
24538
- /** The current Rating, and a one-line reason with Good and Bad buttons to rate the Job. Only completed Jobs can be rated. */
25962
+ function ratingText(rating) {
25963
+ return (rating.verdict === "good" ? "Good" : "Bad") + ": " + rating.reason;
25964
+ }
25965
+
25966
+ /** Whether this Job is its Comparison's Pick: as a whole, or with \`result\` that one result of it. */
25967
+ function isPicked(job, result) {
25968
+ const pick = job.comparison && job.comparison.pick;
25969
+ return !!pick && pick.jobId === job.id && (result === undefined || pick.result === result);
25970
+ }
25971
+
25972
+ /**
25973
+ * A thumbnail, captioned with its number, its own Rating and whether it is its Comparison's Pick, when the Job has
25974
+ * several results or this one is rated or picked.
25975
+ */
25976
+ function resultThumbnail(job, file, index) {
25977
+ const media = thumbnail(job, file);
25978
+ const picked = job.files.length > 1 && isPicked(job, index + 1);
25979
+ if (job.files.length === 1 && !file.rating) return media;
25980
+ const caption = el("figcaption", { textContent: "Result " + (index + 1) + (picked ? " · Picked" : "") + (file.rating ? " · " + ratingText(file.rating) : "") });
25981
+ if (file.rating) caption.className = "rating " + file.rating.verdict;
25982
+ else if (picked) caption.className = "picked";
25983
+ return el("figure", { className: "result-thumb" }, media, caption);
25984
+ }
25985
+
25986
+ /**
25987
+ * For a completed Job of a Comparison: a Pick button for the Job or, when it has several results, the one selected.
25988
+ * A Pick says which of the Comparison's results the User prefers; it is not a Rating.
25989
+ */
25990
+ function pickRow(job) {
25991
+ if (!job.comparison || job.outcome !== "completed") return [];
25992
+ const target = el("select", { className: "rate-target", title: "What you pick" },
25993
+ el("option", { value: "", textContent: "Whole Job" }),
25994
+ ...job.files.map((_, index) => el("option", { value: String(index + 1), textContent: "Result " + (index + 1) })));
25995
+ const button = el("button", { type: "button", textContent: "Pick" });
25996
+ button.onclick = () => act(button, async () => {
25997
+ const body = { jobId: job.id };
25998
+ if (target.value !== "") body.result = Number(target.value);
25999
+ await api("POST", "/api/comparisons/" + encodeURIComponent(job.comparison.id) + "/pick", body);
26000
+ await loadJobs();
26001
+ }, "Pick saved.");
26002
+ const controls = job.files.length > 1 ? [target, button] : [button];
26003
+ return [el("div", { className: "actions" }, el("span", { className: "spacer muted", textContent: "Prefer this one?" }), ...controls)];
26004
+ }
26005
+
26006
+ /**
26007
+ * The whole Job's Rating, and a one-line reason with Good and Bad buttons to rate the Job or, when it has several
26008
+ * results, one of them. Only completed Jobs can be rated.
26009
+ */
24539
26010
  function ratingRow(job) {
24540
26011
  if (job.outcome !== "completed") return [];
26012
+ const whole = job.files.length > 1 ? "Whole Job · " : "";
24541
26013
  const current = job.rating
24542
- ? el("span", { className: "rating " + job.rating.verdict, textContent: (job.rating.verdict === "good" ? "Good" : "Bad") + ": " + job.rating.reason })
24543
- : el("span", { className: "rating muted", textContent: "Not rated yet." });
26014
+ ? el("span", { className: "rating " + job.rating.verdict, textContent: whole + ratingText(job.rating) })
26015
+ : el("span", { className: "rating muted", textContent: whole + "Not rated yet." });
24544
26016
  const reason = el("input", { maxLength: reasonMax, placeholder: "One-line reason, e.g. the text is misspelled" });
26017
+ const target = el("select", { className: "rate-target", title: "What the Rating is about" },
26018
+ el("option", { value: "", textContent: "Whole Job" }),
26019
+ ...job.files.map((_, index) => el("option", { value: String(index + 1), textContent: "Result " + (index + 1) })));
24545
26020
  const rateAs = (verdict, label) => {
24546
26021
  const button = el("button", { type: "button", textContent: label });
24547
26022
  button.onclick = () => act(button, async () => {
24548
- await api("POST", "/api/jobs/" + encodeURIComponent(job.id) + "/rating", { verdict, reason: reason.value });
26023
+ const body = { verdict, reason: reason.value };
26024
+ if (target.value !== "") body.result = Number(target.value);
26025
+ await api("POST", "/api/jobs/" + encodeURIComponent(job.id) + "/rating", body);
24549
26026
  await loadJobs();
24550
26027
  }, "Rating saved.");
24551
26028
  return button;
24552
26029
  };
24553
- return [el("div", { className: "actions rate" }, current, el("div", { className: "inline" }, reason, rateAs("good", "Good"), rateAs("bad", "Bad")))];
26030
+ const controls = job.files.length > 1 ? [target, reason] : [reason];
26031
+ return [el("div", { className: "actions rate" }, current, el("div", { className: "inline" }, ...controls, rateAs("good", "Good"), rateAs("bad", "Bad")))];
24554
26032
  }
24555
26033
 
24556
26034
  function jobCard(job) {
24557
26035
  const when = new Date(job.createdAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
24558
26036
  const outcome = job.outcome === "completed"
24559
- ? job.files.length + " image(s)"
26037
+ ? (job.modality === "video" ? "Video" : job.files.length + " image(s)")
24560
26038
  : job.outcome === "running" ? "Running…" : "Failed";
24561
26039
  const details = [];
24562
26040
  if (job.error) details.push(el("p", { className: "result failure", textContent: job.error }));
24563
26041
  if (job.shortfall) details.push(el("p", { className: "result unsupported", textContent: job.shortfall }));
26042
+ if (job.mismatch) details.push(el("p", { className: "result unsupported", textContent: job.mismatch }));
24564
26043
  return el("div", { className: "card" },
24565
26044
  el("div", {},
24566
26045
  el("div", { className: "card-head" }, el("h3", { textContent: job.model }),
26046
+ ...(isPicked(job) ? [el("span", { className: "chip accent", textContent: "Picked" })] : []),
24567
26047
  el("span", { className: "chip " + (job.outcome === "completed" ? "ok" : job.outcome), textContent: outcome })),
24568
26048
  el("p", { className: "job-meta", textContent: job.modelId + " on " + job.provider + " · " + when + " · " + formatDuration(job.durationMs) })),
24569
26049
  el("p", { className: "prompt", textContent: job.prompt }),
24570
- ...(job.files.length === 0 ? [] : [el("div", { className: "thumbs" }, ...job.files.map(thumbnail))]),
26050
+ ...(job.files.length === 0 ? [] : [el("div", { className: "thumbs" }, ...job.files.map((file, index) => resultThumbnail(job, file, index)))]),
24571
26051
  ...details,
24572
26052
  ...ratingRow(job),
26053
+ ...pickRow(job),
24573
26054
  );
24574
26055
  }
24575
26056
 
26057
+ /** A Comparison's Jobs together, under what the User picked among them. */
26058
+ function comparisonCard(jobs) {
26059
+ const pick = jobs[0].comparison.pick;
26060
+ const picked = pick && jobs.find((job) => job.id === pick.jobId);
26061
+ const status = !pick ? "Not picked yet."
26062
+ : "Picked: " + (pick.result === undefined ? "" : "result " + pick.result + " of ") + (picked ? picked.model + "'s Job" : "Job " + pick.jobId) + ".";
26063
+ return el("div", { className: "comparison" },
26064
+ el("div", { className: "comparison-head" }, el("h3", { textContent: "Comparison · " + jobs.length + " Jobs" }),
26065
+ el("p", { textContent: status + " A Pick says which you prefer; it is not a Rating." })),
26066
+ ...jobs.map(jobCard));
26067
+ }
26068
+
26069
+ /** Job cards, each Comparison's Jobs (which the listing keeps together) in one Comparison card. */
26070
+ function jobEntries(jobs) {
26071
+ const entries = [];
26072
+ for (let i = 0; i < jobs.length; ) {
26073
+ const comparison = jobs[i].comparison;
26074
+ if (!comparison) { entries.push(jobCard(jobs[i])); i++; continue; }
26075
+ let end = i;
26076
+ while (end < jobs.length && jobs[end].comparison && jobs[end].comparison.id === comparison.id) end++;
26077
+ entries.push(comparisonCard(jobs.slice(i, end)));
26078
+ i = end;
26079
+ }
26080
+ return entries;
26081
+ }
26082
+
24576
26083
  async function loadJobs() {
24577
26084
  const list = document.getElementById("job-list");
24578
26085
  const { jobs } = await api("GET", "/api/jobs");
24579
26086
  list.replaceChildren(...(jobs.length === 0
24580
26087
  ? [emptyState("No Jobs yet. Images your Agent generates will appear here.")]
24581
- : jobs.map(jobCard)));
26088
+ : jobEntries(jobs)));
24582
26089
  }
24583
26090
 
24584
26091
  (async () => {
@@ -24654,7 +26161,7 @@ async function startSettingsServer(options) {
24654
26161
  const nonce = randomBytes2(16).toString("base64");
24655
26162
  response.writeHead(200, {
24656
26163
  "content-type": "text/html; charset=utf-8",
24657
- "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`,
26164
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; media-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`,
24658
26165
  ...commonHeaders
24659
26166
  });
24660
26167
  response.end(renderPage(nonce));
@@ -24747,91 +26254,298 @@ function settingsOpener(options) {
24747
26254
  };
24748
26255
  }
24749
26256
  // package.json
24750
- var version2 = "0.1.1";
26257
+ var version2 = "0.3.0";
24751
26258
 
24752
26259
  // src/server.ts
24753
- var instructions = `litevision generates images through Models the User has configured with their own Providers.
26260
+ var instructions = `litevision generates images and videos through Models the User has configured with their own Providers. Before writing a prompt, use the litevision Skill. If you do not have it, mention once that the User can install it with \`npx skills add levyxdev/litevision\`.
24754
26261
 
24755
- - Use the Model the User names (pass its Alias as \`model\`).
24756
- - When the User names none, omit \`model\` to use the default image Model, unless the request needs something the default lacks (e.g. legible Chinese text, reference images, an aspect ratio). Then call \`list_models\` and choose by each Model's capabilities and Model Notes.
24757
26262
  - Never ask the User for an API key or any other Credential. If litevision reports that nothing is configured, call \`open_settings\` so the User can set it up in their browser.
24758
- - \`generate_image\` returns file paths, not image data. Tell the User where the files are.
24759
- - A Job that takes longer than two minutes keeps running in the background: \`generate_image\` then returns its Job id with status "running". Tell the User it is still running, and call \`get_job\` with that Job id to wait for it again and get the files.
24760
- - Ask for an aspect ratio, not pixel sizes. If the Model does not support it, the closest supported one is used; when the result says it was adjusted, tell the User the actual size.
24761
- - If \`generate_image\` says a Model cannot do something (reference images, that many images), the error lists what the Model supports: choose another Model or change the request.
24762
- - Pass \`output_path\` when the images belong in the User's project (e.g. its assets folder); otherwise omit it.
24763
- - For Provider-specific \`provider_options\`, call \`list_models\` with \`model\` first: it gives the Model's docs link, the Provider's parameter schema when it publishes one, and options that already worked for this Model. litevision passes \`provider_options\` through unchecked; if the Provider rejects one, its error says why, so fix it and try again.
24764
- - If fewer images came back than you asked for, the result says why. Tell the User.
24765
- - When the User says what they think of a result ("this one's great", "the text is wrong"), call \`rate_job\` with that Job's id, their verdict (good or bad) and a one-line reason in their words. Call it only when the User actually states a verdict; never invent one, and never rate a result yourself.
24766
- - Model Notes say what each Model is good or bad at. When the User learns something worth keeping, you may propose new Model Notes: short, replacing the old text whole rather than adding to it. Before calling \`update_model_notes\`, show the User the old and new Model Notes and get their agreement; never change them without it.`;
26263
+ - 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\`.
26264
+ - litevision passes \`provider_options\` to the Provider as they are and translates nothing: write each Model's own fields, such as the size, resolution or length, from its Parameter Guide (\`list_models\` with \`model\`). Without them the Provider's defaults apply. Reuse Known-good Options; for anything else, or an option marked suspect, read the Parameter Guide and docs first.
26265
+ - 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.
26266
+ - Tell the User where the files are, and pass on whatever a result says did not match the request or came back short.
26267
+ - \`generate_comparison\` costs as much as all its Jobs. Start a Comparison only when the User asks for one or agrees to it.
26268
+ - 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.
26269
+ - 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.
26270
+ - Before calling \`update_model_notes\`, show the User the old and new Model Notes and get their agreement; never change them without it.`;
26271
+ var recentRatingsShown = 5;
26272
+ var recentPicksShown = 5;
26273
+ var promptStartLength = 80;
24767
26274
  var listedModelSchema = object({
24768
26275
  alias: string2(),
24769
26276
  provider: string2(),
24770
26277
  protocol: string2(),
24771
26278
  model_id: string2().describe("The Provider's own name for the model."),
24772
- default: boolean2(),
26279
+ modality: _enum(modalities).describe("What the Model generates: images with `generate_image`, videos with `generate_video`."),
26280
+ default: boolean2().describe("True for the default Model of its Modality."),
24773
26281
  notes: string2().describe("Model Notes: what the Model is good or bad at, agreed with the User."),
24774
- capabilities: object({
24775
- aspect_ratios: array(object({ aspect_ratio: _enum(aspectRatios), size: string2() })),
24776
- reference_images: boolean2(),
24777
- max_reference_images: number2(),
24778
- max_images_per_job: number2()
24779
- }).nullable().describe("Null when the Model cannot be used; `problem` then says why."),
26282
+ standing_options: record(string2(), unknown()).describe("Standing Options the User set: request fields every Job on this Model sends. Your `provider_options` are merged over them: your values win, objects merge key by key, and `null` removes a key for that Job."),
24780
26283
  docs_url: string2().nullable().describe("Where to read which `provider_options` this Model accepts."),
26284
+ parameters: string2().optional().describe("In brief, what litevision knows about the Model's request parameters: its Parameter Guide, the Provider's own description, or that it knows neither."),
26285
+ parameter_guide: object({
26286
+ family: string2(),
26287
+ checked: string2().describe("When the sources were last checked; read the docs first when this is old."),
26288
+ sources: array(string2()),
26289
+ parameters: array(object({
26290
+ name: string2().describe("The Provider's own field name for `provider_options`, dotted for nested fields."),
26291
+ values: string2(),
26292
+ default: string2(),
26293
+ price: string2().describe("How the value changes the price.")
26294
+ })),
26295
+ notes: array(string2())
26296
+ }).nullable().optional().describe("Only with `model`: the built-in Parameter Guide for the Model's family. Null when litevision has none, or when the Provider describes the Model itself (see `parameter_schema`)."),
26297
+ 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."),
26298
+ suspect_options: record(string2(), object({ value: unknown(), reason: string2(), job_id: string2(), cause: _enum(["rating", "mismatch"]) })).optional().describe('`provider_options` last used by a Job the User rated bad (cause "rating", with the User\'s reason) or whose result did not match the request (cause "mismatch"). Check the docs before sending them again.'),
24781
26299
  problem: string2().optional(),
24782
- parameter_schema: unknown().optional().describe("Only with `model`: the Provider's own description of the Model's parameters, when it publishes one."),
24783
- known_good_options: record(string2(), unknown()).optional().describe("Only with `model`: `provider_options` that completed Jobs on this Model have sent, with the latest value of each.")
24784
- });
24785
- async function listedModel(deps, config, model, isDefault, detailed) {
26300
+ recent_ratings: array(object({
26301
+ job_id: string2(),
26302
+ result: number2().optional().describe("Which result of the Job, from 1; absent when the Rating is about the whole Job."),
26303
+ verdict: _enum(verdicts),
26304
+ reason: string2().describe("The User's reason, in their words."),
26305
+ rated_at: string2(),
26306
+ prompt: string2().describe("The start of the Job's prompt.")
26307
+ })).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.`),
26308
+ recent_picks: array(object({
26309
+ comparison_id: string2(),
26310
+ job_id: string2().describe("This Model's Job in the Comparison."),
26311
+ picked: boolean2().describe("Whether the User picked this Model's Job."),
26312
+ result: number2().optional().describe("When picked: which result of the Job the User picked, from 1; absent when they picked the whole Job."),
26313
+ picked_model: string2().optional().describe("Alias of the Model whose Job the User picked."),
26314
+ picked_at: string2(),
26315
+ prompt: string2().describe("The start of this Model's Job's prompt.")
26316
+ })).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.`),
26317
+ parameter_schema: unknown().optional().describe("Only with `model`: the Provider's own description of the Model's parameters, when it publishes one.")
26318
+ });
26319
+ async function listedModel(deps, config, model, isDefault, detailed, ratedJobs) {
24786
26320
  const base = {
24787
26321
  alias: model.alias,
24788
26322
  provider: model.provider,
24789
26323
  protocol: model.protocol,
24790
26324
  model_id: model.modelId,
26325
+ modality: model.modality,
24791
26326
  default: isDefault,
24792
- notes: model.notes ?? ""
26327
+ notes: model.notes ?? "",
26328
+ standing_options: model.standingOptions ?? {}
24793
26329
  };
24794
- const heading = `${model.alias}${isDefault ? " (default)" : ""}: ${model.modelId} on ${model.provider} via ${model.protocol}.`;
24795
- const notes = model.notes ? ` Notes: ${model.notes}` : "";
24796
- let info;
26330
+ const heading = `${model.alias}${isDefault ? ` (default ${model.modality})` : ""}: ${model.modality} Model ${model.modelId} on ${model.provider} via ${model.protocol}.`;
26331
+ const notes = `${model.notes ? ` Notes: ${model.notes}` : ""}${model.standingOptions ? ` Standing Options: ${JSON.stringify(model.standingOptions)}.` : ""}`;
26332
+ let described;
24797
26333
  try {
24798
26334
  const provider = config.providers.find((candidate) => candidate.id === model.provider);
24799
26335
  if (provider === undefined)
24800
26336
  throw needsSettings(`Model "${model.alias}" refers to Provider "${model.provider}", which is not configured.`);
24801
- info = await modelInfo(deps, { model, provider });
26337
+ if (model.modality === "video") {
26338
+ const info = videoModelInfo({ model, provider });
26339
+ described = {
26340
+ docsUrl: info.docsUrl,
26341
+ ...info.guide === undefined ? {} : { guide: info.guide }
26342
+ };
26343
+ } else {
26344
+ const info = await modelInfo(deps, { model, provider });
26345
+ described = {
26346
+ docsUrl: info.docsUrl,
26347
+ ...info.parameterSchema === undefined ? {} : { parameterSchema: info.parameterSchema },
26348
+ ...info.guide === undefined ? {} : { guide: info.guide }
26349
+ };
26350
+ }
24802
26351
  } catch (error) {
24803
26352
  if (!(error instanceof ConfigError))
24804
26353
  throw error;
24805
26354
  return {
24806
- output: { ...base, capabilities: null, docs_url: null, problem: error.message },
26355
+ output: { ...base, docs_url: null, problem: error.message },
24807
26356
  text: `${heading} Cannot be used: ${error.message}${notes}`
24808
26357
  };
24809
26358
  }
24810
- const { capabilities } = info;
26359
+ const record2 = optionsOf(ratedJobs, model);
24811
26360
  const output = {
24812
26361
  ...base,
24813
- capabilities: {
24814
- aspect_ratios: capabilities.sizes.map((size) => ({ aspect_ratio: size.aspectRatio, size: formatSize(size) })),
24815
- reference_images: capabilities.maxReferences > 0,
24816
- max_reference_images: capabilities.maxReferences,
24817
- max_images_per_job: capabilities.maxImages
24818
- },
24819
- docs_url: info.docsUrl
26362
+ docs_url: described.docsUrl,
26363
+ parameters: parametersSummary(described, model.provider),
26364
+ known_good_options: record2.knownGood,
26365
+ suspect_options: suspectOutput(record2)
24820
26366
  };
24821
- let text = `${heading} Supports ${describeCapabilities(capabilities)}${notes} provider_options: ${info.docsUrl}`;
26367
+ let text = `${heading}${notes} provider_options docs: ${described.docsUrl}`;
26368
+ if (!detailed)
26369
+ text += ` ${output.parameters}`;
26370
+ const goodKeys = Object.keys(record2.knownGood);
26371
+ const suspectKeys = Object.keys(record2.suspect);
24822
26372
  if (detailed) {
24823
- const knownGood = knownGoodOptions(await readJobs(deps.dirs), { provider: model.provider, protocol: model.protocol, modelId: model.modelId });
24824
- output.known_good_options = knownGood;
24825
26373
  text += `
24826
- Known-good provider_options: ${Object.keys(knownGood).length === 0 ? "none recorded yet" : JSON.stringify(knownGood)}`;
24827
- if (info.parameterSchema !== undefined) {
24828
- output.parameter_schema = info.parameterSchema;
26374
+ Known-good Options: ${goodKeys.length === 0 ? "none recorded yet" : JSON.stringify(record2.knownGood)}`;
26375
+ for (const [key, suspect] of Object.entries(record2.suspect)) {
24829
26376
  text += `
24830
- Parameter schema from the Provider: ${JSON.stringify(info.parameterSchema)}`;
24831
- }
26377
+ Suspect: ${key}=${JSON.stringify(suspect.value)}, used by Job ${suspect.jobId}, ${suspectWhy(suspect)}`;
26378
+ }
26379
+ const ratings = recentRatings(ratedJobs, model, recentRatingsShown);
26380
+ output.recent_ratings = ratings.map(recentRatingOutput);
26381
+ text += ratings.length === 0 ? `
26382
+ Recent Ratings: none recorded yet.` : `
26383
+ Recent Ratings, newest first:`;
26384
+ for (const rated of ratings)
26385
+ text += `
26386
+ - ${describeRecentRating(rated)}`;
26387
+ const picks = recentPicks(ratedJobs, model, recentPicksShown);
26388
+ output.recent_picks = picks.map(recentPickOutput);
26389
+ text += picks.length === 0 ? `
26390
+ Recent Picks: none recorded yet.` : `
26391
+ Recent Picks in Comparisons, newest first:`;
26392
+ for (const picked of picks)
26393
+ text += `
26394
+ - ${describeRecentPick(picked)}`;
26395
+ output.parameter_guide = described.parameterSchema === undefined && described.guide !== undefined ? guideOutput(described.guide) : null;
26396
+ if (described.parameterSchema !== undefined)
26397
+ output.parameter_schema = described.parameterSchema;
26398
+ text += `
26399
+ ${parameterHelp(described, model.provider)}`;
26400
+ } else {
26401
+ if (goodKeys.length > 0)
26402
+ text += ` Known-good Options: ${goodKeys.join(", ")}.`;
26403
+ if (suspectKeys.length > 0)
26404
+ text += ` Suspect options: ${suspectKeys.join(", ")}.`;
24832
26405
  }
24833
26406
  return { output, text };
24834
26407
  }
26408
+ function parametersSummary(known, providerId) {
26409
+ if (known.parameterSchema !== undefined) {
26410
+ return `Provider "${providerId}" describes this Model's parameters itself; \`list_models\` with \`model\` shows its description.`;
26411
+ }
26412
+ if (known.guide !== undefined)
26413
+ return guideSummary(known.guide);
26414
+ return noGuide(known.docsUrl);
26415
+ }
26416
+ function parameterHelp(known, providerId) {
26417
+ if (known.parameterSchema !== undefined) {
26418
+ return `Parameter schema from Provider "${providerId}", which wins over litevision's own Parameter Guide: ${JSON.stringify(known.parameterSchema)}`;
26419
+ }
26420
+ if (known.guide !== undefined)
26421
+ return guideText(known.guide);
26422
+ return noGuide(known.docsUrl);
26423
+ }
26424
+ function noGuide(docsUrl) {
26425
+ return `litevision has no Parameter Guide for this model id; read ${docsUrl} before setting provider_options.`;
26426
+ }
26427
+ function ratingOutput(rating) {
26428
+ return {
26429
+ job_id: rating.jobId,
26430
+ ...rating.result === undefined ? {} : { result: rating.result },
26431
+ verdict: rating.verdict,
26432
+ reason: rating.reason,
26433
+ rated_at: rating.ratedAt
26434
+ };
26435
+ }
26436
+ function ratingSubject(rating) {
26437
+ return rating.result === undefined ? `Job ${rating.jobId}` : `result ${rating.result} of Job ${rating.jobId}`;
26438
+ }
26439
+ function recentRatingOutput({ rating, job }) {
26440
+ return { ...ratingOutput(rating), prompt: promptStart(job.prompt) };
26441
+ }
26442
+ function describeRecentRating({ rating, job }) {
26443
+ return `${rating.verdict} on ${ratingSubject(rating)} (${JSON.stringify(promptStart(job.prompt))}): ${rating.reason}`;
26444
+ }
26445
+ function pickOutput(pick) {
26446
+ return {
26447
+ comparison_id: pick.comparison,
26448
+ job_id: pick.jobId,
26449
+ ...pick.result === undefined ? {} : { result: pick.result },
26450
+ picked_at: pick.pickedAt
26451
+ };
26452
+ }
26453
+ function recentPickOutput({ pick, job, picked }) {
26454
+ const isPicked = pick.jobId === job.id;
26455
+ return {
26456
+ comparison_id: pick.comparison,
26457
+ job_id: job.id,
26458
+ picked: isPicked,
26459
+ ...isPicked && pick.result !== undefined ? { result: pick.result } : {},
26460
+ ...picked === undefined ? {} : { picked_model: picked.model },
26461
+ picked_at: pick.pickedAt,
26462
+ prompt: promptStart(job.prompt)
26463
+ };
26464
+ }
26465
+ function describeRecentPick({ pick, job, picked }) {
26466
+ const subject = `Job ${job.id} in Comparison ${pick.comparison} (${JSON.stringify(promptStart(job.prompt))})`;
26467
+ if (pick.jobId === job.id)
26468
+ return `${subject}: picked${pick.result === undefined ? "" : `, result ${pick.result}`}`;
26469
+ return `${subject}: not picked; the User picked ${picked === undefined ? `Job ${pick.jobId}` : `a Job on ${picked.model}`}`;
26470
+ }
26471
+ function promptStart(prompt) {
26472
+ const line = prompt.replace(/\s+/g, " ").trim();
26473
+ return line.length <= promptStartLength ? line : `${line.slice(0, promptStartLength).trimEnd()}…`;
26474
+ }
26475
+ function suspectOutput(record2) {
26476
+ return Object.fromEntries(Object.entries(record2.suspect).map(([key, suspect]) => [
26477
+ key,
26478
+ { value: suspect.value, reason: suspect.reason, job_id: suspect.jobId, cause: suspect.cause }
26479
+ ]));
26480
+ }
26481
+ async function modelOptions(deps, model) {
26482
+ return optionsOf(await readRatedJobs(deps.dirs), model);
26483
+ }
26484
+ function optionsOf(ratedJobs, model) {
26485
+ return optionsRecord(ratedJobs, { provider: model.provider, protocol: model.protocol, modelId: model.modelId });
26486
+ }
26487
+ async function jobDocs(deps, jobId) {
26488
+ const job = await readJob(deps.dirs, jobId);
26489
+ const adapter = job === undefined ? undefined : adapterForProtocol(job.protocol);
26490
+ if (job === undefined || adapter === undefined)
26491
+ return;
26492
+ const modality = job.modality ?? "image";
26493
+ const guide = adapter.parameterGuide?.(job.modelId, modality);
26494
+ let known = { docsUrl: adapter.docsUrl(job.modelId, modality), ...guide === undefined ? {} : { guide } };
26495
+ const provider = modality === "image" ? (await loadConfig(deps.dirs).catch(() => {
26496
+ return;
26497
+ }))?.providers.find((candidate) => candidate.id === job.provider) : undefined;
26498
+ if (provider !== undefined) {
26499
+ const model = { alias: job.model, provider: job.provider, modelId: job.modelId, protocol: job.protocol, modality };
26500
+ known = await modelInfo(deps, { model, provider });
26501
+ }
26502
+ return { known, provider: job.provider, options: optionsRecord(await readRatedJobs(deps.dirs), job) };
26503
+ }
26504
+ function suspectWhy(suspect) {
26505
+ return suspect.cause === "rating" ? `which the User rated bad: "${suspect.reason}"` : `whose result did not match the request: ${suspect.reason}`;
26506
+ }
26507
+ function failureHint(known, providerId, record2, kind) {
26508
+ const help = kind === "rejected_option" || kind === "unsupported" ? `
26509
+ ${parameterHelp(known, providerId)}` : "";
26510
+ return `${optionsHint(known.docsUrl, record2)}${help}`;
26511
+ }
26512
+ function optionsHint(docsUrl, record2) {
26513
+ const good = Object.keys(record2.knownGood).length === 0 ? "none recorded yet" : JSON.stringify(record2.knownGood);
26514
+ const suspect = Object.entries(record2.suspect).map(([key, entry]) => ` Suspect: ${key}=${JSON.stringify(entry.value)}, used by a Job ${suspectWhy(entry)}.`).join("");
26515
+ return ` Docs for this Model's provider_options: ${docsUrl}. Known-good Options: ${good}.${suspect}`;
26516
+ }
26517
+ 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.");
26518
+ var requestedShapeSchema = string2().nullable().describe("The shape the request sent asks for, as litevision read it from the Model's own fields, e.g. 16:9 or 3840x2160; null when it asks for none litevision can read, so the result's shape is not checked. Compare it with `observed`.");
26519
+ var requestedResolutionSchema = string2().nullable().describe("The resolution tier the request sent asks for, as written in its Protocol's tier field, e.g. 4K or 1080P; null when it sets none. Not checked against the result.");
26520
+ var requestedDurationSchema = number2().nullable().describe("Video Jobs: the length in seconds the request sent asks for, as litevision read it; null when unreadable, so the length is not checked.");
26521
+ function requestedOutput(requested) {
26522
+ return {
26523
+ requested_shape: requested.shape,
26524
+ requested_resolution: requested.resolution,
26525
+ ...requested.seconds === undefined ? {} : { requested_duration: requested.seconds }
26526
+ };
26527
+ }
26528
+ 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.");
26529
+ function observedText(observed) {
26530
+ const known = [...new Set(observed.flatMap((facts) => facts === null ? [] : [describeFacts(facts)]))];
26531
+ return known.length === 0 ? "" : ` Got ${known.join(", ")}.`;
26532
+ }
26533
+ function describeFacts(facts) {
26534
+ return `${facts.width}x${facts.height}${facts.seconds === undefined ? "" : `, ${facts.seconds} s`}`;
26535
+ }
26536
+ function requestedText({ shape, resolution, seconds }) {
26537
+ const size = shape === null ? resolution : resolution === null ? shape : `${shape} at ${resolution}`;
26538
+ const read = [size, typeof seconds === "number" ? `${seconds} s` : null].filter((part) => part !== null);
26539
+ const unread = [shape === null ? "shape" : null, seconds === null ? "length" : null].filter((part) => part !== null);
26540
+ const asked = read.length === 0 ? "" : `The request asks for ${read.join(", ")}.`;
26541
+ const unchecked = unread.length === 0 ? "" : `The request asks for no ${unread.join(" or ")} litevision can read, so the result's ${unread.join(" and ")} ${unread.length === 1 ? "was" : "were"} not checked.`;
26542
+ return [asked, unchecked].filter(Boolean).join(" ");
26543
+ }
26544
+ function mismatchText(mismatch, docsUrl) {
26545
+ if (mismatch === undefined)
26546
+ return "";
26547
+ 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.`;
26548
+ }
24835
26549
  var jobStatusSchema = _enum(["completed", "running"]).describe('"running" when the Job outlasted the wait limit and continues in the background; call `get_job` with its `job_id`.');
24836
26550
  function jobOutput(job) {
24837
26551
  return {
@@ -24839,7 +26553,10 @@ function jobOutput(job) {
24839
26553
  status: job.status,
24840
26554
  model: job.model,
24841
26555
  files: job.status === "completed" ? job.files : [],
24842
- ...job.status === "completed" && job.shortfall !== undefined ? { shortfall: job.shortfall } : {}
26556
+ observed: job.status === "completed" ? job.observed : [],
26557
+ ...job.status === "completed" && job.shortfall !== undefined ? { shortfall: job.shortfall } : {},
26558
+ ...job.status === "completed" && job.mismatch !== undefined ? { mismatch: job.mismatch } : {},
26559
+ ...job.status === "completed" && job.requested !== undefined ? requestedOutput(job.requested) : {}
24843
26560
  };
24844
26561
  }
24845
26562
  function jobText(job) {
@@ -24848,111 +26565,265 @@ function jobText(job) {
24848
26565
  function toolError(text) {
24849
26566
  return { content: [{ type: "text", text }], isError: true };
24850
26567
  }
26568
+ async function failure2(error, hint) {
26569
+ const reason = await failureReason(error, hint);
26570
+ return toolError(error instanceof JobFailedError ? `Job ${error.jobId} failed: ${reason}` : reason);
26571
+ }
26572
+ async function failureReason(error, hint) {
26573
+ if (error instanceof ConfigError)
26574
+ return error.message;
26575
+ if (error instanceof JobFailedError) {
26576
+ const helpful = hint !== undefined && (error.kind === "unsupported" || error.kind === "rejected_option" || error.kind === "other");
26577
+ return `${error.message}${helpful ? await hint(error.kind) : ""}`;
26578
+ }
26579
+ throw error;
26580
+ }
26581
+ var imageJobShape = {
26582
+ job_id: string2(),
26583
+ status: jobStatusSchema,
26584
+ model: string2().describe("Alias of the Model that ran the Job."),
26585
+ files: array(string2()).describe("Paths of the saved images; empty while the Job is running."),
26586
+ requested_shape: requestedShapeSchema,
26587
+ requested_resolution: requestedResolutionSchema,
26588
+ shortfall: string2().optional().describe("Present when fewer images than `n` were saved: how many, and why."),
26589
+ observed: observedSchema,
26590
+ mismatch: mismatchSchema
26591
+ };
26592
+ var videoJobShape = {
26593
+ job_id: string2(),
26594
+ status: jobStatusSchema,
26595
+ model: string2().describe("Alias of the Model that ran the Job."),
26596
+ files: array(string2()).describe("Path of the saved video; empty while the Job is running."),
26597
+ requested_shape: requestedShapeSchema,
26598
+ requested_duration: requestedDurationSchema,
26599
+ requested_resolution: requestedResolutionSchema,
26600
+ observed: observedSchema,
26601
+ mismatch: mismatchSchema
26602
+ };
26603
+ var failedJobSchema = object({
26604
+ job_id: string2().nullable().describe("Null when the Job could not start."),
26605
+ status: literal("failed"),
26606
+ model: string2().describe("Alias of the Model the Job ran on."),
26607
+ files: array(string2()).describe("Always empty."),
26608
+ error: string2().describe("Why the Job failed.")
26609
+ });
26610
+ var maxComparisonVariants = 4;
26611
+ function comparisonModality(config, variants, wanted) {
26612
+ const named = variants.flatMap((variant) => config.models.filter((model) => model.alias === variant.model));
26613
+ const found = new Set([...wanted === undefined ? [] : [wanted], ...named.map((model) => model.modality)]);
26614
+ if (found.size > 1) {
26615
+ const makes = [...new Map(named.map((model) => [model.alias, `${model.alias} makes ${model.modality}s`])).values()].join(", ");
26616
+ const asked = wanted === undefined ? "" : ` and \`modality\` is ${wanted}`;
26617
+ throw new ConfigError(`A Comparison's Models must share one Modality, but ${makes}${asked}. Compare image Models or video Models, not both.`);
26618
+ }
26619
+ return [...found][0] ?? "image";
26620
+ }
24851
26621
  function createServer2(deps) {
24852
26622
  const server = new McpServer({ name: "litevision", version: version2 }, { instructions });
24853
26623
  const now = deps.now ?? (() => new Date);
24854
26624
  const cwd = deps.cwd ?? process.cwd();
24855
26625
  const infoDeps = { dirs: deps.dirs, http: deps.http, now };
24856
- const jobs = new JobRunner({ dirs: deps.dirs, http: deps.http, now, waitLimitMs: deps.waitLimitMs, pollIntervalMs: deps.pollIntervalMs });
26626
+ const jobs = new JobRunner({ dirs: deps.dirs, http: deps.http, now, waitLimitMs: deps.waitLimitMs, pollIntervalMs: deps.pollIntervalMs, retryDelayMs: deps.retryDelayMs });
26627
+ const prepareImage = async (resolved, args) => {
26628
+ const { model } = resolved;
26629
+ const adapter = adapterFor(model);
26630
+ const credential = await requireCredential(deps.dirs, resolved.provider);
26631
+ const info = await modelInfo(infoDeps, resolved);
26632
+ const normalised = await normaliseImageRequest({
26633
+ prompt: args.prompt,
26634
+ n: args.n,
26635
+ references: args.references,
26636
+ providerOptions: mergeOptions(model.standingOptions ?? {}, args.provider_options)
26637
+ }, model, adapter, cwd);
26638
+ const outputDir = args.output_path === undefined ? undefined : resolve2(cwd, args.output_path);
26639
+ return {
26640
+ model,
26641
+ start: (comparison) => jobs.start({ ...resolved, adapter, credential, normalised, outputDir, ...comparison === undefined ? {} : { comparison } }),
26642
+ hint: async (kind) => failureHint(info, model.provider, await modelOptions(deps, model), kind),
26643
+ report: (job) => {
26644
+ const output = { ...jobOutput(job), ...requestedOutput(normalised.requested) };
26645
+ const asked = requestedText(normalised.requested);
26646
+ const text = job.status === "running" ? `${jobText(job)} ${asked}` : `Saved ${job.files.length} image(s) from ${model.alias}, Job ${job.id}. ${asked}${observedText(job.observed)}${mismatchText(job.mismatch, info.docsUrl)}${job.shortfall === undefined ? "" : ` ${job.shortfall}`}
26647
+ ${job.files.join(`
26648
+ `)}`;
26649
+ return { output, text };
26650
+ }
26651
+ };
26652
+ };
26653
+ const prepareVideo = async (resolved, args) => {
26654
+ const { model } = resolved;
26655
+ const adapter = adapterFor(model);
26656
+ const info = videoModelInfo(resolved);
26657
+ const credential = await requireCredential(deps.dirs, resolved.provider);
26658
+ const normalised = await normaliseVideoRequest({
26659
+ prompt: args.prompt,
26660
+ ...args.first_frame === undefined ? {} : { firstFrame: args.first_frame },
26661
+ ...args.last_frame === undefined ? {} : { lastFrame: args.last_frame },
26662
+ references: args.references,
26663
+ providerOptions: mergeOptions(model.standingOptions ?? {}, args.provider_options)
26664
+ }, model, adapter, cwd);
26665
+ const outputDir = args.output_path === undefined ? undefined : resolve2(cwd, args.output_path);
26666
+ return {
26667
+ model,
26668
+ start: (comparison) => jobs.startVideo({ ...resolved, adapter, credential, normalised, outputDir, ...comparison === undefined ? {} : { comparison } }),
26669
+ hint: async (kind) => failureHint(info, model.provider, await modelOptions(deps, model), kind),
26670
+ report: (job) => {
26671
+ const output = { ...jobOutput(job), ...requestedOutput(normalised.requested) };
26672
+ const asked = requestedText(normalised.requested);
26673
+ const text = job.status === "running" ? `${jobText(job)} ${asked}` : `Saved a video from ${model.alias}, Job ${job.id}. ${asked}${observedText(job.observed)}${mismatchText(job.mismatch, info.docsUrl)}
26674
+ ${job.files.join(`
26675
+ `)}`;
26676
+ return { output, text };
26677
+ }
26678
+ };
26679
+ };
26680
+ const runSingle = async (prepare) => {
26681
+ let prepared;
26682
+ try {
26683
+ prepared = await prepare();
26684
+ const { output, text } = prepared.report(await prepared.start());
26685
+ return { content: [{ type: "text", text }], structuredContent: output };
26686
+ } catch (error) {
26687
+ return failure2(error, prepared?.hint);
26688
+ }
26689
+ };
24857
26690
  server.registerTool("generate_image", {
24858
26691
  description: "Generate images from a text prompt with one of the User's configured Models. Saves the images locally and returns their file paths.",
24859
- inputSchema: {
26692
+ inputSchema: strictObject({
24860
26693
  prompt: string2().min(1).describe("Sent to the Model verbatim."),
24861
26694
  model: string2().optional().describe("Alias of the Model to use. Omit to use the default image Model."),
24862
- n: number2().int().positive().default(1).describe("How many images to generate in this Job, up to the Model's maximum."),
24863
- aspect_ratio: _enum(aspectRatios).default("1:1").describe("Wanted aspect ratio. If the Model does not support it, the closest supported one is used and reported."),
26695
+ n: number2().int().positive().default(1).describe("How many images this Job brings back: sent as the Model's own count field, or as that many requests where it has none."),
24864
26696
  references: array(string2().min(1)).default([]).describe("Local paths of reference images to edit or draw on. Relative paths resolve against litevision's working directory."),
24865
26697
  output_path: string2().min(1).optional().describe("Folder to save the images in, e.g. the project's assets folder; created if missing. Relative paths resolve against litevision's working directory. Omit to save under a dated folder in the User's home directory."),
24866
- provider_options: record(string2(), unknown()).default({}).describe('Provider-specific request fields passed through untouched, e.g. { "quality": "high" } for OpenAI. Cannot override fields litevision sets from the other parameters.')
24867
- },
26698
+ provider_options: record(string2(), unknown()).default({}).describe(`The Model's own request fields, passed through untouched, e.g. { "size": "2048x1152", "quality": "high" } for OpenAI: see its Parameter Guide in \`list_models\`. Merged over the Model's Standing Options. Only the model id, prompt, reference image fields and response format are litevision's.`)
26699
+ }),
26700
+ outputSchema: imageJobShape
26701
+ }, async ({ model: alias, ...args }) => runSingle(async () => prepareImage(resolveModel(await loadConfig(deps.dirs), alias), args)));
26702
+ server.registerTool("generate_video", {
26703
+ 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.",
26704
+ inputSchema: strictObject({
26705
+ prompt: string2().min(1).describe("Sent to the Model verbatim. Describe motion and camera movement, not only the scene."),
26706
+ model: string2().optional().describe("Alias of the video Model to use. Omit to use the default video Model."),
26707
+ first_frame: string2().min(1).optional().describe("Local path of an image the video starts from."),
26708
+ last_frame: string2().min(1).optional().describe("Local path of an image the video ends on."),
26709
+ 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."),
26710
+ 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."),
26711
+ provider_options: record(string2(), unknown()).default({}).describe(`The Model's own request fields, passed through untouched, e.g. { "resolution": "1080P", "ratio": "9:16", "seconds": "10" } for Wan 2.7: see its Parameter Guide in \`list_models\`. Merged over the Model's Standing Options. Only the model id, prompt and media fields are litevision's.`)
26712
+ }),
26713
+ outputSchema: videoJobShape
26714
+ }, async ({ model: alias, ...args }) => runSingle(async () => prepareVideo(resolveModel(await loadConfig(deps.dirs), alias, "video"), args)));
26715
+ server.registerTool("generate_comparison", {
26716
+ 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.`,
26717
+ inputSchema: strictObject({
26718
+ variants: array(strictObject({
26719
+ prompt: string2().min(1).describe("Sent to this variant's Model verbatim."),
26720
+ model: string2().optional().describe("Alias of this variant's Model. Omit to use the default Model of the Comparison's Modality."),
26721
+ n: number2().int().positive().optional().describe("Image Comparisons only: how many images this variant's Job makes. Defaults to 1."),
26722
+ provider_options: record(string2(), unknown()).default({}).describe("This variant's own request fields, in its Model's own names (see its Parameter Guide), merged over its Standing Options.")
26723
+ })).min(2).max(maxComparisonVariants).describe(`The Jobs to start, 2 to ${maxComparisonVariants}, one per variant.`),
26724
+ modality: _enum(modalities).optional().describe("image or video. Omit to take it from the variants' Models; when no variant names one, image."),
26725
+ references: array(string2().min(1)).default([]).describe("Local paths of reference images every Job gets. Relative paths resolve against litevision's working directory."),
26726
+ 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.")
26727
+ }),
24868
26728
  outputSchema: {
24869
- job_id: string2(),
24870
- status: jobStatusSchema,
24871
- model: string2().describe("Alias of the Model that ran the Job."),
24872
- files: array(string2()).describe("Paths of the saved images; empty while the Job is running."),
24873
- aspect_ratio: _enum(aspectRatios).describe("Aspect ratio actually used."),
24874
- size: string2().describe("Size actually requested from the Provider, e.g. 1536x1024."),
24875
- requested_aspect_ratio: _enum(aspectRatios),
24876
- aspect_ratio_adjusted: boolean2().describe("True when the Model does not support the requested aspect ratio and the closest one was used."),
24877
- shortfall: string2().optional().describe("Present when fewer images than `n` were saved: how many, and why.")
24878
- }
24879
- }, async ({ prompt, model: alias, n, aspect_ratio, references, output_path, provider_options }) => {
26729
+ comparison_id: string2(),
26730
+ modality: _enum(modalities),
26731
+ 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`.')
26732
+ }
26733
+ }, async ({ variants, modality: wanted, references, output_path }) => {
26734
+ let prepared;
26735
+ let modality;
24880
26736
  try {
24881
- const resolved = resolveModel(await loadConfig(deps.dirs), alias);
24882
- const { model } = resolved;
24883
- const adapter = adapterFor(model);
24884
- const credential = await requireCredential(deps.dirs, resolved.provider);
24885
- const normalised = await normaliseImageRequest({ prompt, n, aspectRatio: aspect_ratio, references, providerOptions: provider_options }, model, adapter, await modelInfo(infoDeps, resolved), cwd);
24886
- const outputDir = output_path === undefined ? undefined : resolve2(cwd, output_path);
24887
- const job = await jobs.start({ ...resolved, adapter, credential, normalised, outputDir });
24888
- const { size } = normalised.request;
24889
- const output = {
24890
- ...jobOutput(job),
24891
- aspect_ratio: size.aspectRatio,
24892
- size: formatSize(size),
24893
- requested_aspect_ratio: normalised.requestedAspectRatio,
24894
- aspect_ratio_adjusted: normalised.aspectRatioAdjusted
24895
- };
24896
- const adjustment = normalised.aspectRatioAdjusted ? ` ${model.alias} does not support ${normalised.requestedAspectRatio}, so the closest supported aspect ratio was used.` : "";
24897
- const text = job.status === "running" ? `${jobText(job)} It asks ${model.alias} for ${size.aspectRatio} (${formatSize(size)}).${adjustment}` : `Saved ${job.files.length} image(s) from ${model.alias} at ${size.aspectRatio} (${formatSize(size)}), Job ${job.id}.${adjustment}${job.shortfall === undefined ? "" : ` ${job.shortfall}`}
24898
- ${job.files.join(`
24899
- `)}`;
24900
- return { content: [{ type: "text", text }], structuredContent: output };
26737
+ const config = await loadConfig(deps.dirs);
26738
+ modality = comparisonModality(config, variants, wanted);
26739
+ if (modality === "video" && variants.some((variant) => (variant.n ?? 1) > 1)) {
26740
+ throw new ConfigError("A video Job makes one video, so leave `n` out of a video Comparison's variants.");
26741
+ }
26742
+ prepared = await Promise.all(variants.map((variant) => {
26743
+ const resolved = resolveModel(config, variant.model, modality);
26744
+ const shared = { prompt: variant.prompt, references, provider_options: variant.provider_options, ...output_path === undefined ? {} : { output_path } };
26745
+ return modality === "video" ? prepareVideo(resolved, shared) : prepareImage(resolved, { ...shared, n: variant.n ?? 1 });
26746
+ }));
24901
26747
  } catch (error) {
24902
- if (error instanceof ConfigError)
24903
- return toolError(error.message);
24904
- if (error instanceof JobFailedError)
24905
- return toolError(`Job ${error.jobId} failed: ${error.message}`);
24906
- throw error;
24907
- }
26748
+ return failure2(error, undefined);
26749
+ }
26750
+ const comparisonId = randomUUID3();
26751
+ const settled = await Promise.allSettled(prepared.map((job) => job.start(comparisonId)));
26752
+ const results = await Promise.all(settled.map(async (outcome, index) => {
26753
+ const job = prepared[index];
26754
+ if (outcome.status === "fulfilled")
26755
+ return job.report(outcome.value);
26756
+ const reason = await failureReason(outcome.reason, job.hint).catch(() => `litevision could not run this Job: ${outcome.reason.message}`);
26757
+ const jobId = outcome.reason instanceof JobFailedError ? outcome.reason.jobId : null;
26758
+ return {
26759
+ output: { job_id: jobId, status: "failed", model: job.model.alias, files: [], error: reason },
26760
+ text: `Job${jobId === null ? "" : ` ${jobId}`} on ${job.model.alias} failed: ${reason}`
26761
+ };
26762
+ }));
26763
+ const text = [
26764
+ `Comparison ${comparisonId}: ${results.length} ${modality} Jobs. Show the User every result so they can choose; when they do, call \`pick_result\`.`,
26765
+ ...results.map((result, index) => `Variant ${index + 1}: ${result.text}`)
26766
+ ].join(`
26767
+
26768
+ `);
26769
+ return {
26770
+ content: [{ type: "text", text }],
26771
+ structuredContent: { comparison_id: comparisonId, modality, jobs: results.map((result) => result.output) }
26772
+ };
24908
26773
  });
24909
26774
  server.registerTool("get_job", {
24910
- description: "Get a Job's status, waiting up to two minutes while it runs; once it has completed, the paths of its saved images. Use it when `generate_image` returned a Job that is still running.",
26775
+ description: "Get a Job's status, waiting up to two minutes while it runs; once it has completed, the paths of its saved images or video. Use it when `generate_image`, `generate_video` or `generate_comparison` returned a Job that is still running.",
24911
26776
  inputSchema: {
24912
- job_id: string2().min(1).describe("The `job_id` that `generate_image` returned.")
26777
+ job_id: string2().min(1).describe("The `job_id` that `generate_image`, `generate_video` or `generate_comparison` returned.")
24913
26778
  },
24914
26779
  outputSchema: {
24915
26780
  job_id: string2(),
24916
26781
  status: jobStatusSchema,
24917
26782
  model: string2().describe("Alias of the Model that ran the Job."),
24918
- files: array(string2()).describe("Paths of the saved images; empty while the Job is running."),
24919
- shortfall: string2().optional().describe("Present when fewer images than asked for were saved: how many, and why.")
26783
+ files: array(string2()).describe("Paths of the saved images or video; empty while the Job is running."),
26784
+ shortfall: string2().optional().describe("Present when fewer images than asked for were saved: how many, and why."),
26785
+ requested_shape: requestedShapeSchema.optional(),
26786
+ requested_duration: requestedDurationSchema.optional(),
26787
+ requested_resolution: requestedResolutionSchema.optional(),
26788
+ observed: observedSchema,
26789
+ mismatch: mismatchSchema
24920
26790
  }
24921
26791
  }, async ({ job_id }) => {
24922
26792
  try {
24923
26793
  const job = await jobs.get(job_id);
24924
- const text = job.status === "running" ? jobText(job) : `Job ${job.id} completed: saved ${job.files.length} image(s) from ${job.model}.${job.shortfall === undefined ? "" : ` ${job.shortfall}`}
26794
+ const docsUrl = job.status === "completed" && job.mismatch !== undefined ? (await jobDocs(infoDeps, job_id))?.known.docsUrl ?? "the Model's docs link in `list_models`" : "";
26795
+ const text = job.status === "running" ? jobText(job) : `Job ${job.id} completed: saved ${job.files.length} file(s) from ${job.model}.${observedText(job.observed)}${mismatchText(job.mismatch, docsUrl)}${job.shortfall === undefined ? "" : ` ${job.shortfall}`}
24925
26796
  ${job.files.join(`
24926
26797
  `)}`;
24927
26798
  return { content: [{ type: "text", text }], structuredContent: jobOutput(job) };
24928
26799
  } catch (error) {
24929
- if (error instanceof ConfigError)
24930
- return toolError(error.message);
24931
- if (error instanceof JobFailedError)
24932
- return toolError(`Job ${error.jobId} failed: ${error.message}`);
24933
- throw error;
26800
+ return failure2(error, async (kind) => {
26801
+ const docs = await jobDocs(infoDeps, job_id);
26802
+ return docs === undefined ? "" : failureHint(docs.known, docs.provider, docs.options, kind);
26803
+ });
24934
26804
  }
24935
26805
  });
24936
26806
  server.registerTool("rate_job", {
24937
- description: "Record the User's Rating of a completed Job: good or bad, plus a one-line reason. Call it only when the User has actually said what they think of the result; never invent a verdict. A later Rating of the same Job replaces the earlier one.",
26807
+ description: "Record the User's Rating of a completed Job, or of one result of it: good or bad, plus a one-line reason. Call it only when the User has actually said what they think of the result; never invent a verdict. A later Rating of the same Job and result replaces the earlier one; a Rating of the whole Job and one of a single result stand side by side.",
24938
26808
  inputSchema: {
24939
- job_id: string2().min(1).describe("The `job_id` that `generate_image` or `get_job` returned."),
26809
+ job_id: string2().min(1).describe("The `job_id` that `generate_image`, `generate_video` or `get_job` returned."),
26810
+ result: number2().int().positive().optional().describe("Which result the verdict is about, from 1 in the order of the Job's files. Omit when it is about the whole Job."),
24940
26811
  verdict: _enum(verdicts).describe("The User's verdict."),
24941
26812
  reason: string2().max(ratingReasonMaxLength).describe(`Why, in one line and in the User's words, e.g. "the text is misspelled". At most ${ratingReasonMaxLength} characters.`)
24942
26813
  },
24943
26814
  outputSchema: {
24944
26815
  job_id: string2(),
26816
+ result: number2().optional().describe("The result rated; absent for a Rating of the whole Job."),
24945
26817
  verdict: _enum(verdicts),
24946
26818
  reason: string2().describe("The reason as recorded, on one line."),
24947
26819
  rated_at: string2().describe("When the Rating was recorded, ISO 8601.")
24948
26820
  }
24949
- }, async ({ job_id, verdict, reason }) => {
26821
+ }, async ({ job_id, result, verdict, reason }) => {
24950
26822
  try {
24951
- const rating = await rateJob(deps.dirs, { jobId: job_id, verdict, reason }, now());
24952
- const output = { job_id: rating.jobId, verdict: rating.verdict, reason: rating.reason, rated_at: rating.ratedAt };
26823
+ const rating = await rateJob(deps.dirs, { jobId: job_id, result, verdict, reason }, now());
24953
26824
  return {
24954
- content: [{ type: "text", text: `Recorded a ${rating.verdict} Rating on Job ${rating.jobId}: ${rating.reason}` }],
24955
- structuredContent: output
26825
+ content: [{ type: "text", text: `Recorded a ${rating.verdict} Rating on ${ratingSubject(rating)}: ${rating.reason}` }],
26826
+ structuredContent: ratingOutput(rating)
24956
26827
  };
24957
26828
  } catch (error) {
24958
26829
  if (error instanceof RatingError)
@@ -24960,21 +26831,50 @@ ${job.files.join(`
24960
26831
  throw error;
24961
26832
  }
24962
26833
  });
26834
+ server.registerTool("pick_result", {
26835
+ 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.",
26836
+ inputSchema: {
26837
+ comparison_id: string2().min(1).describe("The `comparison_id` that `generate_comparison` returned."),
26838
+ job_id: string2().min(1).describe("The `job_id` of the chosen Job, one of the Comparison's Jobs."),
26839
+ 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.")
26840
+ },
26841
+ outputSchema: {
26842
+ comparison_id: string2(),
26843
+ job_id: string2(),
26844
+ result: number2().optional().describe("The result picked; absent when the Job was picked as a whole."),
26845
+ picked_at: string2().describe("When the Pick was recorded, ISO 8601.")
26846
+ }
26847
+ }, async ({ comparison_id, job_id, result }) => {
26848
+ try {
26849
+ const pick = await pickResult(deps.dirs, { comparison: comparison_id, jobId: job_id, result }, now());
26850
+ const subject = pick.result === undefined ? `Job ${pick.jobId}` : `result ${pick.result} of Job ${pick.jobId}`;
26851
+ return {
26852
+ content: [{ type: "text", text: `Recorded the User's Pick in Comparison ${pick.comparison}: ${subject}.` }],
26853
+ structuredContent: pickOutput(pick)
26854
+ };
26855
+ } catch (error) {
26856
+ if (error instanceof PickError)
26857
+ return toolError(error.message);
26858
+ throw error;
26859
+ }
26860
+ });
24963
26861
  server.registerTool("list_models", {
24964
- description: "List the User's configured image Models: Alias, Provider, Protocol, capabilities, Model Notes and a docs link for `provider_options`, and which one is the default. Use it to choose a Model when the request needs something the default may lack. Pass `model` for one Model in detail, with the Provider's parameter schema and the `provider_options` known to work.",
26862
+ description: "List the User's configured Models: Alias, Modality (image or video), Provider, Protocol, Model Notes, Standing Options, a summary of each Model's Parameter Guide, the Known-good Options for `provider_options` and a docs link, and which Models are the defaults. Use it to choose a Model by what it accepts and costs. Pass `model` for one Model in detail: its full Parameter Guide (or the Provider's own parameter schema, which wins), the values of its Known-good and suspect options, its five most recent Ratings, and the Picks of up to five Comparisons it took part in (most recently picked first).",
24965
26863
  inputSchema: {
24966
26864
  model: string2().optional().describe("Alias of one Model to describe in detail. Omit to list every Model.")
24967
26865
  },
24968
26866
  outputSchema: {
24969
26867
  default_model: string2().nullable().describe("Alias of the default image Model, used when `generate_image` gets no `model`."),
26868
+ default_video_model: string2().nullable().describe("Alias of the default video Model, used when `generate_video` gets no `model`."),
24970
26869
  models: array(listedModelSchema)
24971
26870
  }
24972
26871
  }, async ({ model: alias }) => {
24973
26872
  try {
24974
26873
  const config = await loadConfig(deps.dirs);
24975
26874
  if (config.models.length === 0)
24976
- throw needsSettings("litevision has no image Model configured yet.");
24977
- const defaultModel = defaultModelAlias(config) ?? null;
26875
+ throw needsSettings("litevision has no Model configured yet.");
26876
+ const defaultModel = defaultModelAlias(config, "image") ?? null;
26877
+ const defaultVideoModel = defaultModelAlias(config, "video") ?? null;
24978
26878
  let models = config.models;
24979
26879
  if (alias !== undefined) {
24980
26880
  models = models.filter((model) => model.alias === alias);
@@ -24982,12 +26882,13 @@ ${job.files.join(`
24982
26882
  throw new ConfigError(`Unknown Model Alias "${alias}". Known Aliases: ${config.models.map((model) => model.alias).join(", ")}.`);
24983
26883
  }
24984
26884
  }
24985
- const entries = await Promise.all(models.map((model) => listedModel(infoDeps, config, model, model.alias === defaultModel, alias !== undefined)));
26885
+ const ratedJobs = await readRatedJobs(deps.dirs);
26886
+ const entries = await Promise.all(models.map((model) => listedModel(infoDeps, config, model, model.alias === (model.modality === "video" ? defaultVideoModel : defaultModel), alias !== undefined, ratedJobs)));
24986
26887
  const text = entries.map((entry) => entry.text).join(`
24987
26888
  `);
24988
26889
  return {
24989
26890
  content: [{ type: "text", text }],
24990
- structuredContent: { default_model: defaultModel, models: entries.map((entry) => entry.output) }
26891
+ structuredContent: { default_model: defaultModel, default_video_model: defaultVideoModel, models: entries.map((entry) => entry.output) }
24991
26892
  };
24992
26893
  } catch (error) {
24993
26894
  if (error instanceof ConfigError)