mirror of
https://github.com/katanemo/plano.git
synced 2026-06-17 15:25:17 +02:00
Merge branch 'main' into salmanap/dynamic-models
This commit is contained in:
commit
a2572d0d7f
25 changed files with 472 additions and 160 deletions
|
|
@ -264,10 +264,19 @@ def validate_and_render_schema():
|
|||
for routing_preference in model_provider.get("routing_preferences", []):
|
||||
if routing_preference.get("name") in model_usage_name_keys:
|
||||
raise Exception(
|
||||
f"Duplicate routing preference name \"{routing_preference.get('name')}\", please provide unique name for each routing preference"
|
||||
f'Duplicate routing preference name "{routing_preference.get("name")}", please provide unique name for each routing preference'
|
||||
)
|
||||
model_usage_name_keys.add(routing_preference.get("name"))
|
||||
|
||||
# Warn if both passthrough_auth and access_key are configured
|
||||
if model_provider.get("passthrough_auth") and model_provider.get(
|
||||
"access_key"
|
||||
):
|
||||
print(
|
||||
f"WARNING: Model provider '{model_provider.get('name')}' has both 'passthrough_auth: true' and 'access_key' configured. "
|
||||
f"The access_key will be ignored and the client's Authorization header will be forwarded instead."
|
||||
)
|
||||
|
||||
model_provider["model"] = model_id
|
||||
model_provider["provider_interface"] = provider
|
||||
model_provider_name_set.add(model_provider.get("name"))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
$schema: "http://json-schema.org/draft-07/schema#"
|
||||
$schema: 'http://json-schema.org/draft-07/schema#'
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
|
|
@ -109,12 +109,12 @@ properties:
|
|||
endpoints:
|
||||
type: object
|
||||
patternProperties:
|
||||
"^[a-zA-Z][a-zA-Z0-9_]*$":
|
||||
'^[a-zA-Z][a-zA-Z0-9_]*$':
|
||||
type: object
|
||||
properties:
|
||||
endpoint:
|
||||
type: string
|
||||
pattern: "^.*$"
|
||||
pattern: '^.*$'
|
||||
connect_timeout:
|
||||
type: string
|
||||
protocol:
|
||||
|
|
@ -143,6 +143,9 @@ properties:
|
|||
type: boolean
|
||||
base_url:
|
||||
type: string
|
||||
passthrough_auth:
|
||||
type: boolean
|
||||
description: "When true, forwards the client's Authorization header to upstream instead of using the configured access_key. Useful for routing to services like LiteLLM that validate their own virtual keys."
|
||||
http_host:
|
||||
type: string
|
||||
provider_interface:
|
||||
|
|
@ -187,6 +190,9 @@ properties:
|
|||
type: boolean
|
||||
base_url:
|
||||
type: string
|
||||
passthrough_auth:
|
||||
type: boolean
|
||||
description: "When true, forwards the client's Authorization header to upstream instead of using the configured access_key. Useful for routing to services like LiteLLM that validate their own virtual keys."
|
||||
http_host:
|
||||
type: string
|
||||
provider_interface:
|
||||
|
|
@ -219,7 +225,7 @@ properties:
|
|||
model_aliases:
|
||||
type: object
|
||||
patternProperties:
|
||||
"^.*$":
|
||||
'^.*$':
|
||||
type: object
|
||||
properties:
|
||||
target:
|
||||
|
|
|
|||
37
config/test_passthrough.yaml
Normal file
37
config/test_passthrough.yaml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Test configuration for passthrough_auth feature
|
||||
# This config demonstrates forwarding client's Authorization header to upstream
|
||||
# instead of using a configured access_key.
|
||||
#
|
||||
# Use case: Deploying Plano in front of LiteLLM, OpenRouter, or other LLM proxies
|
||||
# that manage their own API key validation.
|
||||
#
|
||||
# To test:
|
||||
# docker build -t plano-passthrough-test .
|
||||
# docker run -d -p 10000:10000 -v $(pwd)/config/test_passthrough.yaml:/app/arch_config.yaml plano-passthrough-test
|
||||
#
|
||||
# curl http://localhost:10000/v1/chat/completions \
|
||||
# -H "Authorization: Bearer sk-your-virtual-key" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}'
|
||||
|
||||
version: v0.3.0
|
||||
|
||||
listeners:
|
||||
- name: llm
|
||||
type: model
|
||||
port: 10000
|
||||
|
||||
model_providers:
|
||||
# Passthrough auth example - forwards client's Authorization header
|
||||
# Replace base_url with your LiteLLM or proxy endpoint
|
||||
- model: openai/gpt-4o
|
||||
base_url: 'https://litellm.example.com'
|
||||
passthrough_auth: true
|
||||
default: true
|
||||
|
||||
# Example with both passthrough_auth and access_key (access_key will be ignored)
|
||||
# This configuration will trigger a warning during startup
|
||||
- model: openai/gpt-4o-mini
|
||||
base_url: 'https://litellm.example.com'
|
||||
passthrough_auth: true
|
||||
access_key: 'this-will-be-ignored'
|
||||
|
|
@ -402,7 +402,7 @@ async fn handle_agent_chat(
|
|||
// and add it to the conversation history
|
||||
current_messages.push(OpenAIMessage {
|
||||
role: hermesllm::apis::openai::Role::Assistant,
|
||||
content: hermesllm::apis::openai::MessageContent::Text(response_text),
|
||||
content: Some(hermesllm::apis::openai::MessageContent::Text(response_text)),
|
||||
name: Some(agent_name.clone()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
|
|||
|
|
@ -638,7 +638,7 @@ impl ArchFunctionHandler {
|
|||
let system_prompt = self.format_system_prompt(tools)?;
|
||||
processed_messages.push(Message {
|
||||
role: Role::System,
|
||||
content: MessageContent::Text(system_prompt),
|
||||
content: Some(MessageContent::Text(system_prompt)),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -649,8 +649,9 @@ impl ArchFunctionHandler {
|
|||
for (idx, message) in messages.iter().enumerate() {
|
||||
let mut role = message.role.clone();
|
||||
let mut content = match &message.content {
|
||||
MessageContent::Text(text) => text.clone(),
|
||||
MessageContent::Parts(_) => String::new(),
|
||||
Some(MessageContent::Text(text)) => text.clone(),
|
||||
Some(MessageContent::Parts(_)) => String::new(),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
// Handle tool calls
|
||||
|
|
@ -675,7 +676,8 @@ impl ArchFunctionHandler {
|
|||
} else {
|
||||
// Get the tool call from previous message
|
||||
if idx > 0 {
|
||||
if let MessageContent::Text(prev_content) = &messages[idx - 1].content {
|
||||
if let Some(MessageContent::Text(prev_content)) = &messages[idx - 1].content
|
||||
{
|
||||
let mut tool_call_msg = prev_content.clone();
|
||||
|
||||
// Strip markdown code blocks
|
||||
|
|
@ -721,7 +723,7 @@ impl ArchFunctionHandler {
|
|||
|
||||
processed_messages.push(Message {
|
||||
role,
|
||||
content: MessageContent::Text(content),
|
||||
content: Some(MessageContent::Text(content)),
|
||||
name: message.name.clone(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -740,7 +742,7 @@ impl ArchFunctionHandler {
|
|||
// Add extra instruction if provided
|
||||
if let Some(instruction) = extra_instruction {
|
||||
if let Some(last) = processed_messages.last_mut() {
|
||||
if let MessageContent::Text(content) = &mut last.content {
|
||||
if let Some(MessageContent::Text(content)) = &mut last.content {
|
||||
content.push('\n');
|
||||
content.push_str(instruction);
|
||||
}
|
||||
|
|
@ -761,7 +763,7 @@ impl ArchFunctionHandler {
|
|||
// Keep system message if present
|
||||
if let Some(first) = messages.first() {
|
||||
if first.role == Role::System {
|
||||
if let MessageContent::Text(content) = &first.content {
|
||||
if let Some(MessageContent::Text(content)) = &first.content {
|
||||
num_tokens += content.len() / 4; // Approximate 4 chars per token
|
||||
}
|
||||
conversation_idx = 1;
|
||||
|
|
@ -772,7 +774,7 @@ impl ArchFunctionHandler {
|
|||
// Start with message_idx pointing past the end (will be used if no truncation needed)
|
||||
let mut message_idx = messages.len();
|
||||
for i in (conversation_idx..messages.len()).rev() {
|
||||
if let MessageContent::Text(content) = &messages[i].content {
|
||||
if let Some(MessageContent::Text(content)) = &messages[i].content {
|
||||
num_tokens += content.len() / 4;
|
||||
if num_tokens >= max_tokens && messages[i].role == Role::User {
|
||||
// Set message_idx to current position and break
|
||||
|
|
@ -802,7 +804,7 @@ impl ArchFunctionHandler {
|
|||
pub fn prefill_message(&self, mut messages: Vec<Message>, prefill: &str) -> Vec<Message> {
|
||||
messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Text(prefill.to_string()),
|
||||
content: Some(MessageContent::Text(prefill.to_string())),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ mod tests {
|
|||
fn create_test_message(role: Role, content: &str) -> Message {
|
||||
Message {
|
||||
role,
|
||||
content: MessageContent::Text(content.to_string()),
|
||||
content: Some(MessageContent::Text(content.to_string())),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -129,7 +129,7 @@ mod tests {
|
|||
let processed_messages = result.unwrap();
|
||||
// With empty filter chain, should return the original messages unchanged
|
||||
assert_eq!(processed_messages.len(), 1);
|
||||
if let MessageContent::Text(content) = &processed_messages[0].content {
|
||||
if let Some(MessageContent::Text(content)) = &processed_messages[0].content {
|
||||
assert_eq!(content, "Hello world!");
|
||||
} else {
|
||||
panic!("Expected text content");
|
||||
|
|
|
|||
|
|
@ -811,7 +811,7 @@ impl PipelineProcessor {
|
|||
});
|
||||
}
|
||||
|
||||
info!(
|
||||
debug!(
|
||||
"Response from HTTP agent {}: {}",
|
||||
agent.id,
|
||||
String::from_utf8_lossy(&response_bytes)
|
||||
|
|
@ -887,7 +887,7 @@ mod tests {
|
|||
fn create_test_message(role: Role, content: &str) -> Message {
|
||||
Message {
|
||||
role,
|
||||
content: MessageContent::Text(content.to_string()),
|
||||
content: Some(MessageContent::Text(content.to_string())),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
|
|||
|
|
@ -95,7 +95,9 @@ pub async fn router_chat_get_upstream_model(
|
|||
.messages
|
||||
.last()
|
||||
.map_or("None".to_string(), |msg| {
|
||||
msg.content.to_string().replace('\n', "\\n")
|
||||
msg.content
|
||||
.as_ref()
|
||||
.map_or("None".to_string(), |c| c.to_string().replace('\n', "\\n"))
|
||||
});
|
||||
|
||||
const MAX_MESSAGE_LENGTH: usize = 50;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::collections::HashMap;
|
|||
|
||||
use common::configuration::{AgentUsagePreference, OrchestrationPreference};
|
||||
use hermesllm::apis::openai::{ChatCompletionsRequest, Message, MessageContent, Role};
|
||||
use hermesllm::transforms::lib::ExtractText;
|
||||
use serde::{ser::Serialize as SerializeTrait, Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
|
|
@ -181,7 +182,9 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
|||
let messages_vec = messages
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.role != Role::System && m.role != Role::Tool && !m.content.to_string().is_empty()
|
||||
m.role != Role::System
|
||||
&& m.role != Role::Tool
|
||||
&& !m.content.extract_text().is_empty()
|
||||
})
|
||||
.collect::<Vec<&Message>>();
|
||||
|
||||
|
|
@ -190,7 +193,7 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
|||
let mut token_count = ARCH_ORCHESTRATOR_V1_SYSTEM_PROMPT.len() / TOKEN_LENGTH_DIVISOR;
|
||||
let mut selected_messages_list_reversed: Vec<&Message> = vec![];
|
||||
for (selected_messsage_count, message) in messages_vec.iter().rev().enumerate() {
|
||||
let message_token_count = message.content.to_string().len() / TOKEN_LENGTH_DIVISOR;
|
||||
let message_token_count = message.content.extract_text().len() / TOKEN_LENGTH_DIVISOR;
|
||||
token_count += message_token_count;
|
||||
if token_count > self.max_token_length {
|
||||
debug!(
|
||||
|
|
@ -240,7 +243,12 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
|||
.rev()
|
||||
.map(|message| Message {
|
||||
role: message.role.clone(),
|
||||
content: MessageContent::Text(message.content.to_string()),
|
||||
content: Some(MessageContent::Text(
|
||||
message
|
||||
.content
|
||||
.as_ref()
|
||||
.map_or(String::new(), |c| c.to_string()),
|
||||
)),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -262,7 +270,7 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
|||
ChatCompletionsRequest {
|
||||
model: self.orchestration_model.clone(),
|
||||
messages: vec![Message {
|
||||
content: MessageContent::Text(orchestrator_message),
|
||||
content: Some(MessageContent::Text(orchestrator_message)),
|
||||
role: Role::User,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
|
|
@ -539,7 +547,7 @@ If no routes are needed, return an empty list for `route`.
|
|||
|
||||
let req = orchestrator.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -618,7 +626,7 @@ If no routes are needed, return an empty list for `route`.
|
|||
}]);
|
||||
let req = orchestrator.generate_request(&conversation, &usage_preferences);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -689,7 +697,7 @@ If no routes are needed, return an empty list for `route`.
|
|||
|
||||
let req = orchestrator.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -761,7 +769,7 @@ If no routes are needed, return an empty list for `route`.
|
|||
|
||||
let req = orchestrator.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -848,7 +856,7 @@ If no routes are needed, return an empty list for `route`.
|
|||
|
||||
let req = orchestrator.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -940,7 +948,7 @@ If no routes are needed, return an empty list for `route`.
|
|||
|
||||
let req = orchestrator.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -1058,7 +1066,7 @@ If no routes are needed, return an empty list for `route`.
|
|||
|
||||
let req: ChatCompletionsRequest = orchestrator.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::collections::HashMap;
|
|||
|
||||
use common::configuration::{ModelUsagePreference, RoutingPreference};
|
||||
use hermesllm::apis::openai::{ChatCompletionsRequest, Message, MessageContent, Role};
|
||||
use hermesllm::transforms::lib::ExtractText;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
|
|
@ -78,7 +79,9 @@ impl RouterModel for RouterModelV1 {
|
|||
let messages_vec = messages
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.role != Role::System && m.role != Role::Tool && !m.content.to_string().is_empty()
|
||||
m.role != Role::System
|
||||
&& m.role != Role::Tool
|
||||
&& !m.content.extract_text().is_empty()
|
||||
})
|
||||
.collect::<Vec<&Message>>();
|
||||
|
||||
|
|
@ -87,7 +90,7 @@ impl RouterModel for RouterModelV1 {
|
|||
let mut token_count = ARCH_ROUTER_V1_SYSTEM_PROMPT.len() / TOKEN_LENGTH_DIVISOR;
|
||||
let mut selected_messages_list_reversed: Vec<&Message> = vec![];
|
||||
for (selected_messsage_count, message) in messages_vec.iter().rev().enumerate() {
|
||||
let message_token_count = message.content.to_string().len() / TOKEN_LENGTH_DIVISOR;
|
||||
let message_token_count = message.content.extract_text().len() / TOKEN_LENGTH_DIVISOR;
|
||||
token_count += message_token_count;
|
||||
if token_count > self.max_token_length {
|
||||
debug!(
|
||||
|
|
@ -136,7 +139,12 @@ impl RouterModel for RouterModelV1 {
|
|||
Message {
|
||||
role: message.role.clone(),
|
||||
// we can unwrap here because we have already filtered out messages without content
|
||||
content: MessageContent::Text(message.content.to_string()),
|
||||
content: Some(MessageContent::Text(
|
||||
message
|
||||
.content
|
||||
.as_ref()
|
||||
.map_or(String::new(), |c| c.to_string()),
|
||||
)),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -154,7 +162,7 @@ impl RouterModel for RouterModelV1 {
|
|||
ChatCompletionsRequest {
|
||||
model: self.routing_model.clone(),
|
||||
messages: vec![Message {
|
||||
content: MessageContent::Text(router_message),
|
||||
content: Some(MessageContent::Text(router_message)),
|
||||
role: Role::User,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
|
|
@ -344,7 +352,7 @@ Based on your analysis, provide your response in the following JSON formats if y
|
|||
|
||||
let req = router.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -409,7 +417,7 @@ Based on your analysis, provide your response in the following JSON formats if y
|
|||
}]);
|
||||
let req = router.generate_request(&conversation, &usage_preferences);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -469,7 +477,7 @@ Based on your analysis, provide your response in the following JSON formats if y
|
|||
|
||||
let req = router.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -530,7 +538,7 @@ Based on your analysis, provide your response in the following JSON formats if y
|
|||
|
||||
let req = router.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -598,7 +606,7 @@ Based on your analysis, provide your response in the following JSON formats if y
|
|||
|
||||
let req = router.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -667,7 +675,7 @@ Based on your analysis, provide your response in the following JSON formats if y
|
|||
|
||||
let req = router.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
@ -762,7 +770,7 @@ Based on your analysis, provide your response in the following JSON formats if y
|
|||
|
||||
let req: ChatCompletionsRequest = router.generate_request(&conversation, &None);
|
||||
|
||||
let prompt = req.messages[0].content.to_string();
|
||||
let prompt = req.messages[0].content.extract_text();
|
||||
|
||||
assert_eq!(expected_prompt, prompt);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1122,9 +1122,9 @@ pub struct TextBasedSignalAnalyzer {
|
|||
|
||||
impl TextBasedSignalAnalyzer {
|
||||
/// Extract text content from MessageContent, skipping non-text content
|
||||
fn extract_text(content: &hermesllm::apis::openai::MessageContent) -> Option<String> {
|
||||
fn extract_text(content: &Option<hermesllm::apis::openai::MessageContent>) -> Option<String> {
|
||||
match content {
|
||||
hermesllm::apis::openai::MessageContent::Text(text) => Some(text.clone()),
|
||||
Some(hermesllm::apis::openai::MessageContent::Text(text)) => Some(text.clone()),
|
||||
// Tool calls and other structured content are skipped
|
||||
_ => None,
|
||||
}
|
||||
|
|
@ -1941,12 +1941,13 @@ impl Default for TextBasedSignalAnalyzer {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use hermesllm::apis::openai::MessageContent;
|
||||
use hermesllm::transforms::lib::ExtractText;
|
||||
use std::time::Instant;
|
||||
|
||||
fn create_message(role: Role, content: &str) -> Message {
|
||||
Message {
|
||||
role,
|
||||
content: MessageContent::Text(content.to_string()),
|
||||
content: Some(MessageContent::Text(content.to_string())),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -2130,7 +2131,7 @@ mod tests {
|
|||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, msg)| {
|
||||
let text = msg.content.to_string();
|
||||
let text = msg.content.extract_text();
|
||||
(i, msg.role.clone(), NormalizedMessage::from_text(&text))
|
||||
})
|
||||
.collect()
|
||||
|
|
@ -2532,7 +2533,7 @@ mod tests {
|
|||
|content: &str, tool_id: &str, tool_name: &str, args: &str| -> Message {
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Text(content.to_string()),
|
||||
content: Some(MessageContent::Text(content.to_string())),
|
||||
name: None,
|
||||
tool_calls: Some(vec![ToolCall {
|
||||
id: tool_id.to_string(),
|
||||
|
|
@ -2550,7 +2551,7 @@ mod tests {
|
|||
let create_tool_message = |tool_call_id: &str, content: &str| -> Message {
|
||||
Message {
|
||||
role: Role::Tool,
|
||||
content: MessageContent::Text(content.to_string()),
|
||||
content: Some(MessageContent::Text(content.to_string())),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_call_id.to_string()),
|
||||
|
|
@ -2665,7 +2666,7 @@ mod tests {
|
|||
|content: &str, tool_id: &str, tool_name: &str, args: &str| -> Message {
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Text(content.to_string()),
|
||||
content: Some(MessageContent::Text(content.to_string())),
|
||||
name: None,
|
||||
tool_calls: Some(vec![ToolCall {
|
||||
id: tool_id.to_string(),
|
||||
|
|
@ -2683,7 +2684,7 @@ mod tests {
|
|||
let create_tool_message = |tool_call_id: &str, content: &str| -> Message {
|
||||
Message {
|
||||
role: Role::Tool,
|
||||
content: MessageContent::Text(content.to_string()),
|
||||
content: Some(MessageContent::Text(content.to_string())),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_call_id.to_string()),
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ pub struct LlmProvider {
|
|||
pub cluster_name: Option<String>,
|
||||
pub base_url_path_prefix: Option<String>,
|
||||
pub internal: Option<bool>,
|
||||
pub passthrough_auth: Option<bool>,
|
||||
}
|
||||
|
||||
pub trait IntoModels {
|
||||
|
|
@ -368,6 +369,7 @@ impl Default for LlmProvider {
|
|||
cluster_name: None,
|
||||
base_url_path_prefix: None,
|
||||
internal: None,
|
||||
passthrough_auth: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ impl ProviderRequest for ConverseRequest {
|
|||
if let SystemContentBlock::Text { text } = sys_block {
|
||||
openai_messages.push(Message {
|
||||
role: Role::System,
|
||||
content: MessageContent::Text(text.clone()),
|
||||
content: Some(MessageContent::Text(text.clone())),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -258,7 +258,7 @@ impl ProviderRequest for ConverseRequest {
|
|||
|
||||
openai_messages.push(Message {
|
||||
role,
|
||||
content: MessageContent::Text(content),
|
||||
content: Some(MessageContent::Text(content)),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -279,7 +279,7 @@ impl ProviderRequest for ConverseRequest {
|
|||
for msg in messages {
|
||||
match msg.role {
|
||||
crate::apis::openai::Role::System => {
|
||||
if let crate::apis::openai::MessageContent::Text(text) = &msg.content {
|
||||
if let Some(crate::apis::openai::MessageContent::Text(text)) = &msg.content {
|
||||
system_blocks.push(SystemContentBlock::Text { text: text.clone() });
|
||||
}
|
||||
}
|
||||
|
|
@ -290,12 +290,13 @@ impl ProviderRequest for ConverseRequest {
|
|||
_ => continue,
|
||||
};
|
||||
|
||||
let content =
|
||||
if let crate::apis::openai::MessageContent::Text(text) = &msg.content {
|
||||
vec![ContentBlock::Text { text: text.clone() }]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let content = if let Some(crate::apis::openai::MessageContent::Text(text)) =
|
||||
&msg.content
|
||||
{
|
||||
vec![ContentBlock::Text { text: text.clone() }]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
bedrock_messages.push(crate::apis::amazon_bedrock::Message { role, content });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -584,7 +584,7 @@ impl ProviderRequest for MessagesRequest {
|
|||
let system_text = system_messages
|
||||
.iter()
|
||||
.filter_map(|msg| {
|
||||
if let crate::apis::openai::MessageContent::Text(text) = &msg.content {
|
||||
if let Some(crate::apis::openai::MessageContent::Text(text)) = &msg.content {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
None
|
||||
|
|
|
|||
|
|
@ -155,7 +155,8 @@ pub enum Role {
|
|||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Message {
|
||||
pub role: Role,
|
||||
pub content: MessageContent,
|
||||
/// The contents of the message. Required unless tool_calls is specified (for assistant role)
|
||||
pub content: Option<MessageContent>,
|
||||
pub name: Option<String>,
|
||||
/// Tool calls made by the assistant (only present for assistant role)
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
|
|
@ -204,8 +205,7 @@ impl ResponseMessage {
|
|||
content: self
|
||||
.content
|
||||
.as_ref()
|
||||
.map(|s| MessageContent::Text(s.clone()))
|
||||
.unwrap_or(MessageContent::Text(String::new())),
|
||||
.map(|s| MessageContent::Text(s.clone())),
|
||||
name: None, // Response messages don't have names in the same way request messages do
|
||||
tool_calls: self.tool_calls.clone(),
|
||||
tool_call_id: None, // Response messages don't have tool_call_id
|
||||
|
|
@ -233,6 +233,12 @@ impl ExtractText for MessageContent {
|
|||
}
|
||||
}
|
||||
|
||||
impl ExtractText for Option<MessageContent> {
|
||||
fn extract_text(&self) -> String {
|
||||
self.as_ref().map(|c| c.extract_text()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtractText for Vec<ContentPart> {
|
||||
fn extract_text(&self) -> String {
|
||||
self.iter()
|
||||
|
|
@ -247,23 +253,7 @@ impl ExtractText for Vec<ContentPart> {
|
|||
|
||||
impl Display for MessageContent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MessageContent::Text(text) => write!(f, "{}", text),
|
||||
MessageContent::Parts(parts) => {
|
||||
let text_parts: Vec<String> = parts
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
ContentPart::Text { text } => Some(text.clone()),
|
||||
ContentPart::ImageUrl { .. } => {
|
||||
// skip image URLs or their data in text representation
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let combined_text = text_parts.join("\n");
|
||||
write!(f, "{}", combined_text)
|
||||
}
|
||||
}
|
||||
write!(f, "{}", self.extract_text())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -622,8 +612,10 @@ impl ProviderRequest for ChatCompletionsRequest {
|
|||
|
||||
fn extract_messages_text(&self) -> String {
|
||||
self.messages.iter().fold(String::new(), |acc, m| {
|
||||
acc + " "
|
||||
+ &match &m.content {
|
||||
let content_text = m
|
||||
.content
|
||||
.as_ref()
|
||||
.map(|content| match content {
|
||||
MessageContent::Text(text) => text.clone(),
|
||||
MessageContent::Parts(parts) => parts
|
||||
.iter()
|
||||
|
|
@ -633,16 +625,18 @@ impl ProviderRequest for ChatCompletionsRequest {
|
|||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
acc + " " + &content_text
|
||||
})
|
||||
}
|
||||
|
||||
fn get_recent_user_message(&self) -> Option<String> {
|
||||
self.messages.last().and_then(|msg| {
|
||||
match &msg.content {
|
||||
msg.content.as_ref().and_then(|content| match content {
|
||||
MessageContent::Text(text) => Some(text.clone()),
|
||||
MessageContent::Parts(_) => None, // No user message in parts
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -778,7 +772,8 @@ mod tests {
|
|||
|
||||
let message = &deserialized_request.messages[0];
|
||||
assert_eq!(message.role, Role::User);
|
||||
if let MessageContent::Text(content) = &message.content {
|
||||
assert!(message.content.is_some());
|
||||
if let Some(MessageContent::Text(content)) = &message.content {
|
||||
assert_eq!(content, "Hello, world!");
|
||||
} else {
|
||||
panic!("Expected text content");
|
||||
|
|
@ -822,7 +817,8 @@ mod tests {
|
|||
|
||||
let message = &deserialized_request.messages[0];
|
||||
assert_eq!(message.role, Role::User);
|
||||
if let MessageContent::Text(content) = &message.content {
|
||||
assert!(message.content.is_some());
|
||||
if let Some(MessageContent::Text(content)) = &message.content {
|
||||
assert_eq!(content, "Test message");
|
||||
} else {
|
||||
panic!("Expected text content");
|
||||
|
|
@ -947,7 +943,8 @@ mod tests {
|
|||
// Validate first message (user with multimodal content)
|
||||
let user_message = &deserialized_request.messages[0];
|
||||
assert_eq!(user_message.role, Role::User);
|
||||
if let MessageContent::Parts(ref content_parts) = user_message.content {
|
||||
assert!(user_message.content.is_some());
|
||||
if let Some(MessageContent::Parts(ref content_parts)) = user_message.content {
|
||||
assert_eq!(content_parts.len(), 2);
|
||||
|
||||
// Validate text content part
|
||||
|
|
@ -971,7 +968,8 @@ mod tests {
|
|||
// Validate second message (assistant with tool calls)
|
||||
let assistant_message = &deserialized_request.messages[1];
|
||||
assert_eq!(assistant_message.role, Role::Assistant);
|
||||
if let MessageContent::Text(text) = &assistant_message.content {
|
||||
assert!(assistant_message.content.is_some());
|
||||
if let Some(MessageContent::Text(text)) = &assistant_message.content {
|
||||
assert_eq!(
|
||||
text,
|
||||
"I can see a beautiful cityscape. Let me check the weather for you."
|
||||
|
|
@ -997,7 +995,8 @@ mod tests {
|
|||
// Validate third message (tool response)
|
||||
let tool_message = &deserialized_request.messages[2];
|
||||
assert_eq!(tool_message.role, Role::Tool);
|
||||
if let MessageContent::Text(text) = &tool_message.content {
|
||||
assert!(tool_message.content.is_some());
|
||||
if let Some(MessageContent::Text(text)) = &tool_message.content {
|
||||
assert_eq!(text, "Current weather in New York: 72°F, sunny");
|
||||
} else {
|
||||
panic!("Expected text content for tool message");
|
||||
|
|
@ -1061,6 +1060,62 @@ mod tests {
|
|||
assert!((original_temp - serialized_temp).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_message_with_tool_calls_no_content() {
|
||||
// Test that assistant messages can have tool_calls without content
|
||||
let json_with_tool_calls_no_content = json!({
|
||||
"model": "gpt-4",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What's the weather in San Francisco?"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\": \"San Francisco, CA\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Should deserialize successfully
|
||||
let request: ChatCompletionsRequest =
|
||||
serde_json::from_value(json_with_tool_calls_no_content.clone()).unwrap();
|
||||
|
||||
assert_eq!(request.messages.len(), 2);
|
||||
|
||||
// Check user message
|
||||
let user_msg = &request.messages[0];
|
||||
assert_eq!(user_msg.role, Role::User);
|
||||
assert!(user_msg.content.is_some());
|
||||
|
||||
// Check assistant message - should have tool_calls but no content
|
||||
let assistant_msg = &request.messages[1];
|
||||
assert_eq!(assistant_msg.role, Role::Assistant);
|
||||
assert!(assistant_msg.content.is_none());
|
||||
assert!(assistant_msg.tool_calls.is_some());
|
||||
|
||||
let tool_calls = assistant_msg.tool_calls.as_ref().unwrap();
|
||||
assert_eq!(tool_calls.len(), 1);
|
||||
assert_eq!(tool_calls[0].id, "call_123");
|
||||
assert_eq!(tool_calls[0].function.name, "get_weather");
|
||||
|
||||
// Should serialize back without content field
|
||||
let serialized = serde_json::to_value(&request).unwrap();
|
||||
// Verify the assistant message doesn't have a content field in serialized JSON
|
||||
let serialized_assistant_msg = &serialized["messages"][1];
|
||||
assert!(serialized_assistant_msg.get("content").is_none());
|
||||
assert!(serialized_assistant_msg.get("tool_calls").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_provider_trait() {
|
||||
// Test the ApiDefinition trait implementation
|
||||
|
|
@ -1097,7 +1152,7 @@ mod tests {
|
|||
|
||||
let deserialized_user: Message = serde_json::from_value(user_json.clone()).unwrap();
|
||||
assert_eq!(deserialized_user.role, Role::User);
|
||||
if let MessageContent::Text(content) = &deserialized_user.content {
|
||||
if let Some(MessageContent::Text(content)) = &deserialized_user.content {
|
||||
assert_eq!(content, "Hello!");
|
||||
} else {
|
||||
panic!("Expected text content");
|
||||
|
|
@ -1128,7 +1183,7 @@ mod tests {
|
|||
let deserialized_assistant: Message =
|
||||
serde_json::from_value(assistant_json.clone()).unwrap();
|
||||
assert_eq!(deserialized_assistant.role, Role::Assistant);
|
||||
if let MessageContent::Text(content) = &deserialized_assistant.content {
|
||||
if let Some(MessageContent::Text(content)) = &deserialized_assistant.content {
|
||||
assert_eq!(content, "I'll help with that.");
|
||||
} else {
|
||||
panic!("Expected text content");
|
||||
|
|
@ -1154,7 +1209,7 @@ mod tests {
|
|||
|
||||
let deserialized_tool: Message = serde_json::from_value(tool_json.clone()).unwrap();
|
||||
assert_eq!(deserialized_tool.role, Role::Tool);
|
||||
if let MessageContent::Text(content) = &deserialized_tool.content {
|
||||
if let Some(MessageContent::Text(content)) = &deserialized_tool.content {
|
||||
assert_eq!(content, "Weather is sunny");
|
||||
} else {
|
||||
panic!("Expected text content");
|
||||
|
|
@ -1193,7 +1248,7 @@ mod tests {
|
|||
// Test conversion from ResponseMessage to Message
|
||||
let converted = deserialized_response.to_message();
|
||||
assert_eq!(converted.role, Role::Assistant);
|
||||
if let MessageContent::Text(text) = converted.content {
|
||||
if let Some(MessageContent::Text(text)) = converted.content {
|
||||
assert_eq!(text, "Response content");
|
||||
} else {
|
||||
panic!("Expected text content");
|
||||
|
|
|
|||
|
|
@ -1146,7 +1146,7 @@ impl ProviderRequest for ResponsesAPIRequest {
|
|||
.iter()
|
||||
.filter(|msg| msg.role == crate::apis::openai::Role::System)
|
||||
.filter_map(|msg| {
|
||||
if let crate::apis::openai::MessageContent::Text(text) = &msg.content {
|
||||
if let Some(crate::apis::openai::MessageContent::Text(text)) = &msg.content {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
None
|
||||
|
|
@ -1170,7 +1170,8 @@ impl ProviderRequest for ResponsesAPIRequest {
|
|||
if !input_messages.is_empty() {
|
||||
// If there's only one message, use Text format
|
||||
if input_messages.len() == 1 {
|
||||
if let crate::apis::openai::MessageContent::Text(text) = &input_messages[0].content
|
||||
if let Some(crate::apis::openai::MessageContent::Text(text)) =
|
||||
&input_messages[0].content
|
||||
{
|
||||
self.input = crate::apis::openai_responses::InputParam::Text(text.clone());
|
||||
}
|
||||
|
|
@ -1180,7 +1181,8 @@ impl ProviderRequest for ResponsesAPIRequest {
|
|||
let combined_text = input_messages
|
||||
.iter()
|
||||
.filter_map(|msg| {
|
||||
if let crate::apis::openai::MessageContent::Text(text) = &msg.content {
|
||||
if let Some(crate::apis::openai::MessageContent::Text(text)) = &msg.content
|
||||
{
|
||||
Some(format!(
|
||||
"{}: {}",
|
||||
match msg.role {
|
||||
|
|
|
|||
|
|
@ -671,14 +671,16 @@ mod tests {
|
|||
messages: vec![
|
||||
Message {
|
||||
role: Role::System,
|
||||
content: MessageContent::Text("You are a helpful assistant".to_string()),
|
||||
content: Some(MessageContent::Text(
|
||||
"You are a helpful assistant".to_string(),
|
||||
)),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Text("Hello!".to_string()),
|
||||
content: Some(MessageContent::Text("Hello!".to_string())),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -900,7 +902,7 @@ mod tests {
|
|||
model: "gpt-4".to_string(),
|
||||
messages: vec![Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Text("Hello!".to_string()),
|
||||
content: Some(MessageContent::Text("Hello!".to_string())),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -993,14 +995,14 @@ mod tests {
|
|||
messages: vec![
|
||||
Message {
|
||||
role: Role::System,
|
||||
content: MessageContent::Text("You are helpful".to_string()),
|
||||
content: Some(MessageContent::Text("You are helpful".to_string())),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Text("Hello!".to_string()),
|
||||
content: Some(MessageContent::Text("Hello!".to_string())),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ pub fn convert_openai_message_to_anthropic_content(
|
|||
|
||||
// Handle regular content
|
||||
match &message.content {
|
||||
MessageContent::Text(text) => {
|
||||
Some(MessageContent::Text(text)) => {
|
||||
if !text.is_empty() {
|
||||
blocks.push(MessagesContentBlock::Text {
|
||||
text: text.clone(),
|
||||
|
|
@ -196,7 +196,7 @@ pub fn convert_openai_message_to_anthropic_content(
|
|||
});
|
||||
}
|
||||
}
|
||||
MessageContent::Parts(parts) => {
|
||||
Some(MessageContent::Parts(parts)) => {
|
||||
for part in parts {
|
||||
match part {
|
||||
ContentPart::Text { text } => {
|
||||
|
|
@ -212,6 +212,7 @@ pub fn convert_openai_message_to_anthropic_content(
|
|||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ impl TryFrom<MessagesMessage> for Vec<Message> {
|
|||
MessagesMessageContent::Single(text) => {
|
||||
result.push(Message {
|
||||
role: message.role.into(),
|
||||
content: MessageContent::Text(text),
|
||||
content: Some(MessageContent::Text(text)),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -186,7 +186,7 @@ impl TryFrom<MessagesMessage> for Vec<Message> {
|
|||
for (tool_use_id, result_text, _is_error) in tool_results {
|
||||
result.push(Message {
|
||||
role: Role::Tool,
|
||||
content: MessageContent::Text(result_text),
|
||||
content: Some(MessageContent::Text(result_text)),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id),
|
||||
|
|
@ -260,7 +260,7 @@ impl From<MessagesSystemPrompt> for Message {
|
|||
|
||||
Message {
|
||||
role: Role::System,
|
||||
content: system_content,
|
||||
content: Some(system_content),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
|
|
@ -317,16 +317,19 @@ fn convert_anthropic_tool_choice(
|
|||
fn build_openai_content(
|
||||
content_parts: Vec<ContentPart>,
|
||||
tool_calls: &[ToolCall],
|
||||
) -> MessageContent {
|
||||
if content_parts.len() == 1 && tool_calls.is_empty() {
|
||||
) -> Option<MessageContent> {
|
||||
if content_parts.is_empty() && !tool_calls.is_empty() {
|
||||
// For assistant messages with only tool calls, content is optional
|
||||
None
|
||||
} else if content_parts.len() == 1 && tool_calls.is_empty() {
|
||||
match &content_parts[0] {
|
||||
ContentPart::Text { text } => MessageContent::Text(text.clone()),
|
||||
_ => MessageContent::Parts(content_parts),
|
||||
ContentPart::Text { text } => Some(MessageContent::Text(text.clone())),
|
||||
_ => Some(MessageContent::Parts(content_parts)),
|
||||
}
|
||||
} else if content_parts.is_empty() {
|
||||
MessageContent::Text("".to_string())
|
||||
Some(MessageContent::Text("".to_string()))
|
||||
} else {
|
||||
MessageContent::Parts(content_parts)
|
||||
Some(MessageContent::Parts(content_parts))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ use crate::apis::openai_responses::{
|
|||
ResponsesAPIRequest, Tool as ResponsesTool, ToolChoice as ResponsesToolChoice,
|
||||
};
|
||||
use crate::clients::TransformError;
|
||||
use crate::transforms::lib::ExtractText;
|
||||
use crate::transforms::lib::*;
|
||||
use crate::transforms::*;
|
||||
|
||||
|
|
@ -48,7 +47,7 @@ impl TryFrom<ResponsesInputConverter> for Vec<Message> {
|
|||
if let Some(instructions) = converter.instructions {
|
||||
messages.push(Message {
|
||||
role: Role::System,
|
||||
content: MessageContent::Text(instructions),
|
||||
content: Some(MessageContent::Text(instructions)),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
|
|
@ -58,7 +57,7 @@ impl TryFrom<ResponsesInputConverter> for Vec<Message> {
|
|||
// Add the user message
|
||||
messages.push(Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Text(text),
|
||||
content: Some(MessageContent::Text(text)),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
|
|
@ -74,7 +73,7 @@ impl TryFrom<ResponsesInputConverter> for Vec<Message> {
|
|||
if let Some(instructions) = converter.instructions {
|
||||
converted_messages.push(Message {
|
||||
role: Role::System,
|
||||
content: MessageContent::Text(instructions),
|
||||
content: Some(MessageContent::Text(instructions)),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
|
|
@ -154,7 +153,7 @@ impl TryFrom<ResponsesInputConverter> for Vec<Message> {
|
|||
|
||||
converted_messages.push(Message {
|
||||
role,
|
||||
content,
|
||||
content: Some(content),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
|
|
@ -174,11 +173,7 @@ impl TryFrom<ResponsesInputConverter> for Vec<Message> {
|
|||
|
||||
impl From<Message> for MessagesSystemPrompt {
|
||||
fn from(val: Message) -> Self {
|
||||
let system_text = match val.content {
|
||||
MessageContent::Text(text) => text,
|
||||
MessageContent::Parts(parts) => parts.extract_text(),
|
||||
};
|
||||
MessagesSystemPrompt::Single(system_text)
|
||||
MessagesSystemPrompt::Single(val.content.extract_text())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +186,8 @@ impl TryFrom<Message> for MessagesMessage {
|
|||
Role::Assistant => MessagesRole::Assistant,
|
||||
Role::Tool => {
|
||||
// Tool messages become user messages with tool results
|
||||
// Extract content text first, before moving tool_call_id
|
||||
let content_text = message.content.extract_text();
|
||||
let tool_call_id = message.tool_call_id.ok_or_else(|| {
|
||||
TransformError::MissingField(
|
||||
"tool_call_id required for Tool messages".to_string(),
|
||||
|
|
@ -204,7 +201,7 @@ impl TryFrom<Message> for MessagesMessage {
|
|||
tool_use_id: tool_call_id,
|
||||
is_error: None,
|
||||
content: ToolResultContent::Blocks(vec![MessagesContentBlock::Text {
|
||||
text: message.content.extract_text(),
|
||||
text: content_text,
|
||||
cache_control: None,
|
||||
}]),
|
||||
cache_control: None,
|
||||
|
|
@ -248,12 +245,12 @@ impl TryFrom<Message> for BedrockMessage {
|
|||
Role::User => {
|
||||
// Convert user message content to content blocks
|
||||
match message.content {
|
||||
MessageContent::Text(text) => {
|
||||
Some(MessageContent::Text(text)) => {
|
||||
if !text.is_empty() {
|
||||
content_blocks.push(ContentBlock::Text { text });
|
||||
}
|
||||
}
|
||||
MessageContent::Parts(parts) => {
|
||||
Some(MessageContent::Parts(parts)) => {
|
||||
// Convert OpenAI content parts to Bedrock ContentBlocks
|
||||
for part in parts {
|
||||
match part {
|
||||
|
|
@ -293,6 +290,9 @@ impl TryFrom<Message> for BedrockMessage {
|
|||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Empty content for user - shouldn't happen but handle gracefully
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have at least one content block
|
||||
|
|
@ -550,10 +550,7 @@ impl TryFrom<ChatCompletionsRequest> for ConverseRequest {
|
|||
for message in req.messages {
|
||||
match message.role {
|
||||
Role::System => {
|
||||
let system_text = match message.content {
|
||||
MessageContent::Text(text) => text,
|
||||
MessageContent::Parts(parts) => parts.extract_text(),
|
||||
};
|
||||
let system_text = message.content.extract_text();
|
||||
system_messages.push(SystemContentBlock::Text { text: system_text });
|
||||
}
|
||||
_ => {
|
||||
|
|
@ -778,14 +775,16 @@ mod tests {
|
|||
messages: vec![
|
||||
Message {
|
||||
role: Role::System,
|
||||
content: MessageContent::Text("You are a helpful assistant.".to_string()),
|
||||
content: Some(MessageContent::Text(
|
||||
"You are a helpful assistant.".to_string(),
|
||||
)),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Text("Hello, how are you?".to_string()),
|
||||
content: Some(MessageContent::Text("Hello, how are you?".to_string())),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
|
|
@ -840,7 +839,7 @@ mod tests {
|
|||
model: "gpt-4".to_string(),
|
||||
messages: vec![Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Text("What's the weather like?".to_string()),
|
||||
content: Some(MessageContent::Text("What's the weather like?".to_string())),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
|
|
@ -907,7 +906,7 @@ mod tests {
|
|||
model: "gpt-4".to_string(),
|
||||
messages: vec![Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Text("Help me with something".to_string()),
|
||||
content: Some(MessageContent::Text("Help me with something".to_string())),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
|
|
@ -950,28 +949,30 @@ mod tests {
|
|||
messages: vec![
|
||||
Message {
|
||||
role: Role::System,
|
||||
content: MessageContent::Text("Be concise".to_string()),
|
||||
content: Some(MessageContent::Text("Be concise".to_string())),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Text("Hello".to_string()),
|
||||
content: Some(MessageContent::Text("Hello".to_string())),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
},
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Text("Hi there! How can I help you?".to_string()),
|
||||
content: Some(MessageContent::Text(
|
||||
"Hi there! How can I help you?".to_string(),
|
||||
)),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Text("What's 2+2?".to_string()),
|
||||
content: Some(MessageContent::Text("What's 2+2?".to_string())),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
|
|
@ -1009,7 +1010,7 @@ mod tests {
|
|||
fn test_openai_message_to_bedrock_conversion() {
|
||||
let openai_message = Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Text("Test message".to_string()),
|
||||
content: Some(MessageContent::Text("Test message".to_string())),
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
|
|
|
|||
|
|
@ -158,6 +158,23 @@ impl StreamContext {
|
|||
}
|
||||
|
||||
fn modify_auth_headers(&mut self) -> Result<(), ServerError> {
|
||||
if self.llm_provider().passthrough_auth == Some(true) {
|
||||
// Check if client provided an Authorization header
|
||||
if self.get_http_request_header("Authorization").is_none() {
|
||||
warn!(
|
||||
"[PLANO_REQ_ID:{}] AUTH_PASSTHROUGH: passthrough_auth enabled but no Authorization header present in client request",
|
||||
self.request_identifier()
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"[PLANO_REQ_ID:{}] AUTH_PASSTHROUGH: preserving client Authorization header for provider '{}'",
|
||||
self.request_identifier(),
|
||||
self.llm_provider().name
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let llm_provider_api_key_value =
|
||||
self.llm_provider()
|
||||
.access_key
|
||||
|
|
@ -795,16 +812,11 @@ impl HttpContext for StreamContext {
|
|||
//We need to update the upstream path if there is a variation for a provider like Gemini/Groq, etc.
|
||||
self.update_upstream_path(&request_path);
|
||||
|
||||
if self.llm_provider().endpoint.is_some() {
|
||||
self.add_http_request_header(
|
||||
ARCH_ROUTING_HEADER,
|
||||
&self
|
||||
.llm_provider()
|
||||
.cluster_name
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
);
|
||||
// Clone cluster_name to avoid borrowing self while calling add_http_request_header (which requires mut self)
|
||||
let cluster_name_opt = self.llm_provider().cluster_name.clone();
|
||||
|
||||
if let Some(cluster_name) = cluster_name_opt {
|
||||
self.add_http_request_header(ARCH_ROUTING_HEADER, &cluster_name);
|
||||
} else {
|
||||
self.add_http_request_header(
|
||||
ARCH_ROUTING_HEADER,
|
||||
|
|
|
|||
|
|
@ -728,6 +728,75 @@ Configure routing preferences for dynamic model selection:
|
|||
- name: creative_writing
|
||||
description: creative content generation, storytelling, and writing assistance
|
||||
|
||||
.. _passthrough_auth:
|
||||
|
||||
Passthrough Authentication
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
When deploying Plano in front of LLM proxy services that manage their own API key validation (such as LiteLLM, OpenRouter, or custom gateways), you may want to forward the client's original ``Authorization`` header instead of replacing it with a configured ``access_key``.
|
||||
|
||||
The ``passthrough_auth`` option enables this behavior:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
llm_providers:
|
||||
# Forward client's Authorization header to LiteLLM
|
||||
- model: openai/gpt-4o-litellm
|
||||
base_url: https://litellm.example.com
|
||||
passthrough_auth: true
|
||||
default: true
|
||||
|
||||
# Forward to OpenRouter
|
||||
- model: openai/claude-3-opus
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
passthrough_auth: true
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Client sends a request with ``Authorization: Bearer <virtual-key>``
|
||||
2. Plano preserves this header instead of replacing it with ``access_key``
|
||||
3. The upstream service (e.g., LiteLLM) validates the virtual key
|
||||
4. Response flows back through Plano to the client
|
||||
|
||||
**Use Cases:**
|
||||
|
||||
- **LiteLLM Integration**: Route requests to LiteLLM which manages virtual keys and rate limits
|
||||
- **OpenRouter**: Forward requests to OpenRouter with per-user API keys
|
||||
- **Custom API Gateways**: Integrate with internal gateways that have their own authentication
|
||||
- **Multi-tenant Deployments**: Allow different clients to use their own credentials
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
- When ``passthrough_auth: true`` is set, the ``access_key`` field is ignored (a warning is logged if both are configured)
|
||||
- If the client doesn't provide an ``Authorization`` header, the request is forwarded without authentication (upstream will likely return 401)
|
||||
- The ``base_url`` is typically required when using ``passthrough_auth``
|
||||
|
||||
**Configuration with LiteLLM example:**
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
# plano_config.yaml
|
||||
version: v0.3.0
|
||||
|
||||
listeners:
|
||||
- name: llm
|
||||
type: model
|
||||
port: 10000
|
||||
|
||||
model_providers:
|
||||
- model: openai/gpt-4o
|
||||
base_url: https://litellm.example.com
|
||||
passthrough_auth: true
|
||||
default: true
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Client request - virtual key is forwarded to upstream
|
||||
curl http://localhost:10000/v1/chat/completions \
|
||||
-H "Authorization: Bearer sk-litellm-virtual-key-abc123" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}'
|
||||
|
||||
Model Selection Guidelines
|
||||
--------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,22 @@
|
|||
|
||||
# Arch Gateway configuration version
|
||||
version: v0.3.0
|
||||
|
||||
|
||||
# External HTTP agents - API type is controlled by request path (/v1/responses, /v1/messages, /v1/chat/completions)
|
||||
agents:
|
||||
- id: weather_agent # Example agent for weather
|
||||
- id: weather_agent # Example agent for weather
|
||||
url: http://host.docker.internal:10510
|
||||
|
||||
- id: flight_agent # Example agent for flights
|
||||
- id: flight_agent # Example agent for flights
|
||||
url: http://host.docker.internal:10520
|
||||
|
||||
|
||||
# MCP filters applied to requests/responses (e.g., input validation, query rewriting)
|
||||
filters:
|
||||
- id: input_guards # Example filter for input validation
|
||||
- id: input_guards # Example filter for input validation
|
||||
url: http://host.docker.internal:10500
|
||||
# type: mcp (default)
|
||||
# transport: streamable-http (default)
|
||||
# tool: input_guards (default - same as filter id)
|
||||
|
||||
|
||||
# LLM provider configurations with API keys and model routing
|
||||
model_providers:
|
||||
- model: openai/gpt-4o
|
||||
|
|
@ -36,6 +32,12 @@ model_providers:
|
|||
- model: mistral/ministral-3b-latest
|
||||
access_key: $MISTRAL_API_KEY
|
||||
|
||||
# Example: Passthrough authentication for LiteLLM or similar proxies
|
||||
# When passthrough_auth is true, client's Authorization header is forwarded
|
||||
# instead of using the configured access_key
|
||||
- model: openai/gpt-4o-litellm
|
||||
base_url: https://litellm.example.com
|
||||
passthrough_auth: true
|
||||
|
||||
# Model aliases - use friendly names instead of full provider model names
|
||||
model_aliases:
|
||||
|
|
@ -45,7 +47,6 @@ model_aliases:
|
|||
smart-llm:
|
||||
target: gpt-4o
|
||||
|
||||
|
||||
# HTTP listeners - entry points for agent routing, prompt targets, and direct LLM access
|
||||
listeners:
|
||||
# Agent listener for routing requests to multiple agents
|
||||
|
|
@ -73,7 +74,6 @@ listeners:
|
|||
port: 10000
|
||||
# This listener is used for prompt_targets and function calling
|
||||
|
||||
|
||||
# Reusable service endpoints
|
||||
endpoints:
|
||||
app_server:
|
||||
|
|
@ -83,7 +83,6 @@ endpoints:
|
|||
mistral_local:
|
||||
endpoint: 127.0.0.1:8001
|
||||
|
||||
|
||||
# Prompt targets for function calling and API orchestration
|
||||
prompt_targets:
|
||||
- name: get_current_weather
|
||||
|
|
@ -103,7 +102,6 @@ prompt_targets:
|
|||
path: /weather
|
||||
http_method: POST
|
||||
|
||||
|
||||
# OpenTelemetry tracing configuration
|
||||
tracing:
|
||||
# Random sampling percentage (1-100)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,15 @@ listeners:
|
|||
model: ministral-3b-latest
|
||||
name: mistral/ministral-3b-latest
|
||||
provider_interface: mistral
|
||||
- base_url: https://litellm.example.com
|
||||
cluster_name: openai_litellm.example.com
|
||||
endpoint: litellm.example.com
|
||||
model: gpt-4o-litellm
|
||||
name: openai/gpt-4o-litellm
|
||||
passthrough_auth: true
|
||||
port: 443
|
||||
protocol: https
|
||||
provider_interface: openai
|
||||
name: egress_traffic
|
||||
port: 12000
|
||||
timeout: 30s
|
||||
|
|
@ -91,6 +100,15 @@ model_providers:
|
|||
model: ministral-3b-latest
|
||||
name: mistral/ministral-3b-latest
|
||||
provider_interface: mistral
|
||||
- base_url: https://litellm.example.com
|
||||
cluster_name: openai_litellm.example.com
|
||||
endpoint: litellm.example.com
|
||||
model: gpt-4o-litellm
|
||||
name: openai/gpt-4o-litellm
|
||||
passthrough_auth: true
|
||||
port: 443
|
||||
protocol: https
|
||||
provider_interface: openai
|
||||
- internal: true
|
||||
model: Arch-Function
|
||||
name: arch-function
|
||||
|
|
|
|||
|
|
@ -22,6 +22,81 @@ LLM_GATEWAY_ENDPOINT = os.getenv(
|
|||
# =============================================================================
|
||||
|
||||
|
||||
def test_assistant_message_with_null_content_and_tool_calls():
|
||||
"""Test that assistant messages with null content and tool_calls are properly handled"""
|
||||
logger.info(
|
||||
"Testing assistant message with null content and tool_calls (multi-turn conversation)"
|
||||
)
|
||||
|
||||
base_url = LLM_GATEWAY_ENDPOINT.replace("/v1/chat/completions", "")
|
||||
client = openai.OpenAI(
|
||||
api_key="test-key",
|
||||
base_url=f"{base_url}/v1",
|
||||
)
|
||||
|
||||
# Simulate a multi-turn conversation where:
|
||||
# 1. User asks a question
|
||||
# 2. Assistant makes a tool call (with null content)
|
||||
# 3. Tool responds
|
||||
# 4. Assistant should provide final answer
|
||||
completion = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
max_tokens=500,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a weather assistant. Use the get_weather tool to fetch weather information.",
|
||||
},
|
||||
{"role": "user", "content": "What's the weather in Seattle?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None, # This is the key test - null content with tool_calls
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_test123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Seattle"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_test123",
|
||||
"content": '{"location": "Seattle", "temperature": "10°C", "condition": "Partly cloudy"}',
|
||||
},
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather information for a city",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string", "description": "City name"}
|
||||
},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
response_content = completion.choices[0].message.content
|
||||
logger.info(f"Response after tool call: {response_content}")
|
||||
|
||||
# The assistant should provide a final response using the tool result
|
||||
assert response_content is not None
|
||||
assert len(response_content) > 0
|
||||
logger.info(
|
||||
"✓ Assistant message with null content and tool_calls handled correctly"
|
||||
)
|
||||
|
||||
|
||||
def test_openai_client_with_alias_arch_summarize_v1():
|
||||
"""Test OpenAI client using model alias 'arch.summarize.v1' which should resolve to '4o-mini'"""
|
||||
logger.info("Testing OpenAI client with alias 'arch.summarize.v1' -> '4o-mini'")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue