commit 877f4c581cb9d71525b68bf4cebff5152a4e751e from: leoshimo <56844000+leoshimo@users.noreply.github.com> date: Wed Oct 15 22:45:26 2025 UTC feat: Update to Responses API commit - df099bfbf6d802f729aea67587f6dde90fac70ed commit + 877f4c581cb9d71525b68bf4cebff5152a4e751e blob - b3b300485a29d1064532b04e8ad1ee9a5bf7e29c blob + c518f977566828fb20eba8b47b4db91a7070735b --- src/cli.rs +++ src/cli.rs @@ -2,7 +2,7 @@ use std::time::Duration; -use crate::openai::Message; +use crate::openai::{Message, ReasoningEffort}; use clap::{ arg, builder::PossibleValue, command, value_parser, ArgGroup, ArgMatches, Command, ValueEnum, }; @@ -18,6 +18,8 @@ pub struct Invocation { pub output_format: OutputFormat, pub file: String, pub timeout: Duration, + #[builder(default)] + pub reasoning_effort: Option, } /// The format that invocation's results are in @@ -38,7 +40,7 @@ pub fn parse() -> Invocation { /// Top-level command fn cli() -> Command { command!() - .arg(arg!(model: -m --model "Sets model. See https://platform.openai.com/docs/models for model identifiers.").default_value("gpt-4-1106-preview")) + .arg(arg!(model: -m --model "Sets model. See https://platform.openai.com/docs/models for model identifiers.").default_value("gpt-5")) .arg( arg!(temperature: -t --temperature "Sets temperature") .value_parser(value_parser!(f32)) @@ -70,6 +72,11 @@ fn cli() -> Command { ]) .default_value("plaintext"), ) + .arg( + arg!(reasoning_effort: --"reasoning-effort" "Sets reasoning effort (low, medium, high)") + .value_parser(value_parser!(ReasoningEffort)) + .required(false), + ) .arg(arg!(--json "Shorthand for --output_format json")) .arg(arg!(--jsonp "Shorthand for --output_format jsonpretty")) .group(ArgGroup::new("output_format_short").args(["json", "jsonp"])) @@ -103,6 +110,10 @@ impl From for Invocation { .expect("File is required") .to_string(); + let reasoning_effort = matches + .get_one::("reasoning_effort") + .copied(); + Self { api_key, messages, @@ -111,6 +122,7 @@ impl From for Invocation { timeout, output_format, file, + reasoning_effort, } } } @@ -165,6 +177,20 @@ impl ValueEnum for OutputFormat { } } +impl ValueEnum for ReasoningEffort { + fn value_variants<'a>() -> &'a [Self] { + &[Self::Low, Self::Medium, Self::High] + } + + fn to_possible_value(&self) -> Option { + Some(match self { + Self::Low => PossibleValue::new("low"), + Self::Medium => PossibleValue::new("medium"), + Self::High => PossibleValue::new("high"), + }) + } +} + #[cfg(test)] mod test { use super::*; @@ -200,6 +226,16 @@ mod test { } #[test] + fn chat_reasoning_effort_flag() -> Result<()> { + let args = cli() + .try_get_matches_from(vec!["cogni", "-u", "USER", "--reasoning-effort", "high"]) + .map(Invocation::from)?; + + assert_eq!(args.reasoning_effort, Some(ReasoningEffort::High)); + Ok(()) + } + + #[test] fn chat_many_msgs_with_system_prompt() -> Result<()> { let args = cli() .try_get_matches_from(vec![ blob - 6bfac73a4c54241803810f4357ebad5081198868 blob + 385cb933f50e88ff173b340a8a8252a807509bab --- src/exec/chat.rs +++ src/exec/chat.rs @@ -1,7 +1,7 @@ //! Implements chat subcommand use crate::cli::{Invocation, OutputFormat}; -use crate::openai::{self, ChatCompletion, FinishReason, Message}; +use crate::openai::{self, FinishReason, Message, Reasoning, Response}; use crate::parse; use crate::Error; @@ -26,17 +26,25 @@ pub async fn exec(args: Invocation) -> Result<()> { return Err(Error::NoMessagesProvided.into()); } - // TODO: Lifetimes for `ChatCompletionRequest` fields - let request = openai::ChatCompletionRequest::builder() + // TODO: Lifetimes for `ResponseRequest` fields + let mut builder = openai::ResponseRequest::builder(); + + builder .model(args.model.clone()) .messages(msgs) .temperature(args.temperature) - .timeout(args.timeout) + .timeout(args.timeout); + + if let Some(effort) = args.reasoning_effort { + builder.reasoning(Some(Reasoning::from_effort(effort))); + } + + let request = builder .build() .with_context(|| "failed to create request")?; let res = client - .chat_complete(&request) + .create_response(&request) .await .with_context(|| "failed to fetch request")?; @@ -64,8 +72,8 @@ fn read_messages_from_file(file: &str) -> Result Result<(), Error> { +/// Show formatted output for a Responses API result +fn show_response(dest: impl Write, args: &Invocation, resp: &Response) -> Result<(), Error> { let mut writer = BufWriter::new(dest); let choice = match resp.choices.len() { 1 => &resp.choices[0], @@ -109,7 +117,7 @@ mod test { use crate::{ cli::{Invocation, InvocationBuilder, OutputFormat}, - openai::{ChatCompletion, ChatCompletionBuilder, Choice, FinishReason, Message, Usage}, + openai::{Choice, FinishReason, Message, Response, ResponseBuilder, Usage}, }; use super::*; @@ -195,8 +203,8 @@ mod test { .to_owned() } - fn default_resp() -> ChatCompletionBuilder { - ChatCompletion::builder() + fn default_resp() -> ResponseBuilder { + Response::builder() .created(DateTime::default()) .choices(vec![]) .model(String::default()) blob - b9f70ce1fb66f6137ea849a3e439e7b8d2c909c9 blob + bf70c9c85ba7ea62c5c56807752895d0c3893300 --- src/openai.rs +++ src/openai.rs @@ -1,5 +1,6 @@ //! Interactions with OpenAI APIs +use std::convert::TryFrom; use std::time::Duration; use crate::Error; @@ -8,9 +9,9 @@ use chrono::{DateTime, Utc}; use derive_builder::Builder; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{json, Value}; -/// Convienience Client for OpenAI Chat Completions API +/// Convienience Client for OpenAI Responses API pub struct Client { /// Inner client client: reqwest::Client, @@ -20,20 +21,22 @@ pub struct Client { base_url: String, } -/// Requests for chat_completion -/// Reference: +/// Requests for the Responses API +/// Reference: #[derive(Builder, Default)] -pub struct ChatCompletionRequest { +pub struct ResponseRequest { model: String, messages: Vec, temperature: f32, timeout: Duration, + #[builder(default)] + reasoning: Option, } -/// Responses from chat_completion -/// Reference: +/// Responses from the Responses API +/// Reference: #[derive(Builder, Default, Debug, Serialize, Deserialize)] -pub struct ChatCompletion { +pub struct Response { #[serde(with = "ts_seconds")] pub created: DateTime, pub choices: Vec, @@ -57,7 +60,7 @@ struct APIErrorContainer { error: APIError, } -/// Messages in chat completion request and response +/// Messages in Responses API request and response #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] pub struct Message { pub role: Role, @@ -74,11 +77,30 @@ pub enum Role { #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] pub struct Usage { - pub prompt_tokens: u32, - pub completion_tokens: u32, + pub input_tokens: u32, + pub output_tokens: u32, pub total_tokens: u32, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Reasoning { + pub effort: ReasoningEffort, +} + +impl Reasoning { + pub fn from_effort(effort: ReasoningEffort) -> Self { + Self { effort } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningEffort { + Low, + Medium, + High, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FinishReason { @@ -106,32 +128,26 @@ impl Client { }) } - pub async fn chat_complete( - &self, - request: &ChatCompletionRequest, - ) -> Result { + pub async fn create_response(&self, request: &ResponseRequest) -> Result { let api_key = &self.api_key.as_ref().ok_or(Error::NoAPIKey)?; - let model = &request.model; let resp = self .client - .post(self.chat_endpoint()) + .post(self.responses_endpoint()) .bearer_auth(api_key) .timeout(request.timeout) .header("Content-Type", "application/json") - .json(&json!({ - "model": model, - "messages": request.messages, - "temperature": request.temperature, - })) + .json(&request.to_payload()) .send() .await .map_err(Error::FailedToFetch)?; match resp.status() { StatusCode::OK => { - let res: ChatCompletion = resp.json().await.map_err(Error::FailedToFetch)?; - Ok(res) + let responses: ResponsesAPIResponse = + resp.json().await.map_err(Error::FailedToFetch)?; + let response = Response::try_from(responses).map_err(Error::UnexpectedResponse)?; + Ok(response) } _ => { let error = resp @@ -144,8 +160,8 @@ impl Client { } } - fn chat_endpoint(&self) -> String { - format!("{}{}", self.base_url, "/v1/chat/completions") + fn responses_endpoint(&self) -> String { + format!("{}{}", self.base_url, "/v1/responses") } } @@ -170,47 +186,210 @@ impl Message { } } -impl ChatCompletionRequest { - pub fn builder() -> ChatCompletionRequestBuilder { - ChatCompletionRequestBuilder::default() +impl Message { + fn to_responses_input(&self) -> serde_json::Value { + json!({ + "role": self.role.as_str(), + "content": [{ + "type": "text", + "text": self.content.clone(), + }] + }) } } -impl ChatCompletion { - pub fn builder() -> ChatCompletionBuilder { - ChatCompletionBuilder::default() +impl Role { + fn as_str(&self) -> &'static str { + match self { + Role::System => "system", + Role::Assistant => "assistant", + Role::User => "user", + } } } +impl ResponseRequest { + pub fn builder() -> ResponseRequestBuilder { + ResponseRequestBuilder::default() + } + + fn to_payload(&self) -> Value { + let input = self + .messages + .iter() + .map(Message::to_responses_input) + .collect::>(); + + let mut payload = json!({ + "model": self.model, + "input": input, + "temperature": self.temperature, + }); + + if let Some(reasoning) = &self.reasoning { + if let Some(obj) = payload.as_object_mut() { + obj.insert( + "reasoning".to_string(), + serde_json::to_value(reasoning).expect("failed to serialize reasoning"), + ); + } + } + + payload + } +} + +impl Response { + pub fn builder() -> ResponseBuilder { + ResponseBuilder::default() + } +} + +#[derive(Debug, Deserialize)] +struct ResponsesAPIResponse { + #[serde(with = "ts_seconds")] + created: DateTime, + model: String, + #[serde(default)] + output: Vec, + usage: ResponsesUsage, +} + +#[derive(Debug, Deserialize)] +struct ResponsesUsage { + #[serde(default)] + input_tokens: u32, + #[serde(default)] + output_tokens: u32, + #[serde(default)] + total_tokens: u32, +} + +#[derive(Debug, Deserialize)] +struct ResponseOutput { + #[serde(rename = "type")] + item_type: String, + role: Option, + #[serde(default)] + content: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ResponseContent { + OutputText { + text: String, + }, + Text { + text: String, + }, + #[serde(other)] + Other, +} + +impl ResponseContent { + fn as_text(&self) -> Option<&str> { + match self { + ResponseContent::OutputText { text } => Some(text), + ResponseContent::Text { text } => Some(text), + ResponseContent::Other => None, + } + } +} + +impl ResponseOutput { + fn aggregated_text(&self) -> Option { + let mut aggregated = String::new(); + + for part in &self.content { + if let Some(text) = part.as_text() { + aggregated.push_str(text); + } + } + + if aggregated.is_empty() { + None + } else { + Some(aggregated) + } + } +} + +impl TryFrom for Response { + type Error = String; + + fn try_from(value: ResponsesAPIResponse) -> Result { + let mut choices = Vec::new(); + + for output in value.output.into_iter() { + if output.item_type != "message" { + continue; + } + + let text = output + .aggregated_text() + .ok_or_else(|| "response message missing text content".to_string())?; + + let message = Message { + role: output.role.unwrap_or(Role::Assistant), + content: text, + }; + + choices.push(Choice { + message, + finish_reason: FinishReason::Stop, + }); + } + + if choices.is_empty() { + return Err("response did not contain any assistant messages".to_string()); + } + + Ok(Response { + created: value.created, + choices, + model: value.model, + usage: Usage { + input_tokens: value.usage.input_tokens, + output_tokens: value.usage.output_tokens, + total_tokens: value.usage.total_tokens, + }, + }) + } +} + #[cfg(test)] mod test { use super::*; use anyhow::Result; use chrono::TimeZone; + use std::time::Duration; #[test] - fn parse_chat_completion_response() -> Result<()> { + fn parse_response_payload() -> Result<()> { let data = r#"{ "created": 1688413145, - "model": "gpt-3.5-turbo-0613", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Hello! How can I assist you today?" - }, - "finish_reason": "stop" + "model": "gpt-5", + "output": [{ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "Hello! How can I assist you today?" + }] }], "usage": { - "prompt_tokens": 8, - "completion_tokens": 9, + "input_tokens": 8, + "output_tokens": 9, "total_tokens": 17 } } "#; - let resp = serde_json::from_str::(data)?; + let resp = serde_json::from_str::(data)?; + let resp = Response::try_from(resp).map_err(|e| anyhow::anyhow!(e))?; assert_eq!(resp.created, Utc.timestamp_opt(1688413145, 0).unwrap()); assert_eq!( @@ -223,12 +402,12 @@ mod test { finish_reason: FinishReason::Stop }] ); - assert_eq!(resp.model, "gpt-3.5-turbo-0613"); + assert_eq!(resp.model, "gpt-5"); assert_eq!( resp.usage, Usage { - prompt_tokens: 8, - completion_tokens: 9, + input_tokens: 8, + output_tokens: 9, total_tokens: 17, } ); @@ -237,8 +416,108 @@ mod test { } #[test] - fn parse_chat_completion_error() -> Result<()> { + fn response_payload_includes_reasoning() -> Result<()> { + let request = ResponseRequest::builder() + .model("gpt-5".to_string()) + .messages(vec![Message::user("Hello")]) + .temperature(0.0) + .timeout(Duration::from_secs(30)) + .reasoning(Some(Reasoning::from_effort(ReasoningEffort::High))) + .build() + .expect("request builds"); + + let payload = request.to_payload(); + + assert_eq!(payload["reasoning"]["effort"], "high"); + assert_eq!(payload["model"], "gpt-5"); + + Ok(()) + } + + #[test] + fn response_payload_omits_reasoning_when_not_set() -> Result<()> { + let request = ResponseRequest::builder() + .model("gpt-5".to_string()) + .messages(vec![Message::user("Hello")]) + .temperature(0.0) + .timeout(Duration::from_secs(30)) + .build() + .expect("request builds"); + + let payload = request.to_payload(); + + assert!(payload.get("reasoning").is_none()); + Ok(()) + } + + #[test] + fn response_try_from_concatenates_segments() -> Result<()> { let data = r#"{ + "created": 1688413145, + "model": "gpt-5", + "output": [{ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [{ + "type": "text", + "text": "Hello" + }, { + "type": "output_text", + "text": " world" + }] + }], + "usage": { + "input_tokens": 3, + "output_tokens": 4, + "total_tokens": 7 + } + } + "#; + + let resp = serde_json::from_str::(data)?; + let resp = Response::try_from(resp).map_err(|e| anyhow::anyhow!(e))?; + + assert_eq!(resp.choices.len(), 1); + assert_eq!(resp.choices[0].message.content, "Hello world"); + Ok(()) + } + + #[test] + fn response_try_from_errors_without_assistant_message() -> Result<()> { + let data = r#"{ + "created": 1688413145, + "model": "gpt-5", + "output": [{ + "id": "reasoning_1", + "type": "reasoning", + "content": [{ + "type": "text", + "text": "Thinking..." + }] + }], + "usage": { + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2 + } + } + "#; + + let resp = serde_json::from_str::(data)?; + let err = Response::try_from(resp).expect_err("should error"); + + assert!( + err.contains("did not contain any assistant messages"), + "unexpected error text: {err}" + ); + + Ok(()) + } + + #[test] + fn parse_response_error() -> Result<()> { + let data = r#"{ "error": { "message": "An error message", "type": "invalid_request_error", blob - 34fc261088ffad50b267ea437bfbc819576442a5 blob + 05b4e04c7c77c264b5006ba51802f1006384b482 --- tests/chat.rs +++ tests/chat.rs @@ -31,31 +31,36 @@ fn chat_user_message_from_flag() { let mut server = mockito::Server::new(); let mock = server - .mock("POST", "/v1/chat/completions") + .mock("POST", "/v1/responses") .with_header("content-type", "application/json") .with_header("authorization", "Bearer ABCDE") .match_body(mockito::Matcher::PartialJson(json!({ - "messages": [{ - "content": "Hello", - "role": "user", + "model": "gpt-5", + "input": [{ + "role": "user", + "content": [{ + "type": "text", + "text": "Hello" + }] }] }))) .with_body( r#"{ - "id": "chatcmpl-XXXXX", + "id": "resp_XXXXX", "created": 1688413145, - "model": "gpt-3.5-turbo-0613", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "ASSISTANT REPLY" - }, - "finish_reason": "stop" + "model": "gpt-5", + "output": [{ + "id": "msg_XXXXX", + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "ASSISTANT REPLY" + }] }], "usage": { - "prompt_tokens": 8, - "completion_tokens": 9, + "input_tokens": 8, + "output_tokens": 9, "total_tokens": 17 } }"#, @@ -80,34 +85,39 @@ fn chat_user_message_from_stdin() { let mut server = mockito::Server::new(); let mock = server - .mock("POST", "/v1/chat/completions") + .mock("POST", "/v1/responses") .with_header("content-type", "application/json") .with_header("authorization", "Bearer ABCDE") .match_body(mockito::Matcher::PartialJson(json!({ - "messages": [{ - "content": "Hello", - "role": "user", + "model": "gpt-5", + "input": [{ + "role": "user", + "content": [{ + "type": "text", + "text": "Hello" + }] }] }))) .with_body( r#"{ - "id": "chatcmpl-XXXXX", + "id": "resp_XXXXX", "created": 1688413145, - "model": "gpt-3.5-turbo-0613", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "ASSISTANT REPLY" - }, - "finish_reason": "stop" + "model": "gpt-5", + "output": [{ + "id": "msg_XXXXX", + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "ASSISTANT REPLY" + }] }], - "usage": { - "prompt_tokens": 8, - "completion_tokens": 9, - "total_tokens": 17 - } - }"#, + "usage": { + "input_tokens": 8, + "output_tokens": 9, + "total_tokens": 17 + } + }"#, ) .create(); @@ -124,6 +134,63 @@ fn chat_user_message_from_stdin() { .stdout(predicate::str::contains("ASSISTANT REPLY")); } +#[test] +fn chat_with_reasoning_effort() { + let mut server = mockito::Server::new(); + + let mock = server + .mock("POST", "/v1/responses") + .with_header("content-type", "application/json") + .with_header("authorization", "Bearer ABCDE") + .match_body(mockito::Matcher::PartialJson(json!({ + "model": "gpt-5", + "reasoning": { + "effort": "medium" + }, + "input": [{ + "role": "user", + "content": [{ + "type": "text", + "text": "Hello" + }] + }] + }))) + .with_body( + r#"{ + "id": "resp_XXXXX", + "created": 1688413145, + "model": "gpt-5", + "output": [{ + "id": "msg_XXXXX", + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "ASSISTANT REPLY" + }] + }], + "usage": { + "input_tokens": 8, + "output_tokens": 9, + "total_tokens": 17 + } + }"#, + ) + .create(); + + let cmd = Command::cargo_bin("cogni") + .unwrap() + .args(["-u", "Hello", "--reasoning-effort", "medium"]) + .env("OPENAI_API_ENDPOINT", server.url()) + .env("OPENAI_API_KEY", "ABCDE") + .assert(); + + mock.assert(); + + cmd.success() + .stdout(predicate::str::contains("ASSISTANT REPLY")); +} + /// Test messages provided via /// - System message flag /// - Assistant message flag @@ -134,46 +201,66 @@ fn chat_multiple_messages() { let mut server = mockito::Server::new(); let mock = server - .mock("POST", "/v1/chat/completions") + .mock("POST", "/v1/responses") .with_header("content-type", "application/json") .with_header("authorization", "Bearer ABCDE") .match_body(mockito::Matcher::PartialJson(json!({ - "messages": [{ + "model": "gpt-5", + "input": [{ "role": "system", - "content": "SYSTEM", + "content": [{ + "type": "text", + "text": "SYSTEM" + }], }, { "role": "user", - "content": "USER_1", + "content": [{ + "type": "text", + "text": "USER_1" + }], }, { "role": "assistant", - "content": "ASSI_1", + "content": [{ + "type": "text", + "text": "ASSI_1" + }], }, { "role": "user", - "content": "USER_2", + "content": [{ + "type": "text", + "text": "USER_2" + }], }, { "role": "assistant", - "content": "ASSI_2", + "content": [{ + "type": "text", + "text": "ASSI_2" + }], }, { "role": "user", - "content": "USER_STDIN", + "content": [{ + "type": "text", + "text": "USER_STDIN" + }], }] }))) .with_body( r#"{ - "id": "chatcmpl-XXXXX", + "id": "resp_XXXXX", "created": 1688413145, - "model": "gpt-3.5-turbo-0613", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "ASSISTANT REPLY" - }, - "finish_reason": "stop" + "model": "gpt-5", + "output": [{ + "id": "msg_XXXXX", + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "ASSISTANT REPLY" + }] }], "usage": { - "prompt_tokens": 8, - "completion_tokens": 9, + "input_tokens": 8, + "output_tokens": 9, "total_tokens": 17 } }"#, @@ -202,14 +289,18 @@ fn chat_api_error() { let mut server = mockito::Server::new(); let mock = server - .mock("POST", "/v1/chat/completions") + .mock("POST", "/v1/responses") .with_status(400) .with_header("content-type", "application/json") .with_header("authorization", "Bearer ABCDE") .match_body(mockito::Matcher::PartialJson(json!({ - "messages": [{ + "model": "gpt-5", + "input": [{ "role": "user", - "content": "USER", + "content": [{ + "type": "text", + "text": "USER" + }], }], "temperature": 1000.0 // invalid param }))) @@ -249,31 +340,36 @@ fn chat_user_message_from_file() { infile.write_str("Hello from file").unwrap(); let mock = server - .mock("POST", "/v1/chat/completions") + .mock("POST", "/v1/responses") .with_header("content-type", "application/json") .with_header("authorization", "Bearer ABCDE") .match_body(mockito::Matcher::PartialJson(json!({ - "messages": [{ + "model": "gpt-5", + "input": [{ "role": "user", - "content": "Hello from file", + "content": [{ + "type": "text", + "text": "Hello from file" + }], }], }))) .with_body( r#"{ - "id": "chatcmpl-XXXXX", + "id": "resp_XXXXX", "created": 1688413145, - "model": "gpt-3.5-turbo-0613", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "ASSISTANT REPLY" - }, - "finish_reason": "stop" + "model": "gpt-5", + "output": [{ + "id": "msg_XXXXX", + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "ASSISTANT REPLY" + }] }], "usage": { - "prompt_tokens": 8, - "completion_tokens": 9, + "input_tokens": 8, + "output_tokens": 9, "total_tokens": 17 } }"#,