ort_openrouter_cli/net/
http.rs

1//! ort: Open Router CLI
2//! https://github.com/grahamking/ort
3//!
4//! MIT License
5//! Copyright (c) 2025 Graham King
6
7use core::fmt;
8use core::net::SocketAddr;
9
10extern crate alloc;
11use alloc::ffi::CString;
12use alloc::format;
13use alloc::string::{String, ToString};
14use alloc::vec::Vec;
15
16use crate::libc;
17use crate::{
18    ErrorKind, OrtError, OrtResult, Read, TcpSocket, TlsStream, Write, common::buf_read, ort_error,
19    ort_from_err,
20};
21
22const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
23const HOST: &str = "openrouter.ai";
24const EXPECTED_HTTP_200: &str = "HTTP/1.1 200 OK";
25const CHUNKED_HEADER: &str = "Transfer-Encoding: chunked";
26
27pub fn list_models(api_key: &str, addrs: Vec<SocketAddr>) -> OrtResult<TlsStream<TcpSocket>> {
28    let tcp = connect(addrs)?;
29    let mut tls = TlsStream::connect(tcp, HOST)?;
30
31    let prefix = format!(
32        concat!(
33            "GET /api/v1/models HTTP/1.1\r\n",
34            "Accept: application/json\r\n",
35            "Host: {}\r\n",
36            "Authorization: Bearer {}\r\n",
37            "User-Agent: {}\r\n",
38            "\r\n"
39        ),
40        HOST, api_key, USER_AGENT,
41    );
42
43    tls.write_all(prefix.as_bytes())
44        .map_err(|e| ort_from_err(ErrorKind::SocketWriteFailed, "write list_models request", e))?;
45    tls.flush()
46        .map_err(|e| ort_from_err(ErrorKind::SocketWriteFailed, "flush list_models request", e))?;
47
48    Ok(tls)
49}
50
51pub fn chat_completions(
52    api_key: &str,
53    addrs: Vec<SocketAddr>,
54    json_body: &str,
55) -> OrtResult<buf_read::OrtBufReader<TlsStream<TcpSocket>>> {
56    let tcp = connect(addrs)?;
57
58    let mut tls = TlsStream::connect(tcp, HOST)?;
59
60    // 2) Write HTTP/1.1 request
61    let body = json_body.as_bytes();
62    let prefix = format!(
63        concat!(
64            "POST /api/v1/chat/completions HTTP/1.1\r\n",
65            "Content-Type: application/json\r\n",
66            "Accept: text/event-stream\r\n",
67            "Host: {}\r\n",
68            "Authorization: Bearer {}\r\n",
69            "User-Agent: {}\r\n",
70            "Content-Length: {}\r\n",
71            "\r\n"
72        ),
73        HOST,
74        api_key,
75        USER_AGENT,
76        body.len()
77    );
78
79    tls.write_all(prefix.as_bytes()).map_err(|e| {
80        ort_from_err(
81            ErrorKind::SocketWriteFailed,
82            "write chat_completions header",
83            e,
84        )
85    })?;
86    tls.write_all(body).map_err(|e| {
87        ort_from_err(
88            ErrorKind::SocketWriteFailed,
89            "write chat_completions body",
90            e,
91        )
92    })?;
93    tls.flush()
94        .map_err(|e| ort_from_err(ErrorKind::SocketWriteFailed, "flush chat_completions", e))?;
95
96    Ok(buf_read::OrtBufReader::new(tls))
97}
98
99#[derive(Debug)]
100pub struct HttpError {
101    status_line: String,
102    body: String,
103}
104
105impl HttpError {
106    fn new(status_line: String, body: String) -> Self {
107        HttpError { status_line, body }
108    }
109
110    fn status(status_line: String) -> Self {
111        HttpError {
112            status_line,
113            body: "".to_string(),
114        }
115    }
116}
117
118impl core::error::Error for HttpError {}
119
120impl fmt::Display for HttpError {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(f, "({}, {})", self.status_line, self.body)
123    }
124}
125
126impl From<HttpError> for OrtError {
127    fn from(err: HttpError) -> OrtError {
128        let c_s = CString::new("\nHTTP ERROR: ".to_string() + &err.to_string()).unwrap();
129        unsafe {
130            libc::write(2, c_s.as_ptr().cast(), c_s.count_bytes());
131        }
132        ort_error(ErrorKind::HttpStatusError, "")
133    }
134}
135
136/// Advances the reader to point to the first line of the body.
137/// Returns true if the body has transfer encoding chunked, and hence needs
138/// special handling.
139pub fn skip_header<T: Read + Write>(
140    reader: &mut buf_read::OrtBufReader<TlsStream<T>>,
141) -> Result<bool, HttpError> {
142    let mut buffer = String::with_capacity(16);
143    let status = match reader.read_line(&mut buffer) {
144        Ok(0) => {
145            return Err(HttpError::status("Missing initial status line".to_string()));
146        }
147        Ok(_) => buffer.clone(),
148        Err(err) => {
149            return Err(HttpError::status(format!("Internal TLS error: {err}")));
150        }
151    };
152    let status = status.trim();
153
154    // Skip the rest of the headers
155    let mut is_chunked = false;
156    buffer.clear();
157    loop {
158        reader
159            .read_line(&mut buffer)
160            .map_err(|err| HttpError::status(format!("Reading response header: {err}")))?;
161        let header = buffer.trim();
162        if header.is_empty() {
163            // end of headers
164            break;
165        }
166        if header == CHUNKED_HEADER {
167            is_chunked = true;
168        }
169        buffer.clear();
170    }
171
172    if status.trim() != EXPECTED_HTTP_200 {
173        // Usually the body explains the error so gather that.
174        if is_chunked {
175            // Skip the size line, the header said transfer encoding chunked
176            // so even an HTTP 400 has to respect that.
177            let _ = reader.read_line(&mut buffer);
178            buffer.clear();
179        }
180        match reader.read_line(&mut buffer) {
181            Ok(_) => {
182                // TODO parse JSON. It looks like this:
183                // {"error":{"message":"openai/gpt-oss-90b is not a valid model ID","code":400},"user_id":"user_30mJ0GpP57Kj9wLQ4mDCfMS5nk0"}
184                return Err(HttpError::new(
185                    status.to_string(),
186                    buffer.trim().to_string(),
187                ));
188            }
189            _ => return Err(HttpError::status(status.to_string())),
190        }
191    }
192    Ok(is_chunked)
193}
194
195/// Attempt to connect to all the SocketAddr in order, with a timeout.
196/// The addreses come from the system resolver or `${XDG_CONFIG_HOME}/ort.json`
197/// in settings/dns.
198fn connect(addrs: Vec<SocketAddr>) -> OrtResult<TcpSocket> {
199    // TODO: Erorr handling, don't just try the first
200
201    //let mut errs = vec![];
202    //let addrs: Vec<_> = addrs.to_socket_addrs().unwrap().collect();
203    for addr in addrs {
204        let addr_v4 = match addr {
205            SocketAddr::V4(v4) => v4,
206            _ => continue,
207        };
208        let sock = TcpSocket::new()?;
209        sock.connect(&addr_v4)?;
210        return Ok(sock);
211        /*
212        match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
213            Ok(tcp) => {
214                set_tcp_fastopen(&tcp);
215                return Ok(tcp);
216            }
217            Err(err) => {
218                errs.push((addr, err));
219            }
220        }
221        */
222    }
223    /*
224    let err_msg: Vec<String> = errs
225        .into_iter()
226        .map(|(addr, err)| format!("Failed connecting to {addr:?}: {err}"))
227        .collect();
228    Err(io::Error::other(err_msg.join("; ")))
229    */
230    Err(ort_error(
231        ErrorKind::HttpConnectError,
232        "connect error handling TODO",
233    ))
234}