ds-api 0.10.4

A Rust client library for the DeepSeek API with support for chat completions, streaming, and tools
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# ds-api

[![crates.io](https://img.shields.io/crates/v/ds-api.svg)](https://crates.io/crates/ds-api)
[![docs.rs](https://img.shields.io/docsrs/ds-api)](https://docs.rs/ds-api)
[![license](https://img.shields.io/crates/l/ds-api.svg)](https://github.com/ozongzi/ds-api/blob/main/LICENSE-MIT)

A Rust SDK for building LLM agents on top of DeepSeek (and any OpenAI-compatible API). Define tools in plain Rust, plug them into an agent, and consume a stream of events as the model thinks, calls tools, and responds.

---

## Quickstart

Set your API key and add the dependency:

```bash
export DEEPSEEK_API_KEY="sk-..."
```

```toml
# Cargo.toml
[dependencies]
ds-api  = "0.10.3"
futures = "0.3"
tokio   = { version = "1", features = ["full"] }
serde   = { version = "1", features = ["derive"] }
```

```rust
use ds_api::{AgentEvent, DeepseekAgent, tool};
use futures::StreamExt;
use serde_json::{Value, json};

struct Search;

#[tool]
impl ds_api::Tool for Search {
    /// Search the web and return results.
    /// query: the search query
    async fn search(&self, query: String) -> Value {
        json!({ "results": format!("results for: {query}") })
    }
}

#[tokio::main]
async fn main() {
    let token = std::env::var("DEEPSEEK_API_KEY").unwrap();

    let mut stream = DeepseekAgent::new(token)
        .add_tool(Search)
        .chat("What's the latest news about Rust?");

    while let Some(event) = stream.next().await {
        match event.unwrap() {
            AgentEvent::Token(text)       => print!("{text}"),
            AgentEvent::ToolCall(c)       => println!("\n[calling {}]", c.name),
            AgentEvent::ToolResult(r)     => println!("[result] {}", r.result),
            AgentEvent::ReasoningToken(t) => print!("{t}"),
        }
    }
}
```

The agent runs the full loop for you: it calls the model, dispatches any tool calls, feeds the results back, and keeps going until the model stops requesting tools.

---

## Defining tools

Annotate an `impl Tool for YourStruct` block with `#[tool]`. Each method becomes a callable tool:

- **Doc comment on the impl block** → tool description
- **`/// param: description`** lines in each method's doc comment → argument descriptions
- Return type just needs to be `serde::Serialize` — the macro handles the JSON schema

```rust
use ds_api::tool;
use serde_json::{Value, json};

struct Calculator;

#[tool]
impl ds_api::Tool for Calculator {
    /// Add two numbers together.
    /// a: first number
    /// b: second number
    async fn add(&self, a: f64, b: f64) -> Value {
        json!({ "result": a + b })
    }

    /// Multiply two numbers.
    /// a: first number
    /// b: second number
    async fn multiply(&self, a: f64, b: f64) -> Value {
        json!({ "result": a * b })
    }
}
```

One struct can have multiple methods — they register as separate tools. Stack as many tools as you need with `.add_tool(...)`.

---

## Streaming

Call `.with_streaming()` to get token-by-token output instead of waiting for the full response:

```rust
let mut stream = DeepseekAgent::new(token)
    .with_streaming()
    .add_tool(Search)
    .chat("Search for something and summarise it");

while let Some(event) = stream.next().await {
    match event.unwrap() {
        AgentEvent::Token(t)      => { print!("{t}"); io::stdout().flush().ok(); }
        AgentEvent::ToolCall(c)   => {
            // In streaming mode, ToolCall fires once per SSE chunk.
            // First chunk: c.delta is empty, c.name is set — good moment to show "calling X".
            // Subsequent chunks: c.delta contains incremental argument JSON.
            // In non-streaming mode, exactly one ToolCall fires with the full args in c.delta.
            if c.delta.is_empty() { println!("\n[calling {}]", c.name); }
        }
        AgentEvent::ToolResult(r) => println!("[done] {}: {}", r.name, r.result),
        _                         => {}
    }
}
```

### AgentEvent reference

| Variant | When | Notes |
|---------|------|-------|
| `Token(String)` | Model is speaking | Streaming: one fragment per chunk. Non-streaming: whole reply at once. |
| `ReasoningToken(String)` | Model is thinking | Only from reasoning models (e.g. `deepseek-reasoner`). |
| `ToolCall(ToolCallChunk)` | Tool call in progress | `chunk.id`, `chunk.name`, `chunk.delta`. Streaming: multiple per call. Non-streaming: one per call. |
| `ToolResult(ToolCallResult)` | Tool finished | `result.name`, `result.args`, `result.result`. |

---

## Using a different model or provider

Any OpenAI-compatible endpoint works:

```rust
// OpenRouter
let agent = DeepseekAgent::custom(
    "sk-or-...",
    "https://openrouter.ai/api/v1",
    "meta-llama/llama-3.3-70b-instruct:free",
);

// deepseek-reasoner (think before responding)
let agent = DeepseekAgent::new(token)
    .with_model("deepseek-reasoner");
```

## Custom top-level request fields (`extra_body`)

The library exposes an `extra_body` mechanism to let you merge arbitrary top-level JSON fields into the HTTP request body sent to the provider. This is useful for passing provider-specific or experimental options that are not (yet) modelled by the typed request structure.

There are two primary places you can attach `extra_body` fields:

- On an `ApiRequest` (fine-grained, request-local)
- On a `DeepseekAgent` (convenient builder-style; merged into the next requests built from the agent)

Important notes
- Fields in `extra_body` are flattened into the top-level JSON via `serde(flatten)`, so they appear as peers to `messages`, `model`, etc.
- Avoid key collisions with existing top-level names (e.g. `messages`, `model`). The intended usage is adding provider-specific keys.
- Agent-held `extra_body` maps are merged into the `ApiRequest` when the request is built. (If you want per-request control prefer `ApiRequest::extra_body`.)

Examples

- Using `ApiRequest`:

```rust
use serde_json::{Map, json};
use ds_api::ApiRequest;

let mut m = Map::new();
m.insert("my_flag".to_string(), json!(true));

let req = ApiRequest::builder()
    .messages(vec![])
    .extra_body(m);

// send via ApiClient, or use within library internals that accept ApiRequest
```

- Using `DeepseekAgent` builder helpers:

```rust
use serde_json::{Map, json};
use ds_api::DeepseekAgent;

let mut m = Map::new();
m.insert("provider_option".to_string(), json!("value"));

let agent = DeepseekAgent::new(token)
    .extra_body(m)              // merge these fields into subsequent requests
    .chat("Hello world");
```

- Single-field helper:

```rust
let agent = DeepseekAgent::new(token)
    .extra_field("provider_option", serde_json::json!("value"));
```

Any OpenAI-compatible endpoint works:

```rust
// OpenRouter
let agent = DeepseekAgent::custom(
    "sk-or-...",
    "https://openrouter.ai/api/v1",
    "meta-llama/llama-3.3-70b-instruct:free",
);

// deepseek-reasoner (think before responding)
let agent = DeepseekAgent::new(token)
    .with_model("deepseek-reasoner");
```

---

## Injecting messages mid-run

You can send a message into a running agent loop — useful when the user types something while the agent is still executing tools.

The interrupt channel is attached with `.with_interrupt_channel()` and returns the agent plus a sender you can use from any task. The sender type (`InterruptSender`) is a re-export of `tokio::sync::mpsc::UnboundedSender<String>`, so it is cheap to clone and use concurrently:

```rust
let agent = DeepseekAgent::new(token)
    .with_streaming()
    .add_tool(SlowTool)

let tx = agent.interrupt_sender();
```

Behavior and semantics
- Sending an interrupt: call `tx.send("...".into()).unwrap()` from any task or callback. The message will be delivered into the agent's conversation history.
- During tool execution: the agent now actively listens for interrupts while a tool is running. If an interrupt message arrives while a tool is executing, the executor will:
  1. Immediately append the interrupt text to the conversation history as a `Role::User` message (and drain any queued interrupt messages in order).
  2. Abort the currently running tool (the tool future is cancelled) and stop executing further tools for the current round. (can close only when the tool is awaiting)
  3. Record a placeholder result for the aborted tool (the runtime exposes this as an error-shaped JSON result), and then proceed to the next API turn so the model sees the injected user message.
- Between turns / idle transition: any queued interrupts are drained before the next API call so injected messages are always visible to the model on the next turn.

Example: cancel a running tool and pivot
```rust
// Start the agent and get an interrupt sender.
let (agent, tx) = DeepseekAgent::new(token)
    .with_streaming()
    .add_tool(SlowTool)
    .with_interrupt_channel();

// In another task (e.g. user action), send an interrupt to change the plan.
tx.send("Actually, cancel that and do X instead.".into()).unwrap();

// If the agent is currently executing a tool, that tool will be aborted and the
// interrupt will be pushed into history so the next API turn sees it.
let mut stream = agent.chat("Do the slow thing.");
```

Notes
- `InterruptSender` is non-blocking and can be cloned; use it from any async context without awaiting.
- Aborting a tool is implemented by cancelling the tool future (via the runtime). This is effective for most async tools, but if a tool holds on to external, non-cancellable resources you may want to implement cooperative cancellation inside the tool (for example, by checking a cancellation token).
- The agent ensures interrupt message ordering by draining remaining queued interrupt messages when an interrupt is observed.

---

## MCP tools

MCP (Model Context Protocol) lets you use external processes as tools — Node scripts, Python services, anything that speaks MCP over stdio:

```rust
// Requires the `mcp` feature
let agent = DeepseekAgent::new(token)
    .add_tool(McpTool::stdio("npx", &["-y", "@playwright/mcp"]).await?);
```

---

## Exposing tools as an MCP server

The `mcp-server` feature lets you turn any `ToolBundle` into a standalone MCP server so other LLM clients (Claude Desktop, MCP Studio, etc.) can call your Rust tools.

```toml
[dependencies]
ds-api = { version = "0.10", features = ["mcp-server"] }
tokio  = { version = "1", features = ["full"] }
```

### Stdio mode (Claude Desktop / MCP Studio)

Add this binary to your project and point Claude Desktop at it:

```rust
use ds_api::{McpServer, ToolBundle, tool};

struct Calculator;

#[tool]
impl ds_api::Tool for Calculator {
    /// Add two numbers.
    /// a: first operand
    /// b: second operand
    async fn add(&self, a: f64, b: f64) -> f64 { a + b }

    /// Multiply two numbers.
    /// a: first operand
    /// b: second operand
    async fn multiply(&self, a: f64, b: f64) -> f64 { a * b }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    McpServer::new(ToolBundle::new().add(Calculator))
        .with_name("my-calc-server")
        .serve_stdio()
        .await?;
    Ok(())
}
```

Register it in `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "my-calc": {
      "command": "/path/to/your/binary"
    }
  }
}
```

### HTTP mode (Streamable HTTP transport)

```rust
use ds_api::{McpServer, ToolBundle, tool};

struct Search;

#[tool]
impl ds_api::Tool for Search {
    /// Search the web.
    /// query: what to search for
    async fn search(&self, query: String) -> String {
        format!("results for: {query}")
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // MCP endpoint available at POST /mcp
    McpServer::new(ToolBundle::new().add(Search))
        .serve_http("0.0.0.0:3000")
        .await?;
    Ok(())
}
```

### Custom routing

For custom Axum routing, use `into_http_service()` to get a Tower-compatible service:

```rust
use ds_api::{McpServer, ToolBundle};
use rmcp::transport::streamable_http_server::tower::StreamableHttpServerConfig;

let service = McpServer::new(ToolBundle::new().add(MyTools))
    .into_http_service(Default::default());

let router = axum::Router::new()
    .nest_service("/mcp", service)
    .route("/health", axum::routing::get(|| async { "ok" }));

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, router).await?;
```

---

## Tool Bundle

ToolBundle can handle multiple Tool implementations and
builds a name->index map for dispatch.

### Example

```rust
let group = ToolBundle::new()
    .add(FileSpells)
    .add(SearchSpells)
    .add(ShellSpells);

let agent = DeepseekAgent::custom(...)
    .add_tool(group)
    .add_tool(UiSpells { ... })
    .add_tool(SpawnSpell { ... });
```

---

## Familiar
Familiar is a high-level agent built on top of ds-api. It provides opinionated defaults and a batteries-included experience for common agent patterns. Check out [familiar](https://github.com/ozongzi/familiar)

---

## Contributing

PRs welcome. Keep changes focused; update public API docs when behaviour changes.

## License

MIT OR Apache-2.0