use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProtocolFamily {
OpenAiChatCompletions,
OpenAiResponses,
AnthropicMessages,
GeminiGenerateContent,
BedrockConverse,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum NativeToolKind {
WebSearch,
CodeExecution,
FileSearch,
ImageGeneration,
McpServer,
ToolSearch,
GoogleSearch,
}
impl NativeToolKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::WebSearch => "web_search",
Self::CodeExecution => "code_execution",
Self::FileSearch => "file_search",
Self::ImageGeneration => "image_generation",
Self::McpServer => "mcp_server",
Self::ToolSearch => "tool_search",
Self::GoogleSearch => "google_search",
}
}
#[must_use]
pub fn from_tool_type(tool_type: &str) -> Option<Self> {
match tool_type {
"web_search" | "web_search_preview" => Some(Self::WebSearch),
"code_execution" | "code_interpreter" => Some(Self::CodeExecution),
"file_search" => Some(Self::FileSearch),
"image_generation" => Some(Self::ImageGeneration),
"mcp_server" => Some(Self::McpServer),
"tool_search" => Some(Self::ToolSearch),
"google_search" => Some(Self::GoogleSearch),
_ => None,
}
}
}
fn default_prompted_output_template() -> String {
"Always respond with a JSON object that matches this schema:\n\n{schema}\n\nDon't include any text or Markdown fencing before or after."
.to_string()
}
fn default_thinking_tags() -> (String, String) {
("<think>".to_string(), "</think>".to_string())
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum JsonSchemaTransformer {
InlineDefinitions,
}
impl JsonSchemaTransformer {
#[must_use]
pub fn transform_schema(self, schema: &Value) -> Value {
match self {
Self::InlineDefinitions => inline_schema_definitions(schema),
}
}
}
fn inline_schema_definitions(schema: &Value) -> Value {
let mut schema = schema.clone();
let definitions = schema
.get("$defs")
.or_else(|| schema.get("definitions"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
if !definitions.is_empty() {
inline_schema_refs(&mut schema, &definitions);
}
if let Value::Object(object) = &mut schema {
object.remove("$defs");
object.remove("definitions");
}
schema
}
fn inline_schema_refs(value: &mut Value, definitions: &Map<String, Value>) {
match value {
Value::Object(object) => {
if let Some(definition) = object
.get("$ref")
.and_then(Value::as_str)
.and_then(|reference| schema_ref_name(reference, definitions))
.and_then(|name| definitions.get(name))
.cloned()
{
object.remove("$ref");
if object.is_empty() {
*value = definition;
inline_schema_refs(value, definitions);
return;
}
if let Value::Object(definition_object) = definition {
for (key, nested) in definition_object {
object.entry(key).or_insert(nested);
}
}
}
object.remove("$defs");
object.remove("definitions");
for nested in object.values_mut() {
inline_schema_refs(nested, definitions);
}
}
Value::Array(items) => {
for item in items {
inline_schema_refs(item, definitions);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
fn schema_ref_name<'a>(reference: &'a str, definitions: &Map<String, Value>) -> Option<&'a str> {
let name = reference
.strip_prefix("#/$defs/")
.or_else(|| reference.strip_prefix("#/definitions/"))?;
definitions.contains_key(name).then_some(name)
}
fn all_native_tools() -> BTreeSet<NativeToolKind> {
[
NativeToolKind::WebSearch,
NativeToolKind::CodeExecution,
NativeToolKind::FileSearch,
NativeToolKind::ImageGeneration,
NativeToolKind::McpServer,
NativeToolKind::ToolSearch,
NativeToolKind::GoogleSearch,
]
.into_iter()
.collect()
}
const fn no_native_tools() -> BTreeSet<NativeToolKind> {
BTreeSet::new()
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ModelProfile {
pub protocol: ProtocolFamily,
pub supports_tools: bool,
pub supports_tool_return_schema: bool,
pub supports_json_schema_output: bool,
pub supports_json_object_output: bool,
pub supports_image_output: bool,
pub supports_image_input: bool,
pub supports_video_input: bool,
pub supports_audio_input: bool,
pub supports_document_input: bool,
pub supports_inline_system_prompts: bool,
pub supports_thinking: bool,
#[serde(default)]
pub drop_sampling_parameters_when_reasoning: bool,
pub thinking_always_enabled: bool,
#[serde(default = "default_thinking_tags")]
pub thinking_tags: (String, String),
pub ignore_streamed_leading_whitespace: bool,
pub default_structured_output_mode: StructuredOutputMode,
#[serde(default = "default_prompted_output_template")]
pub prompted_output_template: String,
pub native_output_requires_schema_in_instructions: bool,
#[serde(default = "all_native_tools")]
pub supported_native_tools: BTreeSet<NativeToolKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub json_schema_transformer: Option<JsonSchemaTransformer>,
pub message_normalization: MessageNormalization,
}
impl ModelProfile {
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn for_protocol(protocol: ProtocolFamily) -> Self {
let base = Self {
protocol,
supports_tools: true,
supports_tool_return_schema: false,
supports_json_schema_output: false,
supports_json_object_output: false,
supports_image_output: false,
supports_image_input: false,
supports_video_input: false,
supports_audio_input: false,
supports_document_input: false,
supports_inline_system_prompts: false,
supports_thinking: false,
drop_sampling_parameters_when_reasoning: false,
thinking_always_enabled: false,
thinking_tags: default_thinking_tags(),
ignore_streamed_leading_whitespace: false,
default_structured_output_mode: StructuredOutputMode::Tool,
prompted_output_template: default_prompted_output_template(),
native_output_requires_schema_in_instructions: false,
supported_native_tools: no_native_tools(),
json_schema_transformer: None,
message_normalization: MessageNormalization::MergeAdjacentSameRole,
};
match protocol {
ProtocolFamily::OpenAiChatCompletions => Self {
supports_json_schema_output: true,
supports_json_object_output: true,
supports_image_input: true,
supports_inline_system_prompts: true,
supports_thinking: true,
drop_sampling_parameters_when_reasoning: true,
default_structured_output_mode: StructuredOutputMode::NativeJsonSchema,
supported_native_tools: no_native_tools(),
message_normalization: MessageNormalization::MergeAdjacentSameRole,
..base
},
ProtocolFamily::OpenAiResponses => Self {
supports_json_schema_output: true,
supports_json_object_output: true,
supports_image_input: true,
supports_inline_system_prompts: true,
supports_thinking: true,
drop_sampling_parameters_when_reasoning: true,
default_structured_output_mode: StructuredOutputMode::NativeJsonSchema,
supported_native_tools: [
NativeToolKind::WebSearch,
NativeToolKind::CodeExecution,
NativeToolKind::FileSearch,
NativeToolKind::ImageGeneration,
NativeToolKind::McpServer,
NativeToolKind::ToolSearch,
]
.into_iter()
.collect(),
message_normalization: MessageNormalization::PreserveItems,
..base
},
ProtocolFamily::AnthropicMessages => Self {
supports_json_schema_output: true,
supports_json_object_output: false,
supports_image_input: true,
supports_document_input: true,
supports_thinking: true,
drop_sampling_parameters_when_reasoning: true,
default_structured_output_mode: StructuredOutputMode::NativeJsonSchema,
supported_native_tools: no_native_tools(),
message_normalization: MessageNormalization::SystemField,
..base
},
ProtocolFamily::GeminiGenerateContent => Self {
supports_tool_return_schema: true,
supports_json_schema_output: true,
supports_json_object_output: true,
supports_image_input: true,
supports_video_input: true,
supports_audio_input: true,
supports_document_input: true,
supports_thinking: true,
default_structured_output_mode: StructuredOutputMode::NativeJsonSchema,
supported_native_tools: [
NativeToolKind::GoogleSearch,
NativeToolKind::CodeExecution,
]
.into_iter()
.collect(),
message_normalization: MessageNormalization::SystemInstruction,
..base
},
ProtocolFamily::BedrockConverse => Self {
supports_image_input: true,
supports_document_input: true,
default_structured_output_mode: StructuredOutputMode::Tool,
message_normalization: MessageNormalization::SystemField,
..base
},
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StructuredOutputMode {
NativeJsonSchema,
NativeJsonObject,
Tool,
Prompted,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageNormalization {
MergeAdjacentSameRole,
PreserveItems,
SystemField,
SystemInstruction,
WrapInlineSystem,
}