Skip to main content

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::net::SocketAddr;
8
9extern crate alloc;
10use alloc::ffi::CString;
11use alloc::string::{String, ToString};
12use alloc::vec::Vec;
13
14use crate::{
15    Context, ErrorKind, OrtError, OrtResult, Read, TcpSocket, TlsStream, Write, common::buf_read,
16    ort_error,
17};
18use crate::{syscall, utils};
19
20const EXPECTED_HTTP_200: &str = "HTTP/1.1 200 OK";
21const CHUNKED_HEADER: &str = "Transfer-Encoding: chunked";
22const CONTENT_LENGTH_0: &str = "Content-Length: 0";
23
24const POST: &[u8] = "POST ".as_bytes();
25const GET: &[u8] = "GET ".as_bytes();
26const HTTP_1_1: &[u8] = " HTTP/1.1\r\n".as_bytes();
27const HOST_HEADER: &[u8] = "Host: ".as_bytes();
28const CONTENT_LENGTH_HEADER: &[u8] = "Content-Length: ".as_bytes();
29const CRLF: &[u8] = "\r\n".as_bytes();
30
31// The constant part of the list request headers
32const LIST_REQ_MIDDLE: &[u8] = concat!(
33    "Accept: application/json\r\n",
34    "User-Agent: ",
35    env!("CARGO_PKG_NAME"),
36    "/",
37    env!("CARGO_PKG_VERSION"),
38    "\r\n",
39    "HTTP-Referer: https://github.com/grahamking/ort\r\n",
40    "X-Title: ort\r\n",
41    "Authorization: Bearer "
42)
43.as_bytes();
44
45pub fn list_models(
46    api_key: &str,
47    host: &'static str,
48    list_url: &'static str,
49    addrs: Vec<SocketAddr>,
50) -> OrtResult<TlsStream<TcpSocket>> {
51    let tcp = connect(addrs)?;
52    let mut tls = TlsStream::connect(tcp, host)?;
53
54    // Built request on the stack, zero alloc
55    // Req is about 276 bytes right now. 384 is 256 + 128.
56    let mut req = [0u8; 384];
57
58    // GET <list_url> HTTP/1.1\r\n
59    let mut start = 0;
60    let mut end = GET.len();
61    req[start..end].copy_from_slice(GET);
62    start = end;
63    end += list_url.len();
64    req[start..end].copy_from_slice(list_url.as_bytes());
65    start = end;
66    end += HTTP_1_1.len();
67    req[start..end].copy_from_slice(HTTP_1_1);
68
69    // Host: <host>\r\n
70    start = end;
71    end += HOST_HEADER.len();
72    req[start..end].copy_from_slice(HOST_HEADER);
73    start = end;
74    end += host.len();
75    req[start..end].copy_from_slice(host.as_bytes());
76    start = end;
77    end += CRLF.len();
78    req[start..end].copy_from_slice(CRLF);
79
80    // Rest of the HTTP headers
81    start = end;
82    end += LIST_REQ_MIDDLE.len();
83    req[start..end].copy_from_slice(LIST_REQ_MIDDLE);
84
85    // The constant part finished with "Authorization: Bearer ".
86    // Append the API key and the final double CRLF.
87    start = end;
88    end += api_key.len();
89    req[start..end].copy_from_slice(api_key.as_bytes());
90    start = end;
91    end += CRLF.len();
92    req[start..end].copy_from_slice(CRLF);
93    start = end;
94    end += CRLF.len();
95    req[start..end].copy_from_slice(CRLF);
96
97    tls.write_all(&req[..end])
98        .context("write list_models request")?;
99    tls.flush().context("flush list_models request")?;
100
101    Ok(tls)
102}
103
104const CHAT_REQ_MIDDLE: &[u8] = concat!(
105    "Content-Type: application/json\r\n",
106    "Accept: text/event-stream\r\n",
107    "User-Agent: ",
108    env!("CARGO_PKG_NAME"),
109    "/",
110    env!("CARGO_PKG_VERSION"),
111    "\r\n",
112    // ID for openrouter.ai App rankings
113    "HTTP-Referer: https://github.com/grahamking/ort\r\n",
114    // Name to appear in openrouter.ai App rankings
115    "X-Title: ort\r\n",
116    "Authorization: Bearer "
117)
118.as_bytes();
119
120pub fn chat_completions(
121    api_key: &str,
122    host: &'static str,
123    chat_completions_url: &'static str,
124    addrs: Vec<SocketAddr>,
125    json_body: &str,
126) -> OrtResult<buf_read::OrtBufReader<TlsStream<TcpSocket>>> {
127    let tcp = connect(addrs)?;
128    let mut tls = TlsStream::connect(tcp, host)?;
129
130    let body = json_body.as_bytes();
131
132    // Built HTTP request header on the stack.
133    // With longest current model name headers len is 341.
134    let mut req = [0u8; 512];
135
136    // POST <chat_completions_url> HTTP/1.1\r\n
137    let mut start = 0;
138    let mut end = POST.len();
139    req[start..end].copy_from_slice(POST);
140    start = end;
141    end += chat_completions_url.len();
142    req[start..end].copy_from_slice(chat_completions_url.as_bytes());
143    start = end;
144    end += HTTP_1_1.len();
145    req[start..end].copy_from_slice(HTTP_1_1);
146
147    // Host: <host>\r\n
148    start = end;
149    end += HOST_HEADER.len();
150    req[start..end].copy_from_slice(HOST_HEADER);
151    start = end;
152    end += host.len();
153    req[start..end].copy_from_slice(host.as_bytes());
154    start = end;
155    end += CRLF.len();
156    req[start..end].copy_from_slice(CRLF);
157
158    // Content-Length: <body-len>\r\n
159    start = end;
160    end += CONTENT_LENGTH_HEADER.len();
161    req[start..end].copy_from_slice(CONTENT_LENGTH_HEADER);
162    let mut body_len_buf: [u8; 16] = [0; 16];
163    let buf_len = utils::to_ascii(body.len(), &mut body_len_buf[..]);
164    start = end;
165    // Subtract two to strip the \n and \0 that to_ascii adds
166    end += buf_len - 2;
167    req[start..end].copy_from_slice(&body_len_buf[..buf_len - 2]);
168    start = end;
169    end += CRLF.len();
170    req[start..end].copy_from_slice(CRLF);
171
172    // Rest of the HTTP headers
173    start = end;
174    end += CHAT_REQ_MIDDLE.len();
175    req[start..end].copy_from_slice(CHAT_REQ_MIDDLE);
176
177    // The constant part finished with "Authorization: Bearer ".
178    // Append the API key and the final double CRLF.
179    start = end;
180    end += api_key.len();
181    req[start..end].copy_from_slice(api_key.as_bytes());
182    start = end;
183    end += CRLF.len();
184    req[start..end].copy_from_slice(CRLF);
185    start = end;
186    end += CRLF.len();
187    req[start..end].copy_from_slice(CRLF);
188
189    //let end_str = utils::num_to_string(end);
190    //utils::print_string(c"REQ LEN ", &end_str);
191
192    tls.write_all(&req[..end])
193        .context("write chat_completions header")?;
194    tls.write_all(body).context("write chat_completions body")?;
195    tls.flush().context("flush chat_completions")?;
196
197    Ok(buf_read::OrtBufReader::new(tls))
198}
199
200#[derive(Debug)]
201pub struct HttpError {
202    status_line: String,
203    body: String,
204}
205
206impl HttpError {
207    pub(crate) fn as_string(&self) -> String {
208        let mut msg = String::with_capacity(16 + self.status_line.len() + self.body.len());
209        msg.push_str("\nHTTP ERROR: ");
210        msg.push_str(&self.status_line);
211        msg.push_str(", ");
212        msg.push_str(&self.body);
213        msg.push('\0');
214        msg
215    }
216
217    fn new(status_line: String, body: String) -> Self {
218        HttpError { status_line, body }
219    }
220
221    fn status(status_line: String) -> Self {
222        HttpError {
223            status_line,
224            body: "".to_string(),
225        }
226    }
227}
228
229impl From<HttpError> for OrtError {
230    fn from(err: HttpError) -> OrtError {
231        let c_s = unsafe { CString::from_vec_with_nul_unchecked(err.as_string().into_bytes()) };
232        syscall::write(2, c_s.as_ptr().cast(), c_s.count_bytes());
233        ort_error(ErrorKind::HttpStatusError, "")
234    }
235}
236
237/// Advances the reader to point to the first line of the body.
238/// Returns true if the body has transfer encoding chunked, and hence needs
239/// special handling.
240pub fn skip_header<T: Read + Write>(
241    reader: &mut buf_read::OrtBufReader<TlsStream<T>>,
242) -> Result<bool, HttpError> {
243    let mut buffer = String::with_capacity(512);
244    let status = match reader.read_line(&mut buffer) {
245        Ok(0) => {
246            return Err(HttpError::status("Missing initial status line".to_string()));
247        }
248        Ok(_) => buffer.clone(),
249        Err(err) => {
250            return Err(HttpError::status(
251                "Internal TLS error: ".to_string() + &err.as_string(),
252            ));
253        }
254    };
255    let status = status.trim();
256    //utils::print_string(c"HTTP response status: ", status);
257
258    // Skip the rest of the headers
259    let mut is_chunked = false;
260    let mut has_content = true;
261    buffer.clear();
262    loop {
263        reader.read_line(&mut buffer).map_err(|err| {
264            HttpError::status("Reading response header: ".to_string() + &err.as_string())
265        })?;
266        let header = buffer.trim();
267        if header.is_empty() {
268            // end of headers
269            break;
270        }
271        if header == CHUNKED_HEADER {
272            is_chunked = true;
273        }
274        if header == CONTENT_LENGTH_0 {
275            has_content = false;
276        }
277        //utils::print_string(c"HTTP response header: ", header);
278
279        buffer.clear();
280    }
281
282    if status.trim() != EXPECTED_HTTP_200 {
283        // Usually the body explains the error so gather that.
284        if is_chunked {
285            // Skip the size line, the header said transfer encoding chunked
286            // so even an HTTP 400 has to respect that.
287            let _ = reader.read_line(&mut buffer);
288            buffer.clear();
289        }
290        if !has_content {
291            return Err(HttpError::status(status.to_string()));
292        }
293        match reader.read_line(&mut buffer) {
294            Ok(_) => {
295                // TODO parse JSON. It looks like this:
296                // {"error":{"message":"openai/gpt-oss-90b is not a valid model ID","code":400},"user_id":"user_30mJ0GpP57Kj9wLQ4mDCfMS5nk0"}
297                return Err(HttpError::new(
298                    status.to_string(),
299                    buffer.trim().to_string(),
300                ));
301            }
302            _ => return Err(HttpError::status(status.to_string())),
303        }
304    }
305    Ok(is_chunked)
306}
307
308/// Attempt to connect to all the SocketAddr in order, with a timeout.
309/// The addreses come from the system resolver or `${XDG_CONFIG_HOME}/ort.json`
310/// in settings/dns.
311fn connect(addrs: Vec<SocketAddr>) -> OrtResult<TcpSocket> {
312    // TODO: Erorr handling, don't just try the first
313
314    //let mut errs = vec![];
315    //let addrs: Vec<_> = addrs.to_socket_addrs().unwrap().collect();
316    for addr in addrs {
317        let addr_v4 = match addr {
318            SocketAddr::V4(v4) => v4,
319            _ => continue,
320        };
321        let sock = TcpSocket::new()?;
322        sock.connect(&addr_v4)?;
323        return Ok(sock);
324        /*
325        match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
326            Ok(tcp) => {
327                set_tcp_fastopen(&tcp);
328                return Ok(tcp);
329            }
330            Err(err) => {
331                errs.push((addr, err));
332            }
333        }
334        */
335    }
336    //let err_msg: Vec<String> = errs
337    //    .into_iter()
338    //    .map(|(addr, err)| format!("Failed connecting to {addr:?}: {err}"))
339    //    .collect();
340    //Err(io::Error::other(err_msg.join("; ")))
341    Err(ort_error(
342        ErrorKind::HttpConnectError,
343        "connect error handling TODO",
344    ))
345}