Implement Venice.ai provider
This commit is contained in:
@@ -1,3 +1,8 @@
|
|||||||
|
# (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).
|
||||||
|
|
||||||
|
|
||||||
# (2026-06-05) Version 1.21.1
|
# (2026-06-05) Version 1.21.1
|
||||||
|
|
||||||
- (**Security**) Update the [anthropic](https://github.com/etkecc/anthropic-rs) dependency to use [reqwest](https://crates.io/crates/reqwest) 0.12 / [rustls](https://crates.io/crates/rustls) 0.23, replacing the vulnerable `rustls-webpki` 0.101 line with 0.103.13. This resolves [`GHSA-82j2-j2ch-gfr8`](https://github.com/advisories/GHSA-82j2-j2ch-gfr8) (high — denial of service via panic on a malformed CRL), [`GHSA-xgp8-3hg3-c2mh`](https://github.com/advisories/GHSA-xgp8-3hg3-c2mh) and [`GHSA-965h-392x-2mh5`](https://github.com/advisories/GHSA-965h-392x-2mh5) (name-constraint validation issues).
|
- (**Security**) Update the [anthropic](https://github.com/etkecc/anthropic-rs) dependency to use [reqwest](https://crates.io/crates/reqwest) 0.12 / [rustls](https://crates.io/crates/rustls) 0.23, replacing the vulnerable `rustls-webpki` 0.101 line with 0.103.13. This resolves [`GHSA-82j2-j2ch-gfr8`](https://github.com/advisories/GHSA-82j2-j2ch-gfr8) (high — denial of service via panic on a malformed CRL), [`GHSA-xgp8-3hg3-c2mh`](https://github.com/advisories/GHSA-xgp8-3hg3-c2mh) and [`GHSA-965h-392x-2mh5`](https://github.com/advisories/GHSA-965h-392x-2mh5) (name-constraint validation issues).
|
||||||
|
|||||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -315,7 +315,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "baibot"
|
name = "baibot"
|
||||||
version = "1.21.1"
|
version = "1.22.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -329,6 +329,7 @@ dependencies = [
|
|||||||
"mxlink",
|
"mxlink",
|
||||||
"quick_cache",
|
"quick_cache",
|
||||||
"regex",
|
"regex",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml_ng",
|
"serde_yaml_ng",
|
||||||
@@ -1590,6 +1591,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3074,6 +3076,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -3095,6 +3098,7 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams 0.4.2",
|
"wasm-streams 0.4.2",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -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.21.1"
|
version = "1.22.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -28,6 +28,10 @@ mxlink = ">=1.15.0"
|
|||||||
etke_openai_api_rust = "0.1.*"
|
etke_openai_api_rust = "0.1.*"
|
||||||
quick_cache = "0.6.*"
|
quick_cache = "0.6.*"
|
||||||
regex = "1.12.*"
|
regex = "1.12.*"
|
||||||
|
# Direct dep for the native `venice` provider's HTTP client. Pinned to 0.12 (the version
|
||||||
|
# async-openai 0.41 already resolves) with rustls only and default-features off, so we ride
|
||||||
|
# the existing reqwest+rustls copy instead of pulling a second TLS stack (native-tls/openssl).
|
||||||
|
reqwest = { version = "0.12.*", default-features = false, features = ["json", "multipart", "rustls-tls"] }
|
||||||
serde = { version = "1.0.*", features = ["derive"], default-features = false }
|
serde = { version = "1.0.*", features = ["derive"], default-features = false }
|
||||||
serde_json = "1.0.*"
|
serde_json = "1.0.*"
|
||||||
serde_yaml_ng = "0.10.*"
|
serde_yaml_ng = "0.10.*"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ It's influenced by [chaz](https://github.com/arcuru/chaz), but does **not** use
|
|||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🎨 Encourages **[provider](./docs/providers.md) choice** ([Anthropic](./docs/providers.md#anthropic), [Groq](./docs/providers.md#groq), [LocalAI](./docs/providers.md#localai), [OpenAI](./docs/providers.md#openai) and [☁️ many more](./docs/providers.md#️-providers)) as well as **[mixing & matching models](./docs/features.md#-mixing--matching-models)**:
|
- 🎨 Encourages **[provider](./docs/providers.md) choice** ([Anthropic](./docs/providers.md#anthropic), [Groq](./docs/providers.md#groq), [LocalAI](./docs/providers.md#localai), [OpenAI](./docs/providers.md#openai), [Venice](./docs/providers.md#venice) and [☁️ many more](./docs/providers.md#️-providers)) as well as **[mixing & matching models](./docs/features.md#-mixing--matching-models)**:
|
||||||
|
|
||||||
- Supports **different use purposes** (depending on the [☁️ provider](./docs/providers.md) & model):
|
- Supports **different use purposes** (depending on the [☁️ provider](./docs/providers.md) & model):
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ Example: `!bai config room text-to-speech set-speed-override 1.5` (this can also
|
|||||||
|
|
||||||
### 👫 Voice override
|
### 👫 Voice override
|
||||||
|
|
||||||
The voice override setting lets you change the voice being used by the text-to-speech model configured at the [🤖 agent](../agents.md) level (usually `onyx` when using [OpenAI](../providers.md#openai)).
|
The voice override setting lets you change the voice being used by the text-to-speech model configured at the [🤖 agent](../agents.md) level (e.g. `onyx` when using [OpenAI](../providers.md#openai), or `af_sky` when using [Venice](../providers.md#venice)).
|
||||||
|
|
||||||
Possible values (e.g. `onyx`) depend on the model you're using. For example, for [OpenAI](../providers.md#openai)'s Whisper model, [these voices](https://platform.openai.com/docs/guides/text-to-speech/voice-options) are available.
|
Possible values (e.g. `onyx`) depend on the model you're using. For example, for [OpenAI](../providers.md#openai)'s Whisper model, [these voices](https://platform.openai.com/docs/guides/text-to-speech/voice-options) are available.
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ The list of supported providers is below.
|
|||||||
- [OpenAI Compatible](#openai-compatible)
|
- [OpenAI Compatible](#openai-compatible)
|
||||||
- [OpenRouter](#openrouter)
|
- [OpenRouter](#openrouter)
|
||||||
- [Together AI](#together-ai)
|
- [Together AI](#together-ai)
|
||||||
|
- [Venice](#venice)
|
||||||
|
|
||||||
|
|
||||||
### How to choose a provider
|
### How to choose a provider
|
||||||
@@ -171,3 +172,82 @@ This provider is just as featureful as the [OpenAI](#openai) provider, but is mo
|
|||||||
- create a global agent: `!bai agent create-global together-ai my-together-ai-agent`
|
- create a global agent: `!bai agent create-global together-ai my-together-ai-agent`
|
||||||
|
|
||||||
💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/together-ai.yml).
|
💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/together-ai.yml).
|
||||||
|
|
||||||
|
|
||||||
|
### Venice
|
||||||
|
|
||||||
|
[Venice AI](https://venice.ai) runs inference on Venice-controlled GPUs or zero-data-retention partner infrastructure and stores no prompts or responses, so your conversations don't linger anywhere. It serves both frontier proprietary models and the latest open-source ones.
|
||||||
|
|
||||||
|
- 🆔 Identifier: `venice`
|
||||||
|
- 🔗 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)
|
||||||
|
- 🗲 Quick start:
|
||||||
|
- 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`
|
||||||
|
|
||||||
|
💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/venice.yml).
|
||||||
|
|
||||||
|
Unlike the [OpenAI Compatible](#openai-compatible) provider (which can talk to Venice but drops images and can't reach its audio or native image endpoints), this is a first-class Venice integration that exposes Venice's full parameter set. Image generation uses the native `/image/generate` endpoint rather than the OpenAI-compatible `/images/generations` shim, so every Venice-specific knob below is available.
|
||||||
|
|
||||||
|
#### Configuration reference
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
| Knob | What it does | Default |
|
||||||
|
|------|--------------|---------|
|
||||||
|
| `enable_web_search` | Web search mode: `auto` (model decides), `on` (always), or `off`. | `auto` |
|
||||||
|
| `enable_web_citations` | Append source citations to web-search answers. | — |
|
||||||
|
| `enable_web_scraping` | Allow the model to scrape page contents during web search. | — |
|
||||||
|
| `enable_x_search` | Include X (Twitter) in web search. | — |
|
||||||
|
| `include_search_results_in_stream` | Stream search results back as they arrive. | — |
|
||||||
|
| `return_search_results_as_documents` | Return search results as structured documents. | — |
|
||||||
|
| `include_venice_system_prompt` | Prepend Venice's own system prompt alongside yours. | — |
|
||||||
|
| `character_slug` | Use a public Venice character by its slug. | — |
|
||||||
|
| `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. | — |
|
||||||
|
| `enable_e2ee` | Run in end-to-end-encrypted mode rather than the default TEE-only mode. | `false` |
|
||||||
|
|
||||||
|
**`text_to_speech`**:
|
||||||
|
|
||||||
|
| Knob | What it does | Default |
|
||||||
|
|------|--------------|---------|
|
||||||
|
| `model_id` | The Venice TTS model (e.g. `tts-kokoro`, `tts-qwen3-1-7b`, `tts-xai-v1`). | `tts-kokoro` |
|
||||||
|
| `voice` | The voice to synthesize with. Model-specific (Kokoro: `af_*`/`am_*`/`bf_*`/`bm_*`); a cloned-voice handle (`vv_<id>`) also works. | `af_sky` |
|
||||||
|
| `response_format` | Audio format: `mp3`, `opus`, `aac`, `flac`, `wav`, or `pcm`. | `mp3` |
|
||||||
|
| `speed` | Playback speed, `0.25`–`4.0`. | `1.0` |
|
||||||
|
| `prompt` | A style prompt steering emotion/delivery. Only Qwen 3 TTS honors it. | — |
|
||||||
|
| `temperature` | Sampling temperature, `0.0`–`2.0`. Only Qwen 3 / Orpheus / Chatterbox HD honor it. | — |
|
||||||
|
| `top_p` | Nucleus sampling, `0.0`–`1.0`. Only Qwen 3 TTS honors it. | — |
|
||||||
|
|
||||||
|
**`image_generation`**:
|
||||||
|
|
||||||
|
| Knob | What it does | Default |
|
||||||
|
|------|--------------|---------|
|
||||||
|
| `model_id` | The image-generation model. | `chroma` |
|
||||||
|
| `negative_prompt` | A description of what should **not** appear in the image. | — |
|
||||||
|
| `cfg_scale` | CFG scale, `0`–`20`. Higher values adhere more closely to the prompt. | — |
|
||||||
|
| `steps` | Number of inference steps. Model-specific; some models ignore it. | — |
|
||||||
|
| `style_preset` | A named style to apply (e.g. `3D Model`). | — |
|
||||||
|
| `seed` | Random seed, `-999999999`–`999999999`. Fix it for reproducible results. | random |
|
||||||
|
| `safe_mode` | Blur images classified as adult content. | `true` |
|
||||||
|
| `hide_watermark` | Hide the Venice watermark (may be ignored for some content). | `false` |
|
||||||
|
| `format` | Output format: `jpeg`, `png`, or `webp`. | `webp` |
|
||||||
|
| `width` / `height` | Image dimensions in pixels, each `1`–`1280`. | `1024` |
|
||||||
|
| `aspect_ratio` | Aspect ratio for models that support it (e.g. `1:1`, `16:9`). Alternative to `width`/`height`. | — |
|
||||||
|
| `resolution` | Resolution tier for models that support it (`1K`, `2K`, `4K`). | — |
|
||||||
|
| `quality` | Output quality for supported models: `low`, `medium`, `high`. Higher can cost more. | — |
|
||||||
|
| `lora_strength` | Lora strength, `0`–`100`. Only applies if the model uses additional Loras. | — |
|
||||||
|
| `embed_exif_metadata` | Embed the generation prompt into the image's EXIF metadata. | `false` |
|
||||||
|
| `enable_web_search` | Let the model pull the latest info from the web. Model-specific; costs extra credits. | — |
|
||||||
|
|
||||||
|
**`image_generation.edit`** — image editing reuses the `image_generation` block; only the model and a few output knobs differ:
|
||||||
|
|
||||||
|
| Knob | What it does | Default |
|
||||||
|
|------|--------------|---------|
|
||||||
|
| `model_id` | The image-edit model. | `firered-image-edit` |
|
||||||
|
| `output_format` | Output format: `jpeg`, `png`, or `webp`. When omitted, Venice infers it (PNG at 1K, JPEG at 2K/4K). | inferred |
|
||||||
|
| `aspect_ratio` | Aspect ratio of the result: `auto`, `1:1`, `3:2`, `16:9`, `21:9`, `9:16`, `2:3`, `3:4`, `4:5` (model-specific). | — |
|
||||||
|
| `resolution` | Resolution tier: `1K`, `2K`, `4K` (model-specific). | `1K` |
|
||||||
|
| `safe_mode` | Blur images classified as adult content. | `true` |
|
||||||
|
|||||||
97
docs/sample-provider-configs/venice.yml
Normal file
97
docs/sample-provider-configs/venice.yml
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
base_url: https://api.venice.ai/api/v1
|
||||||
|
api_key: YOUR_API_KEY_HERE
|
||||||
|
text_generation:
|
||||||
|
model_id: kimi-k2-5
|
||||||
|
prompt: "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 }}."
|
||||||
|
temperature: 1.0
|
||||||
|
max_response_tokens: 4096
|
||||||
|
max_context_tokens: 128000
|
||||||
|
# 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
|
||||||
|
# `false` — `false` actively sends `false`.
|
||||||
|
venice_parameters:
|
||||||
|
# Web search: "auto" (model decides), "on" (always), or "off".
|
||||||
|
enable_web_search: "auto"
|
||||||
|
# Strip <think></think> blocks from reasoning models so the user sees only the answer.
|
||||||
|
strip_thinking_response: true
|
||||||
|
# Run in TEE-only mode instead of end-to-end encryption (works across all models).
|
||||||
|
enable_e2ee: false
|
||||||
|
# Other available knobs — uncomment to override Venice's default:
|
||||||
|
# enable_web_citations: true
|
||||||
|
# enable_web_scraping: true
|
||||||
|
# include_venice_system_prompt: false
|
||||||
|
# include_search_results_in_stream: true
|
||||||
|
# return_search_results_as_documents: true
|
||||||
|
# enable_x_search: true
|
||||||
|
# disable_thinking: true
|
||||||
|
# character_slug: public-character-id
|
||||||
|
speech_to_text:
|
||||||
|
model_id: nvidia/parakeet-tdt-0.6b-v3
|
||||||
|
text_to_speech:
|
||||||
|
# The Venice TTS model. Others include tts-qwen3-1-7b, tts-xai-v1,
|
||||||
|
# tts-elevenlabs-turbo-v2-5, tts-minimax-speech-02-hd. See the models list endpoint.
|
||||||
|
model_id: tts-kokoro
|
||||||
|
# The voice to synthesize with. Voices are model-specific: Kokoro uses af_*/am_*/bf_*/bm_*
|
||||||
|
# (e.g. af_sky, am_adam), other models have their own sets. You can also pass a cloned-voice
|
||||||
|
# handle (vv_<id>) created via Venice's voice-cloning API. An incompatible voice returns an error.
|
||||||
|
voice: af_sky
|
||||||
|
# Output audio format: mp3, opus, aac, flac, wav, or pcm. mp3 is the broadest Matrix-client fit.
|
||||||
|
response_format: mp3
|
||||||
|
# Other available knobs — uncomment to override Venice's default:
|
||||||
|
# Playback speed, 0.25–4.0 (1.0 is normal).
|
||||||
|
# speed: 1.0
|
||||||
|
# A style prompt steering emotion/delivery (e.g. "Excited and energetic."). Only Qwen 3 TTS uses it.
|
||||||
|
# prompt: "Calm and warm."
|
||||||
|
# Sampling temperature, 0.0–2.0 (higher = more varied). Only Qwen 3 / Orpheus / Chatterbox HD use it.
|
||||||
|
# temperature: 0.9
|
||||||
|
# Nucleus sampling, 0.0–1.0. Only Qwen 3 TTS uses it.
|
||||||
|
# top_p: 1.0
|
||||||
|
image_generation:
|
||||||
|
# The image-generation model. See the models list endpoint for the full set.
|
||||||
|
model_id: chroma
|
||||||
|
# The image-edit model, used when editing an existing image rather than generating a new one.
|
||||||
|
# Editing shares this same image_generation config block; only the model differs.
|
||||||
|
edit:
|
||||||
|
model_id: firered-image-edit
|
||||||
|
# Other edit knobs — uncomment to override Venice's default:
|
||||||
|
# Output format: jpeg, png, or webp. When omitted, Venice infers it (PNG at 1K, JPEG at 2K/4K).
|
||||||
|
# output_format: png
|
||||||
|
# Aspect ratio of the result: auto, 1:1, 3:2, 16:9, 21:9, 9:16, 2:3, 3:4, 4:5 (model-specific).
|
||||||
|
# aspect_ratio: auto
|
||||||
|
# Resolution tier: 1K, 2K, 4K (model-specific). Defaults to 1K.
|
||||||
|
# resolution: 1K
|
||||||
|
# Blur images classified as adult content. Defaults to true.
|
||||||
|
# safe_mode: true
|
||||||
|
# Other generation knobs — uncomment to override Venice's default. Omitting a knob is NOT the same
|
||||||
|
# as setting it: an omitted knob lets Venice apply its own default, a set value is sent verbatim.
|
||||||
|
# A description of what should NOT appear in the image.
|
||||||
|
# negative_prompt: "blurry, watermark, text"
|
||||||
|
# CFG scale, 0–20. Higher values make the image adhere more closely to the prompt.
|
||||||
|
# cfg_scale: 7.5
|
||||||
|
# Number of inference steps. Model-specific; some models ignore it.
|
||||||
|
# steps: 8
|
||||||
|
# A named style to apply (e.g. "3D Model"). See Venice's image-styles reference.
|
||||||
|
# style_preset: "3D Model"
|
||||||
|
# Random seed, -999999999–999999999. Fix it for reproducible results; omit for a random seed.
|
||||||
|
# seed: 123456789
|
||||||
|
# Blur images classified as adult content. Defaults to true.
|
||||||
|
# safe_mode: true
|
||||||
|
# Hide the Venice watermark. Venice may ignore this for certain generated content. Defaults to false.
|
||||||
|
# hide_watermark: false
|
||||||
|
# Output format: jpeg, png, or webp. webp is smallest; png is highest-quality. Defaults to webp.
|
||||||
|
# format: webp
|
||||||
|
# Image dimensions in pixels, each 1–1280. Default 1024×1024.
|
||||||
|
# width: 1024
|
||||||
|
# height: 1024
|
||||||
|
# Aspect ratio (used by certain models, e.g. Nano Banana): "1:1", "16:9". An alternative to width/height.
|
||||||
|
# aspect_ratio: "1:1"
|
||||||
|
# Resolution tier (used by certain models): "1K", "2K", "4K".
|
||||||
|
# resolution: "1K"
|
||||||
|
# Output quality for supported models (e.g. GPT Image 2): low, medium, high. Higher can cost more.
|
||||||
|
# quality: high
|
||||||
|
# Lora strength, 0–100. Only applies if the model uses additional Loras.
|
||||||
|
# lora_strength: 50
|
||||||
|
# Embed the generation prompt into the image's EXIF metadata. Defaults to false.
|
||||||
|
# embed_exif_metadata: false
|
||||||
|
# Let the model pull the latest info from the web for the image. Model-specific; costs extra credits.
|
||||||
|
# enable_web_search: false
|
||||||
@@ -109,6 +109,9 @@ fn create_controller_from_provider_and_json_value_config(
|
|||||||
AgentProvider::TogetherAI => {
|
AgentProvider::TogetherAI => {
|
||||||
provider::openai_compat::create_controller_from_yaml_value_config(agent_id, config)
|
provider::openai_compat::create_controller_from_yaml_value_config(agent_id, config)
|
||||||
}
|
}
|
||||||
|
AgentProvider::Venice => {
|
||||||
|
provider::venice::create_controller_from_yaml_value_config(agent_id, config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,5 +153,9 @@ pub fn default_config_for_provider(provider: &AgentProvider) -> serde_yaml_ng::V
|
|||||||
let config = super::provider::togetherai::default_config();
|
let config = super::provider::togetherai::default_config();
|
||||||
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
||||||
}
|
}
|
||||||
|
AgentProvider::Venice => {
|
||||||
|
let config = super::provider::venice::default_config();
|
||||||
|
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub enum ControllerType {
|
|||||||
OpenAI(Box<super::openai::Controller>),
|
OpenAI(Box<super::openai::Controller>),
|
||||||
OpenAICompat(Box<super::openai_compat::Controller>),
|
OpenAICompat(Box<super::openai_compat::Controller>),
|
||||||
Anthropic(Box<super::anthropic::Controller>),
|
Anthropic(Box<super::anthropic::Controller>),
|
||||||
|
Venice(Box<super::venice::Controller>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ControllerTrait for ControllerType {
|
impl ControllerTrait for ControllerType {
|
||||||
@@ -69,6 +70,7 @@ impl ControllerTrait for ControllerType {
|
|||||||
ControllerType::OpenAI(controller) => controller.supports_purpose(purpose),
|
ControllerType::OpenAI(controller) => controller.supports_purpose(purpose),
|
||||||
ControllerType::OpenAICompat(controller) => controller.supports_purpose(purpose),
|
ControllerType::OpenAICompat(controller) => controller.supports_purpose(purpose),
|
||||||
ControllerType::Anthropic(controller) => controller.supports_purpose(purpose),
|
ControllerType::Anthropic(controller) => controller.supports_purpose(purpose),
|
||||||
|
ControllerType::Venice(controller) => controller.supports_purpose(purpose),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +79,7 @@ impl ControllerTrait for ControllerType {
|
|||||||
ControllerType::OpenAI(controller) => controller.text_generation_model_id(),
|
ControllerType::OpenAI(controller) => controller.text_generation_model_id(),
|
||||||
ControllerType::OpenAICompat(controller) => controller.text_generation_model_id(),
|
ControllerType::OpenAICompat(controller) => controller.text_generation_model_id(),
|
||||||
ControllerType::Anthropic(controller) => controller.text_generation_model_id(),
|
ControllerType::Anthropic(controller) => controller.text_generation_model_id(),
|
||||||
|
ControllerType::Venice(controller) => controller.text_generation_model_id(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +88,7 @@ impl ControllerTrait for ControllerType {
|
|||||||
ControllerType::OpenAI(controller) => controller.text_generation_prompt(),
|
ControllerType::OpenAI(controller) => controller.text_generation_prompt(),
|
||||||
ControllerType::OpenAICompat(controller) => controller.text_generation_prompt(),
|
ControllerType::OpenAICompat(controller) => controller.text_generation_prompt(),
|
||||||
ControllerType::Anthropic(controller) => controller.text_generation_prompt(),
|
ControllerType::Anthropic(controller) => controller.text_generation_prompt(),
|
||||||
|
ControllerType::Venice(controller) => controller.text_generation_prompt(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +97,7 @@ impl ControllerTrait for ControllerType {
|
|||||||
ControllerType::OpenAI(controller) => controller.text_to_speech_voice(),
|
ControllerType::OpenAI(controller) => controller.text_to_speech_voice(),
|
||||||
ControllerType::OpenAICompat(controller) => controller.text_to_speech_voice(),
|
ControllerType::OpenAICompat(controller) => controller.text_to_speech_voice(),
|
||||||
ControllerType::Anthropic(controller) => controller.text_to_speech_voice(),
|
ControllerType::Anthropic(controller) => controller.text_to_speech_voice(),
|
||||||
|
ControllerType::Venice(controller) => controller.text_to_speech_voice(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +106,7 @@ impl ControllerTrait for ControllerType {
|
|||||||
ControllerType::OpenAI(controller) => controller.text_to_speech_speed(),
|
ControllerType::OpenAI(controller) => controller.text_to_speech_speed(),
|
||||||
ControllerType::OpenAICompat(controller) => controller.text_to_speech_speed(),
|
ControllerType::OpenAICompat(controller) => controller.text_to_speech_speed(),
|
||||||
ControllerType::Anthropic(controller) => controller.text_to_speech_speed(),
|
ControllerType::Anthropic(controller) => controller.text_to_speech_speed(),
|
||||||
|
ControllerType::Venice(controller) => controller.text_to_speech_speed(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +115,7 @@ impl ControllerTrait for ControllerType {
|
|||||||
ControllerType::OpenAI(controller) => controller.text_generation_temperature(),
|
ControllerType::OpenAI(controller) => controller.text_generation_temperature(),
|
||||||
ControllerType::OpenAICompat(controller) => controller.text_generation_temperature(),
|
ControllerType::OpenAICompat(controller) => controller.text_generation_temperature(),
|
||||||
ControllerType::Anthropic(controller) => controller.text_generation_temperature(),
|
ControllerType::Anthropic(controller) => controller.text_generation_temperature(),
|
||||||
|
ControllerType::Venice(controller) => controller.text_generation_temperature(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +124,7 @@ impl ControllerTrait for ControllerType {
|
|||||||
ControllerType::OpenAI(controller) => controller.ping().await,
|
ControllerType::OpenAI(controller) => controller.ping().await,
|
||||||
ControllerType::OpenAICompat(controller) => controller.ping().await,
|
ControllerType::OpenAICompat(controller) => controller.ping().await,
|
||||||
ControllerType::Anthropic(controller) => controller.ping().await,
|
ControllerType::Anthropic(controller) => controller.ping().await,
|
||||||
|
ControllerType::Venice(controller) => controller.ping().await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +143,9 @@ impl ControllerTrait for ControllerType {
|
|||||||
ControllerType::Anthropic(controller) => {
|
ControllerType::Anthropic(controller) => {
|
||||||
controller.generate_text(conversation, params).await
|
controller.generate_text(conversation, params).await
|
||||||
}
|
}
|
||||||
|
ControllerType::Venice(controller) => {
|
||||||
|
controller.generate_text(conversation, params).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +165,9 @@ impl ControllerTrait for ControllerType {
|
|||||||
ControllerType::Anthropic(controller) => {
|
ControllerType::Anthropic(controller) => {
|
||||||
controller.speech_to_text(mime_type, media, params).await
|
controller.speech_to_text(mime_type, media, params).await
|
||||||
}
|
}
|
||||||
|
ControllerType::Venice(controller) => {
|
||||||
|
controller.speech_to_text(mime_type, media, params).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +184,9 @@ impl ControllerTrait for ControllerType {
|
|||||||
ControllerType::Anthropic(controller) => {
|
ControllerType::Anthropic(controller) => {
|
||||||
controller.generate_image(prompt, params).await
|
controller.generate_image(prompt, params).await
|
||||||
}
|
}
|
||||||
|
ControllerType::Venice(controller) => {
|
||||||
|
controller.generate_image(prompt, params).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +206,9 @@ impl ControllerTrait for ControllerType {
|
|||||||
ControllerType::Anthropic(controller) => {
|
ControllerType::Anthropic(controller) => {
|
||||||
controller.create_image_edit(prompt, images, params).await
|
controller.create_image_edit(prompt, images, params).await
|
||||||
}
|
}
|
||||||
|
ControllerType::Venice(controller) => {
|
||||||
|
controller.create_image_edit(prompt, images, params).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +223,7 @@ impl ControllerTrait for ControllerType {
|
|||||||
controller.text_to_speech(text, params).await
|
controller.text_to_speech(text, params).await
|
||||||
}
|
}
|
||||||
ControllerType::Anthropic(controller) => controller.text_to_speech(text, params).await,
|
ControllerType::Anthropic(controller) => controller.text_to_speech(text, params).await,
|
||||||
|
ControllerType::Venice(controller) => controller.text_to_speech(text, params).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub enum AgentProvider {
|
|||||||
OpenAICompat,
|
OpenAICompat,
|
||||||
OpenRouter,
|
OpenRouter,
|
||||||
TogetherAI,
|
TogetherAI,
|
||||||
|
Venice,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentProvider {
|
impl AgentProvider {
|
||||||
@@ -25,6 +26,7 @@ impl AgentProvider {
|
|||||||
&Self::OpenAICompat,
|
&Self::OpenAICompat,
|
||||||
&Self::OpenRouter,
|
&Self::OpenRouter,
|
||||||
&Self::TogetherAI,
|
&Self::TogetherAI,
|
||||||
|
&Self::Venice,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ impl AgentProvider {
|
|||||||
Self::OpenAICompat => "openai-compatible",
|
Self::OpenAICompat => "openai-compatible",
|
||||||
Self::OpenRouter => "openrouter",
|
Self::OpenRouter => "openrouter",
|
||||||
Self::TogetherAI => "together-ai",
|
Self::TogetherAI => "together-ai",
|
||||||
|
Self::Venice => "venice",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +56,7 @@ impl AgentProvider {
|
|||||||
"openai-compatible" => Ok(Self::OpenAICompat),
|
"openai-compatible" => Ok(Self::OpenAICompat),
|
||||||
"openrouter" => Ok(Self::OpenRouter),
|
"openrouter" => Ok(Self::OpenRouter),
|
||||||
"together-ai" => Ok(Self::TogetherAI),
|
"together-ai" => Ok(Self::TogetherAI),
|
||||||
|
"venice" => Ok(Self::Venice),
|
||||||
_ => Err("Unexpected string value"),
|
_ => Err("Unexpected string value"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +185,25 @@ impl AgentProvider {
|
|||||||
text_generation_supports_vision: false,
|
text_generation_supports_vision: false,
|
||||||
text_generation_supports_tools: false,
|
text_generation_supports_tools: false,
|
||||||
},
|
},
|
||||||
|
Self::Venice => AgentProviderInfo {
|
||||||
|
id: Self::Venice.to_static_str(),
|
||||||
|
name: "Venice",
|
||||||
|
description: "Venice AI runs inference on Venice-controlled GPUs or zero-data-retention partner infrastructure and stores no prompts or responses. It serves frontier proprietary and open-source models with text-generation (including vision), speech-to-text, text-to-speech, native image generation and editing, and native web search.",
|
||||||
|
homepage_url: Some("https://venice.ai"),
|
||||||
|
wiki_url: None,
|
||||||
|
sign_up_url: Some("https://venice.ai"),
|
||||||
|
models_list_url: Some("https://api.venice.ai/api/v1/models"),
|
||||||
|
supported_purposes: vec![
|
||||||
|
AgentPurpose::ImageGeneration,
|
||||||
|
AgentPurpose::TextGeneration,
|
||||||
|
AgentPurpose::TextToSpeech,
|
||||||
|
AgentPurpose::SpeechToText,
|
||||||
|
],
|
||||||
|
text_generation_supports_vision: true,
|
||||||
|
// Venice does native web search via `venice_parameters`, NOT baibot's built-in
|
||||||
|
// tools mechanism (the OpenAI web_search/code_interpreter block), so this is false.
|
||||||
|
text_generation_supports_tools: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod openai;
|
|||||||
pub mod openai_compat;
|
pub mod openai_compat;
|
||||||
pub(super) mod openrouter;
|
pub(super) mod openrouter;
|
||||||
pub(super) mod togetherai;
|
pub(super) mod togetherai;
|
||||||
|
pub mod venice;
|
||||||
|
|
||||||
fn default_temperature() -> f32 {
|
fn default_temperature() -> f32 {
|
||||||
1.0
|
1.0
|
||||||
|
|||||||
156
src/agent/provider/venice/audio.rs
Normal file
156
src/agent/provider/venice/audio.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
use crate::agent::AgentPurpose;
|
||||||
|
use crate::agent::provider::entity::{TextToSpeechParams, TextToSpeechResult};
|
||||||
|
use crate::agent::provider::{SpeechToTextParams, SpeechToTextResult};
|
||||||
|
use crate::strings;
|
||||||
|
|
||||||
|
use super::config::Config;
|
||||||
|
use super::wire::{SpeechRequest, TranscriptionResponse};
|
||||||
|
|
||||||
|
pub async fn speech_to_text(
|
||||||
|
config: &Config,
|
||||||
|
http: &reqwest::Client,
|
||||||
|
mime_type: &mxlink::mime::Mime,
|
||||||
|
media: Vec<u8>,
|
||||||
|
params: SpeechToTextParams,
|
||||||
|
) -> anyhow::Result<SpeechToTextResult> {
|
||||||
|
let Some(speech_to_text_config) = &config.speech_to_text else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
strings::agent::no_configuration_for_purpose_so_cannot_be_used(
|
||||||
|
&AgentPurpose::SpeechToText
|
||||||
|
),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unlike the openai_compat path (which writes the audio to a temp file because its library
|
||||||
|
// can't take bytes), reqwest's multipart takes the bytes directly.
|
||||||
|
let part = reqwest::multipart::Part::bytes(media)
|
||||||
|
.file_name("audio")
|
||||||
|
.mime_str(mime_type.as_ref())?;
|
||||||
|
|
||||||
|
let mut form = reqwest::multipart::Form::new()
|
||||||
|
.part("file", part)
|
||||||
|
.text("model", speech_to_text_config.model_id.clone())
|
||||||
|
.text("response_format", "json");
|
||||||
|
|
||||||
|
if let Some(language) = ¶ms.language_override {
|
||||||
|
form = form.text("language", language.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/audio/transcriptions",
|
||||||
|
config.base_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::trace!(
|
||||||
|
model_id = speech_to_text_config.model_id,
|
||||||
|
language = ?params.language_override,
|
||||||
|
"Sending Venice audio transcription API request"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = http
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&config.api_key)
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
// Body to the server log only, not into the returned error (which reaches the Matrix room).
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(%status, body, "Venice audio transcription request failed");
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Venice audio transcription request failed with status {status}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: TranscriptionResponse = response.json().await?;
|
||||||
|
|
||||||
|
Ok(SpeechToTextResult {
|
||||||
|
text: response.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn text_to_speech(
|
||||||
|
config: &Config,
|
||||||
|
http: &reqwest::Client,
|
||||||
|
input: &str,
|
||||||
|
params: TextToSpeechParams,
|
||||||
|
) -> anyhow::Result<TextToSpeechResult> {
|
||||||
|
let Some(text_to_speech_config) = &config.text_to_speech else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
strings::agent::no_configuration_for_purpose_so_cannot_be_used(
|
||||||
|
&AgentPurpose::TextToSpeech
|
||||||
|
),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-call overrides win over the configured defaults.
|
||||||
|
let voice = params
|
||||||
|
.voice_override
|
||||||
|
.or_else(|| text_to_speech_config.voice.clone());
|
||||||
|
let speed = params.speed_override.or(text_to_speech_config.speed);
|
||||||
|
|
||||||
|
let response_format = text_to_speech_config.response_format.clone();
|
||||||
|
let mime_type = response_format_to_mime_type(response_format.as_deref());
|
||||||
|
|
||||||
|
let request = SpeechRequest {
|
||||||
|
model: text_to_speech_config.model_id.clone(),
|
||||||
|
input: input.to_owned(),
|
||||||
|
voice,
|
||||||
|
speed,
|
||||||
|
response_format,
|
||||||
|
prompt: text_to_speech_config.prompt.clone(),
|
||||||
|
temperature: text_to_speech_config.temperature,
|
||||||
|
top_p: text_to_speech_config.top_p,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/audio/speech", config.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
tracing::trace!(
|
||||||
|
model_id = text_to_speech_config.model_id,
|
||||||
|
voice = ?request.voice,
|
||||||
|
"Sending Venice text-to-speech API request"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = http
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&config.api_key)
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
// Body to the server log only, not into the returned error (which reaches the Matrix room).
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(%status, body, "Venice text-to-speech request failed");
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Venice text-to-speech request failed with status {status}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The speech endpoint answers with raw binary audio; read the body directly.
|
||||||
|
let bytes = response.bytes().await?.to_vec();
|
||||||
|
|
||||||
|
Ok(TextToSpeechResult { bytes, mime_type })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a Venice TTS `response_format` to its MIME type. Defaults to `audio/mpeg` (the
|
||||||
|
/// IANA-registered MP3 type, RFC 3003) when the format is unset, matching Venice's own `mp3`
|
||||||
|
/// default. This deliberately uses `audio/mpeg` rather than the `audio/mp3` alias the openai
|
||||||
|
/// provider emits; baibot's downstream audio-filename mapping treats both as `.mp3`.
|
||||||
|
fn response_format_to_mime_type(response_format: Option<&str>) -> mxlink::mime::Mime {
|
||||||
|
let raw = match response_format.unwrap_or("mp3") {
|
||||||
|
"mp3" => "audio/mpeg",
|
||||||
|
"opus" => "audio/ogg",
|
||||||
|
"aac" => "audio/aac",
|
||||||
|
"flac" => "audio/flac",
|
||||||
|
"wav" => "audio/wav",
|
||||||
|
"pcm" => "audio/L8",
|
||||||
|
_ => "audio/mpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
raw.parse()
|
||||||
|
.unwrap_or(mxlink::mime::APPLICATION_OCTET_STREAM)
|
||||||
|
}
|
||||||
122
src/agent/provider/venice/chat.rs
Normal file
122
src/agent/provider/venice/chat.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
use crate::agent::AgentPurpose;
|
||||||
|
use crate::agent::provider::entity::{TextGenerationParams, TextGenerationResult};
|
||||||
|
use crate::conversation::llm::{
|
||||||
|
Author as LLMAuthor, Conversation as LLMConversation, Message as LLMMessage,
|
||||||
|
MessageContent as LLMMessageContent, shorten_messages_list_to_context_size,
|
||||||
|
};
|
||||||
|
use crate::strings;
|
||||||
|
|
||||||
|
use super::config::Config;
|
||||||
|
use super::utils::convert_llm_messages_to_venice;
|
||||||
|
use super::wire::{ChatCompletionRequest, ChatCompletionResponse};
|
||||||
|
|
||||||
|
pub async fn generate_text(
|
||||||
|
config: &Config,
|
||||||
|
http: &reqwest::Client,
|
||||||
|
conversation: LLMConversation,
|
||||||
|
params: TextGenerationParams,
|
||||||
|
) -> anyhow::Result<TextGenerationResult> {
|
||||||
|
let Some(text_generation_config) = &config.text_generation else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
strings::agent::no_configuration_for_purpose_so_cannot_be_used(
|
||||||
|
&AgentPurpose::TextGeneration
|
||||||
|
),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let prompt_text = params.prompt_variables.format(
|
||||||
|
params
|
||||||
|
.prompt_override
|
||||||
|
.unwrap_or(text_generation_config.prompt.clone().unwrap_or_default())
|
||||||
|
.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let prompt_message = if prompt_text.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(LLMMessage {
|
||||||
|
author: LLMAuthor::Prompt,
|
||||||
|
sender_id: None,
|
||||||
|
content: LLMMessageContent::Text(prompt_text),
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut conversation_messages = conversation.messages;
|
||||||
|
|
||||||
|
if params.context_management_enabled {
|
||||||
|
conversation_messages = shorten_messages_list_to_context_size(
|
||||||
|
&text_generation_config.model_id,
|
||||||
|
&prompt_message,
|
||||||
|
conversation_messages,
|
||||||
|
text_generation_config.max_response_tokens,
|
||||||
|
text_generation_config.max_context_tokens,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(prompt_message) = prompt_message {
|
||||||
|
conversation_messages.insert(0, prompt_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = convert_llm_messages_to_venice(conversation_messages);
|
||||||
|
|
||||||
|
let temperature = params
|
||||||
|
.temperature_override
|
||||||
|
.unwrap_or(text_generation_config.temperature);
|
||||||
|
|
||||||
|
let request = ChatCompletionRequest {
|
||||||
|
model: text_generation_config.model_id.clone(),
|
||||||
|
messages,
|
||||||
|
temperature: Some(temperature),
|
||||||
|
// 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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/chat/completions",
|
||||||
|
config.base_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::trace!(
|
||||||
|
model = text_generation_config.model_id,
|
||||||
|
messages_count = request.messages.len(),
|
||||||
|
"Sending Venice chat completion API request"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = http
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&config.api_key)
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
// Log the body server-side for debugging (Venice explains a rejected strict body there),
|
||||||
|
// but keep it OUT of the returned error: that error surfaces in the Matrix room, and the
|
||||||
|
// 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");
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Venice chat completion request failed with status {status}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: ChatCompletionResponse = response.json().await?;
|
||||||
|
|
||||||
|
let Some(choice) = response.choices.into_iter().next() else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"No choices were returned from the Venice chat completion API"
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(content) = choice.message.content else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"No message content was returned from the Venice chat completion API"
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(TextGenerationResult { text: content })
|
||||||
|
}
|
||||||
355
src/agent/provider/venice/config.rs
Normal file
355
src/agent/provider/venice/config.rs
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::agent::{default_prompt, provider::ConfigTrait};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub base_url: String,
|
||||||
|
|
||||||
|
pub api_key: String,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text_generation: Option<TextGenerationConfig>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub speech_to_text: Option<SpeechToTextConfig>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text_to_speech: Option<TextToSpeechConfig>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub image_generation: Option<ImageGenerationConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: "https://api.venice.ai/api/v1".to_owned(),
|
||||||
|
api_key: "YOUR_API_KEY_HERE".to_owned(),
|
||||||
|
text_generation: Some(TextGenerationConfig::default()),
|
||||||
|
speech_to_text: Some(SpeechToTextConfig::default()),
|
||||||
|
text_to_speech: Some(TextToSpeechConfig::default()),
|
||||||
|
image_generation: Some(ImageGenerationConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigTrait for Config {
|
||||||
|
fn validate(&self) -> Result<(), String> {
|
||||||
|
if self.base_url.is_empty() {
|
||||||
|
return Err("The base URL must not be empty.".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TextGenerationConfig {
|
||||||
|
#[serde(default = "default_text_model_id")]
|
||||||
|
pub model_id: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "super::super::default_temperature")]
|
||||||
|
pub temperature: f32,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_response_tokens: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_context_tokens: u32,
|
||||||
|
|
||||||
|
/// 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")]
|
||||||
|
pub venice_parameters: Option<VeniceParameters>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TextGenerationConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
model_id: default_text_model_id(),
|
||||||
|
prompt: Some(default_prompt().to_owned()),
|
||||||
|
temperature: super::super::default_temperature(),
|
||||||
|
// Reserved output budget: sent as the response cap AND subtracted from the context
|
||||||
|
// window when trimming history. Mirrors the openai_compat sibling's default.
|
||||||
|
max_response_tokens: Some(4096),
|
||||||
|
// 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,
|
||||||
|
// 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 {
|
||||||
|
enable_web_search: Some(WebSearchMode::Auto),
|
||||||
|
strip_thinking_response: Some(true),
|
||||||
|
enable_e2ee: Some(false),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_text_model_id() -> String {
|
||||||
|
"kimi-k2-5".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`,
|
||||||
|
/// and an unset knob simply omits rather than sending `null`).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct VeniceParameters {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enable_web_search: Option<WebSearchMode>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enable_web_citations: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enable_web_scraping: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub include_venice_system_prompt: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub include_search_results_in_stream: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub return_search_results_as_documents: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enable_x_search: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enable_e2ee: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub character_slug: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub strip_thinking_response: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub disable_thinking: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum WebSearchMode {
|
||||||
|
Auto,
|
||||||
|
On,
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SpeechToTextConfig {
|
||||||
|
#[serde(default = "default_speech_to_text_model_id")]
|
||||||
|
pub model_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SpeechToTextConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
model_id: default_speech_to_text_model_id(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_speech_to_text_model_id() -> String {
|
||||||
|
"nvidia/parakeet-tdt-0.6b-v3".to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/audio/speech` (`CreateSpeechRequestSchema`) request knobs. Only `model_id` is required on
|
||||||
|
/// the wire; everything else is optional with `skip_serializing_if` so an unset knob is omitted
|
||||||
|
/// rather than sent as `null` (the body is `additionalProperties: false`). `voice` is a free
|
||||||
|
/// `Option<String>`, not a closed enum: Venice's voice set spans dozens of model-specific names
|
||||||
|
/// plus arbitrary cloned-voice handles (`vv_<id>`), so an enum would reject valid handles.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TextToSpeechConfig {
|
||||||
|
#[serde(default = "default_text_to_speech_model_id")]
|
||||||
|
pub model_id: String,
|
||||||
|
|
||||||
|
#[serde(
|
||||||
|
default = "default_text_to_speech_voice",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub voice: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub speed: Option<f32>,
|
||||||
|
|
||||||
|
#[serde(
|
||||||
|
default = "default_text_to_speech_response_format",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub response_format: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub temperature: Option<f32>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub top_p: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TextToSpeechConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
model_id: default_text_to_speech_model_id(),
|
||||||
|
voice: default_text_to_speech_voice(),
|
||||||
|
speed: None,
|
||||||
|
response_format: default_text_to_speech_response_format(),
|
||||||
|
prompt: None,
|
||||||
|
temperature: None,
|
||||||
|
top_p: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_text_to_speech_model_id() -> String {
|
||||||
|
"tts-kokoro".to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_text_to_speech_voice() -> Option<String> {
|
||||||
|
Some("af_sky".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_text_to_speech_response_format() -> Option<String> {
|
||||||
|
Some("mp3".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/image/generate` (`GenerateImageRequest`) request knobs, mirroring Venice's schema
|
||||||
|
/// field-for-field. Only `model_id` is required; every other knob is optional with
|
||||||
|
/// `skip_serializing_if` so unset knobs are omitted (the body is `additionalProperties: false`).
|
||||||
|
/// The full knob set is deliberate: the native `/image/generate` endpoint is the flagship's
|
||||||
|
/// reason to exist over the knob-dropping OpenAI-compat path, so the knobs ARE the feature.
|
||||||
|
/// The deprecated `inpaint` knob is intentionally absent.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ImageGenerationConfig {
|
||||||
|
#[serde(default = "default_image_generation_model_id")]
|
||||||
|
pub model_id: String,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub negative_prompt: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cfg_scale: Option<f32>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub steps: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub style_preset: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub seed: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub safe_mode: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub hide_watermark: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub format: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub width: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub height: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub aspect_ratio: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resolution: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub quality: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub lora_strength: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub embed_exif_metadata: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enable_web_search: Option<bool>,
|
||||||
|
|
||||||
|
/// Image-edit settings, nested here because baibot has a single `ImageGeneration` purpose
|
||||||
|
/// and edit shares its config gate. The gen and edit model sets are disjoint, so edit
|
||||||
|
/// carries its own model field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub edit: ImageEditSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ImageGenerationConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
model_id: default_image_generation_model_id(),
|
||||||
|
negative_prompt: None,
|
||||||
|
cfg_scale: None,
|
||||||
|
steps: None,
|
||||||
|
style_preset: None,
|
||||||
|
seed: None,
|
||||||
|
safe_mode: None,
|
||||||
|
hide_watermark: None,
|
||||||
|
format: None,
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
aspect_ratio: None,
|
||||||
|
resolution: None,
|
||||||
|
quality: None,
|
||||||
|
lora_strength: None,
|
||||||
|
embed_exif_metadata: None,
|
||||||
|
enable_web_search: None,
|
||||||
|
edit: ImageEditSettings::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_image_generation_model_id() -> String {
|
||||||
|
"chroma".to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/image/edit` (`EditImageRequest`) request knobs, mirroring Venice's schema. The source image
|
||||||
|
/// and prompt are supplied per-call (not config), so only the model and the output-shaping knobs
|
||||||
|
/// live here. Each knob is optional with `skip_serializing_if`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ImageEditSettings {
|
||||||
|
#[serde(default = "default_image_edit_model_id")]
|
||||||
|
pub model_id: String,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub output_format: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub aspect_ratio: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resolution: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub safe_mode: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ImageEditSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
model_id: default_image_edit_model_id(),
|
||||||
|
output_format: None,
|
||||||
|
aspect_ratio: None,
|
||||||
|
resolution: None,
|
||||||
|
safe_mode: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_image_edit_model_id() -> String {
|
||||||
|
"firered-image-edit".to_owned()
|
||||||
|
}
|
||||||
146
src/agent/provider/venice/controller.rs
Normal file
146
src/agent/provider/venice/controller.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use crate::agent::AgentPurpose;
|
||||||
|
use crate::agent::provider::entity::{
|
||||||
|
ImageEditResult, ImageGenerationResult, ImageSource, PingResult, TextGenerationParams,
|
||||||
|
TextGenerationResult, TextToSpeechParams, TextToSpeechResult,
|
||||||
|
};
|
||||||
|
use crate::agent::provider::{
|
||||||
|
ImageEditParams, ImageGenerationParams, SpeechToTextParams, SpeechToTextResult,
|
||||||
|
};
|
||||||
|
use crate::conversation::llm::{
|
||||||
|
Author as LLMAuthor, Conversation as LLMConversation, Message as LLMMessage,
|
||||||
|
MessageContent as LLMMessageContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::super::ControllerTrait;
|
||||||
|
use super::config::Config;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Controller {
|
||||||
|
config: Config,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Controller {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
// Image generation and text-to-speech can run long, so give the client a generous timeout
|
||||||
|
// instead of reqwest's default (none). `build` only fails on TLS/system init; fall back to
|
||||||
|
// the infallible `Client::new()` so this constructor stays infallible.
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new());
|
||||||
|
|
||||||
|
Self { config, http }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControllerTrait for Controller {
|
||||||
|
async fn ping(&self) -> anyhow::Result<PingResult> {
|
||||||
|
if !self.supports_purpose(AgentPurpose::TextGeneration) {
|
||||||
|
return Ok(PingResult::Inconclusive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the openai/openai_compat ping: a real "Hello!" round-trip exercises the strict
|
||||||
|
// /chat/completions body and auth, so a successful ping proves text generation works.
|
||||||
|
let messages = vec![LLMMessage {
|
||||||
|
author: LLMAuthor::User,
|
||||||
|
sender_id: None,
|
||||||
|
content: LLMMessageContent::Text("Hello!".to_string()),
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let conversation = LLMConversation { messages };
|
||||||
|
|
||||||
|
self.generate_text(conversation, TextGenerationParams::default())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(PingResult::Successful)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_text(
|
||||||
|
&self,
|
||||||
|
conversation: LLMConversation,
|
||||||
|
params: TextGenerationParams,
|
||||||
|
) -> anyhow::Result<TextGenerationResult> {
|
||||||
|
super::chat::generate_text(&self.config, &self.http, conversation, params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn speech_to_text(
|
||||||
|
&self,
|
||||||
|
mime_type: &mxlink::mime::Mime,
|
||||||
|
media: Vec<u8>,
|
||||||
|
params: SpeechToTextParams,
|
||||||
|
) -> anyhow::Result<SpeechToTextResult> {
|
||||||
|
super::audio::speech_to_text(&self.config, &self.http, mime_type, media, params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_image(
|
||||||
|
&self,
|
||||||
|
prompt: &str,
|
||||||
|
params: ImageGenerationParams,
|
||||||
|
) -> anyhow::Result<ImageGenerationResult> {
|
||||||
|
super::images::generate_image(&self.config, &self.http, prompt, params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_image_edit(
|
||||||
|
&self,
|
||||||
|
prompt: &str,
|
||||||
|
images: Vec<ImageSource>,
|
||||||
|
params: ImageEditParams,
|
||||||
|
) -> anyhow::Result<ImageEditResult> {
|
||||||
|
super::images::create_image_edit(&self.config, &self.http, prompt, images, params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn text_to_speech(
|
||||||
|
&self,
|
||||||
|
input: &str,
|
||||||
|
params: TextToSpeechParams,
|
||||||
|
) -> anyhow::Result<TextToSpeechResult> {
|
||||||
|
super::audio::text_to_speech(&self.config, &self.http, input, params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_purpose(&self, purpose: AgentPurpose) -> bool {
|
||||||
|
match purpose {
|
||||||
|
AgentPurpose::TextGeneration => self.config.text_generation.is_some(),
|
||||||
|
AgentPurpose::SpeechToText => self.config.speech_to_text.is_some(),
|
||||||
|
AgentPurpose::TextToSpeech => self.config.text_to_speech.is_some(),
|
||||||
|
AgentPurpose::ImageGeneration => self.config.image_generation.is_some(),
|
||||||
|
AgentPurpose::CatchAll => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_generation_model_id(&self) -> Option<String> {
|
||||||
|
self.config
|
||||||
|
.text_generation
|
||||||
|
.as_ref()
|
||||||
|
.map(|config| config.model_id.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_generation_prompt(&self) -> Option<String> {
|
||||||
|
self.config
|
||||||
|
.text_generation
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|config| config.prompt.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_generation_temperature(&self) -> Option<f32> {
|
||||||
|
self.config
|
||||||
|
.text_generation
|
||||||
|
.as_ref()
|
||||||
|
.map(|config| config.temperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_to_speech_voice(&self) -> Option<String> {
|
||||||
|
self.config
|
||||||
|
.text_to_speech
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|config| config.voice.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_to_speech_speed(&self) -> Option<f32> {
|
||||||
|
self.config
|
||||||
|
.text_to_speech
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|config| config.speed)
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/agent/provider/venice/images.rs
Normal file
192
src/agent/provider/venice/images.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
use crate::agent::AgentPurpose;
|
||||||
|
use crate::agent::provider::entity::{ImageEditResult, ImageGenerationResult, ImageSource};
|
||||||
|
use crate::agent::provider::{ImageEditParams, ImageGenerationParams};
|
||||||
|
use crate::strings;
|
||||||
|
use crate::utils::base64::{base64_decode, base64_encode};
|
||||||
|
|
||||||
|
use super::config::Config;
|
||||||
|
use super::wire::{EditImageRequest, GenerateImageRequest, GenerateImageResponse};
|
||||||
|
|
||||||
|
/// Generate an image via Venice's native `/image/generate` endpoint.
|
||||||
|
///
|
||||||
|
/// This is the base64-in-JSON path: we pin `return_binary: false` so Venice answers with a JSON
|
||||||
|
/// envelope (`GenerateImageResponse`) carrying the image as a base64 string, which we decode. The
|
||||||
|
/// sibling `create_image_edit` is the *other* response shape (raw binary); the two must not be
|
||||||
|
/// crossed. `params` is advisory only; the Venice config drives the request.
|
||||||
|
pub async fn generate_image(
|
||||||
|
config: &Config,
|
||||||
|
http: &reqwest::Client,
|
||||||
|
prompt: &str,
|
||||||
|
_params: ImageGenerationParams,
|
||||||
|
) -> anyhow::Result<ImageGenerationResult> {
|
||||||
|
let Some(image_generation_config) = &config.image_generation else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
strings::agent::no_configuration_for_purpose_so_cannot_be_used(
|
||||||
|
&AgentPurpose::ImageGeneration
|
||||||
|
),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = GenerateImageRequest {
|
||||||
|
model: image_generation_config.model_id.clone(),
|
||||||
|
prompt: prompt.to_owned(),
|
||||||
|
// Pinned: baibot wants exactly one image, returned as base64-in-JSON so `GenerateImageResponse`
|
||||||
|
// can decode it. Flipping `return_binary` would make Venice answer with raw binary and break
|
||||||
|
// the JSON decode below, so neither knob is configurable.
|
||||||
|
return_binary: false,
|
||||||
|
variants: 1,
|
||||||
|
negative_prompt: image_generation_config.negative_prompt.clone(),
|
||||||
|
cfg_scale: image_generation_config.cfg_scale,
|
||||||
|
steps: image_generation_config.steps,
|
||||||
|
style_preset: image_generation_config.style_preset.clone(),
|
||||||
|
seed: image_generation_config.seed,
|
||||||
|
safe_mode: image_generation_config.safe_mode,
|
||||||
|
hide_watermark: image_generation_config.hide_watermark,
|
||||||
|
format: image_generation_config.format.clone(),
|
||||||
|
width: image_generation_config.width,
|
||||||
|
height: image_generation_config.height,
|
||||||
|
aspect_ratio: image_generation_config.aspect_ratio.clone(),
|
||||||
|
resolution: image_generation_config.resolution.clone(),
|
||||||
|
quality: image_generation_config.quality.clone(),
|
||||||
|
lora_strength: image_generation_config.lora_strength,
|
||||||
|
embed_exif_metadata: image_generation_config.embed_exif_metadata,
|
||||||
|
enable_web_search: image_generation_config.enable_web_search,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/image/generate", config.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
// The prompt is user content; keep it out of logs (mirrors the STT/TTS paths).
|
||||||
|
tracing::trace!(
|
||||||
|
model_id = image_generation_config.model_id,
|
||||||
|
"Sending Venice image generation API request"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = http
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&config.api_key)
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
// Body to the server log only, not into the returned error (which reaches the Matrix room).
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(%status, body, "Venice image generation request failed");
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Venice image generation request failed with status {status}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: GenerateImageResponse = response.json().await?;
|
||||||
|
|
||||||
|
tracing::trace!(request_id = ?response.id, "Venice image generation succeeded");
|
||||||
|
|
||||||
|
let Some(image_base64) = response.images.into_iter().next() else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"The Venice image generation API returned no images"
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Swallow the decode error's detail (it can echo input bytes/offsets); the returned error
|
||||||
|
// reaches the Matrix room, so it stays generic while the real cause goes to the server log.
|
||||||
|
let bytes = base64_decode(&image_base64).map_err(|decode_err| {
|
||||||
|
tracing::warn!(%decode_err, "Venice image generation returned undecodable base64");
|
||||||
|
anyhow::anyhow!("Venice image generation returned invalid base64 image data")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(ImageGenerationResult {
|
||||||
|
bytes,
|
||||||
|
mime_type: image_format_to_mime_type(image_generation_config.format.as_deref()),
|
||||||
|
revised_prompt: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edit an image via Venice's native `/image/edit` endpoint.
|
||||||
|
///
|
||||||
|
/// This is the raw-binary path: the request is JSON carrying the source image as a base64 string
|
||||||
|
/// (Venice's `image` field is `anyOf` upload/base64/URL; we send base64, no multipart), and the
|
||||||
|
/// response body IS the edited image bytes (no JSON envelope). `params` is advisory only.
|
||||||
|
pub async fn create_image_edit(
|
||||||
|
config: &Config,
|
||||||
|
http: &reqwest::Client,
|
||||||
|
prompt: &str,
|
||||||
|
images: Vec<ImageSource>,
|
||||||
|
_params: ImageEditParams,
|
||||||
|
) -> anyhow::Result<ImageEditResult> {
|
||||||
|
let Some(image_generation_config) = &config.image_generation else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
strings::agent::no_configuration_for_purpose_so_cannot_be_used(
|
||||||
|
&AgentPurpose::ImageGeneration
|
||||||
|
),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let edit_config = &image_generation_config.edit;
|
||||||
|
|
||||||
|
let Some(source) = images.into_iter().next() else {
|
||||||
|
return Err(anyhow::anyhow!("No image sources provided"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = EditImageRequest {
|
||||||
|
model: edit_config.model_id.clone(),
|
||||||
|
prompt: prompt.to_owned(),
|
||||||
|
image: base64_encode(&source.bytes),
|
||||||
|
output_format: edit_config.output_format.clone(),
|
||||||
|
aspect_ratio: edit_config.aspect_ratio.clone(),
|
||||||
|
resolution: edit_config.resolution.clone(),
|
||||||
|
safe_mode: edit_config.safe_mode,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/image/edit", config.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
// The prompt is user content; keep it out of logs (mirrors the STT/TTS paths).
|
||||||
|
tracing::trace!(
|
||||||
|
model_id = edit_config.model_id,
|
||||||
|
"Sending Venice image edit API request"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = http
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&config.api_key)
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
// Body to the server log only, not into the returned error (which reaches the Matrix room).
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(%status, body, "Venice image edit request failed");
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Venice image edit request failed with status {status}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The edit endpoint answers with raw binary image bytes, so read the body directly instead of
|
||||||
|
// parsing JSON. The actual format comes from the response Content-Type header; fall back to the
|
||||||
|
// configured `output_format` when the header is missing or unparseable.
|
||||||
|
let mime_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.parse::<mxlink::mime::Mime>().ok())
|
||||||
|
.unwrap_or_else(|| image_format_to_mime_type(edit_config.output_format.as_deref()));
|
||||||
|
|
||||||
|
let bytes = response.bytes().await?.to_vec();
|
||||||
|
|
||||||
|
Ok(ImageEditResult { bytes, mime_type })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a Venice image `format`/`output_format` value (`jpeg`/`png`/`webp`) to its MIME type.
|
||||||
|
/// Venice defaults to `webp` when the format is unset, so an absent value maps to `image/webp`.
|
||||||
|
fn image_format_to_mime_type(format: Option<&str>) -> mxlink::mime::Mime {
|
||||||
|
match format.unwrap_or("webp") {
|
||||||
|
"jpeg" | "jpg" => mxlink::mime::IMAGE_JPEG,
|
||||||
|
"png" => mxlink::mime::IMAGE_PNG,
|
||||||
|
// No mxlink::mime constant for webp; parse it, falling back to PNG on any surprise value.
|
||||||
|
_ => "image/webp"
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(mxlink::mime::IMAGE_PNG),
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/agent/provider/venice/mod.rs
Normal file
47
src/agent/provider/venice/mod.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
mod audio;
|
||||||
|
mod chat;
|
||||||
|
mod config;
|
||||||
|
mod controller;
|
||||||
|
mod images;
|
||||||
|
mod utils;
|
||||||
|
mod wire;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
pub use config::Config;
|
||||||
|
pub use controller::Controller;
|
||||||
|
|
||||||
|
use super::super::AgentInstantiationError;
|
||||||
|
use super::super::AgentInstantiationResult;
|
||||||
|
use super::ConfigTrait;
|
||||||
|
use super::controller::ControllerType;
|
||||||
|
|
||||||
|
pub fn create_controller_from_yaml_value_config(
|
||||||
|
agent_id: &str,
|
||||||
|
config: serde_yaml_ng::Value,
|
||||||
|
) -> AgentInstantiationResult<ControllerType> {
|
||||||
|
let config = match &config {
|
||||||
|
serde_yaml_ng::Value::Mapping(_) => {
|
||||||
|
let config: Config =
|
||||||
|
serde_yaml_ng::from_value(config).map_err(AgentInstantiationError::Yaml)?;
|
||||||
|
|
||||||
|
config
|
||||||
|
.validate()
|
||||||
|
.map_err(AgentInstantiationError::ConfigFailsValidation)?;
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AgentInstantiationError::ConfigForAgentIsNotAMapping(
|
||||||
|
agent_id.to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ControllerType::Venice(Box::new(Controller::new(config))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_config() -> Config {
|
||||||
|
Config::default()
|
||||||
|
}
|
||||||
269
src/agent/provider/venice/tests.rs
Normal file
269
src/agent/provider/venice/tests.rs
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
use mxlink::matrix_sdk::ruma::OwnedMxcUri;
|
||||||
|
use mxlink::matrix_sdk::ruma::events::room::message::{
|
||||||
|
FileMessageEventContent, ImageMessageEventContent,
|
||||||
|
};
|
||||||
|
use mxlink::mime;
|
||||||
|
|
||||||
|
use super::super::ControllerTrait;
|
||||||
|
use crate::agent::AgentPurpose;
|
||||||
|
use crate::conversation::llm::{
|
||||||
|
Author as LLMAuthor, FileDetails, ImageDetails, Message as LLMMessage,
|
||||||
|
MessageContent as LLMMessageContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::config::{Config, VeniceParameters, WebSearchMode};
|
||||||
|
use super::controller::Controller;
|
||||||
|
use super::utils::convert_llm_messages_to_venice;
|
||||||
|
use super::wire::{
|
||||||
|
ContentPart, EditImageRequest, GenerateImageRequest, MessageContent, SpeechRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_round_trips_with_venice_parameters() {
|
||||||
|
let yaml = r#"
|
||||||
|
base_url: https://api.venice.ai/api/v1
|
||||||
|
api_key: test-key
|
||||||
|
text_generation:
|
||||||
|
model_id: kimi-k2-5
|
||||||
|
temperature: 0.7
|
||||||
|
max_response_tokens: 1024
|
||||||
|
max_context_tokens: 65536
|
||||||
|
venice_parameters:
|
||||||
|
enable_web_search: "auto"
|
||||||
|
enable_web_citations: true
|
||||||
|
speech_to_text:
|
||||||
|
model_id: nvidia/parakeet-tdt-0.6b-v3
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config: Config = serde_yaml_ng::from_str(yaml).expect("config should deserialize");
|
||||||
|
|
||||||
|
let tg = config.text_generation.expect("text_generation present");
|
||||||
|
let vp = tg.venice_parameters.expect("venice_parameters present");
|
||||||
|
|
||||||
|
assert!(matches!(vp.enable_web_search, Some(WebSearchMode::Auto)));
|
||||||
|
assert_eq!(vp.enable_web_citations, Some(true));
|
||||||
|
assert_eq!(vp.character_slug, None);
|
||||||
|
|
||||||
|
// The bag must serialize the enum to the exact wire string, and an unset knob must be ABSENT
|
||||||
|
// (not `null`) so the strict `additionalProperties: false` body is honored.
|
||||||
|
let json = serde_json::to_string(&vp).expect("serialize venice_parameters");
|
||||||
|
assert!(
|
||||||
|
json.contains("\"enable_web_search\":\"auto\""),
|
||||||
|
"web search should be the literal \"auto\": {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!json.contains("character_slug"),
|
||||||
|
"an unset knob must be omitted entirely: {json}"
|
||||||
|
);
|
||||||
|
assert!(!json.contains("null"), "no nulls belong in the body: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_image_to_data_uri_and_skips_files() {
|
||||||
|
let messages = vec![
|
||||||
|
LLMMessage {
|
||||||
|
author: LLMAuthor::User,
|
||||||
|
sender_id: None,
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
content: LLMMessageContent::Text("describe this".to_owned()),
|
||||||
|
},
|
||||||
|
LLMMessage {
|
||||||
|
author: LLMAuthor::User,
|
||||||
|
sender_id: None,
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
content: LLMMessageContent::Image(ImageDetails::new(
|
||||||
|
ImageMessageEventContent::plain(
|
||||||
|
"pic.png".to_owned(),
|
||||||
|
OwnedMxcUri::from("mxc://example.com/abc"),
|
||||||
|
),
|
||||||
|
mime::IMAGE_PNG,
|
||||||
|
vec![1, 2, 3],
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
LLMMessage {
|
||||||
|
author: LLMAuthor::User,
|
||||||
|
sender_id: None,
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
content: LLMMessageContent::File(FileDetails::new(
|
||||||
|
FileMessageEventContent::plain(
|
||||||
|
"doc.pdf".to_owned(),
|
||||||
|
OwnedMxcUri::from("mxc://example.com/def"),
|
||||||
|
),
|
||||||
|
mime::APPLICATION_PDF,
|
||||||
|
vec![4, 5, 6],
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let converted = convert_llm_messages_to_venice(messages);
|
||||||
|
|
||||||
|
// Text and image survive; the file is warn-skipped.
|
||||||
|
assert_eq!(converted.len(), 2);
|
||||||
|
|
||||||
|
match &converted[0].content {
|
||||||
|
MessageContent::Text(text) => assert_eq!(text, "describe this"),
|
||||||
|
other => panic!("expected bare text, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match &converted[1].content {
|
||||||
|
MessageContent::Parts(parts) => match &parts[0] {
|
||||||
|
ContentPart::ImageUrl { image_url } => assert!(
|
||||||
|
image_url.url.starts_with("data:image/png;base64,"),
|
||||||
|
"image should be inlined as a data URI: {}",
|
||||||
|
image_url.url
|
||||||
|
),
|
||||||
|
},
|
||||||
|
other => panic!("expected image parts, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supports_purpose_truth_table() {
|
||||||
|
let config: Config = serde_yaml_ng::from_str(
|
||||||
|
r#"
|
||||||
|
base_url: https://api.venice.ai/api/v1
|
||||||
|
api_key: test-key
|
||||||
|
text_generation:
|
||||||
|
model_id: kimi-k2-5
|
||||||
|
speech_to_text:
|
||||||
|
model_id: nvidia/parakeet-tdt-0.6b-v3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("config should deserialize");
|
||||||
|
|
||||||
|
let controller = Controller::new(config);
|
||||||
|
|
||||||
|
assert!(controller.supports_purpose(AgentPurpose::TextGeneration));
|
||||||
|
assert!(controller.supports_purpose(AgentPurpose::SpeechToText));
|
||||||
|
assert!(controller.supports_purpose(AgentPurpose::CatchAll));
|
||||||
|
assert!(!controller.supports_purpose(AgentPurpose::TextToSpeech));
|
||||||
|
assert!(!controller.supports_purpose(AgentPurpose::ImageGeneration));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supports_purpose_true_when_image_and_tts_blocks_present() {
|
||||||
|
let config: Config = serde_yaml_ng::from_str(
|
||||||
|
r#"
|
||||||
|
base_url: https://api.venice.ai/api/v1
|
||||||
|
api_key: test-key
|
||||||
|
text_to_speech:
|
||||||
|
model_id: tts-kokoro
|
||||||
|
image_generation:
|
||||||
|
model_id: chroma
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("config should deserialize");
|
||||||
|
|
||||||
|
let controller = Controller::new(config);
|
||||||
|
|
||||||
|
assert!(controller.supports_purpose(AgentPurpose::TextToSpeech));
|
||||||
|
assert!(controller.supports_purpose(AgentPurpose::ImageGeneration));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speech_request_serializes_voice_and_omits_unset() {
|
||||||
|
let request = SpeechRequest {
|
||||||
|
model: "tts-kokoro".to_owned(),
|
||||||
|
input: "hello".to_owned(),
|
||||||
|
voice: Some("af_sky".to_owned()),
|
||||||
|
speed: None,
|
||||||
|
response_format: Some("mp3".to_owned()),
|
||||||
|
prompt: None,
|
||||||
|
temperature: None,
|
||||||
|
top_p: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).expect("serialize SpeechRequest");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
json.contains("\"voice\":\"af_sky\""),
|
||||||
|
"voice should be present: {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!json.contains("temperature"),
|
||||||
|
"an unset knob must be omitted (not null): {json}"
|
||||||
|
);
|
||||||
|
assert!(!json.contains("null"), "no nulls belong in the body: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_image_request_pins_flags_and_omits_unset() {
|
||||||
|
let request = GenerateImageRequest {
|
||||||
|
model: "chroma".to_owned(),
|
||||||
|
prompt: "a cat".to_owned(),
|
||||||
|
return_binary: false,
|
||||||
|
variants: 1,
|
||||||
|
negative_prompt: None,
|
||||||
|
cfg_scale: None,
|
||||||
|
steps: None,
|
||||||
|
style_preset: None,
|
||||||
|
seed: None,
|
||||||
|
safe_mode: None,
|
||||||
|
hide_watermark: None,
|
||||||
|
format: None,
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
aspect_ratio: None,
|
||||||
|
resolution: None,
|
||||||
|
quality: None,
|
||||||
|
lora_strength: None,
|
||||||
|
embed_exif_metadata: None,
|
||||||
|
enable_web_search: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).expect("serialize GenerateImageRequest");
|
||||||
|
|
||||||
|
assert!(json.contains("\"model\":\"chroma\""), "{json}");
|
||||||
|
assert!(
|
||||||
|
json.contains("\"return_binary\":false"),
|
||||||
|
"return_binary must be pinned false: {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.contains("\"variants\":1"),
|
||||||
|
"variants must be pinned 1: {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!json.contains("cfg_scale"),
|
||||||
|
"an unset knob must be omitted: {json}"
|
||||||
|
);
|
||||||
|
assert!(!json.contains("null"), "no nulls belong in the body: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_image_request_carries_model_and_base64_image() {
|
||||||
|
let request = EditImageRequest {
|
||||||
|
model: "firered-image-edit".to_owned(),
|
||||||
|
prompt: "make it a sunrise".to_owned(),
|
||||||
|
image: "aGVsbG8=".to_owned(),
|
||||||
|
output_format: None,
|
||||||
|
aspect_ratio: None,
|
||||||
|
resolution: None,
|
||||||
|
safe_mode: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).expect("serialize EditImageRequest");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
json.contains("\"model\":\"firered-image-edit\""),
|
||||||
|
"{json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.contains("\"image\":\"aGVsbG8=\""),
|
||||||
|
"the base64 image string must be present: {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!json.contains("output_format"),
|
||||||
|
"an unset knob must be omitted: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_search_mode_off_deserializes_from_bare_yaml_off() {
|
||||||
|
// `off` is a YAML-1.1 boolean but a plain string under serde_yaml_ng's YAML-1.2 core schema,
|
||||||
|
// so it deserializes straight into the lowercase `WebSearchMode::Off`. This pins that the
|
||||||
|
// sample config and docs can use the bare, unquoted `off` without it parsing as a boolean.
|
||||||
|
let params: VeniceParameters =
|
||||||
|
serde_yaml_ng::from_str("enable_web_search: off").expect("bare `off` should deserialize");
|
||||||
|
|
||||||
|
assert!(matches!(params.enable_web_search, Some(WebSearchMode::Off)));
|
||||||
|
}
|
||||||
55
src/agent/provider/venice/utils.rs
Normal file
55
src/agent/provider/venice/utils.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use crate::conversation::llm::{
|
||||||
|
Author as LLMAuthor, Message as LLMMessage, MessageContent as LLMMessageContent,
|
||||||
|
};
|
||||||
|
use crate::utils::base64::base64_encode;
|
||||||
|
|
||||||
|
use super::wire::{ChatMessage, ContentPart, ImageUrl, MessageContent};
|
||||||
|
|
||||||
|
pub fn convert_llm_messages_to_venice(messages: Vec<LLMMessage>) -> 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
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_llm_message_to_venice(message: LLMMessage) -> Option<ChatMessage> {
|
||||||
|
let role = match message.author {
|
||||||
|
LLMAuthor::Prompt => "system",
|
||||||
|
LLMAuthor::Assistant => "assistant",
|
||||||
|
LLMAuthor::User => "user",
|
||||||
|
};
|
||||||
|
|
||||||
|
match message.content {
|
||||||
|
LLMMessageContent::Text(text) => Some(ChatMessage {
|
||||||
|
role: role.to_owned(),
|
||||||
|
content: MessageContent::Text(text),
|
||||||
|
}),
|
||||||
|
LLMMessageContent::Image(image_details) => {
|
||||||
|
// Inline the image as a base64 data URI, the same shape the OpenAI vision content
|
||||||
|
// part uses. This is the gap the openai_compat provider can't fill (it drops images).
|
||||||
|
let data_uri = format!(
|
||||||
|
"data:{};base64,{}",
|
||||||
|
image_details.mime,
|
||||||
|
base64_encode(&image_details.data)
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(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."
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
212
src/agent/provider/venice/wire.rs
Normal file
212
src/agent/provider/venice/wire.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
//! Serde structs modeling Venice's `/chat/completions`, `/audio/transcriptions`,
|
||||||
|
//! `/audio/speech`, `/image/generate`, and `/image/edit` wire shapes. Request types are
|
||||||
|
//! `Serialize`-only (we build them, Venice never sends them back); response types are
|
||||||
|
//! `Deserialize`-only. Keeping the split means the untagged request content enum is never on a
|
||||||
|
//! deserialize path, so a surprise response shape can't fail to match it.
|
||||||
|
//!
|
||||||
|
//! Field names match Venice's schema 1:1 (so the config's `model_id` becomes `model` here). Every
|
||||||
|
//! request body is `additionalProperties: false`, so optional knobs carry `skip_serializing_if`
|
||||||
|
//! to omit rather than send `null`. `/audio/speech` and `/image/edit` return raw binary (no
|
||||||
|
//! response struct); only `/image/generate` returns JSON (`GenerateImageResponse`).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::config::VeniceParameters;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ChatCompletionRequest {
|
||||||
|
pub model: String,
|
||||||
|
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub temperature: Option<f32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_completion_tokens: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub venice_parameters: Option<VeniceParameters>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub role: String,
|
||||||
|
|
||||||
|
pub content: MessageContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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).
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum MessageContent {
|
||||||
|
Text(String),
|
||||||
|
Parts(Vec<ContentPart>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ContentPart {
|
||||||
|
ImageUrl { image_url: ImageUrl },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ImageUrl {
|
||||||
|
/// A `data:<mime>;base64,<data>` URI for inline images.
|
||||||
|
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, Deserialize)]
|
||||||
|
pub struct ChatCompletionResponse {
|
||||||
|
pub choices: Vec<ChatChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ChatChoice {
|
||||||
|
pub message: ResponseMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ResponseMessage {
|
||||||
|
#[serde(default)]
|
||||||
|
pub content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/audio/transcriptions` response. We read `text`; the optional `duration`/`timestamps` the
|
||||||
|
/// API can return are not used in v1.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TranscriptionResponse {
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/audio/speech` (`CreateSpeechRequestSchema`) request. `input` and `model` are always sent;
|
||||||
|
/// the rest are omitted when unset. The response is raw binary audio, so there is no response
|
||||||
|
/// struct.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SpeechRequest {
|
||||||
|
pub model: String,
|
||||||
|
|
||||||
|
pub input: String,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub voice: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub speed: Option<f32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub response_format: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub temperature: Option<f32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub top_p: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/image/generate` (`GenerateImageRequest`) request. `return_binary` is pinned `false` and
|
||||||
|
/// `variants` to `1` by the builder: baibot wants exactly one image returned as base64-in-JSON,
|
||||||
|
/// which `GenerateImageResponse` then decodes. Flipping `return_binary` would make Venice answer
|
||||||
|
/// with raw binary and break that JSON decode, so it is not configurable.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct GenerateImageRequest {
|
||||||
|
pub model: String,
|
||||||
|
|
||||||
|
pub prompt: String,
|
||||||
|
|
||||||
|
pub return_binary: bool,
|
||||||
|
|
||||||
|
pub variants: u32,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub negative_prompt: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cfg_scale: Option<f32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub steps: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub style_preset: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub seed: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub safe_mode: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub hide_watermark: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub format: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub width: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub height: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub aspect_ratio: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resolution: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub quality: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub lora_strength: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub embed_exif_metadata: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enable_web_search: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/image/generate` response when `return_binary` is false: a JSON envelope carrying the images
|
||||||
|
/// as base64 strings. We read `images[0]`; `request`/`timing` and other fields are ignored. `id`
|
||||||
|
/// is telemetry only (logged, never used for correctness), so it is optional: a response that
|
||||||
|
/// carries usable `images` must not fail to deserialize just because the telemetry field drifted.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GenerateImageResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub images: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/image/edit` (`EditImageRequest`) request. The source `image` is a base64-encoded string
|
||||||
|
/// (Venice's `image` field is `anyOf` upload/base64/URL; we send base64-in-JSON, no multipart).
|
||||||
|
/// The response is raw binary, so there is no response struct.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct EditImageRequest {
|
||||||
|
pub model: String,
|
||||||
|
|
||||||
|
pub prompt: String,
|
||||||
|
|
||||||
|
/// Base64-encoded source image bytes.
|
||||||
|
pub image: String,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub output_format: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub aspect_ratio: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resolution: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub safe_mode: Option<bool>,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user