2026-10-16 07:45:57 +00:00
// venice.js: the Venice API calls Havre makes, straight from the browser with the visitor's own key.
// The page's security policy only lets it connect to api.venice.ai (and, without the key, to the private-share links
// Venice hands out for clips from its private video models), so the key can't be sent anywhere else.
2026-10-16 02:45:51 +00:00
// Docs: https://docs.venice.ai/ (OpenAI-compatible; character_slug and friends live in venice_parameters)
export const BASE = "https://api.venice.ai/api/v1" ;
export class VeniceError extends Error {
2026-10-16 23:33:52 +00:00
/**
* refused: Venice turned it down as against its content rules (its own, or the ones of the model's maker). One that
* comes from Venice's answer also carries the answer (said, code, body), and one from a picture or clip call carries
* what was sent (sent: endpoint, model, pictures, safe_mode, prompt), for the page's "what venice saw".
*/
2026-10-16 20:04:24 +00:00
constructor ( message , status , refused = false ) { super ( message ); this . status = status ; this . refused = !! refused ; }
2026-10-16 02:45:51 +00:00
}
2026-10-16 20:04:24 +00:00
/**
* What went wrong, from Venice's answer, as an error to throw. A 422 is a no on content when Venice says so (its
* CONTENT_POLICY_VIOLATION) or says nothing; its other 422s (a clip too long, a picture too big) are what they say.
*/
function failure ( status , body ) {
const e = body && body . error ;
const said = String (( e && typeof e === "object" ? e . message : e ) || ( body && body . message ) || "" ). slice ( 0 , 240 );
const code = String (( e && typeof e === "object" && e . code ) || ( body && body . code ) || "" );
const refused = status === 422 && ( /CONTENT_POLICY/i . test ( code ) || /content polic|violat|moderat|blocked|safety/i . test ( said ) || ! said );
2026-10-16 23:33:52 +00:00
const err = new VeniceError ( explain ( status , said , refused ), status , refused );
// Venice's answer as it came, for the "what venice saw" block: its words, its code, and the body when it says nothing
err . said = said ;
err . code = code ;
err . body = body == null ? "" : (() => { try { return JSON . stringify ( body ). slice ( 0 , 600 ); } catch { return "" ; } })();
return err ;
}
/** what a picture or a clip call sent, kept on the error it failed with (sent), so the page can show it */
function sent ( e , what ) {
if ( e && e . name !== "AbortError" ) e . sent = what ;
return e ;
2026-10-16 20:04:24 +00:00
}
function explain ( status , said , refused ) {
// Venice's own words for it ("violates the content policy of Venice.ai or the model provider") say no more than this
if ( refused ) return "Venice turned that down as against its content rules" + ( /content polic/i . test ( said ) || ! said ? "." : ` (“ ${ said . replace ( /[.\s]+$/ , "" ) } ”).` );
2026-10-16 02:45:51 +00:00
if ( status === 401 || status === 403 ) return "Venice didn't accept that key. Check it at venice.ai → Settings → API." ;
if ( status === 402 ) return "Venice says the key is out of credit." ;
if ( status === 429 ) return "Venice is rate-limiting this key. Wait a moment and try again." ;
if ( status === 404 ) return "Venice doesn't know that " + ( said . includes ( "model" ) ? "model." : "character or model." );
2026-10-16 07:45:57 +00:00
if ( status === 413 ) return "That file is too big for Venice." ;
if ( status === 503 ) return "That model is busy at Venice right now. Try again in a moment, or pick another." ;
2026-10-16 02:45:51 +00:00
if ( status >= 500 ) return "Venice had a problem (" + status + "). Try again in a moment." ;
return said || "Venice answered " + status + "." ;
}
2026-10-16 07:45:57 +00:00
/** a data: URI's base64, or a URL as it is: what Venice's image endpoints take */
const imageInput = ( s ) => ( /^data:image\/[a-z0-9.+-]+;base64,/i . test ( s ) ? s . slice ( s . indexOf ( "," ) + 1 ) : s );
const usdOf = ( x ) => ( x && typeof x . usd === "number" ? x . usd : null );
2026-10-16 02:45:51 +00:00
export class Venice {
/** key: a Venice API key, or "" for the calls that need none (the model list) */
constructor ( key = "" , { fetch : f = globalThis . fetch . bind ( globalThis ), base = BASE } = {}) {
this . key = key ;
this . fetch = f ;
this . base = base ;
}
async call ( path , { method = "GET" , body , signal , auth = true } = {}) {
const headers = { Accept : "application/json" };
if ( body ) headers [ "Content-Type" ] = "application/json" ;
if ( auth ) {
if ( ! this . key ) throw new VeniceError ( "Add your Venice API key first." , 0 );
headers . Authorization = "Bearer " + this . key ;
}
let res ;
try {
res = await this . fetch ( this . base + path , { method , headers , body : body ? JSON . stringify ( body ) : undefined , signal });
} catch ( e ) {
if ( e && e . name === "AbortError" ) throw e ;
throw new VeniceError ( "Couldn't reach Venice. Check your connection." , 0 );
}
let data = null ;
try { data = await res . json (); } catch { data = null ; }
2026-10-16 20:04:24 +00:00
if ( ! res . ok ) throw failure ( res . status , data );
2026-10-16 02:45:51 +00:00
return data ;
}
2026-10-16 07:45:57 +00:00
/** a call whose answer may be a file (a picture, a voice note, a video): the response itself, once it's ok */
async raw ( path , { method = "POST" , body , form , signal } = {}) {
if ( ! this . key ) throw new VeniceError ( "Add your Venice API key first." , 0 );
const headers = { Authorization : "Bearer " + this . key };
if ( body ) headers [ "Content-Type" ] = "application/json" ;
let res ;
try {
res = await this . fetch ( this . base + path , { method , headers , body : form || ( body ? JSON . stringify ( body ) : undefined ), signal });
} catch ( e ) {
if ( e && e . name === "AbortError" ) throw e ;
throw new VeniceError ( "Couldn't reach Venice. Check your connection." , 0 );
}
if ( ! res . ok ) {
let data = null ;
try { data = await res . json (); } catch { data = null ; }
2026-10-16 20:04:24 +00:00
throw failure ( res . status , data );
2026-10-16 07:45:57 +00:00
}
return res ;
}
/**
* Venice's other models, by type: inpaint (photo edits), image, tts (voices), asr (hearing) or video. No key needed.
* Each comes with what the page shows and needs: its price, its voices, its lengths and sizes.
*/
async mediaModels ( type ) {
const d = await this . call ( "/models?type=" + encodeURIComponent ( type ), { auth : false });
return ( d && d . data || []). filter (( m ) => m && ! ( m . model_spec && ( m . model_spec . offline || m . model_spec . beta ))). map (( m ) => mediaInfo ( type , m ));
}
/**
2026-10-16 16:42:20 +00:00
* A photo: an edit of one or more pictures (URLs or data: URIs; the first is the base). Venice's safe mode is on
* unless safe is false, which Havre passes only for an adult character whose pictures it made itself (abilities.js
* adultCheck). Returns { blob, blurred }.
2026-10-16 07:45:57 +00:00
*/
2026-10-16 16:42:20 +00:00
async editImage ({ model , prompt , images , safe = true , signal } = {}) {
2026-10-16 07:45:57 +00:00
const pics = ( images || []). filter ( Boolean );
if ( ! pics . length ) throw new VeniceError ( "A photo needs at least one picture of the character." , 0 );
2026-10-16 23:33:52 +00:00
const safe_mode = safe !== false , endpoint = pics . length === 1 ? "image/edit" : "image/multi-edit" ;
let res ;
try {
res = pics . length === 1
? await this . raw ( "/image/edit" , { body : { model , prompt , image : imageInput ( pics [ 0 ]), safe_mode , output_format : "webp" }, signal })
: await this . raw ( "/image/multi-edit" , { body : { modelId : model , prompt , images : pics . map ( imageInput ), safe_mode , output_format : "webp" }, signal });
} catch ( e ) { throw sent ( e , { endpoint , model , pictures : pics . length , safe_mode , prompt }); }
2026-10-16 07:45:57 +00:00
return { blob : await res . blob (), blurred : res . headers && res . headers . get ? res . headers . get ( "x-venice-is-blurred" ) === "true" : false };
}
2026-10-16 17:28:25 +00:00
/**
* A picture from words alone (a portrait for a character's card). Venice's safe mode is on unless safe is false,
* which Havre passes only for an adult character (abilities.js adultCheck, drawn). Returns { blob, blurred }.
*/
async generateImage ({ model , prompt , aspect = "" , safe = true , signal } = {}) {
const body = { model , prompt , safe_mode : safe !== false , format : "webp" , return_binary : true };
2026-10-16 07:45:57 +00:00
if ( aspect ) body . aspect_ratio = aspect ;
2026-10-16 23:33:52 +00:00
let res ;
try { res = await this . raw ( "/image/generate" , { body , signal }); }
catch ( e ) { throw sent ( e , { endpoint : "image/generate" , model , pictures : 0 , safe_mode : body . safe_mode , prompt }); }
2026-10-16 07:45:57 +00:00
return { blob : await res . blob (), blurred : res . headers && res . headers . get ? res . headers . get ( "x-venice-is-blurred" ) === "true" : false };
}
/** a voice note: words read out by a voice model. mood goes to models that take a style prompt; the rest ignore it. */
async speak ({ model , voice , input , mood = "" , speed = 1 , signal } = {}) {
const body = { input : String ( input || "" ). slice ( 0 , 4096 ), model , speed };
if ( voice ) body . voice = voice ;
if ( mood ) body . prompt = String ( mood ). slice ( 0 , 500 );
const res = await this . raw ( "/audio/speech" , { body , signal });
return res . blob ();
}
/** what was said in a recording: { text, duration } */
async transcribe ({ file , name = "voice.webm" , model , signal } = {}) {
const form = new FormData ();
form . append ( "file" , file , name );
if ( model ) form . append ( "model" , model );
form . append ( "response_format" , "json" );
const res = await this . raw ( "/audio/transcriptions" , { form , signal });
const d = await res . json ();
return { text : String ( d && d . text || "" ). trim (), duration : d && typeof d . duration === "number" ? d . duration : null };
}
/** what a clip would cost, in USD, before anything is made */
async quoteVideo ({ model , duration , resolution = "" , aspect = "" , audio , signal } = {}) {
const body = { model , duration };
if ( resolution ) body . resolution = resolution ;
if ( aspect ) body . aspect_ratio = aspect ;
if ( typeof audio === "boolean" ) body . audio = audio ;
const d = await this . call ( "/video/quote" , { method : "POST" , body , signal });
return typeof ( d && d . quote ) === "number" ? d . quote : null ;
}
/**
* Start a clip. images are the character's pictures: reference-to-video models get them all, other models the
* first one. Returns { queueId, downloadUrl } (the second only from Venice's private models).
*/
async queueVideo ({ model , prompt , duration , resolution = "" , aspect = "" , audio , images = [], signal } = {}) {
const body = { model , prompt , duration };
if ( resolution ) body . resolution = resolution ;
if ( aspect ) body . aspect_ratio = aspect ;
if ( typeof audio === "boolean" ) body . audio = audio ;
const pics = images . filter ( Boolean );
if ( /reference-to-video/ . test ( model )) { if ( pics . length ) body . reference_image_urls = pics . slice ( 0 , 4 ); }
else if ( pics . length ) body . image_url = pics [ 0 ];
2026-10-16 23:33:52 +00:00
let d ;
try { d = await this . call ( "/video/queue" , { method : "POST" , body , signal }); }
catch ( e ) { throw sent ( e , { endpoint : "video/queue" , model , pictures : Math . min ( pics . length , /reference-to-video/ . test ( model ) ? 4 : 1 ), prompt }); }
2026-10-16 07:45:57 +00:00
if ( ! d || ! d . queue_id ) throw new VeniceError ( "Venice didn't start the clip." , 0 );
return { queueId : d . queue_id , downloadUrl : typeof d . download_url === "string" ? d . download_url : "" };
}
/**
* How a clip is coming along: { done: false, elapsed, expected } while it's being made, then { done: true, blob }
* (or { done: true, blob: null } when it waits at its download link). Venice keeps its copy until completeVideo(),
* so a download that breaks halfway can be tried again.
*/
async retrieveVideo ({ model , queueId , signal } = {}) {
const res = await this . raw ( "/video/retrieve" , { body : { model , queue_id : queueId }, signal });
const type = ( res . headers && res . headers . get && res . headers . get ( "content-type" )) || "" ;
let d ;
try {
if ( /^(video\/|application\/octet-stream)/i . test ( type )) return { done : true , blob : await res . blob () };
d = await res . json ();
} catch ( e ) {
if ( e && e . name === "AbortError" ) throw e ;
throw new VeniceError ( "Couldn't read Venice's answer about the clip." , 0 );
}
const status = String ( d && d . status || "" ). toUpperCase ();
if ( status === "COMPLETED" ) return { done : true , blob : null };
if ( /FAIL|ERROR|CANCEL|REJECT|EXPIRE/ . test ( status )) throw new VeniceError ( `Venice couldn't make the clip ( ${ status . toLowerCase () } ).` , - 2 );
return { done : false , elapsed : d && d . execution_duration || 0 , expected : d && d . average_execution_time || 0 };
}
/** a clip waiting at its private download link (no key goes with it) */
async downloadVideo ( url , { signal } = {}) {
if ( ! /^https:\/\/private-share\.venice\.ai\// . test ( url )) throw new VeniceError ( "That isn't a Venice download link." , - 1 );
let res ;
try { res = await this . fetch ( url , { signal }); } catch ( e ) {
if ( e && e . name === "AbortError" ) throw e ;
throw new VeniceError ( "Couldn't download the clip from Venice." , 0 );
}
if ( ! res . ok ) throw new VeniceError ( res . status === 410 ? "Venice has already deleted that clip." : "Couldn't download the clip (" + res . status + ")." , res . status );
try { return await res . blob (); } catch { throw new VeniceError ( "The clip's download broke off." , 0 ); }
}
/** revoke a private download link once the clip is safely home (no key goes with it either) */
async revokeDownload ( url ) {
if ( ! /^https:\/\/private-share\.venice\.ai\// . test ( url )) return false ;
try { await this . fetch ( url , { method : "DELETE" }); return true ; } catch { return false ; } // it lapses within a day anyway
}
/** tell Venice the clip is home, so it deletes its copy */
async completeVideo ({ model , queueId } = {}) {
try { await this . call ( "/video/complete" , { method : "POST" , body : { model , queue_id : queueId } }); return true ; } catch { return false ; }
}
2026-10-16 06:07:41 +00:00
/**
* Text models: what each can do, how private it is, and what it costs (USD per million tokens). No key needed.
* Models that are offline, or open only to Venice's beta testers, are left out.
*/
2026-10-16 02:45:51 +00:00
async models () {
const d = await this . call ( "/models?type=text" , { auth : false });
2026-10-16 06:07:41 +00:00
return ( d && d . data || []). filter (( m ) => m && ! ( m . model_spec && ( m . model_spec . offline || m . model_spec . beta ))). map (( m ) => {
const s = m . model_spec || {}, p = s . pricing || {}, c = s . capabilities || {}, dep = s . deprecation || null ;
2026-10-16 02:45:51 +00:00
return {
id : m . id , name : s . name || m . id , ctx : s . availableContextTokens || 0 , traits : s . traits || [],
input : p . input && p . input . usd , output : p . output && p . output . usd ,
2026-10-16 06:07:41 +00:00
privacy : s . privacy || "" , uncensored : !! s . uncensored ,
vision : !! c . supportsVision , tools : !! c . supportsFunctionCalling , reasoning : !! c . supportsReasoning , web : !! c . supportsWebSearch ,
json : !! c . supportsResponseSchema ,
retiring : dep ? String ( dep . removesAt || dep . date || "soon" ). slice ( 0 , 10 ) : "" ,
2026-10-16 02:45:51 +00:00
};
});
}
2026-10-16 06:07:41 +00:00
/** what is left to spend on this key: { amount, currency, canConsume } */
async balance () {
const d = await this . call ( "/billing/balance" );
const b = d && d . balances || {}, currency = d && d . consumptionCurrency || "USD" ;
const amount = { DIEM : b . diem , BUNDLED_CREDITS : b . bundledCredits , EARNED_CREDITS : b . earnedCredits }[ currency ] ?? b . usd ;
return { amount : typeof amount === "number" ? amount : null , currency , canConsume : !! ( d && d . canConsume ) };
}
2026-10-16 05:21:53 +00:00
/**
2026-10-16 10:43:10 +00:00
* One page of public Venice characters: { list, more }. adult: true lists the adult ones, and only them (left
* unasked, Venice mixes them into the rest, where the first pages barely change); otherwise there are none. sort:
* featured, imports, highestRating, mostRecent (or any other sortBy Venice takes); search matches names,
* descriptions and #tags; tags narrows to a tag.
2026-10-16 05:21:53 +00:00
*/
2026-10-16 06:07:41 +00:00
async characterPage ({ search = "" , limit = 24 , offset = 0 , sort = "" , tags = "" , adult = false } = {}) {
const q = new URLSearchParams ({ limit : String ( limit ), offset : String ( offset ) });
2026-10-16 10:43:10 +00:00
q . set ( "isAdult" , adult ? "true" : "false" );
2026-10-16 02:45:51 +00:00
if ( search ) q . set ( "search" , search );
2026-10-16 05:21:53 +00:00
if ( sort ) q . set ( "sortBy" , sort );
2026-10-16 06:07:41 +00:00
if ( tags ) q . set ( "tags" , tags );
2026-10-16 05:21:53 +00:00
let d ;
try {
d = await this . call ( "/characters?" + q );
} catch ( e ) {
if ( e . status !== 400 ) throw e ;
2026-10-16 06:07:41 +00:00
for ( const k of [ "sortBy" , "isAdult" , "tags" ]) q . delete ( k ); // asked for something this API doesn't take: ask plainly
2026-10-16 05:21:53 +00:00
d = await this . call ( "/characters?" + q );
}
const raw = d && Array . isArray ( d . data ) ? d . data : [];
2026-10-16 10:43:10 +00:00
return { list : raw . filter (( c ) => c && !! c . adult === !! adult ). map ( characterInfo ), more : raw . length >= limit };
2026-10-16 05:21:53 +00:00
}
2026-10-16 10:43:10 +00:00
/** public Venice characters (the adult ones with adult: true, else the rest) */
2026-10-16 05:21:53 +00:00
async characters ( opts = {}) {
return ( await this . characterPage ( opts )). list ;
2026-10-16 02:45:51 +00:00
}
2026-10-16 06:07:41 +00:00
async character ( slug , { adult = false } = {}) {
2026-10-16 02:45:51 +00:00
const d = await this . call ( "/characters/" + encodeURIComponent ( slug ));
const c = d && d . data ;
if ( ! c ) throw new VeniceError ( "Venice doesn't know that character." , 404 );
2026-10-16 06:07:41 +00:00
if ( c . adult && ! adult ) throw new VeniceError ( "That character is marked adult. Switch on adult characters to see them." , 451 );
2026-10-16 02:45:51 +00:00
return characterInfo ( c );
}
/**
* One chat completion. characterSlug: speak through a public Venice character (its own prompt, which Havre never
2026-10-16 06:07:41 +00:00
* sees). veniceSystemPrompt: keep Venice's own system prompt (a plain chat with no character). schema: ask for JSON
* matching it. Returns { text, usage }.
2026-10-16 02:45:51 +00:00
*/
2026-10-16 06:07:41 +00:00
async chat ({ model , messages , characterSlug = "" , veniceSystemPrompt = false , schema = null , maxTokens = 700 , temperature = 0.8 , signal } = {}) {
2026-10-16 02:45:51 +00:00
// short-form chat wants fast, plain replies: no reasoning pass (a reasoning model can spend the whole budget
// thinking and send back nothing), and only Havre's own system prompt
2026-10-16 06:07:41 +00:00
const venice_parameters = { disable_thinking : true , strip_thinking_response : true , include_venice_system_prompt : !! veniceSystemPrompt };
2026-10-16 02:45:51 +00:00
if ( characterSlug ) { venice_parameters . character_slug = characterSlug ; delete venice_parameters . include_venice_system_prompt ; }
const body = { model , messages , max_tokens : maxTokens , temperature , stream : false , venice_parameters };
if ( schema ) body . response_format = { type : "json_schema" , json_schema : { name : "result" , strict : true , schema } };
let d ;
try {
d = await this . call ( "/chat/completions" , { method : "POST" , body , signal });
} catch ( e ) {
if ( ! ( schema && e . status === 400 )) throw e ;
delete body . response_format ; // a model without structured output: ask in words
d = await this . call ( "/chat/completions" , { method : "POST" , body , signal });
}
const msg = d && d . choices && d . choices [ 0 ] && d . choices [ 0 ]. message || {};
return { text : String ( msg . content || "" ), usage : d && d . usage || null };
}
}
2026-10-16 07:45:57 +00:00
function mediaInfo ( type , m ) {
const s = m . model_spec || {}, p = s . pricing || {}, c = s . constraints || {}, dep = s . deprecation || null ;
const base = { id : m . id , name : s . name || m . id , privacy : s . privacy || "" , uncensored : !! s . uncensored , traits : s . traits || [],
retiring : dep ? String ( dep . removesAt || dep . date || "soon" ). slice ( 0 , 10 ) : "" };
const oneK = p . resolutions && p . resolutions [ "1K" ];
if ( type === "inpaint" ) {
return { ... base , price : usdOf ( oneK ) ?? usdOf ( p . inpaint ), extra : p . inputImages ? usdOf ( p . inputImages . additional ) : null ,
included : p . inputImages && typeof p . inputImages . included === "number" ? p . inputImages . included : 1 ,
maxIn : c . maxInputImages || ( c . combineImages ? 3 : 1 ), promptLimit : c . promptCharacterLimit || 1500 };
}
if ( type === "image" ) return { ... base , price : usdOf ( p . generation ) ?? usdOf ( oneK ), aspects : c . aspectRatios || [], promptLimit : c . promptCharacterLimit || 1500 };
if ( type === "tts" ) return { ... base , perMChars : usdOf ( p . input ), voices : s . voices || [], format : s . default_format || "" };
if ( type === "asr" ) return { ... base , perSecond : usdOf ( p . per_audio_second ) };
if ( type === "video" ) {
return { ... base , kind : c . model_type || "" , ref : /reference-to-video/ . test ( m . id ), durations : c . durations || [],
resolutions : c . resolutions || [], aspects : c . aspect_ratios || [], audio : !! c . audio , audioConfigurable : !! c . audio_configurable ,
promptLimit : c . prompt_character_limit || 2500 };
}
return base ;
}
2026-10-16 02:45:51 +00:00
function characterInfo ( c ) {
return {
slug : c . slug , name : c . name || c . slug , description : c . description || "" , creator : c . author || "" ,
photo : /^https:\/\/outerface\.venice\.ai\// . test ( c . photoUrl || "" ) ? c . photoUrl : "" ,
url : "https://venice.ai/c/" + encodeURIComponent ( c . slug ), model : c . modelId || "" , tags : c . tags || [],
2026-10-16 05:21:53 +00:00
imports : c . stats && c . stats . imports || 0 , rating : c . stats && c . stats . averageRating || 0 , web : !! c . webEnabled ,
2026-10-16 06:07:41 +00:00
adult : !! c . adult ,
2026-10-16 02:45:51 +00:00
};
}