qcode/provider/ask.rs
1//! Asking a provider what it can do, over a seam a test can stand in for.
2//!
3//! Every request in QCode that leaves the machine goes through [`Web`], for the same reason
4//! every installation goes through [`Installer`](crate::ui::setup::install::Installer): the real
5//! one is built in one place, and a test is handed one that answers from a string it was given.
6//! No test in this crate can reach the network, whatever it asks for, and the suite is the same
7//! on a machine with no server anywhere.
8//!
9//! Three things are asked here.
10//!
11//! **What models there are**, **what window each of them claims** and **what each of them costs**
12//! — all from the provider's own API, never guessed and never scraped: ollama answers
13//! `/api/tags` and `/api/show`, OpenRouter answers `/api/v1/models` with a price beside every
14//! model. A ready-made provider answers its own `/v1/models`; where that names a model without its
15//! window, the window its maker publishes is kept, and the page says where it was read.
16//!
17//! **What window the server really gives**, which is the one that matters and the one nobody
18//! tells you. A model's record can say 262 144 while the endpoint a harness actually uses pins
19//! the window to 4 096 and throws the front of every larger prompt away without a word or an
20//! error. [`measure`] finds that edge the only way it can be found: it sends prompts of growing
21//! size and watches where the input token count the server reports stops growing.
22//!
23//! A request goes out only when the person asked for it. Nothing here runs because a page was
24//! opened.
25
26use std::sync::Arc;
27use std::time::Duration;
28
29use super::record::Published;
30use super::{Key, Measured, Model, Price, ProviderEntry, ProviderKind, Wire};
31
32/// How long a single request may take before it is given up on. Generous rather than tight: a
33/// large probe on a loaded machine makes the server load a model first, and a short limit would
34/// report a working server as broken. Finite, because a page that waits forever is a page that
35/// is stuck.
36const PATIENCE: Duration = Duration::from_secs(120);
37
38/// How much of a server's answer is ever repeated back to the person. Enough to recognise the
39/// complaint, short enough that a page is not filled with someone's HTML error page.
40const QUOTED: usize = 240;
41
42/// The two methods anything here uses.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Method {
45 /// Reads something the provider publishes.
46 Get,
47 /// Sends something the provider reads.
48 Post,
49}
50
51impl Method {
52 /// The word as it goes on the wire, which is also what the page shows before the request.
53 #[must_use]
54 pub fn name(self) -> &'static str {
55 match self {
56 Self::Get => "GET",
57 Self::Post => "POST",
58 }
59 }
60}
61
62/// How a key is carried into a request.
63///
64/// It is a field of its own rather than another header so that no code path can put a key in a
65/// URL or print one with the headers: the key inside is a [`Key`], whose `Debug` is the last
66/// four characters.
67#[derive(Debug, Clone)]
68pub struct Secret {
69 /// The header the key goes in.
70 pub header: String,
71 /// What stands in front of the key in that header, such as `Bearer `.
72 pub prefix: String,
73 /// The key itself.
74 pub key: Key,
75}
76
77/// One request to a provider.
78#[derive(Debug, Clone)]
79pub struct Ask {
80 /// What is being done.
81 pub method: Method,
82 /// Where it goes. A key is never part of an address, so this is safe to show and to record.
83 pub url: String,
84 /// The headers that hold nothing secret.
85 pub headers: Vec<(String, String)>,
86 /// The body, for a request that has one.
87 pub body: Option<String>,
88 /// The key, for a request that carries one.
89 pub secret: Option<Secret>,
90}
91
92impl Ask {
93 /// The line the page shows before a request goes out, so that nothing leaves the machine
94 /// towards an address the person has not read.
95 #[must_use]
96 pub fn line(&self) -> String {
97 format!("{} {}", self.method.name(), self.url)
98 }
99}
100
101/// What a provider answered.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Answer {
104 /// The status it answered with.
105 pub status: u16,
106 /// What it said.
107 pub body: String,
108}
109
110/// Why a question could not be answered.
111///
112/// None of these carries a key: a key never goes in an address, and what is quoted back is the
113/// server's own words, not the request.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum AskError {
116 /// The address could not be reached at all.
117 Unreachable {
118 /// Where the request was going.
119 url: String,
120 /// What the network said.
121 reason: String,
122 },
123 /// The server answered, and refused.
124 Refused {
125 /// Where the request went.
126 url: String,
127 /// The status it refused with.
128 status: u16,
129 /// The beginning of what it said.
130 said: String,
131 },
132 /// The answer was not the shape this provider's API promises.
133 Unreadable {
134 /// Where the request went.
135 url: String,
136 /// What was looked for and not found.
137 wanted: String,
138 },
139}
140
141impl AskError {
142 /// Where the request was going, which is what the person reads beside the trouble.
143 #[must_use]
144 pub fn url(&self) -> &str {
145 match self {
146 Self::Unreachable { url, .. } | Self::Refused { url, .. } | Self::Unreadable { url, .. } => url,
147 }
148 }
149}
150
151/// Whether a request could be sent to `url` at all, judged the way [`Web::network`] judges it.
152///
153/// The address is read with the very parser the transport reads it with, then held to the two
154/// things the transport asks of it before it opens a connection: a scheme it speaks and a host.
155/// Two addresses run together, `http://127.0.0.1:11434http://192.168.122.1:11434`, look like one
156/// to the eye and to a check on the first seven characters; this parser sees an authority that
157/// cannot be one, and the person hears it while the dialog is still open instead of on every
158/// request afterwards.
159#[must_use]
160pub fn can_be_sent_to(url: &str) -> bool {
161 let Ok(uri) = ureq::http::Uri::try_from(url) else {
162 return false;
163 };
164 let spoken = matches!(uri.scheme_str(), Some("http" | "https"));
165 spoken && uri.host().is_some_and(|host| !host.is_empty())
166}
167
168/// How a request is carried out.
169///
170/// [`network`](Web::network) is the real one. [`new`](Web::new) takes anything else, which is
171/// what every test in this crate uses.
172#[derive(Clone)]
173pub struct Web(Arc<Carry>);
174
175/// What a [`Web`] does with a request.
176type Carry = dyn Fn(&Ask) -> Result<Answer, AskError> + Send + Sync;
177
178impl std::fmt::Debug for Web {
179 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 formatter.write_str("Web")
181 }
182}
183
184impl Web {
185 /// The real one, which goes out over the network.
186 #[must_use]
187 pub fn network() -> Self {
188 Self::new(|ask| {
189 let config = ureq::Agent::config_builder()
190 .timeout_global(Some(PATIENCE))
191 // A refusal is an answer, and its body is what tells the person what the server
192 // objected to; it would be lost if the status alone became an error.
193 .http_status_as_error(false)
194 .build();
195 let agent = ureq::Agent::new_with_config(config);
196 let mut builder = match ask.method {
197 Method::Get => agent.get(&ask.url).force_send_body(),
198 Method::Post => agent.post(&ask.url),
199 };
200 for (name, value) in &ask.headers {
201 builder = builder.header(name, value);
202 }
203 if let Some(secret) = &ask.secret {
204 builder = builder.header(&secret.header, &format!("{}{}", secret.prefix, secret.key.expose()));
205 }
206 let sent = match &ask.body {
207 Some(body) => builder.content_type("application/json").send(body.as_str()),
208 None => builder.send_empty(),
209 };
210 // What the transport says can name the address; it can never name the key, which is
211 // only ever a header value and never part of the address.
212 let mut answer =
213 sent.map_err(|error| AskError::Unreachable { url: ask.url.clone(), reason: error.to_string() })?;
214 let status = answer.status().as_u16();
215 let body = answer
216 .body_mut()
217 .read_to_string()
218 .map_err(|error| AskError::Unreachable { url: ask.url.clone(), reason: error.to_string() })?;
219 Ok(Answer { status, body })
220 })
221 }
222
223 /// A web that carries requests the way `carry` says.
224 #[must_use]
225 pub fn new(carry: impl Fn(&Ask) -> Result<Answer, AskError> + Send + Sync + 'static) -> Self {
226 Self(Arc::new(carry))
227 }
228
229 /// Carries out `ask`, turning anything but a plain success into an [`AskError`].
230 ///
231 /// # Errors
232 ///
233 /// When the address cannot be reached, when the server refuses, or when what came back is
234 /// not JSON.
235 pub fn json(&self, ask: &Ask) -> Result<serde_json::Value, AskError> {
236 let answer = (self.0)(ask)?;
237 if !(200..300).contains(&answer.status) {
238 let said = shorten(&complaint(&answer.body));
239 return Err(AskError::Refused { url: ask.url.clone(), status: answer.status, said });
240 }
241 serde_json::from_str(&answer.body)
242 .map_err(|error| AskError::Unreadable { url: ask.url.clone(), wanted: error.to_string() })
243 }
244}
245
246/// The words of a refusal a person can act on, out of a body that may wrap them in JSON.
247///
248/// OpenRouter answers a rate-limited free model with `"message":"Provider returned error"` and
249/// keeps the sentence that says what happened — "temporarily rate-limited upstream. Please retry
250/// shortly" — under `metadata.raw` beside the error, two hundred characters in. Quoted from the front, the
251/// part a page has room for is the unhelpful half. So the most specific message is taken when the
252/// body is one of the shapes providers use, and the body itself otherwise.
253fn complaint(body: &str) -> String {
254 let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else { return body.to_owned() };
255 let error = value.get("error");
256 [
257 value.get("metadata").and_then(|metadata| metadata.get("raw")),
258 error.and_then(|error| error.get("metadata")).and_then(|metadata| metadata.get("raw")),
259 error.and_then(|error| error.get("message")),
260 error.filter(|error| error.is_string()),
261 value.get("message"),
262 ]
263 .into_iter()
264 .flatten()
265 .find_map(|said| said.as_str().filter(|said| !said.trim().is_empty()))
266 .map_or_else(|| body.to_owned(), str::to_owned)
267}
268
269/// The beginning of `said`, so that a page is never filled with someone's error page.
270fn shorten(said: &str) -> String {
271 let trimmed = said.trim();
272 match trimmed.char_indices().nth(QUOTED) {
273 Some((end, _)) => format!("{}…", &trimmed[..end]),
274 None => trimmed.to_owned(),
275 }
276}
277
278/// The request that lists what a provider offers.
279#[must_use]
280pub fn listing(entry: &ProviderEntry) -> Ask {
281 match entry.kind {
282 ProviderKind::Ollama => plain(Method::Get, entry.address("/api/tags")),
283 ProviderKind::OpenRouter => plain(Method::Get, entry.api_address(Wire::OpenAi, "/v1/models")),
284 // Both ready-made services list only for a key, and both at the OpenAI root.
285 ProviderKind::MimoTokenPlan | ProviderKind::KimiCode => {
286 with_key(Method::Get, entry.api_address(Wire::OpenAi, "/v1/models"), entry)
287 }
288 }
289}
290
291/// The request that asks an ollama server about one model, which is where its claimed window
292/// comes from.
293#[must_use]
294pub fn showing(entry: &ProviderEntry, model: &str) -> Ask {
295 let body = serde_json::json!({ "model": model }).to_string();
296 Ask { method: Method::Post, url: entry.address("/api/show"), headers: Vec::new(), body: Some(body), secret: None }
297}
298
299/// The request that tries the connection: the one thing that only ever goes out because the
300/// person pressed something.
301#[must_use]
302pub fn trial(entry: &ProviderEntry) -> Ask {
303 match entry.kind {
304 ProviderKind::Ollama => plain(Method::Get, entry.address("/api/version")),
305 // The key's own endpoint, so that the trial really tries the key rather than an address
306 // that answers whether or not the key is any good — and so that trying a connection
307 // never spends anything.
308 ProviderKind::OpenRouter => with_key(Method::Get, entry.api_address(Wire::OpenAi, "/v1/key"), entry),
309 // Neither service has an endpoint of the key's own; the model listing answers only for a
310 // good key and spends nothing, which is all a trial may do.
311 ProviderKind::MimoTokenPlan | ProviderKind::KimiCode => listing(entry),
312 }
313}
314
315/// A request that carries nothing secret.
316fn plain(method: Method, url: String) -> Ask {
317 Ask { method, url, headers: Vec::new(), body: None, secret: None }
318}
319
320/// The same request with `entry`'s key on it, when it has one, in the header its kind reads.
321fn with_key(method: Method, url: String, entry: &ProviderEntry) -> Ask {
322 Ask { method, url, headers: Vec::new(), body: None, secret: secret_of(entry) }
323}
324
325/// `entry`'s key as the header its kind reads it from, when it has one. The relay adds the very
326/// same header, so what the page tried is what a tab sends.
327#[must_use]
328pub fn secret_of(entry: &ProviderEntry) -> Option<Secret> {
329 let (header, prefix) = entry.kind.key_header();
330 entry.key.clone().map(|key| Secret { header: header.to_owned(), prefix: prefix.to_owned(), key })
331}
332
333/// What a provider offers, with the window each model claims and, where the service publishes
334/// one, what each of them costs.
335///
336/// The claimed window is asked for, not assumed: OpenRouter gives it with the listing, and an
337/// ollama server is asked about each model in turn. A model whose record cannot be read keeps
338/// its place in the list with nothing claimed, because a model that is there is worth naming
339/// even when its record is not.
340///
341/// # Errors
342///
343/// When the provider could not be reached or its listing could not be read.
344pub fn list_models(web: &Web, entry: &ProviderEntry) -> Result<Vec<Model>, AskError> {
345 let ask = listing(entry);
346 let answered = match web.json(&ask) {
347 Ok(answered) => answered,
348 // A ready-made service that does not list its models is still known to have the ones its
349 // maker publishes; a refused key is a different answer and is said as one.
350 Err(AskError::Refused { status: 404, .. }) if !entry.kind.published().is_empty() => {
351 return Ok(entry.kind.published().iter().map(published_model).collect());
352 }
353 Err(trouble) => return Err(trouble),
354 };
355 match entry.kind {
356 ProviderKind::Ollama => {
357 let names = answered
358 .get("models")
359 .and_then(|models| models.as_array())
360 .ok_or_else(|| AskError::Unreadable { url: ask.url.clone(), wanted: "models".to_owned() })?;
361 let names: Vec<String> =
362 names.iter().filter_map(|model| model.get("name")?.as_str().map(str::to_owned)).collect();
363 Ok(names
364 .into_iter()
365 .map(|id| {
366 let claimed = claimed_by_ollama(web, entry, &id);
367 Model { id, claimed, measured: None, price: None }
368 })
369 .collect())
370 }
371 ProviderKind::OpenRouter | ProviderKind::MimoTokenPlan | ProviderKind::KimiCode => {
372 let models = answered
373 .get("data")
374 .and_then(|data| data.as_array())
375 .ok_or_else(|| AskError::Unreadable { url: ask.url.clone(), wanted: "data".to_owned() })?;
376 let published = entry.kind.published();
377 Ok(models
378 .iter()
379 .filter_map(|model| {
380 let id = model.get("id")?.as_str()?.to_owned();
381 // What the service answers comes first; Kimi gives it, Xiaomi does not, and
382 // then the maker's published figure is all there is.
383 let claimed = model
384 .get("context_length")
385 .and_then(serde_json::Value::as_u64)
386 .or_else(|| published.iter().find(|known| known.id == id).map(|known| known.window));
387 // Only OpenRouter publishes a price per model. The others are the person's
388 // own server or a subscription they already hold, where a model that comes
389 // with the month costs nothing extra and there is no figure to record.
390 let price = match entry.kind {
391 ProviderKind::OpenRouter => openrouter_price(model, &id),
392 _ => None,
393 };
394 Some(Model { id, claimed, measured: None, price })
395 })
396 .collect())
397 }
398 }
399}
400
401/// What OpenRouter says a model costs: free only when both halves of its price are zero.
402///
403/// A listing with no price to read falls back on the `:free` ending OpenRouter gives the models
404/// it runs for nothing, which is the one thing such a model still says about itself; anything
405/// else is left unpriced rather than guessed, because a price nobody published is what a person
406/// spends money on by believing it.
407fn openrouter_price(model: &serde_json::Value, id: &str) -> Option<Price> {
408 let halves = model.get("pricing").and_then(|pricing| {
409 // Anything but a plain zero counts as a price: OpenRouter writes `"-1"` for a router whose
410 // cost is settled per request by the model it picks, which may be any model at all.
411 let above_zero = |key: &str| {
412 let written = pricing.get(key)?.as_str()?;
413 written.parse::<f64>().ok()?.partial_cmp(&0.0).map(std::cmp::Ordering::is_ne)
414 };
415 Some((above_zero("prompt")?, above_zero("completion")?))
416 });
417 match halves {
418 Some((prompt, completion)) => Some(if prompt || completion { Price::Paid } else { Price::Free }),
419 _ if id.ends_with(":free") => Some(Price::Free),
420 _ => None,
421 }
422}
423
424/// A model as its maker publishes it.
425fn published_model(published: &Published) -> Model {
426 Model { id: published.id.to_owned(), claimed: Some(published.window), measured: None, price: None }
427}
428
429/// The window `model`'s own record claims on an ollama server, or `None` when the server would
430/// not say. A record that cannot be read never costs the model its place in the list.
431fn claimed_by_ollama(web: &Web, entry: &ProviderEntry, model: &str) -> Option<u64> {
432 let answered = web.json(&showing(entry, model)).ok()?;
433 let info = answered.get("model_info")?.as_object()?;
434 // The key is named after the architecture the model was built with, so it is found by its
435 // ending rather than by a name QCode would have to keep a list of.
436 info.iter().find(|(name, _)| name.ends_with(".context_length")).and_then(|(_, value)| value.as_u64())
437}
438
439/// What a trial found.
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub enum Reached {
442 /// The server answered and named its own version.
443 Version(String),
444 /// The key was accepted.
445 KeyAccepted,
446}
447
448/// Tries the connection to `entry` and says what answered.
449///
450/// # Errors
451///
452/// When the address could not be reached, or the server refused; for a provider reached with a
453/// key, a refusal is what a key that is no longer good looks like.
454pub fn try_connection(web: &Web, entry: &ProviderEntry) -> Result<Reached, AskError> {
455 let ask = trial(entry);
456 let answered = web.json(&ask)?;
457 match entry.kind {
458 ProviderKind::Ollama => {
459 let version = answered
460 .get("version")
461 .and_then(|version| version.as_str())
462 .ok_or_else(|| AskError::Unreadable { url: ask.url.clone(), wanted: "version".to_owned() })?;
463 Ok(Reached::Version(version.to_owned()))
464 }
465 ProviderKind::OpenRouter | ProviderKind::MimoTokenPlan | ProviderKind::KimiCode => match answered.get("data") {
466 Some(_) => Ok(Reached::KeyAccepted),
467 None => Err(AskError::Unreadable { url: ask.url.clone(), wanted: "data".to_owned() }),
468 },
469 }
470}
471
472/// How large each probe's prompt is, in words. Four sizes, growing by about four times each, so
473/// that a window pinned anywhere in the usual range falls between two of them. The largest is
474/// well past any window a harness could use, because a server whose edge is never reached can
475/// only be reported as "at least", and "at least twelve thousand" was measured reading as good
476/// news on a server that in truth stops at sixteen.
477const PROBE_WORDS: [usize; 4] = [400, 3_000, 12_000, 48_000];
478
479/// How much of a probe has to come back counted for the prompt to have been taken whole. The
480/// filler is ordinary short words, so a tokeniser gives about one token a word; this leaves room
481/// for one that gives fewer without calling an untouched prompt cut.
482const WHOLE_SHARE: u64 = 80;
483
484/// The ordinary words a probe's prompt is built from. Plain prose rather than one word repeated,
485/// because a repeated word is not what a real prompt looks like to a tokeniser.
486const FILLER: [&str; 8] = ["the", "quiet", "river", "carries", "another", "small", "stone", "downstream"];
487
488/// Measures the window `model` really gets on this server, by sending prompts of growing size
489/// and watching for the first one the server does not read whole.
490///
491/// This is the number the page shows beside what the model claims, and the two are often not the
492/// same: the endpoint a harness uses can pin the window far below the model's own capacity and
493/// throw the front of every larger prompt away in silence. Probing stops at the first prompt
494/// that was cut, so a server with a small window is asked twice rather than four times.
495///
496/// Every probe carries its own opening, and `run` makes this measurement's openings unlike any
497/// other's. That is not decoration. A server that remembers the front of a prompt it has already
498/// read counts only what it had to read afresh, so probes that shared an opening were answered
499/// with numbers far below the prompts they were sent — on the owner's own server a window of
500/// 16 384 was reported as 9 512, and asking twice reported 4. Nothing shares an opening now, so
501/// the count is of the prompt rather than of the part that was new.
502///
503/// # Errors
504///
505/// When the provider could not be reached, refused, or answered without a token count.
506pub fn measure(web: &Web, entry: &ProviderEntry, model: &str) -> Result<Measured, AskError> {
507 let run = run_mark();
508 let mut whole = 0;
509 for words in PROBE_WORDS {
510 let tokens = probe(web, entry, model, words, &run)?;
511 // A prompt that came back counted far short of what was sent was cut, and where it was
512 // cut is the window. A larger prompt would be cut to the same place and teach nothing.
513 if cut(words, tokens) {
514 return Ok(Measured::About(tokens));
515 }
516 whole = whole.max(tokens);
517 }
518 // Every probe was taken whole. All that is known is that the window holds the largest one;
519 // where it ends was not found, and a number for it would be invented.
520 Ok(Measured::AtLeast(whole))
521}
522
523/// Whether a probe of `words` words came back counted too short to have been read whole.
524fn cut(words: usize, tokens: u64) -> bool {
525 let sent = u64::try_from(words).unwrap_or(u64::MAX);
526 tokens < sent.saturating_mul(WHOLE_SHARE) / 100
527}
528
529/// An opening no other measurement will use, so that no probe of this run can be answered out of
530/// what a server remembers of an earlier one.
531fn run_mark() -> String {
532 let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
533 format!("m{}", since.as_nanos())
534}
535
536/// The request one probe of `words` words is made of, opening with `run` and its own size so
537/// that no two probes begin alike. See [`measure`] for why that matters.
538#[must_use]
539pub fn probing(entry: &ProviderEntry, model: &str, words: usize, run: &str) -> Ask {
540 // The opening is this run's mark and this probe's size, so no two probes of one measurement
541 // begin alike either, and a server that remembers a prefix has nothing of theirs to remember.
542 let filler = (0..words).map(|index| FILLER[index % FILLER.len()].to_owned());
543 let opening = [run.to_owned(), words.to_string()];
544 let prompt: String = opening.into_iter().chain(filler).collect::<Vec<String>>().join(" ");
545 // One token of answer: the question is what the server read, never what it writes.
546 let body = match entry.wire {
547 Wire::Anthropic => serde_json::json!({
548 "model": model,
549 "max_tokens": 1,
550 "messages": [{ "role": "user", "content": prompt }],
551 }),
552 Wire::OpenAi => serde_json::json!({
553 "model": model,
554 "max_tokens": 1,
555 "messages": [{ "role": "user", "content": prompt }],
556 }),
557 };
558 let mut ask = with_key(Method::Post, entry.messages_address(), entry);
559 if entry.wire == Wire::Anthropic {
560 ask.headers.push(("anthropic-version".to_owned(), "2023-06-01".to_owned()));
561 }
562 ask.body = Some(body.to_string());
563 ask
564}
565
566/// Sends one probe and answers how many input tokens the server says it read.
567fn probe(web: &Web, entry: &ProviderEntry, model: &str, words: usize, run: &str) -> Result<u64, AskError> {
568 let ask = probing(entry, model, words, run);
569 let answered = web.json(&ask)?;
570 let usage = answered.get("usage");
571 let field = match entry.wire {
572 Wire::Anthropic => "input_tokens",
573 Wire::OpenAi => "prompt_tokens",
574 };
575 usage
576 .and_then(|usage| usage.get(field))
577 .and_then(serde_json::Value::as_u64)
578 .ok_or_else(|| AskError::Unreadable { url: ask.url.clone(), wanted: format!("usage.{field}") })
579}