///|
/// Build the chat completion request body for `prompt`.
///
/// A plain `Json` value on purpose: the wire format is the contract, so nothing
/// from a client library shows up in this module's public API.
pub fn request_body(
settings : Settings,
prompt : String,
stream? : Bool = false,
) -> Json {
let messages = Json::array([
Json::object({
"role": Json::string("system"),
"content": Json::string(settings.system),
}),
Json::object({
"role": Json::string("user"),
"content": Json::string(prompt),
}),
])
let body : Map[String, Json] = {
"model": Json::string(settings.model),
"messages": messages,
"stream": Json::boolean(stream),
}
if settings.temperature is Some(temperature) {
body["temperature"] = Json::number(temperature)
}
if settings.max_tokens is Some(max_tokens) {
body["max_tokens"] = Json::number(max_tokens.to_double())
}
// 推理开关:两种风格并存,勾哪个发哪个(端点认哪个由使用方决定)
if settings.enable_thinking {
body["enable_thinking"] = Json::boolean(true)
}
if settings.reasoning_effort is Some(effort) {
body["reasoning_effort"] = Json::string(effort)
}
Json::object(body)
}
///|
/// Token accounting from a `usage` block.
///
/// Reasoning models report `reasoning_tokens` separately; on those, the
/// completion count is thinking plus answer, which is why it is tracked here
/// rather than collapsed into one number.
pub(all) struct TokenUsage {
prompt_tokens : Int
completion_tokens : Int
reasoning_tokens : Int
} derive(Eq, Debug)
///|
pub extend TokenUsage with Eq::{equal, not_equal}
///|
pub extend TokenUsage with @debug.Debug::{to_repr}
///|
/// Build a token accounting record.
pub fn TokenUsage::new(
prompt_tokens : Int,
completion_tokens : Int,
reasoning_tokens : Int,
) -> TokenUsage {
{ prompt_tokens, completion_tokens, reasoning_tokens, }
}
///|
/// The share of the completion budget spent thinking, in `[0, 1]`.
///
/// Returns 0 when the model reported no completion tokens at all.
pub fn TokenUsage::reasoning_ratio(self : TokenUsage) -> Double {
if self.completion_tokens == 0 {
0.0
} else {
self.reasoning_tokens.to_double() / self.completion_tokens.to_double()
}
}
///|
/// A meaningful line of an OpenAI-compatible server-sent event stream.
pub(all) enum SseEvent {
/// An incremental fragment of the visible answer.
Content(String)
/// An incremental fragment of the model's chain of thought.
Reasoning(String)
/// The final `usage` block, sent in a chunk whose `choices` array is empty.
Usage(TokenUsage)
/// The reason the model stopped (`stop`, `length`, `tool_calls`, ...).
/// `length` means the reply was truncated by the token budget.
Finish(String)
/// The `[DONE]` sentinel. The stream is over.
Done
/// A line we understood but that carries nothing we track (role-only chunk,
/// finish reason, tool-call delta, keep-alive comment, ...).
Ignore
} derive(Eq, Debug)
///|
pub extend SseEvent with Eq::{equal, not_equal}
///|
pub extend SseEvent with @debug.Debug::{to_repr}
///|
/// Parse one SSE line from a chat completions stream.
///
/// `None` means "skip": a blank line, a comment, or a non-`data:` field.
pub fn parse_sse_line(line : String) -> SseEvent? {
let text = line.trim(chars=" \t\r\n")
if text.length() == 0 || text.has_prefix(":") {
return None
}
if !text.has_prefix("data:") {
return Some(Ignore)
}
let payload = text[5:].trim(chars=" \t\r\n").to_owned()
if payload == "[DONE]" {
return Some(Done)
}
let parsed = @json.parse(payload) catch { _ => return Some(Ignore) }
Some(parse_stream_chunk(parsed))
}
///|
/// Turn one decoded stream chunk into an event.
///
/// Within a chunk the visible answer wins over the reasoning: if a chunk ever
/// carried both, the answer is what the caller is timing.
fn parse_stream_chunk(json : Json) -> SseEvent {
guard json is Object(obj) else { return Ignore }
let choices = match obj.get("choices") {
Some(Array(choices)) => choices
_ => []
}
if choices.length() > 0 {
return parse_choice(choices)
}
match obj.get("usage") {
Some(Object(usage)) => Usage(parse_usage(usage))
_ => Ignore
}
}
///|
fn parse_choice(choices : Array[Json]) -> SseEvent {
let choice = match choices {
[Object(choice), ..] => choice
_ => return Ignore
}
let delta = match choice.get("delta") {
Some(Object(delta)) => delta
_ => return Ignore
}
match non_empty_string(delta.get("content")) {
Some(text) => return Content(text)
None => ()
}
match non_empty_string(delta.get("reasoning_content")) {
Some(text) => return Reasoning(text)
None => ()
}
match non_empty_string(choice.get("finish_reason")) {
Some(reason) => Finish(reason)
None => Ignore
}
}
///|
fn parse_usage(usage : Map[String, Json]) -> TokenUsage {
{
prompt_tokens: int_field(usage, "prompt_tokens"),
completion_tokens: int_field(usage, "completion_tokens"),
reasoning_tokens: int_field(usage, "reasoning_tokens"),
}
}
///|
/// The string value of a field, treating null and `""` as absent.
fn non_empty_string(value : Json?) -> String? {
match value {
Some(String(text)) => if text.length() == 0 { None } else { Some(text) }
_ => None
}
}
///|
fn int_field(obj : Map[String, Json], key : String) -> Int {
match obj.get(key) {
Some(Number(n, ..)) => n.to_int()
_ => 0
}
}