From c9099feb98c6c659ac6ed9af32cb58c073b71798 Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Mon, 24 Nov 2025 12:37:00 +0100 Subject: [PATCH 1/4] Introduce completion targest (text/structured gen) --- src/core/builder.rs | 74 +++++++++++++++++++++++++++++---------------- src/core/traits.rs | 26 +++++++++++++--- src/core/types.rs | 43 +++++++++++++++++++++++++- 3 files changed, 112 insertions(+), 31 deletions(-) diff --git a/src/core/builder.rs b/src/core/builder.rs index 6231dad..abf3091 100644 --- a/src/core/builder.rs +++ b/src/core/builder.rs @@ -1,7 +1,5 @@ use std::{env, marker::PhantomData}; -use serde::de::Deserialize; - use tracing::{debug, instrument}; use crate::{ @@ -13,8 +11,8 @@ use super::{ error::LlmError, traits::LlmProvider, types::{ - ConversationMessage, GenerationConfig, Message, StructuredRequest, StructuredResponse, - ToolChoice, ToolConfig, ToolRegistry, + ConversationMessage, GenerationConfig, Message, StructuredRequest, ToolChoice, ToolConfig, + ToolRegistry, }, }; @@ -206,14 +204,14 @@ impl LlmBuilder { self } - /// Execute the LLM request and return structured output of type T. - /// The type T must implement Deserialize and JsonSchema for structured output generation as well as - /// be annotated with `#[schemars(deny_unknown_fields)]`. - /// Use the `completion_schema` attribute macro to easily define structured output types. + /// Execute the LLM request and return an output defined by `T`. + /// + /// The target type `T` must implement [`CompletionTarget`]. Structured schemas can be created + /// with the `#[completion_schema]` macro, while plain text responses can use [`TextResponse`]. /// /// # Example /// ```no_run - /// # use rsai::{completion_schema, llm, Message, ChatRole, ApiKey, Provider}; + /// # use rsai::{completion_schema, llm, Message, ChatRole, ApiKey, Provider, TextResponse}; /// # #[tokio::main] /// # async fn main() -> Result<(), Box> { /// #[completion_schema] @@ -231,11 +229,21 @@ impl LlmBuilder { /// }]) /// .complete::() /// .await?; + /// + /// let text = llm::with(Provider::OpenAI) + /// .api_key(ApiKey::Default)? + /// .model("gpt-4o-mini") + /// .messages(vec![Message { + /// role: ChatRole::User, + /// content: "Say hello".to_string(), + /// }]) + /// .complete::() + /// .await?; /// # Ok(()) /// # } /// ``` #[instrument( - name = "generate_structured", + name = "generate_completion", skip(self), fields( model = ?self.fields.model, @@ -244,23 +252,33 @@ impl LlmBuilder { ), err )] - pub async fn complete(self) -> Result, LlmError> + pub async fn complete(self) -> Result where - T: for<'a> Deserialize<'a> + Send + schemars::JsonSchema, + T: super::traits::CompletionTarget + Send, { - debug!("Starting structured generation request"); + debug!("Starting generation request"); let (messages, provider, model) = self.fields.validate()?; let model_string = model.to_string(); let messages = messages.to_vec(); + let format = T::format()?; + + if !T::supports_tools() && self.fields.tool_registry.is_some() { + return Err(LlmError::Builder( + "Tools are only supported with structured completion targets".to_string(), + )); + } // Deferred error handling for tool registry errors in case of a poisoned lock. - let tool_schemas = self - .fields - .tool_registry - .as_ref() - .map(|registry| registry.get_schemas()) - .transpose()? - .map(|tools| tools.into_boxed_slice()); + let tool_schemas = if T::supports_tools() { + self.fields + .tool_registry + .as_ref() + .map(|registry| registry.get_schemas()) + .transpose()? + .map(|tools| tools.into_boxed_slice()) + } else { + None + }; match provider { Provider::OpenAI => { @@ -272,8 +290,8 @@ impl LlmBuilder { let req = StructuredRequest { model: model_string, messages: conversation_messages, - tool_config: Some(ToolConfig { - tools: tool_schemas.clone(), + tool_config: tool_schemas.map(|tools| ToolConfig { + tools: Some(tools), tool_choice: self.fields.tool_choice.clone(), parallel_tool_calls: self.fields.parallel_tool_calls, }), @@ -285,7 +303,11 @@ impl LlmBuilder { }; let client = openai::create_openai_client_from_builder(&self)?; client - .generate_structured(req, self.fields.tool_registry.as_ref()) + .generate_completion::( + req, + format.clone(), + self.fields.tool_registry.as_ref(), + ) .await } Provider::OpenRouter => { @@ -297,8 +319,8 @@ impl LlmBuilder { let req = StructuredRequest { model: model_string, messages: conversation_messages, - tool_config: Some(ToolConfig { - tools: tool_schemas.clone(), + tool_config: tool_schemas.map(|tools| ToolConfig { + tools: Some(tools), tool_choice: self.fields.tool_choice.clone(), parallel_tool_calls: self.fields.parallel_tool_calls, }), @@ -310,7 +332,7 @@ impl LlmBuilder { }; let client = openrouter::create_openrouter_client_from_builder(&self)?; client - .generate_structured(req, self.fields.tool_registry.as_ref()) + .generate_completion::(req, format, self.fields.tool_registry.as_ref()) .await } } diff --git a/src/core/traits.rs b/src/core/traits.rs index f878325..fe2b231 100644 --- a/src/core/traits.rs +++ b/src/core/traits.rs @@ -1,19 +1,25 @@ use async_trait::async_trait; +use crate::{ + Provider, + responses::{request::Format, response::Response}, +}; + use super::{ error::LlmError, - types::{BoxFuture, StructuredRequest, StructuredResponse, Tool, ToolRegistry}, + types::{BoxFuture, StructuredRequest, Tool, ToolRegistry}, }; #[async_trait] pub trait LlmProvider { - async fn generate_structured( + async fn generate_completion( &self, request: StructuredRequest, + format: Format, tool_registry: Option<&ToolRegistry>, - ) -> Result, LlmError> + ) -> Result where - T: serde::de::DeserializeOwned + Send + schemars::JsonSchema; + T: CompletionTarget + Send; } pub trait ToolFunction: Send + Sync { @@ -23,3 +29,15 @@ pub trait ToolFunction: Send + Sync { params: serde_json::Value, ) -> BoxFuture<'a, Result>; } + +pub trait CompletionTarget: Sized + Send { + type Output; + + fn format() -> Result; + + fn parse_response(res: Response, provider: Provider) -> Result; + + fn supports_tools() -> bool { + true + } +} diff --git a/src/core/types.rs b/src/core/types.rs index 193fc35..e44e618 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -1,5 +1,8 @@ -use crate::core::{LlmError, traits::ToolFunction}; +use crate::core::{LlmError, traits::CompletionTarget, traits::ToolFunction}; use crate::provider::Provider; +use crate::responses::{self, request::Format, response::Response}; +use schemars::JsonSchema; +use serde::de::DeserializeOwned; use serde_json::Value; use std::collections::HashMap; use std::future::Future; @@ -97,6 +100,13 @@ pub struct StructuredResponse { pub metadata: ResponseMetadata, } +#[derive(Debug, Clone, PartialEq)] +pub struct TextResponse { + pub text: String, + pub usage: LanguageModelUsage, + pub metadata: ResponseMetadata, +} + #[derive(Debug, Clone, PartialEq)] pub struct LanguageModelUsage { pub prompt_tokens: i32, @@ -255,6 +265,37 @@ impl ToolSet { } } +impl CompletionTarget for T +where + T: DeserializeOwned + JsonSchema + Send, +{ + type Output = StructuredResponse; + + fn format() -> Result { + responses::create_format_for_type::() + } + + fn parse_response(res: Response, provider: Provider) -> Result { + responses::create_core_structured_response(res, provider) + } +} + +impl CompletionTarget for TextResponse { + type Output = TextResponse; + + fn format() -> Result { + Ok(responses::create_text_format()) + } + + fn parse_response(res: Response, provider: Provider) -> Result { + responses::create_core_text_response(res, provider) + } + + fn supports_tools() -> bool { + false + } +} + #[cfg(test)] mod tests { use async_trait::async_trait; From 01ac8221d5f251422c44de4c625e63b492077e42 Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Mon, 24 Nov 2025 13:35:40 +0100 Subject: [PATCH 2/4] Enable text generation for the responses API by passing the response format throught the functions. --- src/core.rs | 6 +- src/lib.rs | 8 ++- src/provider/openai.rs | 18 +++--- src/provider/openrouter.rs | 18 +++--- src/responses/client.rs | 111 +++++++++++++++++++++++++++++-------- src/responses/request.rs | 4 -- tests/client_loop_tests.rs | 29 ++++++++-- 7 files changed, 138 insertions(+), 56 deletions(-) diff --git a/src/core.rs b/src/core.rs index 1fbe1cc..874fe5b 100644 --- a/src/core.rs +++ b/src/core.rs @@ -8,11 +8,11 @@ pub use builder::{ApiKey, LlmBuilder, llm}; pub use error::LlmError; pub use tool_guard::{ToolCallingConfig, ToolCallingGuard}; -pub use traits::{LlmProvider, ToolFunction}; +pub use traits::{CompletionTarget, LlmProvider, ToolFunction}; pub use types::StructuredRequest; pub use types::{ BoxFuture, ChatRole, ConversationMessage, GenerationConfig, LanguageModelUsage, Message, - ResponseMetadata, StructuredResponse, Tool, ToolCall, ToolCallResult, ToolChoice, ToolConfig, - ToolRegistry, ToolSet, + ResponseMetadata, StructuredResponse, TextResponse, Tool, ToolCall, ToolCallResult, ToolChoice, + ToolConfig, ToolRegistry, ToolSet, }; diff --git a/src/lib.rs b/src/lib.rs index c14167d..3343a65 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,10 +44,12 @@ pub use core::{ToolCallingConfig, ToolCallingGuard}; // Configuration types pub use core::{ApiKey, GenerationConfig, LlmBuilder, ToolChoice, ToolConfig}; -pub use responses::HttpClientConfig; +pub use responses::{Format, HttpClientConfig}; // Response types -pub use core::{LanguageModelUsage, ResponseMetadata, StructuredRequest, StructuredResponse}; +pub use core::{ + LanguageModelUsage, ResponseMetadata, StructuredRequest, StructuredResponse, TextResponse, +}; // Async helpers pub use core::BoxFuture; @@ -63,7 +65,7 @@ pub use core::llm; pub use provider::{OpenAiClient, OpenAiConfig, OpenRouterClient, OpenRouterConfig, Provider}; // Traits -pub use core::{LlmProvider, ToolFunction}; +pub use core::{CompletionTarget, LlmProvider, ToolFunction}; // Macros from `rsai-macros` pub use rsai_macros::{completion_schema, tool, toolset}; diff --git a/src/provider/openai.rs b/src/provider/openai.rs index 3e93bbe..a4660f1 100644 --- a/src/provider/openai.rs +++ b/src/provider/openai.rs @@ -14,8 +14,8 @@ use crate::provider::constants::openai; use crate::core::{ - LlmBuilder, LlmError, LlmProvider, StructuredRequest, StructuredResponse, ToolCallingConfig, - ToolCallingGuard, ToolRegistry, + LlmBuilder, LlmError, LlmProvider, StructuredRequest, ToolCallingConfig, ToolCallingGuard, + ToolRegistry, }; use crate::responses::{HttpClientConfig, ResponsesClient, ResponsesProviderConfig}; use async_trait::async_trait; @@ -151,13 +151,14 @@ impl OpenAiClient { #[async_trait] impl LlmProvider for OpenAiClient { - async fn generate_structured( + async fn generate_completion( &self, request: StructuredRequest, + format: crate::responses::Format, tool_registry: Option<&ToolRegistry>, - ) -> Result, LlmError> + ) -> Result where - T: serde::de::DeserializeOwned + Send + schemars::JsonSchema, + T: crate::CompletionTarget + Send, { // If tools are present and we have a registry, handle automatic tool calling let has_tools = request @@ -170,21 +171,22 @@ impl LlmProvider for OpenAiClient { let mut guard = self.responses_client.config.get_tool_calling_guard(); return self .responses_client - .handle_tool_calling_loop(request, tool_registry, &mut guard) + .handle_tool_calling_loop::(request, tool_registry, &mut guard, format) .await; } // Otherwise, make a single request expecting structured content let messages_clone = request.messages.clone(); - let responses_request = self.responses_client.build_request::( + let responses_request = self.responses_client.build_request_with_format( &request, &crate::responses::convert_messages_to_responses_format(messages_clone)?, + format, )?; let api_response = self .responses_client .make_api_request(responses_request) .await?; - crate::responses::create_core_structured_response(api_response, super::Provider::OpenAI) + T::parse_response(api_response, super::Provider::OpenAI) } } diff --git a/src/provider/openrouter.rs b/src/provider/openrouter.rs index e2a5950..32f0e19 100644 --- a/src/provider/openrouter.rs +++ b/src/provider/openrouter.rs @@ -15,8 +15,8 @@ use crate::provider::constants::openrouter; use crate::responses::{HttpClientConfig, ResponsesClient, ResponsesProviderConfig}; use crate::core::{ - LlmBuilder, LlmError, LlmProvider, StructuredRequest, StructuredResponse, ToolCallingConfig, - ToolCallingGuard, ToolRegistry, + LlmBuilder, LlmError, LlmProvider, StructuredRequest, ToolCallingConfig, ToolCallingGuard, + ToolRegistry, }; use async_trait::async_trait; @@ -200,13 +200,14 @@ impl OpenRouterClient { #[async_trait] impl LlmProvider for OpenRouterClient { - async fn generate_structured( + async fn generate_completion( &self, request: StructuredRequest, + format: crate::responses::Format, tool_registry: Option<&ToolRegistry>, - ) -> Result, LlmError> + ) -> Result where - T: serde::de::DeserializeOwned + Send + schemars::JsonSchema, + T: crate::CompletionTarget + Send, { // If tools are present and we have a registry, handle automatic tool calling let has_tools = request @@ -219,21 +220,22 @@ impl LlmProvider for OpenRouterClient { let mut guard = self.responses_client.config.get_tool_calling_guard(); return self .responses_client - .handle_tool_calling_loop(request, tool_registry, &mut guard) + .handle_tool_calling_loop::(request, tool_registry, &mut guard, format) .await; } // Otherwise, make a single request expecting structured content let messages_clone = request.messages.clone(); - let responses_request = self.responses_client.build_request::( + let responses_request = self.responses_client.build_request_with_format( &request, &crate::responses::convert_messages_to_responses_format(messages_clone)?, + format, )?; let api_response = self .responses_client .make_api_request(responses_request) .await?; - crate::responses::create_core_structured_response(api_response, super::Provider::OpenRouter) + T::parse_response(api_response, super::Provider::OpenRouter) } } diff --git a/src/responses/client.rs b/src/responses/client.rs index 16ebce9..00f0007 100644 --- a/src/responses/client.rs +++ b/src/responses/client.rs @@ -10,13 +10,15 @@ use std::time::Duration; use crate::{ - Provider, + CompletionTarget, Provider, core::{ ChatRole, ConversationMessage, LanguageModelUsage, LlmError, ResponseMetadata, - StructuredRequest, StructuredResponse, Tool, ToolCall, ToolCallingGuard, ToolRegistry, + StructuredRequest, StructuredResponse, TextResponse, Tool, ToolCall, ToolCallingGuard, + ToolRegistry, }, responses::{ Format, FormatType, FunctionToolCall, FunctionToolCallOutput, JsonSchema, JsonSchemaType, + TextType, request::{InputItem, InputMessage, InputMessageRole, Request}, response::{MessageContent, OutputContent, Response}, }, @@ -210,16 +212,17 @@ impl ResponsesClient

{ request: StructuredRequest, tool_registry: &ToolRegistry, guard: &mut ToolCallingGuard, - ) -> Result, LlmError> + format: Format, + ) -> Result where - T: serde::de::DeserializeOwned + Send + schemars::JsonSchema, + T: CompletionTarget, { let timeout_duration = guard.timeout; // Use tokio::time::timeout to add timeout protection match tokio::time::timeout( timeout_duration, - self.handle_tool_calling_loop_internal(request, tool_registry, guard), + self.handle_tool_calling_loop_internal::(request, tool_registry, guard, format), ) .await { @@ -246,9 +249,10 @@ impl ResponsesClient

{ request: StructuredRequest, tool_registry: &ToolRegistry, guard: &mut ToolCallingGuard, - ) -> Result, LlmError> + format: Format, + ) -> Result where - T: serde::de::DeserializeOwned + Send + schemars::JsonSchema, + T: CompletionTarget, { let mut responses_input = convert_messages_to_responses_format(request.messages.clone())?; let is_parallel = request @@ -265,14 +269,15 @@ impl ResponsesClient

{ tracing::debug_span!("tool_loop_iteration", iteration = guard.current_iteration()); let _enter = iteration_span.enter(); - let responses_request = self.build_request::(&request, &responses_input)?; + let responses_request = + self.build_request_with_format(&request, &responses_input, format.clone())?; let api_response = self.make_api_request(responses_request).await?; let function_calls = self.extract_function_calls(&api_response); if function_calls.is_empty() { tracing::debug!("No more tool calls, returning final response"); - return create_core_structured_response(api_response, self.config.provider()); + return T::parse_response(api_response, self.config.provider()); } tracing::info!( @@ -291,15 +296,13 @@ impl ResponsesClient

{ } /// Build a responses API request from core request and input - pub fn build_request( + pub fn build_request_with_format( &self, request: &StructuredRequest, responses_input: &[InputItem], - ) -> Result - where - T: schemars::JsonSchema, - { - build_request_payload::(request, responses_input) + format: Format, + ) -> Result { + build_request_payload_with_format(request, responses_input, format) } /// Extract function calls from API response @@ -421,17 +424,16 @@ impl ResponsesClient

{ } } -pub(crate) fn build_request_payload( +// This is a separate method to `build_request_with_format` for testing. +pub(crate) fn build_request_payload_with_format( request: &StructuredRequest, responses_input: &[InputItem], -) -> Result -where - T: schemars::JsonSchema, -{ + format: Format, +) -> Result { let mut req = Request { model: request.model.clone(), input: responses_input.to_vec(), - text: create_format_for_type::()?, + text: format, // Default fields parallel_tool_calls: None, temperature: None, @@ -603,6 +605,14 @@ pub(crate) fn create_format_from_value( }) } +pub(crate) fn create_text_format() -> Format { + Format { + format: FormatType::Text { + r#type: TextType::Text, + }, + } +} + /// Convert API response to core structured response with specified provider pub(crate) fn create_core_structured_response( res: Response, @@ -666,6 +676,55 @@ where } } +/// Convert API response to a plain text response +pub(crate) fn create_core_text_response( + res: Response, + provider: crate::provider::Provider, +) -> Result { + let output_content = res.output.first().ok_or_else(|| LlmError::Provider { + message: "No output in response".to_string(), + source: None, + })?; + + match output_content { + OutputContent::OutputMessage(message) => { + let content = message.content.first().ok_or_else(|| LlmError::Provider { + message: "No content in message".to_string(), + source: None, + })?; + + let text = match content { + MessageContent::OutputText(output) => output.text.clone(), + MessageContent::Refusal(refusal) => { + return Err(LlmError::Api { + message: format!("Model refused: {}", refusal.refusal), + status_code: None, + source: None, + }); + } + }; + + Ok(TextResponse { + text, + usage: LanguageModelUsage { + prompt_tokens: res.usage.input_tokens, + completion_tokens: res.usage.output_tokens, + total_tokens: res.usage.total_tokens, + }, + metadata: ResponseMetadata { + provider, + model: res.model, + id: res.id, + }, + }) + } + OutputContent::FunctionCall(_) => Err(LlmError::Provider { + message: "Function call response received when expecting text output".to_string(), + source: None, + }), + } +} + #[cfg(test)] mod tests { use super::*; @@ -903,8 +962,10 @@ mod tests { let tool_registry = ToolRegistry::new(); let mut guard = ToolCallingGuard::new(); + let format = create_format_for_type::()?; + client - .handle_tool_calling_loop(request, &tool_registry, &mut guard) + .handle_tool_calling_loop::(request, &tool_registry, &mut guard, format) .await } @@ -1176,8 +1237,9 @@ mod schema_tests { let request = sample_request(Some(tool_config), Some(generation_config)); let responses_input = convert_messages_to_responses_format(request.messages.clone()).expect("inputs"); + let format = create_format_for_type::().expect("schema"); let api_request = - build_request_payload::(&request, &responses_input).expect("request"); + build_request_payload_with_format(&request, &responses_input, format).expect("request"); assert_eq!(api_request.model, "gpt-4o-mini"); assert_eq!(api_request.parallel_tool_calls, Some(false)); @@ -1215,8 +1277,9 @@ mod schema_tests { let responses_input = convert_messages_to_responses_format(request.messages.clone()).expect("inputs"); + let format = create_format_for_type::().expect("schema"); let api_request = - build_request_payload::(&request, &responses_input).expect("request"); + build_request_payload_with_format(&request, &responses_input, format).expect("request"); assert!(api_request.parallel_tool_calls.is_none()); assert!(api_request.tools.is_none()); diff --git a/src/responses/request.rs b/src/responses/request.rs index b8b281f..e9e3919 100644 --- a/src/responses/request.rs +++ b/src/responses/request.rs @@ -218,8 +218,6 @@ pub struct FunctionToolCallOutput { #[derive(Debug, Clone, Serialize)] #[serde(untagged, rename_all = "snake_case")] pub enum FormatType { - // TODO: remove this, once text input is supported - #[allow(dead_code)] Text { #[serde(rename = "type")] r#type: TextType, @@ -238,8 +236,6 @@ pub enum InputMessageRole { #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "snake_case")] pub enum TextType { - // TODO: Remove this, once text input is supported - #[allow(dead_code)] Text, } diff --git a/tests/client_loop_tests.rs b/tests/client_loop_tests.rs index e6251d6..08e57ff 100644 --- a/tests/client_loop_tests.rs +++ b/tests/client_loop_tests.rs @@ -1,8 +1,9 @@ use std::time::Duration; use rsai::{ - ChatRole, ConversationMessage, LlmError, LlmProvider, Message, OpenAiClient, StructuredRequest, - ToolCallingConfig, ToolChoice, ToolConfig, ToolSet, completion_schema, tool, toolset, + ChatRole, CompletionTarget, ConversationMessage, LlmError, LlmProvider, Message, OpenAiClient, + StructuredRequest, ToolCallingConfig, ToolChoice, ToolConfig, ToolSet, completion_schema, tool, + toolset, }; use serde_json::{Value, json}; use wiremock::{ @@ -85,7 +86,11 @@ async fn sequential_tool_call_flow_appends_history() { let client = client_for(&server, None); let response = client - .generate_structured::(request, Some(&toolset.registry)) + .generate_completion::( + request, + ::format().expect("format"), + Some(&toolset.registry), + ) .await .expect("structured response"); assert_eq!(response.content.sum, 3); @@ -136,7 +141,11 @@ async fn parallel_tool_calls_submit_all_results_together() { let client = client_for(&server, None); let response = client - .generate_structured::(request, Some(&toolset.registry)) + .generate_completion::( + request, + ::format().expect("format"), + Some(&toolset.registry), + ) .await .expect("structured response"); assert_eq!(response.content.sum, 15); @@ -198,7 +207,11 @@ async fn guard_stops_iteration_after_max_limit() { let guard_config = ToolCallingConfig::new(1, timeout); let client = client_for(&server, Some(guard_config)); let err = client - .generate_structured::(request, Some(&toolset.registry)) + .generate_completion::( + request, + ::format().expect("format"), + Some(&toolset.registry), + ) .await .expect_err("iteration guard should trip"); @@ -231,7 +244,11 @@ async fn tool_call_timeout_triggers_error() { let guard_config = ToolCallingConfig::new(3, Duration::from_millis(50)); let client = client_for(&server, Some(guard_config.clone())); let err = client - .generate_structured::(request, Some(&toolset.registry)) + .generate_completion::( + request, + ::format().expect("format"), + Some(&toolset.registry), + ) .await .expect_err("timeout should trigger"); From fdf0ddf3cbbf969b48c60954a24a9bf3de306e8c Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Mon, 24 Nov 2025 13:39:08 +0100 Subject: [PATCH 3/4] Add text generation example --- Cargo.toml | 4 ++++ examples/text_generation.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 examples/text_generation.rs diff --git a/Cargo.toml b/Cargo.toml index 8e18e3c..f603d43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,10 @@ path = "examples/openrouter.rs" name = "structured-generation" path = "examples/structured_generation.rs" +[[example]] +name = "text-generation" +path = "examples/text_generation.rs" + [[example]] name = "tracing" path = "examples/tracing.rs" diff --git a/examples/text_generation.rs b/examples/text_generation.rs new file mode 100644 index 0000000..c914133 --- /dev/null +++ b/examples/text_generation.rs @@ -0,0 +1,27 @@ +use dotenv::dotenv; +use rsai::{ApiKey, ChatRole, Message, Provider, TextResponse, llm}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + dotenv().ok(); + + let response = llm::with(Provider::OpenAI) + .api_key(ApiKey::Default)? + .model("gpt-4o-mini") + .messages(vec![ + Message { + role: ChatRole::System, + content: "You are a concise, upbeat assistant.".to_string(), + }, + Message { + role: ChatRole::User, + content: "Share a fun fact about Rust programming.".to_string(), + }, + ]) + .complete::() + .await?; + + println!("Assistant:\n{}", response.text); + + Ok(()) +} From 3a8275f64902b6258c9ffbb0c7188d540e3d07ff Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Mon, 24 Nov 2025 13:49:15 +0100 Subject: [PATCH 4/4] Update documentation --- README.md | 48 +++++++++++++++++++++++++++++++++++++- src/lib.rs | 20 +++++++++++++++- src/provider/openai.rs | 2 +- src/provider/openrouter.rs | 2 +- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 985b077..bfa28c4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ## Quick Start ```rust -use rsai::{llm, Message, ChatRole, ApiKey, Provider, completion_schema}; +use rsai::{llm, Message, ChatRole, ApiKey, Provider, TextResponse, completion_schema}; #[completion_schema] struct Analysis { @@ -20,6 +20,24 @@ let analysis = llm::with(Provider::OpenAI) }]) .complete::() .await?; + +let reply = llm::with(Provider::OpenAI) + .api_key(ApiKey::Default)? + .model("gpt-4o-mini") + .messages(vec![ + Message { + role: ChatRole::System, + content: "You are friendly and concise.".to_string(), + }, + Message { + role: ChatRole::User, + content: "Share a fun fact about Rust.".to_string(), + }, + ]) + .complete::() + .await?; + +println!("{}", reply.text); ``` ## Structured Generation @@ -113,6 +131,34 @@ struct CustomType { > **Note**: The library automatically handles provider-specific requirements. For example, OpenAI requires root schemas to be objects, so non-object types like enums are transparently wrapped and unwrapped. +## Text Generation + +To obtain plain-text output without defining a schema, target `TextResponse`: + +```rust +use rsai::{llm, ApiKey, Provider, Message, ChatRole, TextResponse}; + +let fact = llm::with(Provider::OpenAI) + .api_key(ApiKey::Default)? + .model("gpt-4o-mini") + .messages(vec![ + Message { + role: ChatRole::System, + content: "You explain things clearly but briefly.".to_string(), + }, + Message { + role: ChatRole::User, + content: "What makes Rust's borrow checker special?".to_string(), + }, + ]) + .complete::() + .await?; + +println!("{}", fact.text); +``` + +See `examples/text_generation.rs` for a runnable version. + ## Known Issues - .. diff --git a/src/lib.rs b/src/lib.rs index 3343a65..15ebdfe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,7 @@ //! ## Quick Start //! //! ```rust,no_run -//! use rsai::{llm, Message, ChatRole, ApiKey, Provider, completion_schema}; +//! use rsai::{llm, Message, ChatRole, ApiKey, Provider, TextResponse, completion_schema}; //! //! #[completion_schema] //! struct Analysis { @@ -29,6 +29,24 @@ //! }]) //! .complete::() //! .await?; +//! +//! let reply = llm::with(Provider::OpenAI) +//! .api_key(ApiKey::Default)? +//! .model("gpt-4o-mini") +//! .messages(vec![ +//! Message { +//! role: ChatRole::System, +//! content: "You are friendly and concise.".to_string(), +//! }, +//! Message { +//! role: ChatRole::User, +//! content: "Share a fun fact about Rust.".to_string(), +//! }, +//! ]) +//! .complete::() +//! .await?; +//! +//! println!("{}", reply.text); //! Ok(()) //! } //! ``` diff --git a/src/provider/openai.rs b/src/provider/openai.rs index a4660f1..cb11c4c 100644 --- a/src/provider/openai.rs +++ b/src/provider/openai.rs @@ -175,7 +175,7 @@ impl LlmProvider for OpenAiClient { .await; } - // Otherwise, make a single request expecting structured content + // Otherwise, make a single request expecting the configured completion output let messages_clone = request.messages.clone(); let responses_request = self.responses_client.build_request_with_format( &request, diff --git a/src/provider/openrouter.rs b/src/provider/openrouter.rs index 32f0e19..97c38d4 100644 --- a/src/provider/openrouter.rs +++ b/src/provider/openrouter.rs @@ -224,7 +224,7 @@ impl LlmProvider for OpenRouterClient { .await; } - // Otherwise, make a single request expecting structured content + // Otherwise, make a single request expecting the configured completion output let messages_clone = request.messages.clone(); let responses_request = self.responses_client.build_request_with_format( &request,