Initial commit

Co-authored-by: xinchengxx <xinchengxx@deepseek.com>
Co-authored-by: XieJiSS <xiejiss@deepseek.com>
Co-authored-by: yqaty <jiayu@deepseek.com>
Co-authored-by: zhu-he <hezhu@deepseek.com>
Co-authored-by: season-guo <seasonguo@deepseek.com>
This commit is contained in:
icrim 2026-09-10 18:07:01 +08:00
commit 57b9c84242
125 changed files with 555904 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
**/target/
.venv/
__pycache__/
.pytest_cache/
*.so
*.pyd
*.dSYM/
*.egg-info/
dist/
dist-repaired/
.vscode/

3205
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

54
Cargo.toml Normal file
View File

@ -0,0 +1,54 @@
[workspace]
members = [
"deepseek-recipe",
"deepseek-recipe-core",
"deepseek-recipe-encoding",
"deepseek-recipe-image",
"deepseek-recipe-python",
"encoding-decoding-demo",
"server-rs",
]
resolver = "3"
[workspace.package]
edition = "2024"
readme = "README.md"
license = "MIT"
repository = "https://github.com/deepseek-ai/deepseek-recipe"
[workspace.dependencies]
async-stream = "0.3.6"
axum = "0.8.7"
base64 = "0.22.1"
data-url = "0.3.2"
deepseek-recipe = { version = "0.1.0", path = "deepseek-recipe" }
deepseek-recipe-core = { version = "0.1.0", path = "deepseek-recipe-core" }
deepseek-recipe-encoding = { version = "0.1.0", path = "deepseek-recipe-encoding" }
deepseek-recipe-image = { version = "0.1.0", path = "deepseek-recipe-image" }
futures = "0.3.31"
image = { version = "0.25.0", default-features = false, features = [
"gif",
"jpeg",
"png",
"webp",
] }
infer = "0.19.0"
itertools = "0.14.0"
jsonschema = { version = "0.18.3", default-features = false }
opencv = { version = "0.93", default-features = false, features = [
"clang-runtime",
"imgcodecs",
"imgproc",
] }
reqwest = { version = "0.12.28", default-features = false, features = [
"rustls-tls",
"stream",
] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.145", features = ["preserve_order"] }
strum_macros = "0.28.0"
thiserror = "2.0.17"
tokenizers = { version = "0.23.2", features = ["http"] }
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] }
tokio-stream = "0.1.17"
tracing = "0.1.41"

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

137
README.md Normal file
View File

@ -0,0 +1,137 @@
<div align="center">
<img src="static/deepseek-whale.svg" alt="DeepSeek" width="240">
</div>
# deepseek-recipe
**English** | [中文](README.zh.md)
deepseek-recipe is a collection of Rust libraries and Python bindings that
uniformly convert API requests in different formats into the Conversation
format, encode them into prompts for DeepSeek models, and convert model output
into responses in the corresponding format. Use these components to connect an
inference backend to API services that support multiple formats. Model
inference, tool execution, and HTTP transport must be provided externally.
[Getting started](#using-deepseek-recipe) · [Streaming](docs/streaming.md) ·
[Use with tokenizer](docs/tokenizer.md) · [Contributing](CONTRIBUTING.md) ·
[License](#license)
## Supported scope
- **Request/response formats:** Conversion of Messages, Chat Completions, and
Responses requests, [streamed responses](docs/streaming.md), and complete
responses. Supports text, images, thinking, and client tool calls.
- **Prompts:** Encoding of DeepSeek V4 and V4.1 conversations into prompts or token IDs.
- **Generation settings:** Thinking mode, reasoning effort, `temperature`, `top_p`,
and output token limits.
- **Output parsing:** Thinking, tool calls, JSON object output, and stop sequences.
- **Images:** Provided as base64 or external URLs. The image component provides
DeepSeek V4.1 preprocessing with OpenCV.
- **Tool definitions:** Function tools; the Responses API also supports tool
namespaces and the `apply_patch` custom tool.
## Not yet supported
- Token probabilities (`logprobs` and `top_logprobs`).
- Document content, audio/video input, and file retrieval by `file_id`.
- Server tool execution, such as `web_search`.
- JSON Schema and regex output constraints, or enforcement of tool `strict` settings.
- Multiple completions per Chat Completions request (`n > 1`).
- Responses custom tool definitions other than `apply_patch`.
- Responses conversation storage and context retrieval through `previous_response_id`.
- Responses encrypted thinking content (`encrypted_content`).
## Using deepseek-recipe
To convert a Chat Completions request into a DeepSeek V4.1 prompt:
### Python
#### Installation
Python 3.10+:
```sh
python3 -m pip install deepseek-recipe
```
#### Example
```python
from deepseek_recipe import ChatCompletionRequest, ConversionOptions, DeepseekV41Encoding
request = ChatCompletionRequest({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
})
converted = request.convert(ConversionOptions())
rendered = DeepseekV41Encoding().render_conversation(converted.conversation)
print(rendered.prompt)
```
### Rust
#### Installation
```sh
cargo add deepseek-recipe@0.1 deepseek-recipe-encoding@0.1
```
See the [development guide](docs/development.md) for source builds and image dependencies.
#### Example
```rust
use deepseek_recipe::openai::ChatCompletionRequest;
use deepseek_recipe::request::{ConversionOptions, ProtocolRequest};
use deepseek_recipe_encoding::PromptEncoding;
use deepseek_recipe_encoding::v4::dsv41::DeepseekV41Encoding;
use serde_json::json;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let request: ChatCompletionRequest = serde_json::from_value(json!({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
}))?;
let converted = request.convert(ConversionOptions::default())?;
let rendered = DeepseekV41Encoding::new().render_conversation(&converted.conversation);
println!("{}", rendered.prompt);
Ok(())
}
```
### Encoding & Decoding Demo
Run the encoding and decoding demo from the repository root:
```sh
cargo run -p encoding-decoding-demo --locked
```
Open [http://127.0.0.1:7778](http://127.0.0.1:7778).
### More examples
Rust and Python both support converting model output into streaming responses.
See the [streaming examples](docs/streaming.md). To encode conversations into
token IDs or decode backend token IDs, see
[use with tokenizer](docs/tokenizer.md).
## Packages and example projects
| Package | Purpose |
| --- | --- |
| [`deepseek-recipe`](https://docs.rs/deepseek-recipe) | Protocol conversion and model output parsing. |
| [`deepseek-recipe-core`](https://docs.rs/deepseek-recipe-core) | Shared conversation, message, image, and tool types. |
| [`deepseek-recipe-encoding`](https://docs.rs/deepseek-recipe-encoding) | DeepSeek V4 and V4.1 prompt rendering and token encoding. |
| [`deepseek-recipe-image`](https://docs.rs/deepseek-recipe-image) | Image fetching and preprocessing. |
| [`deepseek-recipe-python`](deepseek-recipe-python/README.md) | Python bindings, imported as `deepseek_recipe`. |
| [encoding-decoding-demo](encoding-decoding-demo/README.md) | A web interface for encoding prompts, inspecting special tokens, and decoding complete model output into Chat Completions, Responses, or Messages. |
| [server-rs](server-rs/README.md) | An Axum API example with mock inference. |
| [server-py](server-py/README.md) | A FastAPI example with mock inference. |
## License
Project code and public documentation are licensed under the [MIT License](LICENSE).
Bundled tokenizer notices are in [static/tokenizers/README.md](static/tokenizers/README.md).

122
README.zh.md Normal file
View File

@ -0,0 +1,122 @@
<div align="center">
<img src="static/deepseek-whale.svg" alt="DeepSeek" width="240">
</div>
# deepseek-recipe
[English](README.md) | **中文**
deepseek-recipe 包含一套 Rust 库以及对应的 Python bindings:支持将不同格式的 API 请求统一转换为 Conversation 格式,编码成适用于 DeepSeek 模型的 prompt,并支持将模型输出转换为对应格式的响应。
可用于把推理后端接入兼容多种格式的 API 服务;模型推理、工具执行和 HTTP 传输需由外部提供。
[快速上手](#使用-deepseek-recipe) · [流式响应](docs/streaming.zh.md) · [使用 tokenizer](docs/tokenizer.zh.md) · [贡献指南](CONTRIBUTING.md) · [许可证](#许可证)
## 支持范围
- **请求/响应格式:** Messages、Chat Completions 与 Responses 的请求转换、[流式响应](docs/streaming.zh.md)和完整响应,支持文本、图片、思考和客户端工具调用。
- **Prompt:** 支持将 DeepSeek V4 与 V4.1 的对话编码为 prompt 或 token IDs。
- **生成参数:** 思考模式、reasoning effort、`temperature`、`top_p` 和输出 token 上限。
- **输出解析:** 思考、工具调用、JSON object 输出和 stop sequence。
- **图片:** 支持以 base64 或外部 URL 传入。提供基于 OpenCV 的 DeepSeek V4.1 图片预处理。
- **工具定义:** 支持函数工具;Responses API 还支持工具命名空间和 `apply_patch` 自定义工具。
## 尚未支持
- Token 概率(`logprobs` 和 `top_logprobs`)。
- 文档内容、音视频输入和通过 `file_id` 获取文件。
- 服务端工具执行,例如 `web_search`。
- JSON Schema、正则表达式输出约束,以及工具 `strict` 设置的强制执行。
- Chat Completions 单次请求生成多个候选回复(`n > 1`)。
- Responses 中除 `apply_patch` 外的自定义工具定义。
- Responses 的会话存储,以及通过 `previous_response_id` 获取上下文。
- Responses 的加密思考内容(`encrypted_content`)。
## 使用 deepseek-recipe
将 Chat Completions 请求转换为 DeepSeek V4.1 prompt:
### Python
#### 安装
Python 3.10+:
```sh
python3 -m pip install deepseek-recipe
```
#### 示例
```python
from deepseek_recipe import ChatCompletionRequest, ConversionOptions, DeepseekV41Encoding
request = ChatCompletionRequest({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
})
converted = request.convert(ConversionOptions())
rendered = DeepseekV41Encoding().render_conversation(converted.conversation)
print(rendered.prompt)
```
### Rust
#### 安装
```sh
cargo add deepseek-recipe@0.1 deepseek-recipe-encoding@0.1
```
源码构建与图片依赖见[开发指南](docs/development.md)。
#### 示例
```rust
use deepseek_recipe::openai::ChatCompletionRequest;
use deepseek_recipe::request::{ConversionOptions, ProtocolRequest};
use deepseek_recipe_encoding::PromptEncoding;
use deepseek_recipe_encoding::v4::dsv41::DeepseekV41Encoding;
use serde_json::json;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let request: ChatCompletionRequest = serde_json::from_value(json!({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
}))?;
let converted = request.convert(ConversionOptions::default())?;
let rendered = DeepseekV41Encoding::new().render_conversation(&converted.conversation);
println!("{}", rendered.prompt);
Ok(())
}
```
### Encoding & Decoding Demo
在仓库根目录运行编解码 demo:
```sh
cargo run -p encoding-decoding-demo --locked
```
打开 [http://127.0.0.1:7778](http://127.0.0.1:7778)。
### 更多示例
Rust 和 Python 均支持将模型输出转换为流式响应,见[流式响应示例](docs/streaming.zh.md);将对话编码为 token IDs、以及直接解码后端返回的 token IDs 的示例见[使用 tokenizer](docs/tokenizer.zh.md)。
## 包与示例
| 包 | 用途 |
| --- | --- |
| [`deepseek-recipe`](https://docs.rs/deepseek-recipe) | 协议转换与模型输出解析。 |
| [`deepseek-recipe-core`](https://docs.rs/deepseek-recipe-core) | 共享的对话、消息、图片与工具类型。 |
| [`deepseek-recipe-encoding`](https://docs.rs/deepseek-recipe-encoding) | DeepSeek V4 与 V4.1 的 prompt 渲染与 token 编码。 |
| [`deepseek-recipe-image`](https://docs.rs/deepseek-recipe-image) | 图片拉取与预处理。 |
| [`deepseek-recipe-python`](deepseek-recipe-python/README.md) | Python bindings,以 `deepseek_recipe` 导入。 |
| [encoding-decoding-demo](encoding-decoding-demo/README.md) | 编码 prompt、查看 special token,并将完整模型输出解码为 Chat Completions、Responses 或 Messages 的 Web 界面。 |
| [server-rs](server-rs/README.md) | 使用 mock 推理的 Axum API 示例。 |
| [server-py](server-py/README.md) | 支持 mock 推理的 FastAPI 示例。 |
## 许可证
项目代码和公开文档采用 [MIT License](LICENSE)。随仓库分发的 tokenizer 声明见 [static/tokenizers/README.md](static/tokenizers/README.md)。

View File

@ -0,0 +1,13 @@
[package]
name = "deepseek-recipe-core"
version = "0.1.0"
edition.workspace = true
description = "Shared conversation, message, and tool types for DeepSeek models."
readme.workspace = true
documentation = "https://docs.rs/deepseek-recipe-core"
license.workspace = true
repository.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,55 @@
use serde::{Deserialize, Serialize};
use crate::messages::InputMessage;
use crate::tools::{ToolChoice, ToolDefinition};
/// A conversation and its prompt configuration.
///
/// Sampling parameters and transport options belong to the caller. Thinking is
/// enabled by default.
#[derive(Debug, Clone)]
pub struct Conversation {
/// Messages in conversation order.
pub messages: Vec<InputMessage>,
/// Enable thinking mode for the next assistant turn.
pub thinking_mode: bool,
/// Available client tools, in declaration order.
pub tools: Vec<ToolDefinition>,
/// Tool selection after protocol-specific normalization.
pub tool_choice: ToolChoice,
/// Reasoning effort used when thinking mode is enabled.
pub reasoning_effort: Option<ReasoningEffort>,
/// Requested answer format used during prompt rendering.
pub response_format: ResponseFormat,
}
impl Default for Conversation {
fn default() -> Self {
Self {
messages: Vec::new(),
thinking_mode: true,
tools: Vec::new(),
tool_choice: ToolChoice::Auto,
reasoning_effort: None,
response_format: ResponseFormat::Text,
}
}
}
/// Output format described in the inference prompt.
#[derive(Debug, Clone, Default)]
pub enum ResponseFormat {
#[default]
Text,
JsonObject,
}
/// Protocol-independent reasoning effort.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningEffort {
Low,
High,
Xhigh,
Max,
}

View File

@ -0,0 +1,8 @@
//! Shared conversation, image, and tool types for protocol adapters and prompt
//! rendering.
pub mod conversation;
pub mod messages;
pub mod multimodal;
pub mod tools;
pub mod util;

View File

@ -0,0 +1,34 @@
use crate::multimodal::ImageSource;
/// A message in the conversation supplied for prompt rendering.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputMessage {
System {
content: String,
},
User {
content: String,
image_sources: Vec<ImageSource>,
},
Assistant {
content: String,
reasoning_content: Option<String>,
tool_calls: Option<Vec<ToolCall>>,
},
Tool {
content: String,
image_sources: Vec<ImageSource>,
tool_call_id: String,
},
LatestReminder {
content: String,
},
}
/// A historical tool call and its arguments serialized as JSON.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}

View File

@ -0,0 +1,197 @@
//! Image input types and DeepSeek V4.1 image token accounting.
//!
//! A caller supplies images as [`ImageSource`] values. Resolving an image source
//! produces an [`ImageInfo`] carrying the bytes sent to the inference backend.
//! Every image contributes one [`IMAGE_SPECIAL_TOKEN`] placeholder to the prompt
//! text, in the order the images appear in the conversation.
use serde::{Deserialize, Serialize};
pub mod token_spec;
pub use token_spec::{CalcResizeError, ImageTokenSpec, ResizeResult};
/// Placeholder written into the prompt for every image.
pub const IMAGE_SPECIAL_TOKEN: &str = "<|image|>";
/// Token ID that the V4.1 tokenizer assigns to [`IMAGE_SPECIAL_TOKEN`]. A caller
/// that encodes the prompt into token IDs can compare against this ID.
pub const IMAGE_SPECIAL_TOKEN_ID: u32 = 129264;
/// Maximum UTF-8 byte length of an external image URL during conversion.
pub const MAX_IMAGE_URL_LEN: usize = 8192;
/// Media type accepted for image input.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageMediaType {
Jpeg,
Png,
Gif,
Webp,
}
impl ImageMediaType {
/// Return the media type of this image media type.
pub const fn mime(self) -> &'static str {
match self {
Self::Jpeg => "image/jpeg",
Self::Png => "image/png",
Self::Gif => "image/gif",
Self::Webp => "image/webp",
}
}
/// Resolve a media type string. Parameters after `;` are ignored, matching
/// is case-insensitive, and `image/jpg` is accepted as an alias of
/// `image/jpeg`. Returns `None` for an unsupported media type.
pub fn from_mime(mime: &str) -> Option<Self> {
let mime = mime.split(';').next().unwrap_or_default().trim();
let (kind, subtype) = mime.split_once('/')?;
if !kind.eq_ignore_ascii_case("image") {
return None;
}
if subtype.eq_ignore_ascii_case("jpeg") || subtype.eq_ignore_ascii_case("jpg") {
return Some(Self::Jpeg);
}
if subtype.eq_ignore_ascii_case("png") {
return Some(Self::Png);
}
if subtype.eq_ignore_ascii_case("gif") {
return Some(Self::Gif);
}
if subtype.eq_ignore_ascii_case("webp") {
return Some(Self::Webp);
}
None
}
}
/// Requested image detail level.
///
/// The OpenCV preprocessor applies an additional long-side limit for
/// [`Low`](Self::Low) before fitting the token budget. All levels still undergo
/// token-budget fitting and WebP encoding, which can resize the image.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ImageDetail {
Low,
#[default]
High,
Original,
Auto,
}
impl ImageDetail {
/// Return whether the additional low-detail long-side limit applies.
pub const fn is_low(self) -> bool {
matches!(self, Self::Low)
}
}
/// One image supplied with a conversation, before resolution.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImageSource {
/// Data URL with a base64 or percent-encoded body.
DataUrl {
data_url: String,
detail: ImageDetail,
},
/// External image URL. Conversion and resolution check the case-insensitive
/// `http` prefix; the fetcher is responsible for validating the full URL.
Url { url: String, detail: ImageDetail },
/// Encoded image bytes supplied by the caller.
Bytes { data: Vec<u8>, detail: ImageDetail },
}
impl ImageSource {
/// Return the requested detail level.
pub const fn detail(&self) -> ImageDetail {
match self {
Self::DataUrl { detail, .. }
| Self::Url { detail, .. }
| Self::Bytes { detail, .. } => *detail,
}
}
}
/// Return whether a value is an external image URL.
///
/// Checks whether the first four bytes spell `http`, ignoring ASCII case.
/// This classifies external image references during conversion and resolution;
/// it does not validate URL syntax, the complete scheme, or the destination.
pub fn is_http_url(url: &str) -> bool {
url.get(..4)
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("http"))
}
/// A preprocessed image sent to the inference backend.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageInfo {
/// Encoded image bytes.
pub data: Vec<u8>,
/// Width in pixels after preprocessing.
pub width: u32,
/// Height in pixels after preprocessing.
pub height: u32,
}
/// Images attached to one conversation request.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MultiModalData {
/// Images in the order their placeholders appear in the prompt.
pub images: Vec<ImageInfo>,
}
impl MultiModalData {
/// Return whether no image is attached.
pub fn is_empty(&self) -> bool {
self.images.is_empty()
}
/// Return the number of prompt tokens beyond one per placeholder.
///
/// Every image contributes one placeholder token to the prompt and
/// [`ImageTokenSpec::calc_token_len`] tokens to the inference input. The
/// adjustment is the difference over all images.
///
/// # Errors
///
/// Returns [`CalcResizeError`] when the token length of an image does not
/// converge.
pub fn image_token_adjustment(&self) -> Result<usize, CalcResizeError> {
let spec = ImageTokenSpec::v41();
let mut adjustment = 0usize;
for image in &self.images {
adjustment += spec.calc_token_len(image.width as usize, image.height as usize)? - 1;
}
Ok(adjustment)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_adjustment_counts_one_placeholder_per_image() {
let spec = ImageTokenSpec::v41();
let data = MultiModalData {
images: vec![
ImageInfo {
data: Vec::new(),
width: 1920,
height: 1080,
},
ImageInfo {
data: Vec::new(),
width: 640,
height: 480,
},
],
};
let expected =
spec.calc_token_len(1920, 1080).unwrap() + spec.calc_token_len(640, 480).unwrap() - 2;
assert_eq!(data.image_token_adjustment().unwrap(), expected);
assert!(MultiModalData::default().image_token_adjustment().unwrap() == 0);
}
}

View File

@ -0,0 +1,314 @@
//! Image token accounting for DeepSeek V4.1.
use std::fmt;
/// Result of fitting one image into the token budget.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResizeResult {
/// Number of token rows in the model token grid.
pub n_llm_h: usize,
/// Number of token columns in the model token grid.
pub n_llm_w: usize,
/// Resized image height in pixels.
pub best_height: usize,
/// Resized image width in pixels.
pub best_width: usize,
/// Total number of model tokens for the image.
pub num_tokens: usize,
}
/// Failure to fit an image into the token budget.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CalcResizeError {
/// Fitting the image did not reach a fixed point within the iteration limit.
NotConverged {
max_iter: usize,
last_result: ResizeResult,
},
}
impl fmt::Display for CalcResizeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotConverged {
max_iter,
last_result,
} => write!(
f,
"image resize did not converge after {max_iter} iterations, last result: {last_result:?}"
),
}
}
}
impl std::error::Error for CalcResizeError {}
/// DeepSeek V4.1 image token specification.
///
/// The specification maps an image size to the number of model tokens the image
/// occupies. V4.1 uses 14-pixel patches and a spatial downsample ratio of 3.
/// Images with a positive area below 544 * 544 pixels are upscaled before
/// patch alignment and token-budget fitting. This is an area target, not a
/// minimum for each dimension; token-budget fitting can reduce it further.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageTokenSpec {
patch_size: usize,
downsample_ratio: usize,
max_n_token: usize,
min_pixels: usize,
}
impl ImageTokenSpec {
/// Return the specification of DeepSeek V4.1 image tokens.
///
/// Images are divided into 14-pixel patches, downsampled by 3 in each
/// dimension, and limited to 1024 model tokens. Images with a positive area
/// below 544 * 544 pixels are upscaled before alignment and budget fitting.
pub const fn v41() -> Self {
Self {
patch_size: 14,
downsample_ratio: 3,
max_n_token: 1024,
min_pixels: 544 * 544,
}
}
/// Return the maximum number of model tokens one image may occupy.
pub const fn max_n_token(&self) -> usize {
self.max_n_token
}
/// Return the pixel size of one patch.
pub const fn patch_size(&self) -> usize {
self.patch_size
}
/// Return the spatial downsampling ratio between the patch grid and the
/// model token grid.
pub const fn downsample_ratio(&self) -> usize {
self.downsample_ratio
}
/// Return the model tokens one image of `width`×`height` pixels occupies.
///
/// # Errors
///
/// Returns [`CalcResizeError`] when fitting the image does not converge.
pub fn calc_token_len(&self, width: usize, height: usize) -> Result<usize, CalcResizeError> {
self.calc_resize(width, height)
.map(|result| result.num_tokens)
}
/// Return whether token-budget fitting leaves `width`×`height` unchanged.
/// This checks dimensions only; it does not inspect encoded image bytes.
pub fn is_image_valid(&self, width: usize, height: usize) -> bool {
self.calc_resize(width, height)
.map(|result| result.best_width == width && result.best_height == height)
.unwrap_or(false)
}
/// Return the target size and token length for an image of `width`×`height`
/// pixels.
///
/// # Errors
///
/// Returns [`CalcResizeError`] when fitting the image does not converge.
pub fn calc_resize(
&self,
width: usize,
height: usize,
) -> Result<ResizeResult, CalcResizeError> {
const MAX_ITER: usize = 10;
let mut result = self.calc_resize_once(width, height);
for _ in 1..MAX_ITER {
let next = self.calc_resize_once(result.best_width, result.best_height);
if next == result {
return Ok(result);
}
result = next;
}
Err(CalcResizeError::NotConverged {
max_iter: MAX_ITER,
last_result: result,
})
}
/// Upscale to the minimum area, align to patches, and fit the token budget.
fn calc_resize_once(&self, width: usize, height: usize) -> ResizeResult {
let mut width = width;
let mut height = height;
let current_pixels = width * height;
if current_pixels < self.min_pixels && current_pixels > 0 {
let ratio = (self.min_pixels as f64 / current_pixels as f64).sqrt();
width = (width as f64 * ratio) as usize;
height = (height as f64 * ratio) as usize;
}
let best_width = width.div_ceil(self.patch_size) * self.patch_size;
let best_height = height.div_ceil(self.patch_size) * self.patch_size;
self.fit_token_budget(height, width, best_height, best_width)
}
/// Reduce `best_width`×`best_height` until the token budget is satisfied.
fn fit_token_budget(
&self,
height: usize,
width: usize,
best_height: usize,
best_width: usize,
) -> ResizeResult {
let mut result = self.resize_result(best_height, best_width);
if result.num_tokens > self.max_n_token {
result = self.solve_resize_ratio(height, width, self.max_n_token);
assert!(
result.num_tokens <= self.max_n_token,
"token budget must be satisfied after one solve"
);
}
result
}
/// Return the token length of a patch grid of `best_height`×`best_width`
/// pixels.
fn resize_result(&self, best_height: usize, best_width: usize) -> ResizeResult {
let n_llm_h = (best_height / self.patch_size).div_ceil(self.downsample_ratio);
let n_llm_w = (best_width / self.patch_size).div_ceil(self.downsample_ratio);
ResizeResult {
n_llm_h,
n_llm_w,
best_height,
best_width,
num_tokens: self.calc_num_tokens(n_llm_h, n_llm_w),
}
}
/// Return the model tokens of a `n_llm_h`×`n_llm_w` token grid.
///
/// Each row holds one newline token in addition to its image tokens, and the
/// image is delimited by a start and an end token.
fn calc_num_tokens(&self, n_llm_h: usize, n_llm_w: usize) -> usize {
n_llm_h * (n_llm_w + 1) + 2
}
/// Solve for the largest aspect-preserving size within `max_n_token`.
fn solve_resize_ratio(&self, height: usize, width: usize, max_n_token: usize) -> ResizeResult {
let ratio = height as f64 / width as f64;
let max_w_float = ((max_n_token as f64 - 2.0) / ratio + 0.25).sqrt() - 0.5;
let max_h_float = max_w_float * ratio;
let (best_height, best_width);
if max_w_float < 1.0 {
let max_w = 1;
let max_h = (max_n_token - 2) / (max_w + 1);
best_width = max_w * self.patch_size * self.downsample_ratio;
best_height = max_h * self.patch_size * self.downsample_ratio;
} else if max_h_float < 1.0 {
let max_h = 1;
let max_w = (max_n_token - 2) / max_h - 1;
assert!(max_w > 1, "token budget must allow a two-column grid");
best_width = max_w * self.patch_size * self.downsample_ratio;
best_height = max_h * self.patch_size * self.downsample_ratio;
} else {
let max_w = max_w_float as usize;
let max_h = max_h_float as usize;
let beta_w = (max_w * self.patch_size * self.downsample_ratio) as f64 / width as f64;
let beta_h = (max_h * self.patch_size * self.downsample_ratio) as f64 / height as f64;
let beta = beta_w.min(beta_h);
best_width = (width as f64 * beta / self.patch_size as f64) as usize * self.patch_size;
best_height =
(height as f64 * beta / self.patch_size as f64) as usize * self.patch_size;
}
self.resize_result(best_height, best_width)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Expected target sizes and token lengths of the V4.1 preprocessing.
const CASES: &[(usize, usize, usize, usize, usize, usize, usize)] = &[
// width, height, num_tokens, best_width, best_height, n_llm_h, n_llm_w
(978, 479, 302, 980, 490, 12, 24),
(272, 300, 198, 518, 574, 14, 13),
(516, 517, 184, 546, 546, 13, 13),
(1091, 397, 272, 1092, 406, 10, 26),
(743, 870, 401, 756, 882, 21, 18),
(906, 740, 416, 910, 742, 18, 22),
(1107, 873, 590, 1120, 882, 21, 27),
(493, 243, 202, 784, 392, 10, 19),
(354, 476, 197, 476, 630, 15, 12),
(766, 642, 322, 770, 644, 16, 19),
(972, 672, 402, 980, 672, 16, 24),
(1167, 486, 350, 1176, 490, 12, 28),
(1134, 389, 282, 1134, 392, 10, 27),
(275, 346, 197, 490, 616, 15, 12),
(1033, 141, 187, 1484, 210, 5, 36),
(125, 751, 226, 224, 1344, 32, 6),
(560, 1029, 377, 560, 1036, 25, 14),
(482, 725, 236, 490, 728, 18, 12),
(782, 879, 422, 784, 882, 21, 19),
(434, 272, 200, 700, 434, 11, 17),
(2, 165, 356, 70, 4942, 118, 2),
(165, 2, 240, 4942, 70, 2, 118),
(158, 10000, 762, 126, 7966, 190, 3),
(10000, 158, 962, 10010, 168, 4, 239),
(40, 50, 197, 490, 616, 15, 12),
(50, 40, 194, 616, 490, 12, 15),
(1, 1, 184, 546, 546, 13, 13),
(100, 100, 184, 546, 546, 13, 13),
(16809, 11841, 990, 1540, 1092, 26, 37),
(1920, 330, 378, 1932, 336, 8, 46),
(8192, 8192, 994, 1302, 1302, 31, 31),
(4096, 4096, 994, 1302, 1302, 31, 31),
(1920, 1080, 968, 1708, 966, 23, 41),
(1080, 1920, 986, 966, 1708, 41, 23),
(3024, 4032, 1010, 1134, 1512, 36, 27),
(640, 480, 206, 644, 490, 12, 16),
];
#[test]
fn calc_resize_matches_v41_preprocessing() {
let spec = ImageTokenSpec::v41();
for &(width, height, num_tokens, best_width, best_height, n_llm_h, n_llm_w) in CASES {
let result = spec.calc_resize(width, height).expect("resize converges");
assert_eq!(
result,
ResizeResult {
n_llm_h,
n_llm_w,
best_height,
best_width,
num_tokens,
},
"calc_resize({width}, {height})"
);
assert!(
result.num_tokens <= spec.max_n_token(),
"({width}, {height}) exceeds the budget"
);
}
}
#[test]
fn fitted_images_are_valid() {
let spec = ImageTokenSpec::v41();
for &(_, _, _, best_width, best_height, _, _) in CASES {
assert!(
spec.is_image_valid(best_width, best_height),
"{best_width}x{best_height}"
);
}
}
#[test]
fn token_len_has_a_floor_and_a_ceiling() {
let spec = ImageTokenSpec::v41();
assert_eq!(spec.calc_token_len(1, 1).expect("resize converges"), 184);
assert_eq!(
spec.calc_token_len(16809, 11841).expect("resize converges"),
990
);
}
}

View File

@ -0,0 +1,26 @@
//! Client tool definitions used when constructing a conversation.
/// A function the caller may execute after receiving a tool call.
#[derive(Debug, Clone)]
pub struct ToolDefinition {
/// Function name.
pub name: String,
/// Human-readable purpose of the function.
pub description: Option<String>,
/// JSON Schema describing the input object.
pub parameters: serde_json::Value,
/// Requested strict argument validation. Enforcement belongs to the caller.
pub strict: Option<bool>,
}
/// Normalized tool selection for a rendered conversation.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ToolChoice {
/// The model may answer or call a tool.
#[default]
Auto,
/// Render the prompt with tool definitions omitted.
None,
/// Start the assistant output inside a tool-call block.
Required,
}

View File

@ -0,0 +1,104 @@
//! JSON serialization in Python's `json.dumps` style.
use std::io;
use serde::Serialize;
use serde_json::ser::{CompactFormatter, Formatter};
/// Uses spaced separators and normalizes small negative float exponents.
struct PythonStyleFormatter;
impl Formatter for PythonStyleFormatter {
fn begin_array_value<W>(&mut self, writer: &mut W, first: bool) -> io::Result<()>
where
W: ?Sized + io::Write,
{
writer.write_all(if first { b"" } else { b", " })
}
fn begin_object_key<W>(&mut self, writer: &mut W, first: bool) -> io::Result<()>
where
W: ?Sized + io::Write,
{
writer.write_all(if first { b"" } else { b", " })
}
fn begin_object_value<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
writer.write_all(b": ")
}
fn write_f64<W>(&mut self, writer: &mut W, value: f64) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let mut buf = Vec::with_capacity(32);
CompactFormatter.write_f64(&mut buf, value)?;
if !buf.contains(&b'e') {
let (sign, rest): (&[u8], &[u8]) = match buf.strip_prefix(b"-") {
Some(rest) => (b"-", rest),
None => (b"", &buf),
};
if let Some(fraction) = rest.strip_prefix(b"0.") {
let zeros = fraction.iter().take_while(|&&b| b == b'0').count();
if let [first_digit, rest_digits @ ..] = &fraction[zeros..]
&& zeros >= 4
{
writer.write_all(sign)?;
writer.write_all(std::slice::from_ref(first_digit))?;
if !rest_digits.is_empty() {
writer.write_all(b".")?;
writer.write_all(rest_digits)?;
}
return write!(writer, "e-{:02}", zeros + 1);
}
}
} else if let [mantissa @ .., b'e', b'-', digit] = buf.as_slice() {
writer.write_all(mantissa)?;
return write!(writer, "e-0{}", *digit as char);
}
writer.write_all(&buf)
}
}
/// Serialize JSON with spaced separators and Python-style negative float
/// exponents. Non-ASCII text remains unescaped; other string and number
/// formatting follows `serde_json`.
///
/// The input is a JSON value, whose serialization to memory produces valid
/// JSON.
pub fn stringify_python_style(value: &serde_json::Value) -> String {
let mut buf = Vec::with_capacity(128);
let mut ser = serde_json::ser::Serializer::with_formatter(&mut buf, PythonStyleFormatter);
value
.serialize(&mut ser)
.expect("JSON values serialize to memory");
String::from_utf8(buf).expect("JSON serialization produces UTF-8")
}
#[cfg(test)]
mod tests {
use super::stringify_python_style;
#[test]
fn spaced_separators() {
let value = serde_json::json!({
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The location user interested in"
}
},
"required": ["location"],
"additionalProperties": false
});
assert_eq!(
stringify_python_style(&value),
r#"{"type": "object", "properties": {"location": {"type": "string", "description": "The location user interested in"}}, "required": ["location"], "additionalProperties": false}"#
);
}
}

View File

@ -0,0 +1,3 @@
//! Shared helpers used by protocol adapters and prompt rendering.
pub mod json_formatter;

View File

@ -0,0 +1,17 @@
[package]
name = "deepseek-recipe-encoding"
version = "0.1.0"
edition.workspace = true
description = "Conversation rendering and token encoding for DeepSeek V4 and V4.1 prompts."
readme.workspace = true
documentation = "https://docs.rs/deepseek-recipe-encoding"
license.workspace = true
repository.workspace = true
[dependencies]
deepseek-recipe-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokenizers = { workspace = true }
tracing = { workspace = true }

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,47 @@
//! Model-specific conversation rendering and token encoding.
//!
//! V4 and V4.1 provide prompt rendering. Attach a tokenizer to an encoding to
//! encode conversations into token IDs.
use deepseek_recipe_core::conversation::Conversation;
use deepseek_recipe_core::multimodal::ImageSource;
pub use tokenizer::TokenizerEncoder;
pub use v4::{dsv4, dsv41};
pub mod v4;
mod tokenizer;
/// A rendered prompt and the images its placeholders refer to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedPrompt {
/// Model input string.
pub prompt: String,
/// Image sources in the order their placeholders appear in the prompt.
pub image_sources: Vec<ImageSource>,
}
/// Failure to encode a conversation.
#[derive(Debug, thiserror::Error)]
pub enum EncodingError {
/// The rendered prompt could not be encoded.
#[error("failed to encode conversation: {0}")]
Encode(String),
/// The encoding has no attached tokenizer.
#[error("no tokenizer is attached to the encoding")]
MissingTokenizer,
}
/// Render model prompts and encode conversations into token IDs.
pub trait PromptEncoding {
/// Encode a conversation into model token IDs using the attached tokenizer.
///
/// The tokenizer must be attached to the encoding before this call, through
/// the encoding's `with_tokenizer` method. Returns
/// [`EncodingError::MissingTokenizer`] when no tokenizer is attached.
fn encode(&self, conversation: &Conversation) -> Result<Vec<u32>, EncodingError>;
/// Render the conversation and the prefix for the next assistant turn.
fn render_conversation(&self, conversation: &Conversation) -> RenderedPrompt;
}

View File

@ -0,0 +1,28 @@
//! Encode rendered prompts into token ids.
use std::sync::Arc;
use tokenizers::Tokenizer;
/// Encode text into model token ids.
///
/// Implemented for [`Tokenizer`]; other tokenizers can implement it to back a
/// prompt encoding.
pub trait TokenizerEncoder: Send + Sync {
/// Encode `text` into token ids without added special tokens.
fn encode_ids(&self, text: &str) -> Result<Vec<u32>, String>;
}
impl TokenizerEncoder for Tokenizer {
fn encode_ids(&self, text: &str) -> Result<Vec<u32>, String> {
self.encode(text, false)
.map(|encoding| encoding.get_ids().to_vec())
.map_err(|error| error.to_string())
}
}
impl<T: TokenizerEncoder> TokenizerEncoder for Arc<T> {
fn encode_ids(&self, text: &str) -> Result<Vec<u32>, String> {
(**self).encode_ids(text)
}
}

View File

@ -0,0 +1,85 @@
use deepseek_recipe_core::conversation::ReasoningEffort;
use super::EncodingV4;
use crate::TokenizerEncoder;
const REASONING_EFFORT_HIGH: &str = r"Reasoning Effort: Absolute maximum with no shortcuts permitted.
You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.
Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.
";
const REASONING_EFFORT_MAX: &str = r"Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.
You MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.
Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.
";
/// Prompt rendering and token encoding for DeepSeek V4.
pub struct DeepseekV4Encoding {
tokenizer: Option<Box<dyn TokenizerEncoder>>,
}
impl DeepseekV4Encoding {
/// Create an encoding without an attached tokenizer.
///
/// Token encoding requires a tokenizer, attached with `with_tokenizer`.
pub fn new() -> Self {
Self { tokenizer: None }
}
/// Attach the tokenizer used by token encoding.
pub fn with_tokenizer(mut self, tokenizer: impl TokenizerEncoder + 'static) -> Self {
self.tokenizer = Some(Box::new(tokenizer));
self
}
}
impl Default for DeepseekV4Encoding {
fn default() -> Self {
Self::new()
}
}
impl EncodingV4 for DeepseekV4Encoding {
fn tokenizer(&self) -> Option<&dyn TokenizerEncoder> {
self.tokenizer.as_deref()
}
fn supports_mid_conversation_system(&self) -> bool {
false
}
fn system_token(&self) -> &'static str {
""
}
fn tool_calls_block_name(&self) -> &'static str {
"tool_calls"
}
fn tool_call_tag_name(&self) -> &'static str {
"invoke"
}
fn tool_parameter_tag_name(&self) -> &'static str {
"parameter"
}
fn render_reasoning_effort(
&self,
index: usize,
thinking_mode: bool,
effort: Option<ReasoningEffort>,
) -> String {
if index != 0 || !thinking_mode {
return String::new();
}
match effort {
Some(ReasoningEffort::Low) => String::new(),
None | Some(ReasoningEffort::High) | Some(ReasoningEffort::Xhigh) => {
REASONING_EFFORT_HIGH.to_string()
}
Some(ReasoningEffort::Max) => REASONING_EFFORT_MAX.to_string(),
}
}
}

View File

@ -0,0 +1,79 @@
use deepseek_recipe_core::conversation::ReasoningEffort;
use super::{EncodingV4, SYSTEM_SP_TOKEN};
use crate::TokenizerEncoder;
fn reasoning_effort_template(effort: Option<ReasoningEffort>) -> String {
let effort_score = match effort {
Some(ReasoningEffort::Low) => 50,
None | Some(ReasoningEffort::High) | Some(ReasoningEffort::Xhigh) => 75,
Some(ReasoningEffort::Max) => 100,
};
format!(
"Reasoning Effort: {effort_score} (range 1-100, the higher the value, the more thorough the reasoning)\n\n"
)
}
/// Prompt rendering and token encoding for DeepSeek V4.1.
pub struct DeepseekV41Encoding {
tokenizer: Option<Box<dyn TokenizerEncoder>>,
}
impl DeepseekV41Encoding {
/// Create an encoding without an attached tokenizer.
///
/// Token encoding requires a tokenizer, attached with `with_tokenizer`.
pub fn new() -> Self {
Self { tokenizer: None }
}
/// Attach the tokenizer used by token encoding.
pub fn with_tokenizer(mut self, tokenizer: impl TokenizerEncoder + 'static) -> Self {
self.tokenizer = Some(Box::new(tokenizer));
self
}
}
impl Default for DeepseekV41Encoding {
fn default() -> Self {
Self::new()
}
}
impl EncodingV4 for DeepseekV41Encoding {
fn tokenizer(&self) -> Option<&dyn TokenizerEncoder> {
self.tokenizer.as_deref()
}
fn supports_mid_conversation_system(&self) -> bool {
true
}
fn system_token(&self) -> &'static str {
SYSTEM_SP_TOKEN
}
fn tool_calls_block_name(&self) -> &'static str {
" calls"
}
fn tool_call_tag_name(&self) -> &'static str {
" invoke"
}
fn tool_parameter_tag_name(&self) -> &'static str {
" parameter"
}
fn render_reasoning_effort(
&self,
index: usize,
thinking_mode: bool,
effort: Option<ReasoningEffort>,
) -> String {
if index == 0 && thinking_mode {
return reasoning_effort_template(effort);
}
String::new()
}
}

View File

@ -0,0 +1,435 @@
use deepseek_recipe_core::conversation::{Conversation, ReasoningEffort, ResponseFormat};
use deepseek_recipe_core::messages::{InputMessage, ToolCall};
use deepseek_recipe_core::tools::{ToolChoice, ToolDefinition};
use deepseek_recipe_core::util::json_formatter::stringify_python_style;
use crate::EncodingError;
use crate::PromptEncoding;
use crate::RenderedPrompt;
use crate::TokenizerEncoder;
pub mod dsv4;
pub mod dsv41;
/// Marks the start of the prompt.
pub const BOS_TOKEN: &str = "<|begin▁of▁sentence|>";
/// Starts the reasoning content of an assistant turn.
pub const THINKING_START_TOKEN: &str = "<think>";
/// Ends the reasoning content of an assistant turn.
pub const THINKING_END_TOKEN: &str = "</think>";
/// Starts a system message in a V4.1 prompt.
pub const SYSTEM_SP_TOKEN: &str = "<|System|>";
/// Starts a user message.
pub const USER_SP_TOKEN: &str = "<|User|>";
/// Starts an assistant message.
pub const ASSISTANT_SP_TOKEN: &str = "<|Assistant|>";
/// Starts the latest reminder message.
pub const LATEST_REMINDER_SP_TOKEN: &str = "<|latest_reminder|>";
/// Terminates a message.
pub const EOS_TOKEN: &str = "<|end▁of▁sentence|>";
/// Tag name prefix of the markup that structures tool calls. The angle brackets
/// come from the surrounding template, as in `<|DSML|tool_calls>` for V4 and
/// `<|DSML| calls>` for V4.1.
pub const DSML_SP_TOKEN: &str = "|DSML|";
fn parameter_template(
dsml_token: &str,
tool_parameter_tag_name: &str,
key: &str,
is_str: &str,
value: &str,
) -> String {
format!(
"<{dsml_token}{tool_parameter_tag_name} name=\"{key}\" string=\"{is_str}\">{value}</{dsml_token}{tool_parameter_tag_name}>"
)
}
fn render_tool_arguments(tool_call: &ToolCall, tool_parameter_tag_name: &str) -> String {
let arguments =
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&tool_call.arguments)
.unwrap_or_else(|err| {
tracing::warn!(?err, "invalid tool call arguments");
serde_json::Map::from_iter([(
"arguments".to_owned(),
tool_call.arguments.clone().into(),
)])
});
arguments
.iter()
.map(|(key, value)| {
let (is_str, kv_str) = match value.as_str() {
Some(s) => ("true", s.to_owned()),
None => ("false", stringify_python_style(value)),
};
parameter_template(DSML_SP_TOKEN, tool_parameter_tag_name, key, is_str, &kv_str)
})
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) trait EncodingV4 {
fn tokenizer(&self) -> Option<&dyn TokenizerEncoder>;
fn supports_mid_conversation_system(&self) -> bool;
fn system_token(&self) -> &'static str;
fn tool_calls_block_name(&self) -> &'static str;
fn tool_call_tag_name(&self) -> &'static str;
fn tool_parameter_tag_name(&self) -> &'static str;
fn render_reasoning_effort(
&self,
index: usize,
thinking_mode: bool,
effort: Option<ReasoningEffort>,
) -> String;
}
fn tool_call_template(encoding: &impl EncodingV4, name: &str, arguments: &str) -> String {
let tool_call_tag_name = encoding.tool_call_tag_name();
format!(
"<{DSML_SP_TOKEN}{tool_call_tag_name} name=\"{name}\">\n{arguments}\n</{DSML_SP_TOKEN}{tool_call_tag_name}>"
)
}
fn tool_calls_template(encoding: &impl EncodingV4, tool_calls: &str) -> String {
let tc_block_name = encoding.tool_calls_block_name();
format!("<{DSML_SP_TOKEN}{tc_block_name}>\n{tool_calls}\n</{DSML_SP_TOKEN}{tc_block_name}>")
}
fn render_tool_calls(encoding: &impl EncodingV4, tool_calls: &[ToolCall]) -> String {
tool_calls
.iter()
.map(|tool_call| {
tool_call_template(
encoding,
&tool_call.name,
&render_tool_arguments(tool_call, encoding.tool_parameter_tag_name()),
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn render_message(
encoding: &impl EncodingV4,
messages: &[InputMessage],
index: usize,
thinking_mode: bool,
reasoning_effort: Option<ReasoningEffort>,
) -> String {
let msg = &messages[index];
let prev = messages[..index].last();
let reasoning_effort_prompt =
encoding.render_reasoning_effort(index, thinking_mode, reasoning_effort);
let mut prompt = if index == 0
&& (!reasoning_effort_prompt.is_empty() || matches!(msg, InputMessage::System { .. }))
{
encoding.system_token().to_string()
} else {
String::new()
};
prompt += &reasoning_effort_prompt;
match msg {
InputMessage::System { content } => {
if index > 0 && encoding.supports_mid_conversation_system() {
prompt += encoding.system_token();
}
prompt += content;
}
InputMessage::User { content, .. } => {
if matches!(
prev,
Some(InputMessage::User { .. } | InputMessage::Tool { .. })
) {
prompt += "\n\n";
} else {
prompt += USER_SP_TOKEN;
}
prompt += content;
}
InputMessage::LatestReminder { content } => {
prompt += LATEST_REMINDER_SP_TOKEN;
prompt += content;
}
InputMessage::Tool { content, .. } => {
if matches!(
prev,
Some(InputMessage::User { .. } | InputMessage::Tool { .. })
) {
prompt += "\n\n";
} else {
prompt += USER_SP_TOKEN;
}
prompt += &format!("<tool_result>{content}</tool_result>");
}
InputMessage::Assistant {
content,
reasoning_content,
tool_calls,
} => {
let tool_calls_content = match tool_calls {
Some(tool_calls) if !tool_calls.is_empty() => {
format!(
"\n\n{}",
tool_calls_template(encoding, &render_tool_calls(encoding, tool_calls))
)
}
_ => String::new(),
};
let mut thinking_part = String::new();
if thinking_mode && index > 0 {
if let Some(reasoning_content) = reasoning_content {
thinking_part += reasoning_content;
}
thinking_part += THINKING_END_TOKEN;
}
prompt += ASSISTANT_SP_TOKEN;
prompt += if !thinking_part.is_empty() {
THINKING_START_TOKEN
} else {
THINKING_END_TOKEN
};
prompt += &thinking_part;
prompt += content;
prompt += &tool_calls_content;
prompt += EOS_TOKEN;
}
}
prompt
}
impl<T: EncodingV4> PromptEncoding for T {
fn encode(&self, conversation: &Conversation) -> Result<Vec<u32>, EncodingError> {
let tokenizer = self.tokenizer().ok_or(EncodingError::MissingTokenizer)?;
let rendered = self.render_conversation(conversation);
tokenizer
.encode_ids(&rendered.prompt)
.map_err(EncodingError::Encode)
}
fn render_conversation(&self, conversation: &Conversation) -> RenderedPrompt {
let mut messages = normalize_messages(self, &conversation.messages);
let has_tools =
conversation.tool_choice != ToolChoice::None && !conversation.tools.is_empty();
let format_schema = match &conversation.response_format {
ResponseFormat::Text => None,
ResponseFormat::JsonObject => Some(stringify_python_style(&serde_json::json!({
"type": "json_object"
}))),
};
if has_tools || format_schema.is_some() {
if !matches!(messages.first(), Some(InputMessage::System { .. })) {
messages.insert(
0,
InputMessage::System {
content: String::new(),
},
);
}
if let Some(InputMessage::System { content }) = messages.first_mut() {
if has_tools {
content.push_str("\n\n");
content.push_str(&render_tool_prompt(self, &conversation.tools));
}
if let Some(schema) = format_schema {
content.push_str("\n\n## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n");
content.push_str(&schema);
}
}
}
let mut prompt = BOS_TOKEN.to_string();
for index in 0..messages.len() {
prompt += &render_message(
self,
&messages,
index,
conversation.thinking_mode,
conversation.reasoning_effort,
);
}
prompt.push_str(ASSISTANT_SP_TOKEN);
prompt.push_str(if conversation.thinking_mode {
THINKING_START_TOKEN
} else {
THINKING_END_TOKEN
});
if conversation.tool_choice == ToolChoice::Required && !conversation.tools.is_empty() {
prompt.push_str(&format!(
"\n\n<{DSML_SP_TOKEN}{}>\n",
self.tool_calls_block_name()
));
}
let image_sources = messages
.iter()
.filter_map(|message| match message {
InputMessage::User { image_sources, .. }
| InputMessage::Tool { image_sources, .. } => Some(image_sources.as_slice()),
_ => None,
})
.flatten()
.cloned()
.collect();
RenderedPrompt {
prompt,
image_sources,
}
}
}
fn render_tool_prompt(encoding: &impl EncodingV4, tools: &[ToolDefinition]) -> String {
let tool_schemas = tools
.iter()
.map(|tool| {
stringify_python_style(&serde_json::json!({
"name": tool.name,
"description": tool.description.as_deref().unwrap_or_default(),
"parameters": tool.parameters,
}))
})
.collect::<Vec<_>>()
.join("\n");
let dsml_token = DSML_SP_TOKEN;
let tc_block_name = encoding.tool_calls_block_name();
let tool_call_tag_name = encoding.tool_call_tag_name();
let tool_parameter_tag_name = encoding.tool_parameter_tag_name();
let thinking_start_token = THINKING_START_TOKEN;
let thinking_end_token = THINKING_END_TOKEN;
format!(
r#"## Tools
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}{tc_block_name}>" block like the following:
<{dsml_token}{tc_block_name}>
<{dsml_token}{tool_call_tag_name} name="$TOOL_NAME">
<{dsml_token}{tool_parameter_tag_name} name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}{tool_parameter_tag_name}>
...
</{dsml_token}{tool_call_tag_name}>
<{dsml_token}{tool_call_tag_name} name="$TOOL_NAME2">
...
</{dsml_token}{tool_call_tag_name}>
</{dsml_token}{tc_block_name}>
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
Otherwise, output directly after {thinking_end_token} with tool calls or final response.
### Available Tool Schemas
{tool_schemas}
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
"#
)
}
fn normalize_messages(encoding: &impl EncodingV4, messages: &[InputMessage]) -> Vec<InputMessage> {
let mut normalized = Vec::new();
let mut has_non_system = false;
for message in messages.iter().cloned() {
match message {
InputMessage::System { content } if !encoding.supports_mid_conversation_system() => {
if !has_non_system {
if let Some(InputMessage::System { content: head }) = normalized.last_mut() {
if !head.is_empty() && !content.is_empty() {
head.push_str("\n\n");
}
head.push_str(&content);
} else {
normalized.push(InputMessage::System { content });
}
} else if !content.is_empty() {
normalized.push(InputMessage::User {
content,
image_sources: Vec::new(),
});
}
}
InputMessage::User {
content,
image_sources,
} => {
has_non_system = true;
if let Some(InputMessage::User {
content: previous,
image_sources: previous_image_sources,
}) = normalized.last_mut()
{
previous.push_str("\n\n");
previous.push_str(&content);
previous_image_sources.extend(image_sources);
} else {
normalized.push(InputMessage::User {
content,
image_sources,
});
}
}
message => {
has_non_system |= !matches!(message, InputMessage::System { .. });
normalized.push(message);
}
}
}
sort_tool_results_by_call_order(&mut normalized);
normalized
}
fn sort_tool_results_by_call_order(messages: &mut [InputMessage]) {
let mut order: Vec<String> = Vec::new();
let mut idx = 0;
while idx < messages.len() {
match &messages[idx] {
InputMessage::Assistant {
tool_calls: Some(tool_calls),
..
} if !tool_calls.is_empty() => {
order = tool_calls.iter().map(|tc| tc.id.clone()).collect();
idx += 1;
}
InputMessage::User { .. } | InputMessage::Tool { .. } => {
let start = idx;
while idx < messages.len()
&& matches!(
messages[idx],
InputMessage::User { .. } | InputMessage::Tool { .. }
)
{
idx += 1;
}
let tool_idxs: Vec<usize> = (start..idx)
.filter(|&i| matches!(messages[i], InputMessage::Tool { .. }))
.collect();
if tool_idxs.len() > 1 && !order.is_empty() {
let mut tools: Vec<InputMessage> = tool_idxs
.iter()
.map(|&i| {
std::mem::replace(
&mut messages[i],
InputMessage::LatestReminder {
content: String::new(),
},
)
})
.collect();
tools.sort_by_key(|m| match m {
InputMessage::Tool { tool_call_id, .. } => {
order.iter().position(|id| id == tool_call_id).unwrap_or(0)
}
_ => 0,
});
for (&i, tool) in tool_idxs.iter().zip(tools) {
messages[i] = tool;
}
}
}
_ => idx += 1,
}
}
}

View File

@ -0,0 +1,30 @@
[package]
name = "deepseek-recipe-image"
version = "0.1.0"
edition.workspace = true
description = "Image fetching and preprocessing for DeepSeek V4.1 model input."
readme.workspace = true
documentation = "https://docs.rs/deepseek-recipe-image"
license.workspace = true
repository.workspace = true
[dependencies]
data-url = { workspace = true }
deepseek-recipe-core = { workspace = true }
futures = { workspace = true }
image = { workspace = true, optional = true }
infer = { workspace = true, optional = true }
opencv = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
[features]
default = ["opencv-preprocess", "reqwest-fetch"]
# Supply `OpenCvImagePreprocessor` for DeepSeek V4.1 preprocessing. Disable this
# feature to build without OpenCV when the caller supplies a preprocessor.
opencv-preprocess = ["dep:image", "dep:infer", "dep:opencv"]
# Supply `ReqwestImageFetcher` for downloading images from external URLs.
reqwest-fetch = ["dep:reqwest"]

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,112 @@
//! Failures while resolving or preprocessing an image.
use deepseek_recipe_core::multimodal::CalcResizeError;
/// Failure to fetch or preprocess one image.
#[derive(Debug, thiserror::Error)]
pub enum ImageError {
/// The image source needs a capability this implementation does not provide.
#[error("unsupported image source: {0}")]
Unsupported(&'static str),
/// The external image URL is too long.
#[error("external link length {len} too long, max link length {max}")]
UrlTooLong {
/// Length of the rejected URL.
len: usize,
/// Maximum accepted length.
max: usize,
},
/// The external image reference lacks the required `http` prefix.
#[error("invalid url: {url:?}")]
InvalidUrl {
/// Rejected URL.
url: String,
},
/// The request carries more images than the limit allows.
#[error("too many images: max {max} images per request, got {count}")]
TooManyImages {
/// Maximum number of images per request.
max: usize,
/// Number of images in the request.
count: usize,
},
/// One image is larger than the limit allows.
#[error("image size {size} bytes exceeds the limit of {max} bytes")]
ImageTooLarge {
/// Size of the rejected image.
size: usize,
/// Maximum accepted size.
max: usize,
},
/// The images together are larger than the limit allows.
#[error("total image size {size} bytes exceeds the limit of {max} bytes")]
TotalSizeTooLarge {
/// Total size of all images.
size: usize,
/// Maximum accepted total size.
max: usize,
},
/// The data URL or its encoded body is malformed.
#[error("invalid data url: {0}")]
InvalidDataUrl(String),
/// The image bytes are empty.
#[error("input image data is empty")]
EmptyImage,
/// The image media type is unsupported.
#[error("unsupported image media type: {0}")]
UnsupportedMediaType(String),
/// The image dimensions exceed the limit.
#[error("image dimensions exceed the maximum allowed")]
ImageDimensionsTooLarge,
/// The image could not be decoded.
#[error("failed to decode image: {0}")]
Decode(String),
/// The image could not be resized.
#[error("failed to resize image: {0}")]
Resize(String),
/// The image could not be encoded.
#[error("failed to encode image: {0}")]
Encode(String),
/// The image encoder reported failure without an error message.
#[error("failed to encode image")]
EncodeFailed,
/// Fitting the image into the token budget failed.
#[error(transparent)]
TokenBudget(#[from] CalcResizeError),
/// The image could not be fetched.
#[error("failed to fetch image from {url}: {source}")]
Fetch {
/// Rejected URL.
url: String,
/// Underlying fetcher failure, including wrapped input or size errors.
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
/// The image server returned a non-success status.
#[error("failed to fetch image from {url}: unexpected status {status}")]
FetchStatus {
/// Rejected URL.
url: String,
/// HTTP status code of the response.
status: u16,
},
/// The HTTP client could not be constructed.
#[error("failed to create the HTTP client: {0}")]
Client(String),
}
impl ImageError {
/// Return whether another attempt of the same operation may succeed.
///
/// `Fetch` is retryable regardless of its source; `FetchStatus` is retryable
/// for status codes at least 500. Other variants are not retryable. The
/// resolver evaluates the fetcher's error before wrapping it in `Fetch`,
/// so a returned wrapper may have a different classification.
pub fn is_retryable(&self) -> bool {
match self {
Self::Fetch { .. } => true,
Self::FetchStatus { status, .. } => *status >= 500,
_ => false,
}
}
}

View File

@ -0,0 +1,125 @@
//! Image fetching over HTTP with the `reqwest` client.
use std::time::Duration;
use tokio_stream::StreamExt;
use crate::ImageFetcher;
use crate::error::ImageError;
use crate::limits::{ImageByteBudget, ImageLimits};
/// Downloads images from external URLs with `reqwest`.
///
/// Clients created by [`Self::new`] and [`Self::with_max_bytes`] follow at most
/// five redirects and use a ten-second connection timeout and a sixty-second
/// request timeout. [`Self::from_client`] preserves the supplied client's
/// settings. All constructors enforce the configured response body size limit.
/// A response body is read as a stream, and the bytes that are kept are reserved
/// from the budget of the resolve call, so a body that would exceed the total
/// limit of the request stops the download.
///
/// We strongly recommend that you implement SSRF protection, such as rejecting
/// private, loopback, and link-local addresses, when callers may supply image
/// URLs; this fetcher does not filter its targets, because we think it is beyond
/// the scope of the library.
pub struct ReqwestImageFetcher {
client: reqwest::Client,
max_bytes: usize,
}
impl ReqwestImageFetcher {
/// Create a fetcher with the default image size limit.
///
/// # Errors
///
/// Returns [`ImageError`] when the HTTP client cannot be constructed.
pub fn new() -> Result<Self, ImageError> {
Self::with_max_bytes(ImageLimits::default().max_image_bytes)
}
/// Create a fetcher with an explicit size limit in bytes.
///
/// # Errors
///
/// Returns [`ImageError`] when the HTTP client cannot be constructed.
pub fn with_max_bytes(max_bytes: usize) -> Result<Self, ImageError> {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(|err| ImageError::Client(err.to_string()))?;
Ok(Self { client, max_bytes })
}
/// Create a fetcher from an existing client.
pub fn from_client(client: reqwest::Client, max_bytes: usize) -> Self {
Self { client, max_bytes }
}
/// Return the encoded size limit this fetcher applies to one image.
///
/// The resolver can apply a smaller limit through the
/// [`ImageByteBudget`] of the resolve call.
pub const fn max_bytes(&self) -> usize {
self.max_bytes
}
}
impl ImageFetcher for ReqwestImageFetcher {
async fn fetch(&self, url: &str, budget: &ImageByteBudget) -> Result<Vec<u8>, ImageError> {
let response = self
.client
.get(url)
.send()
.await
.map_err(|err| ImageError::Fetch {
url: url.to_string(),
source: Box::new(err),
})?;
if !response.status().is_success() {
return Err(ImageError::FetchStatus {
url: url.to_string(),
status: response.status().as_u16(),
});
}
// The limit of the fetcher and the limit of the resolve call both apply.
let max_bytes = self.max_bytes.min(budget.max_image_bytes());
if let Some(length) = response.content_length()
&& length > max_bytes as u64
{
return Err(ImageError::ImageTooLarge {
size: length as usize,
max: max_bytes,
});
}
let mut body = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(chunk) => chunk,
Err(err) => {
// A response that fails while its body is read is the
// retryable failure that can occur after bytes were
// reserved, so the retry reserves them once.
budget.release(body.len());
return Err(ImageError::Fetch {
url: url.to_string(),
source: Box::new(err),
});
}
};
let image_bytes = body.len() + chunk.len();
if image_bytes > max_bytes {
return Err(ImageError::ImageTooLarge {
size: image_bytes,
max: max_bytes,
});
}
budget.reserve(chunk.len())?;
body.extend_from_slice(&chunk);
}
Ok(body)
}
}

View File

@ -0,0 +1,94 @@
//! Image fetching and preprocessing for DeepSeek V4.1 model input.
//!
//! [`ImageResolver`] turns the [`ImageSource`](deepseek_recipe_core::multimodal::ImageSource)
//! values produced by protocol conversion into
//! [`MultiModalData`](deepseek_recipe_core::multimodal::MultiModalData). Fetching
//! external URLs and preprocessing are supplied by [`ImageFetcher`] and
//! [`ImagePreprocessor`] implementations.
//!
//! The default `opencv-preprocess` feature provides `OpenCvImagePreprocessor`,
//! and the default `reqwest-fetch` feature provides `ReqwestImageFetcher`. A
//! caller that supplies its own preprocessor disables default features and
//! builds without OpenCV.
use deepseek_recipe_core::multimodal::ImageInfo;
mod error;
#[cfg(feature = "reqwest-fetch")]
mod fetcher;
mod limits;
#[cfg(feature = "opencv-preprocess")]
mod opencv;
mod resolver;
mod retry;
pub use error::ImageError;
#[cfg(feature = "reqwest-fetch")]
pub use fetcher::ReqwestImageFetcher;
pub use limits::{ImageByteBudget, ImageLimits, ImageQuota, PreprocessOptions};
#[cfg(feature = "opencv-preprocess")]
pub use opencv::OpenCvImagePreprocessor;
pub use resolver::ImageResolver;
pub use retry::RetryPolicy;
use std::future::Future;
/// Fetches the bytes of an image identified by an external URL.
///
/// The default implementation rejects every URL. Supply an implementation when
/// requests may reference external images.
pub trait ImageFetcher: Send + Sync {
/// Fetch the bytes of `url`.
///
/// The resolver checks a case-insensitive `http` prefix before calling this
/// method. The fetcher must validate the complete URL and allowed destinations.
///
/// The fetches of one resolve call run concurrently, so an implementation
/// keeps their total size within the limit by reserving every chunk it keeps
/// from `budget` before it keeps the chunk, and it rejects a body larger than
/// [`ImageByteBudget::max_image_bytes`]. An implementation releases the bytes
/// it has reserved before it returns a retryable error, so the retry of that
/// attempt reserves them once. The resolver checks the size of the returned
/// bytes against [`ImageLimits`] as well.
///
/// # Errors
///
/// Returns [`ImageError`] when the URL cannot be fetched or the image
/// exceeds a limit of `budget`.
fn fetch(
&self,
url: &str,
budget: &ImageByteBudget,
) -> impl Future<Output = Result<Vec<u8>, ImageError>> + Send {
let _ = (url, budget);
async {
Err(ImageError::Unsupported(
"image fetching requires an ImageFetcher implementation",
))
}
}
}
/// Decodes, resizes, and encodes one image for the inference backend.
///
/// The default implementation rejects every image.
pub trait ImagePreprocessor: Send + Sync {
/// Preprocess one encoded image.
///
/// # Errors
///
/// Returns [`ImageError`] when the image is unsupported, exceeds a limit, or
/// cannot be preprocessed.
fn preprocess(
&self,
data: Vec<u8>,
options: PreprocessOptions,
) -> impl Future<Output = Result<ImageInfo, ImageError>> + Send {
let _ = (data, options);
async {
Err(ImageError::Unsupported(
"image preprocessing requires an ImagePreprocessor implementation",
))
}
}
}

View File

@ -0,0 +1,246 @@
//! Limits applied while resolving image sources.
use std::sync::atomic::{AtomicUsize, Ordering};
use deepseek_recipe_core::multimodal::{ImageDetail, MAX_IMAGE_URL_LEN};
use crate::error::ImageError;
/// Limits applied to the images of one request.
#[derive(Debug, Clone, Copy)]
pub struct ImageLimits {
/// Maximum number of images in one request.
pub max_images: usize,
/// Maximum UTF-8 byte length of an external image URL.
pub max_url_len: usize,
/// Maximum encoded size of one image.
pub max_image_bytes: usize,
/// Maximum total encoded size of the images of one request.
pub max_total_bytes: usize,
/// Maximum number of sources processed concurrently by one resolve call.
/// A value of zero is treated as one.
pub max_concurrent_sources: usize,
/// Maximum width or height in pixels when a request has few images.
pub max_dimension_px: u32,
/// Maximum width or height in pixels when a request has many images.
pub max_dimension_on_many_images_px: u32,
/// Image count at which [`max_dimension_on_many_images_px`](Self::max_dimension_on_many_images_px)
/// applies.
pub many_images_threshold: usize,
/// Maximum long side before token-budget fitting at low detail.
/// The token specification may subsequently upscale the image.
pub low_detail_max_dimension_px: u32,
}
impl Default for ImageLimits {
fn default() -> Self {
Self {
max_images: 600,
max_url_len: MAX_IMAGE_URL_LEN,
max_image_bytes: 32 * 1024 * 1024,
max_total_bytes: 64 * 1024 * 1024,
max_concurrent_sources: 8,
max_dimension_px: 8192,
max_dimension_on_many_images_px: 4096,
many_images_threshold: 15,
low_detail_max_dimension_px: 512,
}
}
}
impl ImageLimits {
/// Return the maximum accepted width or height for a request carrying
/// `image_count` images.
pub const fn max_dimension(&self, image_count: usize) -> u32 {
if image_count < self.many_images_threshold {
self.max_dimension_px
} else {
self.max_dimension_on_many_images_px
}
}
/// Return the preprocessing options for a request carrying `image_count`
/// images at the given detail level.
pub const fn preprocess_options(
&self,
detail: ImageDetail,
image_count: usize,
) -> PreprocessOptions {
PreprocessOptions {
detail,
max_dimension_px: self.max_dimension(image_count),
low_detail_max_dimension_px: self.low_detail_max_dimension_px,
}
}
}
/// Usage accumulated over the resolve calls of one request.
///
/// The image count and total source byte limits are enforced across
/// [`resolve`](crate::ImageResolver::resolve) calls. Create one quota per
/// request and pass the same quota to every call. A call records its sources
/// after preprocessing every one of them, so a failed call adds nothing.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ImageQuota {
images: usize,
bytes: usize,
}
impl ImageQuota {
/// Return an empty quota.
pub const fn new() -> Self {
Self {
images: 0,
bytes: 0,
}
}
/// Return the number of sources recorded by the completed calls.
pub const fn image_count(&self) -> usize {
self.images
}
/// Return the encoded source bytes recorded by the completed calls.
pub const fn byte_size(&self) -> usize {
self.bytes
}
/// Record `images` preprocessed sources of `bytes` encoded size.
pub(crate) fn add(&mut self, images: usize, bytes: usize) {
self.images += images;
self.bytes += bytes;
}
}
/// The encoded bytes still available to the image sources of one resolve call.
///
/// [`ImageResolver::resolve`](crate::ImageResolver::resolve) creates one budget
/// for each call and passes it to every fetch of that call. A fetch reserves the
/// bytes it keeps as its body arrives, so the fetches running concurrently
/// cannot together exceed [`ImageLimits::max_total_bytes`]. A budget is not
/// shared between calls: the images of earlier calls are accounted for through
/// the [`ImageQuota`] the call was given.
#[derive(Debug)]
pub struct ImageByteBudget {
/// Bytes still available to the sources of one resolve call.
remaining: AtomicUsize,
/// Maximum encoded size of one image.
max_image_bytes: usize,
/// Maximum total encoded size of the images of one request.
max_total_bytes: usize,
}
impl ImageByteBudget {
/// Create the budget of one request that has already accumulated
/// `used_bytes`.
///
/// `max_image_bytes` limits one image, and `max_total_bytes` limits every
/// image of the request.
pub const fn new(max_image_bytes: usize, max_total_bytes: usize, used_bytes: usize) -> Self {
Self {
remaining: AtomicUsize::new(max_total_bytes.saturating_sub(used_bytes)),
max_image_bytes,
max_total_bytes,
}
}
/// Return the maximum encoded size of one image.
pub const fn max_image_bytes(&self) -> usize {
self.max_image_bytes
}
/// Reserve `bytes` of one image.
///
/// # Errors
///
/// Returns [`ImageError::TotalSizeTooLarge`] when the request would exceed
/// its total encoded size. A failed reservation reserves nothing.
pub fn reserve(&self, bytes: usize) -> Result<(), ImageError> {
let mut available = self.remaining.load(Ordering::Relaxed);
loop {
if available < bytes {
let used = self.max_total_bytes.saturating_sub(available);
return Err(ImageError::TotalSizeTooLarge {
size: used.saturating_add(bytes),
max: self.max_total_bytes,
});
}
match self.remaining.compare_exchange_weak(
available,
available - bytes,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return Ok(()),
Err(current) => available = current,
}
}
}
/// Return `bytes` reserved by an attempt that failed.
pub fn release(&self, bytes: usize) {
self.remaining.fetch_add(bytes, Ordering::Relaxed);
}
}
/// Options for one image preprocessing call.
#[derive(Debug, Clone, Copy)]
pub struct PreprocessOptions {
/// Requested detail level.
pub detail: ImageDetail,
/// Maximum accepted source width or height in pixels, before resizing.
pub max_dimension_px: u32,
/// Maximum long side before token-budget fitting at low detail.
/// The token specification may subsequently upscale the image.
pub low_detail_max_dimension_px: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reserving_bytes_reduces_the_available_bytes() {
let budget = ImageByteBudget::new(8, 10, 0);
assert_eq!(budget.max_image_bytes(), 8);
budget.reserve(6).expect("the total limit is available");
let error = budget
.reserve(5)
.expect_err("the reservation exceeds the total limit");
assert!(
matches!(error, ImageError::TotalSizeTooLarge { size: 11, max: 10 }),
"{error}"
);
budget
.reserve(4)
.expect("the remaining bytes are available");
}
#[test]
fn a_budget_accounts_for_the_bytes_of_earlier_calls() {
let budget = ImageByteBudget::new(8, 10, 6);
let error = budget
.reserve(5)
.expect_err("earlier calls used six bytes of the total limit");
assert!(
matches!(error, ImageError::TotalSizeTooLarge { size: 11, max: 10 }),
"{error}"
);
budget
.reserve(4)
.expect("the remaining bytes are available");
}
#[test]
fn a_release_returns_the_bytes_of_a_failed_attempt() {
let budget = ImageByteBudget::new(8, 10, 0);
budget.reserve(10).expect("the total limit is available");
budget.release(10);
budget
.reserve(10)
.expect("the released bytes are available again");
}
}

View File

@ -0,0 +1,456 @@
//! The default image preprocessor for DeepSeek V4.1.
use std::io::Cursor;
use deepseek_recipe_core::multimodal::{ImageInfo, ImageMediaType, ImageTokenSpec};
use image::{ImageDecoder, Limits};
use opencv::core::{Mat, MatTraitConst, Scalar, Vector, VectorToVec};
use opencv::imgcodecs;
use opencv::imgproc;
use opencv::prelude::*;
use crate::ImagePreprocessor;
use crate::error::ImageError;
use crate::limits::PreprocessOptions;
/// Padding color (BGR) matching the model image transform mean of 0.5.
const PAD_COLOR: Scalar = Scalar::new(127.0, 127.0, 127.0, 0.0);
/// Background (BGR) applied where an image has transparency: `#FDFDFD`.
const INFERENCE_BACKGROUND: (u8, u8, u8) = (0xfd, 0xfd, 0xfd);
/// Maximum size of one dimension of a WebP image.
const WEBP_MAX_DIMENSION: i32 = 16383;
/// Largest allocation allowed while reading only an image header.
const MAX_HEADER_ALLOC: u64 = 16 * 1024 * 1024;
/// WebP quality of a preprocessed image.
const WEBP_QUALITY: i32 = 90;
/// Preprocesses images with OpenCV.
///
/// Preprocessing decodes the image, applies the low-detail limit, fits the image
/// into the V4.1 token budget, and encodes it as WebP at quality 90.
#[derive(Debug, Clone, Copy, Default)]
pub struct OpenCvImagePreprocessor;
impl ImagePreprocessor for OpenCvImagePreprocessor {
async fn preprocess(
&self,
data: Vec<u8>,
options: PreprocessOptions,
) -> Result<ImageInfo, ImageError> {
tokio::task::spawn_blocking(move || preprocess(&data, options))
.await
.map_err(|err| ImageError::Decode(err.to_string()))?
}
}
/// Preprocess one encoded image.
fn preprocess(data: &[u8], options: PreprocessOptions) -> Result<ImageInfo, ImageError> {
if data.is_empty() {
return Err(ImageError::EmptyImage);
}
let media_type = detect_media_type(data)?;
check_dimensions(data, options.max_dimension_px)?;
let mut mat = decode_image_mat(data, media_type)?;
if options.detail.is_low() {
mat = limit_image_mat(mat, options.low_detail_max_dimension_px)?;
}
preprocess_mat(mat, options.max_dimension_px)
}
/// Return the media type of the image bytes, read from the file header.
fn detect_media_type(data: &[u8]) -> Result<ImageMediaType, ImageError> {
let kind = infer::get(data)
.ok_or_else(|| ImageError::UnsupportedMediaType("unknown media type".to_string()))?;
let mime = kind.mime_type();
ImageMediaType::from_mime(mime)
.ok_or_else(|| ImageError::UnsupportedMediaType(mime.to_string()))
}
/// Reject an image whose declared dimensions exceed `max_dimension`, reading the
/// file header only.
fn check_dimensions(data: &[u8], max_dimension: u32) -> Result<(), ImageError> {
let mut reader = image::ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|err| ImageError::Decode(err.to_string()))?;
let mut limits = Limits::default();
limits.max_image_width = Some(max_dimension);
limits.max_image_height = Some(max_dimension);
limits.max_alloc = Some(MAX_HEADER_ALLOC);
reader.limits(limits);
let decoder = match reader.into_decoder() {
Ok(decoder) => decoder,
Err(err) => return check_dimensions_without_limits(err, data, max_dimension),
};
let (width, height) = decoder.dimensions();
if width > max_dimension || height > max_dimension {
return Err(ImageError::ImageDimensionsTooLarge);
}
Ok(())
}
/// Handle a header read that failed under the configured limits.
///
/// Decoders that do not support limits report the limit as unsupported. Their
/// dimensions are read directly instead.
fn check_dimensions_without_limits(
err: image::ImageError,
data: &[u8],
max_dimension: u32,
) -> Result<(), ImageError> {
let image::ImageError::Limits(limit_error) = &err else {
return Err(ImageError::Decode(err.to_string()));
};
match limit_error.kind() {
image::error::LimitErrorKind::DimensionError => Err(ImageError::ImageDimensionsTooLarge),
image::error::LimitErrorKind::Unsupported { .. } => {
let reader = image::ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|err| ImageError::Decode(err.to_string()))?;
let decoder = reader
.into_decoder()
.map_err(|err| ImageError::Decode(err.to_string()))?;
let (width, height) = decoder.dimensions();
if width > max_dimension || height > max_dimension {
return Err(ImageError::ImageDimensionsTooLarge);
}
Ok(())
}
_ => Err(ImageError::Decode(err.to_string())),
}
}
/// Decode one image into an 8-bit BGR matrix.
///
/// GIF images are decoded with the `image` crate because OpenCV does not support
/// them. Images with transparency are blended onto the inference background.
fn decode_image_mat(data: &[u8], media_type: ImageMediaType) -> Result<Mat, ImageError> {
let mat = if media_type == ImageMediaType::Gif {
gif_to_mat(data)?
} else {
imgcodecs::imdecode(&Vector::from_slice(data), imgcodecs::IMREAD_UNCHANGED)
.map_err(|err| ImageError::Decode(err.to_string()))?
};
let size = mat
.size()
.map_err(|err| ImageError::Decode(err.to_string()))?;
if size.width == 0 || size.height == 0 {
return Err(ImageError::EmptyImage);
}
normalize_decoded_mat(mat)
}
/// Reduce a decoded matrix to 8-bit BGR.
fn normalize_decoded_mat(mat: Mat) -> Result<Mat, ImageError> {
let mat = if mat.depth() == opencv::core::CV_16U {
let mut mat8 = Mat::default();
mat.convert_to(&mut mat8, opencv::core::CV_8U, 1.0 / 257.0, 0.0)
.map_err(|err| ImageError::Decode(err.to_string()))?;
mat8
} else {
mat
};
match mat.channels() {
3 => Ok(mat),
4 => alpha_blend(&mat),
1 => {
let mut channels = Vector::<Mat>::new();
channels.push(mat.clone());
channels.push(mat.clone());
channels.push(mat);
let mut bgr = Mat::default();
opencv::core::merge(&channels, &mut bgr)
.map_err(|err| ImageError::Decode(err.to_string()))?;
Ok(bgr)
}
2 => {
let mut channels = Vector::<Mat>::new();
opencv::core::split(&mat, &mut channels)
.map_err(|err| ImageError::Decode(err.to_string()))?;
let gray = channels
.get(0)
.map_err(|err| ImageError::Decode(err.to_string()))?;
let alpha = channels
.get(1)
.map_err(|err| ImageError::Decode(err.to_string()))?;
let mut bgra_channels = Vector::<Mat>::new();
bgra_channels.push(gray.clone());
bgra_channels.push(gray.clone());
bgra_channels.push(gray);
bgra_channels.push(alpha);
let mut bgra = Mat::default();
opencv::core::merge(&bgra_channels, &mut bgra)
.map_err(|err| ImageError::Decode(err.to_string()))?;
alpha_blend(&bgra)
}
channels => Err(ImageError::Decode(format!(
"unsupported image channel count: {channels}"
))),
}
}
/// Blend a 4-channel BGRA matrix onto the inference background.
fn alpha_blend(bgra: &Mat) -> Result<Mat, ImageError> {
if !bgra.is_continuous() {
return Err(ImageError::Decode(
"image matrix is not continuous".to_string(),
));
}
let (rows, cols) = (bgra.rows(), bgra.cols());
// SAFETY: the allocation is owned by the returned matrix and is fully
// written by the loop below.
let mut blended = unsafe { Mat::new_rows_cols(rows, cols, opencv::core::CV_8UC3) }
.map_err(|err| ImageError::Decode(err.to_string()))?;
let source = bgra
.data_bytes()
.map_err(|err| ImageError::Decode(err.to_string()))?;
let target = blended
.data_bytes_mut()
.map_err(|err| ImageError::Decode(err.to_string()))?;
let (background_b, background_g, background_r) = INFERENCE_BACKGROUND;
let (background_b, background_g, background_r) = (
u32::from(background_b),
u32::from(background_g),
u32::from(background_r),
);
for (source_pixel, target_pixel) in source.chunks_exact(4).zip(target.chunks_exact_mut(3)) {
let alpha = u32::from(source_pixel[3]);
let inverse_alpha = 255 - alpha;
target_pixel[0] =
((u32::from(source_pixel[0]) * alpha + background_b * inverse_alpha + 127) / 255) as u8;
target_pixel[1] =
((u32::from(source_pixel[1]) * alpha + background_g * inverse_alpha + 127) / 255) as u8;
target_pixel[2] =
((u32::from(source_pixel[2]) * alpha + background_r * inverse_alpha + 127) / 255) as u8;
}
Ok(blended)
}
/// Scale an image down so that its long side is at most `max_dimension`.
fn limit_image_mat(mat: Mat, max_dimension: u32) -> Result<Mat, ImageError> {
let size = mat
.size()
.map_err(|err| ImageError::Resize(err.to_string()))?;
let long_side = size.width.max(size.height);
if long_side <= max_dimension as i32 {
return Ok(mat);
}
let ratio = max_dimension as f64 / long_side as f64;
let width = ((size.width as f64 * ratio).round() as i32).max(1);
let height = ((size.height as f64 * ratio).round() as i32).max(1);
direct_resize(&mat, width, height)
}
/// Fit an image into the V4.1 token budget and encode it as WebP.
fn preprocess_mat(mat: Mat, max_dimension_px: u32) -> Result<ImageInfo, ImageError> {
let size = mat
.size()
.map_err(|err| ImageError::Resize(err.to_string()))?;
// `check_dimensions` reads the size the header declares. The decoded matrix
// is the image the request carries, so it is checked against the limit too.
let max_dimension = max_dimension_px as i32;
if size.width > max_dimension || size.height > max_dimension {
return Err(ImageError::ImageDimensionsTooLarge);
}
let (best_width, best_height) =
fit_webp_target_size(size.width as usize, size.height as usize)?;
if best_width <= 0 || best_height <= 0 {
return Err(ImageError::Resize(format!(
"invalid target size: {best_width}x{best_height}"
)));
}
let resized = if size.width != best_width || size.height != best_height {
resize_to_best(&mat, size.width, size.height, best_width, best_height)
.or_else(|_| direct_resize(&mat, best_width, best_height))?
} else {
mat
};
let mut encoded = Vector::new();
let params = Vector::from_slice(&[imgcodecs::IMWRITE_WEBP_QUALITY, WEBP_QUALITY]);
// A false return value reports an encoding failure, leaving the output
// buffer without an encoded image.
let encode_succeeded = imgcodecs::imencode(".webp", &resized, &mut encoded, &params)
.map_err(|err| ImageError::Encode(err.to_string()))?;
if !encode_succeeded {
return Err(ImageError::EncodeFailed);
}
Ok(ImageInfo {
data: encoded.to_vec(),
width: best_width as u32,
height: best_height as u32,
})
}
/// Return the V4.1 target size of an image of `width`×`height` pixels that WebP
/// can encode.
///
/// [`ImageTokenSpec::v41`] fits the token budget without a dimension limit, and
/// an elongated image can require a side above [`WEBP_MAX_DIMENSION`]. A size
/// outside the limit is scaled into it and placed on the patch grid, and fitting
/// is repeated for that size, so the returned size is both a size the
/// specification leaves unchanged and a size WebP encodes.
fn fit_webp_target_size(width: usize, height: usize) -> Result<(i32, i32), ImageError> {
// The specification scales a size below its minimum area back up, so the
// limit is reached after a few rounds that each raise the short side.
const MAX_FIT_ROUNDS: usize = 6;
let spec = ImageTokenSpec::v41();
let limit = WEBP_MAX_DIMENSION as usize;
let mut source = (width, height);
for _ in 0..MAX_FIT_ROUNDS {
let fitted = spec.calc_resize(source.0, source.1)?;
if fitted.best_width <= limit && fitted.best_height <= limit {
return Ok((fitted.best_width as i32, fitted.best_height as i32));
}
source = scale_into_webp_limit(spec.patch_size(), fitted.best_width, fitted.best_height);
}
Err(ImageError::Resize(format!(
"no V4.1 target size within {WEBP_MAX_DIMENSION} pixels for an image of {width}x{height} pixels"
)))
}
/// Scale a size into the WebP dimension limit.
///
/// The long side is placed on the patch grid at or below the limit, and the
/// short side is placed on the patch grid at or above its scaled value. Raising
/// the short side keeps the scaled size at or above the minimum area of the
/// specification, which otherwise scales the long side past the limit again.
fn scale_into_webp_limit(patch_size: usize, width: usize, height: usize) -> (usize, usize) {
let limit = WEBP_MAX_DIMENSION as usize;
let long_side = width.max(height);
if long_side <= limit {
return (width, height);
}
let scaled_long = limit / patch_size * patch_size;
let scaled_short = (width.min(height) as f64 * scaled_long as f64 / long_side as f64).round();
let short_side = (scaled_short as usize).max(1).div_ceil(patch_size) * patch_size;
if width >= height {
(scaled_long, short_side)
} else {
(short_side, scaled_long)
}
}
/// Scale an image to fit `best_width`×`best_height` while preserving its aspect
/// ratio, and center it on a background of [`PAD_COLOR`].
fn resize_to_best(
mat: &Mat,
source_width: i32,
source_height: i32,
best_width: i32,
best_height: i32,
) -> Result<Mat, ImageError> {
let image_ratio = source_width as f64 / source_height as f64;
let target_ratio = best_width as f64 / best_height as f64;
let (width, height) = if image_ratio > target_ratio {
(
best_width,
round_half_even(source_height as f64 / source_width as f64 * best_width as f64).max(1),
)
} else if image_ratio < target_ratio {
(
round_half_even(source_width as f64 / source_height as f64 * best_height as f64).max(1),
best_height,
)
} else {
(best_width, best_height)
};
let mut scaled = Mat::default();
imgproc::resize(
mat,
&mut scaled,
opencv::core::Size::new(width, height),
0.0,
0.0,
imgproc::INTER_CUBIC,
)
.map_err(|err| ImageError::Resize(err.to_string()))?;
let left = round_half_even((best_width - width) as f64 * 0.5);
let top = round_half_even((best_height - height) as f64 * 0.5);
let right = best_width - width - left;
let bottom = best_height - height - top;
let mut padded = Mat::default();
opencv::core::copy_make_border(
&scaled,
&mut padded,
top,
bottom,
left,
right,
opencv::core::BORDER_CONSTANT,
PAD_COLOR,
)
.map_err(|err| ImageError::Resize(err.to_string()))?;
Ok(padded)
}
/// Scale an image directly to `width`×`height`.
fn direct_resize(mat: &Mat, width: i32, height: i32) -> Result<Mat, ImageError> {
let mut resized = Mat::default();
imgproc::resize(
mat,
&mut resized,
opencv::core::Size::new(width, height),
0.0,
0.0,
imgproc::INTER_CUBIC,
)
.map_err(|err| ImageError::Resize(err.to_string()))?;
Ok(resized)
}
/// Decode the first frame of a GIF into a 4-channel BGRA matrix.
fn gif_to_mat(data: &[u8]) -> Result<Mat, ImageError> {
let reader = image::ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|err| ImageError::Decode(err.to_string()))?;
let format = reader
.format()
.ok_or_else(|| ImageError::Decode("unknown image format".to_string()))?;
if format != image::ImageFormat::Gif {
return Err(ImageError::Decode(format!("expected GIF, got {format:?}")));
}
let frame = reader
.decode()
.map_err(|err| ImageError::Decode(err.to_string()))?;
let (width, height) = (frame.width(), frame.height());
if width == 0 || height == 0 {
return Err(ImageError::EmptyImage);
}
// OpenCV expects BGRA; the `image` crate decodes to RGBA.
let mut bgra = frame.to_rgba8().into_raw();
for pixel in bgra.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
// SAFETY: the allocation is owned by the returned matrix and is fully
// written from `bgra` immediately below.
let mut mat = unsafe { Mat::new_rows_cols(height as i32, width as i32, opencv::core::CV_8UC4) }
.map_err(|err| ImageError::Decode(err.to_string()))?;
mat.data_bytes_mut()
.map_err(|err| ImageError::Decode(err.to_string()))?
.copy_from_slice(&bgra);
Ok(mat)
}
/// Round to the nearest integer, with a value exactly between two integers
/// rounding to the even one.
fn round_half_even(value: f64) -> i32 {
let floor = value.floor();
let fraction = value - floor;
if fraction < 0.5 || (fraction == 0.5 && (floor as i64) % 2 == 0) {
floor as i32
} else {
(floor + 1.0) as i32
}
}

View File

@ -0,0 +1,449 @@
//! Resolve image sources into the images of one request.
use futures::StreamExt;
use futures::stream::{self, TryStreamExt};
use deepseek_recipe_core::multimodal::{ImageMediaType, ImageSource, MultiModalData, is_http_url};
use crate::error::ImageError;
use crate::limits::{ImageByteBudget, ImageLimits, ImageQuota};
use crate::retry::RetryPolicy;
use crate::{ImageFetcher, ImagePreprocessor};
/// Resolves image sources into preprocessed images.
///
/// Supports external URLs, data URLs, and encoded bytes. Applies request limits
/// and preserves source order. The caller supplies the fetcher and preprocessor.
/// At most [`ImageLimits::max_concurrent_sources`] sources are resolved and
/// preprocessed concurrently.
pub struct ImageResolver<F, P> {
fetcher: F,
preprocessor: P,
limits: ImageLimits,
retry: RetryPolicy,
}
impl<F, P> ImageResolver<F, P>
where
F: ImageFetcher,
P: ImagePreprocessor,
{
/// Combine an image fetcher with an image preprocessor.
pub fn new(fetcher: F, preprocessor: P) -> Self {
Self {
fetcher,
preprocessor,
limits: ImageLimits::default(),
retry: RetryPolicy::download(),
}
}
/// Replace the default limits.
pub fn with_limits(mut self, limits: ImageLimits) -> Self {
self.limits = limits;
self
}
/// Replace the default retry policy of image downloads.
pub fn with_retry(mut self, retry: RetryPolicy) -> Self {
self.retry = retry;
self
}
/// Resolve sources concurrently and preserve their order in the result.
///
/// Pass one [`ImageQuota`] per request and reuse it across calls, so the
/// count and total byte limits apply across calls. Dimension limits use the
/// accumulated count including this call; earlier images are not rechecked.
/// The call reserves the bytes it keeps from one [`ImageByteBudget`] it
/// shares with every fetch, so the sources of the call are limited together.
///
/// # Errors
///
/// Returns [`ImageError`] when a source cannot be fetched, decoded, or
/// preprocessed, or when the request exceeds a limit. A failed call leaves
/// `quota` unchanged: the sources of the call are recorded once every one of
/// them is preprocessed.
pub async fn resolve(
&self,
sources: &[ImageSource],
quota: &mut ImageQuota,
) -> Result<MultiModalData, ImageError> {
let request_image_count = quota.image_count() + sources.len();
if request_image_count > self.limits.max_images {
return Err(ImageError::TooManyImages {
max: self.limits.max_images,
count: request_image_count,
});
}
// The bytes of earlier calls of this request are recorded in `quota`
// rather than reserved again in this budget.
let budget = ImageByteBudget::new(
self.limits.max_image_bytes,
self.limits.max_total_bytes,
quota.byte_size(),
);
let concurrency = self.limits.max_concurrent_sources.max(1);
// The futures are created before they are polled, so the sources are
// resolved at most `concurrency` at a time.
let resolutions: Vec<_> = sources
.iter()
.map(|source| self.encoded_bytes(source, &budget))
.collect();
let encoded: Vec<Vec<u8>> = stream::iter(resolutions)
.buffered(concurrency)
.try_collect()
.await?;
// A fetch reserves the bytes it keeps while it reads a body. This check
// rejects an implementation that returns more than it reserved.
let mut request_bytes = quota.byte_size();
for data in &encoded {
if data.len() > self.limits.max_image_bytes {
return Err(ImageError::ImageTooLarge {
size: data.len(),
max: self.limits.max_image_bytes,
});
}
request_bytes += data.len();
if request_bytes > self.limits.max_total_bytes {
return Err(ImageError::TotalSizeTooLarge {
size: request_bytes,
max: self.limits.max_total_bytes,
});
}
}
let admitted_bytes = request_bytes - quota.byte_size();
let preprocesses: Vec<_> = encoded
.into_iter()
.zip(sources)
.map(|(data, source)| {
let options = self
.limits
.preprocess_options(source.detail(), request_image_count);
self.preprocessor.preprocess(data, options)
})
.collect();
let images = stream::iter(preprocesses)
.buffered(concurrency)
.try_collect()
.await?;
// The call records its sources only once all of them are preprocessed, so
// a failure or a cancellation leaves the quota unchanged.
quota.add(sources.len(), admitted_bytes);
Ok(MultiModalData { images })
}
/// Return the encoded bytes of one image source.
async fn encoded_bytes(
&self,
source: &ImageSource,
budget: &ImageByteBudget,
) -> Result<Vec<u8>, ImageError> {
let data = match source {
ImageSource::DataUrl { data_url, .. } => decode_data_url(data_url, budget)?,
ImageSource::Url { url, .. } => {
// Direct sources receive the same HTTP prefix check as
// protocol input. The fetcher validates the full URL.
if !is_http_url(url) {
return Err(ImageError::InvalidUrl { url: url.clone() });
}
if url.len() > self.limits.max_url_len {
return Err(ImageError::UrlTooLong {
len: url.len(),
max: self.limits.max_url_len,
});
}
self.retry
.execute(
|_| async { self.fetcher.fetch(url, budget).await },
ImageError::is_retryable,
)
.await
.map_err(|error| match error {
// The fetcher already reports the rejected URL.
error @ (ImageError::Fetch { .. } | ImageError::FetchStatus { .. }) => {
error
}
error => ImageError::Fetch {
url: url.clone(),
source: Box::new(error),
},
})?
}
ImageSource::Bytes { data, .. } => {
reserve_image_bytes(budget, data.len())?;
data.clone()
}
};
if data.is_empty() {
return Err(ImageError::EmptyImage);
}
Ok(data)
}
}
/// Reserve the bytes of one image that is already in memory.
fn reserve_image_bytes(budget: &ImageByteBudget, bytes: usize) -> Result<(), ImageError> {
let max = budget.max_image_bytes();
if bytes > max {
return Err(ImageError::ImageTooLarge { size: bytes, max });
}
budget.reserve(bytes)
}
/// Decode a base64 or percent-encoded data URL with a supported image media type.
fn decode_data_url(data_url: &str, budget: &ImageByteBudget) -> Result<Vec<u8>, ImageError> {
let parsed = data_url::DataUrl::process(data_url)
.map_err(|err| ImageError::InvalidDataUrl(err.to_string()))?;
let media_type = parsed.mime_type().to_string();
if ImageMediaType::from_mime(&media_type).is_none() {
return Err(ImageError::UnsupportedMediaType(media_type));
}
// A data URL carries the image in the request itself, so the decoded size is
// checked before the body is decoded and again once it is known.
let estimated = estimated_decoded_len(data_url);
let max_image_bytes = budget.max_image_bytes();
if estimated > max_image_bytes {
return Err(ImageError::ImageTooLarge {
size: estimated,
max: max_image_bytes,
});
}
let (data, _) = parsed
.decode_to_vec()
.map_err(|err| ImageError::InvalidDataUrl(err.to_string()))?;
reserve_image_bytes(budget, data.len())?;
Ok(data)
}
/// Return the decoded size a data URL body reports before it is decoded.
///
/// A base64 body occupies four characters for every three bytes it carries, so
/// the result can be up to two bytes smaller than the decoded body. A
/// percent-encoded byte occupies three characters.
fn estimated_decoded_len(data_url: &str) -> usize {
let Some((header, body)) = data_url.split_once(',') else {
return 0;
};
if header.ends_with(";base64") {
body.len() / 4 * 3
} else {
body.len().saturating_sub(2 * body.matches('%').count())
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use deepseek_recipe_core::multimodal::{ImageDetail, ImageInfo};
use super::*;
use crate::limits::PreprocessOptions;
/// A fetcher that returns a body of a fixed size and ignores the budget.
struct FixedFetcher {
body_len: usize,
}
impl ImageFetcher for FixedFetcher {
async fn fetch(
&self,
_url: &str,
_budget: &ImageByteBudget,
) -> Result<Vec<u8>, ImageError> {
Ok(vec![0; self.body_len])
}
}
/// A fetcher that records how many of its calls run at the same time.
#[derive(Clone, Default)]
struct CountingFetcher {
active: Arc<AtomicUsize>,
max_active: Arc<AtomicUsize>,
}
impl ImageFetcher for CountingFetcher {
async fn fetch(&self, _url: &str, budget: &ImageByteBudget) -> Result<Vec<u8>, ImageError> {
let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
self.max_active.fetch_max(active, Ordering::SeqCst);
tokio::task::yield_now().await;
self.active.fetch_sub(1, Ordering::SeqCst);
budget.reserve(1)?;
Ok(vec![0])
}
}
/// A preprocessor that rejects every image.
struct NoPreprocessor;
impl ImagePreprocessor for NoPreprocessor {}
/// A preprocessor that accepts every image without inspecting its bytes.
struct AcceptingPreprocessor;
impl ImagePreprocessor for AcceptingPreprocessor {
async fn preprocess(
&self,
data: Vec<u8>,
_options: PreprocessOptions,
) -> Result<ImageInfo, ImageError> {
Ok(ImageInfo {
data,
width: 1,
height: 1,
})
}
}
fn limits(max_image_bytes: usize, max_total_bytes: usize) -> ImageLimits {
ImageLimits {
max_image_bytes,
max_total_bytes,
..ImageLimits::default()
}
}
fn bytes_source(len: usize) -> ImageSource {
ImageSource::Bytes {
data: vec![0; len],
detail: ImageDetail::High,
}
}
fn url_source() -> ImageSource {
ImageSource::Url {
url: "https://example.com/image.webp".to_string(),
detail: ImageDetail::High,
}
}
#[tokio::test]
async fn bytes_over_the_image_limit_are_rejected() {
let resolver = ImageResolver::new(FixedFetcher { body_len: 0 }, NoPreprocessor)
.with_limits(limits(4, 64));
let error = resolver
.resolve(&[bytes_source(5)], &mut ImageQuota::new())
.await
.expect_err("the image exceeds the image limit");
assert!(
matches!(error, ImageError::ImageTooLarge { size: 5, max: 4 }),
"{error}"
);
}
#[tokio::test]
async fn bytes_over_the_total_limit_are_rejected() {
let resolver = ImageResolver::new(FixedFetcher { body_len: 0 }, NoPreprocessor)
.with_limits(limits(8, 10));
let error = resolver
.resolve(&[bytes_source(6), bytes_source(6)], &mut ImageQuota::new())
.await
.expect_err("the images exceed the total limit");
assert!(
matches!(error, ImageError::TotalSizeTooLarge { size: 12, max: 10 }),
"{error}"
);
}
#[tokio::test]
async fn a_fetcher_that_ignores_the_budget_is_rejected() {
let resolver = ImageResolver::new(FixedFetcher { body_len: 5 }, NoPreprocessor)
.with_limits(limits(4, 64));
let error = resolver
.resolve(&[url_source()], &mut ImageQuota::new())
.await
.expect_err("the fetched image exceeds the image limit");
assert!(
matches!(error, ImageError::ImageTooLarge { size: 5, max: 4 }),
"{error}"
);
}
#[tokio::test]
async fn data_urls_over_the_image_limit_are_rejected_without_decoding() {
let resolver = ImageResolver::new(FixedFetcher { body_len: 0 }, NoPreprocessor)
.with_limits(limits(4, 64));
let source = ImageSource::DataUrl {
data_url: format!("data:image/webp;base64,{}", "A".repeat(8)),
detail: ImageDetail::High,
};
let error = resolver
.resolve(&[source], &mut ImageQuota::new())
.await
.expect_err("the data url exceeds the image limit");
assert!(
matches!(error, ImageError::ImageTooLarge { size: 6, max: 4 }),
"{error}"
);
}
#[tokio::test]
async fn sources_within_the_limits_reach_preprocessing() {
let resolver = ImageResolver::new(FixedFetcher { body_len: 4 }, NoPreprocessor)
.with_limits(limits(4, 64));
let error = resolver
.resolve(&[url_source()], &mut ImageQuota::new())
.await
.expect_err("the preprocessor rejects every image");
assert!(matches!(error, ImageError::Unsupported(_)), "{error}");
}
#[tokio::test]
async fn a_completed_call_records_its_sources() {
let resolver = ImageResolver::new(FixedFetcher { body_len: 0 }, AcceptingPreprocessor)
.with_limits(limits(8, 64));
let mut quota = ImageQuota::new();
resolver
.resolve(&[bytes_source(6), bytes_source(6)], &mut quota)
.await
.expect("the preprocessor accepts every image");
assert_eq!(quota.image_count(), 2);
assert_eq!(quota.byte_size(), 12);
}
#[tokio::test]
async fn a_preprocessing_failure_leaves_the_quota_unchanged() {
let resolver = ImageResolver::new(FixedFetcher { body_len: 0 }, NoPreprocessor)
.with_limits(limits(8, 64));
let mut quota = ImageQuota::new();
resolver
.resolve(&[bytes_source(6), bytes_source(6)], &mut quota)
.await
.expect_err("the preprocessor rejects every image");
assert_eq!(quota, ImageQuota::new());
}
#[tokio::test]
async fn resolutions_are_limited_to_max_concurrent_sources() {
const SOURCES: usize = 6;
let fetcher = CountingFetcher::default();
let resolver =
ImageResolver::new(fetcher.clone(), NoPreprocessor).with_limits(ImageLimits {
max_concurrent_sources: 3,
..limits(8, 64)
});
let sources = vec![url_source(); SOURCES];
resolver
.resolve(&sources, &mut ImageQuota::new())
.await
.expect_err("the preprocessor rejects every image");
assert_eq!(
fetcher.max_active.load(Ordering::SeqCst),
3,
"the resolver starts at most max_concurrent_sources fetches at a time"
);
}
}

View File

@ -0,0 +1,76 @@
//! Retrying an operation that may fail.
use std::future::Future;
use std::time::Duration;
/// Delays applied before repeated attempts of an operation.
///
/// A policy with `n` delays performs at most `n + 1` attempts.
#[derive(Debug, Clone, Default)]
pub struct RetryPolicy {
delays: Vec<Duration>,
}
impl RetryPolicy {
/// Return a policy that waits `delays` between attempts.
pub fn new(delays: Vec<Duration>) -> Self {
Self { delays }
}
/// Return a policy that performs one attempt.
pub const fn none() -> Self {
Self { delays: Vec::new() }
}
/// Return the policy used for image downloads: one retry after 0.3 seconds,
/// one after 0.6 seconds, and one after 1.0 second.
pub fn download() -> Self {
Self {
delays: vec![
Duration::from_millis(300),
Duration::from_millis(600),
Duration::from_secs(1),
],
}
}
/// Return the maximum number of attempts.
pub fn max_attempts(&self) -> usize {
self.delays.len() + 1
}
/// Run `operation` until it succeeds, fails without retrying, or runs out of
/// attempts.
///
/// `retryable` decides whether a failure starts another attempt. `attempt`
/// passed to `operation` is the zero-based attempt index.
///
/// # Errors
///
/// Returns the error of the final attempt.
pub async fn execute<T, E, F, Fut>(
&self,
mut operation: F,
retryable: impl Fn(&E) -> bool,
) -> Result<T, E>
where
F: FnMut(usize) -> Fut,
Fut: Future<Output = Result<T, E>>,
{
let mut attempt = 0;
loop {
let error = match operation(attempt).await {
Ok(value) => return Ok(value),
Err(error) => error,
};
let Some(delay) = self.delays.get(attempt) else {
return Err(error);
};
if !retryable(&error) {
return Err(error);
}
tokio::time::sleep(*delay).await;
attempt += 1;
}
}
}

View File

@ -0,0 +1,27 @@
[package]
name = "deepseek-recipe-python"
version = "0.1.0"
edition = "2024"
description = "Python bindings for deepseek-recipe components."
readme = "README.md"
license.workspace = true
[lib]
crate-type = ["cdylib"]
name = "_native"
doc = false
[dependencies]
deepseek-recipe = { workspace = true }
deepseek-recipe-core = { workspace = true }
deepseek-recipe-encoding = { workspace = true }
deepseek-recipe-image = { workspace = true, features = [
"opencv-preprocess",
"reqwest-fetch",
] }
futures = { workspace = true }
pyo3 = { version = "0.27", features = ["abi3-py310"] }
serde = { workspace = true }
serde_json = { workspace = true }
tokenizers = { workspace = true }
tokio = { workspace = true }

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,34 @@
# deepseek-recipe-python
Python bindings for deepseek-recipe: conversion of Messages, Chat Completions,
and Responses requests, DeepSeek V4/V4.1 prompt and token encoding, image
preprocessing, and response streaming. Model inference, tool execution, and HTTP
transport are provided by the application.
## Installation
Python 3.10 or later:
```sh
python3 -m pip install deepseek-recipe
```
Import as `deepseek_recipe`. Source builds require Rust, OpenCV 4.x, and Clang/libclang.
## Example
```python
from deepseek_recipe import ChatCompletionRequest, ConversionOptions, DeepseekV41Encoding
request = ChatCompletionRequest({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
})
converted = request.convert(ConversionOptions())
rendered = DeepseekV41Encoding().render_conversation(converted.conversation)
print(rendered.prompt)
```
## License
MIT. The distribution includes the license text.

View File

@ -0,0 +1,578 @@
Third-party licenses of the libraries bundled in this distribution
==================================================================
The native extension of this package links the libraries below, whose builds are
bundled in the wheel under "deepseek_recipe/.dylibs" on macOS and
"deepseek_recipe.libs" on Linux. Each library keeps its own license, and the
texts are reproduced in full below.
OpenCV 4.12.0 Apache License 2.0
libjpeg-turbo (compiled into OpenCV imgcodecs) BSD 3-Clause and IJG
libpng (compiled into OpenCV imgcodecs) PNG Reference Library version 2
libwebp (compiled into OpenCV imgcodecs) BSD 3-Clause
zlib (compiled into OpenCV imgcodecs) zlib License
------------------------------------------------------------------------
OpenCV 4.12.0 - Apache License 2.0
------------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
------------------------------------------------------------------------
OpenCV 4.12.0 - copyright notices
------------------------------------------------------------------------
Copyright (C) 2000-2022, Intel Corporation, all rights reserved.
Copyright (C) 2009-2011, Willow Garage Inc., all rights reserved.
Copyright (C) 2009-2016, NVIDIA Corporation, all rights reserved.
Copyright (C) 2010-2013, Advanced Micro Devices, Inc., all rights reserved.
Copyright (C) 2015-2023, OpenCV Foundation, all rights reserved.
Copyright (C) 2008-2016, Itseez Inc., all rights reserved.
Copyright (C) 2019-2023, Xperience AI, all rights reserved.
Copyright (C) 2019-2022, Shenzhen Institute of Artificial Intelligence and Robotics for Society, all rights reserved.
Copyright (C) 2022-2023, Southern University of Science And Technology, all rights reserved.
Third party copyrights are property of their respective owners.
------------------------------------------------------------------------
libjpeg-turbo - BSD 3-Clause and IJG
------------------------------------------------------------------------
libjpeg-turbo Licenses
======================
libjpeg-turbo is covered by two compatible BSD-style open source licenses:
- The IJG (Independent JPEG Group) License, which is listed in
[README.ijg](README.ijg)
This license applies to the libjpeg API library and associated programs,
including any code inherited from libjpeg and any modifications to that
code. Note that the libjpeg-turbo SIMD source code bears the
[zlib License](https://opensource.org/licenses/Zlib), but in the context of
the overall libjpeg API library, the terms of the zlib License are subsumed
by the terms of the IJG License.
- The Modified (3-clause) BSD License, which is listed below
This license applies to the TurboJPEG API library and associated programs, as
well as the build system. Note that the TurboJPEG API library wraps the
libjpeg API library, so in the context of the overall TurboJPEG API library,
both the terms of the IJG License and the terms of the Modified (3-clause)
BSD License apply.
Complying with the libjpeg-turbo Licenses
=========================================
This section provides a roll-up of the libjpeg-turbo licensing terms, to the
best of our understanding. This is not a license in and of itself. It is
intended solely for clarification.
1. If you are distributing a modified version of the libjpeg-turbo source,
then:
1. You cannot alter or remove any existing copyright or license notices
from the source.
**Origin**
- Clause 1 of the IJG License
- Clause 1 of the Modified BSD License
- Clauses 1 and 3 of the zlib License
2. You must add your own copyright notice to the header of each source
file you modified, so others can tell that you modified that file. (If
there is not an existing copyright header in that file, then you can
simply add a notice stating that you modified the file.)
**Origin**
- Clause 1 of the IJG License
- Clause 2 of the zlib License
3. You must include the IJG README file, and you must not alter any of the
copyright or license text in that file.
**Origin**
- Clause 1 of the IJG License
2. If you are distributing only libjpeg-turbo binaries without the source, or
if you are distributing an application that statically links with
libjpeg-turbo, then:
1. Your product documentation must include a message stating:
This software is based in part on the work of the Independent JPEG
Group.
**Origin**
- Clause 2 of the IJG license
2. If your binary distribution includes or uses the TurboJPEG API, then
your product documentation must include the text of the Modified BSD
License (see below.)
**Origin**
- Clause 2 of the Modified BSD License
3. You cannot use the name of the IJG or The libjpeg-turbo Project or the
contributors thereof in advertising, publicity, etc.
**Origin**
- IJG License
- Clause 3 of the Modified BSD License
4. The IJG and The libjpeg-turbo Project do not warrant libjpeg-turbo to be
free of defects, nor do we accept any liability for undesirable
consequences resulting from your use of the software.
**Origin**
- IJG License
- Modified BSD License
- zlib License
The Modified (3-clause) BSD License
===================================
Copyright (C)2009-2024 D. R. Commander. All Rights Reserved.<br>
Copyright (C)2015 Viktor Szathmáry. All Rights Reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
- Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
- Neither the name of the libjpeg-turbo Project nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS",
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
Why Two Licenses?
=================
The zlib License could have been used instead of the Modified (3-clause) BSD
License, and since the IJG License effectively subsumes the distribution
conditions of the zlib License, this would have effectively placed
libjpeg-turbo binary distributions under the IJG License. However, the IJG
License specifically refers to the Independent JPEG Group and does not extend
attribution and endorsement protections to other entities. Thus, it was
desirable to choose a license that granted us the same protections for new code
that were granted to the IJG for code derived from their software.
------------------------------------------------------------------------
libpng - PNG Reference Library version 2
------------------------------------------------------------------------
COPYRIGHT NOTICE, DISCLAIMER, and LICENSE
=========================================
PNG Reference Library License version 2
---------------------------------------
* Copyright (c) 1995-2024 The PNG Reference Library Authors.
* Copyright (c) 2018-2024 Cosmin Truta.
* Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson.
* Copyright (c) 1996-1997 Andreas Dilger.
* Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc.
The software is supplied "as is", without warranty of any kind,
express or implied, including, without limitation, the warranties
of merchantability, fitness for a particular purpose, title, and
non-infringement. In no event shall the Copyright owners, or
anyone distributing the software, be liable for any damages or
other liability, whether in contract, tort or otherwise, arising
from, out of, or in connection with the software, or the use or
other dealings in the software, even if advised of the possibility
of such damage.
Permission is hereby granted to use, copy, modify, and distribute
this software, or portions hereof, for any purpose, without fee,
subject to the following restrictions:
1. The origin of this software must not be misrepresented; you
must not claim that you wrote the original software. If you
use this software in a product, an acknowledgment in the product
documentation would be appreciated, but is not required.
2. Altered source versions must be plainly marked as such, and must
not be misrepresented as being the original software.
3. This Copyright notice may not be removed or altered from any
source or altered source distribution.
PNG Reference Library License version 1 (for libpng 0.5 through 1.6.35)
-----------------------------------------------------------------------
libpng versions 1.0.7, July 1, 2000, through 1.6.35, July 15, 2018 are
Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson, are
derived from libpng-1.0.6, and are distributed according to the same
disclaimer and license as libpng-1.0.6 with the following individuals
added to the list of Contributing Authors:
Simon-Pierre Cadieux
Eric S. Raymond
Mans Rullgard
Cosmin Truta
Gilles Vollant
James Yu
Mandar Sahastrabuddhe
Google Inc.
Vadim Barkov
and with the following additions to the disclaimer:
There is no warranty against interference with your enjoyment of
the library or against infringement. There is no warranty that our
efforts or the library will fulfill any of your particular purposes
or needs. This library is provided with all faults, and the entire
risk of satisfactory quality, performance, accuracy, and effort is
with the user.
Some files in the "contrib" directory and some configure-generated
files that are distributed with libpng have other copyright owners, and
are released under other open source licenses.
libpng versions 0.97, January 1998, through 1.0.6, March 20, 2000, are
Copyright (c) 1998-2000 Glenn Randers-Pehrson, are derived from
libpng-0.96, and are distributed according to the same disclaimer and
license as libpng-0.96, with the following individuals added to the
list of Contributing Authors:
Tom Lane
Glenn Randers-Pehrson
Willem van Schaik
libpng versions 0.89, June 1996, through 0.96, May 1997, are
Copyright (c) 1996-1997 Andreas Dilger, are derived from libpng-0.88,
and are distributed according to the same disclaimer and license as
libpng-0.88, with the following individuals added to the list of
Contributing Authors:
John Bowler
Kevin Bracey
Sam Bushell
Magnus Holmgren
Greg Roelofs
Tom Tanner
Some files in the "scripts" directory have other copyright owners,
but are released under this license.
libpng versions 0.5, May 1995, through 0.88, January 1996, are
Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc.
For the purposes of this copyright and license, "Contributing Authors"
is defined as the following set of individuals:
Andreas Dilger
Dave Martindale
Guy Eric Schalnat
Paul Schmidt
Tim Wegner
The PNG Reference Library is supplied "AS IS". The Contributing
Authors and Group 42, Inc. disclaim all warranties, expressed or
implied, including, without limitation, the warranties of
merchantability and of fitness for any purpose. The Contributing
Authors and Group 42, Inc. assume no liability for direct, indirect,
incidental, special, exemplary, or consequential damages, which may
result from the use of the PNG Reference Library, even if advised of
the possibility of such damage.
Permission is hereby granted to use, copy, modify, and distribute this
source code, or portions hereof, for any purpose, without fee, subject
to the following restrictions:
1. The origin of this source code must not be misrepresented.
2. Altered versions must be plainly marked as such and must not
be misrepresented as being the original source.
3. This Copyright notice may not be removed or altered from any
source or altered source distribution.
The Contributing Authors and Group 42, Inc. specifically permit,
without fee, and encourage the use of this source code as a component
to supporting the PNG file format in commercial products. If you use
this source code in a product, acknowledgment is not required but would
be appreciated.
------------------------------------------------------------------------
libwebp - BSD 3-Clause
------------------------------------------------------------------------
Copyright (c) 2010, Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Google nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
------------------------------------------------------------------------
zlib - zlib License
------------------------------------------------------------------------
Copyright notice:
(C) 1995-2022 Jean-loup Gailly and Mark Adler
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
Jean-loup Gailly Mark Adler
jloup@gzip.org madler@alumni.caltech.edu

View File

@ -0,0 +1,8 @@
//! Configure native library linking for Python wheels.
fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
// Wheel repair writes longer dependency paths into Mach-O load commands.
println!("cargo::rustc-link-arg-cdylib=-Wl,-headerpad_max_install_names");
}
}

View File

@ -0,0 +1,34 @@
[build-system]
requires = ["maturin>=1.9,<2"]
build-backend = "maturin"
[project]
# The distribution name of PyPI; the import name stays `deepseek_recipe`. The
# Cargo package of this crate, and its directory, keep their own name.
name = "deepseek-recipe"
# The version of the distribution; the Cargo packages keep their own version.
version = "0.1.1"
description = "Python bindings for deepseek-recipe components"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE", "THIRD_PARTY_LICENSES"]
[project.optional-dependencies]
test = ["pytest>=8,<10"]
# PyPI shows these links on the project page. The instance serves them once the
# repository is public.
[project.urls]
Homepage = "https://github.com/deepseek-ai/deepseek-recipe"
Repository = "https://github.com/deepseek-ai/deepseek-recipe"
Documentation = "https://github.com/deepseek-ai/deepseek-recipe/tree/main/docs"
Issues = "https://github.com/deepseek-ai/deepseek-recipe/issues"
[tool.maturin]
python-source = "python"
module-name = "deepseek_recipe._native"
features = ["pyo3/extension-module"]
# Importing the package from the source tree leaves `__pycache__` behind, and the
# python-source walk picks it up. Exclude it from the wheel and the sdist.
exclude = ["**/__pycache__/**"]

View File

@ -0,0 +1,129 @@
"""Direct Python bindings for Rust request, rendering, image, and response components."""
from ._native import (
ASSISTANT_SP_TOKEN,
BOS_TOKEN,
DSML_SP_TOKEN,
EOS_TOKEN,
IMAGE_SPECIAL_TOKEN,
IMAGE_SPECIAL_TOKEN_ID,
LATEST_REMINDER_SP_TOKEN,
SYSTEM_SP_TOKEN,
THINKING_END_TOKEN,
THINKING_START_TOKEN,
USER_SP_TOKEN,
AssistantMessage,
BytesImageSource,
CalcResizeError,
ChatCompletionChunk,
ChatCompletionChunkGenerator,
ChatCompletionRequest,
ChatCompletionResponse,
Conversation,
ConversationRequest,
ConversionError,
ConversionOptions,
DataUrlImageSource,
DeepseekV41Encoding,
DeepseekV4Encoding,
ImageError,
ImageInfo,
ImageQuota,
ImageResolver,
ImageSource,
InferenceChunk,
InferenceFinishReason,
InferenceOptions,
LatestReminderMessage,
Message,
MessagesChunkGenerator,
MessagesRequest,
MessagesResponse,
MessagesStreamEvent,
MultiModalData,
OpenCvImagePreprocessor,
ParsingOptions,
PreprocessOptions,
PromptUsage,
ReasoningStage,
RenderedPrompt,
ReqwestImageFetcher,
ResponsesChunkGenerator,
ResponsesRequest,
ResponsesResponse,
ResponsesStreamEvent,
StreamProcessor,
SystemMessage,
Tokenizer,
ToolCall,
ToolDefinition,
ToolMessage,
UrlImageSource,
UserMessage,
WebSearchBehavior,
__version__,
)
__all__ = [
"ASSISTANT_SP_TOKEN",
"BOS_TOKEN",
"DSML_SP_TOKEN",
"EOS_TOKEN",
"IMAGE_SPECIAL_TOKEN",
"IMAGE_SPECIAL_TOKEN_ID",
"LATEST_REMINDER_SP_TOKEN",
"SYSTEM_SP_TOKEN",
"THINKING_END_TOKEN",
"THINKING_START_TOKEN",
"USER_SP_TOKEN",
"AssistantMessage",
"BytesImageSource",
"CalcResizeError",
"ChatCompletionChunk",
"ChatCompletionChunkGenerator",
"ChatCompletionRequest",
"ChatCompletionResponse",
"Conversation",
"ConversationRequest",
"ConversionError",
"ConversionOptions",
"DataUrlImageSource",
"DeepseekV41Encoding",
"DeepseekV4Encoding",
"ImageError",
"ImageInfo",
"ImageQuota",
"ImageResolver",
"ImageSource",
"InferenceChunk",
"InferenceFinishReason",
"InferenceOptions",
"LatestReminderMessage",
"Message",
"MessagesChunkGenerator",
"MessagesRequest",
"MessagesResponse",
"MessagesStreamEvent",
"MultiModalData",
"OpenCvImagePreprocessor",
"ParsingOptions",
"PreprocessOptions",
"PromptUsage",
"ReasoningStage",
"RenderedPrompt",
"ReqwestImageFetcher",
"ResponsesChunkGenerator",
"ResponsesRequest",
"ResponsesResponse",
"ResponsesStreamEvent",
"StreamProcessor",
"SystemMessage",
"Tokenizer",
"ToolCall",
"ToolDefinition",
"ToolMessage",
"UrlImageSource",
"UserMessage",
"WebSearchBehavior",
"__version__",
]

View File

@ -0,0 +1,581 @@
from collections.abc import Sequence
from typing import Any, Literal, TypeAlias
__version__: str
BOS_TOKEN: str
EOS_TOKEN: str
SYSTEM_SP_TOKEN: str
USER_SP_TOKEN: str
ASSISTANT_SP_TOKEN: str
LATEST_REMINDER_SP_TOKEN: str
THINKING_START_TOKEN: str
THINKING_END_TOKEN: str
DSML_SP_TOKEN: str
IMAGE_SPECIAL_TOKEN: str
IMAGE_SPECIAL_TOKEN_ID: int
ToolChoice: TypeAlias = Literal["auto", "none", "required"]
ReasoningEffort: TypeAlias = Literal["low", "high", "xhigh", "max"]
ResponseFormat: TypeAlias = Literal["text", "json_object"]
ImageDetail: TypeAlias = Literal["low", "high", "original", "auto"]
Role: TypeAlias = Literal["system", "user", "assistant", "tool", "latest_reminder"]
ImageSourceKind: TypeAlias = Literal["data_url", "url", "bytes"]
RequestBody: TypeAlias = bytes | str | dict[str, Any]
class ConversionError(Exception):
"""Raised when a protocol request is invalid or cannot be converted."""
status_code: int
body: str
class ImageError(Exception):
"""A Rust image error with its original variant and retry classification."""
kind: str
is_retryable: bool
class CalcResizeError(ValueError):
"""The Rust image token calculation did not converge."""
class ToolCall:
"""A historical tool call and its arguments."""
def __init__(self, id: str, name: str, arguments: str) -> None: ...
@property
def id(self) -> str: ...
@property
def name(self) -> str: ...
@property
def arguments(self) -> str:
"""Caller-supplied arguments, expected to encode a JSON object; not validated."""
class ToolDefinition:
"""A client tool definition."""
def __init__(
self,
name: str,
description: str | None = None,
parameters: Any = None,
strict: bool | None = None,
) -> None: ...
@property
def name(self) -> str: ...
@property
def description(self) -> str | None: ...
@property
def parameters(self) -> Any:
"""Input JSON Schema of the tool."""
@property
def strict(self) -> bool | None: ...
class ImageSource:
"""Base class of the images supplied with a conversation."""
@property
def kind(self) -> ImageSourceKind: ...
@property
def detail(self) -> ImageDetail: ...
@property
def data_url(self) -> str | None: ...
@property
def url(self) -> str | None: ...
@property
def data(self) -> bytes | None: ...
class DataUrlImageSource(ImageSource):
"""An image referenced by a data URL with a base64 or percent-encoded body."""
def __init__(self, data_url: str, detail: ImageDetail = "high") -> None: ...
class UrlImageSource(ImageSource):
"""An image referenced by an external URL."""
def __init__(self, url: str, detail: ImageDetail = "high") -> None: ...
class BytesImageSource(ImageSource):
"""An image supplied as encoded bytes."""
def __init__(self, data: bytes, detail: ImageDetail = "high") -> None: ...
class Message:
"""Base class of the messages in a conversation."""
@property
def role(self) -> Role: ...
@property
def content(self) -> str: ...
@property
def reasoning_content(self) -> str | None: ...
@property
def tool_calls(self) -> list[ToolCall] | None: ...
@property
def tool_call_id(self) -> str | None: ...
@property
def image_sources(self) -> list[ImageSource]: ...
class SystemMessage(Message):
"""A system message."""
def __init__(self, content: str) -> None: ...
class UserMessage(Message):
"""A user message."""
def __init__(
self, content: str, image_sources: Sequence[ImageSource] | None = None
) -> None: ...
class AssistantMessage(Message):
"""An assistant message."""
def __init__(
self,
content: str,
reasoning_content: str | None = None,
tool_calls: Sequence[ToolCall] | None = None,
) -> None: ...
class ToolMessage(Message):
"""A tool result message."""
def __init__(
self,
content: str,
tool_call_id: str,
image_sources: Sequence[ImageSource] | None = None,
) -> None: ...
class LatestReminderMessage(Message):
"""A reminder message that carries additional instructions."""
def __init__(self, content: str) -> None: ...
class Conversation:
"""A conversation and its prompt configuration."""
def __init__(
self,
messages: Sequence[Message] | None = None,
*,
thinking_mode: bool = True,
tools: Sequence[ToolDefinition] | None = None,
tool_choice: ToolChoice = "auto",
reasoning_effort: ReasoningEffort | None = None,
response_format: ResponseFormat = "text",
) -> None: ...
@property
def messages(self) -> list[Message]: ...
@property
def thinking_mode(self) -> bool: ...
@property
def tools(self) -> list[ToolDefinition]: ...
@property
def tool_choice(self) -> ToolChoice: ...
@property
def reasoning_effort(self) -> ReasoningEffort | None: ...
@property
def response_format(self) -> ResponseFormat: ...
class RenderedPrompt:
"""A rendered prompt and the images its placeholders refer to."""
@property
def prompt(self) -> str: ...
@property
def image_sources(self) -> list[ImageSource]: ...
class DeepseekV4Encoding:
"""The Rust V4 prompt renderer. Token encoding requires an attached tokenizer."""
def __init__(self) -> None: ...
def with_tokenizer(self, tokenizer: Tokenizer) -> DeepseekV4Encoding: ...
def render_conversation(self, conversation: Conversation) -> RenderedPrompt: ...
def encode(self, conversation: Conversation) -> list[int]: ...
class DeepseekV41Encoding:
"""The Rust V4.1 prompt renderer. Token encoding requires an attached tokenizer."""
def __init__(self) -> None: ...
def with_tokenizer(self, tokenizer: Tokenizer) -> DeepseekV41Encoding: ...
def render_conversation(self, conversation: Conversation) -> RenderedPrompt: ...
def encode(self, conversation: Conversation) -> list[int]: ...
class WebSearchBehavior:
"""Handling of supported web-search declarations, choices, and Messages blocks."""
Ignore: WebSearchBehavior
Reject: WebSearchBehavior
class ConversionOptions:
"""Conversion defaults: Responses web search is ignored; Messages rejects it."""
def __init__(
self,
*,
default_thinking_mode: bool = True,
responses_web_search: WebSearchBehavior = WebSearchBehavior.Ignore,
messages_web_search: WebSearchBehavior = WebSearchBehavior.Reject,
) -> None: ...
@property
def default_thinking_mode(self) -> bool: ...
@property
def responses_web_search(self) -> WebSearchBehavior: ...
@property
def messages_web_search(self) -> WebSearchBehavior: ...
def with_default_thinking_mode(self, default_thinking_mode: bool) -> ConversionOptions: ...
def with_responses_web_search(
self, responses_web_search: WebSearchBehavior
) -> ConversionOptions: ...
def with_messages_web_search(
self, messages_web_search: WebSearchBehavior
) -> ConversionOptions: ...
class ConversationRequest:
"""A Rust conversation request with inference and parsing options."""
def __init__(self, conversation: Conversation) -> None: ...
@property
def conversation(self) -> Conversation: ...
@property
def inference_options(self) -> InferenceOptions: ...
@property
def parsing_options(self) -> ParsingOptions: ...
@property
def model(self) -> str | None: ...
@property
def stream(self) -> bool: ...
class ChatCompletionRequest:
"""A Rust Chat Completions request; even failed conversion consumes it."""
def __init__(self, body: RequestBody) -> None: ...
def include_usage(self) -> bool:
"""Read the stream usage setting before consuming this request."""
def convert(self, options: ConversionOptions) -> ConversationRequest: ...
@staticmethod
def chunk_generator(
request: ConversationRequest, id: str, model: str
) -> ChatCompletionChunkGenerator: ...
class ResponsesRequest:
"""A Rust Responses request; even failed conversion consumes it."""
def __init__(self, body: RequestBody) -> None: ...
def custom_tool_names(self) -> set[str]:
"""Read the declared custom tool names before consuming this request."""
def convert(self, options: ConversionOptions) -> ConversationRequest: ...
@staticmethod
def chunk_generator(
request: ConversationRequest, id: str, model: str
) -> ResponsesChunkGenerator: ...
class MessagesRequest:
"""A Rust Messages request; even failed conversion consumes it."""
def __init__(self, body: RequestBody) -> None: ...
def convert(self, options: ConversionOptions) -> ConversationRequest: ...
@staticmethod
def chunk_generator(
request: ConversationRequest, id: str, model: str
) -> MessagesChunkGenerator: ...
class InferenceOptions:
"""Converted inference parameters; the backend applies unspecified defaults."""
@property
def max_tokens(self) -> int | None: ...
@property
def temperature(self) -> float | None: ...
@property
def top_p(self) -> float | None: ...
@property
def thinking_budget_tokens(self) -> int | None: ...
@property
def disable_parallel_tool_use(self) -> bool | None: ...
class ImageInfo:
"""Encoded image bytes and their dimensions in pixels."""
def __init__(self, data: bytes, width: int, height: int) -> None: ...
@property
def data(self) -> bytes: ...
@property
def width(self) -> int: ...
@property
def height(self) -> int: ...
class MultiModalData:
"""Images in the order their placeholders appear in a prompt."""
def __init__(self, images: Sequence[ImageInfo] | None = None) -> None: ...
@property
def images(self) -> list[ImageInfo]: ...
def is_empty(self) -> bool: ...
def image_token_adjustment(self) -> int:
"""Image input tokens beyond one token per prompt placeholder."""
class ImageQuota:
"""Source count and encoded bytes recorded across resolve calls.
A call records its sources after it preprocesses every one of them, so a
failed call adds nothing.
"""
def __init__(self) -> None: ...
def image_count(self) -> int: ...
def byte_size(self) -> int: ...
class PreprocessOptions:
"""Options passed to one Rust image preprocessing operation."""
def __init__(
self, detail: ImageDetail, max_dimension_px: int, low_detail_max_dimension_px: int
) -> None: ...
@property
def detail(self) -> ImageDetail: ...
@property
def max_dimension_px(self) -> int: ...
@property
def low_detail_max_dimension_px(self) -> int: ...
class ReqwestImageFetcher:
"""Fetches external image URLs through the Rust HTTP client."""
def __init__(self) -> None: ...
@staticmethod
def with_max_bytes(max_bytes: int) -> ReqwestImageFetcher: ...
def fetch(self, url: str) -> bytes: ...
class OpenCvImagePreprocessor:
"""Preprocesses encoded images through the Rust OpenCV implementation."""
def __init__(self) -> None: ...
def preprocess(self, data: bytes, options: PreprocessOptions) -> ImageInfo: ...
class ImageResolver:
"""Resolves sources using explicit Rust image components and request quota."""
def __init__(
self, fetcher: ReqwestImageFetcher, preprocessor: OpenCvImagePreprocessor
) -> None: ...
def resolve(self, sources: Sequence[ImageSource], quota: ImageQuota) -> MultiModalData:
"""Resolve images and update the supplied quota; a failed call leaves it unchanged."""
class PromptUsage:
"""Prompt-side token counts from the backend."""
def __init__(
self, *, prompt_tokens: int = 0, prompt_cache_hit_tokens: int = 0,
) -> None: ...
@property
def prompt_tokens(self) -> int: ...
@property
def prompt_cache_hit_tokens(self) -> int: ...
class InferenceFinishReason:
"""Backend finish reasons; output parsing determines the protocol finish reason."""
Stop: InferenceFinishReason
Length: InferenceFinishReason
ContentFilter: InferenceFinishReason
class InferenceChunk:
"""One backend update, constructed with ready(), text(), token(), or finish()."""
@staticmethod
def ready(
*, prompt_usage: PromptUsage | None = None, system_fingerprint: str | None = None,
) -> InferenceChunk: ...
@staticmethod
def text(text: str, content_tokens: int = 0) -> InferenceChunk: ...
@staticmethod
def token(token_id: int) -> InferenceChunk: ...
@staticmethod
def finish(finish_reason: InferenceFinishReason) -> InferenceChunk: ...
@property
def kind(self) -> Literal["ready", "text", "token", "finish"]: ...
@property
def content(self) -> str | None: ...
@property
def token_id(self) -> int | None: ...
@property
def finish_reason(self) -> InferenceFinishReason | None: ...
@property
def prompt_usage(self) -> PromptUsage | None: ...
@property
def content_tokens(self) -> int | None:
"""The tokens this chunk's content accounts for; text chunks only."""
@property
def system_fingerprint(self) -> str | None: ...
class ReasoningStage:
"""Position within reasoning content when parsing begins."""
Start: ReasoningStage
Reasoning: ReasoningStage
Content: ReasoningStage
def start_from_reasoning(self) -> bool: ...
class ParsingOptions:
"""Settings passed directly to the Rust stream parser."""
def __init__(
self,
*,
parse_tool_calls: bool = True,
tool_call_initial_stage: bool = False,
parse_json_output: bool = False,
reasoning_initial_stage: ReasoningStage | None = None,
stop_sequences: Sequence[str] | None = None,
) -> None: ...
parse_tool_calls: bool
tool_call_initial_stage: bool
parse_json_output: bool
reasoning_initial_stage: ReasoningStage | None
@property
def stop_sequences(self) -> list[str]: ...
@stop_sequences.setter
def stop_sequences(self, value: Sequence[str]) -> None: ...
class ChatCompletionChunkGenerator:
"""A Rust Chat Completions generator consumed by a stream processor."""
def __init__(
self, id: str, model: str, include_usage: bool, thinking_mode: bool
) -> None: ...
def with_include_usage(self, include_usage: bool) -> ChatCompletionChunkGenerator:
"""Consume this generator and construct one with the supplied usage setting."""
class ResponsesChunkGenerator:
"""A Rust Responses generator consumed by a stream processor."""
def __init__(self, id: str, model: str) -> None: ...
def with_custom_tool_names(
self, custom_tool_names: set[str] | frozenset[str]
) -> ResponsesChunkGenerator:
"""Consume this generator and construct one with the supplied custom tool names."""
class MessagesChunkGenerator:
"""A Rust Messages generator consumed by a stream processor."""
def __init__(self, id: str, model: str, thinking_mode: bool) -> None: ...
def with_signature(self, signature: str) -> MessagesChunkGenerator:
"""Consume this generator and construct one with the supplied signature."""
class ChatCompletionChunk:
"""A Rust Chat Completions chunk consumed by response accumulation."""
def to_json(self) -> str: ...
class ResponsesStreamEvent:
"""A Rust Responses event consumed by response accumulation."""
def to_json(self) -> str: ...
class MessagesStreamEvent:
"""A Rust Messages event consumed by response accumulation."""
def to_json(self) -> str: ...
class ChatCompletionResponse:
"""A Rust Chat Completions response with explicit accumulation."""
def __init__(
self, id: str, model: str, created: int, prompt_tokens: int, prompt_cache_hit_tokens: int
) -> None: ...
def append(self, chunk: ChatCompletionChunk) -> None:
"""Consume this chunk and apply its delta to the response."""
def to_json(self) -> str: ...
@staticmethod
def chunk_event_type(chunk: ChatCompletionChunk) -> str | None: ...
@staticmethod
def done_message() -> str | None: ...
class ResponsesResponse:
"""A Rust Responses response with explicit accumulation."""
def __init__(
self, id: str, model: str, created: int, prompt_tokens: int, prompt_cache_hit_tokens: int
) -> None: ...
def append(self, chunk: ResponsesStreamEvent) -> None:
"""Consume this event and apply its delta to the response."""
def to_json(self) -> str: ...
@staticmethod
def chunk_event_type(chunk: ResponsesStreamEvent) -> str | None: ...
@staticmethod
def done_message() -> str | None: ...
class MessagesResponse:
"""A Rust Messages response with explicit accumulation."""
def __init__(
self, id: str, model: str, created: int, prompt_tokens: int, prompt_cache_hit_tokens: int
) -> None: ...
def append(self, chunk: MessagesStreamEvent) -> None:
"""Consume this event and apply its delta to the response."""
def to_json(self) -> str: ...
@staticmethod
def chunk_event_type(chunk: MessagesStreamEvent) -> str | None: ...
@staticmethod
def done_message() -> str | None: ...
class Tokenizer:
"""A HuggingFace tokenizer loaded from a `tokenizer.json` file."""
@staticmethod
def from_file(path: str) -> Tokenizer:
"""Load a tokenizer from a `tokenizer.json` file."""
@staticmethod
def from_str(text: str) -> Tokenizer:
"""Load a tokenizer from the contents of a `tokenizer.json` file."""
@staticmethod
def from_pretrained(
identifier: str, revision: str | None = None, token: str | None = None
) -> Tokenizer:
"""Download and cache `tokenizer.json` from the HuggingFace Hub."""
def encode(self, text: str) -> list[int]: ...
class StreamProcessor:
"""Adapt Python inference chunks to the Rust processor's input stream."""
def __init__(
self,
generator: ChatCompletionChunkGenerator | ResponsesChunkGenerator | MessagesChunkGenerator,
options: ParsingOptions,
tokenizer: Tokenizer | None = None,
) -> None:
"""Consume the generator and construct a Rust stream processor.
The optional tokenizer decodes token-id chunks. Without one, a token
chunk raises RuntimeError and closes the processor.
"""
@property
def finished(self) -> bool:
"""Whether processing completed normally and the processor remains unclosed."""
def push(
self, chunk: InferenceChunk
) -> list[ChatCompletionChunk | ResponsesStreamEvent | MessagesStreamEvent]:
"""Process one chunk; raises RuntimeError after completion, close, or decode failure."""
def finish(self) -> list[ChatCompletionChunk | ResponsesStreamEvent | MessagesStreamEvent]:
"""Process EOF; repeated calls after completion yield no chunks.
Calls after close or a decoding failure raise RuntimeError.
"""
def close(self) -> None:
"""Release processing state without producing successful completion."""

View File

@ -0,0 +1,880 @@
//! Python bindings for the conversation types that prompt rendering consumes.
use deepseek_recipe_core::conversation::{Conversation, ReasoningEffort, ResponseFormat};
use deepseek_recipe_core::messages::{InputMessage, ToolCall};
use deepseek_recipe_core::multimodal::{ImageDetail, ImageSource};
use deepseek_recipe_core::tools::{ToolChoice, ToolDefinition};
use deepseek_recipe_encoding::RenderedPrompt;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use serde_json::Value;
use crate::json::{json_to_python, python_to_json};
/// Parse a `tool_choice` string.
fn parse_tool_choice(value: &str) -> PyResult<ToolChoice> {
match value {
"auto" => Ok(ToolChoice::Auto),
"none" => Ok(ToolChoice::None),
"required" => Ok(ToolChoice::Required),
other => Err(PyValueError::new_err(format!(
"invalid tool_choice {other:?}; expected \"auto\", \"none\", or \"required\""
))),
}
}
/// Parse a `reasoning_effort` string.
fn parse_reasoning_effort(value: &str) -> PyResult<ReasoningEffort> {
match value {
"low" => Ok(ReasoningEffort::Low),
"high" => Ok(ReasoningEffort::High),
"xhigh" => Ok(ReasoningEffort::Xhigh),
"max" => Ok(ReasoningEffort::Max),
other => Err(PyValueError::new_err(format!(
"invalid reasoning_effort {other:?}; expected \"low\", \"high\", \"xhigh\", or \"max\""
))),
}
}
/// Parse a `response_format` string.
fn parse_response_format(value: &str) -> PyResult<ResponseFormat> {
match value {
"text" => Ok(ResponseFormat::Text),
"json_object" => Ok(ResponseFormat::JsonObject),
other => Err(PyValueError::new_err(format!(
"invalid response_format {other:?}; expected \"text\" or \"json_object\""
))),
}
}
/// Parse an image `detail` string.
pub(crate) fn parse_image_detail(value: &str) -> PyResult<ImageDetail> {
match value {
"low" => Ok(ImageDetail::Low),
"high" => Ok(ImageDetail::High),
"original" => Ok(ImageDetail::Original),
"auto" => Ok(ImageDetail::Auto),
other => Err(PyValueError::new_err(format!(
"invalid detail {other:?}; expected \"low\", \"high\", \"original\", or \"auto\""
))),
}
}
/// Return the detail level string of an image source.
pub(crate) fn image_detail_str(detail: ImageDetail) -> &'static str {
match detail {
ImageDetail::Low => "low",
ImageDetail::High => "high",
ImageDetail::Original => "original",
ImageDetail::Auto => "auto",
}
}
/// Base class of the messages in a conversation.
///
/// Every message has a role and content. Fields that belong to one role are
/// `None` for the other roles.
#[pyclass(name = "Message", module = "deepseek_recipe._native", subclass, frozen)]
pub(crate) struct PyMessage {
pub(crate) inner: InputMessage,
}
#[pymethods]
impl PyMessage {
/// Role of this message: `system`, `user`, `assistant`, `tool`, or
/// `latest_reminder`.
#[getter]
fn role(&self) -> &'static str {
match &self.inner {
InputMessage::System { .. } => "system",
InputMessage::User { .. } => "user",
InputMessage::Assistant { .. } => "assistant",
InputMessage::Tool { .. } => "tool",
InputMessage::LatestReminder { .. } => "latest_reminder",
}
}
/// Message text.
#[getter]
fn content(&self) -> &str {
match &self.inner {
InputMessage::System { content }
| InputMessage::User { content, .. }
| InputMessage::Assistant { content, .. }
| InputMessage::Tool { content, .. }
| InputMessage::LatestReminder { content } => content,
}
}
/// Reasoning content of an assistant message.
#[getter]
fn reasoning_content(&self) -> Option<&str> {
match &self.inner {
InputMessage::Assistant {
reasoning_content, ..
} => reasoning_content.as_deref(),
_ => None,
}
}
/// Tool calls of an assistant message.
#[getter]
fn tool_calls(&self, py: Python<'_>) -> PyResult<Option<Vec<Py<PyToolCall>>>> {
let tool_calls = match &self.inner {
InputMessage::Assistant { tool_calls, .. } => tool_calls.as_deref(),
_ => None,
};
tool_calls
.map(|tool_calls| {
tool_calls
.iter()
.map(|tool_call| Py::new(py, PyToolCall::from_core(tool_call.clone())))
.collect()
})
.transpose()
}
/// Call ID of a tool message.
#[getter]
fn tool_call_id(&self) -> Option<&str> {
match &self.inner {
InputMessage::Tool { tool_call_id, .. } => Some(tool_call_id),
_ => None,
}
}
/// Image sources of a user or tool message.
#[getter]
fn image_sources(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
let image_sources = match &self.inner {
InputMessage::User { image_sources, .. } | InputMessage::Tool { image_sources, .. } => {
image_sources.as_slice()
}
_ => &[],
};
image_sources
.iter()
.map(|source| image_source_to_py(py, source))
.collect()
}
fn __repr__(&self) -> String {
match &self.inner {
InputMessage::System { content } => format!("SystemMessage({content:?})"),
InputMessage::User {
content,
image_sources,
} => format!(
"UserMessage({content:?}, image_sources={})",
image_sources.len()
),
InputMessage::Assistant {
content,
reasoning_content,
tool_calls,
} => format!(
"AssistantMessage({content:?}, reasoning_content={}, tool_calls={})",
reasoning_content.is_some(),
tool_calls.as_ref().map_or(0, Vec::len),
),
InputMessage::Tool {
content,
image_sources,
tool_call_id,
} => format!(
"ToolMessage({content:?}, tool_call_id={tool_call_id:?}, image_sources={})",
image_sources.len()
),
InputMessage::LatestReminder { content } => {
format!("LatestReminderMessage({content:?})")
}
}
}
}
/// A system message.
#[pyclass(
name = "SystemMessage",
module = "deepseek_recipe._native",
extends = PyMessage,
frozen
)]
pub(crate) struct PySystemMessage;
impl PySystemMessage {
fn from_core(content: String) -> PyClassInitializer<Self> {
PyClassInitializer::from(PyMessage {
inner: InputMessage::System { content },
})
.add_subclass(Self)
}
}
#[pymethods]
impl PySystemMessage {
#[new]
fn new(content: String) -> PyClassInitializer<Self> {
Self::from_core(content)
}
}
/// A user message.
#[pyclass(
name = "UserMessage",
module = "deepseek_recipe._native",
extends = PyMessage,
frozen
)]
pub(crate) struct PyUserMessage;
impl PyUserMessage {
fn from_core(content: String, image_sources: Vec<ImageSource>) -> PyClassInitializer<Self> {
PyClassInitializer::from(PyMessage {
inner: InputMessage::User {
content,
image_sources,
},
})
.add_subclass(Self)
}
}
#[pymethods]
impl PyUserMessage {
#[new]
#[pyo3(signature = (content, image_sources=None))]
fn new(
content: String,
image_sources: Option<Vec<Py<PyImageSource>>>,
) -> PyClassInitializer<Self> {
Self::from_core(content, image_sources_from_py(image_sources))
}
}
/// An assistant message.
#[pyclass(
name = "AssistantMessage",
module = "deepseek_recipe._native",
extends = PyMessage,
frozen
)]
pub(crate) struct PyAssistantMessage;
impl PyAssistantMessage {
fn from_core(
content: String,
reasoning_content: Option<String>,
tool_calls: Option<Vec<ToolCall>>,
) -> PyClassInitializer<Self> {
PyClassInitializer::from(PyMessage {
inner: InputMessage::Assistant {
content,
reasoning_content,
tool_calls,
},
})
.add_subclass(Self)
}
}
#[pymethods]
impl PyAssistantMessage {
#[new]
#[pyo3(signature = (content, reasoning_content=None, tool_calls=None))]
fn new(
content: String,
reasoning_content: Option<String>,
tool_calls: Option<Vec<Py<PyToolCall>>>,
) -> PyClassInitializer<Self> {
let tool_calls = tool_calls.map(|tool_calls| {
tool_calls
.iter()
.map(|tool_call| tool_call.get().inner.clone())
.collect()
});
Self::from_core(content, reasoning_content, tool_calls)
}
}
/// A tool result message.
#[pyclass(
name = "ToolMessage",
module = "deepseek_recipe._native",
extends = PyMessage,
frozen
)]
pub(crate) struct PyToolMessage;
impl PyToolMessage {
fn from_core(
content: String,
tool_call_id: String,
image_sources: Vec<ImageSource>,
) -> PyClassInitializer<Self> {
PyClassInitializer::from(PyMessage {
inner: InputMessage::Tool {
content,
image_sources,
tool_call_id,
},
})
.add_subclass(Self)
}
}
#[pymethods]
impl PyToolMessage {
#[new]
#[pyo3(signature = (content, tool_call_id, image_sources=None))]
fn new(
content: String,
tool_call_id: String,
image_sources: Option<Vec<Py<PyImageSource>>>,
) -> PyClassInitializer<Self> {
Self::from_core(content, tool_call_id, image_sources_from_py(image_sources))
}
}
/// A reminder message that carries additional instructions.
#[pyclass(
name = "LatestReminderMessage",
module = "deepseek_recipe._native",
extends = PyMessage,
frozen
)]
pub(crate) struct PyLatestReminderMessage;
impl PyLatestReminderMessage {
fn from_core(content: String) -> PyClassInitializer<Self> {
PyClassInitializer::from(PyMessage {
inner: InputMessage::LatestReminder { content },
})
.add_subclass(Self)
}
}
#[pymethods]
impl PyLatestReminderMessage {
#[new]
fn new(content: String) -> PyClassInitializer<Self> {
Self::from_core(content)
}
}
/// Collect the core image sources of Python image source objects.
fn image_sources_from_py(image_sources: Option<Vec<Py<PyImageSource>>>) -> Vec<ImageSource> {
image_sources
.unwrap_or_default()
.iter()
.map(|source| source.get().inner.clone())
.collect()
}
/// A historical tool call and its arguments.
#[pyclass(name = "ToolCall", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyToolCall {
pub(crate) inner: ToolCall,
}
impl PyToolCall {
fn from_core(inner: ToolCall) -> Self {
Self { inner }
}
}
#[pymethods]
impl PyToolCall {
#[new]
fn new(id: String, name: String, arguments: String) -> Self {
Self::from_core(ToolCall {
id,
name,
arguments,
})
}
/// Call ID that a tool result refers to.
#[getter]
fn id(&self) -> &str {
&self.inner.id
}
/// Name of the called tool.
#[getter]
fn name(&self) -> &str {
&self.inner.name
}
/// Caller-supplied arguments, expected to encode a JSON object.
/// Construction does not validate the string.
#[getter]
fn arguments(&self) -> &str {
&self.inner.arguments
}
fn __repr__(&self) -> String {
format!(
"ToolCall(id={:?}, name={:?}, arguments={:?})",
self.inner.id, self.inner.name, self.inner.arguments
)
}
}
/// A client tool definition.
#[pyclass(name = "ToolDefinition", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyToolDefinition {
pub(crate) inner: ToolDefinition,
}
#[pymethods]
impl PyToolDefinition {
/// Construct a tool definition.
///
/// `parameters` is the input JSON Schema. Python `None` maps to JSON `null`.
/// `strict` requests strict argument validation and is left to the caller.
#[new]
#[pyo3(signature = (name, description=None, parameters=None, strict=None))]
fn new(
py: Python<'_>,
name: String,
description: Option<String>,
parameters: Option<Py<PyAny>>,
strict: Option<bool>,
) -> PyResult<Self> {
let parameters = match parameters {
Some(parameters) => python_to_json(parameters.bind(py))?,
None => Value::Null,
};
Ok(Self {
inner: ToolDefinition {
name,
description,
parameters,
strict,
},
})
}
/// Tool name.
#[getter]
fn name(&self) -> &str {
&self.inner.name
}
/// Human-readable purpose of the tool.
#[getter]
fn description(&self) -> Option<&str> {
self.inner.description.as_deref()
}
/// Input JSON Schema of the tool.
#[getter]
fn parameters(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
json_to_python(py, &self.inner.parameters)
}
/// Requested strict argument validation.
#[getter]
fn strict(&self) -> Option<bool> {
self.inner.strict
}
fn __repr__(&self) -> String {
format!(
"ToolDefinition(name={:?}, description={:?}, strict={:?})",
self.inner.name, self.inner.description, self.inner.strict
)
}
}
/// Base class of the images supplied with a conversation.
#[pyclass(
name = "ImageSource",
module = "deepseek_recipe._native",
subclass,
frozen
)]
pub(crate) struct PyImageSource {
pub(crate) inner: ImageSource,
}
#[pymethods]
impl PyImageSource {
/// Kind of image source: `data_url`, `url`, or `bytes`.
#[getter]
fn kind(&self) -> &'static str {
match &self.inner {
ImageSource::DataUrl { .. } => "data_url",
ImageSource::Url { .. } => "url",
ImageSource::Bytes { .. } => "bytes",
}
}
/// Requested image detail level.
#[getter]
fn detail(&self) -> &'static str {
image_detail_str(self.inner.detail())
}
/// Data URL of a data URL source, with a base64 or percent-encoded body.
#[getter]
fn data_url(&self) -> Option<&str> {
match &self.inner {
ImageSource::DataUrl { data_url, .. } => Some(data_url),
_ => None,
}
}
/// External URL of a URL source.
#[getter]
fn url(&self) -> Option<&str> {
match &self.inner {
ImageSource::Url { url, .. } => Some(url),
_ => None,
}
}
/// Encoded bytes of a bytes source.
#[getter]
fn data(&self) -> Option<&[u8]> {
match &self.inner {
ImageSource::Bytes { data, .. } => Some(data),
_ => None,
}
}
fn __repr__(&self) -> String {
match &self.inner {
ImageSource::DataUrl { data_url, detail } => format!(
"DataUrlImageSource({data_url:?}, detail={:?})",
image_detail_str(*detail)
),
ImageSource::Url { url, detail } => {
format!(
"UrlImageSource({url:?}, detail={:?})",
image_detail_str(*detail)
)
}
ImageSource::Bytes { data, detail } => format!(
"BytesImageSource(<{} bytes>, detail={:?})",
data.len(),
image_detail_str(*detail)
),
}
}
}
/// An image referenced by a data URL with a base64 or percent-encoded body.
#[pyclass(
name = "DataUrlImageSource",
module = "deepseek_recipe._native",
extends = PyImageSource,
frozen
)]
pub(crate) struct PyDataUrlImageSource;
impl PyDataUrlImageSource {
fn from_core(data_url: String, detail: ImageDetail) -> PyClassInitializer<Self> {
PyClassInitializer::from(PyImageSource {
inner: ImageSource::DataUrl { data_url, detail },
})
.add_subclass(Self)
}
}
#[pymethods]
impl PyDataUrlImageSource {
#[new]
#[pyo3(signature = (data_url, detail="high"))]
fn new(data_url: String, detail: &str) -> PyResult<PyClassInitializer<Self>> {
Ok(Self::from_core(data_url, parse_image_detail(detail)?))
}
}
/// An image referenced by an external URL.
#[pyclass(
name = "UrlImageSource",
module = "deepseek_recipe._native",
extends = PyImageSource,
frozen
)]
pub(crate) struct PyUrlImageSource;
impl PyUrlImageSource {
fn from_core(url: String, detail: ImageDetail) -> PyClassInitializer<Self> {
PyClassInitializer::from(PyImageSource {
inner: ImageSource::Url { url, detail },
})
.add_subclass(Self)
}
}
#[pymethods]
impl PyUrlImageSource {
#[new]
#[pyo3(signature = (url, detail="high"))]
fn new(url: String, detail: &str) -> PyResult<PyClassInitializer<Self>> {
Ok(Self::from_core(url, parse_image_detail(detail)?))
}
}
/// An image supplied as encoded bytes.
#[pyclass(
name = "BytesImageSource",
module = "deepseek_recipe._native",
extends = PyImageSource,
frozen
)]
pub(crate) struct PyBytesImageSource;
impl PyBytesImageSource {
fn from_core(data: Vec<u8>, detail: ImageDetail) -> PyClassInitializer<Self> {
PyClassInitializer::from(PyImageSource {
inner: ImageSource::Bytes { data, detail },
})
.add_subclass(Self)
}
}
#[pymethods]
impl PyBytesImageSource {
#[new]
#[pyo3(signature = (data, detail="high"))]
fn new(data: Vec<u8>, detail: &str) -> PyResult<PyClassInitializer<Self>> {
Ok(Self::from_core(data, parse_image_detail(detail)?))
}
}
/// Construct the Python image source object for a core image source.
fn image_source_to_py(py: Python<'_>, source: &ImageSource) -> PyResult<Py<PyAny>> {
Ok(match source {
ImageSource::DataUrl { data_url, detail } => Py::new(
py,
PyDataUrlImageSource::from_core(data_url.clone(), *detail),
)?
.into_any(),
ImageSource::Url { url, detail } => {
Py::new(py, PyUrlImageSource::from_core(url.clone(), *detail))?.into_any()
}
ImageSource::Bytes { data, detail } => {
Py::new(py, PyBytesImageSource::from_core(data.clone(), *detail))?.into_any()
}
})
}
/// Construct the Python message object for a core message.
fn message_to_py(py: Python<'_>, message: &InputMessage) -> PyResult<Py<PyAny>> {
Ok(match message {
InputMessage::System { content } => {
Py::new(py, PySystemMessage::from_core(content.clone()))?.into_any()
}
InputMessage::User {
content,
image_sources,
} => Py::new(
py,
PyUserMessage::from_core(content.clone(), image_sources.clone()),
)?
.into_any(),
InputMessage::Assistant {
content,
reasoning_content,
tool_calls,
} => Py::new(
py,
PyAssistantMessage::from_core(
content.clone(),
reasoning_content.clone(),
tool_calls.clone(),
),
)?
.into_any(),
InputMessage::Tool {
content,
image_sources,
tool_call_id,
} => Py::new(
py,
PyToolMessage::from_core(content.clone(), tool_call_id.clone(), image_sources.clone()),
)?
.into_any(),
InputMessage::LatestReminder { content } => {
Py::new(py, PyLatestReminderMessage::from_core(content.clone()))?.into_any()
}
})
}
/// A conversation and its prompt configuration.
#[pyclass(name = "Conversation", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyConversation {
pub(crate) inner: Conversation,
}
impl PyConversation {
pub(crate) fn from_core(inner: Conversation) -> Self {
Self { inner }
}
}
#[pymethods]
impl PyConversation {
/// Construct a conversation.
///
/// Thinking mode is enabled by default, tool selection defaults to `auto`,
/// and the response format defaults to `text`.
#[new]
#[pyo3(signature = (
messages=None,
*,
thinking_mode=true,
tools=None,
tool_choice="auto",
reasoning_effort=None,
response_format="text"
))]
fn new(
messages: Option<Vec<Py<PyMessage>>>,
thinking_mode: bool,
tools: Option<Vec<Py<PyToolDefinition>>>,
tool_choice: &str,
reasoning_effort: Option<&str>,
response_format: &str,
) -> PyResult<Self> {
let messages = messages
.unwrap_or_default()
.iter()
.map(|message| message.get().inner.clone())
.collect();
let tools = tools
.unwrap_or_default()
.iter()
.map(|tool| tool.get().inner.clone())
.collect();
let reasoning_effort = reasoning_effort.map(parse_reasoning_effort).transpose()?;
Ok(Self {
inner: Conversation {
messages,
thinking_mode,
tools,
tool_choice: parse_tool_choice(tool_choice)?,
reasoning_effort,
response_format: parse_response_format(response_format)?,
},
})
}
/// Messages in conversation order.
#[getter]
fn messages(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
self.inner
.messages
.iter()
.map(|message| message_to_py(py, message))
.collect()
}
/// Whether thinking mode is enabled for the next assistant turn.
#[getter]
fn thinking_mode(&self) -> bool {
self.inner.thinking_mode
}
/// Available client tools, in declaration order.
#[getter]
fn tools(&self, py: Python<'_>) -> PyResult<Vec<Py<PyToolDefinition>>> {
self.inner
.tools
.iter()
.map(|tool| {
Py::new(
py,
PyToolDefinition {
inner: tool.clone(),
},
)
})
.collect()
}
/// Tool selection: `auto`, `none`, or `required`.
#[getter]
fn tool_choice(&self) -> &'static str {
match self.inner.tool_choice {
ToolChoice::Auto => "auto",
ToolChoice::None => "none",
ToolChoice::Required => "required",
}
}
/// Reasoning effort used when thinking mode is enabled.
#[getter]
fn reasoning_effort(&self) -> Option<&'static str> {
self.inner.reasoning_effort.map(|effort| match effort {
ReasoningEffort::Low => "low",
ReasoningEffort::High => "high",
ReasoningEffort::Xhigh => "xhigh",
ReasoningEffort::Max => "max",
})
}
/// Requested answer format: `text` or `json_object`.
#[getter]
fn response_format(&self) -> &'static str {
match self.inner.response_format {
ResponseFormat::Text => "text",
ResponseFormat::JsonObject => "json_object",
}
}
fn __repr__(&self) -> String {
format!(
"Conversation(messages={}, thinking_mode={}, tools={}, tool_choice={:?}, \
reasoning_effort={:?}, response_format={:?})",
self.inner.messages.len(),
self.inner.thinking_mode,
self.inner.tools.len(),
self.tool_choice(),
self.reasoning_effort(),
self.response_format(),
)
}
}
/// A rendered prompt and the images its placeholders refer to.
#[pyclass(name = "RenderedPrompt", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyRenderedPrompt {
inner: RenderedPrompt,
}
impl PyRenderedPrompt {
pub(crate) fn from_core(inner: RenderedPrompt) -> Self {
Self { inner }
}
}
#[pymethods]
impl PyRenderedPrompt {
/// Model input string.
#[getter]
fn prompt(&self) -> &str {
&self.inner.prompt
}
/// Image sources in the order their placeholders appear in the prompt.
#[getter]
fn image_sources(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
self.inner
.image_sources
.iter()
.map(|source| image_source_to_py(py, source))
.collect()
}
fn __repr__(&self) -> String {
format!(
"RenderedPrompt(prompt=<{} characters>, image_sources={})",
self.inner.prompt.chars().count(),
self.inner.image_sources.len(),
)
}
}

View File

@ -0,0 +1,66 @@
//! Direct bindings for the Rust model-specific prompt renderers.
use std::sync::Arc;
use deepseek_recipe_encoding::PromptEncoding;
use deepseek_recipe_encoding::v4::dsv4::DeepseekV4Encoding;
use deepseek_recipe_encoding::v4::dsv41::DeepseekV41Encoding;
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::*;
use crate::conversation::{PyConversation, PyRenderedPrompt};
use crate::tokenizer::PyTokenizer;
macro_rules! prompt_encoding {
($python:ident, $rust:ty, $name:literal) => {
/// A Rust prompt renderer. Token encoding requires an attached tokenizer.
#[pyclass(name = $name, module = "deepseek_recipe._native", frozen)]
pub(crate) struct $python {
inner: $rust,
}
#[pymethods]
impl $python {
#[new]
fn new() -> Self {
Self {
inner: <$rust>::new(),
}
}
/// Attach a tokenizer for `encode`, returning a new encoding.
fn with_tokenizer(&self, tokenizer: PyRef<'_, PyTokenizer>) -> Self {
Self {
inner: <$rust>::new().with_tokenizer(Arc::clone(&tokenizer.inner)),
}
}
/// Render a conversation through the Rust model's prompt template.
fn render_conversation(
&self,
py: Python<'_>,
conversation: &PyConversation,
) -> PyRenderedPrompt {
let rendered = py.detach(|| self.inner.render_conversation(&conversation.inner));
PyRenderedPrompt::from_core(rendered)
}
/// Encode a conversation into model token IDs using the attached tokenizer.
fn encode(&self, py: Python<'_>, conversation: &PyConversation) -> PyResult<Vec<u32>> {
py.detach(|| self.inner.encode(&conversation.inner))
.map_err(|error| PyRuntimeError::new_err(error.to_string()))
}
}
};
}
prompt_encoding!(
PyDeepseekV4Encoding,
DeepseekV4Encoding,
"DeepseekV4Encoding"
);
prompt_encoding!(
PyDeepseekV41Encoding,
DeepseekV41Encoding,
"DeepseekV41Encoding"
);

View File

@ -0,0 +1,34 @@
//! The exception raised when a protocol request cannot be converted.
use deepseek_recipe::request::ConversionError as RustConversionError;
use pyo3::exceptions::PyException;
use pyo3::prelude::*;
pyo3::create_exception!(
_native,
ConversionError,
PyException,
"Raised when a protocol request is invalid or cannot be converted."
);
/// Build the exception for a failed conversion.
///
/// The exception message is the conversion error message. Its `status_code` and
/// `body` attributes hold the HTTP status and serialized JSON error response.
pub(crate) fn request_error(py: Python<'_>, error: RustConversionError) -> PyErr {
let status_code = error.status_code();
let response = error.into_response();
let body = match serde_json::to_string(&response) {
Ok(body) => body,
Err(error) => return pyo3::exceptions::PyRuntimeError::new_err(error.to_string()),
};
let exception = ConversionError::new_err(response.error.message);
let value = exception.value(py);
if let Err(error) = value
.setattr("status_code", status_code)
.and_then(|()| value.setattr("body", body))
{
return error;
}
exception
}

View File

@ -0,0 +1,350 @@
//! Python bindings for image fetching, preprocessing, and token accounting.
use std::future::Future;
use std::sync::Arc;
use deepseek_recipe_core::multimodal::{ImageInfo, MultiModalData};
use deepseek_recipe_image::{
ImageByteBudget, ImageError as RustImageError, ImageFetcher, ImagePreprocessor, ImageQuota,
ImageResolver, OpenCvImagePreprocessor, PreprocessOptions, ReqwestImageFetcher,
};
use pyo3::exceptions::{PyException, PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::PyBytes;
use crate::conversation::{PyImageSource, image_detail_str, parse_image_detail};
pyo3::create_exception!(
_native,
ImageError,
PyException,
"An image fetching, decoding, or preprocessing failure."
);
pyo3::create_exception!(
_native,
CalcResizeError,
PyValueError,
"The Rust image token calculation did not converge."
);
/// Preserve the Rust error variant and retry classification in Python.
fn image_error(py: Python<'_>, error: RustImageError) -> PyErr {
let kind = match &error {
RustImageError::Unsupported(_) => "Unsupported",
RustImageError::UrlTooLong { .. } => "UrlTooLong",
RustImageError::InvalidUrl { .. } => "InvalidUrl",
RustImageError::TooManyImages { .. } => "TooManyImages",
RustImageError::ImageTooLarge { .. } => "ImageTooLarge",
RustImageError::TotalSizeTooLarge { .. } => "TotalSizeTooLarge",
RustImageError::InvalidDataUrl(_) => "InvalidDataUrl",
RustImageError::EmptyImage => "EmptyImage",
RustImageError::UnsupportedMediaType(_) => "UnsupportedMediaType",
RustImageError::ImageDimensionsTooLarge => "ImageDimensionsTooLarge",
RustImageError::Decode(_) => "Decode",
RustImageError::Resize(_) => "Resize",
RustImageError::Encode(_) => "Encode",
RustImageError::TokenBudget(_) => "TokenBudget",
RustImageError::Fetch { .. } => "Fetch",
RustImageError::FetchStatus { .. } => "FetchStatus",
RustImageError::Client(_) => "Client",
RustImageError::EncodeFailed => "EncodeFailed",
};
let exception = ImageError::new_err(error.to_string());
let value = exception.value(py);
if let Err(error) = value
.setattr("kind", kind)
.and_then(|()| value.setattr("is_retryable", error.is_retryable()))
{
return error;
}
exception
}
/// Run an asynchronous Rust image operation with the GIL released.
fn run_image_operation<T: Send>(
py: Python<'_>,
future: impl Future<Output = Result<T, RustImageError>> + Send,
) -> PyResult<T> {
let result = py
.detach(|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
Ok::<_, std::io::Error>(runtime.block_on(future))
})
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
result.map_err(|error| image_error(py, error))
}
/// Encoded image bytes and their dimensions in pixels.
#[pyclass(name = "ImageInfo", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyImageInfo {
inner: ImageInfo,
}
#[pymethods]
impl PyImageInfo {
#[new]
fn new(data: Vec<u8>, width: u32, height: u32) -> Self {
Self {
inner: ImageInfo {
data,
width,
height,
},
}
}
/// Encoded image bytes.
#[getter]
fn data(&self, py: Python<'_>) -> Py<PyBytes> {
PyBytes::new(py, &self.inner.data).unbind()
}
/// Image width in pixels.
#[getter]
fn width(&self) -> u32 {
self.inner.width
}
/// Image height in pixels.
#[getter]
fn height(&self) -> u32 {
self.inner.height
}
}
/// Images in the order their placeholders appear in a prompt.
#[pyclass(name = "MultiModalData", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyMultiModalData {
inner: MultiModalData,
}
#[pymethods]
impl PyMultiModalData {
#[new]
#[pyo3(signature = (images=None))]
fn new(images: Option<Vec<Py<PyImageInfo>>>) -> Self {
Self {
inner: MultiModalData {
images: images
.unwrap_or_default()
.iter()
.map(|image| image.get().inner.clone())
.collect(),
},
}
}
/// Images in placeholder order.
#[getter]
fn images(&self) -> Vec<PyImageInfo> {
self.inner
.images
.iter()
.map(|image| PyImageInfo {
inner: image.clone(),
})
.collect()
}
/// Return whether no image is attached.
fn is_empty(&self) -> bool {
self.inner.is_empty()
}
/// Return the number of prompt tokens beyond one per image placeholder.
fn image_token_adjustment(&self) -> PyResult<usize> {
self.inner
.image_token_adjustment()
.map_err(|error| CalcResizeError::new_err(error.to_string()))
}
}
/// Image count and encoded size accumulated across resolve calls.
/// A call records its sources after preprocessing all of them, so a failed call
/// adds nothing.
#[pyclass(name = "ImageQuota", module = "deepseek_recipe._native")]
pub(crate) struct PyImageQuota {
inner: ImageQuota,
}
#[pymethods]
impl PyImageQuota {
#[new]
fn new() -> Self {
Self {
inner: ImageQuota::new(),
}
}
/// Return the number of sources recorded by the completed calls.
fn image_count(&self) -> usize {
self.inner.image_count()
}
/// Return the encoded source bytes recorded by the completed calls.
fn byte_size(&self) -> usize {
self.inner.byte_size()
}
}
/// Options passed to one image preprocessing operation.
#[pyclass(name = "PreprocessOptions", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyPreprocessOptions {
inner: PreprocessOptions,
}
#[pymethods]
impl PyPreprocessOptions {
#[new]
fn new(
detail: &str,
max_dimension_px: u32,
low_detail_max_dimension_px: u32,
) -> PyResult<Self> {
Ok(Self {
inner: PreprocessOptions {
detail: parse_image_detail(detail)?,
max_dimension_px,
low_detail_max_dimension_px,
},
})
}
/// Requested image detail level.
#[getter]
fn detail(&self) -> &'static str {
image_detail_str(self.inner.detail)
}
/// Maximum accepted source width or height in pixels, before resizing.
#[getter]
fn max_dimension_px(&self) -> u32 {
self.inner.max_dimension_px
}
/// Maximum long side before token-budget fitting at low detail.
#[getter]
fn low_detail_max_dimension_px(&self) -> u32 {
self.inner.low_detail_max_dimension_px
}
}
/// Fetches external image URLs through the Rust HTTP client.
#[derive(Clone)]
#[pyclass(
name = "ReqwestImageFetcher",
module = "deepseek_recipe._native",
frozen
)]
pub(crate) struct PyReqwestImageFetcher {
inner: Arc<ReqwestImageFetcher>,
}
impl ImageFetcher for PyReqwestImageFetcher {
fn fetch(
&self,
url: &str,
budget: &ImageByteBudget,
) -> impl Future<Output = Result<Vec<u8>, RustImageError>> + Send {
self.inner.fetch(url, budget)
}
}
#[pymethods]
impl PyReqwestImageFetcher {
/// Construct the Rust fetcher with its default image size limit.
#[new]
fn new(py: Python<'_>) -> PyResult<Self> {
ReqwestImageFetcher::new()
.map(|inner| Self {
inner: Arc::new(inner),
})
.map_err(|error| image_error(py, error))
}
/// Construct the Rust fetcher with an explicit encoded size limit.
#[staticmethod]
fn with_max_bytes(py: Python<'_>, max_bytes: usize) -> PyResult<Self> {
ReqwestImageFetcher::with_max_bytes(max_bytes)
.map(|inner| Self {
inner: Arc::new(inner),
})
.map_err(|error| image_error(py, error))
}
/// Fetch encoded image bytes with the GIL released.
///
/// A standalone fetch applies the size limit of this fetcher to one image.
fn fetch(&self, py: Python<'_>, url: &str) -> PyResult<Py<PyBytes>> {
let max_bytes = self.inner.max_bytes();
let budget = ImageByteBudget::new(max_bytes, max_bytes, 0);
let data = run_image_operation(py, self.inner.fetch(url, &budget))?;
Ok(PyBytes::new(py, &data).unbind())
}
}
/// Preprocesses encoded images through the Rust OpenCV implementation.
#[pyclass(
name = "OpenCvImagePreprocessor",
module = "deepseek_recipe._native",
frozen
)]
pub(crate) struct PyOpenCvImagePreprocessor {
inner: OpenCvImagePreprocessor,
}
#[pymethods]
impl PyOpenCvImagePreprocessor {
#[new]
fn new() -> Self {
Self {
inner: OpenCvImagePreprocessor,
}
}
/// Preprocess encoded image bytes with the GIL released.
fn preprocess(
&self,
py: Python<'_>,
data: Vec<u8>,
options: &PyPreprocessOptions,
) -> PyResult<PyImageInfo> {
run_image_operation(py, self.inner.preprocess(data, options.inner))
.map(|inner| PyImageInfo { inner })
}
}
/// Resolves image sources using explicit fetching and preprocessing components.
#[pyclass(name = "ImageResolver", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyImageResolver {
inner: ImageResolver<PyReqwestImageFetcher, OpenCvImagePreprocessor>,
}
#[pymethods]
impl PyImageResolver {
#[new]
fn new(fetcher: &PyReqwestImageFetcher, preprocessor: &PyOpenCvImagePreprocessor) -> Self {
Self {
inner: ImageResolver::new(fetcher.clone(), preprocessor.inner),
}
}
/// Resolve image sources and update the caller-supplied request quota.
/// A failed call leaves the quota unchanged.
fn resolve(
&self,
py: Python<'_>,
sources: Vec<Py<PyImageSource>>,
mut quota: PyRefMut<'_, PyImageQuota>,
) -> PyResult<PyMultiModalData> {
let sources: Vec<_> = sources
.iter()
.map(|source| source.get().inner.clone())
.collect();
run_image_operation(py, self.inner.resolve(&sources, &mut quota.inner))
.map(|inner| PyMultiModalData { inner })
}
}

View File

@ -0,0 +1,246 @@
//! Python representations of backend inference chunks and usage snapshots.
use deepseek_recipe::stream::{InferenceChunk, InferenceFinishReason, PromptUsage};
use pyo3::prelude::*;
/// Prompt-side token counts supplied by the inference backend.
///
/// Unspecified counts default to zero. The binding does not infer token counts
/// from generated text.
#[pyclass(name = "PromptUsage", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyPromptUsage {
inner: PromptUsage,
}
#[pymethods]
impl PyPromptUsage {
#[new]
#[pyo3(signature = (*, prompt_tokens = 0, prompt_cache_hit_tokens = 0))]
fn new(prompt_tokens: usize, prompt_cache_hit_tokens: usize) -> Self {
Self {
inner: PromptUsage {
prompt_tokens,
prompt_cache_hit_tokens,
},
}
}
/// Total input tokens, including tokens read from the prompt cache.
#[getter]
fn prompt_tokens(&self) -> usize {
self.inner.prompt_tokens
}
/// Input tokens read from the prompt cache.
#[getter]
fn prompt_cache_hit_tokens(&self) -> usize {
self.inner.prompt_cache_hit_tokens
}
}
/// The finish reason reported by the inference backend.
#[derive(Clone, Copy, PartialEq, Eq)]
#[pyclass(
name = "InferenceFinishReason",
module = "deepseek_recipe._native",
eq,
frozen
)]
pub(crate) enum PyInferenceFinishReason {
Stop,
Length,
ContentFilter,
}
impl PyInferenceFinishReason {
fn to_core(self) -> InferenceFinishReason {
match self {
Self::Stop => InferenceFinishReason::Stop,
Self::Length => InferenceFinishReason::Length,
Self::ContentFilter => InferenceFinishReason::ContentFilter,
}
}
fn from_core(reason: InferenceFinishReason) -> Self {
match reason {
InferenceFinishReason::Stop => Self::Stop,
InferenceFinishReason::Length => Self::Length,
InferenceFinishReason::ContentFilter => Self::ContentFilter,
}
}
}
/// One backend inference chunk, constructed with `ready`, `text`, `token`, or
/// `finish`.
///
/// Ready chunks carry the prompt usage snapshot; text chunks carry the tokens
/// their content accounts for. Omitted counts default to zero.
#[pyclass(name = "InferenceChunk", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyInferenceChunk {
inner: InferenceChunk,
}
impl PyInferenceChunk {
pub(crate) fn to_core(&self) -> InferenceChunk {
match &self.inner {
InferenceChunk::Ready {
system_fingerprint,
prompt_usage,
} => InferenceChunk::Ready {
system_fingerprint: system_fingerprint.clone(),
prompt_usage: *prompt_usage,
},
InferenceChunk::Text {
content,
content_tokens,
} => InferenceChunk::Text {
content: content.clone(),
content_tokens: *content_tokens,
},
InferenceChunk::Token { token_id } => InferenceChunk::Token {
token_id: *token_id,
},
InferenceChunk::Finish { finish_reason } => InferenceChunk::Finish {
finish_reason: *finish_reason,
},
}
}
}
#[pymethods]
impl PyInferenceChunk {
/// Construct the backend's initial metadata chunk.
///
/// Omitted usage defaults to zero. The system fingerprint is carried into
/// protocol responses that expose it, such as OpenAI Chat Completions.
#[staticmethod]
#[pyo3(signature = (*, prompt_usage = None, system_fingerprint = None))]
fn ready(
prompt_usage: Option<PyRef<'_, PyPromptUsage>>,
system_fingerprint: Option<String>,
) -> Self {
Self {
inner: InferenceChunk::Ready {
system_fingerprint,
prompt_usage: prompt_usage.map(|usage| usage.inner).unwrap_or_default(),
},
}
}
/// Construct one text chunk from the backend.
///
/// `content_tokens` is the number of tokens this chunk's content accounts
/// for; the stream processor accumulates it across chunks.
#[staticmethod]
#[pyo3(signature = (text, content_tokens = 0))]
fn text(text: String, content_tokens: usize) -> Self {
Self {
inner: InferenceChunk::Text {
content: text,
content_tokens,
},
}
}
/// Construct one token-id chunk, decoded by the processor's tokenizer.
#[staticmethod]
fn token(token_id: u32) -> Self {
Self {
inner: InferenceChunk::Token { token_id },
}
}
/// Construct the backend's terminal chunk.
#[staticmethod]
fn finish(finish_reason: PyRef<'_, PyInferenceFinishReason>) -> Self {
Self {
inner: InferenceChunk::Finish {
finish_reason: (*finish_reason).to_core(),
},
}
}
/// The chunk kind: `ready`, `text`, `token`, or `finish`.
#[getter]
fn kind(&self) -> &'static str {
match &self.inner {
InferenceChunk::Ready { .. } => "ready",
InferenceChunk::Text { .. } => "text",
InferenceChunk::Token { .. } => "token",
InferenceChunk::Finish { .. } => "finish",
}
}
/// The backend content, present only on text chunks.
#[getter]
fn content(&self) -> Option<&str> {
match &self.inner {
InferenceChunk::Text { content, .. } => Some(content),
InferenceChunk::Ready { .. }
| InferenceChunk::Token { .. }
| InferenceChunk::Finish { .. } => None,
}
}
/// The backend token id, present only on token chunks.
#[getter]
fn token_id(&self) -> Option<u32> {
match &self.inner {
InferenceChunk::Token { token_id } => Some(*token_id),
InferenceChunk::Ready { .. }
| InferenceChunk::Text { .. }
| InferenceChunk::Finish { .. } => None,
}
}
/// The backend finish reason, present only on finish chunks.
#[getter]
fn finish_reason(&self) -> Option<PyInferenceFinishReason> {
match &self.inner {
InferenceChunk::Finish { finish_reason, .. } => {
Some(PyInferenceFinishReason::from_core(*finish_reason))
}
InferenceChunk::Ready { .. }
| InferenceChunk::Text { .. }
| InferenceChunk::Token { .. } => None,
}
}
/// The prompt usage snapshot, present only on ready chunks.
#[getter]
fn prompt_usage(&self) -> Option<PyPromptUsage> {
match &self.inner {
InferenceChunk::Ready { prompt_usage, .. } => Some(PyPromptUsage {
inner: *prompt_usage,
}),
InferenceChunk::Text { .. }
| InferenceChunk::Token { .. }
| InferenceChunk::Finish { .. } => None,
}
}
/// The tokens this chunk's content accounts for, present only on text
/// chunks.
#[getter]
fn content_tokens(&self) -> Option<usize> {
match &self.inner {
InferenceChunk::Text { content_tokens, .. } => Some(*content_tokens),
InferenceChunk::Ready { .. }
| InferenceChunk::Token { .. }
| InferenceChunk::Finish { .. } => None,
}
}
/// The backend fingerprint, present only on ready chunks.
#[getter]
fn system_fingerprint(&self) -> Option<&str> {
match &self.inner {
InferenceChunk::Ready {
system_fingerprint, ..
} => system_fingerprint.as_deref(),
InferenceChunk::Text { .. }
| InferenceChunk::Token { .. }
| InferenceChunk::Finish { .. } => None,
}
}
}

View File

@ -0,0 +1,143 @@
//! Conversion between Python objects and JSON values.
use pyo3::exceptions::{PyOverflowError, PyRecursionError, PyTypeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple};
use serde_json::{Map, Number, Value};
/// Maximum number of nested Python containers converted into one JSON value.
const MAX_JSON_DEPTH: usize = 128;
/// Convert a Python object into a JSON value.
///
/// Accepts `None`, `bool`, `int`, `float`, `str`, `list`, `tuple`, and `dict`
/// with string keys.
///
/// # Errors
///
/// Raises `TypeError` for unsupported types, `OverflowError` for an integer
/// outside the JSON number range, and `ValueError` for a non-finite float or a
/// circular reference. More than 128 nested containers raises `RecursionError`.
pub(crate) fn python_to_json(value: &Bound<'_, PyAny>) -> PyResult<Value> {
python_to_json_with_parents(value, &mut Vec::new())
}
fn python_to_json_with_parents(
value: &Bound<'_, PyAny>,
parents: &mut Vec<*mut pyo3::ffi::PyObject>,
) -> PyResult<Value> {
if value.is_none() {
return Ok(Value::Null);
}
if let Ok(boolean) = value.cast::<PyBool>() {
return Ok(Value::Bool(boolean.is_true()));
}
if let Ok(text) = value.cast::<PyString>() {
return Ok(Value::String(text.to_str()?.to_owned()));
}
if let Ok(integer) = value.cast::<PyInt>() {
if let Ok(number) = integer.extract::<i64>() {
return Ok(Value::Number(Number::from(number)));
}
if let Ok(number) = integer.extract::<u64>() {
return Ok(Value::Number(Number::from(number)));
}
return Err(PyOverflowError::new_err(
"integer is outside the JSON number range",
));
}
if let Ok(float) = value.cast::<PyFloat>() {
let number = float.value();
return Number::from_f64(number)
.map(Value::Number)
.ok_or_else(|| PyValueError::new_err(format!("{number} is not a finite JSON number")));
}
let pointer = value.as_ptr();
if parents.contains(&pointer) {
return Err(PyValueError::new_err("circular reference in JSON input"));
}
if parents.len() >= MAX_JSON_DEPTH {
return Err(PyRecursionError::new_err(format!(
"JSON input exceeds the maximum nesting depth of {MAX_JSON_DEPTH}"
)));
}
parents.push(pointer);
let result = python_container_to_json(value, parents);
parents.pop();
result
}
fn python_container_to_json(
value: &Bound<'_, PyAny>,
parents: &mut Vec<*mut pyo3::ffi::PyObject>,
) -> PyResult<Value> {
if let Ok(list) = value.cast::<PyList>() {
return Ok(Value::Array(
list.iter()
.map(|item| python_to_json_with_parents(&item, parents))
.collect::<PyResult<_>>()?,
));
}
if let Ok(tuple) = value.cast::<PyTuple>() {
return Ok(Value::Array(
tuple
.iter()
.map(|item| python_to_json_with_parents(&item, parents))
.collect::<PyResult<_>>()?,
));
}
if let Ok(dict) = value.cast::<PyDict>() {
let mut entries = Map::new();
for (key, item) in dict.iter() {
let key = key
.cast_into::<PyString>()
.map_err(|_| PyTypeError::new_err("JSON object keys must be str"))?;
entries.insert(
key.to_str()?.to_owned(),
python_to_json_with_parents(&item, parents)?,
);
}
return Ok(Value::Object(entries));
}
Err(PyTypeError::new_err(format!(
"{} is not a JSON value",
value.get_type().name()?
)))
}
/// Convert a JSON value into a Python object.
pub(crate) fn json_to_python(py: Python<'_>, value: &Value) -> PyResult<Py<PyAny>> {
Ok(match value {
Value::Null => py.None(),
Value::Bool(boolean) => boolean.into_pyobject(py)?.to_owned().into_any().unbind(),
Value::Number(number) => {
if let Some(integer) = number.as_i64() {
integer.into_pyobject(py)?.into_any().unbind()
} else if let Some(integer) = number.as_u64() {
integer.into_pyobject(py)?.into_any().unbind()
} else {
number
.as_f64()
.unwrap_or_default()
.into_pyobject(py)?
.into_any()
.unbind()
}
}
Value::String(text) => PyString::new(py, text).into_any().unbind(),
Value::Array(items) => {
let list = PyList::empty(py);
for item in items {
list.append(json_to_python(py, item)?)?;
}
list.into_any().unbind()
}
Value::Object(entries) => {
let dict = PyDict::new(py);
for (key, item) in entries {
dict.set_item(key, json_to_python(py, item)?)?;
}
dict.into_any().unbind()
}
})
}

View File

@ -0,0 +1,114 @@
//! Python bindings for Rust request, rendering, image, and response components.
//!
//! Bindings adapt Python values and ownership to the corresponding Rust types.
use deepseek_recipe_core::multimodal::{IMAGE_SPECIAL_TOKEN, IMAGE_SPECIAL_TOKEN_ID};
use deepseek_recipe_encoding::v4::{
ASSISTANT_SP_TOKEN, BOS_TOKEN, DSML_SP_TOKEN, EOS_TOKEN, LATEST_REMINDER_SP_TOKEN,
SYSTEM_SP_TOKEN, THINKING_END_TOKEN, THINKING_START_TOKEN, USER_SP_TOKEN,
};
use pyo3::prelude::*;
use crate::conversation::{
PyAssistantMessage, PyBytesImageSource, PyConversation, PyDataUrlImageSource, PyImageSource,
PyLatestReminderMessage, PyMessage, PyRenderedPrompt, PySystemMessage, PyToolCall,
PyToolDefinition, PyToolMessage, PyUrlImageSource, PyUserMessage,
};
use crate::encoding::{PyDeepseekV4Encoding, PyDeepseekV41Encoding};
use crate::error::ConversionError;
use crate::image::{
CalcResizeError, ImageError, PyImageInfo, PyImageQuota, PyImageResolver, PyMultiModalData,
PyOpenCvImagePreprocessor, PyPreprocessOptions, PyReqwestImageFetcher,
};
use crate::inference::{PyInferenceChunk, PyInferenceFinishReason, PyPromptUsage};
use crate::parsing::{PyParsingOptions, PyReasoningStage};
use crate::request::{
PyChatCompletionRequest, PyConversationRequest, PyConversionOptions, PyInferenceOptions,
PyMessagesRequest, PyResponsesRequest, PyWebSearchBehavior,
};
use crate::response::{
PyChatCompletionChunk, PyChatCompletionChunkGenerator, PyChatCompletionResponse,
PyMessagesChunkGenerator, PyMessagesResponse, PyMessagesStreamEvent, PyResponsesChunkGenerator,
PyResponsesResponse, PyResponsesStreamEvent, PyStreamProcessor,
};
use crate::tokenizer::PyTokenizer;
mod conversation;
mod encoding;
mod error;
mod image;
mod inference;
mod json;
mod parsing;
mod request;
mod response;
mod tokenizer;
#[pymodule]
fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
m.add("BOS_TOKEN", BOS_TOKEN)?;
m.add("EOS_TOKEN", EOS_TOKEN)?;
m.add("SYSTEM_SP_TOKEN", SYSTEM_SP_TOKEN)?;
m.add("USER_SP_TOKEN", USER_SP_TOKEN)?;
m.add("ASSISTANT_SP_TOKEN", ASSISTANT_SP_TOKEN)?;
m.add("LATEST_REMINDER_SP_TOKEN", LATEST_REMINDER_SP_TOKEN)?;
m.add("THINKING_START_TOKEN", THINKING_START_TOKEN)?;
m.add("THINKING_END_TOKEN", THINKING_END_TOKEN)?;
m.add("DSML_SP_TOKEN", DSML_SP_TOKEN)?;
m.add("IMAGE_SPECIAL_TOKEN", IMAGE_SPECIAL_TOKEN)?;
m.add("IMAGE_SPECIAL_TOKEN_ID", IMAGE_SPECIAL_TOKEN_ID)?;
m.add("ConversionError", m.py().get_type::<ConversionError>())?;
m.add("ImageError", m.py().get_type::<ImageError>())?;
m.add("CalcResizeError", m.py().get_type::<CalcResizeError>())?;
m.add_class::<PyChatCompletionRequest>()?;
m.add_class::<PyResponsesRequest>()?;
m.add_class::<PyMessagesRequest>()?;
m.add_class::<PyConversionOptions>()?;
m.add_class::<PyWebSearchBehavior>()?;
m.add_class::<PyConversationRequest>()?;
m.add_class::<PyInferenceOptions>()?;
m.add_class::<PyDeepseekV4Encoding>()?;
m.add_class::<PyDeepseekV41Encoding>()?;
m.add_class::<PyImageInfo>()?;
m.add_class::<PyMultiModalData>()?;
m.add_class::<PyImageQuota>()?;
m.add_class::<PyPreprocessOptions>()?;
m.add_class::<PyReqwestImageFetcher>()?;
m.add_class::<PyOpenCvImagePreprocessor>()?;
m.add_class::<PyImageResolver>()?;
m.add_class::<PyInferenceChunk>()?;
m.add_class::<PyPromptUsage>()?;
m.add_class::<PyInferenceFinishReason>()?;
m.add_class::<PyReasoningStage>()?;
m.add_class::<PyParsingOptions>()?;
m.add_class::<PyChatCompletionChunkGenerator>()?;
m.add_class::<PyResponsesChunkGenerator>()?;
m.add_class::<PyMessagesChunkGenerator>()?;
m.add_class::<PyChatCompletionChunk>()?;
m.add_class::<PyResponsesStreamEvent>()?;
m.add_class::<PyMessagesStreamEvent>()?;
m.add_class::<PyChatCompletionResponse>()?;
m.add_class::<PyResponsesResponse>()?;
m.add_class::<PyMessagesResponse>()?;
m.add_class::<PyTokenizer>()?;
m.add_class::<PyStreamProcessor>()?;
m.add_class::<PyConversation>()?;
m.add_class::<PyRenderedPrompt>()?;
m.add_class::<PyMessage>()?;
m.add_class::<PySystemMessage>()?;
m.add_class::<PyUserMessage>()?;
m.add_class::<PyAssistantMessage>()?;
m.add_class::<PyToolMessage>()?;
m.add_class::<PyLatestReminderMessage>()?;
m.add_class::<PyToolCall>()?;
m.add_class::<PyToolDefinition>()?;
m.add_class::<PyImageSource>()?;
m.add_class::<PyDataUrlImageSource>()?;
m.add_class::<PyUrlImageSource>()?;
m.add_class::<PyBytesImageSource>()?;
Ok(())
}

View File

@ -0,0 +1,130 @@
//! Python representations of Rust stream parsing options.
use deepseek_recipe::stream::state_machine::{ParsingOptions, ReasoningStage};
use pyo3::prelude::*;
/// Position within reasoning content when parsing begins.
#[derive(Clone, Copy, PartialEq, Eq)]
#[pyclass(
name = "ReasoningStage",
module = "deepseek_recipe._native",
eq,
frozen
)]
pub(crate) enum PyReasoningStage {
Start,
Reasoning,
Content,
}
impl PyReasoningStage {
fn to_core(self) -> ReasoningStage {
match self {
Self::Start => ReasoningStage::Start,
Self::Reasoning => ReasoningStage::Reasoning,
Self::Content => ReasoningStage::Content,
}
}
fn from_core(stage: ReasoningStage) -> Self {
match stage {
ReasoningStage::Start => Self::Start,
ReasoningStage::Reasoning => Self::Reasoning,
ReasoningStage::Content => Self::Content,
}
}
}
#[pymethods]
impl PyReasoningStage {
/// Whether the initial stage contains reasoning content.
fn start_from_reasoning(&self) -> bool {
self.to_core().start_from_reasoning()
}
}
/// Settings passed directly to the Rust stream parser.
#[pyclass(name = "ParsingOptions", module = "deepseek_recipe._native")]
pub(crate) struct PyParsingOptions {
pub(crate) inner: ParsingOptions,
}
impl PyParsingOptions {
pub(crate) fn from_core(inner: ParsingOptions) -> Self {
Self { inner }
}
}
#[pymethods]
impl PyParsingOptions {
#[new]
#[pyo3(signature = (*, parse_tool_calls = true, tool_call_initial_stage = false,
parse_json_output = false, reasoning_initial_stage = None, stop_sequences = None))]
fn new(
parse_tool_calls: bool,
tool_call_initial_stage: bool,
parse_json_output: bool,
reasoning_initial_stage: Option<PyRef<'_, PyReasoningStage>>,
stop_sequences: Option<Vec<String>>,
) -> Self {
Self::from_core(ParsingOptions {
parse_tool_calls,
tool_call_initial_stage,
parse_json_output,
reasoning_initial_stage: reasoning_initial_stage.map(|stage| (*stage).to_core()),
stop_sequences: stop_sequences.unwrap_or_default(),
})
}
#[getter]
fn parse_tool_calls(&self) -> bool {
self.inner.parse_tool_calls
}
#[setter]
fn set_parse_tool_calls(&mut self, value: bool) {
self.inner.parse_tool_calls = value;
}
#[getter]
fn tool_call_initial_stage(&self) -> bool {
self.inner.tool_call_initial_stage
}
#[setter]
fn set_tool_call_initial_stage(&mut self, value: bool) {
self.inner.tool_call_initial_stage = value;
}
#[getter]
fn parse_json_output(&self) -> bool {
self.inner.parse_json_output
}
#[setter]
fn set_parse_json_output(&mut self, value: bool) {
self.inner.parse_json_output = value;
}
#[getter]
fn reasoning_initial_stage(&self) -> Option<PyReasoningStage> {
self.inner
.reasoning_initial_stage
.map(PyReasoningStage::from_core)
}
#[setter]
fn set_reasoning_initial_stage(&mut self, value: Option<PyRef<'_, PyReasoningStage>>) {
self.inner.reasoning_initial_stage = value.map(|stage| (*stage).to_core());
}
#[getter]
fn stop_sequences(&self) -> Vec<String> {
self.inner.stop_sequences.clone()
}
#[setter]
fn set_stop_sequences(&mut self, value: Vec<String>) {
self.inner.stop_sequences = value;
}
}

View File

@ -0,0 +1,296 @@
//! Python representations of Rust protocol requests and conversion results.
use std::collections::HashSet;
use deepseek_recipe::anthropic::MessagesRequest;
use deepseek_recipe::openai::{ChatCompletionRequest, ResponsesRequest};
use deepseek_recipe::request::{
ConversationRequest, ConversionOptions, InferenceOptions, ProtocolRequest, WebSearchBehavior,
};
use pyo3::exceptions::{PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::{PyBytes, PyString};
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::conversation::PyConversation;
use crate::error::request_error;
use crate::json::python_to_json;
use crate::parsing::PyParsingOptions;
use crate::response::{
PyChatCompletionChunkGenerator, PyMessagesChunkGenerator, PyResponsesChunkGenerator,
};
/// Rust conversion behavior for supported web-search declarations and content.
#[derive(Clone, Copy, PartialEq, Eq)]
#[pyclass(
name = "WebSearchBehavior",
module = "deepseek_recipe._native",
eq,
frozen
)]
pub(crate) enum PyWebSearchBehavior {
Ignore,
Reject,
}
impl PyWebSearchBehavior {
fn to_core(self) -> WebSearchBehavior {
match self {
Self::Ignore => WebSearchBehavior::Ignore,
Self::Reject => WebSearchBehavior::Reject,
}
}
fn from_core(behavior: WebSearchBehavior) -> PyResult<Self> {
match behavior {
WebSearchBehavior::Ignore => Ok(Self::Ignore),
WebSearchBehavior::Reject => Ok(Self::Reject),
other => Err(PyValueError::new_err(format!(
"unsupported web search behavior: {other:?}"
))),
}
}
}
/// Defaults passed to Rust protocol request conversion.
#[pyclass(name = "ConversionOptions", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyConversionOptions {
inner: ConversionOptions,
}
#[pymethods]
impl PyConversionOptions {
/// Construct conversion defaults.
///
/// Every argument defaults to the Rust default: thinking enabled, Responses
/// `web_search` ignored, and Messages `web_search` rejected.
#[new]
#[pyo3(signature = (
*,
default_thinking_mode=true,
responses_web_search=PyWebSearchBehavior::Ignore,
messages_web_search=PyWebSearchBehavior::Reject
))]
fn new(
default_thinking_mode: bool,
responses_web_search: PyWebSearchBehavior,
messages_web_search: PyWebSearchBehavior,
) -> Self {
Self {
inner: ConversionOptions::new()
.with_default_thinking_mode(default_thinking_mode)
.with_responses_web_search(responses_web_search.to_core())
.with_messages_web_search(messages_web_search.to_core()),
}
}
#[getter]
fn default_thinking_mode(&self) -> bool {
self.inner.default_thinking_mode
}
fn with_default_thinking_mode(&self, default_thinking_mode: bool) -> Self {
Self {
inner: self.inner.with_default_thinking_mode(default_thinking_mode),
}
}
/// Responses web-search behavior, defaulting to `Ignore`.
#[getter]
fn responses_web_search(&self) -> PyResult<PyWebSearchBehavior> {
PyWebSearchBehavior::from_core(self.inner.responses_web_search)
}
/// Messages web-search behavior, defaulting to `Reject`.
#[getter]
fn messages_web_search(&self) -> PyResult<PyWebSearchBehavior> {
PyWebSearchBehavior::from_core(self.inner.messages_web_search)
}
/// Construct options with the supplied Responses web-search behavior.
fn with_responses_web_search(&self, responses_web_search: PyWebSearchBehavior) -> Self {
Self {
inner: self
.inner
.with_responses_web_search(responses_web_search.to_core()),
}
}
/// Construct options with the supplied Messages web-search behavior.
fn with_messages_web_search(&self, messages_web_search: PyWebSearchBehavior) -> Self {
Self {
inner: self
.inner
.with_messages_web_search(messages_web_search.to_core()),
}
}
}
/// Inference parameters extracted by Rust protocol conversion.
#[pyclass(
name = "InferenceOptions",
module = "deepseek_recipe._native",
frozen,
get_all
)]
pub(crate) struct PyInferenceOptions {
max_tokens: Option<u32>,
temperature: Option<f32>,
top_p: Option<f32>,
thinking_budget_tokens: Option<u64>,
disable_parallel_tool_use: Option<bool>,
}
impl PyInferenceOptions {
fn from_core(options: &InferenceOptions) -> Self {
Self {
max_tokens: options.max_tokens,
temperature: options.temperature,
top_p: options.top_p,
thinking_budget_tokens: options.thinking_budget_tokens,
disable_parallel_tool_use: options.disable_parallel_tool_use,
}
}
}
/// A Rust conversation request with inference and parsing options.
#[pyclass(
name = "ConversationRequest",
module = "deepseek_recipe._native",
frozen
)]
pub(crate) struct PyConversationRequest {
pub(crate) inner: ConversationRequest,
}
#[pymethods]
impl PyConversationRequest {
#[new]
fn new(conversation: &PyConversation) -> Self {
Self {
inner: ConversationRequest::new(conversation.inner.clone()),
}
}
#[getter]
fn conversation(&self) -> PyConversation {
PyConversation::from_core(self.inner.conversation.clone())
}
#[getter]
fn inference_options(&self) -> PyInferenceOptions {
PyInferenceOptions::from_core(&self.inner.inference_options)
}
#[getter]
fn parsing_options(&self) -> PyParsingOptions {
PyParsingOptions::from_core(self.inner.parsing_options.clone())
}
#[getter]
fn model(&self) -> Option<&str> {
self.inner.model.as_deref()
}
#[getter]
fn stream(&self) -> bool {
self.inner.stream
}
}
macro_rules! protocol_request {
($python:ident, $rust:ty, $name:literal, $generator:ty, $($extra:item)*) => {
/// A Rust protocol request deserialized from a Python dictionary or JSON.
///
/// Conversion consumes the request even if conversion fails. Keep caller
/// metadata from the original body; `include_usage` is exposed separately
/// for Chat Completions and must be read before calling `convert`.
#[pyclass(name = $name, module = "deepseek_recipe._native")]
pub(crate) struct $python {
inner: Option<$rust>,
}
#[pymethods]
impl $python {
#[new]
fn new(body: &Bound<'_, PyAny>) -> PyResult<Self> {
Ok(Self {
inner: Some(deserialize(body)?),
})
}
fn convert(
&mut self,
py: Python<'_>,
options: &PyConversionOptions,
) -> PyResult<PyConversationRequest> {
let request = self.inner.take().ok_or_else(|| {
PyRuntimeError::new_err("protocol request has been consumed")
})?;
let options = options.inner;
let inner = py
.detach(|| request.convert(options))
.map_err(|error| request_error(py, error))?;
Ok(PyConversationRequest { inner })
}
#[staticmethod]
fn chunk_generator(
request: &PyConversationRequest,
id: String,
model: String,
) -> $generator {
<$generator>::from_core(<$rust>::chunk_generator(&request.inner, id, model))
}
$($extra)*
}
};
}
protocol_request!(
PyChatCompletionRequest,
ChatCompletionRequest,
"ChatCompletionRequest",
PyChatCompletionChunkGenerator,
fn include_usage(&self) -> PyResult<bool> {
self.inner
.as_ref()
.map(ChatCompletionRequest::include_usage)
.ok_or_else(|| PyRuntimeError::new_err("protocol request has been consumed"))
}
);
protocol_request!(
PyResponsesRequest,
ResponsesRequest,
"ResponsesRequest",
PyResponsesChunkGenerator,
fn custom_tool_names(&self) -> PyResult<HashSet<String>> {
self.inner
.as_ref()
.map(ResponsesRequest::custom_tool_names)
.ok_or_else(|| PyRuntimeError::new_err("protocol request has been consumed"))
}
);
protocol_request!(
PyMessagesRequest,
MessagesRequest,
"MessagesRequest",
PyMessagesChunkGenerator,
);
/// Deserialize the Rust request using Python-to-JSON representation conversion.
fn deserialize<T: DeserializeOwned>(body: &Bound<'_, PyAny>) -> PyResult<T> {
let value: Value = if let Ok(text) = body.cast::<PyString>() {
serde_json::from_str(text.to_str()?)
.map_err(|error| PyValueError::new_err(format!("invalid JSON request: {error}")))?
} else if let Ok(bytes) = body.cast::<PyBytes>() {
serde_json::from_slice(bytes.as_bytes())
.map_err(|error| PyValueError::new_err(format!("invalid JSON request: {error}")))?
} else {
python_to_json(body)?
};
serde_json::from_value(value)
.map_err(|error| PyValueError::new_err(format!("invalid request body: {error}")))
}

View File

@ -0,0 +1,480 @@
//! Python bindings for Rust response generators, accumulation, and streams.
use std::collections::HashSet;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::task::Poll;
use deepseek_recipe::anthropic::messages::response::{
MessagesChunkGenerator, MessagesResponse, MessagesStreamEvent,
};
use deepseek_recipe::openai::chat_completion::response::{
ChatCompletionChunk, ChatCompletionChunkGenerator, ChatCompletionResponse,
};
use deepseek_recipe::openai::responses::response::{
ResponsesChunkGenerator, ResponsesResponse, ResponsesStreamEvent,
};
use deepseek_recipe::response::ProtocolResponse;
use deepseek_recipe::stream::state_machine::ParsingOptions;
use deepseek_recipe::stream::{ChunkGenerator, InferenceChunk, StreamError, StreamProcessor};
use deepseek_recipe::util::append_delta::AppendDelta;
use futures::Stream;
use futures::channel::mpsc;
use futures::executor::block_on;
use futures::future::poll_fn;
use pyo3::exceptions::{PyRuntimeError, PyTypeError};
use pyo3::prelude::*;
use serde::Serialize;
use tokenizers::Tokenizer;
use crate::inference::PyInferenceChunk;
use crate::parsing::PyParsingOptions;
use crate::tokenizer::PyTokenizer;
/// A Rust Chat Completions generator consumed by a stream processor.
#[pyclass(
name = "ChatCompletionChunkGenerator",
module = "deepseek_recipe._native"
)]
pub(crate) struct PyChatCompletionChunkGenerator {
inner: Option<ChatCompletionChunkGenerator>,
}
impl PyChatCompletionChunkGenerator {
pub(crate) fn from_core(inner: ChatCompletionChunkGenerator) -> Self {
Self { inner: Some(inner) }
}
}
#[pymethods]
impl PyChatCompletionChunkGenerator {
#[new]
fn new(id: String, model: String, include_usage: bool, thinking_mode: bool) -> Self {
Self::from_core(ChatCompletionChunkGenerator::new(
id,
model,
include_usage,
thinking_mode,
))
}
/// Consume this generator and apply the Rust usage setting.
fn with_include_usage(&mut self, include_usage: bool) -> PyResult<Self> {
Ok(Self::from_core(
take_generator(&mut self.inner)?.with_include_usage(include_usage),
))
}
}
/// A Rust Responses generator consumed by a stream processor.
#[pyclass(name = "ResponsesChunkGenerator", module = "deepseek_recipe._native")]
pub(crate) struct PyResponsesChunkGenerator {
inner: Option<ResponsesChunkGenerator>,
}
impl PyResponsesChunkGenerator {
pub(crate) fn from_core(inner: ResponsesChunkGenerator) -> Self {
Self { inner: Some(inner) }
}
}
#[pymethods]
impl PyResponsesChunkGenerator {
#[new]
fn new(id: String, model: String) -> Self {
Self::from_core(ResponsesChunkGenerator::new(id, model))
}
/// Consume this generator and configure the custom tool names.
fn with_custom_tool_names(&mut self, custom_tool_names: HashSet<String>) -> PyResult<Self> {
Ok(Self::from_core(
take_generator(&mut self.inner)?.with_custom_tool_names(custom_tool_names),
))
}
}
/// A Rust Messages generator consumed by a stream processor.
#[pyclass(name = "MessagesChunkGenerator", module = "deepseek_recipe._native")]
pub(crate) struct PyMessagesChunkGenerator {
inner: Option<MessagesChunkGenerator>,
}
impl PyMessagesChunkGenerator {
pub(crate) fn from_core(inner: MessagesChunkGenerator) -> Self {
Self { inner: Some(inner) }
}
}
#[pymethods]
impl PyMessagesChunkGenerator {
#[new]
fn new(id: String, model: String, thinking_mode: bool) -> Self {
Self::from_core(MessagesChunkGenerator::new(id, model, thinking_mode))
}
/// Consume this generator and apply the Rust thinking signature setting.
fn with_signature(&mut self, signature: String) -> PyResult<Self> {
Ok(Self::from_core(
take_generator(&mut self.inner)?.with_signature(signature),
))
}
}
fn take_generator<G>(generator: &mut Option<G>) -> PyResult<G> {
generator
.take()
.ok_or_else(|| PyRuntimeError::new_err("chunk generator has been consumed"))
}
fn to_json<T: Serialize>(value: &T) -> PyResult<String> {
serde_json::to_string(value)
.map_err(|error| PyRuntimeError::new_err(format!("failed to serialize response: {error}")))
}
// Each wrapper delegates serialization and accumulation to its Rust type.
macro_rules! response_bindings {
(
$py_chunk:ident, $chunk:ident, $chunk_name:literal,
$py_response:ident, $response:ident, $response_name:literal
) => {
/// A protocol chunk consumed by response accumulation.
#[pyclass(name = $chunk_name, module = "deepseek_recipe._native")]
pub(crate) struct $py_chunk {
inner: Option<$chunk>,
}
#[pymethods]
impl $py_chunk {
/// Serialize this chunk with the Rust protocol representation.
fn to_json(&self) -> PyResult<String> {
to_json(self.as_core()?)
}
}
impl $py_chunk {
fn as_core(&self) -> PyResult<&$chunk> {
self.inner
.as_ref()
.ok_or_else(|| PyRuntimeError::new_err("protocol chunk has been consumed"))
}
}
/// A Rust protocol response with explicit accumulation.
#[pyclass(name = $response_name, module = "deepseek_recipe._native")]
pub(crate) struct $py_response {
inner: $response,
}
#[pymethods]
impl $py_response {
#[new]
fn new(
id: String,
model: String,
created: u64,
prompt_tokens: usize,
prompt_cache_hit_tokens: usize,
) -> Self {
Self {
inner: $response::new(
id,
model,
created,
prompt_tokens,
prompt_cache_hit_tokens,
),
}
}
/// Consume a chunk and delegate to Rust `AppendDelta::append`.
fn append(&mut self, chunk: &mut $py_chunk) -> PyResult<()> {
let chunk = chunk
.inner
.take()
.ok_or_else(|| PyRuntimeError::new_err("protocol chunk has been consumed"))?;
self.inner.append(chunk);
Ok(())
}
/// Serialize the current Rust response, including partial state.
fn to_json(&self) -> PyResult<String> {
to_json(&self.inner)
}
/// Return the event name supplied by the Rust protocol response.
#[staticmethod]
fn chunk_event_type(chunk: &$py_chunk) -> PyResult<Option<&'static str>> {
Ok($response::chunk_event_type(chunk.as_core()?))
}
/// Return the transport sentinel supplied by the Rust response.
#[staticmethod]
fn done_message() -> Option<String> {
$response::done_message()
}
}
};
}
response_bindings!(
PyChatCompletionChunk,
ChatCompletionChunk,
"ChatCompletionChunk",
PyChatCompletionResponse,
ChatCompletionResponse,
"ChatCompletionResponse"
);
response_bindings!(
PyResponsesStreamEvent,
ResponsesStreamEvent,
"ResponsesStreamEvent",
PyResponsesResponse,
ResponsesResponse,
"ResponsesResponse"
);
response_bindings!(
PyMessagesStreamEvent,
MessagesStreamEvent,
"MessagesStreamEvent",
PyMessagesResponse,
MessagesResponse,
"MessagesResponse"
);
enum ProtocolChunk {
ChatCompletion(ChatCompletionChunk),
Responses(ResponsesStreamEvent),
Messages(MessagesStreamEvent),
}
impl ProtocolChunk {
fn into_python(self, py: Python<'_>) -> PyResult<Py<PyAny>> {
match self {
Self::ChatCompletion(chunk) => {
Ok(Py::new(py, PyChatCompletionChunk { inner: Some(chunk) })?.into_any())
}
Self::Responses(chunk) => {
Ok(Py::new(py, PyResponsesStreamEvent { inner: Some(chunk) })?.into_any())
}
Self::Messages(chunk) => {
Ok(Py::new(py, PyMessagesStreamEvent { inner: Some(chunk) })?.into_any())
}
}
}
}
type ChunkStream = Pin<Box<dyn Stream<Item = Result<ProtocolChunk, StreamError>> + Send>>;
struct Processor {
input: Option<mpsc::Sender<InferenceChunk>>,
events: Option<ChunkStream>,
input_pending: Arc<AtomicBool>,
finished: bool,
closed: bool,
}
/// Adapt Python input to the Rust processor's inference stream.
///
/// Construction consumes the generator. Each push produces the protocol chunks
/// available before the processor needs another input. Finish closes the input
/// normally; close releases state without generating successful completion.
#[pyclass(name = "StreamProcessor", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyStreamProcessor {
state: Mutex<Processor>,
}
impl PyStreamProcessor {
fn from_generator<G>(
generator: G,
options: ParsingOptions,
tokenizer: Option<Arc<Tokenizer>>,
wrap: fn(G::Chunk) -> ProtocolChunk,
) -> Self
where
G: ChunkGenerator,
G::Chunk: Send,
{
let (sender, mut receiver) = mpsc::channel(1);
let input_pending = Arc::new(AtomicBool::new(false));
let receiver_pending = Arc::clone(&input_pending);
let input = futures::stream::poll_fn(move |cx| {
let result = Pin::new(&mut receiver).poll_next(cx);
if result.is_pending() {
receiver_pending.store(true, Ordering::Relaxed);
}
result
});
let processor = StreamProcessor::new(generator, options);
let processor = match tokenizer {
Some(tokenizer) => processor.with_tokenizer(tokenizer),
None => processor,
};
let events =
futures::StreamExt::map(processor.process(input), move |event| event.map(wrap));
Self {
state: Mutex::new(Processor {
input: Some(sender),
events: Some(Box::pin(events)),
input_pending,
finished: false,
closed: false,
}),
}
}
fn with_state<T: Send>(
&self,
py: Python<'_>,
operation: impl FnOnce(&mut Processor) -> PyResult<T> + Send,
) -> PyResult<T> {
py.detach(|| {
let mut state = self
.state
.lock()
.map_err(|_| PyRuntimeError::new_err("stream processor lock poisoned"))?;
operation(&mut state)
})
}
}
#[pymethods]
impl PyStreamProcessor {
#[new]
#[pyo3(signature = (generator, options, tokenizer = None))]
fn new(
generator: &Bound<'_, PyAny>,
options: &PyParsingOptions,
tokenizer: Option<PyRef<'_, PyTokenizer>>,
) -> PyResult<Self> {
let tokenizer = tokenizer.map(|tokenizer| tokenizer.inner.clone());
if let Ok(mut generator) =
generator.extract::<PyRefMut<'_, PyChatCompletionChunkGenerator>>()
{
Ok(Self::from_generator(
take_generator(&mut generator.inner)?,
options.inner.clone(),
tokenizer,
ProtocolChunk::ChatCompletion,
))
} else if let Ok(mut generator) =
generator.extract::<PyRefMut<'_, PyResponsesChunkGenerator>>()
{
Ok(Self::from_generator(
take_generator(&mut generator.inner)?,
options.inner.clone(),
tokenizer,
ProtocolChunk::Responses,
))
} else if let Ok(mut generator) =
generator.extract::<PyRefMut<'_, PyMessagesChunkGenerator>>()
{
Ok(Self::from_generator(
take_generator(&mut generator.inner)?,
options.inner.clone(),
tokenizer,
ProtocolChunk::Messages,
))
} else {
Err(PyTypeError::new_err("expected a protocol chunk generator"))
}
}
/// Process one inference chunk until Rust needs another input.
/// Raises `RuntimeError` after completion or close, or on a decoding failure.
fn push(&self, py: Python<'_>, chunk: &PyInferenceChunk) -> PyResult<Vec<Py<PyAny>>> {
let chunk = chunk.to_core();
self.with_state(py, |state| state.push(chunk))?
.into_iter()
.map(|chunk| chunk.into_python(py))
.collect()
}
/// Close the input normally and process its buffered output.
/// Repeated calls after completion produce no chunks. Calls after close or
/// a decoding failure raise `RuntimeError`.
fn finish(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
self.with_state(py, Processor::finish)?
.into_iter()
.map(|chunk| chunk.into_python(py))
.collect()
}
/// Whether the Rust stream reached normal completion and has not been closed.
#[getter]
fn finished(&self, py: Python<'_>) -> PyResult<bool> {
self.with_state(py, |state| Ok(state.finished && !state.closed))
}
/// Release state without producing normal completion chunks.
fn close(&self, py: Python<'_>) -> PyResult<()> {
self.with_state(py, |state| {
state.close();
Ok(())
})
}
}
impl Processor {
fn drain(&mut self) -> PyResult<Vec<ProtocolChunk>> {
let mut chunks = Vec::new();
block_on(poll_fn(|cx| {
loop {
// Only a pending input read ends this batch. Other pending
// futures retain their wakeups and continue on this executor.
self.input_pending.store(false, Ordering::Relaxed);
let event = self.events.as_mut().unwrap().as_mut().poll_next(cx);
match event {
Poll::Ready(Some(Ok(chunk))) => chunks.push(chunk),
Poll::Ready(Some(Err(error))) => {
self.close();
return Poll::Ready(Err(PyRuntimeError::new_err(error.to_string())));
}
Poll::Ready(None) => {
self.finished = true;
self.input = None;
self.events = None;
return Poll::Ready(Ok(std::mem::take(&mut chunks)));
}
Poll::Pending if self.input_pending.load(Ordering::Relaxed) => {
return Poll::Ready(Ok(std::mem::take(&mut chunks)));
}
Poll::Pending => return Poll::Pending,
}
}
}))
}
fn push(&mut self, chunk: InferenceChunk) -> PyResult<Vec<ProtocolChunk>> {
if self.closed {
return Err(PyRuntimeError::new_err("stream processor is closed"));
}
if self.finished {
return Err(PyRuntimeError::new_err("stream processor has finished"));
}
if let Err(error) = self.input.as_mut().unwrap().try_send(chunk) {
self.close();
return Err(PyRuntimeError::new_err(format!(
"failed to submit inference chunk: {error}"
)));
}
self.drain()
}
fn finish(&mut self) -> PyResult<Vec<ProtocolChunk>> {
if self.closed {
return Err(PyRuntimeError::new_err("stream processor is closed"));
}
if self.finished {
return Ok(Vec::new());
}
self.input = None;
self.drain()
}
fn close(&mut self) {
self.closed = true;
self.input = None;
self.events = None;
}
}

View File

@ -0,0 +1,69 @@
//! Bindings for tokenizers used to decode token-id inference chunks.
use std::sync::Arc;
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::*;
use tokenizers::{FromPretrainedParameters, Tokenizer};
/// A HuggingFace tokenizer loaded from a `tokenizer.json` file.
///
/// A stream processor uses it to decode token-id inference chunks.
#[pyclass(name = "Tokenizer", module = "deepseek_recipe._native", frozen)]
pub(crate) struct PyTokenizer {
pub(crate) inner: Arc<Tokenizer>,
}
impl PyTokenizer {
fn from_tokenizer(inner: Tokenizer) -> Self {
Self {
inner: Arc::new(inner),
}
}
}
#[pymethods]
impl PyTokenizer {
/// Load a tokenizer from a `tokenizer.json` file.
#[staticmethod]
fn from_file(path: &str) -> PyResult<Self> {
Tokenizer::from_file(path)
.map(Self::from_tokenizer)
.map_err(|error| PyRuntimeError::new_err(error.to_string()))
}
/// Load a tokenizer from the contents of a `tokenizer.json` file.
#[staticmethod]
fn from_str(text: &str) -> PyResult<Self> {
Tokenizer::from_bytes(text)
.map(Self::from_tokenizer)
.map_err(|error| PyRuntimeError::new_err(error.to_string()))
}
/// Download and cache `tokenizer.json` from the HuggingFace Hub.
#[staticmethod]
#[pyo3(signature = (identifier, revision = None, token = None))]
#[allow(deprecated)]
fn from_pretrained(
py: Python<'_>,
identifier: &str,
revision: Option<String>,
token: Option<String>,
) -> PyResult<Self> {
let mut params = FromPretrainedParameters::default();
if let Some(revision) = revision {
params.revision = revision;
}
params.token = token;
py.detach(|| Tokenizer::from_pretrained(identifier, Some(params)))
.map(Self::from_tokenizer)
.map_err(|error| PyRuntimeError::new_err(error.to_string()))
}
/// Encode text into token ids without added special tokens.
fn encode(&self, py: Python<'_>, text: &str) -> PyResult<Vec<u32>> {
py.detach(|| self.inner.encode(text, false))
.map(|encoding| encoding.get_ids().to_vec())
.map_err(|error| PyRuntimeError::new_err(error.to_string()))
}
}

View File

@ -0,0 +1,335 @@
"""Smoke tests for the Rust conversion, rendering, response, and image bindings."""
import json
from pathlib import Path
import pytest
from deepseek_recipe import (
IMAGE_SPECIAL_TOKEN,
THINKING_END_TOKEN,
ChatCompletionRequest,
ChatCompletionResponse,
Conversation,
ConversionError,
ConversionOptions,
DeepseekV41Encoding,
ImageQuota,
ImageResolver,
InferenceChunk,
InferenceFinishReason,
MessagesRequest,
OpenCvImagePreprocessor,
PromptUsage,
ReqwestImageFetcher,
ResponsesRequest,
StreamProcessor,
Tokenizer,
ToolDefinition,
UserMessage,
WebSearchBehavior,
)
REQUEST = {
"model": "smoke-model",
"messages": [{"role": "user", "content": "hi"}],
}
TOKENIZER_PATH = (
Path(__file__).resolve().parents[2] / "static" / "tokenizers" / "v41" / "tokenizer.json"
)
def test_rendering():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
encoding = DeepseekV41Encoding()
rendered = encoding.render_conversation(Conversation(messages=[UserMessage("hi")]))
assert converted.conversation.messages[0].content == "hi"
assert "hi" in rendered.prompt
assert rendered.image_sources == []
assert encoding.render_conversation(converted.conversation).prompt == rendered.prompt
def test_encoding_with_tokenizer():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
with pytest.raises(RuntimeError, match="no tokenizer"):
DeepseekV41Encoding().encode(converted.conversation)
encoding = DeepseekV41Encoding().with_tokenizer(tokenizer)
prompt_ids = encoding.encode(converted.conversation)
assert prompt_ids == tokenizer.encode(encoding.render_conversation(converted.conversation).prompt)
def test_web_search_conversion_options():
defaults = ConversionOptions()
constructed = ConversionOptions(
default_thinking_mode=False,
responses_web_search=WebSearchBehavior.Reject,
messages_web_search=WebSearchBehavior.Ignore,
)
configured = (
defaults.with_default_thinking_mode(False)
.with_responses_web_search(WebSearchBehavior.Reject)
.with_messages_web_search(WebSearchBehavior.Ignore)
)
assert defaults.default_thinking_mode
assert defaults.responses_web_search == WebSearchBehavior.Ignore
assert defaults.messages_web_search == WebSearchBehavior.Reject
for options in (constructed, configured):
assert not options.default_thinking_mode
assert options.responses_web_search == WebSearchBehavior.Reject
assert options.messages_web_search == WebSearchBehavior.Ignore
responses = {
"model": "smoke-model",
"input": "hi",
"tools": [{"type": "web_search"}],
}
messages = {
**REQUEST,
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
}
assert ResponsesRequest(responses).convert(defaults).conversation.tools == []
with pytest.raises(ConversionError, match="server tools are not supported"):
MessagesRequest(messages).convert(defaults)
converted = MessagesRequest(messages).convert(configured)
assert converted.conversation.tools == []
assert not converted.conversation.thinking_mode
with pytest.raises(ConversionError, match="Server tools are not supported"):
ResponsesRequest(responses).convert(configured)
def test_custom_inference():
converted = ChatCompletionRequest({**REQUEST, "temperature": 0.25}).convert(
ConversionOptions()
)
generator = ChatCompletionRequest.chunk_generator(converted, "smoke-response", "smoke-model")
processor = StreamProcessor(generator, converted.parsing_options)
response = ChatCompletionResponse("smoke-response", "smoke-model", 0, 0, 0)
try:
assert converted.inference_options.temperature == 0.25
for chunk in (
InferenceChunk.ready(prompt_usage=PromptUsage(prompt_tokens=4)),
InferenceChunk.text(
THINKING_END_TOKEN + "Custom answer", content_tokens=2
),
InferenceChunk.finish(InferenceFinishReason.Stop),
):
for output in processor.push(chunk):
response.append(output)
for output in processor.finish():
response.append(output)
body = json.loads(response.to_json())
assert processor.finished
assert body["id"] == "smoke-response"
assert body["choices"][0]["message"]["content"] == "Custom answer"
assert body["usage"]["prompt_tokens"] == 4
assert body["usage"]["completion_tokens"] == 2
finally:
processor.close()
def test_token_chunks():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "token-response", "smoke-model")
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
token_ids = tokenizer.encode(THINKING_END_TOKEN + "Custom answer")
processor = StreamProcessor(generator, converted.parsing_options, tokenizer)
response = ChatCompletionResponse("token-response", "smoke-model", 0, 0, 0)
try:
for chunk in (
InferenceChunk.ready(),
*(InferenceChunk.token(token_id) for token_id in token_ids),
InferenceChunk.finish(InferenceFinishReason.Stop),
):
for output in processor.push(chunk):
response.append(output)
for output in processor.finish():
response.append(output)
body = json.loads(response.to_json())
assert body["choices"][0]["message"]["content"] == "Custom answer"
assert body["usage"]["completion_tokens"] == len(token_ids)
finally:
processor.close()
def test_split_character_tokens():
# A character outside the vocabulary decodes from several byte tokens; the
# ids buffered while it was incomplete still count as completion tokens.
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "token-response", "smoke-model")
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
token_ids = tokenizer.encode(THINKING_END_TOKEN + "🦀")
assert len(token_ids) > 2
processor = StreamProcessor(generator, converted.parsing_options, tokenizer)
response = ChatCompletionResponse("token-response", "smoke-model", 0, 0, 0)
try:
for chunk in (
InferenceChunk.ready(),
*(InferenceChunk.token(token_id) for token_id in token_ids),
InferenceChunk.finish(InferenceFinishReason.Stop),
):
for output in processor.push(chunk):
response.append(output)
for output in processor.finish():
response.append(output)
body = json.loads(response.to_json())
assert body["choices"][0]["message"]["content"] == "🦀"
assert body["usage"]["completion_tokens"] == len(token_ids)
finally:
processor.close()
def test_undecodable_token_ignored():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "token-response", "smoke-model")
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
processor = StreamProcessor(generator, converted.parsing_options, tokenizer)
response = ChatCompletionResponse("token-response", "smoke-model", 0, 0, 0)
try:
for chunk in (
InferenceChunk.ready(),
InferenceChunk.token(999_999_999),
InferenceChunk.text(THINKING_END_TOKEN + "Hi", content_tokens=1),
InferenceChunk.finish(InferenceFinishReason.Stop),
):
for output in processor.push(chunk):
response.append(output)
for output in processor.finish():
response.append(output)
body = json.loads(response.to_json())
assert body["choices"][0]["message"]["content"] == "Hi"
assert body["usage"]["completion_tokens"] == 1
finally:
processor.close()
def test_token_chunk_without_tokenizer_errors():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "token-response", "smoke-model")
processor = StreamProcessor(generator, converted.parsing_options)
try:
with pytest.raises(RuntimeError, match="without a tokenizer"):
processor.push(InferenceChunk.token(1))
finally:
processor.close()
def test_tokenizer_from_str():
# Loading from the file's contents gives the same ids as loading the file.
text = TOKENIZER_PATH.read_text()
assert Tokenizer.from_str(text).encode("Custom answer") == Tokenizer.from_file(
str(TOKENIZER_PATH)
).encode("Custom answer")
def test_tokenizer_from_str_errors():
with pytest.raises(RuntimeError):
Tokenizer.from_str("not json")
def test_foreign_tokenizer_errors():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "py-response", "smoke-model")
with pytest.raises(TypeError):
StreamProcessor(generator, converted.parsing_options, object())
def test_image_resolution():
# One 50-by-40 RGB PNG exercises the native image decoder and WebP encoder.
png = (
"iVBORw0KGgoAAAANSUhEUgAAADIAAAAoCAIAAAAzED4bAAAAPElEQVR4nO3OUQkAIBBA"
"sQvht/2jGMsYPmSwAJu1T9A8H2hpaWlpaTVoaWkVaGlpFWhpaRVoaWkVaH3QuqTYo"
"YpppISkAAAAAElFTkSuQmCC"
)
converted = ChatCompletionRequest({
**REQUEST,
"messages": [{"role": "user", "content": [{
"type": "image_url",
"image_url": {"url": "data:image/png;base64," + png},
}]}],
}).convert(ConversionOptions())
rendered = DeepseekV41Encoding().render_conversation(converted.conversation)
resolver = ImageResolver(ReqwestImageFetcher(), OpenCvImagePreprocessor())
multimodal = resolver.resolve(rendered.image_sources, ImageQuota())
assert IMAGE_SPECIAL_TOKEN in rendered.prompt
(image,) = multimodal.images
assert image.width > 0 and image.height > 0
assert image.data[:4] == b"RIFF" and image.data[8:12] == b"WEBP"
assert multimodal.image_token_adjustment() > 0
def test_circular_dict_rejected():
# A self-referencing container must raise instead of exhausting the stack.
parameters = {}
parameters["self"] = parameters
with pytest.raises(ValueError, match="circular reference"):
ToolDefinition(name="f", parameters=parameters)
def test_circular_list_rejected():
parameters = []
parameters.append(parameters)
with pytest.raises(ValueError, match="circular reference"):
ToolDefinition(name="f", parameters=parameters)
def test_indirect_cycle_rejected():
outer = {}
inner = {"outer": outer}
outer["inner"] = inner
with pytest.raises(ValueError, match="circular reference"):
ToolDefinition(name="f", parameters=outer)
def test_cycle_through_tuple_rejected():
parameters = {"items": []}
parameters["items"].append((parameters,))
with pytest.raises(ValueError, match="circular reference"):
ToolDefinition(name="f", parameters=parameters)
def test_circular_request_body_rejected():
body = dict(REQUEST)
body["self"] = body
with pytest.raises(ValueError, match="circular reference"):
ChatCompletionRequest(body)
def test_deep_nesting_rejected():
parameters = {}
node = parameters
for _ in range(200):
child = {}
node["next"] = child
node = child
with pytest.raises(RecursionError):
ToolDefinition(name="f", parameters=parameters)
def test_nesting_within_limit_accepted():
parameters = {}
node = parameters
for _ in range(127):
child = {}
node["next"] = child
node = child
assert ToolDefinition(name="f", parameters=parameters).parameters == parameters
def test_shared_subtree_accepted():
# A reference used twice is a shared subtree, not a circular reference.
shared = {"type": "string"}
parameters = {"type": "object", "properties": {"a": shared, "b": shared}}
assert ToolDefinition(name="f", parameters=parameters).parameters == parameters

View File

@ -0,0 +1,24 @@
[package]
name = "deepseek-recipe"
version = "0.1.0"
edition.workspace = true
description = "API request conversion and streamed output parsing for DeepSeek models."
readme.workspace = true
documentation = "https://docs.rs/deepseek-recipe"
license.workspace = true
repository.workspace = true
[dependencies]
async-stream = { workspace = true }
base64 = { workspace = true }
deepseek-recipe-core = { workspace = true }
itertools = { workspace = true }
jsonschema = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
strum_macros = { workspace = true }
tokenizers = { workspace = true }
tokio-stream = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

21
deepseek-recipe/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,48 @@
use serde::Serialize;
use crate::request::ConversionError;
#[derive(Debug, Serialize)]
pub struct ErrorDetail {
pub message: String,
pub r#type: String,
pub param: Option<String>,
pub code: String,
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: ErrorDetail,
}
impl ConversionError {
/// The HTTP status code for this error.
pub fn status_code(&self) -> u16 {
match self {
ConversionError::BadRequest { .. } => 400,
ConversionError::Internal { .. } => 500,
}
}
/// Build the error response body for this error.
pub fn into_response(self) -> ErrorResponse {
let status_code = self.status_code();
let (type_, detail) = match self {
ConversionError::BadRequest { detail } => ("invalid_request_error", detail),
ConversionError::Internal { detail } => ("internal_error", detail),
};
ErrorResponse {
error: ErrorDetail {
message: detail,
r#type: type_.to_string(),
param: None,
code: if status_code >= 500 {
type_.to_string()
} else {
"invalid_request_error".to_owned()
},
},
}
}
}

View File

@ -0,0 +1,16 @@
//! API protocol conversion and inference output parsing.
//!
//! [`request::ProtocolRequest::convert`] produces a shared
//! [`request::ConversationRequest`]. [`stream::StreamProcessor`] parses backend
//! output and passes it to the selected protocol's event generator. The caller
//! supplies prompt rendering, token encoding, inference, usage, and transport.
pub use protocol::anthropic;
pub use protocol::openai;
pub mod error_response;
mod protocol;
pub mod request;
pub mod response;
pub mod stream;
pub mod util;

View File

@ -0,0 +1,25 @@
//! Anthropic Messages request conversion and response events.
//!
//! Supports text, images, client tools, and thinking blocks. Document content
//! and hosted tool execution are outside the supported scope. Server tools are
//! rejected by default; web-search handling is configurable through
//! [`crate::request::ConversionOptions::messages_web_search`].
//!
//! Input text must not spell out the image placeholder. Every placeholder in a
//! prompt corresponds to one image source, and the adapter inserts them for
//! image blocks only, so message text, tool references, thinking blocks, tool
//! result text, the top-level `system` field, tool definitions, and historical
//! tool calls are rejected when they carry the placeholder spelling.
//!
//! Use [`crate::request::ProtocolRequest::convert`] to produce a shared
//! [`crate::request::ConversationRequest`], and
//! [`crate::request::ProtocolRequest::chunk_generator`] to construct response
//! events. [`crate::stream::StreamProcessor`] parses backend output into typed
//! [`response::MessagesStreamEvent`] values, which can also be accumulated into
//! a complete [`response::MessagesResponse`].
//!
//! The caller supplies model inference, tool execution, token usage, and HTTP
//! transport.
pub mod request;
pub mod response;

View File

@ -0,0 +1,622 @@
use std::collections::{BTreeMap, HashSet};
use deepseek_recipe_core::conversation::{Conversation, ReasoningEffort};
use deepseek_recipe_core::messages::{InputMessage, ToolCall};
use deepseek_recipe_core::multimodal::{IMAGE_SPECIAL_TOKEN, ImageSource};
use deepseek_recipe_core::tools::{ToolChoice, ToolDefinition};
use deepseek_recipe_core::util::json_formatter::stringify_python_style;
use itertools::Itertools;
use crate::protocol::image::{
UNSUPPORTED_DOCUMENT_PLACEHOLDER, base64_image_source, external_image_source,
file_id_unsupported, image_not_allowed,
};
use crate::protocol::validation::{
image_special_token_not_allowed, validate_max_tokens, validate_sampling, validate_tool_name,
validate_tool_parameters, validate_tool_text,
};
use crate::request::{
ConversationRequest, ConversionError, ConversionOptions, InferenceOptions, ProtocolRequest,
WebSearchBehavior,
};
use crate::stream::state_machine::{ParsingOptions, ReasoningStage};
use super::super::response::{MessagesChunkGenerator, MessagesResponse};
use super::schema::{
MessagesContent, MessagesContentBlock, MessagesImageSource, MessagesMessage,
MessagesReasoningEffort, MessagesRequest, MessagesTextBlock, MessagesTextOrTextBlocks,
MessagesThinkingType, MessagesTool, MessagesToolChoice,
};
impl ProtocolRequest for MessagesRequest {
type Response = MessagesResponse;
/// Validate and convert Messages input into a conversation request.
///
/// Explicit thinking overrides effort, which overrides the supplied default.
/// Missing historical thinking is accepted, and supplied thinking is preserved.
fn convert(self, options: ConversionOptions) -> Result<ConversationRequest, ConversionError> {
let (thinking_mode, reasoning_effort) = self.resolve_thinking(options);
let inference_options = self.inference_options()?;
let stop_sequences = self.stop_sequences.unwrap_or_default();
let (tools, tool_choice) =
convert_tools(self.tools, self.tool_choice, thinking_mode, &options)?;
let parsing_options =
Self::parsing_options(thinking_mode, &tools, tool_choice, stop_sequences);
let messages = transform_messages(self.messages, self.system, &options)?;
validate_tool_text(&tools, &messages)?;
Ok(ConversationRequest {
conversation: Conversation {
messages,
thinking_mode,
tools,
tool_choice,
reasoning_effort,
..Conversation::default()
},
inference_options,
parsing_options,
model: Some(self.model),
stream: self.stream.unwrap_or(false),
})
}
fn chunk_generator(
request: &ConversationRequest,
id: String,
model: String,
) -> MessagesChunkGenerator {
MessagesChunkGenerator::new(id, model, request.conversation.thinking_mode)
}
}
impl MessagesRequest {
fn resolve_thinking(&self, options: ConversionOptions) -> (bool, Option<ReasoningEffort>) {
let effort = self
.output_config
.as_ref()
.and_then(|config| config.effort)
.map(|effort| match effort {
MessagesReasoningEffort::Low => ReasoningEffort::Low,
MessagesReasoningEffort::Medium | MessagesReasoningEffort::High => {
ReasoningEffort::High
}
MessagesReasoningEffort::Xhigh => ReasoningEffort::Xhigh,
MessagesReasoningEffort::Ultra | MessagesReasoningEffort::Max => {
ReasoningEffort::Max
}
});
let thinking = match self.thinking.as_ref().map(|thinking| thinking.kind) {
Some(MessagesThinkingType::Enabled) => true,
Some(MessagesThinkingType::Disabled) => false,
None => effort.is_some() || options.default_thinking_mode,
};
(
thinking,
thinking.then_some(effort.unwrap_or(ReasoningEffort::High)),
)
}
fn parsing_options(
thinking: bool,
tools: &[ToolDefinition],
tool_choice: ToolChoice,
stop_sequences: Vec<String>,
) -> ParsingOptions {
ParsingOptions {
parse_tool_calls: !tools.is_empty(),
reasoning_initial_stage: thinking.then_some(ReasoningStage::Start),
stop_sequences,
tool_call_initial_stage: tool_choice == ToolChoice::Required,
..ParsingOptions::default()
}
}
fn inference_options(&self) -> Result<InferenceOptions, ConversionError> {
validate_max_tokens(self.max_tokens, "max_tokens")?;
validate_sampling(self.temperature, self.top_p)?;
if let Some(stops) = &self.stop_sequences
&& (stops.len() > 16 || stops.iter().any(String::is_empty))
{
return Err(ConversionError::bad_request(
"stop_sequences must contain at most 16 non-empty strings",
));
}
let disable_parallel_tool_use = match &self.tool_choice {
Some(
MessagesToolChoice::Auto {
disable_parallel_tool_use,
}
| MessagesToolChoice::Any {
disable_parallel_tool_use,
}
| MessagesToolChoice::Tool {
disable_parallel_tool_use,
..
},
) => *disable_parallel_tool_use,
_ => None,
};
Ok(InferenceOptions {
max_tokens: self.max_tokens,
temperature: self.temperature,
top_p: self.top_p,
thinking_budget_tokens: self
.thinking
.as_ref()
.and_then(|thinking| thinking.budget_tokens),
disable_parallel_tool_use,
})
}
}
fn convert_tools(
tools: Option<Vec<MessagesTool>>,
choice: Option<MessagesToolChoice>,
thinking: bool,
options: &ConversionOptions,
) -> Result<(Vec<ToolDefinition>, ToolChoice), ConversionError> {
let mut names = HashSet::new();
let mut definitions = Vec::new();
let mut ignored_server_tool_names = HashSet::new();
for (index, tool) in tools.unwrap_or_default().into_iter().enumerate() {
if let Some(kind) = &tool.kind {
if kind.starts_with("web_search") {
match options.messages_web_search {
WebSearchBehavior::Ignore => {
ignored_server_tool_names.insert(tool.name.clone());
continue;
}
WebSearchBehavior::Reject => {
return Err(ConversionError::bad_request(format!(
"tools.{index}: server tools are not supported"
)));
}
}
}
return Err(ConversionError::bad_request(format!(
"tools.{index}: server tools are not supported"
)));
}
validate_tool_name(&tool.name, &format!("tools.{index}.name"))?;
if !names.insert(tool.name.clone()) {
return Err(ConversionError::bad_request("Tool names must be unique"));
}
let parameters = tool.input_schema.ok_or_else(|| {
ConversionError::bad_request(format!("tools.{index}: missing input_schema"))
})?;
validate_tool_parameters(&parameters, &format!("tools.{index}.input_schema"))?;
definitions.push(ToolDefinition {
name: tool.name,
description: tool.description,
parameters,
strict: None,
});
}
let choice = match choice {
Some(MessagesToolChoice::None) => {
definitions.clear();
ToolChoice::None
}
Some(MessagesToolChoice::Tool { name, .. }) => {
if ignored_server_tool_names.contains(&name) {
// The named server tool was dropped; drop the choice too.
ToolChoice::Auto
} else if !names.contains(&name) {
return Err(ConversionError::bad_request(format!(
"tool_choice: no tool named '{name}' was specified"
)));
} else if thinking {
return Err(ConversionError::bad_request(
"Thinking mode does not support this tool_choice",
));
} else {
definitions.retain(|tool| tool.name == name);
ToolChoice::Required
}
}
_ => ToolChoice::Auto,
};
Ok((definitions, choice))
}
impl MessagesContentBlock {
/// Convert text and tool references, substitute document placeholders, and
/// collect images with one [`IMAGE_SPECIAL_TOKEN`] placeholder per image.
fn into_content(self, path: &str) -> Result<(String, Vec<ImageSource>), ConversionError> {
match self {
MessagesContentBlock::Text { text } => Ok((text, Vec::new())),
MessagesContentBlock::ToolReference { tool_name } => Ok((tool_name, Vec::new())),
MessagesContentBlock::Image { source } => Ok((
IMAGE_SPECIAL_TOKEN.to_string(),
vec![image_source(source, path)?],
)),
MessagesContentBlock::Document => {
Ok((UNSUPPORTED_DOCUMENT_PLACEHOLDER.to_string(), Vec::new()))
}
_ => Err(ConversionError::internal("unexpected content block type")),
}
}
/// Convert textual content and document placeholders, rejecting images.
fn into_text(self, path: &str, role: &str) -> Result<String, ConversionError> {
let (content, image_sources) = self.into_content(path)?;
if !image_sources.is_empty() {
return Err(image_not_allowed(role));
}
Ok(content)
}
}
impl MessagesTextBlock {
/// Convert text and tool references, substitute document placeholders, and
/// collect images with one [`IMAGE_SPECIAL_TOKEN`] placeholder per image.
fn into_content(self, path: &str) -> Result<(String, Vec<ImageSource>), ConversionError> {
match self {
MessagesTextBlock::Text { text } => Ok((text, Vec::new())),
MessagesTextBlock::ToolReference { tool_name } => Ok((tool_name, Vec::new())),
MessagesTextBlock::Image { source } => Ok((
IMAGE_SPECIAL_TOKEN.to_string(),
vec![image_source(source, path)?],
)),
MessagesTextBlock::Document => {
Ok((UNSUPPORTED_DOCUMENT_PLACEHOLDER.to_string(), Vec::new()))
}
MessagesTextBlock::Unsupported => Err(unsupported_block()),
}
}
}
impl MessagesTextOrTextBlocks {
/// Join textual content and document placeholders, collecting image sources.
fn into_content(self, path: &str) -> Result<(String, Vec<ImageSource>), ConversionError> {
match self {
MessagesTextOrTextBlocks::Raw(content) => Ok((content, Vec::new())),
MessagesTextOrTextBlocks::Blocks(blocks) => {
let mut texts = Vec::new();
let mut image_sources = Vec::new();
for (block_index, block) in blocks.into_iter().enumerate() {
let (text, images) = block.into_content(&format!("{path}[{block_index}]"))?;
texts.push(text);
image_sources.extend(images);
}
Ok((texts.join("\n\n"), image_sources))
}
}
}
/// Join textual content and document placeholders, rejecting images.
fn into_text(self, path: &str, role: &str) -> Result<String, ConversionError> {
let (content, image_sources) = self.into_content(path)?;
if !image_sources.is_empty() {
return Err(image_not_allowed(role));
}
Ok(content)
}
}
/// Convert one Messages image reference.
fn image_source(source: MessagesImageSource, path: &str) -> Result<ImageSource, ConversionError> {
match source {
MessagesImageSource::Base64 { media_type, data } => {
base64_image_source(&media_type, data, path)
}
MessagesImageSource::Url { url } => external_image_source(url, None, path),
MessagesImageSource::File { file_id: _ } => Err(file_id_unsupported(path)),
}
}
fn check_tool_use_resolved(
message_idx: usize,
tool_use_ids: &BTreeMap<String, usize>,
) -> Result<(), ConversionError> {
if tool_use_ids.is_empty() {
return Ok(());
}
Err(ConversionError::bad_request(format!(
"messages.{message_idx}: `tool_use` ids were found without `tool_result` blocks immediately after: {}. Each `tool_use` block \
must have a corresponding `tool_result` block in the next message.",
tool_use_ids.keys().join(", ")
)))
}
/// Return whether any message text or top-level system text carries
/// [`IMAGE_SPECIAL_TOKEN`].
///
/// Message text, tool references, thinking blocks, and tool result text are
/// checked. Tool call names and arguments are checked after conversion by
/// [`validate_tool_text`].
fn messages_contain_image_special_token(
messages: &[MessagesMessage],
system: Option<&MessagesTextOrTextBlocks>,
) -> bool {
messages.iter().any(|message| match message {
MessagesMessage::User(content) | MessagesMessage::Assistant(content) => {
content_contains_image_special_token(content)
}
MessagesMessage::System(content) => {
text_or_text_blocks_contain_image_special_token(content)
}
}) || system.is_some_and(text_or_text_blocks_contain_image_special_token)
}
/// Return whether one message content carries [`IMAGE_SPECIAL_TOKEN`].
fn content_contains_image_special_token(content: &MessagesContent) -> bool {
match content {
MessagesContent::Raw(content) => content.contains(IMAGE_SPECIAL_TOKEN),
MessagesContent::Blocks(blocks) => blocks.iter().any(block_contains_image_special_token),
}
}
/// Return whether one message content block carries [`IMAGE_SPECIAL_TOKEN`].
fn block_contains_image_special_token(block: &MessagesContentBlock) -> bool {
match block {
MessagesContentBlock::Text { text } => text.contains(IMAGE_SPECIAL_TOKEN),
MessagesContentBlock::ToolReference { tool_name } => {
tool_name.contains(IMAGE_SPECIAL_TOKEN)
}
MessagesContentBlock::Thinking { thinking, .. } => thinking.contains(IMAGE_SPECIAL_TOKEN),
MessagesContentBlock::ToolResult { content, .. } => content
.as_ref()
.is_some_and(text_or_text_blocks_contain_image_special_token),
_ => false,
}
}
/// Return whether one text or text-block value carries [`IMAGE_SPECIAL_TOKEN`].
fn text_or_text_blocks_contain_image_special_token(content: &MessagesTextOrTextBlocks) -> bool {
match content {
MessagesTextOrTextBlocks::Raw(content) => content.contains(IMAGE_SPECIAL_TOKEN),
MessagesTextOrTextBlocks::Blocks(blocks) => blocks.iter().any(|block| match block {
MessagesTextBlock::Text { text } => text.contains(IMAGE_SPECIAL_TOKEN),
MessagesTextBlock::ToolReference { tool_name } => {
tool_name.contains(IMAGE_SPECIAL_TOKEN)
}
_ => false,
}),
}
}
fn transform_messages(
messages: Vec<MessagesMessage>,
system: Option<MessagesTextOrTextBlocks>,
options: &ConversionOptions,
) -> Result<Vec<InputMessage>, ConversionError> {
if messages.is_empty() {
return Err(ConversionError::bad_request(
"messages: at least one message is required",
));
}
if messages_contain_image_special_token(&messages, system.as_ref()) {
return Err(image_special_token_not_allowed());
}
let mut transformed_messages = Vec::new();
if let Some(system) = system {
transformed_messages.push(InputMessage::System {
content: system.into_text("system", "system")?,
});
}
let mut tool_use_ids = BTreeMap::new();
let messages_len = messages.len();
for (message_idx, message) in messages.into_iter().enumerate() {
match message {
MessagesMessage::User(MessagesContent::Raw(content)) => {
check_tool_use_resolved(message_idx, &tool_use_ids)?;
transformed_messages.push(InputMessage::User {
content,
image_sources: Vec::new(),
});
}
MessagesMessage::Assistant(MessagesContent::Raw(content)) => {
check_tool_use_resolved(message_idx, &tool_use_ids)?;
transformed_messages.push(InputMessage::Assistant {
content,
reasoning_content: None,
tool_calls: None,
});
}
MessagesMessage::System(content) => {
check_tool_use_resolved(message_idx, &tool_use_ids)?;
let (content, image_sources) =
content.into_content(&format!("messages.{message_idx}"))?;
transformed_messages.push(InputMessage::User {
content: format!("<system-reminder>\n{content}\n</system-reminder>"),
image_sources,
});
}
MessagesMessage::User(MessagesContent::Blocks(blocks)) => {
if blocks.is_empty() {
return Err(ConversionError::bad_request(format!(
"messages.{message_idx}: all messages must have non-empty content"
)));
}
let mut matched_tool_use_ids: HashSet<String> = HashSet::new();
for (block_idx, block) in blocks.into_iter().enumerate() {
let path = format!("messages.{message_idx}.content[{block_idx}]");
match block {
MessagesContentBlock::Unsupported => return Err(unsupported_block()),
MessagesContentBlock::Text { .. }
| MessagesContentBlock::ToolReference { .. } => {
check_tool_use_resolved(message_idx, &tool_use_ids)?;
let content = block.into_text(&path, "user")?;
transformed_messages.push(InputMessage::User {
content,
image_sources: Vec::new(),
});
}
MessagesContentBlock::Image { source } => {
check_tool_use_resolved(message_idx, &tool_use_ids)?;
transformed_messages.push(InputMessage::User {
content: IMAGE_SPECIAL_TOKEN.to_string(),
image_sources: vec![image_source(source, &path)?],
});
}
MessagesContentBlock::Document => {
check_tool_use_resolved(message_idx, &tool_use_ids)?;
transformed_messages.push(InputMessage::User {
content: UNSUPPORTED_DOCUMENT_PLACEHOLDER.to_string(),
image_sources: Vec::new(),
});
}
MessagesContentBlock::ToolUse { .. } => {
return Err(ConversionError::bad_request(format!(
"messages.{message_idx}: `tool_use` blocks can only be in `assistant` messages"
)));
}
MessagesContentBlock::ToolResult {
tool_use_id,
content,
..
} => {
if matched_tool_use_ids.contains(tool_use_id.as_str()) {
return Err(ConversionError::bad_request(format!(
"messages.{message_idx}.content.{block_idx}: each tool_use must have a single result. Found \
multiple `tool_result` blocks with id: {tool_use_id}"
)));
} else if !tool_use_ids.contains_key(tool_use_id.as_str()) {
return Err(ConversionError::bad_request(format!(
"unexpected `messages.{message_idx}.content.{block_idx}: tool_use_id` found in `tool_result` \
blocks: {tool_use_id}. Each `tool_result` block must have a corresponding `tool_use` block in \
the previous message."
)));
} else {
tool_use_ids.remove(&tool_use_id);
matched_tool_use_ids.insert(tool_use_id.clone());
}
let (content, image_sources) = match content {
Some(content) => content.into_content(&path)?,
None => (String::new(), Vec::new()),
};
transformed_messages.push(InputMessage::Tool {
content,
image_sources,
tool_call_id: tool_use_id,
});
}
MessagesContentBlock::Thinking { .. } => {
return Err(ConversionError::bad_request(format!(
"messages.{message_idx}.content: thinking blocks may only be in `assistant` messages"
)));
}
MessagesContentBlock::ServerToolUse { .. } => {
return Err(ConversionError::bad_request(format!(
"messages.{message_idx}: `server_tool_use` blocks can only be in `assistant` messages"
)));
}
MessagesContentBlock::WebSearchToolResult { .. } => {
return Err(ConversionError::bad_request(format!(
"messages.{message_idx}: `web_search_tool_result` blocks can only be in `assistant` messages"
)));
}
}
}
if !tool_use_ids.is_empty() {
return Err(ConversionError::bad_request(format!(
"messages.{}:`tool_use` ids were found without `tool_result` blocks immediately after: {}. Each `tool_use` \
block must have a corresponding `tool_result` block in the next message.",
message_idx - 1,
tool_use_ids.keys().join(", ")
)));
}
}
MessagesMessage::Assistant(MessagesContent::Blocks(blocks)) => {
check_tool_use_resolved(message_idx, &tool_use_ids)?;
if blocks.is_empty() {
return Err(ConversionError::bad_request(format!(
"messages.{message_idx}: all messages must have non-empty content"
)));
}
let mut content = String::new();
let mut reasoning_content: Option<String> = None;
let mut tool_calls: Option<Vec<ToolCall>> = None;
for (block_idx, block) in blocks.into_iter().enumerate() {
let path = format!("messages.{message_idx}.content[{block_idx}]");
match block {
MessagesContentBlock::Unsupported => return Err(unsupported_block()),
MessagesContentBlock::Text { .. }
| MessagesContentBlock::ToolReference { .. }
| MessagesContentBlock::Image { .. }
| MessagesContentBlock::Document => {
if tool_calls.is_some() {
return Err(ConversionError::bad_request(format!(
"messages.{message_idx}.{block_idx}: `tool_use` ids were found without `tool_result` blocks \
immediately after: {}. Each `tool_use` block must have a corresponding `tool_result` block in \
the next message.",
tool_use_ids.keys().join(", ")
)));
}
if !content.is_empty() {
content.push_str("\n\n");
}
content.push_str(&block.into_text(&path, "assistant")?);
}
MessagesContentBlock::ToolUse { id, name, input } => {
let tool_use_id = id.clone();
let tool_call = ToolCall {
id,
name,
arguments: stringify_python_style(&input),
};
tool_calls.get_or_insert_with(Vec::new).push(tool_call);
if tool_use_ids.insert(tool_use_id, block_idx).is_some() {
return Err(ConversionError::bad_request(format!(
"messages.{message_idx}.content.{block_idx}: `tool_use` ids must be unique"
)));
}
}
MessagesContentBlock::ToolResult { .. } => {
return Err(ConversionError::bad_request(format!(
"messages.{message_idx}: `tool_result` blocks can only be in `user` messages"
)));
}
MessagesContentBlock::Thinking { thinking, .. } => {
if let Some(reasoning_content) = &mut reasoning_content {
reasoning_content.push_str("\n\n");
reasoning_content.push_str(&thinking);
} else {
reasoning_content = Some(thinking);
}
}
MessagesContentBlock::ServerToolUse { .. }
| MessagesContentBlock::WebSearchToolResult { .. } => {
if options.messages_web_search == WebSearchBehavior::Reject {
return Err(ConversionError::bad_request(
"Unsupported content block: server tools are not supported",
));
}
}
}
}
transformed_messages.push(InputMessage::Assistant {
content,
reasoning_content,
tool_calls,
});
}
}
}
if !tool_use_ids.is_empty() {
return Err(ConversionError::bad_request(format!(
"messages.{}:`tool_use` ids were found without `tool_result` blocks immediately after: {}. Each `tool_use` block must \
have a corresponding `tool_result` block in the next message.",
messages_len - 1,
tool_use_ids.keys().join(", ")
)));
}
Ok(transformed_messages)
}
fn unsupported_block() -> ConversionError {
ConversionError::bad_request("Unsupported content block")
}

View File

@ -0,0 +1,4 @@
pub use schema::*;
mod convert;
mod schema;

View File

@ -0,0 +1,192 @@
use serde::Deserialize;
/// An Anthropic Messages request. Unknown top-level fields are ignored.
///
/// Model selection, execution and usage accounting are the caller's
/// responsibility. Images are supported. Document blocks become the text
/// `[Unsupported Document]`; their contents are discarded. `web_search` server
/// tools are rejected by default; see
/// [`crate::request::WebSearchBehavior`].
#[derive(Debug, Deserialize)]
pub struct MessagesRequest {
pub model: String,
pub messages: Vec<MessagesMessage>,
pub system: Option<MessagesTextOrTextBlocks>,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub stop_sequences: Option<Vec<String>>,
pub stream: Option<bool>,
pub tools: Option<Vec<MessagesTool>>,
pub tool_choice: Option<MessagesToolChoice>,
pub thinking: Option<MessagesThinking>,
pub output_config: Option<MessagesOutputConfig>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "role", content = "content", rename_all = "snake_case")]
pub enum MessagesMessage {
User(MessagesContent),
Assistant(MessagesContent),
/// Compatibility extension, rendered as a user system-reminder.
System(MessagesTextOrTextBlocks),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum MessagesContent {
Raw(String),
Blocks(Vec<MessagesContentBlock>),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum MessagesTextOrTextBlocks {
Raw(String),
Blocks(Vec<MessagesTextBlock>),
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessagesTextBlock {
Text {
text: String,
},
ToolReference {
tool_name: String,
},
Image {
source: MessagesImageSource,
},
/// Documents are rendered as an unsupported-document placeholder.
Document,
/// Recognized during deserialization, rejected during conversion.
#[serde(other)]
Unsupported,
}
/// One image referenced by inline data, URL, or file identifier. A file
/// identifier requires a file service and is rejected during conversion.
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessagesImageSource {
Base64 {
/// Declared media type, validated against the supported image types.
media_type: String,
data: String,
},
Url {
url: String,
},
File {
file_id: String,
},
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessagesContentBlock {
Text {
text: String,
},
ToolReference {
tool_name: String,
},
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
ToolResult {
tool_use_id: String,
content: Option<MessagesTextOrTextBlocks>,
/// Accepted for compatibility; the result text is preserved either way.
is_error: Option<bool>,
},
Thinking {
/// Signature data available to the caller before conversion.
signature: Option<String>,
thinking: String,
},
Image {
source: MessagesImageSource,
},
/// A server tool invocation. Assistant messages only; dropped or rejected
/// per [`crate::request::ConversionOptions::messages_web_search`].
ServerToolUse {
id: String,
name: String,
input: serde_json::Value,
},
/// A server tool result. Assistant messages only; dropped or rejected per
/// [`crate::request::ConversionOptions::messages_web_search`].
WebSearchToolResult {
tool_use_id: String,
content: serde_json::Value,
},
/// Documents are rendered as an unsupported-document placeholder.
Document,
#[serde(other)]
Unsupported,
}
/// A client tool declaration. A `type` starting with `web_search` is dropped or
/// rejected per [`crate::request::ConversionOptions::messages_web_search`];
/// other `type` discriminators are rejected as unsupported.
#[derive(Debug, Deserialize)]
pub struct MessagesTool {
pub name: String,
pub description: Option<String>,
pub input_schema: Option<serde_json::Value>,
#[serde(rename = "type")]
pub kind: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessagesToolChoice {
Auto {
disable_parallel_tool_use: Option<bool>,
},
/// For compatibility, `any` permits both text replies and tool calls.
Any {
disable_parallel_tool_use: Option<bool>,
},
Tool {
name: String,
disable_parallel_tool_use: Option<bool>,
},
None,
}
#[derive(Debug, Deserialize)]
pub struct MessagesThinking {
#[serde(rename = "type")]
pub kind: MessagesThinkingType,
/// Thinking budget passed through for the caller to enforce.
pub budget_tokens: Option<u64>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessagesThinkingType {
#[serde(alias = "adaptive")]
Enabled,
Disabled,
}
#[derive(Debug, Deserialize)]
pub struct MessagesOutputConfig {
pub effort: Option<MessagesReasoningEffort>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessagesReasoningEffort {
Low,
Medium,
High,
Xhigh,
Ultra,
Max,
}

View File

@ -0,0 +1,207 @@
use crate::response::ProtocolResponse;
use crate::stream::{ChunkGenerator, OutputChunk, PromptUsage};
use super::schema::{
MessagesDelta, MessagesResponse, MessagesResponseContent, MessagesStopInfo,
MessagesStreamEvent, MessagesUsage,
};
/// Converts parsed inference output to Messages events.
#[derive(Debug)]
pub struct MessagesChunkGenerator {
id: String,
model: String,
thinking_mode: bool,
signature: String,
content_index: usize,
tool_call_index: usize,
active: Option<MessagesContentBlockKind>,
prompt_usage: PromptUsage,
started: bool,
finished: bool,
}
#[derive(Debug, PartialEq, Eq)]
enum MessagesContentBlockKind {
Text,
Thinking,
ToolUse,
}
impl MessagesChunkGenerator {
/// The caller supplies a unique response ID and the public response model name.
/// Tool IDs are derived from that response ID and the per-response tool index.
/// The default opaque thinking signature is the response ID.
pub fn new(id: impl Into<String>, model: impl Into<String>, thinking_mode: bool) -> Self {
let id = id.into();
Self {
signature: id.clone(),
id,
model: model.into(),
thinking_mode,
content_index: 0,
tool_call_index: 0,
active: None,
prompt_usage: PromptUsage::default(),
started: false,
finished: false,
}
}
/// Set the signature emitted at the end of a thinking block.
pub fn with_signature(mut self, signature: impl Into<String>) -> Self {
self.signature = signature.into();
self
}
fn close_block(&mut self, events: &mut Vec<MessagesStreamEvent>) {
if let Some(kind) = self.active.take() {
if kind == MessagesContentBlockKind::Thinking {
events.push(MessagesStreamEvent::ContentBlockDelta {
index: self.content_index,
delta: MessagesDelta::SignatureDelta {
signature: self.signature.clone(),
},
});
}
events.push(MessagesStreamEvent::ContentBlockStop {
index: self.content_index,
});
self.content_index += 1;
}
}
fn open_block(
&mut self,
kind: MessagesContentBlockKind,
content_block: MessagesResponseContent,
events: &mut Vec<MessagesStreamEvent>,
) {
self.close_block(events);
events.push(MessagesStreamEvent::ContentBlockStart {
index: self.content_index,
content_block,
});
if self.content_index == 0 {
events.push(MessagesStreamEvent::Ping);
}
self.active = Some(kind);
}
fn delta(&self, delta: MessagesDelta) -> MessagesStreamEvent {
MessagesStreamEvent::ContentBlockDelta {
index: self.content_index,
delta,
}
}
}
impl ChunkGenerator for MessagesChunkGenerator {
type Chunk = MessagesStreamEvent;
async fn generate(&mut self, chunk: OutputChunk) -> Vec<Self::Chunk> {
if self.finished {
return Vec::new();
}
let mut events = Vec::new();
match chunk {
OutputChunk::Start { usage, .. } => {
if self.started {
return events;
}
self.started = true;
self.prompt_usage = usage;
events.push(MessagesStreamEvent::MessageStart {
message: MessagesResponse::new(
self.id.clone(),
self.model.clone(),
0,
usage.prompt_tokens,
usage.prompt_cache_hit_tokens,
),
});
if self.thinking_mode {
self.open_block(
MessagesContentBlockKind::Thinking,
MessagesResponseContent::Thinking {
thinking: String::new(),
signature: String::new(),
},
&mut events,
);
}
}
OutputChunk::Raw { content } => {
if self.active != Some(MessagesContentBlockKind::Text) {
self.open_block(
MessagesContentBlockKind::Text,
MessagesResponseContent::Text {
text: String::new(),
},
&mut events,
);
}
events.push(self.delta(MessagesDelta::TextDelta { text: content }));
}
OutputChunk::Reasoning { content } => {
if self.active != Some(MessagesContentBlockKind::Thinking) {
self.open_block(
MessagesContentBlockKind::Thinking,
MessagesResponseContent::Thinking {
thinking: String::new(),
signature: String::new(),
},
&mut events,
);
}
events.push(self.delta(MessagesDelta::ThinkingDelta { thinking: content }));
}
OutputChunk::ToolCall {
tool_name,
arguments,
} => {
let id = format!("toolu_{}_{}", self.id, self.tool_call_index);
self.tool_call_index += 1;
let input = if arguments.is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(&arguments).unwrap_or_else(|_| serde_json::json!({}))
};
self.open_block(
MessagesContentBlockKind::ToolUse,
MessagesResponseContent::ToolUse {
id,
name: tool_name,
input,
},
&mut events,
);
}
OutputChunk::ToolArgumentsDelta { content } => {
if self.active == Some(MessagesContentBlockKind::ToolUse) {
events.push(self.delta(MessagesDelta::InputJsonDelta {
partial_json: content,
}));
}
}
OutputChunk::Finish {
reason,
stop_sequence,
usage,
} => {
self.close_block(&mut events);
events.push(MessagesStreamEvent::MessageDelta {
delta: MessagesStopInfo {
stop_reason: Some(reason.into()),
stop_sequence,
},
usage: MessagesUsage::from((self.prompt_usage, usage)),
});
events.push(MessagesStreamEvent::MessageStop);
self.finished = true;
}
OutputChunk::ToolCallBegin => {}
}
events
}
}

View File

@ -0,0 +1,5 @@
pub use chunk_generator::*;
pub use schema::*;
mod chunk_generator;
mod schema;

View File

@ -0,0 +1,262 @@
use std::collections::BTreeMap;
use serde::Serialize;
use crate::response::ProtocolResponse;
use crate::stream::{CompletionUsage, FinishReason, PromptUsage};
use crate::util::append_delta::AppendDelta;
use super::chunk_generator::MessagesChunkGenerator;
/// A complete Messages response, also usable as an accumulator for generated
/// events.
///
/// Tool argument fragments are parsed when their content block stops. Invalid
/// or truncated JSON produces `{}`. Events with invalid indices or incompatible
/// delta types leave the response unchanged.
#[derive(Debug, Serialize)]
pub struct MessagesResponse {
pub id: String,
#[serde(rename = "type")]
pub response_type: MessagesResponseType,
pub role: MessagesRole,
pub model: String,
pub content: Vec<MessagesResponseContent>,
pub stop_reason: Option<MessagesStopReason>,
pub stop_sequence: Option<String>,
pub usage: MessagesUsage,
#[serde(skip)]
partial_tool_inputs: BTreeMap<usize, String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MessagesResponseType {
Message,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MessagesRole {
Assistant,
}
/// A complete content block, distinct from the delta applied to it.
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessagesResponseContent {
Text {
text: String,
},
Thinking {
thinking: String,
signature: String,
},
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessagesDelta {
TextDelta { text: String },
ThinkingDelta { thinking: String },
SignatureDelta { signature: String },
InputJsonDelta { partial_json: String },
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MessagesStopReason {
EndTurn,
MaxTokens,
StopSequence,
ToolUse,
Refusal,
}
impl From<FinishReason> for MessagesStopReason {
fn from(reason: FinishReason) -> Self {
match reason {
FinishReason::Stop | FinishReason::EndOfStream => Self::EndTurn,
FinishReason::Length => Self::MaxTokens,
FinishReason::StopSequence => Self::StopSequence,
FinishReason::ToolCalls => Self::ToolUse,
FinishReason::ContentFilter => Self::Refusal,
}
}
}
#[derive(Debug, Serialize)]
pub struct MessagesStopInfo {
pub stop_reason: Option<MessagesStopReason>,
pub stop_sequence: Option<String>,
}
/// Protocol representation of caller-supplied accounting.
#[derive(Debug, Serialize)]
pub struct MessagesUsage {
pub input_tokens: usize,
pub cache_creation_input_tokens: usize,
pub cache_read_input_tokens: usize,
pub output_tokens: usize,
pub service_tier: MessagesServiceTier,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MessagesServiceTier {
Standard,
}
impl From<(PromptUsage, CompletionUsage)> for MessagesUsage {
fn from((prompt, completion): (PromptUsage, CompletionUsage)) -> Self {
Self {
input_tokens: prompt
.prompt_tokens
.saturating_sub(prompt.prompt_cache_hit_tokens),
cache_creation_input_tokens: 0,
cache_read_input_tokens: prompt.prompt_cache_hit_tokens,
output_tokens: completion.completion_tokens,
service_tier: MessagesServiceTier::Standard,
}
}
}
/// A Messages stream event. Serialize it as JSON and obtain its SSE event name
/// with [`Self::event_type`]. The caller supplies SSE framing and transport.
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessagesStreamEvent {
MessageStart {
message: MessagesResponse,
},
ContentBlockStart {
index: usize,
content_block: MessagesResponseContent,
},
ContentBlockDelta {
index: usize,
delta: MessagesDelta,
},
ContentBlockStop {
index: usize,
},
MessageDelta {
delta: MessagesStopInfo,
usage: MessagesUsage,
},
MessageStop,
Ping,
}
impl MessagesStreamEvent {
/// Event name used by the SSE transport.
pub fn event_type(&self) -> &'static str {
match self {
Self::MessageStart { .. } => "message_start",
Self::ContentBlockStart { .. } => "content_block_start",
Self::ContentBlockDelta { .. } => "content_block_delta",
Self::ContentBlockStop { .. } => "content_block_stop",
Self::MessageDelta { .. } => "message_delta",
Self::MessageStop => "message_stop",
Self::Ping => "ping",
}
}
}
impl ProtocolResponse for MessagesResponse {
type ChunkGenerator = MessagesChunkGenerator;
fn new(
id: String,
model: String,
_created: u64,
prompt_tokens: usize,
prompt_cache_hit_tokens: usize,
) -> Self {
Self {
id,
response_type: MessagesResponseType::Message,
role: MessagesRole::Assistant,
model,
content: Vec::new(),
stop_reason: None,
stop_sequence: None,
usage: MessagesUsage::from((
PromptUsage {
prompt_tokens,
prompt_cache_hit_tokens,
},
CompletionUsage::default(),
)),
partial_tool_inputs: BTreeMap::new(),
}
}
fn chunk_event_type(chunk: &MessagesStreamEvent) -> Option<&'static str> {
Some(chunk.event_type())
}
}
impl AppendDelta<MessagesStreamEvent> for MessagesResponse {
fn append(&mut self, event: MessagesStreamEvent) {
match event {
MessagesStreamEvent::MessageStart { message } => *self = message,
MessagesStreamEvent::ContentBlockStart {
index,
content_block,
} => {
if index == self.content.len() {
self.content.push(content_block);
}
}
MessagesStreamEvent::ContentBlockDelta { index, delta } => {
let Some(block) = self.content.get_mut(index) else {
return;
};
match (block, delta) {
(
MessagesResponseContent::Text { text },
MessagesDelta::TextDelta { text: value },
) => text.push_str(&value),
(
MessagesResponseContent::Thinking { thinking, .. },
MessagesDelta::ThinkingDelta { thinking: value },
) => thinking.push_str(&value),
(
MessagesResponseContent::Thinking { signature, .. },
MessagesDelta::SignatureDelta { signature: value },
) => signature.push_str(&value),
(
MessagesResponseContent::ToolUse { .. },
MessagesDelta::InputJsonDelta { partial_json },
) => {
self.partial_tool_inputs
.entry(index)
.or_default()
.push_str(&partial_json);
}
_ => {}
}
}
MessagesStreamEvent::ContentBlockStop { index } => {
if let Some(json) = self.partial_tool_inputs.remove(&index)
&& let Some(MessagesResponseContent::ToolUse { input, .. }) =
self.content.get_mut(index)
{
*input = serde_json::from_str(&json).unwrap_or_else(|_| serde_json::json!({}));
}
}
MessagesStreamEvent::MessageDelta { delta, usage } => {
self.stop_reason = delta.stop_reason;
self.stop_sequence = delta.stop_sequence;
self.usage = usage;
}
MessagesStreamEvent::MessageStop | MessagesStreamEvent::Ping => {}
}
}
}

View File

@ -0,0 +1,6 @@
//! Anthropic-compatible protocol types and conversion.
pub use messages::request::MessagesRequest;
pub use messages::response::{MessagesResponse, MessagesStreamEvent};
pub mod messages;

View File

@ -0,0 +1,139 @@
//! Image input validation shared by the protocol adapters.
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use deepseek_recipe_core::multimodal::{
ImageDetail, ImageMediaType, ImageSource, MAX_IMAGE_URL_LEN, is_http_url,
};
use crate::request::ConversionError;
/// Text substituted for document blocks by the Messages and Responses adapters.
/// Document contents are not read or forwarded to the backend.
pub const UNSUPPORTED_DOCUMENT_PLACEHOLDER: &str = "[Unsupported Document]";
/// Error message for an image whose bytes are missing or unsupported.
pub const UNSUPPORTED_IMAGE_ERR: &str = "You have uploaded an unsupported image. Please make sure your image is valid and has one of the following formats: webp, png, jpeg, and gif.";
/// Convert an external image URL into an image source.
///
/// Checks the case-insensitive `http` prefix and a UTF-8 byte length no greater
/// than [`MAX_IMAGE_URL_LEN`]. Full URL validation belongs to the fetcher.
pub(super) fn external_image_source(
url: String,
detail: Option<ImageDetail>,
path: &str,
) -> Result<ImageSource, ConversionError> {
if !is_http_url(&url) {
return Err(ConversionError::bad_request(format!(
"{path}: invalid url: {url:?}"
)));
}
if url.len() > MAX_IMAGE_URL_LEN {
return Err(ConversionError::bad_request(format!(
"{path}: external link length {} too long, max link length {MAX_IMAGE_URL_LEN}. Consider passing file data directly with \
base64 data url instead.",
url.len()
)));
}
Ok(ImageSource::Url {
url,
detail: detail.unwrap_or_default(),
})
}
/// Convert a data URL into an image source.
///
/// The body is not inspected. The resolver accepts both a base64 body and a
/// percent-encoded body.
pub(super) fn data_url_image_source(data_url: String, detail: Option<ImageDetail>) -> ImageSource {
ImageSource::DataUrl {
data_url,
detail: detail.unwrap_or_default(),
}
}
/// Convert an `image_url` value into an image source.
///
/// Values with a case-insensitive `http` prefix are external. Other values must
/// contain the literal `base64,` marker. Data URL syntax, media type, and body
/// decoding are checked later by the image resolver.
pub(super) fn image_url_source(
url: String,
detail: Option<ImageDetail>,
path: &str,
) -> Result<ImageSource, ConversionError> {
if is_http_url(&url) {
external_image_source(url, detail, path)
} else if url.contains("base64,") {
Ok(data_url_image_source(url, detail))
} else {
Err(ConversionError::bad_request(format!(
"{path}: Unsupported image_url format"
)))
}
}
/// Build a base64 data URL from a declared media type and base64 payload.
///
/// Normalizes trailing padding and validates the standard base64 encoding.
/// Empty payloads and invalid image contents are checked by the resolver and
/// preprocessor, respectively.
pub(super) fn base64_image_source(
media_type: &str,
data: String,
path: &str,
) -> Result<ImageSource, ConversionError> {
let media_type =
ImageMediaType::from_mime(media_type).ok_or_else(|| unsupported_image(path))?;
let data = normalize_base64_padding(data);
if STANDARD.decode(&data).is_err() {
return Err(ConversionError::bad_request(format!(
"{path}: base64 decode error"
)));
}
Ok(ImageSource::DataUrl {
data_url: format!("data:{};base64,{data}", media_type.mime()),
detail: ImageDetail::default(),
})
}
/// Return the error for an unsupported image.
pub(super) fn unsupported_image(path: &str) -> ConversionError {
ConversionError::bad_request(format!("{path}: {UNSUPPORTED_IMAGE_ERR}"))
}
/// Return the error for an image in a message that does not accept images.
pub(super) fn image_not_allowed(role: &str) -> ConversionError {
ConversionError::bad_request(format!("Image in {role} message is unsupported"))
}
/// Return the error for a file identifier, which requires a file service this
/// library does not provide.
pub(super) fn file_id_unsupported(path: &str) -> ConversionError {
ConversionError::bad_request(format!(
"{path}: file_id is not supported. Pass the image as a base64 data url instead."
))
}
/// Normalize the padding of a base64 payload.
///
/// A caller may supply the wrong number of trailing `=` characters. A payload
/// carrying at least one trailing `=` has its padding dropped and replaced with
/// the canonical amount. A payload with no trailing `=` is returned unchanged,
/// because a truncated payload and an unpadded payload are indistinguishable.
/// A payload whose unpadded length leaves a single symbol is also returned
/// unchanged, so the decoder reports the invalid payload.
fn normalize_base64_padding(mut data: String) -> String {
if !data.ends_with('=') {
return data;
}
let unpadded_len = data.trim_end_matches('=').len();
let remainder = unpadded_len % 4;
if remainder == 1 {
return data;
}
data.truncate(unpadded_len);
data.push_str(&"=".repeat((4 - remainder) % 4));
data
}

View File

@ -0,0 +1,5 @@
pub mod anthropic;
pub mod openai;
mod image;
mod validation;

View File

@ -0,0 +1,2 @@
pub mod request;
pub mod response;

View File

@ -0,0 +1,504 @@
use std::collections::HashSet;
use deepseek_recipe_core::conversation::{Conversation, ReasoningEffort, ResponseFormat};
use deepseek_recipe_core::messages::{InputMessage, ToolCall};
use deepseek_recipe_core::multimodal::{IMAGE_SPECIAL_TOKEN, ImageSource};
use deepseek_recipe_core::tools::{ToolChoice, ToolDefinition};
use crate::protocol::image::{
data_url_image_source, file_id_unsupported, image_not_allowed, image_url_source,
};
use crate::protocol::validation::{
contains_json_instruction, image_special_token_not_allowed, validate_max_tokens,
validate_sampling, validate_tool_name, validate_tool_parameters, validate_tool_text,
};
use crate::request::{
ConversationRequest, ConversionError, ConversionOptions, InferenceOptions, ProtocolRequest,
};
use crate::stream::state_machine::{ParsingOptions, ReasoningStage};
use super::super::response::{ChatCompletionChunkGenerator, ChatCompletionResponse};
use super::schema::{
ChatCompletionReasoningEffort, ChatCompletionRequest, ChatCompletionRequestContent,
ChatCompletionRequestContentBlock, ChatCompletionRequestMessage, ChatCompletionResponseFormat,
ChatCompletionStopSequences, ChatCompletionStreamOptions, ChatCompletionThinkingType,
ChatCompletionTool, ChatCompletionToolChoiceMode, ChatCompletionToolChoiceOption,
};
/// Maximum number of stop sequences accepted by the adapter.
const MAX_STOP_SEQUENCES: usize = 16;
impl ProtocolRequest for ChatCompletionRequest {
type Response = ChatCompletionResponse;
fn convert(self, options: ConversionOptions) -> Result<ConversationRequest, ConversionError> {
let (thinking_mode, reasoning_effort) = self.resolve_thinking(options);
let mut parsing_options = self.parsing_options(thinking_mode);
let inference_options = self.inference_options()?;
validate_api_params(
self.stop.as_ref(),
self.stream,
self.stream_options.as_ref(),
)?;
let (tools, tool_choice) = convert_tools(self.tools, self.tool_choice, thinking_mode)?;
parsing_options.parse_tool_calls = !tools.is_empty() && tool_choice != ToolChoice::None;
parsing_options.tool_call_initial_stage = tool_choice == ToolChoice::Required;
let messages = transform_messages(self.messages)?;
validate_tool_text(&tools, &messages)?;
let response_format = convert_response_format(&messages, self.response_format)?;
parsing_options.parse_json_output = matches!(response_format, ResponseFormat::JsonObject);
Ok(ConversationRequest {
conversation: Conversation {
messages,
thinking_mode,
tools,
tool_choice,
reasoning_effort,
response_format,
},
inference_options,
parsing_options,
model: Some(self.model),
stream: self.stream.unwrap_or(false),
})
}
fn chunk_generator(
request: &ConversationRequest,
id: String,
model: String,
) -> ChatCompletionChunkGenerator {
ChatCompletionChunkGenerator::new(
id,
model,
!request.stream,
request.conversation.thinking_mode,
)
}
}
impl ChatCompletionRequest {
fn resolve_thinking(&self, options: ConversionOptions) -> (bool, Option<ReasoningEffort>) {
let effort = self
.reasoning_effort
.and_then(ChatCompletionReasoningEffort::to_reasoning_effort);
let thinking = match self.thinking.as_ref().map(|thinking| thinking.kind) {
Some(ChatCompletionThinkingType::Enabled) => true,
Some(ChatCompletionThinkingType::Disabled) => false,
None => match self.reasoning_effort {
Some(ChatCompletionReasoningEffort::None) => false,
Some(_) => true,
None => options.default_thinking_mode,
},
};
(
thinking,
thinking.then_some(effort.unwrap_or(ReasoningEffort::High)),
)
}
fn inference_options(&self) -> Result<InferenceOptions, ConversionError> {
if self.n.is_some_and(|value| value != 1) {
return Err(ConversionError::bad_request(
"Invalid n value (currently only n = 1 is supported)",
));
}
for (name, value) in [
("frequency_penalty", self.frequency_penalty),
("presence_penalty", self.presence_penalty),
] {
if value.is_some_and(|value| !(-2.0..=2.0).contains(&value)) {
return Err(ConversionError::bad_request(format!(
"{name} must be in [-2, 2]"
)));
}
}
validate_max_tokens(self.max_tokens, "max_tokens")?;
validate_sampling(self.temperature, self.top_p)?;
if self.seed.is_some_and(|value| value >= 1u64 << 63) {
return Err(ConversionError::bad_request("seed must be in [0, 2^63)"));
}
Ok(InferenceOptions {
max_tokens: self.max_tokens,
temperature: self.temperature,
top_p: self.top_p,
..InferenceOptions::default()
})
}
fn parsing_options(&self, thinking: bool) -> ParsingOptions {
ParsingOptions {
reasoning_initial_stage: thinking.then_some(ReasoningStage::Start),
stop_sequences: self
.stop
.clone()
.map(Vec::<String>::from)
.unwrap_or_default(),
..ParsingOptions::default()
}
}
/// Return the setting for usage inclusion in response chunks.
///
/// Read this before consuming the request with `convert`, then pass the value
/// to `ChatCompletionChunkGenerator::with_include_usage`.
pub fn include_usage(&self) -> bool {
self.stream != Some(true)
|| self
.stream_options
.as_ref()
.and_then(|options| options.include_usage)
.unwrap_or(false)
}
}
fn convert_tools(
tools: Option<Vec<ChatCompletionTool>>,
choice: Option<ChatCompletionToolChoiceOption>,
thinking: bool,
) -> Result<(Vec<ToolDefinition>, ToolChoice), ConversionError> {
let tools = tools.unwrap_or_default();
for (index, tool) in tools.iter().enumerate() {
validate_tool_name(&tool.function.name, &format!("tools.{index}.function.name"))?;
}
if matches!(
choice,
Some(ChatCompletionToolChoiceOption::Mode(
ChatCompletionToolChoiceMode::None
))
) {
return Ok((Vec::new(), ToolChoice::None));
}
if tools.is_empty() {
return Ok((Vec::new(), ToolChoice::Auto));
}
let mut names = HashSet::new();
let mut definitions = Vec::new();
for (index, tool) in tools.into_iter().enumerate() {
let tool = tool.function;
if !names.insert(tool.name.clone()) {
return Err(ConversionError::bad_request("Tool names must be unique"));
}
if let Some(parameters) = &tool.parameters {
validate_tool_parameters(parameters, &format!("tools.{index}.function.parameters"))?;
}
definitions.push(ToolDefinition {
name: tool.name,
description: tool.description,
parameters: tool.parameters.unwrap_or_else(|| serde_json::json!({})),
strict: tool.strict,
});
}
let choice = match choice {
Some(ChatCompletionToolChoiceOption::Named(named)) => {
let name = named.function.name;
if !names.contains(&name) {
return Err(ConversionError::bad_request(format!(
"tool_choice: no tool named '{name}' was specified"
)));
}
definitions.retain(|tool| tool.name == name);
ToolChoice::Required
}
Some(ChatCompletionToolChoiceOption::Mode(ChatCompletionToolChoiceMode::Required)) => {
ToolChoice::Required
}
_ => ToolChoice::Auto,
};
if choice == ToolChoice::Required && thinking {
return Err(ConversionError::bad_request(
"Thinking mode does not support this tool_choice",
));
}
Ok((definitions, choice))
}
impl ChatCompletionReasoningEffort {
fn to_reasoning_effort(self) -> Option<ReasoningEffort> {
match self {
Self::None => None,
Self::Minimal | Self::Low => Some(ReasoningEffort::Low),
Self::Medium | Self::High => Some(ReasoningEffort::High),
Self::Xhigh => Some(ReasoningEffort::Xhigh),
Self::Max => Some(ReasoningEffort::Max),
}
}
}
impl ChatCompletionRequestContent {
/// Return whether text carries [`IMAGE_SPECIAL_TOKEN`].
fn contains_image_special_token(&self) -> bool {
match self {
Self::String(content) => content.contains(IMAGE_SPECIAL_TOKEN),
Self::List(blocks) => blocks.iter().any(|block| match block {
ChatCompletionRequestContentBlock::Text { text } => {
text.contains(IMAGE_SPECIAL_TOKEN)
}
_ => false,
}),
}
}
/// Convert text and image blocks. Each image block contributes one
/// [`IMAGE_SPECIAL_TOKEN`] placeholder in the returned text.
fn into_content(self, path: &str) -> Result<(String, Vec<ImageSource>), ConversionError> {
match self {
Self::String(content) => Ok((content, Vec::new())),
Self::List(blocks) => {
let mut texts = Vec::new();
let mut image_sources = Vec::new();
for (block_index, block) in blocks.into_iter().enumerate() {
match block {
ChatCompletionRequestContentBlock::Text { text } => texts.push(text),
ChatCompletionRequestContentBlock::ImageUrl { image_url } => {
let block_path = format!("{path}.content[{block_index}].image_url.url");
image_sources.push(image_url_source(
image_url.url,
image_url.detail,
&block_path,
)?);
texts.push(IMAGE_SPECIAL_TOKEN.to_string());
}
ChatCompletionRequestContentBlock::File {
file_id,
file_data,
filename: _,
} => {
if let Some(_file_id) = file_id {
return Err(file_id_unsupported(&format!(
"{path}.content[{block_index}]"
)));
}
let Some(file_data) = file_data else {
return Err(ConversionError::bad_request(format!(
"{path}.content[{block_index}]: file must have file_data"
)));
};
image_sources.push(data_url_image_source(file_data, None));
texts.push(IMAGE_SPECIAL_TOKEN.to_string());
}
}
}
Ok((texts.join("\n\n"), image_sources))
}
}
}
/// Convert text blocks and reject image blocks.
fn into_text(self, path: &str, role: &str) -> Result<String, ConversionError> {
let (content, image_sources) = self.into_content(path)?;
if !image_sources.is_empty() {
return Err(image_not_allowed(role));
}
Ok(content)
}
}
/// Return whether any message carries [`IMAGE_SPECIAL_TOKEN`] in text that
/// reaches the prompt.
///
/// Message content and assistant reasoning content are checked. Tool call names
/// and arguments are checked after conversion by [`validate_tool_text`].
fn messages_contain_image_special_token(messages: &[ChatCompletionRequestMessage]) -> bool {
messages.iter().any(|message| match message {
ChatCompletionRequestMessage::System { content }
| ChatCompletionRequestMessage::User { content }
| ChatCompletionRequestMessage::Tool { content, .. } => {
content.contains_image_special_token()
}
ChatCompletionRequestMessage::Assistant {
content,
reasoning_content,
..
} => {
content
.as_ref()
.is_some_and(ChatCompletionRequestContent::contains_image_special_token)
|| reasoning_content
.as_ref()
.is_some_and(|text| text.contains(IMAGE_SPECIAL_TOKEN))
}
ChatCompletionRequestMessage::LatestReminder { content } => {
content.contains(IMAGE_SPECIAL_TOKEN)
}
})
}
fn transform_messages(
messages: Vec<ChatCompletionRequestMessage>,
) -> Result<Vec<InputMessage>, ConversionError> {
if messages.is_empty() {
return Err(ConversionError::bad_request("Empty input messages"));
}
if messages_contain_image_special_token(&messages) {
return Err(image_special_token_not_allowed());
}
let mut result = Vec::new();
let mut message_iter = messages.into_iter().enumerate();
while let Some((index, message)) = message_iter.next() {
let path = format!("messages[{index}]");
match message {
ChatCompletionRequestMessage::System { content } => {
let content = content.into_text(&path, "system")?;
if !content.is_empty() {
result.push(InputMessage::System { content });
}
}
ChatCompletionRequestMessage::User { content } => {
let (content, image_sources) = content.into_content(&path)?;
result.push(InputMessage::User {
content,
image_sources,
});
}
ChatCompletionRequestMessage::Assistant {
content,
reasoning_content,
tool_calls,
} => {
if content.is_none() && tool_calls.is_none() {
return Err(ConversionError::bad_request(
"Invalid assistant message: content or tool_calls must be set",
));
}
let content = content
.map(|content| content.into_text(&path, "assistant"))
.transpose()?
.unwrap_or_default();
let mut tool_results = Vec::new();
let mut input_tool_calls = None;
if let Some(tool_calls) = &tool_calls {
let count = tool_calls.len();
if count == 0 {
return Err(ConversionError::bad_request(format!(
"Invalid '{path}.tool_calls': empty array. Expected an array with minimum length 1, but got an empty \
array instead."
)));
}
let mut tool_ids: HashSet<String> = HashSet::new();
for tool_call in tool_calls {
if tool_ids.contains(&tool_call.id) {
return Err(ConversionError::bad_request(format!(
"Duplicate value for 'tool_call_id' of {} in message[{}]",
tool_call.id, index
)));
}
tool_ids.insert(tool_call.id.clone());
}
// The assistant message is followed by one tool message per call.
let mut checked_tool_call_ids: HashSet<String> = HashSet::new();
for _ in 0..count {
let Some((
tool_result_index,
ChatCompletionRequestMessage::Tool {
content,
tool_call_id,
},
)) = message_iter.next()
else {
return Err(ConversionError::bad_request(
"An assistant message with 'tool_calls' must be followed by tool messages responding to each \
'tool_call_id'. (insufficient tool messages following tool_calls message)",
));
};
if !tool_ids.contains(&tool_call_id) {
return Err(ConversionError::bad_request(format!(
"An assistant message with 'tool_calls' must be followed by tool messages responding to each \
'tool_call_id'. Unexpected tool_call_id: {tool_call_id}"
)));
}
if checked_tool_call_ids.contains(&tool_call_id) {
return Err(ConversionError::bad_request(format!(
"Duplicate value for 'tool_call_id' of {tool_call_id} in message[{tool_result_index}]",
)));
}
checked_tool_call_ids.insert(tool_call_id.clone());
let (content, image_sources) = content
.into_content(&format!("messages[{tool_result_index}].content"))?;
tool_results.push(InputMessage::Tool {
content,
image_sources,
tool_call_id,
});
}
input_tool_calls = Some(
tool_calls
.iter()
.map(|tool_call| ToolCall {
id: tool_call.id.clone(),
name: tool_call.function.name.clone(),
arguments: tool_call.function.arguments.clone(),
})
.collect(),
);
}
result.push(InputMessage::Assistant {
content,
reasoning_content,
tool_calls: input_tool_calls,
});
result.extend(tool_results);
}
ChatCompletionRequestMessage::Tool { .. } => {
// Tool results must follow an assistant tool call.
return Err(ConversionError::bad_request(
"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'",
));
}
ChatCompletionRequestMessage::LatestReminder { content } => {
result.push(InputMessage::LatestReminder { content });
}
}
}
Ok(result)
}
fn convert_response_format(
messages: &[InputMessage],
response_format: Option<ChatCompletionResponseFormat>,
) -> Result<ResponseFormat, ConversionError> {
match response_format {
Some(ChatCompletionResponseFormat::JsonObject) => {
if !contains_json_instruction(messages) {
return Err(ConversionError::bad_request(
"Prompt must contain the word 'json' in some form to use 'response_format' of type 'json_object'.",
));
}
Ok(ResponseFormat::JsonObject)
}
Some(ChatCompletionResponseFormat::Regex { .. }) => Err(ConversionError::bad_request(
"response_format: only text and json_object are supported",
)),
Some(ChatCompletionResponseFormat::Text | ChatCompletionResponseFormat::JsonSchema)
| None => Ok(ResponseFormat::Text),
}
}
/// Validate stop sequences and streaming options.
fn validate_api_params(
stop: Option<&ChatCompletionStopSequences>,
stream: Option<bool>,
stream_options: Option<&ChatCompletionStreamOptions>,
) -> Result<(), ConversionError> {
if let Some(ChatCompletionStopSequences::Array(stop_sequences)) = stop
&& stop_sequences.len() > MAX_STOP_SEQUENCES
{
return Err(ConversionError::bad_request(format!(
"ChatCompletionStopSequences string array too long: {}",
stop_sequences.len()
)));
}
if stream != Some(true) && stream_options.is_some() {
return Err(ConversionError::bad_request(
"stream_options should be set along with stream = true",
));
}
Ok(())
}

View File

@ -0,0 +1,24 @@
//! Chat Completions request deserialization and conversion.
//!
//! [`ChatCompletionRequest`] accepts messages, sampling parameters, client
//! tools, and JSON object output. An explicit `thinking` setting overrides
//! `reasoning_effort`, which overrides the caller's thinking default. Sampling
//! values are validated and passed through to the caller.
//!
//! Text, images, and historical tool calls are supported. A tool result must
//! follow the assistant message that requested it, and tool names must be
//! unique. Named or required tool choices require thinking to be disabled when
//! tools are present. JSON Schema output, `logprobs`, and `top_logprobs` are
//! accepted and ignored. Function-tool `strict` flags are passed through for the
//! caller to enforce. Model resolution and tool execution belong to the caller.
//!
//! Input text must not spell out the image placeholder. Every placeholder in a
//! prompt corresponds to one image source, and the adapter inserts them for
//! image blocks only, so message content, assistant reasoning content, tool
//! definitions, and historical tool calls that carry the placeholder spelling
//! are rejected.
pub use schema::*;
mod convert;
mod schema;

View File

@ -0,0 +1,235 @@
use serde::Deserialize;
use deepseek_recipe_core::multimodal::ImageDetail;
/// A Chat Completions request. Unknown fields are ignored.
///
/// Model resolution and inference execution belong to the caller. Client tools,
/// historical tool calls, and image blocks are supported. Server tools are
/// rejected by this adapter. Constrained decoding and model-specific sampling
/// belong to the caller. JSON Schema output, `logprobs`, and `top_logprobs` are
/// accepted and ignored.
#[derive(Debug, Deserialize)]
pub struct ChatCompletionRequest {
pub messages: Vec<ChatCompletionRequestMessage>,
pub model: String,
/// Validated in [-2, 2], then omitted from the converted request. Extract
/// before conversion if the backend supports it.
pub frequency_penalty: Option<f32>,
pub max_tokens: Option<u32>,
/// Only one completion per request is supported.
pub n: Option<u8>,
/// Validated in [-2, 2], then omitted from the converted request. Extract
/// before conversion if the backend supports it.
pub presence_penalty: Option<f32>,
/// Validated in [0, 2^63), then omitted from the converted request. Extract
/// before conversion if the backend supports it.
pub seed: Option<u64>,
pub stop: Option<ChatCompletionStopSequences>,
pub stream: Option<bool>,
pub stream_options: Option<ChatCompletionStreamOptions>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
/// Text or JSON object output. JSON Schema settings are accepted and ignored.
pub response_format: Option<ChatCompletionResponseFormat>,
pub tools: Option<Vec<ChatCompletionTool>>,
/// With tools present, required and named choices require thinking off.
pub tool_choice: Option<ChatCompletionToolChoiceOption>,
pub reasoning_effort: Option<ChatCompletionReasoningEffort>,
/// Explicit thinking overrides reasoning effort and the conversion default.
pub thinking: Option<ChatCompletionThinking>,
/// Deserialized for the caller; conversion leaves this setting unused.
pub parallel_tool_calls: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ChatCompletionRequestMessage {
System {
content: ChatCompletionRequestContent,
},
User {
content: ChatCompletionRequestContent,
},
Assistant {
content: Option<ChatCompletionRequestContent>,
reasoning_content: Option<String>,
tool_calls: Option<Vec<ChatCompletionRequestToolCall>>,
},
Tool {
content: ChatCompletionRequestContent,
tool_call_id: String,
},
LatestReminder {
content: String,
},
}
#[derive(Debug, Deserialize)]
pub struct ChatCompletionRequestToolCall {
pub id: String,
pub function: ChatCompletionRequestFunctionCall,
}
#[derive(Debug, Deserialize)]
pub struct ChatCompletionRequestFunctionCall {
pub name: String,
pub arguments: String,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ChatCompletionRequestContent {
String(String),
List(Vec<ChatCompletionRequestContentBlock>),
}
#[derive(Debug, Deserialize)]
pub struct ChatCompletionImageUrl {
/// HTTP URL or base64 data URL. A value that does not start with `http`
/// must carry a `base64` body.
pub url: String,
#[serde(default)]
pub detail: Option<ImageDetail>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ChatCompletionRequestContentBlock {
Text {
text: String,
},
ImageUrl {
image_url: ChatCompletionImageUrl,
},
/// One image carried as a data URL. The resolver accepts base64 and
/// percent-encoded bodies. `file_id` is rejected during conversion; the
/// filename is accepted and ignored.
File {
file_id: Option<String>,
file_data: Option<String>,
filename: Option<String>,
},
}
/// One stop sequence or a list of stop sequences.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ChatCompletionStopSequences {
Array(Vec<String>),
String(String),
}
impl From<ChatCompletionStopSequences> for Vec<String> {
fn from(value: ChatCompletionStopSequences) -> Self {
match value {
ChatCompletionStopSequences::Array(values) => values,
ChatCompletionStopSequences::String(value) => vec![value],
}
}
}
#[derive(Debug, Deserialize)]
pub struct ChatCompletionStreamOptions {
/// Include a null usage field before the final chunk. The final chunk
/// carries usage regardless of this setting.
pub include_usage: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ChatCompletionResponseFormat {
JsonObject,
/// Accepted and ignored, including nested schema fields.
JsonSchema,
Regex {
regex: String,
},
Text,
}
#[derive(Debug, Deserialize)]
pub struct ChatCompletionTool {
pub r#type: ChatCompletionToolType,
pub function: ChatCompletionFunctionDefinition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatCompletionToolType {
Function,
}
#[derive(Debug, Deserialize)]
pub struct ChatCompletionFunctionDefinition {
pub name: String,
pub description: Option<String>,
/// A JSON Schema of type object. Omission renders an empty schema.
pub parameters: Option<serde_json::Value>,
/// Passed through for the caller to enforce during inference.
pub strict: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ChatCompletionToolChoiceOption {
/// One of `"none"`, `"auto"`, or `"required"`.
Mode(ChatCompletionToolChoiceMode),
Named(ChatCompletionNamedToolChoice),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatCompletionToolChoiceMode {
None,
Auto,
Required,
}
#[derive(Debug, Deserialize)]
pub struct ChatCompletionNamedToolChoice {
pub r#type: ChatCompletionToolType,
pub function: ChatCompletionToolChoiceFunction,
}
#[derive(Debug, Deserialize)]
pub struct ChatCompletionToolChoiceFunction {
pub name: String,
}
/// Optional thinking control for DeepSeek-compatible requests.
#[derive(Debug, Deserialize)]
pub struct ChatCompletionThinking {
#[serde(rename = "type")]
pub kind: ChatCompletionThinkingType,
/// Deserialized for the caller; conversion leaves this setting unused.
pub budget_tokens: Option<u64>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatCompletionThinkingType {
#[serde(alias = "adaptive")]
Enabled,
Disabled,
}
/// Reasoning effort accepted by this adapter. Unless overridden by `thinking`,
/// `none` disables thinking and other values enable it. Enabled thinking uses
/// high effort by default. Supplied values map to the shared reasoning levels.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatCompletionReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
Xhigh,
Max,
}

View File

@ -0,0 +1,169 @@
use std::time::{SystemTime, UNIX_EPOCH};
use crate::stream::{ChunkGenerator, OutputChunk, PromptUsage};
use super::schema::{
CHAT_COMPLETION_CHUNK_OBJECT, ChatCompletionChunk, ChatCompletionChunkChoice,
ChatCompletionFinishReason, ChatCompletionFunctionArgumentsDelta, ChatCompletionMessageDelta,
ChatCompletionResponseFunctionCall, ChatCompletionResponseToolCall, ChatCompletionRole,
ChatCompletionToolCallDelta, ChatCompletionToolCallType, ChatCompletionUsage,
};
/// Converts parsed output into Chat Completions streaming chunks.
///
/// The caller supplies an ordered stream for one inference. Tool argument
/// deltas follow the tool call whose arguments they extend.
#[derive(Debug)]
pub struct ChatCompletionChunkGenerator {
id: String,
model: String,
created: u64,
thinking_mode: bool,
include_usage: bool,
tool_call_index: u32,
prompt_usage: PromptUsage,
system_fingerprint: Option<String>,
}
impl ChatCompletionChunkGenerator {
/// The caller supplies a unique response ID and public model name. Tool call
/// IDs combine the response ID and per-response tool index.
pub fn new(id: String, model: String, include_usage: bool, thinking_mode: bool) -> Self {
Self {
id,
model,
created: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or(0),
thinking_mode,
include_usage,
tool_call_index: 0,
prompt_usage: PromptUsage::default(),
system_fingerprint: None,
}
}
/// Apply usage settings extracted from the original Chat Completions request.
pub fn with_include_usage(mut self, include_usage: bool) -> Self {
self.include_usage = include_usage;
self
}
fn new_chunk(
&self,
delta: ChatCompletionMessageDelta,
finish_reason: Option<ChatCompletionFinishReason>,
usage: Option<ChatCompletionUsage>,
) -> Vec<ChatCompletionChunk> {
// Include null usage before the final snapshot when requested. Otherwise the
// final chunk includes usage.
let usage = if self.include_usage {
Some(usage)
} else {
usage.map(Some)
};
vec![ChatCompletionChunk {
id: self.id.clone(),
object: CHAT_COMPLETION_CHUNK_OBJECT,
created: self.created,
model: self.model.clone(),
system_fingerprint: self.system_fingerprint.clone(),
choices: vec![ChatCompletionChunkChoice {
index: 0,
delta,
logprobs: None,
finish_reason,
}],
usage,
}]
}
}
impl ChunkGenerator for ChatCompletionChunkGenerator {
type Chunk = ChatCompletionChunk;
async fn generate(&mut self, chunk: OutputChunk) -> Vec<Self::Chunk> {
match chunk {
OutputChunk::Start {
system_fingerprint,
usage,
} => {
self.prompt_usage = usage;
self.system_fingerprint = system_fingerprint;
let delta = ChatCompletionMessageDelta {
role: Some(ChatCompletionRole::Assistant),
content: if self.thinking_mode {
Some(None)
} else {
Some(Some(String::new()))
},
reasoning_content: self.thinking_mode.then(|| Some(String::new())),
tool_calls: None,
};
self.new_chunk(delta, None, None)
}
OutputChunk::Raw { content } => {
let delta = ChatCompletionMessageDelta {
content: Some(Some(content)),
reasoning_content: self.thinking_mode.then_some(None),
..Default::default()
};
self.new_chunk(delta, None, None)
}
OutputChunk::Reasoning { content } => {
let delta = ChatCompletionMessageDelta {
content: Some(None),
reasoning_content: Some(Some(content)),
..Default::default()
};
self.new_chunk(delta, None, None)
}
OutputChunk::ToolCallBegin => vec![],
OutputChunk::ToolCall {
tool_name,
arguments,
} => {
let index = self.tool_call_index;
self.tool_call_index += 1;
let delta = ChatCompletionMessageDelta {
tool_calls: Some(vec![ChatCompletionToolCallDelta::Start {
index,
call: ChatCompletionResponseToolCall {
id: format!("call_{}_{index}", self.id),
r#type: ChatCompletionToolCallType::Function,
function: ChatCompletionResponseFunctionCall {
name: tool_name,
arguments,
},
},
}]),
..Default::default()
};
self.new_chunk(delta, None, None)
}
OutputChunk::ToolArgumentsDelta { content } => {
let delta = ChatCompletionMessageDelta {
tool_calls: Some(vec![ChatCompletionToolCallDelta::Arguments {
index: self.tool_call_index - 1,
function: ChatCompletionFunctionArgumentsDelta { arguments: content },
}]),
..Default::default()
};
self.new_chunk(delta, None, None)
}
OutputChunk::Finish { reason, usage, .. } => {
let delta = ChatCompletionMessageDelta {
content: Some(Some(String::new())),
reasoning_content: self.thinking_mode.then_some(None),
..Default::default()
};
self.new_chunk(
delta,
Some(reason.into()),
Some(ChatCompletionUsage::from((self.prompt_usage, usage))),
)
}
}
}
}

View File

@ -0,0 +1,5 @@
pub use chunk_generator::*;
pub use schema::*;
mod chunk_generator;
mod schema;

View File

@ -0,0 +1,309 @@
use serde::Serialize;
use crate::response::ProtocolResponse;
use crate::stream::{CompletionUsage, FinishReason, PromptUsage};
use crate::util::append_delta::AppendDelta;
use super::chunk_generator::ChatCompletionChunkGenerator;
const CHAT_COMPLETION_OBJECT: &str = "chat.completion";
pub(crate) const CHAT_COMPLETION_CHUNK_OBJECT: &str = "chat.completion.chunk";
/// A complete response, also usable as an accumulator for generated chunks.
/// Chunks are accumulated in inference order. Tool argument deltas extend the
/// tool call identified by their index.
#[derive(Debug, Serialize)]
pub struct ChatCompletionResponse {
pub id: String,
pub object: &'static str,
pub created: u64,
pub model: String,
pub system_fingerprint: Option<String>,
pub choices: Vec<ChatCompletionChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<ChatCompletionUsage>,
}
#[derive(Debug, Serialize)]
pub struct ChatCompletionChoice {
pub index: u32,
pub message: ChatCompletionResponseMessage,
pub logprobs: Option<ChatCompletionLogprobs>,
pub finish_reason: Option<ChatCompletionFinishReason>,
}
#[derive(Debug, Serialize)]
pub struct ChatCompletionChunk {
pub id: String,
pub object: &'static str,
pub created: u64,
pub model: String,
pub system_fingerprint: Option<String>,
pub choices: Vec<ChatCompletionChunkChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Option<ChatCompletionUsage>>,
}
#[derive(Debug, Serialize)]
pub struct ChatCompletionChunkChoice {
pub index: u32,
pub delta: ChatCompletionMessageDelta,
pub logprobs: Option<ChatCompletionLogprobs>,
pub finish_reason: Option<ChatCompletionFinishReason>,
}
/// A complete assistant message accumulated from response deltas.
#[derive(Debug, Serialize)]
pub struct ChatCompletionResponseMessage {
pub role: ChatCompletionRole,
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ChatCompletionResponseToolCall>>,
}
/// An incremental update to an assistant message.
#[derive(Debug, Default, Serialize)]
pub struct ChatCompletionMessageDelta {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<ChatCompletionRole>,
/// `None` omits the field; `Some(None)` serializes an explicit null.
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ChatCompletionToolCallDelta>>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatCompletionRole {
Assistant,
}
/// A complete tool call in an assistant response message.
#[derive(Debug, Serialize)]
pub struct ChatCompletionResponseToolCall {
pub id: String,
pub r#type: ChatCompletionToolCallType,
pub function: ChatCompletionResponseFunctionCall,
}
#[derive(Debug, Serialize)]
pub struct ChatCompletionResponseFunctionCall {
pub name: String,
pub arguments: String,
}
/// An initial tool call or an update to its arguments.
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum ChatCompletionToolCallDelta {
Start {
index: u32,
#[serde(flatten)]
call: ChatCompletionResponseToolCall,
},
Arguments {
index: u32,
function: ChatCompletionFunctionArgumentsDelta,
},
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatCompletionToolCallType {
Function,
}
#[derive(Debug, Serialize)]
pub struct ChatCompletionFunctionArgumentsDelta {
pub arguments: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatCompletionFinishReason {
Stop,
Length,
ContentFilter,
InsufficientSystemResource,
ToolCalls,
Aborted,
}
impl From<FinishReason> for ChatCompletionFinishReason {
fn from(value: FinishReason) -> Self {
match value {
FinishReason::Stop | FinishReason::StopSequence | FinishReason::EndOfStream => {
Self::Stop
}
FinishReason::ToolCalls => Self::ToolCalls,
FinishReason::ContentFilter => Self::ContentFilter,
FinishReason::Length => Self::Length,
}
}
}
/// Log probabilities are not supplied by the current inference contract.
#[derive(Debug, Serialize)]
pub struct ChatCompletionLogprobs {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Vec<ChatCompletionTokenLogprob>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<Vec<ChatCompletionTokenLogprob>>,
}
impl AppendDelta<ChatCompletionLogprobs> for ChatCompletionLogprobs {
fn append(&mut self, delta: ChatCompletionLogprobs) {
self.content.append(delta.content);
self.reasoning_content.append(delta.reasoning_content);
}
}
#[derive(Debug, Serialize)]
pub struct ChatCompletionTokenLogprob {
pub token: String,
pub logprob: f32,
pub bytes: Option<Vec<u8>>,
pub top_logprobs: Vec<ChatCompletionTopLogprob>,
}
#[derive(Debug, Serialize)]
pub struct ChatCompletionTopLogprob {
pub token: String,
pub logprob: f32,
pub bytes: Option<Vec<u8>>,
}
#[derive(Debug, Serialize)]
pub struct ChatCompletionUsage {
pub prompt_tokens: usize,
pub completion_tokens: usize,
pub total_tokens: usize,
pub prompt_tokens_details: ChatCompletionInputTokenUsage,
#[serde(skip_serializing_if = "Option::is_none")]
pub completion_tokens_details: Option<ChatCompletionOutputTokenUsage>,
pub prompt_cache_hit_tokens: usize,
pub prompt_cache_miss_tokens: usize,
}
#[derive(Debug, Serialize)]
pub struct ChatCompletionInputTokenUsage {
pub cached_tokens: usize,
}
#[derive(Debug, Serialize)]
pub struct ChatCompletionOutputTokenUsage {
pub reasoning_tokens: usize,
}
impl From<(PromptUsage, CompletionUsage)> for ChatCompletionUsage {
fn from((prompt, completion): (PromptUsage, CompletionUsage)) -> Self {
Self {
prompt_tokens: prompt.prompt_tokens,
completion_tokens: completion.completion_tokens,
total_tokens: prompt.prompt_tokens + completion.completion_tokens,
prompt_tokens_details: ChatCompletionInputTokenUsage {
cached_tokens: prompt.prompt_cache_hit_tokens,
},
completion_tokens_details: None,
prompt_cache_hit_tokens: prompt.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: prompt
.prompt_tokens
.saturating_sub(prompt.prompt_cache_hit_tokens),
}
}
}
impl ProtocolResponse for ChatCompletionResponse {
type ChunkGenerator = ChatCompletionChunkGenerator;
fn new(
id: String,
model: String,
created: u64,
_prompt_tokens: usize,
_prompt_cache_hit_tokens: usize,
) -> Self {
Self {
id,
object: CHAT_COMPLETION_OBJECT,
created,
model,
system_fingerprint: None,
choices: vec![],
usage: None,
}
}
fn done_message() -> Option<String> {
Some("[DONE]".to_string())
}
}
impl AppendDelta<ChatCompletionChunk> for ChatCompletionResponse {
fn append(&mut self, chunk: ChatCompletionChunk) {
if let Some(Some(usage)) = chunk.usage {
self.usage = Some(usage);
}
if self.system_fingerprint.is_none() {
self.system_fingerprint = chunk.system_fingerprint;
}
for chunk_choice in chunk.choices {
let choice = match self
.choices
.iter_mut()
.find(|choice| choice.index == chunk_choice.index)
{
Some(choice) => choice,
None => {
self.choices.push(ChatCompletionChoice {
index: chunk_choice.index,
message: ChatCompletionResponseMessage {
role: ChatCompletionRole::Assistant,
content: None,
reasoning_content: None,
tool_calls: None,
},
logprobs: None,
finish_reason: None,
});
self.choices.last_mut().expect("the choice was inserted")
}
};
choice.message.append(chunk_choice.delta);
choice.logprobs.append(chunk_choice.logprobs);
if chunk_choice.finish_reason.is_some() {
choice.finish_reason = chunk_choice.finish_reason;
}
}
}
}
impl AppendDelta<ChatCompletionMessageDelta> for ChatCompletionResponseMessage {
fn append(&mut self, delta: ChatCompletionMessageDelta) {
if let Some(role) = delta.role {
self.role = role;
}
self.content.append(delta.content.flatten());
self.reasoning_content
.append(delta.reasoning_content.flatten());
if let Some(tool_deltas) = delta.tool_calls {
let tool_calls = self.tool_calls.get_or_insert_with(Vec::new);
for tool_delta in tool_deltas {
match tool_delta {
ChatCompletionToolCallDelta::Start { call, .. } => tool_calls.push(call),
ChatCompletionToolCallDelta::Arguments { index, function } => {
if let Some(call) = tool_calls.get_mut(index as usize) {
call.function.arguments.push_str(&function.arguments);
}
}
}
}
}
}
}

View File

@ -0,0 +1,5 @@
pub use chat_completion::request::ChatCompletionRequest;
pub use responses::request::ResponsesRequest;
pub mod chat_completion;
pub mod responses;

View File

@ -0,0 +1,2 @@
pub mod request;
pub mod response;

View File

@ -0,0 +1,667 @@
use std::collections::{HashMap, HashSet};
use deepseek_recipe_core::conversation::{Conversation, ReasoningEffort, ResponseFormat};
use deepseek_recipe_core::messages::{InputMessage, ToolCall};
use deepseek_recipe_core::multimodal::{IMAGE_SPECIAL_TOKEN, ImageSource};
use deepseek_recipe_core::tools::ToolChoice;
use itertools::Itertools;
use crate::protocol::image::{
UNSUPPORTED_DOCUMENT_PLACEHOLDER, file_id_unsupported, image_not_allowed, image_url_source,
};
use crate::protocol::openai::responses::response::{ResponsesChunkGenerator, ResponsesResponse};
use crate::protocol::validation::{
contains_json_instruction, image_special_token_not_allowed, validate_max_tokens,
validate_sampling, validate_tool_text,
};
use crate::request::{
ConversationRequest, ConversionError, ConversionOptions, InferenceOptions, ProtocolRequest,
};
use crate::stream::state_machine::{ParsingOptions, ReasoningStage};
use super::schema::{
ResponsesContentBlock, ResponsesInput, ResponsesInputImage, ResponsesInputItem,
ResponsesInputMessage, ResponsesMessageContent, ResponsesMessageRole, ResponsesReasoningEffort,
ResponsesReasoningItem, ResponsesRequest, ResponsesTextConfig, ResponsesTextFormat,
ResponsesTool, ResponsesToolCallOutputContent, ResponsesToolCallOutputItem,
ResponsesTypedInputItem,
};
use super::tools::ResponsesToolSet;
impl ResponsesMessageContent {
/// Convert text, substitute document placeholders, and collect images with
/// one [`IMAGE_SPECIAL_TOKEN`] placeholder per image.
fn into_content(self, path: &str) -> Result<(String, Vec<ImageSource>), ConversionError> {
match self {
ResponsesMessageContent::String(s) => Ok((s, Vec::new())),
ResponsesMessageContent::List(blocks) => {
let mut texts = Vec::new();
let mut image_sources = Vec::new();
for (block_index, block) in blocks.into_iter().enumerate() {
let block_path = format!("{path}.content[{block_index}]");
match block {
ResponsesContentBlock::InputText { text }
| ResponsesContentBlock::OutputText { text } => texts.push(text),
ResponsesContentBlock::InputImage(image) => {
image_sources.push(input_image_source(image, &block_path)?);
texts.push(IMAGE_SPECIAL_TOKEN.to_string());
}
ResponsesContentBlock::InputFile {} => {
texts.push(UNSUPPORTED_DOCUMENT_PLACEHOLDER.to_string());
}
ResponsesContentBlock::Unsupported => return Err(unsupported_content()),
}
}
Ok((texts.join("\n\n"), image_sources))
}
}
}
/// Convert text and document placeholders, rejecting images.
fn into_text(self, path: &str, role: &str) -> Result<String, ConversionError> {
let (content, image_sources) = self.into_content(path)?;
if !image_sources.is_empty() {
return Err(image_not_allowed(role));
}
Ok(content)
}
}
impl ResponsesToolCallOutputContent {
/// Convert text and document placeholders, collecting image sources.
fn into_content(self, path: &str) -> Result<(String, Vec<ImageSource>), ConversionError> {
match self {
ResponsesToolCallOutputContent::String(s) => Ok((s, Vec::new())),
ResponsesToolCallOutputContent::List(items) => {
let mut texts = Vec::new();
let mut image_sources = Vec::new();
for (item_index, item) in items.into_iter().enumerate() {
let item_path = format!("{path}[{item_index}]");
match item {
ResponsesToolCallOutputItem::InputText { text } => texts.push(text),
ResponsesToolCallOutputItem::InputImage(image) => {
image_sources.push(input_image_source(image, &item_path)?);
texts.push(IMAGE_SPECIAL_TOKEN.to_string());
}
ResponsesToolCallOutputItem::InputFile {} => {
texts.push(UNSUPPORTED_DOCUMENT_PLACEHOLDER.to_string());
}
ResponsesToolCallOutputItem::Unsupported => {
return Err(unsupported_content());
}
}
}
Ok((texts.join("\n\n"), image_sources))
}
}
}
}
/// Return whether the input or instructions carry [`IMAGE_SPECIAL_TOKEN`].
///
/// Message text, tool output text, and reasoning text are checked. Tool call
/// names and arguments are checked after conversion by [`validate_tool_text`].
fn contains_image_special_token(
input: Option<&ResponsesInput>,
instructions: Option<&str>,
) -> bool {
if instructions.is_some_and(|text| text.contains(IMAGE_SPECIAL_TOKEN)) {
return true;
}
match input {
Some(ResponsesInput::String(content)) => content.contains(IMAGE_SPECIAL_TOKEN),
Some(ResponsesInput::Items(items)) => items.iter().any(item_contains_image_special_token),
None => false,
}
}
/// Return whether one input item carries [`IMAGE_SPECIAL_TOKEN`].
fn item_contains_image_special_token(item: &ResponsesInputItem) -> bool {
match item {
ResponsesInputItem::Message(message) => {
message_content_contains_image_special_token(&message.content)
}
ResponsesInputItem::Typed(typed) => match typed {
ResponsesTypedInputItem::Message(message) => {
message_content_contains_image_special_token(&message.content)
}
ResponsesTypedInputItem::FunctionCallOutput(output) => {
output_content_contains_image_special_token(&output.output)
}
ResponsesTypedInputItem::CustomToolCallOutput(output) => output
.output
.as_ref()
.is_some_and(output_content_contains_image_special_token),
ResponsesTypedInputItem::Reasoning(reasoning) => {
reasoning.content.as_ref().is_some_and(|blocks| {
blocks
.iter()
.any(|block| block.text.contains(IMAGE_SPECIAL_TOKEN))
})
}
_ => false,
},
}
}
/// Return whether one message content carries [`IMAGE_SPECIAL_TOKEN`].
fn message_content_contains_image_special_token(content: &ResponsesMessageContent) -> bool {
match content {
ResponsesMessageContent::String(content) => content.contains(IMAGE_SPECIAL_TOKEN),
ResponsesMessageContent::List(blocks) => blocks.iter().any(|block| match block {
ResponsesContentBlock::InputText { text }
| ResponsesContentBlock::OutputText { text } => text.contains(IMAGE_SPECIAL_TOKEN),
_ => false,
}),
}
}
/// Return whether one tool call output carries [`IMAGE_SPECIAL_TOKEN`].
fn output_content_contains_image_special_token(content: &ResponsesToolCallOutputContent) -> bool {
match content {
ResponsesToolCallOutputContent::String(content) => content.contains(IMAGE_SPECIAL_TOKEN),
ResponsesToolCallOutputContent::List(items) => items.iter().any(|item| match item {
ResponsesToolCallOutputItem::InputText { text } => text.contains(IMAGE_SPECIAL_TOKEN),
_ => false,
}),
}
}
/// Convert one Responses image reference.
fn input_image_source(
image: ResponsesInputImage,
path: &str,
) -> Result<ImageSource, ConversionError> {
let image_url = image.image_url.filter(|url| !url.is_empty());
match (image_url, image.file_id) {
(None, None) => Err(ConversionError::bad_request(format!(
"{path}: input_image must have image_url or file_id"
))),
(Some(_), Some(_)) => Err(ConversionError::bad_request(format!(
"{path}: input_image cannot have both image_url and file_id"
))),
(None, Some(_)) => Err(file_id_unsupported(path)),
(Some(url), None) => image_url_source(url, image.detail, &format!("{path}.image_url")),
}
}
fn check_tool_call_id(
item_idx: usize,
call_id: &str,
pending: &HashSet<String>,
resolved: &HashSet<String>,
) -> Result<(), ConversionError> {
if call_id.is_empty() {
return Err(ConversionError::bad_request(format!(
"Invalid 'input[{item_idx}].call_id': empty string. Expected a string with minimum length 1, but got an empty string \
instead."
)));
}
if pending.contains(call_id) || resolved.contains(call_id) {
return Err(ConversionError::bad_request(format!(
"Duplicate 'call_id': {call_id}."
)));
}
Ok(())
}
fn check_tool_output_id(
item_idx: usize,
call_id: &str,
pending: &mut HashSet<String>,
resolved: &mut HashSet<String>,
) -> Result<(), ConversionError> {
if call_id.is_empty() {
return Err(ConversionError::bad_request(format!(
"Invalid 'input[{item_idx}].call_id': empty string. Expected a string with minimum length 1, but got an empty string \
instead."
)));
}
if !pending.contains(call_id) {
if resolved.contains(call_id) {
return Err(ConversionError::bad_request(format!(
"Duplicate tool output for call_id: {call_id}."
)));
}
return Err(ConversionError::bad_request(format!(
"No tool call found for tool output with call_id {call_id}."
)));
}
pending.remove(call_id);
resolved.insert(call_id.to_string());
Ok(())
}
fn check_pending_calls(pending_call_ids: &HashSet<String>) -> Result<(), ConversionError> {
if let Some(call_id) = pending_call_ids.iter().next() {
return Err(ConversionError::bad_request(format!(
"No tool output found for tool call {call_id}."
)));
}
Ok(())
}
#[derive(Default)]
struct ResponsesToolResult {
content: String,
image_sources: Vec<ImageSource>,
}
#[derive(Default)]
struct ResponsesToolCallGroup {
calls: Vec<ToolCall>,
outputs: HashMap<String, ResponsesToolResult>,
}
impl ResponsesToolCallGroup {
fn is_complete(&self) -> bool {
self.outputs.len() >= self.calls.len()
}
fn append_messages(self, messages: &mut Vec<InputMessage>) {
if self.calls.is_empty() {
return;
}
let mut calls = self.calls;
calls.sort_by(|a, b| a.id.cmp(&b.id));
let mut outputs = self.outputs;
let tool_results: Vec<InputMessage> = calls
.iter()
.filter_map(|call| {
outputs.remove(&call.id).map(|output| InputMessage::Tool {
content: output.content,
image_sources: output.image_sources,
tool_call_id: call.id.clone(),
})
})
.collect();
if !matches!(messages.last(), Some(InputMessage::Assistant { .. })) {
messages.push(InputMessage::Assistant {
content: String::new(),
reasoning_content: None,
tool_calls: None,
});
}
if let Some(InputMessage::Assistant { tool_calls, .. }) = messages.last_mut() {
match tool_calls {
Some(existing) => existing.extend(calls),
None => *tool_calls = Some(calls),
}
}
messages.extend(tool_results);
}
}
fn push_call_to_group(pending_group: &mut Option<ResponsesToolCallGroup>, tool_call: ToolCall) {
pending_group
.get_or_insert_with(ResponsesToolCallGroup::default)
.calls
.push(tool_call);
}
fn push_output_to_group(
pending_group: &mut Option<ResponsesToolCallGroup>,
call_id: String,
output: ResponsesToolResult,
messages: &mut Vec<InputMessage>,
) {
let Some(group) = pending_group.as_mut() else {
messages.push(InputMessage::Tool {
content: output.content,
image_sources: output.image_sources,
tool_call_id: call_id,
});
return;
};
group.outputs.insert(call_id, output);
if group.is_complete() {
append_tool_call_group(pending_group, messages);
}
}
fn append_tool_call_group(
pending_group: &mut Option<ResponsesToolCallGroup>,
messages: &mut Vec<InputMessage>,
) {
if let Some(group) = pending_group.take() {
group.append_messages(messages);
}
}
fn push_message(
msg: ResponsesInputMessage,
item_idx: usize,
messages: &mut Vec<InputMessage>,
) -> Result<(), ConversionError> {
let path = format!("input[{item_idx}]");
match msg.role {
ResponsesMessageRole::System => {
let content = msg.content.into_text(&path, "system")?;
messages.push(InputMessage::System { content });
}
ResponsesMessageRole::User | ResponsesMessageRole::Developer => {
let (content, image_sources) = msg.content.into_content(&path)?;
messages.push(InputMessage::User {
content,
image_sources,
});
}
ResponsesMessageRole::Assistant => {
let content = msg.content.into_text(&path, "assistant")?;
if let Some(InputMessage::Assistant {
content: last_content,
tool_calls: None,
..
}) = messages.last_mut()
{
if !content.is_empty() {
if !last_content.is_empty() {
last_content.push_str("\n\n");
}
last_content.push_str(&content);
}
} else {
messages.push(InputMessage::Assistant {
content,
reasoning_content: None,
tool_calls: None,
});
}
}
}
Ok(())
}
fn push_reasoning(reasoning: ResponsesReasoningItem, messages: &mut Vec<InputMessage>) {
let text = reasoning
.content
.unwrap_or_default()
.into_iter()
.map(|c| c.text)
.join("\n");
if text.is_empty() {
return;
}
if !matches!(
messages.last(),
Some(InputMessage::Assistant { content, .. }) if content.is_empty()
) {
messages.push(InputMessage::Assistant {
content: String::new(),
reasoning_content: None,
tool_calls: None,
});
}
if let Some(InputMessage::Assistant {
reasoning_content, ..
}) = messages.last_mut()
{
match reasoning_content {
Some(existing) => {
existing.push('\n');
existing.push_str(&text);
}
None => *reasoning_content = Some(text),
}
}
}
fn transform_input_items(
items: Vec<ResponsesInputItem>,
messages: &mut Vec<InputMessage>,
tools: &ResponsesToolSet,
) -> Result<(), ConversionError> {
if items.is_empty() {
return Err(ConversionError::bad_request(
"Input items array must not be empty",
));
}
let mut pending_call_ids: HashSet<String> = HashSet::new();
let mut resolved_call_ids: HashSet<String> = HashSet::new();
let mut pending_group: Option<ResponsesToolCallGroup> = None;
let mut historical_names = HashMap::new();
for (item_idx, item) in items.into_iter().enumerate() {
match item {
ResponsesInputItem::Typed(typed) => match typed {
ResponsesTypedInputItem::Message(msg) => {
check_pending_calls(&pending_call_ids)?;
append_tool_call_group(&mut pending_group, messages);
push_message(msg, item_idx, messages)?;
}
ResponsesTypedInputItem::Reasoning(reasoning) => {
check_pending_calls(&pending_call_ids)?;
append_tool_call_group(&mut pending_group, messages);
push_reasoning(reasoning, messages);
}
ResponsesTypedInputItem::FunctionCall(fc) => {
check_tool_call_id(
item_idx,
&fc.call_id,
&pending_call_ids,
&resolved_call_ids,
)?;
pending_call_ids.insert(fc.call_id.clone());
push_call_to_group(
&mut pending_group,
tools.function_call(fc, &mut historical_names),
);
}
ResponsesTypedInputItem::FunctionCallOutput(fco) => {
check_tool_output_id(
item_idx,
&fco.call_id,
&mut pending_call_ids,
&mut resolved_call_ids,
)?;
let (content, image_sources) = fco
.output
.into_content(&format!("input[{item_idx}].output"))?;
push_output_to_group(
&mut pending_group,
fco.call_id,
ResponsesToolResult {
content,
image_sources,
},
messages,
);
}
ResponsesTypedInputItem::CustomToolCall(call) => {
check_tool_call_id(
item_idx,
&call.call_id,
&pending_call_ids,
&resolved_call_ids,
)?;
pending_call_ids.insert(call.call_id.clone());
push_call_to_group(
&mut pending_group,
ToolCall {
id: call.call_id,
name: call.name,
arguments: serde_json::json!({"input": call.input}).to_string(),
},
);
}
ResponsesTypedInputItem::CustomToolCallOutput(output) => {
check_tool_output_id(
item_idx,
&output.call_id,
&mut pending_call_ids,
&mut resolved_call_ids,
)?;
let (content, image_sources) = output
.output
.map(|content| content.into_content(&format!("input[{item_idx}].output")))
.transpose()?
.unwrap_or_default();
push_output_to_group(
&mut pending_group,
output.call_id,
ResponsesToolResult {
content,
image_sources,
},
messages,
);
}
ResponsesTypedInputItem::Unsupported => {}
},
ResponsesInputItem::Message(msg) => {
check_pending_calls(&pending_call_ids)?;
append_tool_call_group(&mut pending_group, messages);
push_message(msg, item_idx, messages)?;
}
}
}
append_tool_call_group(&mut pending_group, messages);
check_pending_calls(&pending_call_ids)?;
Ok(())
}
impl ProtocolRequest for ResponsesRequest {
type Response = ResponsesResponse;
fn convert(self, options: ConversionOptions) -> Result<ConversationRequest, ConversionError> {
if contains_image_special_token(self.input.as_ref(), self.instructions.as_deref()) {
return Err(image_special_token_not_allowed());
}
let (thinking_mode, reasoning_effort) = self.resolve_thinking(options);
let inference_options = self.inference_options()?;
let converted_tools = ResponsesToolSet::convert(self.tools, &options)?;
let mut messages: Vec<InputMessage> = Vec::new();
if let Some(instructions) = self.instructions
&& !instructions.is_empty()
{
messages.push(InputMessage::System {
content: instructions,
});
}
match self.input {
Some(ResponsesInput::String(s)) => messages.push(InputMessage::User {
content: s,
image_sources: Vec::new(),
}),
Some(ResponsesInput::Items(items)) => {
transform_input_items(items, &mut messages, &converted_tools)?
}
None if messages.is_empty() => {
return Err(ConversionError::bad_request(
"Either input or instructions must be provided",
));
}
None => {}
}
let (tools, tool_choice) =
converted_tools.select(self.tool_choice, thinking_mode, &options)?;
validate_tool_text(&tools, &messages)?;
let response_format = convert_text_format(self.text, &messages)?;
let parsing_options = ParsingOptions {
parse_tool_calls: !tools.is_empty(),
tool_call_initial_stage: tool_choice == ToolChoice::Required,
parse_json_output: matches!(response_format, ResponseFormat::JsonObject),
reasoning_initial_stage: thinking_mode.then_some(ReasoningStage::Start),
..ParsingOptions::default()
};
Ok(ConversationRequest {
conversation: Conversation {
messages,
thinking_mode,
reasoning_effort,
tools,
tool_choice,
response_format,
},
inference_options,
parsing_options,
model: Some(self.model),
stream: self.stream.unwrap_or(false),
})
}
fn chunk_generator(
_request: &ConversationRequest,
id: String,
model: String,
) -> ResponsesChunkGenerator {
ResponsesChunkGenerator::new(id, model)
}
}
impl ResponsesRequest {
/// Return the custom tool names declared in `tools`.
/// Read before `convert` and pass to
/// [`ResponsesChunkGenerator::with_custom_tool_names`].
pub fn custom_tool_names(&self) -> HashSet<String> {
self.tools
.iter()
.flatten()
.filter_map(|tool| match tool {
ResponsesTool::Custom { name } => Some(name.clone()),
_ => None,
})
.collect()
}
fn resolve_thinking(&self, options: ConversionOptions) -> (bool, Option<ReasoningEffort>) {
let effort = self
.reasoning
.as_ref()
.and_then(|reasoning| reasoning.effort);
let effort = match effort {
Some(ResponsesReasoningEffort::None) => return (false, None),
Some(ResponsesReasoningEffort::Minimal | ResponsesReasoningEffort::Low) => {
ReasoningEffort::Low
}
Some(ResponsesReasoningEffort::Medium | ResponsesReasoningEffort::High) => {
ReasoningEffort::High
}
Some(ResponsesReasoningEffort::Xhigh) => ReasoningEffort::Xhigh,
Some(ResponsesReasoningEffort::Max) => ReasoningEffort::Max,
None if !options.default_thinking_mode => return (false, None),
None => ReasoningEffort::High,
};
(true, Some(effort))
}
fn inference_options(&self) -> Result<InferenceOptions, ConversionError> {
validate_max_tokens(self.max_output_tokens, "max_output_tokens")?;
validate_sampling(self.temperature, self.top_p)?;
Ok(InferenceOptions {
max_tokens: self.max_output_tokens,
temperature: self.temperature,
top_p: self.top_p,
..InferenceOptions::default()
})
}
}
fn convert_text_format(
text: Option<ResponsesTextConfig>,
messages: &[InputMessage],
) -> Result<ResponseFormat, ConversionError> {
match text.map(|text| text.format).unwrap_or_default() {
ResponsesTextFormat::Text | ResponsesTextFormat::JsonSchema => Ok(ResponseFormat::Text),
ResponsesTextFormat::JsonObject => {
if !contains_json_instruction(messages) {
return Err(ConversionError::bad_request(
"Response input messages must contain the word 'json' to use text.format of type json_object",
));
}
Ok(ResponseFormat::JsonObject)
}
}
}
fn unsupported_content() -> ConversionError {
ConversionError::bad_request("Unsupported content block")
}

View File

@ -0,0 +1,26 @@
//! Responses request deserialization and conversion.
//!
//! [`ResponsesRequest`] supports text, images, plaintext reasoning history,
//! instructions, sampling parameters, reasoning effort, and JSON object output.
//! Client tools include functions, namespaces, and the `apply_patch` custom tool.
//!
//! Document content, encrypted reasoning, conversation storage, and hosted tool
//! execution are outside the supported scope. Web-search declarations and
//! choices are ignored by default; [`crate::request::WebSearchBehavior::Reject`]
//! rejects them instead.
//!
//! Input text must not spell out the image placeholder. Every placeholder in a
//! prompt corresponds to one image source, and the adapter inserts them for
//! image blocks only, so text input, instructions, message text, tool output
//! text, reasoning text, tool definitions, and historical tool calls that carry
//! the placeholder spelling are rejected.
//!
//! JSON Schema output, `logprobs`, and `top_logprobs` are accepted and ignored.
//! Function-tool `strict` settings are passed through for the caller to enforce.
//! The caller supplies model inference, tool execution, and HTTP transport.
pub use schema::*;
mod convert;
mod schema;
mod tools;

View File

@ -0,0 +1,303 @@
use serde::{Deserialize, Deserializer};
use deepseek_recipe_core::multimodal::ImageDetail;
/// A Responses request with text and image input and client tools.
///
/// Unknown top-level fields, including `logprobs` and `top_logprobs`, are
/// ignored. Model resolution, server tools, and encrypted reasoning recovery
/// belong to the caller.
#[derive(Debug, Deserialize)]
pub struct ResponsesRequest {
pub model: String,
pub input: Option<ResponsesInput>,
pub instructions: Option<String>,
pub max_output_tokens: Option<u32>,
pub reasoning: Option<ResponsesReasoningConfig>,
pub stream: Option<bool>,
pub temperature: Option<f32>,
pub text: Option<ResponsesTextConfig>,
pub tool_choice: Option<ResponsesToolChoice>,
pub tools: Option<Vec<ResponsesTool>>,
pub top_p: Option<f32>,
/// Caller metadata. Extract this before consuming the request.
pub user: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ResponsesInput {
String(String),
Items(Vec<ResponsesInputItem>),
}
/// An explicitly typed item or a message without a `type` field.
#[derive(Debug)]
pub enum ResponsesInputItem {
Typed(ResponsesTypedInputItem),
Message(ResponsesInputMessage),
}
impl<'de> Deserialize<'de> for ResponsesInputItem {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
// The presence of `type` selects typed-item deserialization.
if value.get("type").is_some() {
serde_json::from_value(value)
.map(Self::Typed)
.map_err(serde::de::Error::custom)
} else {
serde_json::from_value(value)
.map(Self::Message)
.map_err(serde::de::Error::custom)
}
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesTypedInputItem {
Message(ResponsesInputMessage),
FunctionCall(ResponsesFunctionCall),
FunctionCallOutput(ResponsesFunctionCallOutput),
Reasoning(ResponsesReasoningItem),
CustomToolCall(ResponsesCustomToolCall),
CustomToolCallOutput(ResponsesCustomToolCallOutput),
/// Unsupported item types, including `web_search_call`, are ignored.
#[serde(other)]
Unsupported,
}
#[derive(Debug, Deserialize)]
pub struct ResponsesInputMessage {
pub role: ResponsesMessageRole,
pub content: ResponsesMessageContent,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponsesMessageRole {
User,
Assistant,
System,
Developer,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ResponsesMessageContent {
String(String),
List(Vec<ResponsesContentBlock>),
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesContentBlock {
InputText {
text: String,
},
OutputText {
text: String,
},
InputImage(ResponsesInputImage),
/// Documents are rendered as an unsupported-document placeholder. Every
/// field is accepted and ignored.
InputFile {},
/// Content block types not listed above are rejected during conversion.
#[serde(other)]
Unsupported,
}
/// One image referenced by URL or by a file identifier. A file identifier
/// requires a file service and is rejected during conversion.
#[derive(Debug, Deserialize)]
pub struct ResponsesInputImage {
/// HTTP URL or base64 data URL. An empty string is treated as absent, and a
/// value that does not start with `http` must carry a `base64` body.
pub image_url: Option<String>,
#[serde(default)]
pub detail: Option<ImageDetail>,
pub file_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ResponsesFunctionCall {
pub arguments: String,
pub call_id: String,
pub name: String,
pub namespace: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ResponsesFunctionCallOutput {
pub call_id: String,
pub output: ResponsesToolCallOutputContent,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ResponsesToolCallOutputContent {
String(String),
List(Vec<ResponsesToolCallOutputItem>),
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesToolCallOutputItem {
InputText {
text: String,
},
InputImage(ResponsesInputImage),
/// Documents are rendered as an unsupported-document placeholder. Every
/// field is accepted and ignored.
InputFile {},
/// Content block types not listed above are rejected during conversion.
#[serde(other)]
Unsupported,
}
#[derive(Debug, Deserialize)]
pub struct ResponsesReasoningItem {
pub content: Option<Vec<ResponsesReasoningContent>>,
/// Encrypted reasoning is ignored; plaintext reasoning is preserved.
pub encrypted_content: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ResponsesReasoningContent {
pub text: String,
}
#[derive(Debug, Deserialize)]
pub struct ResponsesCustomToolCall {
pub call_id: String,
pub name: String,
pub input: String,
}
#[derive(Debug, Deserialize)]
pub struct ResponsesCustomToolCallOutput {
pub call_id: String,
pub output: Option<ResponsesToolCallOutputContent>,
}
#[derive(Debug, Deserialize)]
pub struct ResponsesReasoningConfig {
pub effort: Option<ResponsesReasoningEffort>,
/// Summary setting available to the caller before conversion.
pub summary: Option<ResponsesReasoningSummary>,
/// Alternate summary setting available to the caller before conversion.
pub generate_summary: Option<ResponsesReasoningSummary>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponsesReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
Xhigh,
Max,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponsesReasoningSummary {
Auto,
Concise,
Detailed,
}
#[derive(Debug, Deserialize)]
pub struct ResponsesTextConfig {
#[serde(default)]
pub format: ResponsesTextFormat,
/// Verbosity setting available to the caller before conversion.
pub verbosity: Option<ResponsesVerbosity>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesTextFormat {
#[default]
Text,
JsonObject,
/// Accepted and ignored, including all schema fields. Uses plain text output.
JsonSchema,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponsesVerbosity {
Low,
Medium,
High,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesTool {
Function(ResponsesFunctionTool),
Namespace {
name: String,
description: Option<String>,
tools: Vec<ResponsesNamespaceTool>,
},
/// The `apply_patch` custom tool is converted to a function tool.
Custom {
name: String,
},
#[serde(alias = "web_search_2025_08_26")]
WebSearch,
#[serde(other)]
Unsupported,
}
#[derive(Debug, Deserialize)]
pub struct ResponsesFunctionTool {
pub name: String,
pub description: Option<String>,
pub parameters: Option<serde_json::Value>,
/// Passed through; backend enforcement belongs to the caller.
pub strict: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesNamespaceTool {
Function(ResponsesFunctionTool),
/// Custom tools inside a namespace are rejected during conversion.
Custom {
name: String,
},
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ResponsesToolChoice {
Mode(ResponsesToolChoiceMode),
Named(ResponsesNamedToolChoice),
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponsesToolChoiceMode {
None,
Auto,
Required,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesNamedToolChoice {
Function {
name: String,
},
Custom {
name: String,
},
#[serde(alias = "web_search_2025_08_26")]
WebSearch,
}

View File

@ -0,0 +1,291 @@
use std::collections::{HashMap, HashSet};
use deepseek_recipe_core::messages::ToolCall;
use deepseek_recipe_core::tools::{ToolChoice, ToolDefinition};
use crate::protocol::validation::{validate_tool_name, validate_tool_parameters};
use crate::request::{ConversionError, ConversionOptions, WebSearchBehavior};
use super::schema::{
ResponsesFunctionCall, ResponsesFunctionTool, ResponsesNamedToolChoice, ResponsesNamespaceTool,
ResponsesTool, ResponsesToolChoice, ResponsesToolChoiceMode,
};
pub(super) struct ResponsesToolSet {
definitions: Vec<ResponsesFunctionTool>,
namespace_names: HashMap<(String, String), String>,
top_level_names: HashSet<String>,
}
impl ResponsesToolSet {
pub(super) fn convert(
tools: Option<Vec<ResponsesTool>>,
options: &ConversionOptions,
) -> Result<Self, ConversionError> {
let tools = tools.unwrap_or_default();
let mut converted = Self {
definitions: Vec::new(),
namespace_names: HashMap::new(),
top_level_names: tools
.iter()
.filter_map(|tool| match tool {
ResponsesTool::Function(function) => Some(function.name.clone()),
_ => None,
})
.collect(),
};
let mut namespaces = HashSet::new();
for (index, tool) in tools.into_iter().enumerate() {
let path = format!("tools[{index}]");
match tool {
ResponsesTool::Function(function) => {
validate_tool_name(&function.name, &format!("{path}.name"))?;
converted.definitions.push(function);
}
ResponsesTool::Namespace {
name,
description,
tools,
} => {
validate_tool_name(&name, &format!("{path}.name"))?;
if !namespaces.insert(name.clone()) {
return Err(ConversionError::bad_request(format!(
"{path}: duplicate namespace name '{name}'"
)));
}
let mut inner_names = HashSet::new();
for (inner_index, tool) in tools.into_iter().enumerate() {
let inner_path = format!("{path}.tools[{inner_index}]");
let ResponsesNamespaceTool::Function(mut function) = tool else {
return Err(ConversionError::bad_request(format!(
"{inner_path}: custom tools are not supported inside a namespace"
)));
};
validate_tool_name(&function.name, &format!("{inner_path}.name"))?;
if !inner_names.insert(function.name.clone()) {
return Err(ConversionError::bad_request(format!(
"{inner_path}: tool names within a namespace must be unique"
)));
}
if let Some(namespace_description) = description
.as_deref()
.filter(|description| !description.is_empty())
{
let function_description =
function.description.as_deref().unwrap_or_default();
function.description =
Some(format!("{namespace_description}\n{function_description}"));
}
let function_name =
unique_name(join_namespace(&name, &function.name), |candidate| {
converted.is_name_taken(candidate)
});
converted
.namespace_names
.insert((name.clone(), function.name), function_name.clone());
function.name = function_name;
converted.definitions.push(function);
}
}
ResponsesTool::Custom { name } => {
if name != "apply_patch" {
return Err(ConversionError::bad_request(format!(
"Unsupported custom tool: '{name}'. Only 'apply_patch' is supported"
)));
}
converted.definitions.push(apply_patch_tool());
}
ResponsesTool::WebSearch => match options.responses_web_search {
WebSearchBehavior::Ignore => {}
WebSearchBehavior::Reject => {
return Err(ConversionError::bad_request(
"Server tools are not supported",
));
}
},
ResponsesTool::Unsupported => {}
}
}
Ok(converted)
}
pub(super) fn function_call(
&self,
call: ResponsesFunctionCall,
historical_names: &mut HashMap<(String, String), String>,
) -> ToolCall {
let name = match call.namespace {
Some(namespace) => {
let key = (namespace, call.name);
if let Some(name) = self.namespace_names.get(&key) {
name.clone()
} else if let Some(name) = historical_names.get(&key) {
name.clone()
} else {
// Each historical namespace/name pair uses one unique name.
let name = unique_name(join_namespace(&key.0, &key.1), |candidate| {
self.is_name_taken(candidate)
|| historical_names.values().any(|name| name == candidate)
});
historical_names.insert(key, name.clone());
name
}
}
None => call.name,
};
ToolCall {
id: call.call_id,
name,
arguments: call.arguments,
}
}
pub(super) fn select(
self,
choice: Option<ResponsesToolChoice>,
thinking: bool,
options: &ConversionOptions,
) -> Result<(Vec<ToolDefinition>, ToolChoice), ConversionError> {
// Dropping the web_search tool also drops a choice naming it.
let choice = match choice {
Some(ResponsesToolChoice::Named(ResponsesNamedToolChoice::WebSearch)) => {
match options.responses_web_search {
WebSearchBehavior::Ignore => None,
WebSearchBehavior::Reject => {
return Err(ConversionError::bad_request(
"Server tools are not supported",
));
}
}
}
other => other,
};
// Disabling tools skips parameter-schema and final-name checks.
// Definition conversion has already handled tool names and kinds.
if matches!(
choice,
Some(ResponsesToolChoice::Mode(ResponsesToolChoiceMode::None))
) {
return Ok((Vec::new(), ToolChoice::None));
}
let mut tools = self.definitions;
if tools.is_empty() {
return Ok((Vec::new(), ToolChoice::Auto));
}
let mut names = HashSet::new();
for tool in &tools {
if !names.insert(&tool.name) {
return Err(ConversionError::bad_request("Tool names must be unique"));
}
if let Some(parameters) = &tool.parameters {
validate_tool_parameters(parameters, &format!("Tool '{}' parameters", tool.name))?;
}
}
let required = match choice {
Some(ResponsesToolChoice::Named(
ResponsesNamedToolChoice::Function { name }
| ResponsesNamedToolChoice::Custom { name },
)) => {
if !names.contains(&name) {
return Err(ConversionError::bad_request(format!(
"tool_choice: no tool named '{name}' was specified"
)));
}
tools.retain(|tool| tool.name == name);
true
}
Some(ResponsesToolChoice::Mode(ResponsesToolChoiceMode::Required)) => true,
_ => false,
};
if required && thinking {
return Err(ConversionError::bad_request(
"Thinking mode does not support this tool_choice",
));
}
Ok((
tools.into_iter().map(Into::into).collect(),
if required {
ToolChoice::Required
} else {
ToolChoice::Auto
},
))
}
fn is_name_taken(&self, candidate: &str) -> bool {
self.top_level_names.contains(candidate)
|| self.namespace_names.values().any(|name| name == candidate)
}
}
impl From<ResponsesFunctionTool> for ToolDefinition {
fn from(function: ResponsesFunctionTool) -> Self {
Self {
name: function.name,
description: function.description,
parameters: function.parameters.unwrap_or_else(|| serde_json::json!({})),
strict: function.strict,
}
}
}
fn join_namespace(namespace: &str, name: &str) -> String {
format!("{namespace}::{name}")
}
fn unique_name(base: String, is_taken: impl Fn(&str) -> bool) -> String {
if !is_taken(&base) {
return base;
}
for index in 1.. {
let candidate = format!("{base}_{index}");
if !is_taken(&candidate) {
return candidate;
}
}
unreachable!()
}
fn apply_patch_tool() -> ResponsesFunctionTool {
ResponsesFunctionTool {
name: "apply_patch".to_string(),
description: Some(APPLY_PATCH_DESCRIPTION.to_string()),
parameters: Some(serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"input": {
"type": "string",
"description": "The entire contents of the apply_patch command (the *** Begin Patch ... *** End Patch envelope)."
}
},
"required": ["input"]
})),
strict: None,
}
}
const APPLY_PATCH_DESCRIPTION: &str = "Use the `apply_patch` tool to edit files.\nYour patch language is a stripped‑down, file‑oriented diff format designed to be easy \
to parse and safe to apply. You can think of it as a high‑level envelope:\n\n*** Begin Patch\n[ one or more file sections ]\n*** \
End Patch\n\nWithin that envelope, you get a sequence of file operations.\nYou MUST include a header to specify the action you \
are taking.\nEach operation starts with one of three headers:\n\n*** Add File: <path> - create a new file. Every following line \
is a + line (the initial contents).\n*** Delete File: <path> - remove an existing file. Nothing follows.\n*** Update File: <path> \
- patch an existing file in place (optionally with a rename).\n\nMay be immediately followed by *** Move to: <new path> if you \
want to rename the file.\nThen one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).\nWithin a hunk \
each line starts with:\n\nFor instructions on [context_before] and [context_after]:\n- By default, show 3 lines of code \
immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate \
the first change’s [context_after] lines in the second change’s [context_before] lines.\n- If 3 lines of context is insufficient \
to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the \
snippet belongs. For instance, we might have:\n@@ class BaseClass\n[3 lines of pre-context]\n- [old_code]\n+ [new_code]\n[3 lines \
of post-context]\n\n- If a code block is repeated so many times in a class or function such that even a single `@@` statement and \
3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right \
context. For instance:\n\n@@ class BaseClass\n@@ \t def method():\n[3 lines of pre-context]\n- [old_code]\n+ [new_code]\n[3 lines \
of post-context]\n\nThe full grammar definition is below:\nPatch := Begin { FileOp } End\nBegin := \"*** Begin Patch\" \
NEWLINE\nEnd := \"*** End Patch\" NEWLINE\nFileOp := AddFile | DeleteFile | UpdateFile\nAddFile := \"*** Add File: \" path \
NEWLINE { \"+\" line NEWLINE }\nDeleteFile := \"*** Delete File: \" path NEWLINE\nUpdateFile := \"*** Update File: \" path \
NEWLINE [ MoveTo ] { Hunk }\nMoveTo := \"*** Move to: \" newPath NEWLINE\nHunk := \"@@\" [ header ] NEWLINE { HunkLine } [ \"*** \
End of File\" NEWLINE ]\nHunkLine := (\" \" | \"-\" | \"+\") text NEWLINE\n\nA full patch can combine several operations:\n\n*** \
Begin Patch\n*** Add File: hello.txt\n+Hello world\n*** Update File: src/app.py\n*** Move to: src/main.py\n@@ def \
greet():\n-print(\"Hi\")\n+print(\"Hello, world!\")\n*** Delete File: obsolete.txt\n*** End Patch\n\nIt is important to \
remember:\n\n- You must include a header with your intended action (Add/Delete/Update)\n- You must prefix new lines with `+` even \
when creating a new file\n- File references can only be relative, NEVER ABSOLUTE.\n";

View File

@ -0,0 +1,225 @@
use crate::util::append_delta::AppendDelta;
use super::schema::{
ResponsesContentPart, ResponsesOutputItem, ResponsesResponse, ResponsesStreamEvent,
};
/// Apply events in generation order.
impl AppendDelta<ResponsesStreamEvent> for ResponsesResponse {
fn append(&mut self, event: ResponsesStreamEvent) {
match event {
ResponsesStreamEvent::Created { response, .. }
| ResponsesStreamEvent::InProgress { response, .. }
| ResponsesStreamEvent::Completed { response, .. }
| ResponsesStreamEvent::Incomplete { response, .. } => *self = response,
ResponsesStreamEvent::OutputItemAdded {
item, output_index, ..
} => {
if output_index == self.output.len() && valid_item_content(&item) {
self.output.push(item);
}
}
ResponsesStreamEvent::OutputItemDone {
item, output_index, ..
} => {
if let Some(current) = self.output.get_mut(output_index)
&& item_id(current) == item_id(&item)
&& std::mem::discriminant(current) == std::mem::discriminant(&item)
&& valid_item_content(&item)
{
*current = item;
}
}
ResponsesStreamEvent::ContentPartAdded {
content_index,
item_id,
output_index,
part,
..
} => {
if let Some(item) = self.matching_item_mut(output_index, &item_id)
&& let Some(content) = matching_content_mut(item, &part)
&& content_index == content.len()
{
content.push(part);
}
}
ResponsesStreamEvent::ContentPartDone {
content_index,
item_id,
output_index,
part,
..
} => {
if let Some(item) = self.matching_item_mut(output_index, &item_id)
&& let Some(content) = matching_content_mut(item, &part)
&& let Some(current) = content.get_mut(content_index)
&& std::mem::discriminant(current) == std::mem::discriminant(&part)
{
*current = part;
}
}
ResponsesStreamEvent::OutputTextDelta {
content_index,
delta,
item_id,
logprobs: delta_logprobs,
output_index,
..
} => {
if let Some(ResponsesOutputItem::Message { content, .. }) =
self.matching_item_mut(output_index, &item_id)
&& let Some(ResponsesContentPart::OutputText { text, logprobs, .. }) =
content.get_mut(content_index)
{
text.push_str(&delta);
logprobs.extend(delta_logprobs);
}
}
ResponsesStreamEvent::OutputTextDone {
content_index,
item_id,
logprobs: final_logprobs,
output_index,
text: final_text,
..
} => {
if let Some(ResponsesOutputItem::Message { content, .. }) =
self.matching_item_mut(output_index, &item_id)
&& let Some(ResponsesContentPart::OutputText { text, logprobs, .. }) =
content.get_mut(content_index)
{
*text = final_text;
*logprobs = final_logprobs;
}
}
ResponsesStreamEvent::ReasoningTextDelta {
content_index,
delta,
item_id,
output_index,
..
} => {
if let Some(ResponsesOutputItem::Reasoning { content, .. }) =
self.matching_item_mut(output_index, &item_id)
&& let Some(ResponsesContentPart::ReasoningText { text }) =
content.get_mut(content_index)
{
text.push_str(&delta);
}
}
ResponsesStreamEvent::ReasoningTextDone {
content_index,
item_id,
output_index,
text: final_text,
..
} => {
if let Some(ResponsesOutputItem::Reasoning { content, .. }) =
self.matching_item_mut(output_index, &item_id)
&& let Some(ResponsesContentPart::ReasoningText { text }) =
content.get_mut(content_index)
{
*text = final_text;
}
}
ResponsesStreamEvent::FunctionCallArgumentsDelta {
delta,
item_id,
output_index,
..
} => {
if let Some(ResponsesOutputItem::FunctionCall { arguments, .. }) =
self.matching_item_mut(output_index, &item_id)
{
arguments.push_str(&delta);
}
}
ResponsesStreamEvent::FunctionCallArgumentsDone {
arguments: final_arguments,
item_id,
output_index,
..
} => {
if let Some(ResponsesOutputItem::FunctionCall { arguments, .. }) =
self.matching_item_mut(output_index, &item_id)
{
*arguments = final_arguments;
}
}
ResponsesStreamEvent::CustomToolCallInputDelta {
delta,
item_id,
output_index,
..
} => {
if let Some(ResponsesOutputItem::CustomToolCall { input, .. }) =
self.matching_item_mut(output_index, &item_id)
{
input.push_str(&delta);
}
}
ResponsesStreamEvent::CustomToolCallInputDone {
input: final_input,
item_id,
output_index,
..
} => {
if let Some(ResponsesOutputItem::CustomToolCall { input, .. }) =
self.matching_item_mut(output_index, &item_id)
{
*input = final_input;
}
}
}
}
}
impl ResponsesResponse {
fn matching_item_mut(
&mut self,
output_index: usize,
expected_id: &str,
) -> Option<&mut ResponsesOutputItem> {
self.output
.get_mut(output_index)
.filter(|item| item_id(item) == expected_id)
}
}
fn item_id(item: &ResponsesOutputItem) -> &str {
match item {
ResponsesOutputItem::Message { id, .. }
| ResponsesOutputItem::Reasoning { id, .. }
| ResponsesOutputItem::FunctionCall { id, .. }
| ResponsesOutputItem::CustomToolCall { id, .. } => id,
}
}
fn valid_item_content(item: &ResponsesOutputItem) -> bool {
match item {
ResponsesOutputItem::Message { content, .. } => content
.iter()
.all(|part| matches!(part, ResponsesContentPart::OutputText { .. })),
ResponsesOutputItem::Reasoning { content, .. } => content
.iter()
.all(|part| matches!(part, ResponsesContentPart::ReasoningText { .. })),
ResponsesOutputItem::FunctionCall { .. } | ResponsesOutputItem::CustomToolCall { .. } => {
true
}
}
}
fn matching_content_mut<'a>(
item: &'a mut ResponsesOutputItem,
part: &ResponsesContentPart,
) -> Option<&'a mut Vec<ResponsesContentPart>> {
match (item, part) {
(ResponsesOutputItem::Message { content, .. }, ResponsesContentPart::OutputText { .. })
| (
ResponsesOutputItem::Reasoning { content, .. },
ResponsesContentPart::ReasoningText { .. },
) => Some(content),
_ => None,
}
}

View File

@ -0,0 +1,420 @@
use std::collections::HashSet;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::response::ProtocolResponse;
use crate::stream::{ChunkGenerator, FinishReason, OutputChunk, PromptUsage};
use super::custom_tool_input::ResponsesCustomToolInputParser;
use super::schema::{
ResponsesContentPart, ResponsesIncompleteDetails, ResponsesIncompleteReason,
ResponsesOutputItem, ResponsesPhase, ResponsesResponse, ResponsesRole, ResponsesStatus,
ResponsesStreamEvent, ResponsesUsage,
};
/// Generate Responses events from one `Start` to `Finish` sequence.
/// Repeated starts and chunks outside this sequence are ignored.
/// Argument deltas extend only the active tool call.
/// Custom tool output requires `with_custom_tool_names`.
/// Other tools retain their model names, including flattened namespaces.
#[derive(Debug)]
pub struct ResponsesChunkGenerator {
response: ResponsesResponse,
active: Option<usize>,
sequence_number: usize,
tool_call_index: usize,
custom_tool_names: HashSet<String>,
custom_tool_input: Option<ResponsesCustomToolInputParser>,
prompt_usage: PromptUsage,
started: bool,
finished: bool,
}
enum ResponsesTextKind {
Output,
Reasoning,
}
impl ResponsesChunkGenerator {
/// Use a unique response ID and public model name.
/// Item and call IDs derive from the response ID and indices.
pub fn new(id: impl Into<String>, model: impl Into<String>) -> Self {
Self {
response: ResponsesResponse::new(id.into(), model.into(), timestamp(), 0, 0),
active: None,
sequence_number: 0,
tool_call_index: 0,
custom_tool_names: HashSet::new(),
custom_tool_input: None,
prompt_usage: PromptUsage::default(),
started: false,
finished: false,
}
}
/// Set declared custom tool names whose JSON `input` strings are decoded.
/// Defaults to an empty set; other tools retain their function arguments.
pub fn with_custom_tool_names(mut self, custom_tool_names: HashSet<String>) -> Self {
self.custom_tool_names = custom_tool_names;
self
}
fn next_sequence_number(&mut self) -> usize {
let sequence_number = self.sequence_number;
self.sequence_number += 1;
sequence_number
}
fn open_item(&mut self, mut item: ResponsesOutputItem, events: &mut Vec<ResponsesStreamEvent>) {
let output_index = self.response.output.len();
self.response.output.push(item.clone());
let content = match &mut item {
ResponsesOutputItem::Message { id, content, .. }
| ResponsesOutputItem::Reasoning { id, content, .. } => {
content.pop().map(|part| (id.clone(), part))
}
ResponsesOutputItem::FunctionCall { .. }
| ResponsesOutputItem::CustomToolCall { .. } => None,
};
self.active = Some(output_index);
events.push(ResponsesStreamEvent::OutputItemAdded {
item,
output_index,
sequence_number: self.next_sequence_number(),
});
if let Some((item_id, part)) = content {
events.push(ResponsesStreamEvent::ContentPartAdded {
content_index: 0,
item_id,
output_index,
part,
sequence_number: self.next_sequence_number(),
});
}
}
fn close_item(&mut self, status: ResponsesStatus, events: &mut Vec<ResponsesStreamEvent>) {
let Some(output_index) = self.active.take() else {
return;
};
if let Some(mut parser) = self.custom_tool_input.take() {
self.append_custom_input(output_index, parser.finish(), events);
}
let item = &mut self.response.output[output_index];
match item {
ResponsesOutputItem::Message {
status: item_status,
..
}
| ResponsesOutputItem::Reasoning {
status: item_status,
..
}
| ResponsesOutputItem::FunctionCall {
status: item_status,
..
}
| ResponsesOutputItem::CustomToolCall {
status: item_status,
..
} => *item_status = status,
}
let item = item.clone();
match &item {
ResponsesOutputItem::Message { id, content, .. }
| ResponsesOutputItem::Reasoning { id, content, .. } => {
for (content_index, part) in content.iter().enumerate() {
let sequence_number = self.next_sequence_number();
events.push(match part {
ResponsesContentPart::OutputText { text, logprobs, .. } => {
ResponsesStreamEvent::OutputTextDone {
content_index,
item_id: id.clone(),
output_index,
text: text.clone(),
logprobs: logprobs.clone(),
sequence_number,
}
}
ResponsesContentPart::ReasoningText { text } => {
ResponsesStreamEvent::ReasoningTextDone {
content_index,
item_id: id.clone(),
output_index,
text: text.clone(),
sequence_number,
}
}
});
events.push(ResponsesStreamEvent::ContentPartDone {
content_index,
item_id: id.clone(),
output_index,
part: part.clone(),
sequence_number: self.next_sequence_number(),
});
}
}
ResponsesOutputItem::FunctionCall { id, arguments, .. } => {
events.push(ResponsesStreamEvent::FunctionCallArgumentsDone {
arguments: arguments.clone(),
item_id: id.clone(),
output_index,
sequence_number: self.next_sequence_number(),
});
}
ResponsesOutputItem::CustomToolCall { id, input, .. } => {
events.push(ResponsesStreamEvent::CustomToolCallInputDone {
input: input.clone(),
item_id: id.clone(),
output_index,
sequence_number: self.next_sequence_number(),
});
}
}
events.push(ResponsesStreamEvent::OutputItemDone {
item,
output_index,
sequence_number: self.next_sequence_number(),
});
}
fn append_text(
&mut self,
delta: String,
kind: ResponsesTextKind,
events: &mut Vec<ResponsesStreamEvent>,
) {
let reasoning = matches!(kind, ResponsesTextKind::Reasoning);
let current = self
.active
.and_then(|index| self.response.output.get(index));
let continues_item = matches!(
(reasoning, current),
(true, Some(ResponsesOutputItem::Reasoning { .. }))
| (false, Some(ResponsesOutputItem::Message { .. }))
);
if !continues_item {
self.close_item(ResponsesStatus::Completed, events);
let output_index = self.response.output.len();
let item = if reasoning {
ResponsesOutputItem::Reasoning {
id: format!("rs_{}_{output_index}", self.response.id),
status: ResponsesStatus::InProgress,
content: vec![ResponsesContentPart::ReasoningText {
text: String::new(),
}],
summary: Vec::new(),
}
} else {
ResponsesOutputItem::Message {
id: format!("msg_{}_{output_index}", self.response.id),
status: ResponsesStatus::InProgress,
role: ResponsesRole::Assistant,
phase: ResponsesPhase::FinalAnswer,
content: vec![ResponsesContentPart::OutputText {
annotations: Vec::new(),
logprobs: Vec::new(),
text: String::new(),
}],
}
};
self.open_item(item, events);
}
let Some(output_index) = self.active else {
return;
};
let (item_id, content) = match &mut self.response.output[output_index] {
ResponsesOutputItem::Message { id, content, .. }
| ResponsesOutputItem::Reasoning { id, content, .. } => (id.clone(), content),
ResponsesOutputItem::FunctionCall { .. }
| ResponsesOutputItem::CustomToolCall { .. } => return,
};
let Some(part) = content.first_mut() else {
return;
};
match part {
ResponsesContentPart::OutputText { text, .. }
| ResponsesContentPart::ReasoningText { text } => text.push_str(&delta),
}
let sequence_number = self.next_sequence_number();
events.push(if reasoning {
ResponsesStreamEvent::ReasoningTextDelta {
content_index: 0,
delta,
item_id,
output_index,
sequence_number,
}
} else {
ResponsesStreamEvent::OutputTextDelta {
content_index: 0,
delta,
item_id,
logprobs: Vec::new(),
output_index,
sequence_number,
}
});
}
fn append_custom_input(
&mut self,
output_index: usize,
delta: String,
events: &mut Vec<ResponsesStreamEvent>,
) {
if delta.is_empty() {
return;
}
if let ResponsesOutputItem::CustomToolCall { id, input, .. } =
&mut self.response.output[output_index]
{
input.push_str(&delta);
let item_id = id.clone();
events.push(ResponsesStreamEvent::CustomToolCallInputDelta {
delta,
item_id,
output_index,
sequence_number: self.next_sequence_number(),
});
}
}
}
impl ChunkGenerator for ResponsesChunkGenerator {
type Chunk = ResponsesStreamEvent;
async fn generate(&mut self, chunk: OutputChunk) -> Vec<Self::Chunk> {
if self.finished || (!self.started && !matches!(chunk, OutputChunk::Start { .. })) {
return Vec::new();
}
let mut events = Vec::new();
match chunk {
OutputChunk::Start { usage, .. } => {
if self.started {
return events;
}
self.started = true;
self.prompt_usage = usage;
events.push(ResponsesStreamEvent::Created {
response: self.response.clone(),
sequence_number: self.next_sequence_number(),
});
events.push(ResponsesStreamEvent::InProgress {
response: self.response.clone(),
sequence_number: self.next_sequence_number(),
});
}
OutputChunk::Raw { content } => {
self.append_text(content, ResponsesTextKind::Output, &mut events);
}
OutputChunk::Reasoning { content } => {
self.append_text(content, ResponsesTextKind::Reasoning, &mut events);
}
OutputChunk::ToolCall {
tool_name,
arguments,
} => {
if let Some(output_index) = self.active
&& let ResponsesOutputItem::Message { phase, .. } =
&mut self.response.output[output_index]
{
*phase = ResponsesPhase::Commentary;
}
self.close_item(ResponsesStatus::Completed, &mut events);
let output_index = self.response.output.len();
let call_id = format!("call_{}_{}", self.response.id, self.tool_call_index);
let item = if self.custom_tool_names.contains(&tool_name) {
let mut parser = ResponsesCustomToolInputParser::default();
let input = parser.feed(&arguments);
self.custom_tool_input = Some(parser);
ResponsesOutputItem::CustomToolCall {
id: format!("ctc_{}_{output_index}", self.response.id),
status: ResponsesStatus::InProgress,
call_id,
name: tool_name,
input,
}
} else {
let (name, namespace) = match tool_name.split_once("::") {
Some((namespace, name)) => (name.to_string(), Some(namespace.to_string())),
None => (tool_name, None),
};
ResponsesOutputItem::FunctionCall {
id: format!("fc_{}_{output_index}", self.response.id),
status: ResponsesStatus::InProgress,
call_id,
name,
namespace,
arguments,
}
};
self.tool_call_index += 1;
self.open_item(item, &mut events);
}
OutputChunk::ToolArgumentsDelta { content } => {
if let Some(output_index) = self.active
&& let ResponsesOutputItem::FunctionCall { id, arguments, .. } =
&mut self.response.output[output_index]
{
arguments.push_str(&content);
let item_id = id.clone();
events.push(ResponsesStreamEvent::FunctionCallArgumentsDelta {
delta: content,
item_id,
output_index,
sequence_number: self.next_sequence_number(),
});
} else if let Some(output_index) = self.active
&& let Some(parser) = &mut self.custom_tool_input
{
let delta = parser.feed(&content);
self.append_custom_input(output_index, delta, &mut events);
}
}
OutputChunk::Finish { reason, usage, .. } => {
let incomplete_reason = match reason {
FinishReason::Length => Some(ResponsesIncompleteReason::MaxOutputTokens),
FinishReason::ContentFilter => Some(ResponsesIncompleteReason::ContentFilter),
FinishReason::Stop
| FinishReason::ToolCalls
| FinishReason::StopSequence
| FinishReason::EndOfStream => None,
};
self.response.incomplete_details =
incomplete_reason.map(|reason| ResponsesIncompleteDetails { reason });
self.response.status = if self.response.incomplete_details.is_some() {
ResponsesStatus::Incomplete
} else {
ResponsesStatus::Completed
};
self.response.usage = Some(ResponsesUsage::from((self.prompt_usage, usage)));
self.response.completed_at = Some(timestamp());
self.close_item(self.response.status, &mut events);
let response = self.response.clone();
let sequence_number = self.next_sequence_number();
events.push(if self.response.status == ResponsesStatus::Incomplete {
ResponsesStreamEvent::Incomplete {
response,
sequence_number,
}
} else {
ResponsesStreamEvent::Completed {
response,
sequence_number,
}
});
self.finished = true;
}
OutputChunk::ToolCallBegin => {}
}
events
}
}
fn timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or(0)
}

View File

@ -0,0 +1,116 @@
/// Decode the leading JSON `input` string, preserving nonmatching arguments.
/// Content after the closing quote is ignored.
#[derive(Debug, Default)]
pub(super) struct ResponsesCustomToolInputParser {
state: ResponsesCustomToolInputParserState,
prefix: String,
prefix_part: usize,
prefix_offset: usize,
escape: String,
}
#[derive(Debug, Default)]
enum ResponsesCustomToolInputParserState {
#[default]
Prefix,
Value,
Passthrough,
Done,
}
const PREFIX_PARTS: [&[u8]; 4] = [b"{", b"\"input\"", b":", b"\""];
impl ResponsesCustomToolInputParser {
/// Decode available value text, retaining incomplete prefixes and escapes.
pub(super) fn feed(&mut self, chunk: &str) -> String {
let mut output = String::new();
for ch in chunk.chars() {
match self.state {
ResponsesCustomToolInputParserState::Prefix => self.feed_prefix(ch, &mut output),
ResponsesCustomToolInputParserState::Value => self.feed_value(ch, &mut output),
ResponsesCustomToolInputParserState::Passthrough => output.push(ch),
ResponsesCustomToolInputParserState::Done => break,
}
}
output
}
/// End parsing and return any unfinished prefix or escape unchanged.
/// Repeated calls and subsequent input produce no further output.
pub(super) fn finish(&mut self) -> String {
self.state = ResponsesCustomToolInputParserState::Done;
let mut output = std::mem::take(&mut self.prefix);
output.push_str(&std::mem::take(&mut self.escape));
output
}
fn feed_prefix(&mut self, ch: char, output: &mut String) {
self.prefix.push(ch);
let part = PREFIX_PARTS[self.prefix_part];
if ch == char::from(part[self.prefix_offset]) {
self.prefix_offset += 1;
if self.prefix_offset == part.len() {
self.prefix_part += 1;
self.prefix_offset = 0;
if self.prefix_part == PREFIX_PARTS.len() {
self.prefix.clear();
self.state = ResponsesCustomToolInputParserState::Value;
}
}
} else if self.prefix_offset != 0 || !ch.is_ascii_whitespace() {
output.push_str(&self.prefix);
self.prefix.clear();
self.state = ResponsesCustomToolInputParserState::Passthrough;
}
}
fn feed_value(&mut self, ch: char, output: &mut String) {
if self.escape.is_empty() {
match ch {
'\\' => self.escape.push(ch),
'"' => self.state = ResponsesCustomToolInputParserState::Done,
_ => output.push(ch),
}
return;
}
if ch == '"' && !self.escape.ends_with('\\') {
output.push_str(&std::mem::take(&mut self.escape));
self.state = ResponsesCustomToolInputParserState::Done;
return;
}
self.escape.push(ch);
if incomplete_escape(&self.escape) {
return;
}
let raw = std::mem::take(&mut self.escape);
match serde_json::from_str::<String>(&format!("\"{raw}\"")) {
Ok(decoded) => output.push_str(&decoded),
Err(_) => output.push_str(&raw),
}
}
}
fn incomplete_escape(raw: &str) -> bool {
if raw.len() < 6 {
return partial_unicode_escape(raw);
}
// Validate the first ASCII escape before inspecting a surrogate pair.
// Invalid escapes can contain multibyte characters at any position.
let Some(first) = raw.get(..6) else {
return false;
};
if !partial_unicode_escape(first) {
return false;
}
let high_surrogate =
u16::from_str_radix(&first[2..], 16).is_ok_and(|value| (0xD800..=0xDBFF).contains(&value));
high_surrogate && raw.len() < 12 && partial_unicode_escape(&raw[6..])
}
fn partial_unicode_escape(raw: &str) -> bool {
raw.len() <= 6
&& ("\\u".starts_with(raw)
|| raw
.strip_prefix("\\u")
.is_some_and(|digits| digits.chars().all(|ch| ch.is_ascii_hexdigit())))
}

View File

@ -0,0 +1,7 @@
pub use chunk_generator::*;
pub use schema::*;
mod accumulator;
mod chunk_generator;
mod custom_tool_input;
mod schema;

View File

@ -0,0 +1,321 @@
use serde::Serialize;
use strum_macros::IntoStaticStr;
use crate::response::ProtocolResponse;
use crate::stream::{CompletionUsage, PromptUsage};
use super::chunk_generator::ResponsesChunkGenerator;
/// A complete Responses response with event accumulation.
/// Deltas append, done events replace values, and full responses replace state.
/// Incremental events with mismatched indices, IDs, or types are ignored.
#[derive(Debug, Clone, Serialize)]
pub struct ResponsesResponse {
pub id: String,
pub object: &'static str,
pub created_at: u64,
pub model: String,
pub status: ResponsesStatus,
pub completed_at: Option<u64>,
pub error: Option<ResponsesError>,
pub incomplete_details: Option<ResponsesIncompleteDetails>,
pub output: Vec<ResponsesOutputItem>,
/// Usage is null until `Finish`; constructor token counts are ignored.
pub usage: Option<ResponsesUsage>,
}
/// Response and output item states generated from parsed output chunks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponsesStatus {
InProgress,
Completed,
Incomplete,
}
/// Error fields for a complete response. Parsed output chunks contain no error
/// information, so generated responses set `error` to null.
#[derive(Debug, Clone, Serialize)]
pub struct ResponsesError {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ResponsesIncompleteDetails {
pub reason: ResponsesIncompleteReason,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponsesIncompleteReason {
MaxOutputTokens,
ContentFilter,
}
/// Output items retain their position in the response throughout streaming.
/// Item IDs identify output items in events. Call IDs associate subsequent
/// tool results with their calls.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesOutputItem {
Message {
id: String,
status: ResponsesStatus,
role: ResponsesRole,
phase: ResponsesPhase,
content: Vec<ResponsesContentPart>,
},
Reasoning {
id: String,
status: ResponsesStatus,
content: Vec<ResponsesContentPart>,
summary: Vec<()>,
},
FunctionCall {
id: String,
status: ResponsesStatus,
call_id: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
namespace: Option<String>,
arguments: String,
},
CustomToolCall {
id: String,
status: ResponsesStatus,
call_id: String,
name: String,
input: String,
},
}
/// Content produced from parsed model output.
/// Annotations and log probabilities are emitted as empty arrays.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesContentPart {
OutputText {
annotations: Vec<()>,
logprobs: Vec<()>,
text: String,
},
ReasoningText {
text: String,
},
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponsesRole {
Assistant,
}
/// Answer content immediately before a tool call uses `commentary` in the
/// completed output item.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponsesPhase {
Commentary,
FinalAnswer,
}
/// Backend prompt counts combined with completion counts accumulated by the
/// stream processor.
#[derive(Debug, Clone, Copy, Serialize)]
pub struct ResponsesUsage {
pub input_tokens: usize,
pub input_tokens_details: ResponsesInputTokensDetails,
pub output_tokens: usize,
pub output_tokens_details: ResponsesOutputTokensDetails,
pub total_tokens: usize,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct ResponsesInputTokensDetails {
pub cached_tokens: usize,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct ResponsesOutputTokensDetails {
/// Always zero because the backend reports no reasoning token count.
pub reasoning_tokens: usize,
}
impl From<(PromptUsage, CompletionUsage)> for ResponsesUsage {
fn from((prompt, completion): (PromptUsage, CompletionUsage)) -> Self {
Self {
input_tokens: prompt.prompt_tokens,
input_tokens_details: ResponsesInputTokensDetails {
cached_tokens: prompt.prompt_cache_hit_tokens,
},
output_tokens: completion.completion_tokens,
output_tokens_details: ResponsesOutputTokensDetails {
reasoning_tokens: 0,
},
total_tokens: prompt.prompt_tokens + completion.completion_tokens,
}
}
}
/// A typed Responses SSE event. The `type` field is also the SSE event name.
/// Sequence numbers increase across all event kinds within one response.
#[derive(Debug, Clone, Serialize, IntoStaticStr)]
#[serde(tag = "type")]
pub enum ResponsesStreamEvent {
#[serde(rename = "response.created")]
#[strum(serialize = "response.created")]
Created {
response: ResponsesResponse,
sequence_number: usize,
},
#[serde(rename = "response.in_progress")]
#[strum(serialize = "response.in_progress")]
InProgress {
response: ResponsesResponse,
sequence_number: usize,
},
#[serde(rename = "response.output_item.added")]
#[strum(serialize = "response.output_item.added")]
OutputItemAdded {
item: ResponsesOutputItem,
output_index: usize,
sequence_number: usize,
},
#[serde(rename = "response.content_part.added")]
#[strum(serialize = "response.content_part.added")]
ContentPartAdded {
content_index: usize,
item_id: String,
output_index: usize,
part: ResponsesContentPart,
sequence_number: usize,
},
#[serde(rename = "response.output_text.delta")]
#[strum(serialize = "response.output_text.delta")]
OutputTextDelta {
content_index: usize,
delta: String,
item_id: String,
logprobs: Vec<()>,
output_index: usize,
sequence_number: usize,
},
#[serde(rename = "response.output_text.done")]
#[strum(serialize = "response.output_text.done")]
OutputTextDone {
content_index: usize,
item_id: String,
logprobs: Vec<()>,
output_index: usize,
sequence_number: usize,
text: String,
},
#[serde(rename = "response.reasoning_text.delta")]
#[strum(serialize = "response.reasoning_text.delta")]
ReasoningTextDelta {
content_index: usize,
delta: String,
item_id: String,
output_index: usize,
sequence_number: usize,
},
#[serde(rename = "response.reasoning_text.done")]
#[strum(serialize = "response.reasoning_text.done")]
ReasoningTextDone {
content_index: usize,
item_id: String,
output_index: usize,
sequence_number: usize,
text: String,
},
#[serde(rename = "response.function_call_arguments.delta")]
#[strum(serialize = "response.function_call_arguments.delta")]
FunctionCallArgumentsDelta {
delta: String,
item_id: String,
output_index: usize,
sequence_number: usize,
},
#[serde(rename = "response.function_call_arguments.done")]
#[strum(serialize = "response.function_call_arguments.done")]
FunctionCallArgumentsDone {
arguments: String,
item_id: String,
output_index: usize,
sequence_number: usize,
},
#[serde(rename = "response.custom_tool_call_input.delta")]
#[strum(serialize = "response.custom_tool_call_input.delta")]
CustomToolCallInputDelta {
delta: String,
item_id: String,
output_index: usize,
sequence_number: usize,
},
#[serde(rename = "response.custom_tool_call_input.done")]
#[strum(serialize = "response.custom_tool_call_input.done")]
CustomToolCallInputDone {
input: String,
item_id: String,
output_index: usize,
sequence_number: usize,
},
#[serde(rename = "response.content_part.done")]
#[strum(serialize = "response.content_part.done")]
ContentPartDone {
content_index: usize,
item_id: String,
output_index: usize,
part: ResponsesContentPart,
sequence_number: usize,
},
#[serde(rename = "response.output_item.done")]
#[strum(serialize = "response.output_item.done")]
OutputItemDone {
item: ResponsesOutputItem,
output_index: usize,
sequence_number: usize,
},
#[serde(rename = "response.completed")]
#[strum(serialize = "response.completed")]
Completed {
response: ResponsesResponse,
sequence_number: usize,
},
#[serde(rename = "response.incomplete")]
#[strum(serialize = "response.incomplete")]
Incomplete {
response: ResponsesResponse,
sequence_number: usize,
},
}
impl ProtocolResponse for ResponsesResponse {
type ChunkGenerator = ResponsesChunkGenerator;
fn new(
id: String,
model: String,
created: u64,
_prompt_tokens: usize,
_prompt_cache_hit_tokens: usize,
) -> Self {
Self {
id,
object: "response",
created_at: created,
model,
status: ResponsesStatus::InProgress,
completed_at: None,
error: None,
incomplete_details: None,
output: vec![],
usage: None,
}
}
fn chunk_event_type(chunk: &ResponsesStreamEvent) -> Option<&'static str> {
Some(chunk.into())
}
}

View File

@ -0,0 +1,131 @@
use deepseek_recipe_core::messages::InputMessage;
use deepseek_recipe_core::multimodal::IMAGE_SPECIAL_TOKEN;
use deepseek_recipe_core::tools::ToolDefinition;
use deepseek_recipe_core::util::json_formatter::stringify_python_style;
use crate::request::ConversionError;
/// Return the error for input text carrying [`IMAGE_SPECIAL_TOKEN`].
///
/// An adapter inserts one placeholder per image source. A placeholder in input
/// text would reach the prompt without an image source, so the adapters reject
/// input text that spells it out.
pub(super) fn image_special_token_not_allowed() -> ConversionError {
ConversionError::bad_request(format!(
"The sub-string \"{IMAGE_SPECIAL_TOKEN}\" in your input is not allowed for this API. Please remove \
\"{IMAGE_SPECIAL_TOKEN}\" from your input and try again."
))
}
/// Return the error for converted tool text carrying [`IMAGE_SPECIAL_TOKEN`].
///
/// A rendered tool prompt contains the name, description, and parameter schema
/// of every definition, and the tool calls of a historical assistant turn
/// contribute their names and arguments. Declared tool names are already
/// restricted by [`validate_tool_name`]; historical call names and arguments are
/// not.
pub(super) fn validate_tool_text(
tools: &[ToolDefinition],
messages: &[InputMessage],
) -> Result<(), ConversionError> {
if tools
.iter()
.any(tool_definition_contains_image_special_token)
|| messages.iter().any(tool_calls_contain_image_special_token)
{
return Err(image_special_token_not_allowed());
}
Ok(())
}
/// Return whether a tool definition carries [`IMAGE_SPECIAL_TOKEN`].
fn tool_definition_contains_image_special_token(tool: &ToolDefinition) -> bool {
tool.name.contains(IMAGE_SPECIAL_TOKEN)
|| tool
.description
.as_deref()
.is_some_and(|description| description.contains(IMAGE_SPECIAL_TOKEN))
|| stringify_python_style(&tool.parameters).contains(IMAGE_SPECIAL_TOKEN)
}
/// Return whether the tool calls of one message carry [`IMAGE_SPECIAL_TOKEN`].
fn tool_calls_contain_image_special_token(message: &InputMessage) -> bool {
let InputMessage::Assistant { tool_calls, .. } = message else {
return false;
};
tool_calls.as_ref().is_some_and(|tool_calls| {
tool_calls.iter().any(|tool_call| {
tool_call.name.contains(IMAGE_SPECIAL_TOKEN)
|| tool_call.arguments.contains(IMAGE_SPECIAL_TOKEN)
})
})
}
pub(super) fn validate_max_tokens(value: Option<u32>, field: &str) -> Result<(), ConversionError> {
if value == Some(0) {
return Err(ConversionError::bad_request(format!(
"{field} must be greater than zero"
)));
}
Ok(())
}
pub(super) fn validate_sampling(
temperature: Option<f32>,
top_p: Option<f32>,
) -> Result<(), ConversionError> {
if temperature.is_some_and(|value| !(0.0..=2.0).contains(&value)) {
return Err(ConversionError::bad_request(
"temperature must be in [0, 2]",
));
}
if top_p.is_some_and(|value| !value.is_finite() || value <= 0.0 || value > 1.0) {
return Err(ConversionError::bad_request("top_p must be in (0, 1]"));
}
Ok(())
}
pub(super) fn validate_tool_name(name: &str, path: &str) -> Result<(), ConversionError> {
if name.is_empty()
|| name.len() > 128
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
{
return Err(ConversionError::bad_request(format!(
"{path}: expected 1-128 ASCII letters, digits, underscores or hyphens"
)));
}
Ok(())
}
pub(super) fn validate_tool_parameters(
schema: &serde_json::Value,
path: &str,
) -> Result<(), ConversionError> {
if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
return Err(ConversionError::bad_request(format!(
"{path} must be a JSON Schema of type object"
)));
}
// Schema validation uses local data with HTTP and file resolution disabled.
jsonschema::JSONSchema::compile(schema)
.map(|_| ())
.map_err(|error| ConversionError::bad_request(format!("{path}: {error}")))
}
pub(super) fn contains_json_instruction(messages: &[InputMessage]) -> bool {
messages.iter().any(|message| {
let content = match message {
InputMessage::System { content }
| InputMessage::User { content, .. }
| InputMessage::Assistant { content, .. }
| InputMessage::Tool { content, .. }
| InputMessage::LatestReminder { content } => content,
};
content
.as_bytes()
.windows(4)
.any(|part| part.eq_ignore_ascii_case(b"json"))
})
}

View File

@ -0,0 +1,109 @@
//! Shared request data and the conversion contract for protocol adapters.
use deepseek_recipe_core::conversation::Conversation;
use crate::response::ProtocolResponse;
use crate::stream::state_machine::ParsingOptions;
pub use options::{ConversionOptions, WebSearchBehavior};
mod options;
/// Inference parameters extracted from a protocol request.
///
/// Unspecified parameters remain `None`; model-specific defaults belong to the
/// caller.
#[derive(Debug, Default)]
pub struct InferenceOptions {
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
/// Passed through for applications that support a thinking budget.
pub thinking_budget_tokens: Option<u64>,
/// Passed through for the caller to control parallel tool execution.
pub disable_parallel_tool_use: Option<bool>,
}
/// A conversation with inference parameters and output parsing options.
///
/// Protocol adapters produce this representation. The caller renders the
/// conversation, tokenizes the prompt, and constructs the inference request.
/// Protocol-specific metadata is available on the protocol request before
/// conversion.
#[derive(Debug)]
pub struct ConversationRequest {
pub conversation: Conversation,
pub inference_options: InferenceOptions,
pub parsing_options: ParsingOptions,
/// Original model name, when exposed by the adapter. The caller resolves it.
pub model: Option<String>,
pub stream: bool,
}
impl ConversationRequest {
/// Construct a request with default inference, parsing, and transport options.
pub fn new(conversation: Conversation) -> Self {
Self {
conversation,
inference_options: InferenceOptions::default(),
parsing_options: ParsingOptions::default(),
model: None,
stream: false,
}
}
}
/// Convert protocol input into a shared conversation request.
pub trait ProtocolRequest: 'static + Send + Sync {
type Response: ProtocolResponse;
/// Validate and convert the protocol's supported request fields.
///
/// Extract any protocol-specific metadata needed by the application first.
///
/// # Errors
///
/// Returns [`ConversionError::BadRequest`] for invalid or unsupported input, or
/// [`ConversionError::Internal`] if an internal conversion step fails.
fn convert(self, options: ConversionOptions) -> Result<ConversationRequest, ConversionError>;
/// Construct protocol events using the converted request's resolved settings.
fn chunk_generator(
request: &ConversationRequest,
id: String,
model: String,
) -> <Self::Response as ProtocolResponse>::ChunkGenerator;
}
/// A failure while converting protocol input into a conversation request.
#[derive(Debug)]
pub enum ConversionError {
/// The input is invalid or uses an unsupported feature.
BadRequest { detail: String },
/// An internal conversion operation failed.
Internal { detail: String },
}
impl ConversionError {
pub fn bad_request(detail: impl Into<String>) -> Self {
ConversionError::BadRequest {
detail: detail.into(),
}
}
pub fn internal(detail: impl Into<String>) -> Self {
ConversionError::Internal {
detail: detail.into(),
}
}
}
impl std::fmt::Display for ConversionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadRequest { detail } | Self::Internal { detail } => f.write_str(detail),
}
}
}
impl std::error::Error for ConversionError {}

View File

@ -0,0 +1,70 @@
/// How an adapter handles supported `web_search` declarations and tool choices.
/// Messages also applies this setting to assistant server-tool content blocks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum WebSearchBehavior {
/// Drop matching declarations and choices, plus Messages server-tool blocks.
#[default]
Ignore,
/// Reject matching declarations and choices, plus Messages server-tool blocks.
Reject,
}
/// Defaults for conversion from protocol requests.
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct ConversionOptions {
/// Default thinking mode for requests with an unspecified thinking mode.
///
/// Defaults to `true`.
pub default_thinking_mode: bool,
/// Behavior for Responses `web_search` tool declarations and tool choices.
/// Historical `web_search_call` input items are ignored under either setting.
///
/// Defaults to [`WebSearchBehavior::Ignore`].
pub responses_web_search: WebSearchBehavior,
/// Behavior for the Messages `web_search` server tool, its named tool
/// choice, and its `server_tool_use` / `web_search_tool_result` blocks.
///
/// Defaults to [`WebSearchBehavior::Reject`].
pub messages_web_search: WebSearchBehavior,
}
impl ConversionOptions {
/// Construct options that enable thinking for requests that do not
/// specify a thinking mode.
pub const fn new() -> Self {
Self {
default_thinking_mode: true,
responses_web_search: WebSearchBehavior::Ignore,
messages_web_search: WebSearchBehavior::Reject,
}
}
/// Set the default thinking mode, consuming and returning `self`.
#[must_use]
pub fn with_default_thinking_mode(mut self, default_thinking_mode: bool) -> Self {
self.default_thinking_mode = default_thinking_mode;
self
}
/// Set the Responses `web_search` behavior, consuming and returning `self`.
#[must_use]
pub fn with_responses_web_search(mut self, responses_web_search: WebSearchBehavior) -> Self {
self.responses_web_search = responses_web_search;
self
}
/// Set the Messages `web_search` behavior, consuming and returning `self`.
#[must_use]
pub fn with_messages_web_search(mut self, messages_web_search: WebSearchBehavior) -> Self {
self.messages_web_search = messages_web_search;
self
}
}
impl Default for ConversionOptions {
fn default() -> Self {
Self::new()
}
}

View File

@ -0,0 +1,35 @@
//! Protocol response construction and event accumulation.
use serde::Serialize;
use crate::stream::ChunkGenerator;
use crate::util::append_delta::AppendDelta;
/// A protocol response that can accumulate events from its chunk generator.
pub trait ProtocolResponse:
'static + Send + Serialize + AppendDelta<<Self::ChunkGenerator as ChunkGenerator>::Chunk>
{
/// Generator for this protocol's response events.
type ChunkGenerator: ChunkGenerator;
/// Construct a response with caller-supplied identity and input token usage.
fn new(
id: String,
model: String,
created: u64,
prompt_tokens: usize,
prompt_cache_hit_tokens: usize,
) -> Self;
/// Return the SSE event name, or `None` for an unnamed data event.
fn chunk_event_type(
_chunk: &<Self::ChunkGenerator as ChunkGenerator>::Chunk,
) -> Option<&'static str> {
None
}
/// Return a final transport sentinel if the protocol requires one.
fn done_message() -> Option<String> {
None
}
}

View File

@ -0,0 +1,71 @@
//! Decode token ids into text for the stream processor.
use std::sync::Arc;
use tokenizers::Tokenizer;
use super::StreamError;
/// Replacement character emitted while a multi-token character is incomplete.
const REPLACEMENT_CHARACTER: char = '\u{FFFD}';
/// Decode token ids into text.
///
/// Implemented for [`Tokenizer`]; other backends can implement it to feed a
/// stream processor.
pub trait TokenizerDecoder: Send {
/// Decode `ids` into text, skipping special tokens when requested.
fn decode_ids(&self, ids: &[u32], skip_special_tokens: bool) -> Result<String, String>;
}
impl TokenizerDecoder for Tokenizer {
fn decode_ids(&self, ids: &[u32], skip_special_tokens: bool) -> Result<String, String> {
self.decode(ids, skip_special_tokens)
.map_err(|error| error.to_string())
}
}
impl<T> TokenizerDecoder for Arc<T>
where
T: TokenizerDecoder + Sync,
{
fn decode_ids(&self, ids: &[u32], skip_special_tokens: bool) -> Result<String, String> {
(**self).decode_ids(ids, skip_special_tokens)
}
}
/// Buffer token ids until they decode into text.
pub struct StreamDecoder {
decoder: Box<dyn TokenizerDecoder>,
ids: Vec<u32>,
pending: usize,
}
impl StreamDecoder {
pub fn new(decoder: Box<dyn TokenizerDecoder>) -> Self {
Self {
decoder,
ids: Vec::new(),
pending: 0,
}
}
/// Return the text contributed by the buffered token ids and the number of
/// ids it accounts for, or `None` while no text is available yet.
///
/// Special tokens drive the parser's state machine, so they stay in the
/// decoded text. A decoder failure is returned as a stream error.
pub fn decode(&mut self, token_id: u32) -> Result<Option<(String, usize)>, StreamError> {
self.pending += 1;
self.ids.push(token_id);
let content = self
.decoder
.decode_ids(&self.ids, false)
.map_err(|detail| StreamError::Decode { detail })?;
if content.is_empty() || content.ends_with(REPLACEMENT_CHARACTER) {
return Ok(None);
}
self.ids.clear();
Ok(Some((content, std::mem::take(&mut self.pending))))
}
}

View File

@ -0,0 +1,61 @@
/// Prompt-side token counts supplied by the inference backend. Unspecified
/// counts default to zero.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PromptUsage {
/// Total input tokens, including tokens read from the prompt cache.
pub prompt_tokens: usize,
/// Input tokens read from the prompt cache.
pub prompt_cache_hit_tokens: usize,
}
/// Completion-side token counts accumulated by the stream processor from
/// `Text.content_tokens` and token IDs that decode into text.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CompletionUsage {
/// Tokens accounted for by processed chunks, including discarded markup
/// and the entire chunk containing a local stop sequence. Undecoded trailing
/// token IDs and chunks after the stop are excluded.
pub completion_tokens: usize,
}
/// Text and metadata received from an inference backend.
#[derive(Debug)]
pub enum InferenceChunk {
/// Initial metadata reported before the model produces output.
Ready {
system_fingerprint: Option<String>,
prompt_usage: PromptUsage,
},
/// Text produced by the model, and the tokens that text accounts for.
Text {
content: String,
content_tokens: usize,
},
/// One generated token id, decoded by the processor's tokenizer.
Token { token_id: u32 },
/// Terminal metadata reported after the model stops.
Finish {
finish_reason: InferenceFinishReason,
},
}
/// The finish reason reported by the inference backend.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InferenceFinishReason {
Stop,
Length,
ContentFilter,
}
/// Final result after applying the stream parser's tool and stop-sequence
/// rules.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FinishReason {
Stop,
Length,
ToolCalls,
StopSequence,
ContentFilter,
/// The source ended without an explicit finish reason.
EndOfStream,
}

View File

@ -0,0 +1,74 @@
//! Parse model output and turn it into protocol-specific response events.
pub use decoder::TokenizerDecoder;
pub use inference::{
CompletionUsage, FinishReason, InferenceChunk, InferenceFinishReason, PromptUsage,
};
pub use processor::StreamProcessor;
mod decoder;
mod inference;
mod processor;
pub mod state_machine;
/// An error that ends stream processing before normal completion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamError {
/// A token-id chunk arrived without an attached tokenizer.
MissingTokenizer,
/// The tokenizer failed to decode buffered token ids.
Decode {
/// The decoder's failure message.
detail: String,
},
}
impl std::fmt::Display for StreamError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingTokenizer => {
formatter.write_str("token chunk received without a tokenizer")
}
Self::Decode { detail } => write!(formatter, "failed to decode token ids: {detail}"),
}
}
}
impl std::error::Error for StreamError {}
/// A parsed piece of model output, before protocol-specific event conversion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutputChunk {
Start {
system_fingerprint: Option<String>,
usage: PromptUsage,
},
Raw {
content: String,
},
Reasoning {
content: String,
},
ToolCallBegin,
ToolCall {
tool_name: String,
arguments: String,
},
ToolArgumentsDelta {
content: String,
},
Finish {
reason: FinishReason,
stop_sequence: Option<String>,
usage: CompletionUsage,
},
}
/// Convert parsed output into a protocol's response events.
pub trait ChunkGenerator: Send + 'static {
/// Event type emitted by this protocol.
type Chunk;
/// Produce zero or more events for one parsed output chunk.
fn generate(&mut self, chunk: OutputChunk) -> impl Future<Output = Vec<Self::Chunk>> + Send;
}

View File

@ -0,0 +1,369 @@
use std::collections::VecDeque;
use std::pin::pin;
use async_stream::stream;
use tokio_stream::{Stream, StreamExt};
use super::decoder::{StreamDecoder, TokenizerDecoder};
use super::inference::{
CompletionUsage, FinishReason, InferenceChunk, InferenceFinishReason, PromptUsage,
};
use super::state_machine::{OutputAction, OutputActionSegment, ParsingOptions, StateMachine};
use super::{ChunkGenerator, OutputChunk, StreamError};
/// Parse backend inference chunks and produce protocol response events.
pub struct StreamProcessor<G> {
generator: G,
options: ParsingOptions,
decoder: Option<StreamDecoder>,
}
impl<G> StreamProcessor<G>
where
G: ChunkGenerator,
{
/// Combine a protocol event generator with output parsing options.
pub fn new(generator: G, options: ParsingOptions) -> Self {
Self {
generator,
options,
decoder: None,
}
}
/// Decode `InferenceChunk::Token` ids with the supplied decoder.
///
/// Ids that contribute text count as completion tokens, including the ids
/// buffered while a multi-token character was incomplete. IDs still buffered
/// at the end of input contribute neither text nor completion usage.
/// Without a decoder, a token chunk fails the stream with
/// [`StreamError::MissingTokenizer`].
pub fn with_tokenizer(mut self, decoder: impl TokenizerDecoder + 'static) -> Self {
self.decoder = Some(StreamDecoder::new(Box::new(decoder)));
self
}
/// Consume inference chunks until a finish chunk, matched stop sequence, or EOF.
///
/// A ready chunk received before output starts supplies the initial prompt
/// usage and fingerprint. Without it, the start event uses zero prompt usage
/// and no fingerprint. Later ready chunks do not update emitted metadata.
/// Completion usage accumulates each processed chunk's token count, including
/// the entire chunk containing a stop sequence; later chunks are not read.
/// Missing tokenizers and decoder failures yield an error and end the stream
/// without a normal finish event.
pub fn process(
self,
inference: impl Stream<Item = InferenceChunk> + Send,
) -> impl Stream<Item = Result<G::Chunk, StreamError>> + Send
where
G::Chunk: Send,
{
let mut generator = self.generator;
let options = self.options;
let mut decoder = self.decoder;
stream! {
let mut inference = pin!(inference);
let mut state_machine = StateMachine::new(options);
let mut stashed = StashedChunks::new();
let mut started = false;
let mut prompt_usage = PromptUsage::default();
let mut completion_usage = CompletionUsage::default();
let mut backend_finish = None;
let mut stop_sequence = None;
while let Some(chunk) = inference.next().await {
let (content, content_tokens) = match chunk {
InferenceChunk::Ready {
system_fingerprint,
prompt_usage: ready_usage,
} => {
prompt_usage = ready_usage;
if !started {
for out in generator.generate(OutputChunk::Start { system_fingerprint, usage: prompt_usage }).await {
yield Ok(out);
}
started = true;
}
continue;
}
InferenceChunk::Finish { finish_reason } => {
if !started {
for out in generator.generate(OutputChunk::Start { system_fingerprint: None, usage: prompt_usage }).await {
yield Ok(out);
}
started = true;
}
backend_finish = Some(finish_reason);
break;
}
InferenceChunk::Text {
content,
content_tokens,
} => (content, content_tokens),
InferenceChunk::Token { token_id } => {
let Some(decoder) = decoder.as_mut() else {
yield Err(StreamError::MissingTokenizer);
return;
};
match decoder.decode(token_id) {
Ok(Some(decoded)) => decoded,
Ok(None) => continue,
Err(error) => {
yield Err(error);
return;
}
}
}
};
if !started {
for out in generator.generate(OutputChunk::Start { system_fingerprint: None, usage: prompt_usage }).await {
yield Ok(out);
}
started = true;
}
completion_usage.completion_tokens += content_tokens;
let actions = state_machine.feed(&content);
stashed.push(Some(content));
for out in stashed.apply_actions(actions, &mut generator).await {
yield Ok(out);
}
stop_sequence = stashed.take_stop_sequence();
if stop_sequence.is_some() {
break;
}
}
if !started {
for out in generator.generate(OutputChunk::Start { system_fingerprint: None, usage: prompt_usage }).await {
yield Ok(out);
}
}
let actions = state_machine.finish();
for out in stashed.apply_actions(actions, &mut generator).await {
yield Ok(out);
}
// Combine the parts of a stop sequence matched across source chunks.
if let Some(tail) = stashed.take_stop_sequence() {
stop_sequence.get_or_insert_with(String::new).push_str(&tail);
}
let reason = if stop_sequence.is_some() {
FinishReason::StopSequence
} else {
match backend_finish {
Some(InferenceFinishReason::Stop) if stashed.has_tool_calls => {
FinishReason::ToolCalls
}
Some(InferenceFinishReason::Stop) => FinishReason::Stop,
Some(InferenceFinishReason::Length) => FinishReason::Length,
Some(InferenceFinishReason::ContentFilter) => FinishReason::ContentFilter,
None => FinishReason::EndOfStream,
}
};
let finish = OutputChunk::Finish {
reason,
stop_sequence,
usage: completion_usage,
};
for out in generator.generate(finish).await {
yield Ok(out);
}
}
}
}
struct StashedChunks {
chunks: VecDeque<Option<String>>,
last_stashed_action: Option<OutputActionSegment>,
stashed_tool_name: String,
last_pop_action: OutputAction,
stop_sequence: Option<String>,
has_tool_calls: bool,
}
impl StashedChunks {
fn new() -> Self {
Self {
chunks: VecDeque::new(),
last_stashed_action: None,
stashed_tool_name: String::new(),
last_pop_action: OutputAction::Skip,
stop_sequence: None,
has_tool_calls: false,
}
}
fn push(&mut self, content: Option<String>) {
self.chunks.push_back(content);
}
fn take_stop_sequence(&mut self) -> Option<String> {
self.stop_sequence.take()
}
fn pop(&mut self, action: OutputActionSegment) -> Option<OutputChunk> {
let mut front_chunk = self.chunks.pop_front()?;
let front_len = front_chunk
.as_ref()
.map(|content| content.len())
.unwrap_or(0);
if action.len < front_len {
let remaining = front_chunk
.as_mut()
.map(|content| content.split_off(action.len));
self.chunks.push_front(remaining);
}
let last_pop_action = self.last_pop_action;
self.last_pop_action = action.action;
match action.action {
OutputAction::Raw => non_empty(front_chunk).map(|content| OutputChunk::Raw { content }),
OutputAction::Reasoning => {
non_empty(front_chunk).map(|content| OutputChunk::Reasoning { content })
}
OutputAction::ToSpace => Some(OutputChunk::Raw {
content: " ".to_string(),
}),
OutputAction::Skip | OutputAction::SkipInvalid { .. } => None,
OutputAction::StopSequence => {
let content = front_chunk.unwrap_or_default();
self.stop_sequence
.get_or_insert_with(String::new)
.push_str(&content);
None
}
OutputAction::ToolCallBegin => {
if last_pop_action != OutputAction::ToolCallBegin {
Some(OutputChunk::ToolCallBegin)
} else {
None
}
}
OutputAction::ToolName => {
if let Some(content) = front_chunk {
self.stashed_tool_name.push_str(&content);
}
None
}
OutputAction::ToolNameEnd => {
if last_pop_action != OutputAction::ToolNameEnd {
self.has_tool_calls = true;
let tool_name = std::mem::take(&mut self.stashed_tool_name);
Some(OutputChunk::ToolCall {
tool_name,
arguments: String::new(),
})
} else {
None
}
}
OutputAction::LabelToolCallArguments { label, .. } => {
if last_pop_action != action.action {
Some(OutputChunk::ToolArgumentsDelta {
content: label.to_string(),
})
} else {
None
}
}
OutputAction::RawToolCallArguments { string } => {
let content = if string {
escape_json_string(front_chunk.unwrap_or_default())
} else {
front_chunk.unwrap_or_default()
};
Some(OutputChunk::ToolArgumentsDelta { content })
}
OutputAction::ToolCallArgumentsEnd { output } => {
if last_pop_action == action.action {
return None;
}
output.map(|output| OutputChunk::ToolArgumentsDelta {
content: output.to_string(),
})
}
OutputAction::Label(label) => {
if last_pop_action != action.action {
Some(OutputChunk::Raw {
content: label.to_string(),
})
} else {
None
}
}
}
}
fn apply_whole_chunks(
&mut self,
action: &mut OutputActionSegment,
outputs: &mut Vec<OutputChunk>,
) {
loop {
let front_len = self
.chunks
.front()
.map(|content| content.as_ref().map(|content| content.len()).unwrap_or(0));
if front_len.is_some_and(|front_len| front_len <= action.len) {
let front_len = front_len.unwrap();
if let Some(chunk) = self.pop(OutputActionSegment::new(action.action, front_len)) {
outputs.push(chunk);
}
action.len -= front_len;
} else {
return;
}
}
}
async fn apply_actions<G>(
&mut self,
actions: Vec<OutputActionSegment>,
generator: &mut G,
) -> Vec<G::Chunk>
where
G: ChunkGenerator,
{
let mut outputs = Vec::new();
let mut last_stashed_action = self.last_stashed_action.take();
for action in actions {
if let Some(last_stashed_action) = &mut last_stashed_action {
if action.action == last_stashed_action.action {
last_stashed_action.len += action.len;
} else {
self.apply_whole_chunks(last_stashed_action, &mut outputs);
if last_stashed_action.len > 0
&& let Some(chunk) = self.pop(last_stashed_action.clone())
{
outputs.push(chunk);
}
*last_stashed_action = action;
}
} else {
last_stashed_action = Some(action);
}
}
if let Some(last_stashed_action) = &mut last_stashed_action {
self.apply_whole_chunks(last_stashed_action, &mut outputs);
}
self.last_stashed_action = last_stashed_action;
let mut chunks = Vec::new();
for output in outputs {
chunks.extend(generator.generate(output).await);
}
chunks
}
}
fn non_empty(content: Option<String>) -> Option<String> {
content.filter(|content| !content.is_empty())
}
fn escape_json_string(content: String) -> String {
match serde_json::to_string(&content) {
Ok(escaped) if escaped.len() > 2 => escaped[1..escaped.len() - 1].to_string(),
_ => String::new(),
}
}

View File

@ -0,0 +1,577 @@
//! Incrementally recognize reasoning, tool-call markup, and stop sequences.
const JSON_BEGIN_LABELS: [&str; 2] = ["```json\n", "```\n"];
/// Position within the reasoning section when parsing begins.
#[derive(Debug, Clone, Copy)]
pub enum ReasoningStage {
/// Begin reasoning while discarding leading newlines.
Start,
/// Continue reasoning while preserving leading newlines.
Reasoning,
/// Continue answer content while preserving leading newlines.
Content,
}
impl ReasoningStage {
pub fn start_from_reasoning(&self) -> bool {
matches!(self, Self::Start | Self::Reasoning)
}
}
const RAW_JSON_BEGIN_LABELS: [&str; 2] = ["{", "["];
const JSON_END_LABEL: &str = "\n```";
const DSML_BEGIN_LABEL: &str = "<|DSML|";
const DSML_END_LABEL: &str = "</|DSML|";
const DSML_TOOL_CALLS_END_LABELS: [&str; 2] = ["</|DSML|tool_calls>", "</|DSML| calls>"];
pub const REASONING_END_LABEL: &str = "</think>";
/// Settings for parsing inference output.
#[derive(Debug, Clone)]
pub struct ParsingOptions {
/// Parse model-specific tool-call markup.
pub parse_tool_calls: bool,
/// Begin parsing inside the tool-call block specified by the prompt.
/// Takes precedence over `reasoning_initial_stage`.
pub tool_call_initial_stage: bool,
/// Recognize JSON fences or a raw `{` or `[` prefix and replace surrounding
/// text with spaces. This does not validate JSON syntax.
pub parse_json_output: bool,
/// Initial reasoning or answer stage. `None` starts answer content with
/// leading-newline removal.
pub reasoning_initial_stage: Option<ReasoningStage>,
/// Match stop sequences in ordinary answer text and detected JSON output.
/// Reasoning, tool-call markup, and surrounding JSON-mode text are excluded.
/// The state machine discards empty sequences during construction.
pub stop_sequences: Vec<String>,
}
impl Default for ParsingOptions {
fn default() -> Self {
Self {
parse_tool_calls: true,
tool_call_initial_stage: false,
parse_json_output: false,
reasoning_initial_stage: None,
stop_sequences: Vec::new(),
}
}
}
/// An incremental parser whose output segments refer to input byte lengths.
pub struct StateMachine {
options: ParsingOptions,
state: State,
}
impl StateMachine {
pub fn new(mut options: ParsingOptions) -> Self {
options
.stop_sequences
.retain(|sequence| !sequence.is_empty());
let stage = if options.tool_call_initial_stage {
Stage::ToolCalls
} else if let Some(reasoning_initial_stage) = &options.reasoning_initial_stage {
match reasoning_initial_stage {
ReasoningStage::Start => Stage::Reasoning { is_leading: true },
ReasoningStage::Reasoning => Stage::Reasoning { is_leading: false },
ReasoningStage::Content => Stage::Common { is_leading: false },
}
} else {
Stage::Common { is_leading: true }
};
let state = State::new(stage, &options);
Self { options, state }
}
/// Parse the next source chunk, retaining incomplete markers for later input.
pub fn feed(&mut self, content: &str) -> Vec<OutputActionSegment> {
self.state.feed(content, &self.options)
}
/// Process buffered input when the source ends.
pub fn finish(self) -> Vec<OutputActionSegment> {
self.state.finish().1
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InvalidTokenKind {
ExtraEndOfThinking,
ContentAfterFinished,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OutputAction {
Raw,
ToSpace,
SkipInvalid { kind: InvalidTokenKind },
Skip,
StopSequence,
ToolCallBegin,
ToolName,
ToolNameEnd,
LabelToolCallArguments { label: &'static str, id: u32 },
RawToolCallArguments { string: bool },
ToolCallArgumentsEnd { output: Option<&'static str> },
Reasoning,
Label(&'static str),
}
#[derive(Debug, Clone)]
pub struct OutputActionSegment {
pub action: OutputAction,
pub len: usize,
}
impl OutputActionSegment {
pub fn new(action: OutputAction, len: usize) -> Self {
Self { action, len }
}
}
#[derive(Debug, Clone, Copy)]
enum Stage {
Common { is_leading: bool },
Json,
MatchedJson,
ToolCalls,
ToolName,
ToolCallArguments { is_leading: bool },
ToolCallParamName,
ToolCallParamType,
ToolCallParamValue { string: bool },
Reasoning { is_leading: bool },
Finished,
}
#[derive(Debug)]
struct MatchBranch {
pub state: MatchState,
pub next_stage: Stage,
pub action_on_matched: ActionOnMatched,
}
#[derive(Debug)]
struct MatchBranches(Vec<MatchBranch>);
impl MatchBranches {
fn new() -> Self {
Self(Vec::new())
}
fn new_branch(
&mut self,
pattern: MatchPattern,
next_stage: Stage,
action_on_matched: ActionOnMatched,
) {
self.0.push(MatchBranch {
state: pattern.new_state(),
next_stage,
action_on_matched,
});
}
fn new_string_branch<T: ToString + ?Sized>(
&mut self,
label: &T,
next_stage: Stage,
action_on_matched: ActionOnMatched,
) {
self.new_branch(
MatchPattern::String(label.to_string()),
next_stage,
action_on_matched,
);
}
fn new_strings_branch<T: ToString>(
&mut self,
labels: &[T],
next_stage: Stage,
action_on_matched: ActionOnMatched,
) {
labels
.iter()
.for_each(|label| self.new_string_branch(label, next_stage, action_on_matched));
}
fn add_tool_call_begin_branches(&mut self) {
self.new_branch(
MatchPattern::NewlinesAndString(DSML_BEGIN_LABEL.to_string()),
Stage::ToolCalls,
ActionOnMatched::Skip,
);
}
}
#[derive(Debug)]
struct State {
stage: Stage,
match_branches: MatchBranches,
action_on_unmatched: ActionOnUnmatched,
stashed_size: usize,
}
type ActionOnMatched = OutputAction;
type ActionOnUnmatched = OutputAction;
impl State {
fn new(stage: Stage, options: &ParsingOptions) -> Self {
let mut match_branches = MatchBranches::new();
let mut add_stop_sequence_branches = || {
match_branches.new_strings_branch(
&options.stop_sequences,
Stage::Finished,
ActionOnMatched::StopSequence,
);
};
let action_on_unmatched = match stage {
Stage::Common { is_leading } => {
if options.parse_json_output {
match_branches.new_strings_branch(
&JSON_BEGIN_LABELS,
Stage::Json,
ActionOnMatched::Skip,
);
for raw_json_label in RAW_JSON_BEGIN_LABELS {
match_branches.new_string_branch(
raw_json_label,
Stage::Json,
ActionOnMatched::Label(raw_json_label),
);
}
} else {
add_stop_sequence_branches();
}
if options.parse_tool_calls {
match_branches.add_tool_call_begin_branches();
}
if is_leading {
match_branches.new_branch(
MatchPattern::LeadingNewline,
Stage::Common { is_leading: true },
ActionOnMatched::Skip,
);
}
match_branches.new_string_branch(
REASONING_END_LABEL,
Stage::Common { is_leading: false },
ActionOnMatched::SkipInvalid {
kind: InvalidTokenKind::ExtraEndOfThinking,
},
);
if options.parse_json_output {
ActionOnUnmatched::ToSpace
} else {
ActionOnUnmatched::Raw
}
}
Stage::Json => {
add_stop_sequence_branches();
match_branches.new_string_branch(
JSON_END_LABEL,
Stage::MatchedJson,
ActionOnMatched::Skip,
);
ActionOnUnmatched::Raw
}
Stage::MatchedJson => {
if options.parse_tool_calls {
match_branches.add_tool_call_begin_branches();
}
ActionOnUnmatched::ToSpace
}
Stage::ToolCalls => {
match_branches.new_string_branch(
"invoke name=\"",
Stage::ToolName,
ActionOnMatched::ToolCallBegin,
);
match_branches.new_strings_branch(
&DSML_TOOL_CALLS_END_LABELS,
Stage::Finished,
ActionOnMatched::Skip,
);
ActionOnUnmatched::Skip
}
Stage::ToolName => {
match_branches.new_string_branch(
"\"",
Stage::ToolCallArguments { is_leading: true },
ActionOnMatched::ToolNameEnd,
);
ActionOnUnmatched::ToolName
}
Stage::ToolCallArguments { is_leading } => {
match_branches.new_string_branch(
"parameter name=",
Stage::ToolCallParamName,
if is_leading {
ActionOnMatched::LabelToolCallArguments { label: "{", id: 0 }
} else {
ActionOnMatched::LabelToolCallArguments { label: ", ", id: 1 }
},
);
match_branches.new_string_branch(
DSML_END_LABEL,
Stage::ToolCalls,
if is_leading {
ActionOnMatched::ToolCallArgumentsEnd { output: Some("{}") }
} else {
ActionOnMatched::ToolCallArgumentsEnd { output: Some("}") }
},
);
ActionOnUnmatched::Skip
}
Stage::ToolCallParamName => {
match_branches.new_string_branch(
" ",
Stage::ToolCallParamType,
ActionOnMatched::LabelToolCallArguments { label: ": ", id: 2 },
);
ActionOnUnmatched::RawToolCallArguments { string: false }
}
Stage::ToolCallParamType => {
match_branches.new_string_branch(
"true\">",
Stage::ToolCallParamValue { string: true },
ActionOnMatched::LabelToolCallArguments { label: "\"", id: 3 },
);
match_branches.new_string_branch(
"false\">",
Stage::ToolCallParamValue { string: false },
ActionOnMatched::Skip,
);
ActionOnUnmatched::Skip
}
Stage::ToolCallParamValue { string } => {
match_branches.new_string_branch(
DSML_END_LABEL,
Stage::ToolCallArguments { is_leading: false },
if string {
ActionOnMatched::LabelToolCallArguments { label: "\"", id: 4 }
} else {
ActionOnMatched::Skip
},
);
ActionOnUnmatched::RawToolCallArguments { string }
}
Stage::Reasoning { is_leading } => {
if options.parse_tool_calls {
match_branches.add_tool_call_begin_branches();
}
if is_leading {
match_branches.new_branch(
MatchPattern::LeadingNewline,
Stage::Reasoning { is_leading },
ActionOnMatched::Skip,
);
}
match_branches.new_branch(
MatchPattern::NewlinesAndString(REASONING_END_LABEL.to_string()),
Stage::Common { is_leading: true },
ActionOnMatched::Skip,
);
ActionOnUnmatched::Reasoning
}
Stage::Finished => ActionOnUnmatched::SkipInvalid {
kind: InvalidTokenKind::ContentAfterFinished,
},
};
Self {
stage,
match_branches,
action_on_unmatched,
stashed_size: 0,
}
}
fn feed(&mut self, content: &str, options: &ParsingOptions) -> Vec<OutputActionSegment> {
let mut output_actions = Vec::new();
for byte in content.bytes() {
self.stashed_size += 1;
let mut matched_branch = None;
for branch in self.match_branches.0.iter_mut() {
if branch.state.feed(byte) {
matched_branch = Some((
branch.state.matching_len(),
branch.next_stage,
branch.action_on_matched,
));
break;
}
}
if let Some((matching_len, next_stage, action_on_matched)) = matched_branch {
if matching_len < self.stashed_size {
output_actions.push(OutputActionSegment::new(
self.action_on_unmatched,
self.stashed_size - matching_len,
));
}
if matching_len > 0 {
output_actions.push(OutputActionSegment::new(action_on_matched, matching_len));
}
*self = State::new(next_stage, options);
}
}
let matching_len = self
.match_branches
.0
.iter()
.map(|branch| branch.state.matching_len())
.max()
.unwrap_or(0);
if matching_len < self.stashed_size {
output_actions.push(OutputActionSegment::new(
self.action_on_unmatched,
self.stashed_size - matching_len,
));
self.stashed_size = matching_len;
}
output_actions
}
fn finish(self) -> (Stage, Vec<OutputActionSegment>) {
let mut output_actions = Vec::new();
if self.stashed_size > 0 {
output_actions.push(OutputActionSegment::new(
self.action_on_unmatched,
self.stashed_size,
));
}
(self.stage, output_actions)
}
}
enum MatchPattern {
String(String),
NewlinesAndString(String),
LeadingNewline,
}
impl MatchPattern {
fn new_state(self) -> MatchState {
match self {
Self::String(s) => {
let kmp_table = kmp_table(s.as_bytes());
MatchState::String {
s,
kmp_table,
len: 0,
}
}
Self::NewlinesAndString(s) => {
let kmp_table = kmp_table(s.as_bytes());
MatchState::NewlinesAndString {
s,
kmp_table,
len: 0,
newline_count: 0,
}
}
Self::LeadingNewline => MatchState::LeadingNewline {
leading: true,
matched: false,
},
}
}
}
type KMPTable = Vec<usize>;
fn kmp_table(pattern: &[u8]) -> KMPTable {
let mut table = vec![0; pattern.len()];
let mut len = 0;
let mut i = 1;
while i < pattern.len() {
if pattern[i] == pattern[len] {
len += 1;
table[i] = len;
i += 1;
} else if len != 0 {
len = table[len - 1];
} else {
table[i] = 0;
i += 1;
}
}
table
}
#[derive(Debug)]
enum MatchState {
String {
s: String,
kmp_table: KMPTable,
len: usize,
},
NewlinesAndString {
s: String,
kmp_table: KMPTable,
len: usize,
newline_count: usize,
},
LeadingNewline {
leading: bool,
matched: bool,
},
}
fn kmp_next(s: &str, kmp_table: &[usize], mut len: usize, byte: u8) -> usize {
while len > 0 && s.as_bytes().get(len) != Some(&byte) {
len = kmp_table[len - 1];
}
if s.as_bytes().get(len) == Some(&byte) {
len += 1;
}
len
}
impl MatchState {
fn feed(&mut self, byte: u8) -> bool {
match self {
MatchState::String { s, kmp_table, len } => {
*len = kmp_next(s, kmp_table, *len, byte);
*len == s.len()
}
MatchState::NewlinesAndString {
s,
kmp_table,
len,
newline_count,
} => {
if *len == 0 && byte == b'\n' {
*newline_count += 1;
} else {
let next = kmp_next(s, kmp_table, *len, byte);
if next <= *len {
*newline_count = 0
}
*len = next;
}
*len == s.len()
}
MatchState::LeadingNewline { leading, matched } => {
if *leading {
*matched = byte == b'\n';
}
*leading = false;
*matched
}
}
}
fn matching_len(&self) -> usize {
match self {
MatchState::String { len, .. } => *len,
MatchState::NewlinesAndString {
len, newline_count, ..
} => *len + *newline_count,
MatchState::LeadingNewline { matched, .. } => {
if *matched {
1
} else {
0
}
}
}
}
}

View File

@ -0,0 +1,39 @@
/// Accumulate an incremental update into an existing value.
pub trait AppendDelta<D> {
/// Apply one update in arrival order.
fn append(&mut self, delta: D);
}
impl<T> AppendDelta<Option<T>> for Option<T>
where
T: AppendDelta<T>,
{
fn append(&mut self, delta: Option<T>) {
let Some(delta) = delta else {
return;
};
if let Some(inner) = self {
inner.append(delta);
} else {
*self = Some(delta);
}
}
}
impl<T> AppendDelta<Vec<T>> for Vec<T> {
fn append(&mut self, delta: Vec<T>) {
self.extend(delta);
}
}
impl AppendDelta<String> for String {
fn append(&mut self, delta: String) {
self.push_str(&delta);
}
}
impl AppendDelta<&str> for String {
fn append(&mut self, delta: &str) {
self.push_str(delta);
}
}

View File

@ -0,0 +1 @@
pub mod append_delta;

212
docs/development.md Normal file
View File

@ -0,0 +1,212 @@
# Development guide
Run commands from the repository root. Use the Rust toolchain pinned in
[rust-toolchain.toml](../rust-toolchain.toml); rustup selects it automatically
inside this checkout. Python bindings require Python 3.10 or later.
## Native dependencies
The workspace requires a C/C++ compiler. OpenCV development headers and
libraries, Clang, and libclang are also required by the image crate's default
`opencv-preprocess` feature, by the Rust example server, by the inference demo,
and by the Python bindings. An application that supplies its own image
preprocessor builds the image crate with `--no-default-features` and without
OpenCV.
For Debian 12 (bookworm):
```sh
sudo apt update
sudo apt install -y build-essential pkg-config clang libclang-dev libopencv-dev
```
For Python development on Debian, also install `python3-dev` and `python3-venv`.
Use OpenCV 4.x for this workspace. The pinned Rust `opencv` 0.93 bindings support
OpenCV 3.4 and 4.x, not OpenCV 5.x. For other systems or a custom installation,
follow the binding version's [installation guide][opencv-install] and
[troubleshooting guide][opencv-troubleshooting]. Configure `PKG_CONFIG_PATH`,
`OpenCV_DIR`, or `LIBCLANG_PATH` as appropriate for your installation.
To check an OpenCV 4 installation discovered through pkg-config:
```sh
pkg-config --modversion opencv4
```
## Rust source installation
To use an unreleased checkout, create an application beside this repository:
```sh
cargo new --edition 2024 ../recipe-example
```
Add these entries under `[dependencies]` in `../recipe-example/Cargo.toml`.
Adjust the paths if the checkout directory has a different name.
```toml
deepseek-recipe = { path = "../deepseek-recipe/deepseek-recipe" }
deepseek-recipe-encoding = { path = "../deepseek-recipe/deepseek-recipe-encoding" }
serde_json = "1"
```
Save the Rust quick-start example from the [README](../README.md#quick-start)
as `../recipe-example/src/main.rs`. Run it from the repository root to use the
pinned toolchain:
```sh
cargo run --manifest-path ../recipe-example/Cargo.toml
```
## Rust checks
Run the full checks when changing Rust code, public APIs, or dependencies:
```sh
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps --locked
```
The last command uses POSIX shell syntax. On PowerShell, set
`$env:RUSTDOCFLAGS = '-D warnings'` before running `cargo doc`.
For protocol or prompt work without OpenCV, check the relevant packages:
```sh
cargo test -p deepseek-recipe-core -p deepseek-recipe -p deepseek-recipe-encoding -p encoding-decoding-demo --locked
```
The image crate compiles without OpenCV when its default features are disabled:
```sh
cargo check -p deepseek-recipe-image --no-default-features --locked
```
Cargo downloads dependencies on the first build.
## Python development and tests
After installing the native dependencies, create a virtual environment:
```sh
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install 'maturin>=1.9,<2' 'pytest>=8,<10' httpx
maturin develop --manifest-path deepseek-recipe-python/Cargo.toml --locked
python3 -m pytest deepseek-recipe-python/tests server-py/tests
```
On Windows PowerShell, activate with `.venv\Scripts\Activate.ps1` instead.
Run `maturin develop` again after changing Rust bindings. The tests exercise
request conversion, prompt rendering, response processing, and the FastAPI
example with mock inference; no model weights or API credentials are needed.
For an installation without editable development, use:
```sh
python3 -m pip install ./deepseek-recipe-python
```
## Wheel packaging
The extension links OpenCV dynamically, so a wheel is only installable on a
machine with a matching OpenCV until the libraries are copied into it:
`delocate` on macOS, `auditwheel` on Linux. The libraries of the build machine
become part of the wheel, so the wheel is built against an OpenCV that is small
enough to bundle.
Build that OpenCV from source, with only the modules the extension uses. OpenCL,
TBB, OpenEXR, and TIFF are left out because each of them links libraries of its
own that the wheel would then have to carry.
```sh
git clone --depth 1 --branch 4.12.0 https://github.com/opencv/opencv.git
cmake -S opencv -B opencv/build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$PWD/target/opencv-min" \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
-DBUILD_SHARED_LIBS=ON \
-DBUILD_LIST=core,imgproc,imgcodecs \
-DWITH_OPENCL=OFF -DWITH_TBB=OFF -DWITH_OPENEXR=OFF -DWITH_TIFF=OFF \
-DWITH_JPEG=ON -DWITH_PNG=ON -DWITH_WEBP=ON
cmake --build opencv/build --parallel
cmake --install opencv/build
```
Keep `CMAKE_OSX_DEPLOYMENT_TARGET` at the oldest release the wheel should support:
the installed libraries decide the platform tag of the wheel.
Then build the wheel against that OpenCV, and copy its libraries into it:
```sh
cd deepseek-recipe-python
export OPENCV_INCLUDE_PATHS="$PWD/../target/opencv-min/include/opencv4"
export OPENCV_LINK_PATHS="$PWD/../target/opencv-min/lib"
export OPENCV_LINK_LIBS=opencv_core,opencv_imgproc,opencv_imgcodecs
maturin build --release
delocate-wheel -w dist-repaired dist/*.whl # `auditwheel repair` on Linux
twine check dist-repaired/*
```
The `OPENCV_*` variables take precedence over pkg-config, so an OpenCV installed
elsewhere on the build machine cannot take over. The linker still resolves
`-lopencv_core` in the order of its search paths, so a build machine that carries
another OpenCV in its flags has to search this one first:
```sh
export RUSTFLAGS="-L$PWD/../target/opencv-min/lib ${RUSTFLAGS:-}"
```
On macOS this produces a wheel of about 8 MB; bundling an OpenCV installed by a
package manager produces a much larger one, because that build links every
library of its own dependencies.
Upload the repaired artifacts after checking them against TestPyPI:
```sh
twine upload --repository testpypi deepseek-recipe-python/dist-repaired/*
twine upload deepseek-recipe-python/dist-repaired/*
```
An upload authenticates with an API token: `__token__` as the user name and the
token as the password, in `~/.pypirc` or in `TWINE_USERNAME` and
`TWINE_PASSWORD`.
The wheel bundles OpenCV and the codecs that its `imgcodecs` module compiles in,
so it also carries their license texts:
`deepseek-recipe-python/THIRD_PARTY_LICENSES` reproduces them and the package
declares the file as a license file.
## API documentation
Build and open the Rust API documentation locally:
```sh
cargo doc --workspace --no-deps --locked --open
```
The Python package includes a `py.typed` marker and
[type stubs](../deepseek-recipe-python/python/deepseek_recipe/_native.pyi).
## Example servers
[server-rs](../server-rs/README.md) and [server-py](../server-py/README.md) listen on
`127.0.0.1:7777` by default. Run one at a time or choose different ports. Both
default to mock inference and provide JSON and SSE examples for the three protocols.
[encoding-decoding-demo](../encoding-decoding-demo/README.md) listens on
`127.0.0.1:7778`. It encodes prompts, displays special tokens, and decodes complete
model output into Chat Completions, Responses, or Messages without executing a model.
The examples have no authentication. Keep them on loopback for local use.
Applications exposed to other users need authentication, request limits, and
appropriate network access controls. The default `ReqwestImageFetcher` follows
redirects and does not filter private, loopback, or link-local destinations.
When accepting untrusted image URLs, enforce destination restrictions on the
initial request and every redirect, using a custom fetcher or network policy.
Image byte quotas do not provide this destination filtering.
[opencv-install]: https://github.com/twistedfall/opencv-rust/blob/v0.93.7/INSTALL.md
[opencv-troubleshooting]: https://github.com/twistedfall/opencv-rust/blob/v0.93.7/TROUBLESHOOTING.md

111
docs/streaming.md Normal file
View File

@ -0,0 +1,111 @@
# Response streaming
**English** | [中文](streaming.zh.md)
`StreamProcessor` converts backend output into Messages, Chat Completions, or
Responses events. The examples below use mock inference and print Chat
Completions chunks. The application supplies the backend and HTTP transport.
## Rust
Add the dependencies to your application:
```sh
cargo add deepseek-recipe@0.1 serde_json@1 tokio-stream@0.1
cargo add tokio@1 --features macros,rt-multi-thread
```
Save this as `src/main.rs` and run `cargo run`:
```rust
use deepseek_recipe::openai::ChatCompletionRequest;
use deepseek_recipe::request::{ConversionOptions, ProtocolRequest};
use deepseek_recipe::stream::{
InferenceChunk, InferenceFinishReason, PromptUsage, StreamProcessor,
};
use serde_json::json;
use tokio_stream::{StreamExt, iter};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let body = json!({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
"thinking": {"type": "disabled"},
});
// The chunk generator uses the converted request's resolved settings.
let request: ChatCompletionRequest = serde_json::from_value(body)?;
let converted = request.convert(ConversionOptions::default())?;
let generator =
ChatCompletionRequest::chunk_generator(&converted, "id-1".into(), "deepseek-flash".into());
let processor = StreamProcessor::new(generator, converted.parsing_options);
// Mock the backend's inference chunks.
let inference = iter(vec![
InferenceChunk::Ready {
system_fingerprint: Some("fp-1".into()),
prompt_usage: PromptUsage {
prompt_tokens: 4,
prompt_cache_hit_tokens: 0,
},
},
InferenceChunk::Text {
content: "Hello!".into(),
content_tokens: 2,
},
InferenceChunk::Finish {
finish_reason: InferenceFinishReason::Stop,
},
]);
// Convert them into protocol chunks.
let mut chunks = std::pin::pin!(processor.process(inference));
while let Some(chunk) = chunks.next().await {
println!("{}", serde_json::to_string(&chunk?)?);
}
Ok(())
}
```
## Python
Install the package with `python3 -m pip install deepseek-recipe`.
`push` processes one backend chunk; `finish` completes response processing.
```python
from deepseek_recipe import (
ChatCompletionRequest,
ConversionOptions,
InferenceChunk,
InferenceFinishReason,
PromptUsage,
StreamProcessor,
)
request = ChatCompletionRequest({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
"thinking": {"type": "disabled"},
})
converted = request.convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "id-1", "deepseek-flash")
processor = StreamProcessor(generator, converted.parsing_options)
# Mock the backend's inference chunks.
chunks = []
for chunk in [
InferenceChunk.ready(system_fingerprint="fp-1", prompt_usage=PromptUsage(prompt_tokens=4)),
InferenceChunk.text("Hello!", content_tokens=2),
InferenceChunk.finish(InferenceFinishReason.Stop),
]:
chunks.extend(processor.push(chunk))
chunks.extend(processor.finish())
for chunk in chunks:
print(chunk.to_json())
processor.close()
```
See the [project README](../README.md) for supported scope and the
[Rust API documentation](https://docs.rs/deepseek-recipe) for API contracts.

108
docs/streaming.zh.md Normal file
View File

@ -0,0 +1,108 @@
# 流式响应
[English](streaming.md) | **中文**
`StreamProcessor` 把后端输出转换为 Messages、Chat Completions 或 Responses 事件。以下示例使用 mock 推理并打印 Chat Completions chunk。后端与 HTTP 传输由应用提供。
## Rust
为你的应用添加依赖:
```sh
cargo add deepseek-recipe@0.1 serde_json@1 tokio-stream@0.1
cargo add tokio@1 --features macros,rt-multi-thread
```
将以下代码保存为 `src/main.rs`,然后运行 `cargo run`:
```rust
use deepseek_recipe::openai::ChatCompletionRequest;
use deepseek_recipe::request::{ConversionOptions, ProtocolRequest};
use deepseek_recipe::stream::{
InferenceChunk, InferenceFinishReason, PromptUsage, StreamProcessor,
};
use serde_json::json;
use tokio_stream::{StreamExt, iter};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let body = json!({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
"thinking": {"type": "disabled"},
});
// chunk 生成器使用转换后请求的最终设置。
let request: ChatCompletionRequest = serde_json::from_value(body)?;
let converted = request.convert(ConversionOptions::default())?;
let generator =
ChatCompletionRequest::chunk_generator(&converted, "id-1".into(), "deepseek-flash".into());
let processor = StreamProcessor::new(generator, converted.parsing_options);
// 模拟后端的推理 chunk。
let inference = iter(vec![
InferenceChunk::Ready {
system_fingerprint: Some("fp-1".into()),
prompt_usage: PromptUsage {
prompt_tokens: 4,
prompt_cache_hit_tokens: 0,
},
},
InferenceChunk::Text {
content: "Hello!".into(),
content_tokens: 2,
},
InferenceChunk::Finish {
finish_reason: InferenceFinishReason::Stop,
},
]);
// 将其转换为协议 chunk。
let mut chunks = std::pin::pin!(processor.process(inference));
while let Some(chunk) = chunks.next().await {
println!("{}", serde_json::to_string(&chunk?)?);
}
Ok(())
}
```
## Python
安装包:`python3 -m pip install deepseek-recipe`。
`push` 处理一个后端 chunk;`finish` 完成响应处理。
```python
from deepseek_recipe import (
ChatCompletionRequest,
ConversionOptions,
InferenceChunk,
InferenceFinishReason,
PromptUsage,
StreamProcessor,
)
request = ChatCompletionRequest({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
"thinking": {"type": "disabled"},
})
converted = request.convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "id-1", "deepseek-flash")
processor = StreamProcessor(generator, converted.parsing_options)
# 模拟后端的推理 chunk。
chunks = []
for chunk in [
InferenceChunk.ready(system_fingerprint="fp-1", prompt_usage=PromptUsage(prompt_tokens=4)),
InferenceChunk.text("Hello!", content_tokens=2),
InferenceChunk.finish(InferenceFinishReason.Stop),
]:
chunks.extend(processor.push(chunk))
chunks.extend(processor.finish())
for chunk in chunks:
print(chunk.to_json())
processor.close()
```
支持范围见[项目 README](../README.zh.md),API 约定见 [Rust API 文档](https://docs.rs/deepseek-recipe)。

186
docs/tokenizer.md Normal file
View File

@ -0,0 +1,186 @@
# Use with tokenizer
**English** | [中文](tokenizer.zh.md)
With a tokenizer attached, a `PromptEncoding` encodes the prompt into model
token IDs, and a `StreamProcessor` accepts token IDs. Neither loads a tokenizer
on its own: pass one with `with_tokenizer`, then call the corresponding method.
Using `encode` or passing token IDs without an attached tokenizer returns an
error.
Use the tokenizer that matches your model. This repository bundles copies under
[`static/tokenizers/`](../static/tokenizers/README.md) for the V4 and V4.1
prompt templates; point the loader at `tokenizer.json` in the matching
directory. A tokenizer from the Hugging Face Hub works too, but its special
token spelling must match the encoding's constants, and its image token must
resolve to the same ID.
## Rust
Add the dependencies to your application:
```sh
cargo add deepseek-recipe@0.1 deepseek-recipe-encoding@0.1 serde_json@1 tokenizers@0.23
```
Save this as `src/main.rs` and run `cargo run`. The tokenizer path below points
at this repository's bundled file; use your own `tokenizer.json` otherwise:
```rust
use deepseek_recipe::openai::ChatCompletionRequest;
use deepseek_recipe::request::{ConversionOptions, ProtocolRequest};
use deepseek_recipe_encoding::PromptEncoding;
use deepseek_recipe_encoding::v4::dsv41::DeepseekV41Encoding;
use serde_json::json;
use tokenizers::Tokenizer;
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let request: ChatCompletionRequest = serde_json::from_value(json!({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
}))?;
let converted = request.convert(ConversionOptions::default())?;
// Attach the tokenizer, then encode the conversation's tokens.
let tokenizer = Tokenizer::from_file("static/tokenizers/v41/tokenizer.json")?;
let encoding = DeepseekV41Encoding::new().with_tokenizer(tokenizer);
let token_ids = encoding.encode(&converted.conversation)?;
println!("{token_ids:?}");
Ok(())
}
```
`Tokenizer` implements the `TokenizerEncoder` trait used by `with_tokenizer`.
Any other type implementing that trait can be attached instead. Encoding is a
two-step operation: `render_conversation` returns the prompt text and its image
placeholders, and `encode` tokenizes that prompt with added special tokens
disabled, because the prompt already contains the special token text.
### Decoding
A backend can return token IDs instead of text. Attach the same tokenizer to the
stream processor to decode those chunks. The example also needs `tokio-stream`,
plus `tokio` with the `macros` and `rt-multi-thread` features, as listed in
[response streaming](streaming.md):
```rust
use deepseek_recipe::openai::ChatCompletionRequest;
use deepseek_recipe::request::{ConversionOptions, ProtocolRequest};
use deepseek_recipe::stream::{InferenceChunk, InferenceFinishReason, StreamProcessor};
use serde_json::json;
use tokenizers::Tokenizer;
use tokio_stream::{StreamExt, iter};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let request: ChatCompletionRequest = serde_json::from_value(json!({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
"thinking": {"type": "disabled"},
}))?;
let converted = request.convert(ConversionOptions::default())?;
let generator =
ChatCompletionRequest::chunk_generator(&converted, "id-1".into(), "deepseek-flash".into());
// The backend answers with token IDs; the attached tokenizer decodes them.
let tokenizer = Tokenizer::from_file("static/tokenizers/v41/tokenizer.json")?;
let token_ids = tokenizer.encode("Hello there!", false)?.get_ids().to_vec();
let processor =
StreamProcessor::new(generator, converted.parsing_options).with_tokenizer(tokenizer);
let inference = iter(
token_ids
.into_iter()
.map(|token_id| InferenceChunk::Token { token_id })
.chain([InferenceChunk::Finish {
finish_reason: InferenceFinishReason::Stop,
}]),
);
let mut chunks = std::pin::pin!(processor.process(inference));
while let Some(chunk) = chunks.next().await {
println!("{}", serde_json::to_string(&chunk?)?);
}
Ok(())
}
```
The processor decodes ids incrementally and buffers them while a multi-token
character is incomplete; ids that produced text count as completion tokens.
Special tokens stay in the decoded text because they drive output parsing. A
`Token` chunk without an attached tokenizer fails the stream with
`StreamError::MissingTokenizer`. `Tokenizer` implements the `TokenizerDecoder`
trait used by `with_tokenizer`.
## Python
Install the package with `python3 -m pip install deepseek-recipe`:
```python
from deepseek_recipe import (
ChatCompletionRequest,
ConversionOptions,
DeepseekV41Encoding,
Tokenizer,
)
request = ChatCompletionRequest({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
})
converted = request.convert(ConversionOptions())
tokenizer = Tokenizer.from_file("static/tokenizers/v41/tokenizer.json")
encoding = DeepseekV41Encoding().with_tokenizer(tokenizer)
print(encoding.encode(converted.conversation))
```
`Tokenizer.from_pretrained` downloads and caches a `tokenizer.json` from the
Hugging Face Hub, and `Tokenizer.from_str` loads the file contents directly.
`render_conversation` and `encode` return the same results as in Rust.
### Decoding
Pass the tokenizer as the processor's third argument. Token-id chunks are
decoded with the same behavior as in Rust; without a tokenizer, a token chunk
raises `RuntimeError` and closes the processor.
```python
from deepseek_recipe import (
ChatCompletionRequest,
ConversionOptions,
InferenceChunk,
InferenceFinishReason,
StreamProcessor,
Tokenizer,
)
converted = ChatCompletionRequest({
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "Hello"}],
"thinking": {"type": "disabled"},
}).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "id-1", "deepseek-flash")
# The backend answers with token IDs; the tokenizer passed to the processor
# decodes them.
tokenizer = Tokenizer.from_file("static/tokenizers/v41/tokenizer.json")
token_ids = tokenizer.encode("Hello there!")
processor = StreamProcessor(generator, converted.parsing_options, tokenizer)
chunks = []
for chunk in (
*(InferenceChunk.token(token_id) for token_id in token_ids),
InferenceChunk.finish(InferenceFinishReason.Stop),
):
chunks.extend(processor.push(chunk))
chunks.extend(processor.finish())
for chunk in chunks:
print(chunk.to_json())
processor.close()
```
See the [project README](../README.md) for supported scope and the
[response streaming](streaming.md) guide for the full chunk sequence.

Some files were not shown because too many files have changed in this diff Show More