Venice improvements

This commit is contained in:
Aine
2026-06-22 17:18:08 +01:00
parent 2ae641109f
commit 9e5f6de965
12 changed files with 668 additions and 34 deletions

View File

@@ -1,3 +1,9 @@
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::OnceLock;
use regex::Regex;
use crate::agent::AgentPurpose;
use crate::agent::provider::entity::{TextGenerationParams, TextGenerationResult};
use crate::conversation::llm::{
@@ -6,9 +12,9 @@ use crate::conversation::llm::{
};
use crate::strings;
use super::config::Config;
use super::config::{Config, WebSearchMode};
use super::utils::convert_llm_messages_to_venice;
use super::wire::{ChatCompletionRequest, ChatCompletionResponse};
use super::wire::{ChatCompletionRequest, ChatCompletionResponse, WebSearchCitation};
pub async fn generate_text(
config: &Config,
@@ -31,6 +37,17 @@ pub async fn generate_text(
.trim(),
);
// Prompt-cache routing key. Hash ONLY conversation-stable inputs: the rendered system prompt
// and the conversation start time. Folding in anything per-turn (message content, the current
// time, the message count) would mint a fresh key every turn, miss the cache every lookup, and
// pay full price plus the hashing cost. The start time is rendered explicitly here so the key
// stays stable even when the user's prompt template never mentions the time variable; an
// unknown start time renders "unknown" and simply keys on the prompt alone.
let conversation_start_time = params
.prompt_variables
.format("{{ baibot_conversation_start_time_utc }}");
let prompt_cache_key = derive_prompt_cache_key(&prompt_text, &conversation_start_time);
let prompt_message = if prompt_text.is_empty() {
None
} else {
@@ -58,12 +75,29 @@ pub async fn generate_text(
conversation_messages.insert(0, prompt_message);
}
let messages = convert_llm_messages_to_venice(conversation_messages);
let messages = convert_llm_messages_to_venice(conversation_messages)?;
let temperature = params
.temperature_override
.unwrap_or(text_generation_config.temperature);
// When web search is active, ask Venice to return structured search results so we can render
// readable citations from them. Respect an explicit user choice and only fill the flag when
// the user left it unset.
let venice_parameters = text_generation_config
.venice_parameters
.clone()
.map(|mut vp| {
let web_search_active = matches!(
vp.enable_web_search,
Some(WebSearchMode::On | WebSearchMode::Auto)
);
if web_search_active && vp.return_search_results_as_documents.is_none() {
vp.return_search_results_as_documents = Some(true);
}
vp
});
let request = ChatCompletionRequest {
model: text_generation_config.model_id.clone(),
messages,
@@ -71,7 +105,14 @@ pub async fn generate_text(
// Web search rides entirely inside `venice_parameters`; there is no `tools` array here.
// `max_tokens` is deprecated on Venice in favor of `max_completion_tokens`.
max_completion_tokens: text_generation_config.max_response_tokens,
venice_parameters: text_generation_config.venice_parameters.clone(),
top_p: text_generation_config.top_p,
frequency_penalty: text_generation_config.frequency_penalty,
presence_penalty: text_generation_config.presence_penalty,
repetition_penalty: text_generation_config.repetition_penalty,
reasoning_effort: text_generation_config.reasoning_effort.clone(),
prompt_cache_key: Some(prompt_cache_key),
prompt_cache_retention: text_generation_config.prompt_cache_retention.clone(),
venice_parameters,
};
let url = format!(
@@ -99,6 +140,16 @@ pub async fn generate_text(
// body can carry account / rate-limit details that shouldn't reach room members.
let body = response.text().await.unwrap_or_default();
tracing::warn!(%status, body, "Venice chat completion request failed");
// A 413 almost always means an attached file pushed the request past Venice's size limit.
// Surface a clear, actionable message rather than the opaque status; the raw body still
// stays out of the room for the reason above.
if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE {
return Err(anyhow::anyhow!(
"The request was too large for Venice, most likely an attached file over the 25MB limit."
));
}
return Err(anyhow::anyhow!(
"Venice chat completion request failed with status {status}"
));
@@ -106,6 +157,11 @@ pub async fn generate_text(
let response: ChatCompletionResponse = response.json().await?;
let citations = response
.venice_parameters
.map(|vp| vp.web_search_citations)
.unwrap_or_default();
let Some(choice) = response.choices.into_iter().next() else {
return Err(anyhow::anyhow!(
"No choices were returned from the Venice chat completion API"
@@ -118,5 +174,125 @@ pub async fn generate_text(
));
};
Ok(TextGenerationResult { text: content })
let text = render_with_citations(content, &citations);
let text = append_reasoning(
text,
choice.message.reasoning_content,
text_generation_config.show_reasoning,
);
Ok(TextGenerationResult { text })
}
/// Builds the prompt-cache routing key from conversation-stable inputs. `DefaultHasher::new()` is a
/// fixed-seed SipHasher (keys 0,0), so it is deterministic across processes and restarts: identical
/// inputs always produce the same key, which is what lets a restarted bot keep hitting the warm
/// cache. The algorithm is not guaranteed stable across Rust std versions, so a rebuild on a new
/// toolchain can shift every key once, a one-time cache warm-up with no correctness effect.
pub(super) fn derive_prompt_cache_key(prompt_text: &str, conversation_start_time: &str) -> String {
let mut hasher = DefaultHasher::new();
prompt_text.hash(&mut hasher);
conversation_start_time.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
/// Appends the model's thinking to the reply only when the deployment opts in via `show_reasoning`.
/// `reasoning_content` is a field separate from the answer `content` (it is unaffected by
/// `strip_thinking_response`, which only strips inline `<think>` blocks from `content`), so reading
/// it here is independent of that knob. Default-off matches today's behavior: thinking never reaches
/// a room that did not ask for it.
pub(super) fn append_reasoning(
text: String,
reasoning_content: Option<String>,
show_reasoning: bool,
) -> String {
if !show_reasoning {
return text;
}
match reasoning_content {
Some(reasoning) if !reasoning.trim().is_empty() => {
format!("{text}\n\n---\n\n*Reasoning:*\n\n{reasoning}")
}
_ => text,
}
}
/// Rewrites Venice's inline `^n^` citation superscripts into readable `[n]` references and appends
/// a `Sources:` list of markdown links, one per citation in order. Returns the content unchanged
/// when web search returned no citations, so non-search replies are never touched.
///
/// Citation `title` and `url` come from scraped web pages, so they are attacker-influenced. The
/// title is escaped so it cannot break out of the markdown link label, and the URL is used as a
/// link target only when it is a clean `http(s)` URL with no markdown-breaking characters;
/// otherwise the citation renders as plain text. This stops a hostile page title or URL from
/// injecting a spoofed clickable link into the room.
pub(super) fn render_with_citations(content: String, citations: &[WebSearchCitation]) -> String {
if citations.is_empty() {
return content;
}
let mut text = rewrite_citation_superscripts(&content);
let mut sources = String::from("\n\nSources:");
for (index, citation) in citations.iter().enumerate() {
let n = index + 1;
let title = escape_markdown_link_text(&citation.title);
match sanitize_link_url(&citation.url) {
// A citation that arrived with no title still renders as a usable link by showing the
// URL as the link text, rather than an empty `[]( )` label.
Some(url) if title.is_empty() => sources.push_str(&format!("\n[{n}] [{url}]({url})")),
Some(url) => sources.push_str(&format!("\n[{n}] [{title}]({url})")),
None if !title.is_empty() => sources.push_str(&format!("\n[{n}] {title}")),
None => sources.push_str(&format!("\n[{n}] (source unavailable)")),
}
}
text.push_str(&sources);
text
}
/// Venice marks web-search citations with superscript runs in the reply text: a single `^1^`, a
/// comma list `^1,2^`, or a caret-chained run `^2^3^10^` where consecutive citations share a
/// caret. The whole run has to be matched at once: a per-citation pattern (string or regex)
/// consumes the shared caret on the first match and orphans the rest (`^2^3^` would leave `3^`).
/// So this matches each full run and expands it to one `[n]` per citation (`^2^3^` -> `[2][3]`).
fn rewrite_citation_superscripts(content: &str) -> String {
static RUN: OnceLock<Regex> = OnceLock::new();
let run = RUN.get_or_init(|| {
Regex::new(r"\^\d+(?:[,^]\d+)*\^").expect("citation superscript regex is valid")
});
run.replace_all(content, |caps: &regex::Captures| {
caps[0]
.split(['^', ','])
.filter(|piece| !piece.is_empty())
.map(|n| format!("[{n}]"))
.collect::<String>()
})
.into_owned()
}
/// Escapes the characters that would let citation title text break out of a markdown link label,
/// and folds newlines to spaces so a multi-line title cannot inject extra markdown structure.
fn escape_markdown_link_text(text: &str) -> String {
text.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
.replace(['\r', '\n'], " ")
}
/// Returns the URL as a markdown link target only when it is a clean `http(s)` URL with no
/// characters that would break the `(...)` destination or smuggle a different scheme. Anything else
/// returns `None`, so the caller renders the citation as plain text instead of a link.
fn sanitize_link_url(url: &str) -> Option<String> {
let url = url.trim();
let is_http = url.starts_with("https://") || url.starts_with("http://");
let is_clean = !url.contains(['(', ')', '<', '>', ' ', '\t', '\r', '\n']);
if is_http && is_clean {
Some(url.to_owned())
} else {
None
}
}

View File

@@ -61,6 +61,39 @@ pub struct TextGenerationConfig {
#[serde(default)]
pub max_context_tokens: u32,
/// Sampling and reasoning knobs that live at the top level of Venice's `/chat/completions`
/// body, not inside the `venice_parameters` bag. Venice silently ignores a top-level knob
/// placed in the bag, so these sit here as siblings and map straight to top-level wire fields
/// in `chat.rs`. Each is omitted from the request when unset.
#[serde(default)]
pub top_p: Option<f32>,
#[serde(default)]
pub frequency_penalty: Option<f32>,
#[serde(default)]
pub presence_penalty: Option<f32>,
#[serde(default)]
pub repetition_penalty: Option<f32>,
#[serde(default)]
pub reasoning_effort: Option<String>,
/// Prompt-cache retention window (`default`, `extended`, or `24h`). This carries a named
/// default rather than a bare `#[serde(default)]` (which would yield `None`), so a config that
/// omits the key still ships `24h` and keeps caching on. Caching is the per-deployment cost
/// lever, so the omitted-key case must not silently disable it. The value here must agree with
/// the `Default` impl below.
#[serde(default = "default_prompt_cache_retention")]
pub prompt_cache_retention: Option<String>,
/// When set, the model's `reasoning_content` (its thinking) is appended to the reply. Off by
/// default to match today's `strip_thinking_response: true` behavior, so existing deployments
/// see no change.
#[serde(default)]
pub show_reasoning: bool,
/// Venice-specific request knobs, serialized 1:1 into the `venice_parameters` bag on the
/// wire. Any unset field is omitted, so Venice applies its own server-side default.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -79,6 +112,16 @@ impl Default for TextGenerationConfig {
// Matches Venice's own `availableContextTokens` (131072) and the non-OpenAI sibling
// providers (ollama/localai/mistral all default to 128_000).
max_context_tokens: 128_000,
// Sampling knobs stay None so Venice applies its own server-side default. Caching is
// the one exception: retention defaults to 24h here so a programmatic default caches
// out of the box, agreeing with the `#[serde(default = ...)]` on the field.
top_p: None,
frequency_penalty: None,
presence_penalty: None,
repetition_penalty: None,
reasoning_effort: None,
prompt_cache_retention: default_prompt_cache_retention(),
show_reasoning: false,
// A usable starting point, not an everything-set dump: only these three are sent;
// every other knob stays None so Venice applies its own default (omitting != false).
venice_parameters: Some(VeniceParameters {
@@ -95,6 +138,13 @@ fn default_text_model_id() -> String {
"kimi-k2-5".to_owned()
}
/// Defaults prompt-cache retention to 24h so caching is on unless a config explicitly opts out.
/// A bare `#[serde(default)]` would deserialize an omitted key to `None`, which disables caching;
/// this keeps the cost lever engaged for configs that never mention it.
fn default_prompt_cache_retention() -> Option<String> {
Some("24h".to_owned())
}
/// The full `venice_parameters` knob set, mirroring Venice's `ChatCompletionRequest`
/// schema field-for-field. Every field is optional with `skip_serializing_if`, so the
/// request never carries a knob the user didn't set (the body is `additionalProperties: false`,
@@ -133,6 +183,11 @@ pub struct VeniceParameters {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disable_thinking: Option<bool>,
/// Response verbosity (`low`, `medium`, `high`). Venice accepts this both top-level and inside
/// the bag; it lives here so the top-level config stays lean, and Venice reads it from the bag.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbosity: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]

View File

@@ -11,11 +11,13 @@ use crate::conversation::llm::{
MessageContent as LLMMessageContent,
};
use super::config::{Config, VeniceParameters, WebSearchMode};
use super::chat::{append_reasoning, derive_prompt_cache_key, render_with_citations};
use super::config::{Config, TextGenerationConfig, VeniceParameters, WebSearchMode};
use super::controller::Controller;
use super::utils::convert_llm_messages_to_venice;
use super::wire::{
ContentPart, EditImageRequest, GenerateImageRequest, MessageContent, SpeechRequest,
ChatCompletionRequest, ContentPart, EditImageRequest, GenerateImageRequest, MessageContent,
SpeechRequest, WebSearchCitation,
};
#[test]
@@ -59,7 +61,7 @@ speech_to_text:
}
#[test]
fn converts_image_to_data_uri_and_skips_files() {
fn converts_text_image_and_file_to_content_parts() {
let messages = vec![
LLMMessage {
author: LLMAuthor::User,
@@ -95,10 +97,11 @@ fn converts_image_to_data_uri_and_skips_files() {
},
];
let converted = convert_llm_messages_to_venice(messages);
let converted =
convert_llm_messages_to_venice(messages).expect("conversion should succeed");
// Text and image survive; the file is warn-skipped.
assert_eq!(converted.len(), 2);
// Text, image, AND file all survive now: the file is no longer warn-skipped.
assert_eq!(converted.len(), 3);
match &converted[0].content {
MessageContent::Text(text) => assert_eq!(text, "describe this"),
@@ -112,9 +115,25 @@ fn converts_image_to_data_uri_and_skips_files() {
"image should be inlined as a data URI: {}",
image_url.url
),
other => panic!("expected an image part, got {other:?}"),
},
other => panic!("expected image parts, got {other:?}"),
}
match &converted[2].content {
MessageContent::Parts(parts) => match &parts[0] {
ContentPart::File { file } => {
assert!(
file.file_data.starts_with("data:application/pdf;base64,"),
"file should be inlined as a data URI: {}",
file.file_data
);
assert_eq!(file.filename.as_deref(), Some("doc.pdf"));
}
other => panic!("expected a file part, got {other:?}"),
},
other => panic!("expected file parts, got {other:?}"),
}
}
#[test]
@@ -267,3 +286,255 @@ fn web_search_mode_off_deserializes_from_bare_yaml_off() {
assert!(matches!(params.enable_web_search, Some(WebSearchMode::Off)));
}
#[test]
fn request_places_sampling_top_level_and_verbosity_in_the_bag() {
// The whole config-shape decision in one assertion: top-level knobs serialize at the top
// level, the dual-position `verbosity` serializes inside the bag. Venice silently ignores a
// top-level knob misplaced into the bag, so this is the guard against a silent no-op.
let request = ChatCompletionRequest {
model: "kimi-k2-5".to_owned(),
messages: vec![],
temperature: Some(0.5),
max_completion_tokens: Some(1024),
top_p: Some(0.5),
frequency_penalty: None,
presence_penalty: None,
repetition_penalty: None,
reasoning_effort: Some("high".to_owned()),
prompt_cache_key: Some("00000000cafef00d".to_owned()),
prompt_cache_retention: Some("24h".to_owned()),
venice_parameters: Some(VeniceParameters {
verbosity: Some("high".to_owned()),
..Default::default()
}),
};
let json = serde_json::to_value(&request).expect("serialize request");
assert_eq!(json["top_p"], 0.5);
assert_eq!(json["reasoning_effort"], "high");
assert_eq!(json["prompt_cache_retention"], "24h");
assert_eq!(json["prompt_cache_key"], "00000000cafef00d");
assert!(
json.get("verbosity").is_none(),
"verbosity must not be a top-level field: {json}"
);
assert_eq!(json["venice_parameters"]["verbosity"], "high");
assert!(
json["venice_parameters"].get("top_p").is_none(),
"top_p must not be inside the bag: {json}"
);
}
#[test]
fn config_defaults_prompt_cache_retention_to_24h() {
// The programmatic default.
assert_eq!(
TextGenerationConfig::default()
.prompt_cache_retention
.as_deref(),
Some("24h")
);
// A config that omits the key must ALSO default to 24h, via the named serde default. A bare
// `#[serde(default)]` would yield None here and silently disable caching for such configs.
let tg: TextGenerationConfig = serde_yaml_ng::from_str("model_id: kimi-k2-5\n")
.expect("minimal config should deserialize");
assert_eq!(
tg.prompt_cache_retention.as_deref(),
Some("24h"),
"an omitted retention key must still default to 24h"
);
}
#[test]
fn cache_key_is_stable_for_same_inputs_and_varies_otherwise() {
let key = derive_prompt_cache_key("system prompt", "2024-09-20 (Friday), 18:34:15 UTC");
// Identical inputs produce an identical key: this is what keeps turn 5 routing to the warm
// server holding turns 1-4 (and what survives a process restart).
assert_eq!(
key,
derive_prompt_cache_key("system prompt", "2024-09-20 (Friday), 18:34:15 UTC"),
"identical inputs must produce an identical key"
);
assert_eq!(key.len(), 16, "the key is a 16-char hex string");
assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
// A different conversation start time or a different prompt must change the key.
assert_ne!(
key,
derive_prompt_cache_key("system prompt", "2024-09-21 (Saturday), 09:00:00 UTC"),
"a different start time must change the key"
);
assert_ne!(
key,
derive_prompt_cache_key("other prompt", "2024-09-20 (Friday), 18:34:15 UTC"),
"a different prompt must change the key"
);
}
#[test]
fn citations_render_inline_refs_and_a_sources_block() {
let citations = vec![WebSearchCitation {
title: "Example Source".to_owned(),
url: "https://example.com/a".to_owned(),
}];
let rendered = render_with_citations("the sky is blue^1^".to_owned(), &citations);
assert!(
rendered.contains("the sky is blue[1]"),
"inline ^1^ becomes [1]: {rendered}"
);
assert!(
rendered.contains("Sources:"),
"a Sources block is appended: {rendered}"
);
assert!(
rendered.contains("[1] [Example Source](https://example.com/a)"),
"the source renders as a markdown link: {rendered}"
);
}
#[test]
fn citations_absent_leaves_content_untouched() {
let content = "plain answer, no web search".to_owned();
assert_eq!(render_with_citations(content.clone(), &[]), content);
}
#[test]
fn citation_title_and_url_cannot_inject_markdown() {
// A hostile page sets its title to break out of the link label and its URL to a non-http
// scheme. Neither may produce a spoofed clickable link in the room.
let citations = vec![WebSearchCitation {
title: "evil](http://phish.example) take".to_owned(),
url: "javascript:alert(1)".to_owned(),
}];
let rendered = render_with_citations("result^1^".to_owned(), &citations);
assert!(
rendered.contains("evil\\](http://phish.example) take"),
"the title's brackets must be escaped so it cannot close the link label: {rendered}"
);
assert!(
!rendered.contains("(javascript:alert(1))"),
"a non-http(s) URL must never become a markdown link target: {rendered}"
);
}
#[test]
fn chained_and_comma_citation_runs_each_expand_to_separate_refs() {
let citations = vec![
WebSearchCitation {
title: "One".to_owned(),
url: "https://example.com/1".to_owned(),
},
WebSearchCitation {
title: "Two".to_owned(),
url: "https://example.com/2".to_owned(),
},
WebSearchCitation {
title: "Three".to_owned(),
url: "https://example.com/3".to_owned(),
},
];
// Caret-chained run: Venice shares the caret between consecutive citations (`^2^3^`). The whole
// run must expand, not just the first, with no orphaned `3^` left behind.
let chained = render_with_citations("alpha^2^3^ and beta^1^".to_owned(), &citations);
assert!(
chained.contains("alpha[2][3] and beta[1]"),
"a chained ^2^3^ run must expand to [2][3] with no orphaned caret: {chained}"
);
// Comma run.
let comma = render_with_citations("gamma^1,3^".to_owned(), &citations);
assert!(
comma.contains("gamma[1][3]"),
"a comma ^1,3^ run must expand to [1][3]: {comma}"
);
// Multi-digit citation indices survive intact.
let multidigit = render_with_citations("delta^2^10^".to_owned(), &citations);
assert!(
multidigit.contains("delta[2][10]"),
"a multi-digit chained run must expand to [2][10]: {multidigit}"
);
}
#[test]
fn malformed_citation_degrades_instead_of_failing() {
// A citation arriving without a `url` must still deserialize (to an empty default) rather than
// failing the whole response parse and losing an otherwise-good answer.
let parsed: WebSearchCitation = serde_json::from_str(r#"{"title":"Only a title"}"#)
.expect("a citation missing `url` should still deserialize");
assert_eq!(parsed.url, "");
// Rendering citations with missing fields stays graceful: no empty `[]()` link, no panic.
let citations = vec![
WebSearchCitation {
title: String::new(),
url: "https://example.com/u".to_owned(),
},
WebSearchCitation {
title: String::new(),
url: String::new(),
},
];
let rendered = render_with_citations("answer^1^2^".to_owned(), &citations);
assert!(
rendered.contains("[1] [https://example.com/u](https://example.com/u)"),
"a citation with no title falls back to the URL as link text: {rendered}"
);
assert!(
rendered.contains("[2] (source unavailable)"),
"a citation with neither title nor URL renders a placeholder: {rendered}"
);
}
#[test]
fn reasoning_is_appended_only_when_show_reasoning_is_set() {
let base = "the answer".to_owned();
// Off (the default): thinking is dropped, never reaching the room.
let off = append_reasoning(base.clone(), Some("secret thinking".to_owned()), false);
assert_eq!(off, "the answer");
// On: thinking is appended below the answer.
let on = append_reasoning(base.clone(), Some("visible thinking".to_owned()), true);
assert!(on.contains("the answer"));
assert!(on.contains("visible thinking"));
// On but empty or missing reasoning: nothing is appended.
assert_eq!(
append_reasoning(base.clone(), Some(" ".to_owned()), true),
"the answer"
);
assert_eq!(append_reasoning(base.clone(), None, true), "the answer");
}
#[test]
fn oversized_file_is_rejected() {
let messages = vec![LLMMessage {
author: LLMAuthor::User,
sender_id: None,
timestamp: chrono::Utc::now(),
content: LLMMessageContent::File(FileDetails::new(
FileMessageEventContent::plain(
"big.pdf".to_owned(),
OwnedMxcUri::from("mxc://example.com/big"),
),
mime::APPLICATION_PDF,
vec![0u8; 25 * 1024 * 1024 + 1],
)),
}];
assert!(
convert_llm_messages_to_venice(messages).is_err(),
"a file over the 25MB limit must be rejected"
);
}

View File

@@ -3,21 +3,24 @@ use crate::conversation::llm::{
};
use crate::utils::base64::base64_encode;
use super::wire::{ChatMessage, ContentPart, ImageUrl, MessageContent};
use super::wire::{ChatMessage, ContentPart, FilePart, ImageUrl, MessageContent};
pub fn convert_llm_messages_to_venice(messages: Vec<LLMMessage>) -> Vec<ChatMessage> {
/// Venice's documented file-input ceiling is 25MB on the decoded bytes (swagger `file_data`).
/// We check it here so an oversized file gets a clear message instead of an opaque 413 from the
/// API; the 413 status branch in `chat.rs` is the backstop if a file slips past this guard.
const MAX_FILE_BYTES: usize = 25 * 1024 * 1024;
pub fn convert_llm_messages_to_venice(messages: Vec<LLMMessage>) -> anyhow::Result<Vec<ChatMessage>> {
let mut venice_messages: Vec<ChatMessage> = Vec::with_capacity(messages.len());
for message in messages {
if let Some(venice_message) = convert_llm_message_to_venice(message) {
venice_messages.push(venice_message);
}
venice_messages.push(convert_llm_message_to_venice(message)?);
}
venice_messages
Ok(venice_messages)
}
fn convert_llm_message_to_venice(message: LLMMessage) -> Option<ChatMessage> {
fn convert_llm_message_to_venice(message: LLMMessage) -> anyhow::Result<ChatMessage> {
let role = match message.author {
LLMAuthor::Prompt => "system",
LLMAuthor::Assistant => "assistant",
@@ -25,7 +28,7 @@ fn convert_llm_message_to_venice(message: LLMMessage) -> Option<ChatMessage> {
};
match message.content {
LLMMessageContent::Text(text) => Some(ChatMessage {
LLMMessageContent::Text(text) => Ok(ChatMessage {
role: role.to_owned(),
content: MessageContent::Text(text),
}),
@@ -38,18 +41,39 @@ fn convert_llm_message_to_venice(message: LLMMessage) -> Option<ChatMessage> {
base64_encode(&image_details.data)
);
Some(ChatMessage {
Ok(ChatMessage {
role: role.to_owned(),
content: MessageContent::Parts(vec![ContentPart::ImageUrl {
image_url: ImageUrl { url: data_uri },
}]),
})
}
LLMMessageContent::File(_file_details) => {
tracing::warn!(
"The Venice provider does not support file content. This file message will be skipped."
LLMMessageContent::File(file_details) => {
// Inline the file as a base64 data URI in a `file` content part. This is the input
// type the openai_compat provider drops; baibot already extracts the bytes upstream.
// The message reaches the room, so it carries no user-controlled filename: a crafted
// name could otherwise inject markdown (a spoofed link) into the bot's reply.
if file_details.data.len() > MAX_FILE_BYTES {
return Err(anyhow::anyhow!(
"The attached file is too large for Venice (the limit is 25MB)."
));
}
let data_uri = format!(
"data:{};base64,{}",
file_details.mime,
base64_encode(&file_details.data)
);
None
Ok(ChatMessage {
role: role.to_owned(),
content: MessageContent::Parts(vec![ContentPart::File {
file: FilePart {
file_data: data_uri,
filename: Some(file_details.filename()),
},
}]),
})
}
}
}

View File

@@ -25,6 +25,27 @@ pub struct ChatCompletionRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repetition_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_retention: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub venice_parameters: Option<VeniceParameters>,
}
@@ -37,8 +58,8 @@ pub struct ChatMessage {
}
/// A message body is either a bare string or a list of content parts. Venice accepts both; we
/// send the parts form only when a message carries an image (baibot keeps text and images in
/// separate messages, so a parts list only ever holds images in v1).
/// send the parts form when a message carries an image or a file (baibot keeps text, images, and
/// files in separate messages, so a parts list holds a single image part or file part).
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum MessageContent {
@@ -50,6 +71,7 @@ pub enum MessageContent {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
ImageUrl { image_url: ImageUrl },
File { file: FilePart },
}
#[derive(Debug, Serialize)]
@@ -58,12 +80,26 @@ pub struct ImageUrl {
pub url: String,
}
/// Standard OpenAI-shaped chat completion response. We only read `choices[0].message.content`;
/// when web search is on, Venice inlines citations as `^n^` superscripts in that content and we
/// pass it through untouched.
#[derive(Debug, Serialize)]
pub struct FilePart {
/// A `data:<mime>;base64,<data>` URI carrying the file bytes inline.
pub file_data: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
}
/// Standard OpenAI-shaped chat completion response. We read `choices[0].message.content` and,
/// when web search is on, the structured `venice_parameters.web_search_citations` (requested via
/// `return_search_results_as_documents`) to rewrite the inline `^n^` superscripts into readable
/// `[n]` references plus a `Sources:` block. `reasoning_content` carries the model's thinking when
/// the model exposes it; it is appended only when `show_reasoning` is set.
#[derive(Debug, Deserialize)]
pub struct ChatCompletionResponse {
pub choices: Vec<ChatChoice>,
#[serde(default)]
pub venice_parameters: Option<ResponseVeniceParameters>,
}
#[derive(Debug, Deserialize)]
@@ -75,6 +111,31 @@ pub struct ChatChoice {
pub struct ResponseMessage {
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub reasoning_content: Option<String>,
}
/// The `venice_parameters` envelope on a chat-completion *response*, distinct from the request-side
/// `VeniceParameters` bag. Only the citation list is read; other response-side fields are ignored.
#[derive(Debug, Deserialize, Default)]
pub struct ResponseVeniceParameters {
#[serde(default)]
pub web_search_citations: Vec<WebSearchCitation>,
}
/// Only the `title` and `url` are read (for rendering the `Sources:` block). Venice also returns
/// `content` and `date` per citation; serde drops them, the same way the response structs above
/// ignore the response fields baibot does not use. Both fields default to empty so a single
/// citation that arrives without one (schema drift on scraped results) degrades gracefully in the
/// rendered list instead of failing the whole response deserialization.
#[derive(Debug, Deserialize)]
pub struct WebSearchCitation {
#[serde(default)]
pub title: String,
#[serde(default)]
pub url: String,
}
/// `/audio/transcriptions` response. We read `text`; the optional `duration`/`timestamps` the