Venice improvements
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,3 +1,16 @@
|
|||||||
|
# (2026-06-22) Version 1.23.0
|
||||||
|
|
||||||
|
- (**Feature**) The [Venice](https://venice.ai) provider now accepts file inputs (PDF, DOCX, and other documents, up to 25MB), the same way it already handled images. This makes Venice the second provider after OpenAI to accept files; the others (Anthropic and the OpenAI-compatible providers) skip them. See the [text-generation feature docs](./docs/features.md#-text-generation).
|
||||||
|
|
||||||
|
- (**Feature**) Add prompt caching to the Venice provider, on by default (`prompt_cache_retention: 24h`). baibot derives the cache key from the system prompt and the conversation start time (both fixed for the life of a conversation), so a long, stable system prompt stays cached across the day instead of being reprocessed and re-billed on every turn. See [Text Generation / Prompt Override](./docs/configuration/text-generation.md#️-prompt-override).
|
||||||
|
|
||||||
|
- (**Feature**) Wire up the rest of Venice's sampling and reasoning controls: top-level `top_p`, `frequency_penalty`, `presence_penalty`, `repetition_penalty`, and `reasoning_effort`; `verbosity` in the `venice_parameters` bag; and a `show_reasoning` toggle that appends the model's reasoning to the reply (off by default). See the [Venice configuration reference](./docs/providers.md#venice).
|
||||||
|
|
||||||
|
- (**Feature**) Render Venice web-search citations as readable `[n]` references with a `Sources:` list of links, instead of leaving Venice's raw `^n^` superscripts in the reply.
|
||||||
|
|
||||||
|
- (**Security**) Escape citation titles and validate citation URLs before rendering them, and drop user-supplied filenames from error messages, so a hostile web page or a crafted filename cannot inject a spoofed link into the bot's reply.
|
||||||
|
|
||||||
|
|
||||||
# (2026-06-21) Version 1.22.0
|
# (2026-06-21) Version 1.22.0
|
||||||
|
|
||||||
- (**Feature**) Add a native [Venice](https://venice.ai) provider with [🖌️ image-generation](./docs/features.md#️-image-creation) (incl. editing), [💬 text-generation](./docs/features.md#-text-generation) (incl. vision), [🗣️ text-to-speech](./docs/features.md#️-text-to-speech), [🦻 speech-to-text](./docs/features.md#-speech-to-text), and Venice's native web search via the full `venice_parameters` knob set. Unlike the [OpenAI-compatible](./docs/providers.md#openai-compatible) path (which drops images and can't reach Venice's audio or native image endpoints), it talks to Venice's API directly, using the knob-rich native `/image/generate` and `/image/edit` endpoints. See the [Venice provider docs](./docs/providers.md#venice).
|
- (**Feature**) Add a native [Venice](https://venice.ai) provider with [🖌️ image-generation](./docs/features.md#️-image-creation) (incl. editing), [💬 text-generation](./docs/features.md#-text-generation) (incl. vision), [🗣️ text-to-speech](./docs/features.md#️-text-to-speech), [🦻 speech-to-text](./docs/features.md#-speech-to-text), and Venice's native web search via the full `venice_parameters` knob set. Unlike the [OpenAI-compatible](./docs/providers.md#openai-compatible) path (which drops images and can't reach Venice's audio or native image endpoints), it talks to Venice's API directly, using the knob-rich native `/image/generate` and `/image/edit` endpoints. See the [Venice provider docs](./docs/providers.md#venice).
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -315,7 +315,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "baibot"
|
name = "baibot"
|
||||||
version = "1.22.0"
|
version = "1.23.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ license = "AGPL-3.0-or-later"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["matrix", "chat", "bot", "AI", "LLM"]
|
keywords = ["matrix", "chat", "bot", "AI", "LLM"]
|
||||||
include = ["/etc/assets/baibot-torso-768.png", "/src", "/README.md", "/CHANGELOG.md", "/LICENSE"]
|
include = ["/etc/assets/baibot-torso-768.png", "/src", "/README.md", "/CHANGELOG.md", "/LICENSE"]
|
||||||
version = "1.22.0"
|
version = "1.23.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ Prompts may contain the following **placeholder variables** which will be replac
|
|||||||
|
|
||||||
💡 `{{ baibot_now_utc }}` changes as time goes on, which prevents [prompt caching](https://platform.openai.com/docs/guides/prompt-caching) from working. It's better to use `{{ baibot_conversation_start_time_utc }}` in prompts, as its value doesn't change yet still orients the bot to the current date/time.
|
💡 `{{ baibot_now_utc }}` changes as time goes on, which prevents [prompt caching](https://platform.openai.com/docs/guides/prompt-caching) from working. It's better to use `{{ baibot_conversation_start_time_utc }}` in prompts, as its value doesn't change yet still orients the bot to the current date/time.
|
||||||
|
|
||||||
|
💡 On the [Venice provider](../providers.md#venice), baibot derives the prompt-cache key from the system prompt and the conversation start time, both stable for the life of a conversation, and ships `prompt_cache_retention: 24h` by default. A stable system prompt then stays cached across the whole conversation instead of being reprocessed (and re-billed) on every turn.
|
||||||
|
|
||||||
Here's a prompt that combines some of the above variables:
|
Here's a prompt that combines some of the above variables:
|
||||||
|
|
||||||
> You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}."
|
> You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}."
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Text Generation is the bot's ability to **respond to users' messages with text**
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Some models also support vision and document understanding, so you may be able to mix text, images, and files (PDFs, text documents, etc.) in the same conversation. Note that certain providers may not support all file types or may have issues with specific files (e.g. scanned/image-based PDFs). If a file is rejected by the provider, the conversation thread may become unusable — start a new thread to work around this.
|
Some models also support vision and document understanding, so you may be able to mix text, images, and files (PDFs, text documents, etc.) in the same conversation. File inputs (documents such as PDFs) are currently accepted only by the OpenAI and Venice providers; the others skip them. Note that certain providers may not support all file types or may have issues with specific files (e.g. scanned/image-based PDFs). If a file is rejected by the provider, the conversation thread may become unusable — start a new thread to work around this.
|
||||||
|
|
||||||
In multi-user (group) rooms, to avoid disturbing the normal conversation between people, the bot is auto-configured to only respond to messages starting with the command prefix (`!bai`) or direct mentions via the [💬 Text Generation / 🗟 Prefix Requirement Type](./configuration/text-generation.md#-prefix-requirement-type) setting.
|
In multi-user (group) rooms, to avoid disturbing the normal conversation between people, the bot is auto-configured to only respond to messages starting with the command prefix (`!bai`) or direct mentions via the [💬 Text Generation / 🗟 Prefix Requirement Type](./configuration/text-generation.md#-prefix-requirement-type) setting.
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ This provider is just as featureful as the [OpenAI](#openai) provider, but is mo
|
|||||||
|
|
||||||
- 🆔 Identifier: `venice`
|
- 🆔 Identifier: `venice`
|
||||||
- 🔗 Links: [🏠 Home page](https://venice.ai), [👤 Sign up](https://venice.ai), [📋 Models list](https://api.venice.ai/api/v1/models)
|
- 🔗 Links: [🏠 Home page](https://venice.ai), [👤 Sign up](https://venice.ai), [📋 Models list](https://api.venice.ai/api/v1/models)
|
||||||
- 🌟 Capabilities: [🖌️ image-generation](./features.md#️-image-creation) (incl. editing, via the native knob-rich `/image/generate` and `/image/edit` endpoints), [💬 text-generation](./features.md#-text-generation) (incl. vision; native web search via the `venice_parameters` config), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text)
|
- 🌟 Capabilities: [🖌️ image-generation](./features.md#️-image-creation) (incl. editing, via the native knob-rich `/image/generate` and `/image/edit` endpoints), [💬 text-generation](./features.md#-text-generation) (incl. vision, file inputs like PDF and DOCX, and prompt caching; native web search via the `venice_parameters` config), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text)
|
||||||
- 🗲 Quick start:
|
- 🗲 Quick start:
|
||||||
- create a room-local agent: `!bai agent create-room-local venice my-venice-agent`
|
- create a room-local agent: `!bai agent create-room-local venice my-venice-agent`
|
||||||
- create a global agent: `!bai agent create-global venice my-venice-agent`
|
- create a global agent: `!bai agent create-global venice my-venice-agent`
|
||||||
@@ -193,7 +193,19 @@ Unlike the [OpenAI Compatible](#openai-compatible) provider (which can talk to V
|
|||||||
|
|
||||||
Every parameter below is optional unless marked otherwise. Omitting a knob lets Venice apply its own server-side default; this is **not** the same as setting it to `false`, which actively sends `false`.
|
Every parameter below is optional unless marked otherwise. Omitting a knob lets Venice apply its own server-side default; this is **not** the same as setting it to `false`, which actively sends `false`.
|
||||||
|
|
||||||
**`text_generation.venice_parameters`** — Venice-specific request knobs sent in the `venice_parameters` bag (alongside the standard `model_id`, `prompt`, `temperature`, `max_response_tokens`, and `max_context_tokens` fields). Set any of them to override Venice's behavior. The `Default` column shows the value baibot's sample config ships; a `—` means the knob is left unset, so Venice's own default applies.
|
**`text_generation`** (top-level knobs) — sampling, caching, and reasoning controls that sit directly on `text_generation`, next to `model_id`, `prompt`, `temperature`, `max_response_tokens`, and `max_context_tokens`. They map to top-level fields on Venice's request, separate from the `venice_parameters` bag below.
|
||||||
|
|
||||||
|
| Knob | What it does | Default |
|
||||||
|
|------|--------------|---------|
|
||||||
|
| `top_p` | Nucleus sampling, `0.0`–`1.0`. An alternative to `temperature`. | — |
|
||||||
|
| `frequency_penalty` | Penalize tokens by how often they have already appeared, `-2.0`–`2.0`. | — |
|
||||||
|
| `presence_penalty` | Penalize tokens that have appeared at all, `-2.0`–`2.0`. | — |
|
||||||
|
| `repetition_penalty` | Penalize repetition. Values above `1.0` discourage repeats. | — |
|
||||||
|
| `reasoning_effort` | Reasoning budget for models that support it: `low`, `medium`, `high`. | — |
|
||||||
|
| `prompt_cache_retention` | How long Venice keeps the prompt prefix cached: `default`, `extended`, or `24h`. `24h` is the lever that makes a long, stable system prompt cheap across a day of conversations. | `24h` |
|
||||||
|
| `show_reasoning` | Append the model's reasoning (its `reasoning_content`) below the answer. Reads a field separate from the answer text, so it works regardless of `strip_thinking_response`. | `false` |
|
||||||
|
|
||||||
|
**`text_generation.venice_parameters`** — Venice-specific request knobs sent in the `venice_parameters` bag. Set any of them to override Venice's behavior. The `Default` column shows the value baibot's sample config ships; a `—` means the knob is left unset, so Venice's own default applies.
|
||||||
|
|
||||||
| Knob | What it does | Default |
|
| Knob | What it does | Default |
|
||||||
|------|--------------|---------|
|
|------|--------------|---------|
|
||||||
@@ -208,6 +220,7 @@ Every parameter below is optional unless marked otherwise. Omitting a knob lets
|
|||||||
| `strip_thinking_response` | Strip `<think></think>` blocks from reasoning models so the user sees only the answer. | `true` |
|
| `strip_thinking_response` | Strip `<think></think>` blocks from reasoning models so the user sees only the answer. | `true` |
|
||||||
| `disable_thinking` | Disable the model's reasoning step entirely. | — |
|
| `disable_thinking` | Disable the model's reasoning step entirely. | — |
|
||||||
| `enable_e2ee` | Run in end-to-end-encrypted mode rather than the default TEE-only mode. | `false` |
|
| `enable_e2ee` | Run in end-to-end-encrypted mode rather than the default TEE-only mode. | `false` |
|
||||||
|
| `verbosity` | Response verbosity for models that support it: `low`, `medium`, `high`. | — |
|
||||||
|
|
||||||
**`text_to_speech`**:
|
**`text_to_speech`**:
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ text_generation:
|
|||||||
temperature: 1.0
|
temperature: 1.0
|
||||||
max_response_tokens: 4096
|
max_response_tokens: 4096
|
||||||
max_context_tokens: 128000
|
max_context_tokens: 128000
|
||||||
|
# Prompt caching: how long Venice keeps the prompt prefix cached. "default", "extended", or "24h".
|
||||||
|
# "24h" (shipped by default) makes a long, stable system prompt cheap across a day of conversations.
|
||||||
|
prompt_cache_retention: 24h
|
||||||
|
# Top-level sampling and reasoning knobs (uncomment to override Venice's default):
|
||||||
|
# Nucleus sampling, 0.0-1.0 (an alternative to temperature).
|
||||||
|
# top_p: 0.9
|
||||||
|
# Penalize tokens by how often they have already appeared, -2.0-2.0.
|
||||||
|
# frequency_penalty: 0.0
|
||||||
|
# Penalize tokens that have appeared at all, -2.0-2.0.
|
||||||
|
# presence_penalty: 0.0
|
||||||
|
# Penalize repetition; values above 1.0 discourage repeats.
|
||||||
|
# repetition_penalty: 1.0
|
||||||
|
# Reasoning budget for models that support it: low, medium, high.
|
||||||
|
# reasoning_effort: medium
|
||||||
|
# Append the model's reasoning below the answer. Reads a field separate from the answer text, so
|
||||||
|
# it works alongside strip_thinking_response (which only strips <think> blocks from the answer).
|
||||||
|
# show_reasoning: true
|
||||||
# Venice-specific request parameters. Only the keys present below are sent to Venice; omit a
|
# Venice-specific request parameters. Only the keys present below are sent to Venice; omit a
|
||||||
# key to fall back to Venice's own default. Omitting a knob is NOT the same as setting it to
|
# key to fall back to Venice's own default. Omitting a knob is NOT the same as setting it to
|
||||||
# `false` — `false` actively sends `false`.
|
# `false` — `false` actively sends `false`.
|
||||||
@@ -24,6 +41,8 @@ text_generation:
|
|||||||
# return_search_results_as_documents: true
|
# return_search_results_as_documents: true
|
||||||
# enable_x_search: true
|
# enable_x_search: true
|
||||||
# disable_thinking: true
|
# disable_thinking: true
|
||||||
|
# Response verbosity for models that support it: low, medium, high.
|
||||||
|
# verbosity: medium
|
||||||
# character_slug: public-character-id
|
# character_slug: public-character-id
|
||||||
speech_to_text:
|
speech_to_text:
|
||||||
model_id: nvidia/parakeet-tdt-0.6b-v3
|
model_id: nvidia/parakeet-tdt-0.6b-v3
|
||||||
|
|||||||
@@ -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::AgentPurpose;
|
||||||
use crate::agent::provider::entity::{TextGenerationParams, TextGenerationResult};
|
use crate::agent::provider::entity::{TextGenerationParams, TextGenerationResult};
|
||||||
use crate::conversation::llm::{
|
use crate::conversation::llm::{
|
||||||
@@ -6,9 +12,9 @@ use crate::conversation::llm::{
|
|||||||
};
|
};
|
||||||
use crate::strings;
|
use crate::strings;
|
||||||
|
|
||||||
use super::config::Config;
|
use super::config::{Config, WebSearchMode};
|
||||||
use super::utils::convert_llm_messages_to_venice;
|
use super::utils::convert_llm_messages_to_venice;
|
||||||
use super::wire::{ChatCompletionRequest, ChatCompletionResponse};
|
use super::wire::{ChatCompletionRequest, ChatCompletionResponse, WebSearchCitation};
|
||||||
|
|
||||||
pub async fn generate_text(
|
pub async fn generate_text(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
@@ -31,6 +37,17 @@ pub async fn generate_text(
|
|||||||
.trim(),
|
.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() {
|
let prompt_message = if prompt_text.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -58,12 +75,29 @@ pub async fn generate_text(
|
|||||||
conversation_messages.insert(0, prompt_message);
|
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
|
let temperature = params
|
||||||
.temperature_override
|
.temperature_override
|
||||||
.unwrap_or(text_generation_config.temperature);
|
.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 {
|
let request = ChatCompletionRequest {
|
||||||
model: text_generation_config.model_id.clone(),
|
model: text_generation_config.model_id.clone(),
|
||||||
messages,
|
messages,
|
||||||
@@ -71,7 +105,14 @@ pub async fn generate_text(
|
|||||||
// Web search rides entirely inside `venice_parameters`; there is no `tools` array here.
|
// 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_tokens` is deprecated on Venice in favor of `max_completion_tokens`.
|
||||||
max_completion_tokens: text_generation_config.max_response_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!(
|
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.
|
// body can carry account / rate-limit details that shouldn't reach room members.
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
tracing::warn!(%status, body, "Venice chat completion request failed");
|
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!(
|
return Err(anyhow::anyhow!(
|
||||||
"Venice chat completion request failed with status {status}"
|
"Venice chat completion request failed with status {status}"
|
||||||
));
|
));
|
||||||
@@ -106,6 +157,11 @@ pub async fn generate_text(
|
|||||||
|
|
||||||
let response: ChatCompletionResponse = response.json().await?;
|
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 {
|
let Some(choice) = response.choices.into_iter().next() else {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"No choices were returned from the Venice chat completion API"
|
"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)]
|
#[serde(default)]
|
||||||
pub max_context_tokens: u32,
|
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
|
/// 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.
|
/// wire. Any unset field is omitted, so Venice applies its own server-side default.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[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
|
// Matches Venice's own `availableContextTokens` (131072) and the non-OpenAI sibling
|
||||||
// providers (ollama/localai/mistral all default to 128_000).
|
// providers (ollama/localai/mistral all default to 128_000).
|
||||||
max_context_tokens: 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;
|
// 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).
|
// every other knob stays None so Venice applies its own default (omitting != false).
|
||||||
venice_parameters: Some(VeniceParameters {
|
venice_parameters: Some(VeniceParameters {
|
||||||
@@ -95,6 +138,13 @@ fn default_text_model_id() -> String {
|
|||||||
"kimi-k2-5".to_owned()
|
"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`
|
/// The full `venice_parameters` knob set, mirroring Venice's `ChatCompletionRequest`
|
||||||
/// schema field-for-field. Every field is optional with `skip_serializing_if`, so the
|
/// 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`,
|
/// 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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub disable_thinking: Option<bool>,
|
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)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ use crate::conversation::llm::{
|
|||||||
MessageContent as LLMMessageContent,
|
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::controller::Controller;
|
||||||
use super::utils::convert_llm_messages_to_venice;
|
use super::utils::convert_llm_messages_to_venice;
|
||||||
use super::wire::{
|
use super::wire::{
|
||||||
ContentPart, EditImageRequest, GenerateImageRequest, MessageContent, SpeechRequest,
|
ChatCompletionRequest, ContentPart, EditImageRequest, GenerateImageRequest, MessageContent,
|
||||||
|
SpeechRequest, WebSearchCitation,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -59,7 +61,7 @@ speech_to_text:
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn converts_image_to_data_uri_and_skips_files() {
|
fn converts_text_image_and_file_to_content_parts() {
|
||||||
let messages = vec![
|
let messages = vec![
|
||||||
LLMMessage {
|
LLMMessage {
|
||||||
author: LLMAuthor::User,
|
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.
|
// Text, image, AND file all survive now: the file is no longer warn-skipped.
|
||||||
assert_eq!(converted.len(), 2);
|
assert_eq!(converted.len(), 3);
|
||||||
|
|
||||||
match &converted[0].content {
|
match &converted[0].content {
|
||||||
MessageContent::Text(text) => assert_eq!(text, "describe this"),
|
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 should be inlined as a data URI: {}",
|
||||||
image_url.url
|
image_url.url
|
||||||
),
|
),
|
||||||
|
other => panic!("expected an image part, got {other:?}"),
|
||||||
},
|
},
|
||||||
other => panic!("expected image parts, 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]
|
#[test]
|
||||||
@@ -267,3 +286,255 @@ fn web_search_mode_off_deserializes_from_bare_yaml_off() {
|
|||||||
|
|
||||||
assert!(matches!(params.enable_web_search, Some(WebSearchMode::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 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());
|
let mut venice_messages: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||||
|
|
||||||
for message in messages {
|
for message in messages {
|
||||||
if let Some(venice_message) = convert_llm_message_to_venice(message) {
|
venice_messages.push(convert_llm_message_to_venice(message)?);
|
||||||
venice_messages.push(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 {
|
let role = match message.author {
|
||||||
LLMAuthor::Prompt => "system",
|
LLMAuthor::Prompt => "system",
|
||||||
LLMAuthor::Assistant => "assistant",
|
LLMAuthor::Assistant => "assistant",
|
||||||
@@ -25,7 +28,7 @@ fn convert_llm_message_to_venice(message: LLMMessage) -> Option<ChatMessage> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match message.content {
|
match message.content {
|
||||||
LLMMessageContent::Text(text) => Some(ChatMessage {
|
LLMMessageContent::Text(text) => Ok(ChatMessage {
|
||||||
role: role.to_owned(),
|
role: role.to_owned(),
|
||||||
content: MessageContent::Text(text),
|
content: MessageContent::Text(text),
|
||||||
}),
|
}),
|
||||||
@@ -38,18 +41,39 @@ fn convert_llm_message_to_venice(message: LLMMessage) -> Option<ChatMessage> {
|
|||||||
base64_encode(&image_details.data)
|
base64_encode(&image_details.data)
|
||||||
);
|
);
|
||||||
|
|
||||||
Some(ChatMessage {
|
Ok(ChatMessage {
|
||||||
role: role.to_owned(),
|
role: role.to_owned(),
|
||||||
content: MessageContent::Parts(vec![ContentPart::ImageUrl {
|
content: MessageContent::Parts(vec![ContentPart::ImageUrl {
|
||||||
image_url: ImageUrl { url: data_uri },
|
image_url: ImageUrl { url: data_uri },
|
||||||
}]),
|
}]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
LLMMessageContent::File(_file_details) => {
|
LLMMessageContent::File(file_details) => {
|
||||||
tracing::warn!(
|
// Inline the file as a base64 data URI in a `file` content part. This is the input
|
||||||
"The Venice provider does not support file content. This file message will be skipped."
|
// 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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub max_completion_tokens: Option<u32>,
|
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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub venice_parameters: Option<VeniceParameters>,
|
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
|
/// 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
|
/// send the parts form when a message carries an image or a file (baibot keeps text, images, and
|
||||||
/// separate messages, so a parts list only ever holds images in v1).
|
/// files in separate messages, so a parts list holds a single image part or file part).
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum MessageContent {
|
pub enum MessageContent {
|
||||||
@@ -50,6 +71,7 @@ pub enum MessageContent {
|
|||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ContentPart {
|
pub enum ContentPart {
|
||||||
ImageUrl { image_url: ImageUrl },
|
ImageUrl { image_url: ImageUrl },
|
||||||
|
File { file: FilePart },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -58,12 +80,26 @@ pub struct ImageUrl {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Standard OpenAI-shaped chat completion response. We only read `choices[0].message.content`;
|
#[derive(Debug, Serialize)]
|
||||||
/// when web search is on, Venice inlines citations as `^n^` superscripts in that content and we
|
pub struct FilePart {
|
||||||
/// pass it through untouched.
|
/// 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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ChatCompletionResponse {
|
pub struct ChatCompletionResponse {
|
||||||
pub choices: Vec<ChatChoice>,
|
pub choices: Vec<ChatChoice>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub venice_parameters: Option<ResponseVeniceParameters>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -75,6 +111,31 @@ pub struct ChatChoice {
|
|||||||
pub struct ResponseMessage {
|
pub struct ResponseMessage {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub content: Option<String>,
|
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
|
/// `/audio/transcriptions` response. We read `text`; the optional `duration`/`timestamps` the
|
||||||
|
|||||||
Reference in New Issue
Block a user