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