/v1/messages: the model, through the operator's gateway, narrowed to what Whiskers sends. It exists so the gateway is never put on the private network: the gateway may be the operator's subscription with no login of its own, and this is its only guard. Do not widen what it lets through.

5use ::log::{debug, error, info, warn};
6use whiskers_ports::{
7    Allowance, Clock, HasAllowance, HasClock, HasHousehold, HasThinker, Hub, ThinkError, ThinkRequest, ThinkingRoom, Thinker, tokens_in,
8};
10use super::{Route, sealed};
11use crate::{Content, Response};

POST /v1/messages. Needs the model, the household's choices (the thinking window), the allowance and the clock.

14pub struct Messages;
16impl sealed::Sealed for Messages {}
17
18impl<C: HasThinker + HasHousehold + HasAllowance + HasClock> Route<C> for Messages {
19    const PATHS: &'static [&'static str] = &["/v1/messages"];
20    async fn answer(&self, adapter: &C, _: &str, body: &str) -> Response {
21        messages(adapter, body).await
22    }
23}
24
25async fn messages<C: HasThinker + HasHousehold + HasAllowance + HasClock>(adapter: &C, body: &str) -> Response {
26    let request = match ThinkRequest::vet(body, adapter.thinker().serves()) {
27        Ok(r) => r,
28        Err(e) => {
29            warn!("/v1/messages refused: {e}");
30            return Response::text(400, e.to_string());
31        }
32    };
33    let limit = match adapter.household().current().await {
34        Ok(h) => h.config().tokens,
35        Err(e) => {
36            error!("/v1/messages: the household's choices cannot be read: {e}");
37            return Response::empty(500);
38        }
39    };
40    match adapter.allowance().thinking_room(adapter.clock().now(), &limit).await {
41        Ok(ThinkingRoom::Room) => {}
42        Ok(ThinkingRoom::Spent { frees_up_at }) => {
43            info!("/v1/messages refused: the thinking allowance is spent (room returns at {} ms)", frees_up_at.get());
44            // The tablet looks for this word.
45            return Response::text(429, "{\"error\":\"budget\"}".into());
46        }
47        Err(e) => {
48            error!("/v1/messages: the thinking allowance cannot be read: {e}");
49            return Response::empty(500);
50        }
51    }
52    match adapter.thinker().think(&request).await {
53        Ok(reply) => {
54            debug!("/v1/messages: the gateway answered {}, {} bytes", reply.status, reply.body.len());
55            if reply.is_answer() {
56                let cost = tokens_in(&reply.body);
57                debug!("/v1/messages: the answer cost {} tokens", cost.get());
58                if let Err(e) = adapter.allowance().charge_thinking(adapter.clock().now(), cost).await {
59                    // The answer exists; losing the record of its cost risks a little overspending.
60                    warn!("/v1/messages: the cost of an answer could not be kept: {e}");
61                }
62            } else {
63                warn!("/v1/messages: the gateway answered {} ({} byte body)", reply.status, reply.body.len());
64            }
65            Response::bytes(reply.status, Content::Json, reply.body.into_bytes())
66        }
67        Err(e) => {
68            match &e {
69                ThinkError::NotConfigured(_) => warn!("/v1/messages refused: {e}"),
70                ThinkError::Unreachable(_) | ThinkError::Unreadable(_) => error!("/v1/messages refused: {e}"),
71            }
72            Response::text(502, e.to_string())
73        }
74    }
75}