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