Skip to main content

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}