diff --git a/js/app/packages/core/component/AI/assets/openai.svg b/js/app/packages/core/component/AI/assets/openai.svg new file mode 100644 index 0000000000..0697c9c4c2 --- /dev/null +++ b/js/app/packages/core/component/AI/assets/openai.svg @@ -0,0 +1,4 @@ + + + + diff --git a/js/app/packages/core/component/AI/constant/model.ts b/js/app/packages/core/component/AI/constant/model.ts index 7317c3b97f..bbf6ce8938 100644 --- a/js/app/packages/core/component/AI/constant/model.ts +++ b/js/app/packages/core/component/AI/constant/model.ts @@ -1,4 +1,5 @@ import AnthropicIcon from '@core/component/AI/assets/anthropic.svg'; +import OpenAiIcon from '@core/component/AI/assets/openai.svg'; import type { TModel } from '@core/component/AI/types'; export { Model } from '@core/component/AI/types'; @@ -13,6 +14,8 @@ export const MODEL_PRETTYNAME: ExhaustiveMap = { opus4_7: 'Opus 4.7', sonnet4_6: 'Sonnet 4.6', haiku4_5: 'Haiku 4.5', + gpt5_5: 'GPT-5.5', + gpt5Mini: 'GPT-5 mini', retired: 'Retired', } as const; @@ -22,6 +25,8 @@ export const MODEL_PROVIDER_ICON: ExhaustiveMap = { opus4_7: AnthropicIcon, sonnet4_6: AnthropicIcon, haiku4_5: AnthropicIcon, + gpt5_5: OpenAiIcon, + gpt5Mini: OpenAiIcon, retired: AnthropicIcon, }; diff --git a/js/app/packages/service-clients/service-cognition/generated/schemas/agentModel.ts b/js/app/packages/service-clients/service-cognition/generated/schemas/agentModel.ts index 05502a6954..df99e89d98 100644 --- a/js/app/packages/service-clients/service-cognition/generated/schemas/agentModel.ts +++ b/js/app/packages/service-clients/service-cognition/generated/schemas/agentModel.ts @@ -21,5 +21,7 @@ export const AgentModel = { opus4_7: 'opus4_7', sonnet4_6: 'sonnet4_6', haiku4_5: 'haiku4_5', + gpt5_5: 'gpt5_5', + gpt5Mini: 'gpt5Mini', retired: 'retired', } as const; diff --git a/js/app/packages/service-clients/service-cognition/generated/schemas/model.ts b/js/app/packages/service-clients/service-cognition/generated/schemas/model.ts index 22efba2f13..f85b27f754 100644 --- a/js/app/packages/service-clients/service-cognition/generated/schemas/model.ts +++ b/js/app/packages/service-clients/service-cognition/generated/schemas/model.ts @@ -9,11 +9,15 @@ export type Model = (typeof Model)[keyof typeof Model]; export const Model = { 'claude-opus-4-7': 'claude-opus-4-7', 'claude-haiku-4-5': 'claude-haiku-4-5', + 'gpt-5.5': 'gpt-5.5', + 'gpt-5-mini': 'gpt-5-mini', } as const; export const AllModels: Model[] = [ 'claude-opus-4-7', 'claude-haiku-4-5', + 'gpt-5.5', + 'gpt-5-mini', ] as const; const values: [Model, ...Model[]] = [ Object.values(Model)[0], diff --git a/js/app/packages/service-clients/service-cognition/openapi.json b/js/app/packages/service-clients/service-cognition/openapi.json index b1cf282b32..a527494258 100644 --- a/js/app/packages/service-clients/service-cognition/openapi.json +++ b/js/app/packages/service-clients/service-cognition/openapi.json @@ -1413,7 +1413,16 @@ "AgentModel": { "type": "string", "description": "Model to use for completions.\n\nUnrecognized model strings (including retired Google/OpenAI variants\nfrom older data) deserialize to `Retired` via the manual\n`Deserialize` impl.", - "enum": ["smart", "fast", "opus4_7", "sonnet4_6", "haiku4_5", "retired"] + "enum": [ + "smart", + "fast", + "opus4_7", + "sonnet4_6", + "haiku4_5", + "gpt5_5", + "gpt5Mini", + "retired" + ] }, "AssistantMessagePart": { "oneOf": [ diff --git a/js/app/packages/service-clients/service-scheduled-action/generated/schemas/agentModel.ts b/js/app/packages/service-clients/service-scheduled-action/generated/schemas/agentModel.ts index 5b2be6cede..c87ec94fbd 100644 --- a/js/app/packages/service-clients/service-scheduled-action/generated/schemas/agentModel.ts +++ b/js/app/packages/service-clients/service-scheduled-action/generated/schemas/agentModel.ts @@ -22,5 +22,7 @@ export const AgentModel = { opus4_7: 'opus4_7', sonnet4_6: 'sonnet4_6', haiku4_5: 'haiku4_5', + gpt5_5: 'gpt5_5', + gpt5Mini: 'gpt5Mini', retired: 'retired', } as const; diff --git a/js/app/packages/service-clients/service-scheduled-action/openapi.json b/js/app/packages/service-clients/service-scheduled-action/openapi.json index e498438cc9..c28e47d7b2 100644 --- a/js/app/packages/service-clients/service-scheduled-action/openapi.json +++ b/js/app/packages/service-clients/service-scheduled-action/openapi.json @@ -418,7 +418,16 @@ "AgentModel": { "type": "string", "description": "Model to use for completions.\n\nUnrecognized model strings (including retired Google/OpenAI variants\nfrom older data) deserialize to `Retired` via the manual\n`Deserialize` impl.", - "enum": ["smart", "fast", "opus4_7", "sonnet4_6", "haiku4_5", "retired"] + "enum": [ + "smart", + "fast", + "opus4_7", + "sonnet4_6", + "haiku4_5", + "gpt5_5", + "gpt5Mini", + "retired" + ] }, "AgentTask": { "type": "object", diff --git a/rust/cloud-storage/Cargo.lock b/rust/cloud-storage/Cargo.lock index 33f18b0518..f392dc9d70 100644 --- a/rust/cloud-storage/Cargo.lock +++ b/rust/cloud-storage/Cargo.lock @@ -2629,9 +2629,9 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" dependencies = [ "unicode-segmentation", ] @@ -8238,7 +8238,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -9550,7 +9550,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -10061,9 +10061,8 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" [[package]] name = "rig-core" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6bd308c89a90f89ce7cd6641a078c7d2a2c55fb5a4147fd48b5a0989c3430bd" +version = "0.38.2" +source = "git+https://github.com/macro-inc/rig?branch=feat%2Fresponses-api-non-strict-tools#6deadfc6f9b432c849ea79733a549f46407530b7" dependencies = [ "as-any", "async-stream", @@ -10087,7 +10086,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.23.1", + "tokio-tungstenite 0.28.0", "tracing", "tracing-futures", "url", @@ -10095,9 +10094,8 @@ dependencies = [ [[package]] name = "rig-derive" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ba9149c63403a49ddfd5d373860487e6feef0021bfe5329812d1c4e72ee207c" +version = "0.38.2" +source = "git+https://github.com/macro-inc/rig?branch=feat%2Fresponses-api-non-strict-tools#6deadfc6f9b432c849ea79733a549f46407530b7" dependencies = [ "convert_case", "deluxe", @@ -12342,22 +12340,6 @@ dependencies = [ "tokio-stream", ] -[[package]] -name = "tokio-tungstenite" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" -dependencies = [ - "futures-util", - "log", - "rustls 0.23.40", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.4", - "tungstenite 0.23.0", - "webpki-roots 0.26.11", -] - [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -12744,26 +12726,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http 1.4.0", - "httparse", - "log", - "rand 0.8.6", - "rustls 0.23.40", - "rustls-pki-types", - "sha1 0.10.6", - "thiserror 1.0.69", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.28.0" @@ -13616,15 +13578,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -13673,30 +13626,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -13715,12 +13651,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -13739,12 +13669,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -13763,24 +13687,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -13799,12 +13711,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -13823,12 +13729,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -13847,12 +13747,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -13871,12 +13765,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" diff --git a/rust/cloud-storage/agent/Cargo.toml b/rust/cloud-storage/agent/Cargo.toml index f30ea943c4..b6bae88dee 100644 --- a/rust/cloud-storage/agent/Cargo.toml +++ b/rust/cloud-storage/agent/Cargo.toml @@ -13,7 +13,9 @@ async-openai = { workspace = true } async-stream = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } -rig-core = "0.37" +# Macro fork of rig (upstream main + with_non_strict_tools() on Responses +# API models). Drop back to crates.io once the patch lands upstream. +rig-core = { git = "https://github.com/macro-inc/rig", branch = "feat/responses-api-non-strict-tools" } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/rust/cloud-storage/agent/src/agent_loop.rs b/rust/cloud-storage/agent/src/agent_loop.rs index 4434c9dee9..337c0e830b 100644 --- a/rust/cloud-storage/agent/src/agent_loop.rs +++ b/rust/cloud-storage/agent/src/agent_loop.rs @@ -1,8 +1,7 @@ /// The main entry point: [`AgentLoop`] and [`Session`]. -use crate::anthropic_model::AnthropicModel; use crate::error::AgentError; use crate::hook::{StreamBridge, ToolRouter}; -use crate::model::AgentModel; +use crate::model::{AgentModel, ModelProvider}; use crate::stream::{ChatCompletionStream, StreamPart}; use crate::tool_adapter::DynToolSetAdapter; use ai_toolset::{RequestContext, ToolSet as AiToolSet}; @@ -10,10 +9,11 @@ use futures::StreamExt; use macro_user_id::user_id::MacroUserIdStr; use rig_core::agent::{Agent, MultiTurnStreamItem}; use rig_core::client::{CompletionClient, ProviderClient}; +use rig_core::completion::{CompletionModel, GetTokenUsage}; use rig_core::message::Message; -use rig_core::providers::anthropic; +use rig_core::providers::{anthropic, openai}; use rig_core::streaming::{StreamedAssistantContent, StreamingPrompt}; -use rig_core::tool::server::ToolServer; +use rig_core::tool::server::{ToolServer, ToolServerHandle}; use std::sync::{Arc, RwLock}; const DEFAULT_MAX_TURNS: usize = 16; @@ -21,11 +21,13 @@ const DEFAULT_MAX_TOKENS: u64 = 16_000; /// Factory for creating per-request agent sessions. /// -/// Holds the Anthropic client. Tools and system prompt are provided -/// per-session since they vary by request (MCP tools are per-user, -/// system prompt depends on toolset selection). +/// Holds one client per provider and routes each session to the provider +/// serving the selected model (see [`AgentModel::provider`]). Tools and +/// system prompt are provided per-session since they vary by request +/// (MCP tools are per-user, system prompt depends on toolset selection). pub struct AgentLoop { - client: anthropic::Client, + anthropic: anthropic::Client, + openai: Option, model: AgentModel, max_turns: usize, max_tokens: u64, @@ -38,12 +40,18 @@ impl Default for AgentLoop { } impl AgentLoop { - /// Create an `AgentLoop` with the Anthropic client from the environment - /// and the default model (Opus 4.7). + /// Create an `AgentLoop` with provider clients from the environment and + /// the default model (Opus 4.7). + /// + /// `ANTHROPIC_API_KEY` is required. `OPENAI_API_KEY` is optional at + /// construction; selecting an OpenAI model without it panics at + /// session creation. pub fn new() -> Self { - let client = anthropic::Client::from_env().expect("ANTHROPIC_API_KEY must be set"); + let anthropic = anthropic::Client::from_env().expect("ANTHROPIC_API_KEY must be set"); + let openai = openai::Client::from_env().ok(); Self { - client, + anthropic, + openai, model: AgentModel::default(), max_turns: DEFAULT_MAX_TURNS, max_tokens: DEFAULT_MAX_TOKENS, @@ -68,6 +76,21 @@ impl AgentLoop { self } + fn build_agent( + &self, + model: M, + handle: ToolServerHandle, + system_prompt: &str, + ) -> Agent { + rig_core::agent::AgentBuilder::new(model) + .tool_server_handle(handle) + .default_max_turns(self.max_turns) + .max_tokens(self.max_tokens) + .additional_params(self.model.thinking_params()) + .preamble(system_prompt) + .build() + } + /// Start a new streaming session. /// /// `toolset` is the combined tool set (static + MCP) for this request. @@ -103,16 +126,26 @@ impl AgentLoop { .expect("failed to register tool"); } - let raw_model = self.client.completion_model(self.model.api_id()); - let model = AnthropicModel::new(raw_model); - - let agent = rig_core::agent::AgentBuilder::new(model) - .tool_server_handle(handle) - .default_max_turns(self.max_turns) - .max_tokens(self.max_tokens) - .additional_params(self.model.thinking_params()) - .preamble(system_prompt) - .build(); + let agent = match self.model.provider() { + ModelProvider::Anthropic => { + let model = self.anthropic.completion_model(self.model.api_id()); + ProviderAgent::Anthropic(self.build_agent(model, handle, system_prompt)) + } + ModelProvider::OpenAi => { + let client = self + .openai + .as_ref() + .expect("OPENAI_API_KEY must be set to use OpenAI models"); + // Non-strict tools: send tool schemas verbatim instead of + // letting rig sanitize them into OpenAI's strict subset, + // which silently forces every optional parameter into + // `required`. + let model = client + .completion_model(self.model.api_id()) + .with_non_strict_tools(); + ProviderAgent::OpenAi(self.build_agent(model, handle, system_prompt)) + } + }; Session { agent, @@ -123,9 +156,15 @@ impl AgentLoop { } } +/// A rig agent bound to the provider that serves the session's model. +enum ProviderAgent { + Anthropic(Agent), + OpenAi(Agent), +} + /// A single streaming conversation session. pub struct Session { - agent: Agent, + agent: ProviderAgent, history: Vec, max_turns: usize, routing: ToolRouter, @@ -149,62 +188,100 @@ impl Session { ))); }; - let (bridge, mut rx) = StreamBridge::channel(self.routing.clone()); + let stream = match &self.agent { + ProviderAgent::Anthropic(agent) => { + run_stream( + agent, + prompt.clone(), + history.to_vec(), + self.max_turns, + self.routing.clone(), + ) + .await + } + ProviderAgent::OpenAi(agent) => { + run_stream( + agent, + prompt.clone(), + history.to_vec(), + self.max_turns, + self.routing.clone(), + ) + .await + } + }; - let mut rig_stream = self - .agent - .stream_prompt(prompt.clone()) - .with_history(history.to_vec()) - .multi_turn(self.max_turns) - .with_hook(bridge) - .await; + Ok(stream) + } - let stream = async_stream::stream! { - let mut thinking_buf = String::new(); + /// Get the conversation messages accumulated during this session. + pub fn get_history(&self) -> &[Message] { + &self.history + } +} + +/// Run the agentic loop on `agent` and adapt rig's stream into the +/// provider-agnostic [`StreamPart`] stream consumed by DCS. +async fn run_stream( + agent: &Agent, + prompt: Message, + history: Vec, + max_turns: usize, + routing: ToolRouter, +) -> ChatCompletionStream<'static> +where + M: CompletionModel + 'static, + M::StreamingResponse: GetTokenUsage + Send + Sync, +{ + let (bridge, mut rx) = StreamBridge::channel(routing); + + let mut rig_stream = agent + .stream_prompt(prompt) + .with_history(history) + .multi_turn(max_turns) + .with_hook(bridge) + .await; - while let Some(item) = rig_stream.next().await { - while let Ok(part) = rx.try_recv() { - yield part; + let stream = async_stream::stream! { + let mut thinking_buf = String::new(); + + while let Some(item) = rig_stream.next().await { + while let Ok(part) = rx.try_recv() { + yield part; + } + match item { + Ok(MultiTurnStreamItem::StreamAssistantItem( + StreamedAssistantContent::ReasoningDelta { reasoning, .. }, + )) => { + thinking_buf.push_str(&reasoning); } - match item { - Ok(MultiTurnStreamItem::StreamAssistantItem( - StreamedAssistantContent::ReasoningDelta { reasoning, .. }, - )) => { - thinking_buf.push_str(&reasoning); + other => { + if !thinking_buf.is_empty() { + yield Ok(StreamPart::Thinking(std::mem::take(&mut thinking_buf))); } - other => { - if !thinking_buf.is_empty() { - yield Ok(StreamPart::Thinking(std::mem::take(&mut thinking_buf))); + match other { + Ok(MultiTurnStreamItem::FinalResponse(final_resp)) => { + let usage = final_resp.usage(); + yield Ok(StreamPart::Usage(crate::stream::Usage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + })); } - match other { - Ok(MultiTurnStreamItem::FinalResponse(final_resp)) => { - let usage = final_resp.usage(); - yield Ok(StreamPart::Usage(crate::stream::Usage { - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - })); - } - Err(e) => { - yield Err(AgentError::Streaming(e)); - } - _ => {} + Err(e) => { + yield Err(AgentError::Streaming(e)); } + _ => {} } } } - if !thinking_buf.is_empty() { - yield Ok(StreamPart::Thinking(std::mem::take(&mut thinking_buf))); - } - while let Ok(part) = rx.try_recv() { - yield part; - } - }; - - Ok(Box::pin(stream)) - } + } + if !thinking_buf.is_empty() { + yield Ok(StreamPart::Thinking(std::mem::take(&mut thinking_buf))); + } + while let Ok(part) = rx.try_recv() { + yield part; + } + }; - /// Get the conversation messages accumulated during this session. - pub fn get_history(&self) -> &[Message] { - &self.history - } + Box::pin(stream) } diff --git a/rust/cloud-storage/agent/src/anthropic_model.rs b/rust/cloud-storage/agent/src/anthropic_model.rs deleted file mode 100644 index e1232f910a..0000000000 --- a/rust/cloud-storage/agent/src/anthropic_model.rs +++ /dev/null @@ -1,95 +0,0 @@ -use rig_core::OneOrMany; -/// Newtype around the Anthropic [`CompletionModel`] that merges consecutive -/// `User` messages before each request. -/// -/// RIG's agentic loop creates one `User` message per tool result, but the -/// Anthropic API requires all `tool_result` blocks for a batch of -/// `tool_use` calls to appear in a single `User` message. This wrapper -/// fixes that at the model boundary so the rest of the stack is unaware. -use rig_core::completion::{ - CompletionError, CompletionModel, CompletionRequest, CompletionRequestBuilder, - CompletionResponse, -}; -use rig_core::message::Message; -use rig_core::providers::anthropic; -use rig_core::streaming::StreamingCompletionResponse; - -type Inner = anthropic::completion::CompletionModel; - -/// Anthropic completion model that merges consecutive user messages. -#[derive(Clone)] -pub struct AnthropicModel(Inner); - -impl AnthropicModel { - /// Wrap a raw Anthropic completion model. - pub fn new(inner: Inner) -> Self { - Self(inner) - } -} - -impl CompletionModel for AnthropicModel { - type Response = ::Response; - type StreamingResponse = ::StreamingResponse; - type Client = ::Client; - - fn make(client: &Self::Client, model: impl Into) -> Self { - Self(Inner::make(client, model)) - } - - async fn completion( - &self, - mut request: CompletionRequest, - ) -> Result, CompletionError> { - request.chat_history = merge_consecutive_user(request.chat_history); - self.0.completion(request).await - } - - async fn stream( - &self, - mut request: CompletionRequest, - ) -> Result, CompletionError> { - request.chat_history = merge_consecutive_user(request.chat_history); - self.0.stream(request).await - } - - fn completion_request(&self, prompt: impl Into) -> CompletionRequestBuilder { - CompletionRequestBuilder::new(self.clone(), prompt) - } -} - -fn merge_consecutive_user(history: OneOrMany) -> OneOrMany { - let messages: Vec = history.into_iter().collect(); - if messages.len() < 2 { - return OneOrMany::many(messages).unwrap_or_else(|_| OneOrMany::one(Message::user(""))); - } - - let mut merged: Vec = Vec::with_capacity(messages.len()); - - for msg in messages { - if matches!(&msg, Message::User { .. }) - && merged - .last() - .is_some_and(|m| matches!(m, Message::User { .. })) - { - let Message::User { - content: new_content, - } = msg - else { - unreachable!() - }; - let Some(Message::User { content: existing }) = merged.last_mut() else { - unreachable!() - }; - for item in new_content { - existing.push(item); - } - } else { - merged.push(msg); - } - } - - OneOrMany::many(merged).unwrap_or_else(|_| OneOrMany::one(Message::user(""))) -} - -#[cfg(test)] -mod test; diff --git a/rust/cloud-storage/agent/src/anthropic_model/test.rs b/rust/cloud-storage/agent/src/anthropic_model/test.rs deleted file mode 100644 index d65fad8e47..0000000000 --- a/rust/cloud-storage/agent/src/anthropic_model/test.rs +++ /dev/null @@ -1,63 +0,0 @@ -use super::*; -use rig_core::message::{Message, ToolResultContent, UserContent}; - -#[test] -fn no_merge_needed() { - let history = OneOrMany::many(vec![ - Message::user("hello"), - Message::assistant("hi"), - Message::user("bye"), - ]) - .unwrap(); - let merged = merge_consecutive_user(history); - assert_eq!(merged.len(), 3); -} - -#[test] -fn merges_two_consecutive_user_messages() { - let history = OneOrMany::many(vec![Message::user("a"), Message::user("b")]).unwrap(); - let merged = merge_consecutive_user(history); - assert_eq!(merged.len(), 1); - let Message::User { content } = merged.first() else { - panic!("expected user"); - }; - assert_eq!(content.len(), 2); -} - -#[test] -fn merges_tool_results_after_tool_calls() { - let tr1 = UserContent::tool_result( - "call_1", - OneOrMany::one(ToolResultContent::text("result 1")), - ); - let tr2 = UserContent::tool_result( - "call_2", - OneOrMany::one(ToolResultContent::text("result 2")), - ); - let history = OneOrMany::many(vec![ - Message::user("do stuff"), - Message::assistant("calling tools"), - Message::User { - content: OneOrMany::one(tr1), - }, - Message::User { - content: OneOrMany::one(tr2), - }, - ]) - .unwrap(); - - let merged = merge_consecutive_user(history); - assert_eq!(merged.len(), 3, "user, assistant, merged-user"); - - let Message::User { content } = &merged.iter().collect::>()[2] else { - panic!("expected user"); - }; - assert_eq!(content.len(), 2, "both tool results in one message"); -} - -#[test] -fn single_message_unchanged() { - let history = OneOrMany::one(Message::user("only")); - let merged = merge_consecutive_user(history); - assert_eq!(merged.len(), 1); -} diff --git a/rust/cloud-storage/agent/src/completion.rs b/rust/cloud-storage/agent/src/completion.rs index cf628c1224..77e4adb6fe 100644 --- a/rust/cloud-storage/agent/src/completion.rs +++ b/rust/cloud-storage/agent/src/completion.rs @@ -1,10 +1,9 @@ /// One-shot completion — send a prompt and get a string response. -use crate::anthropic_model::AnthropicModel; -use crate::model::AgentModel; +use crate::model::{AgentModel, ModelProvider}; use rig_core::client::{CompletionClient, ProviderClient}; -use rig_core::completion::Prompt; +use rig_core::completion::{CompletionModel, Prompt}; use rig_core::message::Message; -use rig_core::providers::anthropic; +use rig_core::providers::{anthropic, openai}; /// Send a system prompt + user message and return the model's text response. /// @@ -16,11 +15,64 @@ pub async fn complete( system_prompt: &str, user_message: &str, ) -> anyhow::Result { - let client = anthropic::Client::from_env()?; - let raw_model = client.completion_model(model.api_id()); - let wrapped = AnthropicModel::new(raw_model); + match model.provider() { + ModelProvider::Anthropic => { + let client = anthropic::Client::from_env()?; + prompt_once( + client.completion_model(model.api_id()), + system_prompt, + user_message, + ) + .await + } + ModelProvider::OpenAi => { + let client = openai::Client::from_env()?; + prompt_once( + client.completion_model(model.api_id()), + system_prompt, + user_message, + ) + .await + } + } +} + +/// Send a system prompt + conversation history and return the model's text +/// response. +#[tracing::instrument(skip(system_prompt, messages), err)] +pub async fn complete_with_history( + model: AgentModel, + system_prompt: &str, + messages: Vec, +) -> anyhow::Result { + match model.provider() { + ModelProvider::Anthropic => { + let client = anthropic::Client::from_env()?; + prompt_with_history( + client.completion_model(model.api_id()), + system_prompt, + messages, + ) + .await + } + ModelProvider::OpenAi => { + let client = openai::Client::from_env()?; + prompt_with_history( + client.completion_model(model.api_id()), + system_prompt, + messages, + ) + .await + } + } +} - let agent = rig_core::agent::AgentBuilder::new(wrapped) +async fn prompt_once( + completion_model: M, + system_prompt: &str, + user_message: &str, +) -> anyhow::Result { + let agent = rig_core::agent::AgentBuilder::new(completion_model) .preamble(system_prompt) .max_tokens(16_000) .build(); @@ -29,19 +81,12 @@ pub async fn complete( Ok(response) } -/// Send a system prompt + conversation history and return the model's text -/// response. -#[tracing::instrument(skip(system_prompt, messages), err)] -pub async fn complete_with_history( - model: AgentModel, +async fn prompt_with_history( + completion_model: M, system_prompt: &str, messages: Vec, ) -> anyhow::Result { - let client = anthropic::Client::from_env()?; - let raw_model = client.completion_model(model.api_id()); - let wrapped = AnthropicModel::new(raw_model); - - let agent = rig_core::agent::AgentBuilder::new(wrapped) + let agent = rig_core::agent::AgentBuilder::new(completion_model) .preamble(system_prompt) .max_tokens(16_000) .build(); diff --git a/rust/cloud-storage/agent/src/convert.rs b/rust/cloud-storage/agent/src/convert.rs index 0038300f23..05811f9568 100644 --- a/rust/cloud-storage/agent/src/convert.rs +++ b/rust/cloud-storage/agent/src/convert.rs @@ -145,23 +145,21 @@ fn convert_assistant(msg: &ChatMessage) -> Vec { assistant_parts.push(AssistantContent::text(text)); } } - AssistantMessagePart::ToolCall { name, json, id } => { + AssistantMessagePart::ToolCall { name, json, id } + | AssistantMessagePart::McpToolCall { name, json, id, .. } => { saw_tool_call = true; - assistant_parts.push(AssistantContent::ToolCall(ToolCall::new( - id.clone(), - ToolFunction::new(name.clone(), json.clone()), - ))); - } - AssistantMessagePart::McpToolCall { name, json, id, .. } => { - saw_tool_call = true; - assistant_parts.push(AssistantContent::ToolCall(ToolCall::new( - id.clone(), - ToolFunction::new(name.clone(), json.clone()), - ))); + assistant_parts.push(AssistantContent::ToolCall( + ToolCall::new( + replay_item_id(id), + ToolFunction::new(name.clone(), json.clone()), + ) + .with_call_id(id.clone()), + )); } AssistantMessagePart::ToolCallResponseJson { id, json, .. } => { let text = serde_json::to_string(json).unwrap_or_default(); - tool_results.push(UserContent::tool_result( + tool_results.push(UserContent::tool_result_with_call_id( + replay_item_id(id), id.clone(), OneOrMany::one(ToolResultContent::text(text)), )); @@ -169,7 +167,8 @@ fn convert_assistant(msg: &ChatMessage) -> Vec { AssistantMessagePart::ToolCallErr { id, description, .. } => { - tool_results.push(UserContent::tool_result( + tool_results.push(UserContent::tool_result_with_call_id( + replay_item_id(id), id.clone(), OneOrMany::one(ToolResultContent::text(description.clone())), )); @@ -182,6 +181,18 @@ fn convert_assistant(msg: &ChatMessage) -> Vec { out } +/// Item id replayed to the provider for a persisted tool call or result. +/// +/// The persisted id is the provider call id when one exists (OpenAI's +/// `call_…`) or an internal nanoid otherwise. OpenAI's Responses API rejects +/// replayed `function_call` item ids that don't begin with `fc`, and pairs +/// calls to results through `call_id` — which is why the persisted id is also +/// set as `call_id` above. Anthropic ignores `call_id` and only requires a +/// result's id to match its call's id, which this uniform prefix preserves. +fn replay_item_id(id: &str) -> String { + format!("fc_{id}") +} + /// Merges consecutive `Text` and `Thinking` parts into single entries. pub fn merge_consecutive_parts(parts: Vec) -> Vec { let mut out: Vec = Vec::with_capacity(parts.len()); diff --git a/rust/cloud-storage/agent/src/convert/test.rs b/rust/cloud-storage/agent/src/convert/test.rs index de1c78a820..a7649ddee4 100644 --- a/rust/cloud-storage/agent/src/convert/test.rs +++ b/rust/cloud-storage/agent/src/convert/test.rs @@ -212,7 +212,46 @@ fn tool_call_error_becomes_tool_result() { let UserContent::ToolResult(result) = content.first() else { panic!("expected tool result"); }; - assert_eq!(result.id, "call_1"); + assert_eq!(result.id, "fc_call_1"); + assert_eq!(result.call_id.as_deref(), Some("call_1")); +} + +#[test] +fn tool_call_round_trip_carries_call_id_and_fc_item_id() { + let msg = assistant_parts(vec![ + AssistantMessagePart::ToolCall { + name: "search".to_owned(), + json: json!({"query": "test"}), + id: "call_1".to_owned(), + }, + AssistantMessagePart::ToolCallResponseJson { + name: "search".to_owned(), + json: json!({"results": []}), + id: "call_1".to_owned(), + }, + ]); + let messages = to_rig_messages(&[msg]); + + let Message::Assistant { content, .. } = &messages[0] else { + panic!("expected assistant"); + }; + let AssistantContent::ToolCall(call) = content.first() else { + panic!("expected tool call"); + }; + assert_eq!(call.id, "fc_call_1"); + assert_eq!(call.call_id.as_deref(), Some("call_1")); + + let Message::User { content } = &messages[1] else { + panic!("expected tool result user message"); + }; + let UserContent::ToolResult(result) = content.first() else { + panic!("expected tool result"); + }; + assert_eq!( + result.id, "fc_call_1", + "result id must match the call's item id" + ); + assert_eq!(result.call_id.as_deref(), Some("call_1")); } #[test] diff --git a/rust/cloud-storage/agent/src/lib.rs b/rust/cloud-storage/agent/src/lib.rs index e0438d55b1..933437cb9e 100644 --- a/rust/cloud-storage/agent/src/lib.rs +++ b/rust/cloud-storage/agent/src/lib.rs @@ -4,7 +4,6 @@ mod accumulator; mod agent_loop; -mod anthropic_model; mod completion; mod convert; mod error; @@ -22,9 +21,9 @@ pub use completion::{complete, complete_with_history}; pub use convert::{merge_consecutive_parts, to_rig_messages}; pub use error::AgentError; pub use hook::StreamBridge; -pub use model::AgentModel; +pub use model::{AgentModel, ModelProvider}; pub use stream::{ChatCompletionStream, McpInfo, StreamPart, ToolCall, ToolResponse, Usage}; -pub use tool_adapter::{DynToolSetAdapter, ToolsetToolAdapter}; +pub use tool_adapter::{DynToolSetAdapter, ToolsetToolAdapter, normalize_request_schema}; pub use rig_core::message::Message; pub use rig_core::tool::{Tool, ToolDyn}; diff --git a/rust/cloud-storage/agent/src/model.rs b/rust/cloud-storage/agent/src/model.rs index b5943b1d13..ed3941260e 100644 --- a/rust/cloud-storage/agent/src/model.rs +++ b/rust/cloud-storage/agent/src/model.rs @@ -1,9 +1,28 @@ /// Supported models for the agent loop. -use rig_core::providers::anthropic; +use rig_core::providers::{anthropic, openai}; use serde::{Deserialize, Serialize}; use strum::{Display, EnumIter, EnumString}; use utoipa::ToSchema; +/// API provider serving a model. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelProvider { + /// Anthropic (Claude models) + Anthropic, + /// OpenAI (GPT models) + OpenAi, +} + +impl ModelProvider { + /// Lowercase provider name as exposed in the models schema. + pub fn as_str(&self) -> &'static str { + match self { + Self::Anthropic => "anthropic", + Self::OpenAi => "openai", + } + } +} + /// Model to use for completions. /// /// Unrecognized model strings (including retired Google/OpenAI variants @@ -25,6 +44,10 @@ pub enum AgentModel { Sonnet4_6, /// Claude Haiku 4.5 Haiku4_5, + /// OpenAI GPT-5.5 + Gpt5_5, + /// OpenAI GPT-5 mini + Gpt5Mini, /// Retired or unrecognized model, routes to the default Retired, } @@ -40,6 +63,8 @@ impl<'de> Deserialize<'de> for AgentModel { Opus4_7, Sonnet4_6, Haiku4_5, + Gpt5_5, + Gpt5Mini, Retired, } match serde_json::from_value::(serde_json::Value::String(s)) { @@ -48,6 +73,8 @@ impl<'de> Deserialize<'de> for AgentModel { Ok(Known::Opus4_7) => Ok(Self::Opus4_7), Ok(Known::Sonnet4_6) => Ok(Self::Sonnet4_6), Ok(Known::Haiku4_5) => Ok(Self::Haiku4_5), + Ok(Known::Gpt5_5) => Ok(Self::Gpt5_5), + Ok(Known::Gpt5Mini) => Ok(Self::Gpt5Mini), Ok(Known::Retired) => Ok(Self::Retired), Err(_) => Ok(Self::Retired), } @@ -55,19 +82,23 @@ impl<'de> Deserialize<'de> for AgentModel { } impl AgentModel { - /// Returns the Anthropic API model identifier. + /// Returns the provider API model identifier. pub fn api_id(&self) -> &'static str { match self { Self::Smart | Self::Opus4_7 | Self::Retired => anthropic::completion::CLAUDE_OPUS_4_7, Self::Fast | Self::Haiku4_5 => anthropic::completion::CLAUDE_HAIKU_4_5, Self::Sonnet4_6 => anthropic::completion::CLAUDE_SONNET_4_6, + Self::Gpt5_5 => openai::GPT_5_5, + Self::Gpt5Mini => openai::GPT_5_MINI, } } - /// Returns `additional_params` JSON to enable extended thinking. + /// Returns `additional_params` JSON to enable extended thinking / reasoning. /// /// - Opus 4.7: `adaptive` (model chooses when to think) /// - Sonnet 4.6 / Haiku 4.5: `enabled` with `budget_tokens` + /// - GPT-5.5 / GPT-5 mini: Responses API `reasoning` with effort + /// (no `temperature`; reasoning models reject it) pub fn thinking_params(&self) -> serde_json::Value { match self { Self::Smart | Self::Opus4_7 | Self::Retired => serde_json::json!({ @@ -82,6 +113,12 @@ impl AgentModel { }, "temperature": 1 }), + Self::Gpt5_5 => serde_json::json!({ + "reasoning": { "effort": "medium", "summary": "auto" } + }), + Self::Gpt5Mini => serde_json::json!({ + "reasoning": { "effort": "low", "summary": "auto" } + }), } } @@ -90,12 +127,21 @@ impl AgentModel { match self { Self::Smart | Self::Opus4_7 | Self::Sonnet4_6 | Self::Retired => 1_000_000, Self::Fast | Self::Haiku4_5 => 200_000, + Self::Gpt5_5 | Self::Gpt5Mini => 400_000, } } - /// API provider name. - pub fn provider(&self) -> &'static str { - "anthropic" + /// API provider serving this model. + pub fn provider(&self) -> ModelProvider { + match self { + Self::Smart + | Self::Fast + | Self::Opus4_7 + | Self::Sonnet4_6 + | Self::Haiku4_5 + | Self::Retired => ModelProvider::Anthropic, + Self::Gpt5_5 | Self::Gpt5Mini => ModelProvider::OpenAi, + } } /// from json or Retired diff --git a/rust/cloud-storage/agent/src/model/test.rs b/rust/cloud-storage/agent/src/model/test.rs index 487f7b5c28..eb9d71ba1d 100644 --- a/rust/cloud-storage/agent/src/model/test.rs +++ b/rust/cloud-storage/agent/src/model/test.rs @@ -45,6 +45,35 @@ fn retired_uses_default_api_id() { assert_eq!(AgentModel::Retired.api_id(), AgentModel::Smart.api_id()); } +#[test] +fn gpt5_5_deserializes() { + let m: AgentModel = serde_json::from_str(r#""gpt5_5""#).unwrap(); + assert_eq!(m, AgentModel::Gpt5_5); +} + +#[test] +fn gpt5_mini_deserializes() { + let m: AgentModel = serde_json::from_str(r#""gpt5Mini""#).unwrap(); + assert_eq!(m, AgentModel::Gpt5Mini); +} + +#[test] +fn gpt_models_route_to_openai() { + assert_eq!(AgentModel::Gpt5_5.provider(), ModelProvider::OpenAi); + assert_eq!(AgentModel::Gpt5Mini.provider(), ModelProvider::OpenAi); + assert_eq!(AgentModel::Smart.provider(), ModelProvider::Anthropic); + assert_eq!(AgentModel::Retired.provider(), ModelProvider::Anthropic); +} + +#[test] +fn gpt_round_trip_serialization() { + for m in [AgentModel::Gpt5_5, AgentModel::Gpt5Mini] { + let json = serde_json::to_string(&m).unwrap(); + let back: AgentModel = serde_json::from_str(&json).unwrap(); + assert_eq!(back, m); + } +} + #[test] fn round_trip_serialization() { let m = AgentModel::Sonnet4_6; diff --git a/rust/cloud-storage/agent/src/tool_adapter.rs b/rust/cloud-storage/agent/src/tool_adapter.rs index ed7b08cf1c..863048081c 100644 --- a/rust/cloud-storage/agent/src/tool_adapter.rs +++ b/rust/cloud-storage/agent/src/tool_adapter.rs @@ -1,4 +1,7 @@ /// Adapts `ai_toolset` tool types into RIG [`ToolDyn`] objects. +#[cfg(test)] +mod test; + use ai_toolset::tool_object::ToolSetCallable; use ai_toolset::{AsyncToolCollection, RequestContext, RequestSchema, ToolSet as AiToolSet}; use rig_core::completion::ToolDefinition; @@ -6,6 +9,47 @@ use rig_core::tool::{ToolDyn, ToolError}; use rig_core::wasm_compat::WasmBoxedFuture; use std::sync::{Arc, RwLock}; +/// Ensure every object schema carries an explicit `properties` map. +/// +/// Zero-argument tools serialize to `{"type": "object"}` with no +/// `properties` key, which OpenAI's strict function validation rejects +/// ("object schema missing properties"). Adding an empty map is a +/// semantic no-op accepted by every provider. Recurses through the +/// standard schema-bearing keywords. +pub fn normalize_request_schema(schema: &mut serde_json::Value) { + let serde_json::Value::Object(map) = schema else { + return; + }; + + if map.get("type").and_then(|t| t.as_str()) == Some("object") && !map.contains_key("properties") + { + map.insert( + "properties".to_owned(), + serde_json::Value::Object(serde_json::Map::new()), + ); + } + + for key in ["properties", "$defs", "definitions"] { + if let Some(serde_json::Value::Object(children)) = map.get_mut(key) { + for child in children.values_mut() { + normalize_request_schema(child); + } + } + } + for key in ["anyOf", "oneOf", "allOf", "prefixItems"] { + if let Some(serde_json::Value::Array(children)) = map.get_mut(key) { + for child in children.iter_mut() { + normalize_request_schema(child); + } + } + } + for key in ["items", "additionalProperties", "not", "if", "then", "else"] { + if let Some(child) = map.get_mut(key) { + normalize_request_schema(child); + } + } +} + type Deserializer = Box< dyn Fn( &serde_json::Value, @@ -48,7 +92,8 @@ where .into_iter() .map(|(name, tool_object)| { let description = tool_object.description.clone(); - let input_schema = serde_json::Value::Object(tool_object.input_schema.clone()); + let mut input_schema = serde_json::Value::Object(tool_object.input_schema.clone()); + normalize_request_schema(&mut input_schema); let deserializer = Box::new( move |json: &serde_json::Value| -> Result< Box + Send + Sync>, @@ -144,8 +189,9 @@ where schemas .into_iter() .map(|RequestSchema { name, schema }| { - let schema_json = serde_json::to_value(&schema) + let mut schema_json = serde_json::to_value(&schema) .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); + normalize_request_schema(&mut schema_json); DynToolSetAdapter { name, schema: schema_json, diff --git a/rust/cloud-storage/agent/src/tool_adapter/test.rs b/rust/cloud-storage/agent/src/tool_adapter/test.rs new file mode 100644 index 0000000000..affc08d4fb --- /dev/null +++ b/rust/cloud-storage/agent/src/tool_adapter/test.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +fn empty_object_schema_gains_properties() { + let mut schema = serde_json::json!({ + "type": "object", + "additionalProperties": false, + "title": "ListLabels", + "description": "List the user's Gmail labels." + }); + normalize_request_schema(&mut schema); + assert_eq!(schema["properties"], serde_json::json!({})); +} + +#[test] +fn object_schema_with_properties_is_unchanged() { + let original = serde_json::json!({ + "type": "object", + "properties": { "input": { "type": "string" } }, + "required": ["input"] + }); + let mut schema = original.clone(); + normalize_request_schema(&mut schema); + assert_eq!(schema, original); +} + +#[test] +fn nested_empty_objects_gain_properties() { + let mut schema = serde_json::json!({ + "type": "object", + "properties": { + "config": { "type": "object" }, + "variants": { "anyOf": [{ "type": "object" }, { "type": "null" }] }, + "list": { "type": "array", "items": { "type": "object" } } + }, + "$defs": { + "Empty": { "type": "object" } + } + }); + normalize_request_schema(&mut schema); + assert_eq!( + schema["properties"]["config"]["properties"], + serde_json::json!({}) + ); + assert_eq!( + schema["properties"]["variants"]["anyOf"][0]["properties"], + serde_json::json!({}) + ); + assert_eq!( + schema["properties"]["list"]["items"]["properties"], + serde_json::json!({}) + ); + assert_eq!( + schema["$defs"]["Empty"]["properties"], + serde_json::json!({}) + ); +} + +#[test] +fn non_object_schemas_are_unchanged() { + let original = serde_json::json!({ "type": "string", "description": "plain" }); + let mut schema = original.clone(); + normalize_request_schema(&mut schema); + assert_eq!(schema, original); +} diff --git a/rust/cloud-storage/ai_tools/src/bin/gen_ai_request_schemas.rs b/rust/cloud-storage/ai_tools/src/bin/gen_ai_request_schemas.rs new file mode 100644 index 0000000000..a1a264ff9f --- /dev/null +++ b/rust/cloud-storage/ai_tools/src/bin/gen_ai_request_schemas.rs @@ -0,0 +1,47 @@ +//! Binary to dump the JSON schema for every tool exactly as it is sent to +//! AI providers. +//! +//! The chat agent loop reads tools via `ToolSet::request_schemas` and wraps +//! each one in a rig `ToolDefinition { name, description: "", parameters }` +//! (see `agent::DynToolSetAdapter`) — tool descriptions live inside the +//! schema itself. This binary serializes those definitions verbatim. +//! +//! Usage: `cargo run -p ai_tools --bin gen_ai_request_schemas [out_path]` +//! Defaults to `ai_tools/schemas/ai_request_schemas.json`. + +use ai_toolset::ToolSet; + +fn main() { + let out_path = std::env::args().nth(1).unwrap_or_else(|| { + concat!( + env!("CARGO_MANIFEST_DIR"), + "/schemas/ai_request_schemas.json" + ) + .to_string() + }); + + let tools = ai_tools::all_tools(); + let schemas = tools.toolset.request_schemas().unwrap_or_default(); + + let definitions = schemas + .iter() + .map(|s| { + let mut parameters = serde_json::to_value(&s.schema).expect("serialize tool schema"); + agent::normalize_request_schema(&mut parameters); + serde_json::json!({ + "name": s.name, + "description": "", + "parameters": parameters, + }) + }) + .collect::>(); + + let json = serde_json::to_string_pretty(&serde_json::json!({ "tools": definitions })) + .expect("serialize tool definitions"); + + if let Some(parent) = std::path::Path::new(&out_path).parent() { + std::fs::create_dir_all(parent).expect("create output dir"); + } + std::fs::write(&out_path, &json).expect("write schema file"); + println!("Wrote {} tool definitions to {out_path}", definitions.len()); +} diff --git a/rust/cloud-storage/document_cognition_service/src/core/model.rs b/rust/cloud-storage/document_cognition_service/src/core/model.rs index 19e297d0e9..6c5eda7af6 100644 --- a/rust/cloud-storage/document_cognition_service/src/core/model.rs +++ b/rust/cloud-storage/document_cognition_service/src/core/model.rs @@ -1,4 +1,9 @@ use agent::AgentModel; -pub static CHAT_MODELS: &[AgentModel] = &[AgentModel::Smart, AgentModel::Fast]; +pub static CHAT_MODELS: &[AgentModel] = &[ + AgentModel::Smart, + AgentModel::Fast, + AgentModel::Gpt5_5, + AgentModel::Gpt5Mini, +]; pub static FALLBACK_MODEL: AgentModel = AgentModel::Haiku4_5; diff --git a/rust/cloud-storage/document_cognition_service/src/models_bin.rs b/rust/cloud-storage/document_cognition_service/src/models_bin.rs index fbec6bf0a1..1803595314 100644 --- a/rust/cloud-storage/document_cognition_service/src/models_bin.rs +++ b/rust/cloud-storage/document_cognition_service/src/models_bin.rs @@ -36,7 +36,7 @@ impl From for ModelSchema { fn from(m: AgentModel) -> Self { Self { name: m.api_id().to_owned(), - provider: m.provider(), + provider: m.provider().as_str(), metadata: ModelMetadata { context_window: m.context_window(), },