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(),
},