claude: cache the prefix the bridge was re-billing every turn
No cache_control was ever set, so tools, system and the whole transcript were charged in full on every round. Breakpoints now sit on the tail of each stable span; message content is always a block array so the marked tail cannot change the prefix bytes as it moves. Mid-conversation system notes — the turn loop's pulse and interjections, the subagent round warnings — were hoisted into the top-level system field, rewriting the front of the prefix on the round they fired. They stay where they happened now. Usage carries the cache split; input_tokens alone understates the prompt by whatever was served cheaply.
This commit is contained in:
parent
bf469c4216
commit
a10ef5e3ed
3 changed files with 284 additions and 23 deletions
|
|
@ -314,12 +314,19 @@ pub struct ToolCallFunction {
|
||||||
pub arguments: String,
|
pub arguments: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
pub struct Usage {
|
pub struct Usage {
|
||||||
pub prompt_tokens: u32,
|
pub prompt_tokens: u32,
|
||||||
pub completion_tokens: u32,
|
pub completion_tokens: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub total_tokens: u32,
|
pub total_tokens: u32,
|
||||||
|
/// Prefix served from cache. Anthropic reports the uncached remainder as
|
||||||
|
/// `input_tokens`, so these two are the only way to tell a cache hit from a
|
||||||
|
/// short prompt — without them there is no way to know caching works.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_read_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_write_tokens: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream chunk from Bifrost (OpenAI SSE format)
|
/// Stream chunk from Bifrost (OpenAI SSE format)
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,20 @@ fn thinks_by_default(model: &str) -> bool {
|
||||||
THINKS.iter().any(|m| model.starts_with(m))
|
THINKS.iter().any(|m| model.starts_with(m))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Models that accept a `role: "system"` turn inside `messages`. Everywhere
|
||||||
|
/// else a mid-conversation note has to be folded into the user turn, which
|
||||||
|
/// costs the operator role but keeps the cached prefix intact either way.
|
||||||
|
/// Sonnet 5 is deliberately absent — it 400s on the role.
|
||||||
|
fn supports_mid_conversation_system(model: &str) -> bool {
|
||||||
|
const SUPPORTED: &[&str] = &[
|
||||||
|
"claude-opus-5",
|
||||||
|
"claude-opus-4-8",
|
||||||
|
"claude-fable-5",
|
||||||
|
"claude-mythos-5",
|
||||||
|
];
|
||||||
|
SUPPORTED.iter().any(|m| model.starts_with(m))
|
||||||
|
}
|
||||||
|
|
||||||
/// Direct Claude Code subscription inference provider.
|
/// Direct Claude Code subscription inference provider.
|
||||||
pub struct ClaudeSubscriptionProvider {
|
pub struct ClaudeSubscriptionProvider {
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -190,7 +204,8 @@ impl ClaudeSubscriptionProvider {
|
||||||
/// Translate an OpenAI chat-completion request to a wire-shaped Anthropic
|
/// Translate an OpenAI chat-completion request to a wire-shaped Anthropic
|
||||||
/// `/v1/messages` body.
|
/// `/v1/messages` body.
|
||||||
fn shape_body(&self, model: &str, request: &ChatCompletionRequest) -> Result<Vec<u8>> {
|
fn shape_body(&self, model: &str, request: &ChatCompletionRequest) -> Result<Vec<u8>> {
|
||||||
let (messages, system_text, first_user_text) = translate_messages(&request.messages);
|
let (messages, system_text, first_user_text) =
|
||||||
|
translate_messages(&request.messages, supports_mid_conversation_system(model));
|
||||||
let default_max_tokens = if thinks_by_default(model) {
|
let default_max_tokens = if thinks_by_default(model) {
|
||||||
THINKING_MAX_TOKENS
|
THINKING_MAX_TOKENS
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -225,6 +240,7 @@ impl ClaudeSubscriptionProvider {
|
||||||
let obj = body.as_object_mut().unwrap();
|
let obj = body.as_object_mut().unwrap();
|
||||||
inject_attribution(obj, &self.cc_version, &first_user_text);
|
inject_attribution(obj, &self.cc_version, &first_user_text);
|
||||||
inject_metadata(obj, self);
|
inject_metadata(obj, self);
|
||||||
|
mark_cache_breakpoints(obj);
|
||||||
|
|
||||||
Ok(serde_json::to_vec(&body)?)
|
Ok(serde_json::to_vec(&body)?)
|
||||||
}
|
}
|
||||||
|
|
@ -663,15 +679,33 @@ fn compute_fingerprint(first_user_text: &str, version: &str) -> String {
|
||||||
// ── OpenAI ↔ Anthropic translation =========================================
|
// ── OpenAI ↔ Anthropic translation =========================================
|
||||||
|
|
||||||
/// Translate OpenAI messages into Anthropic messages + a joined system string.
|
/// Translate OpenAI messages into Anthropic messages + a joined system string.
|
||||||
fn translate_messages(openai: &[Message]) -> (Vec<Value>, Option<String>, String) {
|
///
|
||||||
|
/// `mid_conv_system` decides where a system note that arrives *after* the
|
||||||
|
/// conversation has started lands. The top-level `system` field renders at the
|
||||||
|
/// very front of the prefix, so hoisting the turn loop's pulses, interjections
|
||||||
|
/// and subagent-awareness notes into it rewrites the front of the prompt on the
|
||||||
|
/// round they fire and re-bills tools, system and the whole transcript. They
|
||||||
|
/// stay where they happened instead.
|
||||||
|
fn translate_messages(
|
||||||
|
openai: &[Message],
|
||||||
|
mid_conv_system: bool,
|
||||||
|
) -> (Vec<Value>, Option<String>, String) {
|
||||||
let mut system_parts: Vec<String> = Vec::new();
|
let mut system_parts: Vec<String> = Vec::new();
|
||||||
let mut merged: Vec<(String, Vec<Value>)> = Vec::new();
|
let mut merged: Vec<(String, Vec<Value>)> = Vec::new();
|
||||||
|
|
||||||
for msg in openai {
|
for msg in openai {
|
||||||
match msg.role.as_str() {
|
match msg.role.as_str() {
|
||||||
"system" => {
|
"system" => {
|
||||||
if !msg.content.is_empty() {
|
if msg.content.is_empty() {
|
||||||
system_parts.push(msg.content.as_text());
|
continue;
|
||||||
|
}
|
||||||
|
let text = msg.content.as_text();
|
||||||
|
let conversation_started = merged.iter().any(|(role, _)| role == "user");
|
||||||
|
if !conversation_started {
|
||||||
|
system_parts.push(text);
|
||||||
|
} else {
|
||||||
|
let role = if mid_conv_system { "system" } else { "user" };
|
||||||
|
push_merge(&mut merged, role, json!({ "type": "text", "text": text }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"tool" => {
|
"tool" => {
|
||||||
|
|
@ -735,24 +769,24 @@ fn translate_messages(openai: &[Message]) -> (Vec<Value>, Option<String>, String
|
||||||
while merged.first().is_some_and(|(role, _)| role == "assistant") {
|
while merged.first().is_some_and(|(role, _)| role == "assistant") {
|
||||||
merged.remove(0);
|
merged.remove(0);
|
||||||
}
|
}
|
||||||
|
let merged = normalize_system_turns(merged);
|
||||||
|
|
||||||
let messages: Vec<Value> = merged
|
let messages: Vec<Value> = merged
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(role, blocks)| {
|
.map(|(role, blocks)| {
|
||||||
let content = if blocks.len() == 1
|
if role == "system" {
|
||||||
&& blocks[0].get("type").and_then(Value::as_str) == Some("text")
|
let text = blocks
|
||||||
{
|
.iter()
|
||||||
Value::String(
|
.filter_map(|b| b.get("text").and_then(Value::as_str))
|
||||||
blocks[0]
|
.collect::<Vec<_>>()
|
||||||
.get("text")
|
.join("\n\n");
|
||||||
.and_then(Value::as_str)
|
return json!({ "role": "system", "content": text });
|
||||||
.unwrap_or("")
|
}
|
||||||
.to_string(),
|
// Content is always a block array, never the equivalent bare
|
||||||
)
|
// string. Cache breakpoints attach to a block, and a shape that
|
||||||
} else {
|
// flipped between the two as the marked tail moved would change the
|
||||||
Value::Array(blocks)
|
// prefix bytes every round and miss every read.
|
||||||
};
|
json!({ "role": role, "content": Value::Array(blocks) })
|
||||||
json!({ "role": role, "content": content })
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -771,6 +805,75 @@ fn translate_messages(openai: &[Message]) -> (Vec<Value>, Option<String>, String
|
||||||
(messages, system, first_user_text)
|
(messages, system, first_user_text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `role: "system"` turn is only legal between a user turn and an assistant
|
||||||
|
/// turn, or as the last entry. Demote every other one into the user turn ahead
|
||||||
|
/// of it — still after the cached prefix, just without the operator role.
|
||||||
|
fn normalize_system_turns(merged: Vec<(String, Vec<Value>)>) -> Vec<(String, Vec<Value>)> {
|
||||||
|
let mut out: Vec<(String, Vec<Value>)> = Vec::with_capacity(merged.len());
|
||||||
|
for (i, (role, blocks)) in merged.iter().enumerate() {
|
||||||
|
let legal = role != "system"
|
||||||
|
|| (out.last().is_some_and(|(r, _)| r == "user")
|
||||||
|
&& merged
|
||||||
|
.get(i + 1)
|
||||||
|
.is_none_or(|(r, _)| r.as_str() == "assistant"));
|
||||||
|
let role = if legal { role.as_str() } else { "user" };
|
||||||
|
match out.last_mut() {
|
||||||
|
Some(last) if last.0 == role => last.1.extend(blocks.iter().cloned()),
|
||||||
|
_ => out.push((role.to_string(), blocks.clone())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place the four breakpoints a request is allowed.
|
||||||
|
///
|
||||||
|
/// Caching is a prefix match rendered `tools` → `system` → `messages`. The tool
|
||||||
|
/// schemas are byte-identical across every conversation and every agent, and
|
||||||
|
/// the system prompt is fixed for the life of one conversation, so each gets a
|
||||||
|
/// breakpoint of its own — editing the prompt then still reads the tools. The
|
||||||
|
/// remaining two ride the tail of the transcript so round N+1 of the tool loop
|
||||||
|
/// reads round N's prefix instead of paying for it again.
|
||||||
|
fn mark_cache_breakpoints(body: &mut Map<String, Value>) {
|
||||||
|
fn mark(block: &mut Value) {
|
||||||
|
if let Some(obj) = block.as_object_mut() {
|
||||||
|
obj.insert("cache_control".to_string(), json!({ "type": "ephemeral" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn mark_last(body: &mut Map<String, Value>, key: &str) {
|
||||||
|
if let Some(last) = body
|
||||||
|
.get_mut(key)
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
.and_then(|blocks| blocks.last_mut())
|
||||||
|
{
|
||||||
|
mark(last);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mark_last(body, "tools");
|
||||||
|
mark_last(body, "system");
|
||||||
|
|
||||||
|
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut marked = 0;
|
||||||
|
for message in messages.iter_mut().rev() {
|
||||||
|
if marked == 2 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// A system turn carries a bare string — no block to hang a breakpoint
|
||||||
|
// on, and it is the volatile tail anyway.
|
||||||
|
let Some(last) = message
|
||||||
|
.get_mut("content")
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
.and_then(|blocks| blocks.last_mut())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
mark(last);
|
||||||
|
marked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn push_merge(merged: &mut Vec<(String, Vec<Value>)>, role: &str, block: Value) {
|
fn push_merge(merged: &mut Vec<(String, Vec<Value>)>, role: &str, block: Value) {
|
||||||
if let Some(last) = merged.last_mut() {
|
if let Some(last) = merged.last_mut() {
|
||||||
if last.0 == role {
|
if last.0 == role {
|
||||||
|
|
@ -847,12 +950,22 @@ fn parse_completion(text: &str) -> Result<CompletionResult> {
|
||||||
.or(Some("stop".to_string()));
|
.or(Some("stop".to_string()));
|
||||||
|
|
||||||
let usage = value.get("usage").map(|u| {
|
let usage = value.get("usage").map(|u| {
|
||||||
let input = u.get("input_tokens").and_then(Value::as_u64).unwrap_or(0) as u32;
|
let field = |key: &str| u.get(key).and_then(Value::as_u64).unwrap_or(0) as u32;
|
||||||
let output = u.get("output_tokens").and_then(Value::as_u64).unwrap_or(0) as u32;
|
// `input_tokens` is only the part of the prompt that missed cache, so
|
||||||
|
// it understates the context by however much was served cheaply.
|
||||||
|
// `prompt_tokens` carries the real prompt size; the split rides
|
||||||
|
// alongside it.
|
||||||
|
let uncached = field("input_tokens");
|
||||||
|
let cache_read = field("cache_read_input_tokens");
|
||||||
|
let cache_write = field("cache_creation_input_tokens");
|
||||||
|
let output = field("output_tokens");
|
||||||
|
let input = uncached + cache_read + cache_write;
|
||||||
Usage {
|
Usage {
|
||||||
prompt_tokens: input,
|
prompt_tokens: input,
|
||||||
completion_tokens: output,
|
completion_tokens: output,
|
||||||
total_tokens: input + output,
|
total_tokens: input + output,
|
||||||
|
cache_read_tokens: cache_read,
|
||||||
|
cache_write_tokens: cache_write,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1002,7 +1115,7 @@ mod tests {
|
||||||
Message::tool_result("call_1", "read", "example-host"),
|
Message::tool_result("call_1", "read", "example-host"),
|
||||||
Message::text("user", "thanks"),
|
Message::text("user", "thanks"),
|
||||||
];
|
];
|
||||||
let (messages, system, first_user) = translate_messages(&msgs);
|
let (messages, system, first_user) = translate_messages(&msgs, true);
|
||||||
assert!(system.is_none());
|
assert!(system.is_none());
|
||||||
let roles: Vec<&str> = messages
|
let roles: Vec<&str> = messages
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -1027,10 +1140,150 @@ mod tests {
|
||||||
Message::text("system", "you are souvie"),
|
Message::text("system", "you are souvie"),
|
||||||
Message::text("user", "hi"),
|
Message::text("user", "hi"),
|
||||||
];
|
];
|
||||||
let (_, system, _) = translate_messages(&msgs);
|
let (_, system, _) = translate_messages(&msgs, true);
|
||||||
assert_eq!(system.as_deref(), Some("you are souvie"));
|
assert_eq!(system.as_deref(), Some("you are souvie"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The turn loop's pulse and the subagent's round warnings arrive as system
|
||||||
|
/// messages mid-conversation. Hoisting them into the top-level field would
|
||||||
|
/// rewrite the front of the prefix on the round they fire.
|
||||||
|
#[test]
|
||||||
|
fn mid_conversation_system_note_stays_out_of_the_system_field() {
|
||||||
|
let msgs = vec![
|
||||||
|
Message::text("system", "you are souvie"),
|
||||||
|
Message::text("user", "hi"),
|
||||||
|
Message::text("system", "[pulse] 4m elapsed"),
|
||||||
|
];
|
||||||
|
let (messages, system, _) = translate_messages(&msgs, true);
|
||||||
|
assert_eq!(system.as_deref(), Some("you are souvie"));
|
||||||
|
let last = messages.last().unwrap();
|
||||||
|
assert_eq!(last["role"], "system");
|
||||||
|
assert_eq!(last["content"], "[pulse] 4m elapsed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mid_conversation_note_folds_into_the_user_turn_when_unsupported() {
|
||||||
|
let msgs = vec![
|
||||||
|
Message::text("user", "hi"),
|
||||||
|
Message::text("system", "[pulse] 4m elapsed"),
|
||||||
|
];
|
||||||
|
let (messages, system, _) = translate_messages(&msgs, false);
|
||||||
|
assert!(system.is_none());
|
||||||
|
assert_eq!(messages.len(), 1);
|
||||||
|
assert_eq!(messages[0]["role"], "user");
|
||||||
|
let blocks = messages[0]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(blocks.len(), 2);
|
||||||
|
assert_eq!(blocks[1]["text"], "[pulse] 4m elapsed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A system turn may only sit between a user turn and an assistant turn, or
|
||||||
|
/// last. One landing anywhere else is demoted rather than sent and 400ed.
|
||||||
|
#[test]
|
||||||
|
fn demotes_a_system_turn_that_cannot_sit_where_it_landed() {
|
||||||
|
let msgs = vec![
|
||||||
|
Message::text("user", "hi"),
|
||||||
|
Message::text("assistant", "hello"),
|
||||||
|
Message::text("system", "[migraine] stop"),
|
||||||
|
Message::text("user", "still there?"),
|
||||||
|
];
|
||||||
|
let (messages, _, _) = translate_messages(&msgs, true);
|
||||||
|
let roles: Vec<&str> = messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| m["role"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(roles, vec!["user", "assistant", "user"]);
|
||||||
|
let folded = messages[2]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(folded[0]["text"], "[migraine] stop");
|
||||||
|
assert_eq!(folded[1]["text"], "still there?");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_content_is_always_a_block_array() {
|
||||||
|
let msgs = vec![Message::text("user", "hi")];
|
||||||
|
let (messages, _, _) = translate_messages(&msgs, true);
|
||||||
|
assert!(messages[0]["content"].is_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every request gets at most the four breakpoints the API allows, and they
|
||||||
|
/// land on the tail of each stable span: tools, system, transcript.
|
||||||
|
#[test]
|
||||||
|
fn places_breakpoints_on_tools_system_and_the_transcript_tail() {
|
||||||
|
let mut body = json!({
|
||||||
|
"tools": [{ "name": "read" }, { "name": "bash" }],
|
||||||
|
"system": "you are souvie",
|
||||||
|
"messages": [
|
||||||
|
{ "role": "user", "content": [{ "type": "text", "text": "one" }] },
|
||||||
|
{ "role": "assistant", "content": [{ "type": "text", "text": "two" }] },
|
||||||
|
{ "role": "user", "content": [{ "type": "text", "text": "three" }] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
inject_attribution(&mut body, "2.1.196", "one");
|
||||||
|
mark_cache_breakpoints(&mut body);
|
||||||
|
|
||||||
|
let marked = |v: &Value| v.get("cache_control").is_some();
|
||||||
|
let tools = body["tools"].as_array().unwrap();
|
||||||
|
assert!(!marked(&tools[0]) && marked(&tools[1]));
|
||||||
|
let system = body["system"].as_array().unwrap();
|
||||||
|
assert!(!marked(&system[0]) && marked(system.last().unwrap()));
|
||||||
|
|
||||||
|
let messages = body["messages"].as_array().unwrap();
|
||||||
|
let tails: Vec<bool> = messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| marked(m["content"].as_array().unwrap().last().unwrap()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(tails, vec![false, true, true]);
|
||||||
|
|
||||||
|
let total = serde_json::to_string(&body)
|
||||||
|
.unwrap()
|
||||||
|
.matches("cache_control")
|
||||||
|
.count();
|
||||||
|
assert_eq!(total, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A system turn carries a bare string, so it has no block to mark — the
|
||||||
|
/// breakpoint has to skip past it to the last turn that does.
|
||||||
|
#[test]
|
||||||
|
fn breakpoints_skip_a_trailing_system_turn() {
|
||||||
|
let mut body = json!({
|
||||||
|
"system": ["you are souvie"],
|
||||||
|
"messages": [
|
||||||
|
{ "role": "user", "content": [{ "type": "text", "text": "one" }] },
|
||||||
|
{ "role": "system", "content": "[pulse] 4m elapsed" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
mark_cache_breakpoints(&mut body);
|
||||||
|
let messages = body["messages"].as_array().unwrap();
|
||||||
|
assert!(messages[1].get("cache_control").is_none());
|
||||||
|
assert!(messages[0]["content"].as_array().unwrap()[0]
|
||||||
|
.get("cache_control")
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_counts_the_cached_prefix_as_prompt_tokens() {
|
||||||
|
let raw = r#"{
|
||||||
|
"id":"msg_3","stop_reason":"end_turn",
|
||||||
|
"content":[{"type":"text","text":"pong"}],
|
||||||
|
"usage":{
|
||||||
|
"input_tokens":12,
|
||||||
|
"cache_read_input_tokens":9000,
|
||||||
|
"cache_creation_input_tokens":300,
|
||||||
|
"output_tokens":4
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let usage = parse_completion(raw).unwrap().usage.unwrap();
|
||||||
|
assert_eq!(usage.prompt_tokens, 9312);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 9000);
|
||||||
|
assert_eq!(usage.cache_write_tokens, 300);
|
||||||
|
assert_eq!(usage.total_tokens, 9316);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_anthropic_text_response() {
|
fn parses_anthropic_text_response() {
|
||||||
let raw = r#"{
|
let raw = r#"{
|
||||||
|
|
|
||||||
|
|
@ -244,5 +244,6 @@ fn parse_usage(u: &Value) -> Option<Usage> {
|
||||||
prompt_tokens: prompt,
|
prompt_tokens: prompt,
|
||||||
completion_tokens: completion,
|
completion_tokens: completion,
|
||||||
total_tokens: prompt + completion,
|
total_tokens: prompt + completion,
|
||||||
|
..Usage::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue