19
#![cfg_attr(not(unix), allow(dead_code))]
20
21
use std::sync::Arc;
22
+use std::sync::atomic::{AtomicBool, Ordering};
23
24
use async_trait::async_trait;
25
use onde::inference::{ChatEngine, ChatMessage, ChatRole, EngineStatus, ToolDefinition};
62
/// Why the model stopped. Only the last round of a turn decides how the
63
/// turn is reported; a round that goes on to run tools is not the end.
64
pub finish: FinishReason,
65
+ /// The context in use once this reply is in, as the endpoint counted it:
66
+ /// prompt plus completion tokens. `None` when nothing was reported, which
67
+ /// is always the case on-device.
68
+ pub context_tokens: Option<u64>,
69
}
70
71
/// Why the model stopped generating, reduced to what the agent loop acts on.
919
text,
920
tool_calls: Vec::new(),
921
finish,
922
+ context_tokens: None,
923
})
924
}
925
927
fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
928
TurnResult {
929
finish: FinishReason::from_wire(Some(&result.finish_reason)),
930
+ context_tokens: None,
931
text: result.text,
932
tool_calls: result
933
.tool_calls
962
/// Whether `model` takes audio (see `provider::model_accepts_audio`), with
963
/// the same effect on audio already in `history`.
964
accepts_audio: bool,
965
+ /// Set once the endpoint has refused `stream_options`, so streamed
966
+ /// requests stop asking it for token usage.
967
+ stream_usage_refused: Arc<AtomicBool>,
968
}
969
970
impl OpenAiBackend {
990
model,
991
http: reqwest::Client::new(),
992
history: Mutex::new(history),
993
+ stream_usage_refused: Arc::default(),
994
}
995
}
996
1070
text: join_reply_text(&result.text, &retry.text),
1071
tool_calls: retry.tool_calls,
1072
finish: retry.finish,
1073
+ context_tokens: retry.context_tokens,
1074
})
1075
}
1076
1103
"messages": messages,
1104
"stream": streaming,
1105
});
1106
+ // A stream reports no token usage unless asked to. It comes in a last
1107
+ // chunk with no choices, which feeds the client's context meter.
1108
+ let ask_for_usage = streaming && !self.stream_usage_refused.load(Ordering::Relaxed);
1109
+ if ask_for_usage {
1110
+ body["stream_options"] = serde_json::json!({ "include_usage": true });
1111
+ }
1112
if allow_tool_calls && !tools.is_empty() {
1113
body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
1114
// OpenAI specifies `auto` as the default when tools are present,
1119
body["tool_choice"] = serde_json::Value::String("auto".to_string());
1120
}
1121
1104
- let response = self
1122
+ let mut response = self
1123
.http
1124
.post(&url)
1125
.bearer_auth(&self.api_key)
1130
1131
if !response.status().is_success() {
1132
let status = response.status();
1115
- let body = response.text().await.unwrap_or_default();
1116
- return Err(describe_api_error(status, &body));
1133
+ let error_body = response.text().await.unwrap_or_default();
1134
+ // Usage is a nicety; an endpoint that does not know the option is
1135
+ // asked again without it, and never asked for it again.
1136
+ if !(ask_for_usage && refuses_stream_options(status, &error_body)) {
1137
+ return Err(describe_api_error(status, &error_body));
1138
+ }
1139
+ log::info!("{url} refused stream_options; streaming without token usage");
1140
+ self.stream_usage_refused.store(true, Ordering::Relaxed);
1141
+ if let Some(fields) = body.as_object_mut() {
1142
+ fields.remove("stream_options");
1143
+ }
1144
+ response = self
1145
+ .http
1146
+ .post(&url)
1147
+ .bearer_auth(&self.api_key)
1148
+ .json(&body)
1149
+ .send()
1150
+ .await
1151
+ .map_err(|error| format!("request to {url} failed: {error}"))?;
1152
+ if !response.status().is_success() {
1153
+ let status = response.status();
1154
+ let error_body = response.text().await.unwrap_or_default();
1155
+ return Err(describe_api_error(status, &error_body));
1156
+ }
1157
}
1158
1159
if let Some(sink) = sink {
1229
.json()
1230
.await
1231
.map_err(|error| format!("response parse error: {error}"))?;
1232
+ let context_tokens = parsed.usage.as_ref().map(Usage::context_tokens);
1233
1234
let choice = parsed
1235
.choices
1284
text,
1285
tool_calls: Vec::new(),
1286
finish,
1287
+ context_tokens,
1288
},
1289
malformed,
1290
));
1324
text: cleaned,
1325
tool_calls,
1326
finish,
1327
+ context_tokens,
1328
},
1329
malformed,
1330
));
1338
text: cleaned,
1339
tool_calls,
1340
finish,
1341
+ context_tokens,
1342
},
1343
malformed,
1344
));
1368
text: extracted.text,
1369
tool_calls,
1370
finish,
1371
+ context_tokens,
1372
},
1373
malformed,
1374
));
1388
text,
1389
tool_calls,
1390
finish,
1391
+ context_tokens,
1392
},
1393
0,
1394
))
1427
let mut done = false;
1428
// Arrives on a late chunk, usually one with an empty delta.
1429
let mut finish = FinishReason::default();
1430
+ // Arrives on the last chunk, which has no choices, when asked for.
1431
+ let mut context_tokens = None;
1432
// Recovers a tool call the model wrote as literal `<tool_call>` text
1433
// instead of a structured delta. Scanning here (rather than after the
1434
// stream) keeps the tag off the UI: content goes straight to `sink` as
1483
Err(_) => continue,
1484
};
1485
1486
+ if let Some(usage) = &chunk.usage {
1487
+ context_tokens = Some(usage.context_tokens());
1488
+ }
1489
let Some(choice) = chunk.choices.into_iter().next() else {
1490
continue;
1491
};
1654
text,
1655
tool_calls,
1656
finish,
1657
+ context_tokens,
1658
},
1659
malformed,
1660
))
1833
history: Mutex::new(Vec::new()),
1834
accepts_images: self.accepts_images,
1835
accepts_audio: self.accepts_audio,
1836
+ // It is the same endpoint, so what it refused it refuses here.
1837
+ stream_usage_refused: Arc::clone(&self.stream_usage_refused),
1838
}))
1839
}
1840
2066
struct ChatCompletion {
2067
#[serde(default)]
2068
choices: Vec<CompletionChoice>,
2069
+ #[serde(default)]
2070
+ usage: Option<Usage>,
2071
+}
2072
+
2073
+/// Token usage as an OpenAI-compatible endpoint reports it.
2074
+#[derive(Debug, Deserialize)]
2075
+struct Usage {
2076
+ #[serde(default)]
2077
+ prompt_tokens: u64,
2078
+ #[serde(default)]
2079
+ completion_tokens: u64,
2080
+ #[serde(default)]
2081
+ total_tokens: Option<u64>,
2082
+}
2083
+
2084
+impl Usage {
2085
+ /// The context in use once the reply is in.
2086
+ fn context_tokens(&self) -> u64 {
2087
+ self.total_tokens
2088
+ .unwrap_or(self.prompt_tokens + self.completion_tokens)
2089
+ }
2090
+}
2091
+
2092
+/// Whether a failed request was turned down for carrying `stream_options`,
2093
+/// which some OpenAI-compatible servers predate and reject as unknown.
2094
+fn refuses_stream_options(status: reqwest::StatusCode, body: &str) -> bool {
2095
+ matches!(status.as_u16(), 400 | 422) && body.contains("stream_options")
2096
}
2097
2098
#[derive(Debug, Deserialize)]
2157
struct StreamCompletion {
2158
#[serde(default)]
2159
choices: Vec<StreamChoice>,
2160
+ #[serde(default)]
2161
+ usage: Option<Usage>,
2162
}
2163
2164
#[derive(Debug, Deserialize)]
3082
assert!(shown.contains("stopped this reply"), "{shown}");
3083
}
3084
3085
+ /// Serves `responses` in order, one per connection, as `(status,
3086
+ /// content type, body)`, and hands back each request body.
3087
+ fn spawn_scripted_stub(
3088
+ responses: Vec<(u16, &'static str, String)>,
3089
+ ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
3090
+ use std::io::{Read, Write};
3091
+
3092
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3093
+ let addr = listener.local_addr().unwrap();
3094
+ let (sender, receiver) = std::sync::mpsc::channel();
3095
+ std::thread::spawn(move || {
3096
+ for (status, content_type, body) in responses {
3097
+ let Ok((mut stream, _)) = listener.accept() else {
3098
+ return;
3099
+ };
3100
+ let mut request = Vec::new();
3101
+ let mut chunk = [0u8; 4096];
3102
+ loop {
3103
+ let n = stream.read(&mut chunk).unwrap_or(0);
3104
+ if n == 0 {
3105
+ break;
3106
+ }
3107
+ request.extend_from_slice(&chunk[..n]);
3108
+ if let Some(headers_end) =
3109
+ request.windows(4).position(|window| window == b"\r\n\r\n")
3110
+ {
3111
+ let headers = String::from_utf8_lossy(&request[..headers_end]);
3112
+ let content_length = headers
3113
+ .lines()
3114
+ .find_map(|line| {
3115
+ line.to_ascii_lowercase()
3116
+ .strip_prefix("content-length:")
3117
+ .map(|value| value.trim().parse::<usize>().unwrap_or(0))
3118
+ })
3119
+ .unwrap_or(0);
3120
+ if request.len() >= headers_end + 4 + content_length {
3121
+ let _ = sender.send(
3122
+ String::from_utf8_lossy(&request[headers_end + 4..]).into_owned(),
3123
+ );
3124
+ break;
3125
+ }
3126
+ }
3127
+ }
3128
+ let response = format!(
3129
+ "HTTP/1.1 {status} X\r\ncontent-type: {content_type}\r\n\
3130
+ content-length: {}\r\nconnection: close\r\n\r\n{}",
3131
+ body.len(),
3132
+ body
3133
+ );
3134
+ let _ = stream.write_all(response.as_bytes());
3135
+ }
3136
+ });
3137
+ (addr, receiver)
3138
+ }
3139
+
3140
+ fn sse(events: &[serde_json::Value]) -> String {
3141
+ let mut body: String = events
3142
+ .iter()
3143
+ .map(|event| format!("data: {event}\n\n"))
3144
+ .collect();
3145
+ body.push_str("data: [DONE]\n\n");
3146
+ body
3147
+ }
3148
+
3149
+ /// A stream asks for token usage and reads it off the last chunk, the
3150
+ /// one with no choices, for the client's context meter (issue #194).
3151
+ #[tokio::test]
3152
+ async fn a_stream_reports_the_usage_it_was_asked_for() {
3153
+ let (addr, requests) = spawn_scripted_stub(vec![(
3154
+ 200,
3155
+ "text/event-stream",
3156
+ sse(&[
3157
+ serde_json::json!({"choices": [{"delta": {"content": "Hi."}}]}),
3158
+ serde_json::json!({
3159
+ "choices": [],
3160
+ "usage": {"prompt_tokens": 1200, "completion_tokens": 34, "total_tokens": 1234},
3161
+ }),
3162
+ ]),
3163
+ )]);
3164
+ let backend =
3165
+ OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3166
+ let (sink, _chunks) = tokio::sync::mpsc::unbounded_channel();
3167
+
3168
+ let result = backend
3169
+ .send_message_with_tools("hello", &[], Some(&sink))
3170
+ .await
3171
+ .unwrap();
3172
+
3173
+ assert_eq!(result.text, "Hi.");
3174
+ assert_eq!(result.context_tokens, Some(1234));
3175
+ let request: serde_json::Value = serde_json::from_str(&requests.recv().unwrap()).unwrap();
3176
+ assert_eq!(request["stream_options"]["include_usage"], true);
3177
+ }
3178
+
3179
+ #[tokio::test]
3180
+ async fn a_json_reply_reports_its_usage() {
3181
+ let body = serde_json::json!({
3182
+ "choices": [{"message": {"role": "assistant", "content": "Hi."}}],
3183
+ "usage": {"prompt_tokens": 90, "completion_tokens": 10},
3184
+ })
3185
+ .to_string();
3186
+ let (addr, requests) = spawn_scripted_stub(vec![(200, "application/json", body)]);
3187
+ let backend =
3188
+ OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3189
+
3190
+ let result = backend
3191
+ .send_message_with_tools("hello", &[], None)
3192
+ .await
3193
+ .unwrap();
3194
+
3195
+ // No total given: prompt plus completion.
3196
+ assert_eq!(result.context_tokens, Some(100));
3197
+ let request: serde_json::Value = serde_json::from_str(&requests.recv().unwrap()).unwrap();
3198
+ assert!(request.get("stream_options").is_none(), "{request}");
3199
+ }
3200
+
3201
+ /// An endpoint that predates `stream_options` is asked again without it,
3202
+ /// and not asked for usage again after that.
3203
+ #[tokio::test]
3204
+ async fn an_endpoint_that_refuses_stream_options_still_streams() {
3205
+ let refusal = serde_json::json!({
3206
+ "error": {"message": "Unrecognized request argument supplied: stream_options"},
3207
+ })
3208
+ .to_string();
3209
+ let reply = sse(&[serde_json::json!({"choices": [{"delta": {"content": "Hi."}}]})]);
3210
+ let (addr, requests) = spawn_scripted_stub(vec![
3211
+ (400, "application/json", refusal),
3212
+ (200, "text/event-stream", reply.clone()),
3213
+ (200, "text/event-stream", reply),
3214
+ ]);
3215
+ let backend =
3216
+ OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3217
+ let (sink, _chunks) = tokio::sync::mpsc::unbounded_channel();
3218
+
3219
+ let first = backend
3220
+ .send_message_with_tools("hello", &[], Some(&sink))
3221
+ .await
3222
+ .unwrap();
3223
+ assert_eq!(first.text, "Hi.");
3224
+ assert_eq!(first.context_tokens, None);
3225
+ let second = backend
3226
+ .send_message_with_tools("again", &[], Some(&sink))
3227
+ .await
3228
+ .unwrap();
3229
+ assert_eq!(second.text, "Hi.");
3230
+
3231
+ let bodies: Vec<serde_json::Value> = requests
3232
+ .try_iter()
3233
+ .map(|body| serde_json::from_str(&body).unwrap())
3234
+ .collect();
3235
+ assert_eq!(bodies.len(), 3);
3236
+ assert!(bodies[0].get("stream_options").is_some());
3237
+ assert!(bodies[1].get("stream_options").is_none());
3238
+ assert!(bodies[2].get("stream_options").is_none());
3239
+ }
3240
+
3241
#[test]
3242
fn finish_reasons_map_from_the_wire() {
3243
for reason in [None, Some("stop"), Some("tool_calls"), Some("unheard-of")] {