audacity_sdk/passthrough.rs
1//! Provider-native pass-through surfaces (spec §9).
2//!
3//! Exposes the gateway's OpenAI-compatible and Anthropic-compatible endpoints
4//! directly on [`Client`], with the SDK's auth, retry, and §4 error handling
5//! but **no shape translation** — requests are sent verbatim and responses
6//! come back as raw [`serde_json::Value`]s in the provider's own wire format:
7//!
8//! ```text
9//! client.chat_completions().create(params) → POST /v1/chat/completions
10//! client.chat_completions().create_stream(params) → POST /v1/chat/completions (SSE)
11//! client.messages().create(params) → POST /v1/messages
12//! client.messages().create_stream(params) → POST /v1/messages (SSE)
13//! client.messages().count_tokens(params) → POST /v1/messages/count_tokens
14//! ```
15//!
16//! Params are anything that serializes to a JSON object (`serde_json::json!`
17//! literals or your own `Serialize` structs), so any field the gateway
18//! supports works — unknown fields flow through with no SDK release needed.
19
20use futures_util::StreamExt;
21use serde::Serialize;
22use serde_json::{Map, Value};
23use tokio::sync::mpsc;
24
25use crate::error::parse_stream_error;
26use crate::sse::{SseParser, SseToken};
27use crate::{Client, Error};
28
29const OPENAI_PATH: &str = "/v1/chat/completions";
30const MESSAGES_PATH: &str = "/v1/messages";
31const COUNT_TOKENS_PATH: &str = "/v1/messages/count_tokens";
32
33/// Anthropic API version header sent on the Anthropic-format endpoints. The
34/// gateway forwards Anthropic-format bodies as-is; the header keeps parity
35/// with the official anthropic SDKs.
36const ANTHROPIC_HEADERS: &[(&str, &str)] = &[("anthropic-version", "2023-06-01")];
37
38// ── param preparation ─────────────────────────────────────────────────────────
39
40/// Serialize params to a JSON object and check the two fields every §9
41/// endpoint requires (`model`, `messages`) are present — everything else is
42/// passed through untouched.
43fn prepare(params: impl Serialize, endpoint: &str) -> Result<Map<String, Value>, Error> {
44 let value = serde_json::to_value(params)
45 .map_err(|e| Error::sdk(format!("failed to serialize {endpoint} params: {e}")))?;
46 let body = match value {
47 Value::Object(map) => map,
48 _ => {
49 return Err(Error::client_validation(format!(
50 "{endpoint} params must serialize to a JSON object"
51 )))
52 }
53 };
54 for field in ["model", "messages"] {
55 if matches!(body.get(field), None | Some(Value::Null)) {
56 return Err(Error::client_validation(format!(
57 "{field} is required for {endpoint}"
58 )));
59 }
60 }
61 Ok(body)
62}
63
64/// `create` is the non-streaming call; sending `stream: true` through it
65/// would get an SSE body the JSON decoder can't handle, so fail fast with a
66/// pointer to `create_stream`.
67fn reject_stream_flag(body: &Map<String, Value>, endpoint: &str) -> Result<(), Error> {
68 if body.get("stream") == Some(&Value::Bool(true)) {
69 return Err(Error::client_validation(format!(
70 "params set stream=true — use create_stream() for streaming {endpoint} requests"
71 )));
72 }
73 Ok(())
74}
75
76// ── OpenAI-format surface ─────────────────────────────────────────────────────
77
78/// `client.chat_completions()` — OpenAI-format pass-through (spec §9).
79pub struct ChatCompletions {
80 client: Client,
81}
82
83impl ChatCompletions {
84 pub(crate) fn new(client: Client) -> Self {
85 Self { client }
86 }
87
88 /// OpenAI-format chat completion (POST /v1/chat/completions).
89 ///
90 /// Accepts the standard OpenAI Chat Completions request fields (`model`,
91 /// `messages`, `max_tokens`, `tools`, …) and passes them through verbatim.
92 /// Returns the raw OpenAI-shaped response object. Errors map to the
93 /// standard [`Error`] taxonomy (spec §4); retries follow the standard
94 /// policy.
95 pub async fn create(&self, params: impl Serialize) -> Result<Value, Error> {
96 let body = prepare(params, OPENAI_PATH)?;
97 reject_stream_flag(&body, OPENAI_PATH)?;
98 self.client
99 .post_passthrough_json(OPENAI_PATH, &Value::Object(body), &[])
100 .await
101 }
102
103 /// Streaming OpenAI-format chat completion.
104 ///
105 /// Sets `stream: true` on the body (all other fields pass through
106 /// verbatim) and returns a [`RawEventStream`] of raw chunk objects. The
107 /// gateway's `data: [DONE]` sentinel ends the stream and is not yielded;
108 /// EOF without `[DONE]` surfaces [`Error::ModelStreamError`]; a chunk
109 /// carrying `error` aborts with the §4-mapped error. Retries stop at the
110 /// first streamed byte.
111 pub async fn create_stream(&self, params: impl Serialize) -> Result<RawEventStream, Error> {
112 let mut body = prepare(params, OPENAI_PATH)?;
113 body.insert("stream".into(), Value::Bool(true));
114 let resp = self
115 .client
116 .post_passthrough_stream(OPENAI_PATH, &Value::Object(body), &[])
117 .await?;
118 Ok(spawn_raw_event_driver(resp, RawStreamKind::OpenAi))
119 }
120}
121
122// ── Anthropic-format surface ──────────────────────────────────────────────────
123
124/// `client.messages()` — Anthropic-format pass-through (spec §9).
125pub struct Messages {
126 client: Client,
127}
128
129impl Messages {
130 pub(crate) fn new(client: Client) -> Self {
131 Self { client }
132 }
133
134 /// Anthropic-format message (POST /v1/messages) — the wire format used by
135 /// the anthropic SDKs and Claude Code.
136 ///
137 /// Accepts the standard Anthropic Messages request fields (`model`,
138 /// `max_tokens`, `messages`, `system`, `tools`, …) verbatim and returns
139 /// the raw Anthropic-shaped response object. Works with every gateway
140 /// model, not just Claude — the gateway bridges the wire format.
141 pub async fn create(&self, params: impl Serialize) -> Result<Value, Error> {
142 let body = prepare(params, MESSAGES_PATH)?;
143 reject_stream_flag(&body, MESSAGES_PATH)?;
144 self.client
145 .post_passthrough_json(MESSAGES_PATH, &Value::Object(body), ANTHROPIC_HEADERS)
146 .await
147 }
148
149 /// Streaming Anthropic-format message.
150 ///
151 /// Sets `stream: true` on the body (all other fields pass through
152 /// verbatim) and returns a [`RawEventStream`] of raw Anthropic stream
153 /// events (`message_start` … `message_stop`). There is no `[DONE]` — a
154 /// healthy stream ends with a `message_stop` event followed by EOF; EOF
155 /// before `message_stop` surfaces [`Error::ModelStreamError`]; an `error`
156 /// event aborts with the §4-mapped error.
157 pub async fn create_stream(&self, params: impl Serialize) -> Result<RawEventStream, Error> {
158 let mut body = prepare(params, MESSAGES_PATH)?;
159 body.insert("stream".into(), Value::Bool(true));
160 let resp = self
161 .client
162 .post_passthrough_stream(MESSAGES_PATH, &Value::Object(body), ANTHROPIC_HEADERS)
163 .await?;
164 Ok(spawn_raw_event_driver(resp, RawStreamKind::Anthropic))
165 }
166
167 /// Anthropic-format token counting (POST /v1/messages/count_tokens).
168 ///
169 /// Returns the raw response object (`{"input_tokens": …}`). Token
170 /// counting is free — no inference happens.
171 pub async fn count_tokens(&self, params: impl Serialize) -> Result<Value, Error> {
172 let body = prepare(params, COUNT_TOKENS_PATH)?;
173 self.client
174 .post_passthrough_json(COUNT_TOKENS_PATH, &Value::Object(body), ANTHROPIC_HEADERS)
175 .await
176 }
177}
178
179// ── raw event stream ──────────────────────────────────────────────────────────
180
181/// Termination semantics of a raw pass-through SSE stream.
182#[derive(Clone, Copy, PartialEq, Eq)]
183enum RawStreamKind {
184 /// Ends on `data: [DONE]`; EOF without it is an error.
185 OpenAi,
186 /// Ends on a `message_stop` event + EOF; EOF before it is an error.
187 Anthropic,
188}
189
190/// Stream of raw provider events returned by the §9 `create_stream` calls.
191///
192/// Mirrors [`StreamReceiver`](crate::StreamReceiver):
193/// ```rust,ignore
194/// while let Some(event) = stream.recv().await? { … }
195/// ```
196pub struct RawEventStream {
197 rx: mpsc::Receiver<Result<Value, Error>>,
198}
199
200impl RawEventStream {
201 /// Receive the next raw provider event.
202 /// Returns `Ok(None)` when the stream has ended normally.
203 /// Returns `Err(e)` on stream errors.
204 pub async fn recv(&mut self) -> Result<Option<Value>, Error> {
205 match self.rx.recv().await {
206 Some(Ok(event)) => Ok(Some(event)),
207 Some(Err(e)) => Err(e),
208 None => Ok(None),
209 }
210 }
211}
212
213/// Drive the SSE byte stream in a background task, forwarding raw provider
214/// events under the §1 transport rules (32 MiB line cap, mid-stream transport
215/// failures → ModelStreamError) with per-format termination handling.
216fn spawn_raw_event_driver(response: reqwest::Response, kind: RawStreamKind) -> RawEventStream {
217 let (tx, rx) = mpsc::channel::<Result<Value, Error>>(64);
218
219 tokio::spawn(async move {
220 let mut byte_stream = response.bytes_stream();
221 let mut parser = SseParser::new();
222
223 let mut done = false; // OpenAI: saw the [DONE] sentinel
224 let mut saw_message_stop = false; // Anthropic: saw a message_stop event
225 let mut aborted = false; // already surfaced an error / receiver gone
226 let mut eof = false; // byte stream ended
227
228 'read: while !done && !eof {
229 match byte_stream.next().await {
230 Some(Ok(chunk)) => {
231 parser.push(&chunk);
232 if parser.overflowed() {
233 let _ = tx
234 .send(Err(Error::model_stream_error(
235 "SSE line exceeds the 32 MiB limit",
236 )))
237 .await;
238 aborted = true;
239 break 'read;
240 }
241 }
242 Some(Err(e)) => {
243 let _ = tx
244 .send(Err(Error::model_stream_error(format!(
245 "stream read error: {e}"
246 ))))
247 .await;
248 aborted = true;
249 break 'read;
250 }
251 None => {
252 parser.flush();
253 eof = true;
254 }
255 }
256
257 for token in parser.drain() {
258 let payload = match token {
259 SseToken::Done => match kind {
260 RawStreamKind::OpenAi => {
261 done = true;
262 break;
263 }
264 // Anthropic streams carry no [DONE]; a stray one is
265 // not a valid event — skip it.
266 RawStreamKind::Anthropic => continue,
267 },
268 SseToken::Data(payload) => payload,
269 };
270
271 // Malformed / non-object payloads are skipped (bad wire data).
272 let Ok(event) = serde_json::from_str::<Value>(&payload) else {
273 continue;
274 };
275 if !event.is_object() {
276 continue;
277 }
278
279 let is_error = match kind {
280 RawStreamKind::OpenAi => event.get("error").is_some(),
281 RawStreamKind::Anthropic => {
282 event.get("type").and_then(Value::as_str) == Some("error")
283 }
284 };
285 if is_error {
286 let _ = tx.send(Err(parse_stream_error(&payload))).await;
287 aborted = true;
288 break 'read;
289 }
290
291 if kind == RawStreamKind::Anthropic
292 && event.get("type").and_then(Value::as_str) == Some("message_stop")
293 {
294 saw_message_stop = true;
295 }
296
297 if tx.send(Ok(event)).await.is_err() {
298 aborted = true;
299 break 'read;
300 }
301 }
302 }
303
304 // Terminal rule (spec §9): OpenAI streams must end with [DONE];
305 // Anthropic streams must have seen message_stop before EOF.
306 let healthy = match kind {
307 RawStreamKind::OpenAi => done,
308 RawStreamKind::Anthropic => saw_message_stop,
309 };
310 if !healthy && !aborted {
311 let message = match kind {
312 RawStreamKind::OpenAi => "connection closed before [DONE]",
313 RawStreamKind::Anthropic => "stream ended unexpectedly before message_stop",
314 };
315 let _ = tx.send(Err(Error::model_stream_error(message))).await;
316 }
317 });
318
319 RawEventStream { rx }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use serde_json::json;
326
327 #[test]
328 fn prepare_accepts_object_with_required_fields() {
329 let body = prepare(
330 json!({"model": "m", "messages": [], "custom_field": {"x": 1}}),
331 OPENAI_PATH,
332 )
333 .unwrap();
334 // unknown fields survive untouched
335 assert_eq!(body["custom_field"], json!({"x": 1}));
336 }
337
338 #[test]
339 fn prepare_rejects_non_object_params() {
340 let err = prepare(json!(["not", "an", "object"]), OPENAI_PATH).unwrap_err();
341 assert!(matches!(err, Error::Validation(_)));
342 }
343
344 #[test]
345 fn prepare_rejects_missing_model() {
346 let err = prepare(json!({"messages": []}), MESSAGES_PATH).unwrap_err();
347 match err {
348 Error::Validation(d) => assert!(d.message.contains("model")),
349 other => panic!("expected Validation, got {other:?}"),
350 }
351 }
352
353 #[test]
354 fn prepare_rejects_null_messages() {
355 let err = prepare(json!({"model": "m", "messages": null}), OPENAI_PATH).unwrap_err();
356 match err {
357 Error::Validation(d) => assert!(d.message.contains("messages")),
358 other => panic!("expected Validation, got {other:?}"),
359 }
360 }
361
362 #[test]
363 fn reject_stream_flag_only_fires_on_true() {
364 let on = prepare(
365 json!({"model": "m", "messages": [], "stream": true}),
366 OPENAI_PATH,
367 )
368 .unwrap();
369 assert!(reject_stream_flag(&on, OPENAI_PATH).is_err());
370 let off = prepare(
371 json!({"model": "m", "messages": [], "stream": false}),
372 OPENAI_PATH,
373 )
374 .unwrap();
375 assert!(reject_stream_flag(&off, OPENAI_PATH).is_ok());
376 }
377}