Venice improvements
This commit is contained in:
@@ -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: ®ex::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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
}]),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user