Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- crates/forge_app/src/dto/anthropic/error.rs +8 -0
- crates/forge_app/src/dto/anthropic/mod.rs +9 -0
- crates/forge_app/src/dto/anthropic/request.rs +872 -0
- crates/forge_app/src/dto/anthropic/response.rs +881 -0
- crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs +187 -0
- crates/forge_app/src/dto/anthropic/transforms/capitalize_tool_names.rs +116 -0
- crates/forge_app/src/dto/anthropic/transforms/claude_code.md +1 -0
- crates/forge_app/src/dto/anthropic/transforms/drop_invalid_toolcalls.rs +136 -0
- crates/forge_app/src/dto/anthropic/transforms/enforce_schema.rs +138 -0
- crates/forge_app/src/dto/anthropic/transforms/mcp_tool_names.rs +116 -0
- crates/forge_app/src/dto/anthropic/transforms/mod.rs +19 -0
- crates/forge_app/src/dto/anthropic/transforms/reasoning_transform.rs +135 -0
- crates/forge_app/src/dto/anthropic/transforms/remove_output_format.rs +75 -0
- crates/forge_app/src/dto/anthropic/transforms/sanitize_tool_ids.rs +223 -0
- crates/forge_app/src/dto/anthropic/transforms/set_cache.rs +267 -0
- crates/forge_app/src/dto/google/mod.rs +5 -0
- crates/forge_app/src/dto/google/request.rs +982 -0
- crates/forge_app/src/dto/google/response.rs +790 -0
- crates/forge_app/src/dto/openai/copilot_model.rs +407 -0
- crates/forge_app/src/dto/openai/error.rs +193 -0
- crates/forge_app/src/dto/openai/fixtures/chutes_api_response.json +16 -0
- crates/forge_app/src/dto/openai/fixtures/chutes_completion_response.json +23 -0
- crates/forge_app/src/dto/openai/fixtures/model_invalid_pricing.json +8 -0
- crates/forge_app/src/dto/openai/fixtures/model_mixed_pricing.json +9 -0
- crates/forge_app/src/dto/openai/fixtures/model_no_pricing.json +4 -0
- crates/forge_app/src/dto/openai/fixtures/model_numeric_pricing.json +8 -0
- crates/forge_app/src/dto/openai/fixtures/model_scientific_notation.json +8 -0
- crates/forge_app/src/dto/openai/fixtures/model_string_pricing.json +8 -0
- crates/forge_app/src/dto/openai/fixtures/orcarouter_api_response.json +31 -0
- crates/forge_app/src/dto/openai/fixtures/zai_api_delta_response.json +1 -0
- crates/forge_app/src/dto/openai/fixtures/zai_api_response.json +1 -0
- crates/forge_app/src/dto/openai/mod.rs +17 -0
- crates/forge_app/src/dto/openai/model.rs +356 -0
- crates/forge_app/src/dto/openai/reasoning.rs +18 -0
- crates/forge_app/src/dto/openai/request.rs +937 -0
- crates/forge_app/src/dto/openai/response.rs +1120 -0
- crates/forge_app/src/dto/openai/responses.jsonl +20 -0
- crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__assistant_message_with_tool_call_conversion.snap +18 -0
- crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__message_with_special_chars.snap +8 -0
- crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_conversion.snap +10 -0
- crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_typescript_code.snap +10 -0
- crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_with_special_chars.snap +10 -0
- crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__user_message_conversion.snap +8 -0
- crates/forge_app/src/dto/openai/tool_choice.rs +81 -0
- crates/forge_app/src/dto/openai/transformers/default_reasoning_content.rs +99 -0
- crates/forge_app/src/dto/openai/transformers/drop_tool_call.rs +89 -0
- crates/forge_app/src/dto/openai/transformers/ensure_system_first.rs +198 -0
- crates/forge_app/src/dto/openai/transformers/github_copilot_reasoning.rs +126 -0
- crates/forge_app/src/dto/openai/transformers/make_cerebras_compat.rs +43 -0
- crates/forge_app/src/dto/openai/transformers/make_openai_compat.rs +112 -0
crates/forge_app/src/dto/anthropic/error.rs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use serde::Deserialize;
|
| 2 |
+
|
| 3 |
+
#[derive(Debug, Deserialize, Clone, PartialEq, thiserror::Error)]
|
| 4 |
+
#[serde(rename_all = "snake_case", tag = "type")]
|
| 5 |
+
pub enum Error {
|
| 6 |
+
#[error("Overload error: {message}")]
|
| 7 |
+
OverloadedError { message: String },
|
| 8 |
+
}
|
crates/forge_app/src/dto/anthropic/mod.rs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod error;
|
| 2 |
+
mod request;
|
| 3 |
+
mod response;
|
| 4 |
+
mod transforms;
|
| 5 |
+
|
| 6 |
+
pub use error::*;
|
| 7 |
+
pub use request::*;
|
| 8 |
+
pub use response::*;
|
| 9 |
+
pub use transforms::*;
|
crates/forge_app/src/dto/anthropic/request.rs
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use derive_setters::Setters;
|
| 2 |
+
use forge_domain::{ContextMessage, Image};
|
| 3 |
+
use serde::{Deserialize, Serialize};
|
| 4 |
+
|
| 5 |
+
#[derive(Serialize, Default, Setters)]
|
| 6 |
+
#[setters(into, strip_option)]
|
| 7 |
+
pub struct Request {
|
| 8 |
+
pub max_tokens: u64,
|
| 9 |
+
pub messages: Vec<Message>,
|
| 10 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 11 |
+
pub model: Option<String>,
|
| 12 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 13 |
+
pub metadata: Option<Metadata>,
|
| 14 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 15 |
+
pub stop_sequence: Option<String>,
|
| 16 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 17 |
+
pub stream: Option<bool>,
|
| 18 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 19 |
+
pub system: Option<Vec<SystemMessage>>,
|
| 20 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 21 |
+
pub temperature: Option<f32>,
|
| 22 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 23 |
+
pub tool_choice: Option<ToolChoice>,
|
| 24 |
+
#[serde(skip_serializing_if = "Vec::is_empty")]
|
| 25 |
+
pub tools: Vec<ToolDefinition>,
|
| 26 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 27 |
+
pub top_k: Option<u64>,
|
| 28 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 29 |
+
pub top_p: Option<f32>,
|
| 30 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 31 |
+
pub thinking: Option<Thinking>,
|
| 32 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 33 |
+
pub output_config: Option<OutputConfig>,
|
| 34 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 35 |
+
pub output_format: Option<OutputFormat>,
|
| 36 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 37 |
+
pub anthropic_version: Option<String>,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
#[derive(Serialize, Default)]
|
| 41 |
+
pub struct SystemMessage {
|
| 42 |
+
pub r#type: String,
|
| 43 |
+
pub text: String,
|
| 44 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 45 |
+
pub cache_control: Option<CacheControl>,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
impl SystemMessage {
|
| 49 |
+
pub fn cached(mut self, cached: bool) -> Self {
|
| 50 |
+
self.cache_control = if cached {
|
| 51 |
+
Some(CacheControl::Ephemeral)
|
| 52 |
+
} else {
|
| 53 |
+
None
|
| 54 |
+
};
|
| 55 |
+
self
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
pub fn is_cached(&self) -> bool {
|
| 59 |
+
self.cache_control.is_some()
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/// Anthropic's `thinking` request field. Opus 4.7 rejects the `Enabled` shape
|
| 64 |
+
/// and the orchestrator applies model-specific reasoning normalization before
|
| 65 |
+
/// request conversion.
|
| 66 |
+
#[derive(Serialize, Debug, PartialEq, Eq)]
|
| 67 |
+
#[serde(tag = "type", rename_all = "lowercase")]
|
| 68 |
+
pub enum Thinking {
|
| 69 |
+
Enabled {
|
| 70 |
+
budget_tokens: u64,
|
| 71 |
+
},
|
| 72 |
+
Adaptive {
|
| 73 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 74 |
+
display: Option<ThinkingDisplay>,
|
| 75 |
+
},
|
| 76 |
+
Disabled,
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/// On Opus 4.7 adaptive thinking content is omitted from responses unless
|
| 80 |
+
/// `Summarized` is requested explicitly.
|
| 81 |
+
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
|
| 82 |
+
#[serde(rename_all = "lowercase")]
|
| 83 |
+
pub enum ThinkingDisplay {
|
| 84 |
+
Summarized,
|
| 85 |
+
Omitted,
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
|
| 89 |
+
#[serde(rename_all = "lowercase")]
|
| 90 |
+
pub enum OutputEffort {
|
| 91 |
+
Low,
|
| 92 |
+
Medium,
|
| 93 |
+
High,
|
| 94 |
+
XHigh,
|
| 95 |
+
Max,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
#[derive(Serialize, Debug, PartialEq, Eq)]
|
| 99 |
+
pub struct OutputConfig {
|
| 100 |
+
pub effort: OutputEffort,
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
#[derive(Serialize, Debug, PartialEq)]
|
| 104 |
+
#[serde(tag = "type", rename_all = "snake_case")]
|
| 105 |
+
pub enum OutputFormat {
|
| 106 |
+
#[serde(rename = "json_schema")]
|
| 107 |
+
JsonSchema { schema: schemars::Schema },
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
impl TryFrom<forge_domain::Context> for Request {
|
| 111 |
+
type Error = anyhow::Error;
|
| 112 |
+
fn try_from(request: forge_domain::Context) -> std::result::Result<Self, Self::Error> {
|
| 113 |
+
let system_messages = request
|
| 114 |
+
.messages
|
| 115 |
+
.iter()
|
| 116 |
+
.filter_map(|msg| match &**msg {
|
| 117 |
+
ContextMessage::Text(msg) if msg.has_role(forge_domain::Role::System) => {
|
| 118 |
+
Some(SystemMessage {
|
| 119 |
+
r#type: "text".to_string(),
|
| 120 |
+
text: msg.content.clone(),
|
| 121 |
+
cache_control: None,
|
| 122 |
+
})
|
| 123 |
+
}
|
| 124 |
+
_ => None,
|
| 125 |
+
})
|
| 126 |
+
.collect::<Vec<_>>();
|
| 127 |
+
|
| 128 |
+
// Gate on the domain rule so inherited configs with `enabled: None` but
|
| 129 |
+
// a positive effort / `max_tokens` still emit reasoning on the wire.
|
| 130 |
+
let reasoning_on = request.is_reasoning_supported();
|
| 131 |
+
let (thinking, output_config) = if reasoning_on && let Some(reasoning) = request.reasoning {
|
| 132 |
+
// Adaptive thinking on 4.7 hides reasoning content by default; opting
|
| 133 |
+
// into reasoning should surface it unless the caller set `exclude`.
|
| 134 |
+
let adaptive_display = if reasoning.exclude == Some(true) {
|
| 135 |
+
Some(ThinkingDisplay::Omitted)
|
| 136 |
+
} else {
|
| 137 |
+
Some(ThinkingDisplay::Summarized)
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
let thinking = if let Some(budget) = reasoning.max_tokens {
|
| 141 |
+
Thinking::Enabled { budget_tokens: budget as u64 }
|
| 142 |
+
} else {
|
| 143 |
+
Thinking::Adaptive { display: adaptive_display }
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
// `Effort::None` is an explicit opt-out; `is_reasoning_supported`
|
| 147 |
+
// already filters it, but guard here so it can never become a stray
|
| 148 |
+
// `output_config.effort`.
|
| 149 |
+
let output_config = reasoning.effort.and_then(|effort| {
|
| 150 |
+
let output_effort = match effort {
|
| 151 |
+
forge_domain::Effort::None => return None,
|
| 152 |
+
forge_domain::Effort::Minimal | forge_domain::Effort::Low => OutputEffort::Low,
|
| 153 |
+
forge_domain::Effort::Medium => OutputEffort::Medium,
|
| 154 |
+
forge_domain::Effort::High => OutputEffort::High,
|
| 155 |
+
forge_domain::Effort::XHigh => OutputEffort::XHigh,
|
| 156 |
+
forge_domain::Effort::Max => OutputEffort::Max,
|
| 157 |
+
};
|
| 158 |
+
Some(OutputConfig { effort: output_effort })
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
(Some(thinking), output_config)
|
| 162 |
+
} else {
|
| 163 |
+
(None, None)
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
Ok(Self {
|
| 167 |
+
messages: request
|
| 168 |
+
.messages
|
| 169 |
+
.into_iter()
|
| 170 |
+
.filter(|message| !message.has_role(forge_domain::Role::System))
|
| 171 |
+
.map(|msg| Message::try_from(msg.message))
|
| 172 |
+
.collect::<std::result::Result<Vec<_>, _>>()?,
|
| 173 |
+
tools: request
|
| 174 |
+
.tools
|
| 175 |
+
.into_iter()
|
| 176 |
+
.map(ToolDefinition::try_from)
|
| 177 |
+
.collect::<std::result::Result<Vec<_>, _>>()?,
|
| 178 |
+
system: Some(system_messages),
|
| 179 |
+
temperature: request.temperature.map(|t| t.value()),
|
| 180 |
+
top_p: request.top_p.map(|t| t.value()),
|
| 181 |
+
top_k: request.top_k.map(|t| t.value() as u64),
|
| 182 |
+
tool_choice: request.tool_choice.map(ToolChoice::from),
|
| 183 |
+
stream: Some(request.stream.unwrap_or(true)),
|
| 184 |
+
thinking,
|
| 185 |
+
output_config,
|
| 186 |
+
output_format: request.response_format.and_then(|rf| match rf {
|
| 187 |
+
forge_domain::ResponseFormat::Text => {
|
| 188 |
+
// Anthropic doesn't have a "text" output format, so we skip it
|
| 189 |
+
None
|
| 190 |
+
}
|
| 191 |
+
forge_domain::ResponseFormat::JsonSchema(schema) => {
|
| 192 |
+
Some(OutputFormat::JsonSchema { schema: *schema })
|
| 193 |
+
}
|
| 194 |
+
}),
|
| 195 |
+
..Default::default()
|
| 196 |
+
})
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
impl Request {
|
| 201 |
+
/// Get a reference to the messages
|
| 202 |
+
pub fn get_messages(&self) -> &[Message] {
|
| 203 |
+
&self.messages
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/// Get a mutable reference to the messages
|
| 207 |
+
pub fn get_messages_mut(&mut self) -> &mut Vec<Message> {
|
| 208 |
+
&mut self.messages
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
#[derive(Serialize)]
|
| 213 |
+
pub struct Metadata {
|
| 214 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 215 |
+
pub user_id: Option<String>,
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
#[derive(Serialize)]
|
| 219 |
+
pub struct Message {
|
| 220 |
+
pub content: Vec<Content>,
|
| 221 |
+
pub role: Role,
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
impl TryFrom<ContextMessage> for Message {
|
| 225 |
+
type Error = anyhow::Error;
|
| 226 |
+
fn try_from(value: ContextMessage) -> std::result::Result<Self, Self::Error> {
|
| 227 |
+
Ok(match value {
|
| 228 |
+
ContextMessage::Text(chat_message) => {
|
| 229 |
+
let mut content = Vec::with_capacity(
|
| 230 |
+
chat_message
|
| 231 |
+
.tool_calls
|
| 232 |
+
.as_ref()
|
| 233 |
+
.map(|tc| tc.len())
|
| 234 |
+
.unwrap_or_default()
|
| 235 |
+
+ 1,
|
| 236 |
+
);
|
| 237 |
+
|
| 238 |
+
if let Some(reasoning) = chat_message.reasoning_details
|
| 239 |
+
&& let Some((sig, text)) = reasoning.into_iter().find_map(|reasoning| {
|
| 240 |
+
match (reasoning.signature, reasoning.text) {
|
| 241 |
+
(Some(sig), Some(text)) => Some((sig, text)),
|
| 242 |
+
_ => None,
|
| 243 |
+
}
|
| 244 |
+
})
|
| 245 |
+
{
|
| 246 |
+
content.push(Content::Thinking { signature: Some(sig), thinking: Some(text) });
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
if !chat_message.content.is_empty() {
|
| 250 |
+
// NOTE: Anthropic does not allow empty text content.
|
| 251 |
+
content.push(Content::Text { text: chat_message.content, cache_control: None });
|
| 252 |
+
}
|
| 253 |
+
if let Some(tool_calls) = chat_message.tool_calls {
|
| 254 |
+
for tool_call in tool_calls {
|
| 255 |
+
content.push(tool_call.try_into()?);
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
match chat_message.role {
|
| 260 |
+
forge_domain::Role::User => Message { role: Role::User, content },
|
| 261 |
+
forge_domain::Role::Assistant => Message { role: Role::Assistant, content },
|
| 262 |
+
forge_domain::Role::System => {
|
| 263 |
+
// note: Anthropic doesn't support system role messages and they're already
|
| 264 |
+
// filtered out. so this state is unreachable.
|
| 265 |
+
return Err(
|
| 266 |
+
forge_domain::Error::UnsupportedRole("System".to_string()).into()
|
| 267 |
+
);
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
ContextMessage::Tool(tool_result) => {
|
| 272 |
+
Message { role: Role::User, content: vec![tool_result.try_into()?] }
|
| 273 |
+
}
|
| 274 |
+
ContextMessage::Image(img) => {
|
| 275 |
+
Message { content: vec![Content::from(img)], role: Role::User }
|
| 276 |
+
}
|
| 277 |
+
})
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
impl Message {
|
| 282 |
+
pub fn cached(mut self, enable_cache: bool) -> Self {
|
| 283 |
+
// Reset cache control on all content items first
|
| 284 |
+
for content in &mut self.content {
|
| 285 |
+
*content = std::mem::take(content).cached(false);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
// If enabling cache, set cache control on the last cacheable content item
|
| 289 |
+
if enable_cache
|
| 290 |
+
&& let Some(last_cacheable_idx) =
|
| 291 |
+
self.content
|
| 292 |
+
.iter()
|
| 293 |
+
.enumerate()
|
| 294 |
+
.rev()
|
| 295 |
+
.find_map(|(idx, content)| match content {
|
| 296 |
+
Content::Text { .. }
|
| 297 |
+
| Content::Image { .. }
|
| 298 |
+
| Content::ToolUse { .. }
|
| 299 |
+
| Content::ToolResult { .. } => Some(idx),
|
| 300 |
+
_ => None,
|
| 301 |
+
})
|
| 302 |
+
&& let Some(content) = self.content.get_mut(last_cacheable_idx)
|
| 303 |
+
{
|
| 304 |
+
*content = std::mem::take(content).cached(true);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
self
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
pub fn is_cached(&self) -> bool {
|
| 311 |
+
self.content.iter().any(|content| content.is_cached())
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
impl Default for Message {
|
| 316 |
+
fn default() -> Self {
|
| 317 |
+
Message { content: vec![], role: Role::User }
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
impl From<Image> for Content {
|
| 322 |
+
fn from(value: Image) -> Self {
|
| 323 |
+
Content::Image {
|
| 324 |
+
source: ImageSource {
|
| 325 |
+
type_: "base64".to_string(),
|
| 326 |
+
media_type: Some(value.mime_type().to_string()),
|
| 327 |
+
data: Some(value.data().into()),
|
| 328 |
+
url: None,
|
| 329 |
+
},
|
| 330 |
+
cache_control: None,
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
#[derive(Serialize)]
|
| 336 |
+
pub struct ImageSource {
|
| 337 |
+
#[serde(rename = "type")]
|
| 338 |
+
pub type_: String,
|
| 339 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 340 |
+
pub media_type: Option<String>,
|
| 341 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 342 |
+
pub data: Option<String>,
|
| 343 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 344 |
+
pub url: Option<String>,
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
#[derive(Serialize)]
|
| 348 |
+
#[serde(rename_all = "snake_case", tag = "type")]
|
| 349 |
+
pub enum Content {
|
| 350 |
+
Image {
|
| 351 |
+
source: ImageSource,
|
| 352 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 353 |
+
cache_control: Option<CacheControl>,
|
| 354 |
+
},
|
| 355 |
+
Text {
|
| 356 |
+
text: String,
|
| 357 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 358 |
+
cache_control: Option<CacheControl>,
|
| 359 |
+
},
|
| 360 |
+
ToolUse {
|
| 361 |
+
id: String,
|
| 362 |
+
input: Option<serde_json::Value>,
|
| 363 |
+
name: String,
|
| 364 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 365 |
+
cache_control: Option<CacheControl>,
|
| 366 |
+
},
|
| 367 |
+
ToolResult {
|
| 368 |
+
tool_use_id: String,
|
| 369 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 370 |
+
content: Option<String>,
|
| 371 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 372 |
+
is_error: Option<bool>,
|
| 373 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 374 |
+
cache_control: Option<CacheControl>,
|
| 375 |
+
},
|
| 376 |
+
Thinking {
|
| 377 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 378 |
+
signature: Option<String>,
|
| 379 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 380 |
+
thinking: Option<String>,
|
| 381 |
+
},
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
impl Default for Content {
|
| 385 |
+
fn default() -> Self {
|
| 386 |
+
Content::Thinking { signature: None, thinking: None }
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
impl Content {
|
| 391 |
+
pub fn cached(self, enable_cache: bool) -> Self {
|
| 392 |
+
let cache_control = enable_cache.then_some(CacheControl::Ephemeral);
|
| 393 |
+
|
| 394 |
+
match self {
|
| 395 |
+
Content::Text { text, .. } => Content::Text { text, cache_control },
|
| 396 |
+
Content::ToolUse { id, input, name, .. } => {
|
| 397 |
+
Content::ToolUse { id, input, name, cache_control }
|
| 398 |
+
}
|
| 399 |
+
Content::ToolResult { tool_use_id, content, is_error, .. } => {
|
| 400 |
+
Content::ToolResult { tool_use_id, content, is_error, cache_control }
|
| 401 |
+
}
|
| 402 |
+
Content::Image { source, .. } => Content::Image { source, cache_control },
|
| 403 |
+
// TODO: verify this Thinking variants don't support cache control
|
| 404 |
+
Content::Thinking { signature, thinking } => Content::Thinking { signature, thinking },
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
pub fn is_cached(&self) -> bool {
|
| 409 |
+
match self {
|
| 410 |
+
Content::Text { cache_control, .. } => cache_control.is_some(),
|
| 411 |
+
Content::ToolUse { cache_control, .. } => cache_control.is_some(),
|
| 412 |
+
Content::ToolResult { cache_control, .. } => cache_control.is_some(),
|
| 413 |
+
Content::Image { cache_control, .. } => cache_control.is_some(),
|
| 414 |
+
Content::Thinking { .. } => false,
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
impl TryFrom<forge_domain::ToolCallFull> for Content {
|
| 420 |
+
type Error = anyhow::Error;
|
| 421 |
+
fn try_from(value: forge_domain::ToolCallFull) -> std::result::Result<Self, Self::Error> {
|
| 422 |
+
let call_id = value
|
| 423 |
+
.call_id
|
| 424 |
+
.as_ref()
|
| 425 |
+
.ok_or(forge_domain::Error::ToolCallMissingId)?;
|
| 426 |
+
|
| 427 |
+
Ok(Content::ToolUse {
|
| 428 |
+
id: call_id.as_str().to_string(),
|
| 429 |
+
input: serde_json::to_value(value.arguments).ok(),
|
| 430 |
+
name: value.name.to_string(),
|
| 431 |
+
cache_control: None,
|
| 432 |
+
})
|
| 433 |
+
}
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
impl TryFrom<forge_domain::ToolResult> for Content {
|
| 437 |
+
type Error = anyhow::Error;
|
| 438 |
+
fn try_from(value: forge_domain::ToolResult) -> std::result::Result<Self, Self::Error> {
|
| 439 |
+
let call_id = value
|
| 440 |
+
.call_id
|
| 441 |
+
.as_ref()
|
| 442 |
+
.ok_or(forge_domain::Error::ToolCallMissingId)?;
|
| 443 |
+
Ok(Content::ToolResult {
|
| 444 |
+
tool_use_id: call_id.as_str().to_string(),
|
| 445 |
+
cache_control: None,
|
| 446 |
+
content: value
|
| 447 |
+
.output
|
| 448 |
+
.values
|
| 449 |
+
.iter()
|
| 450 |
+
.filter_map(|item| item.as_str().map(|s| s.to_string()))
|
| 451 |
+
.next(),
|
| 452 |
+
is_error: Some(value.is_error()),
|
| 453 |
+
})
|
| 454 |
+
}
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
#[derive(Serialize)]
|
| 458 |
+
#[serde(tag = "type", rename_all = "snake_case")]
|
| 459 |
+
pub enum CacheControl {
|
| 460 |
+
Ephemeral,
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)]
|
| 464 |
+
#[serde(rename_all = "snake_case")]
|
| 465 |
+
pub enum Role {
|
| 466 |
+
User,
|
| 467 |
+
Assistant,
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
#[derive(Serialize)]
|
| 471 |
+
#[serde(rename_all = "snake_case", tag = "type")]
|
| 472 |
+
pub enum ToolChoice {
|
| 473 |
+
Auto {
|
| 474 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 475 |
+
disable_parallel_tool_use: Option<bool>,
|
| 476 |
+
},
|
| 477 |
+
Any {
|
| 478 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 479 |
+
disable_parallel_tool_use: Option<bool>,
|
| 480 |
+
},
|
| 481 |
+
Tool {
|
| 482 |
+
name: String,
|
| 483 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 484 |
+
disable_parallel_tool_use: Option<bool>,
|
| 485 |
+
},
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
// To understand the mappings refer: https://docs.anthropic.com/en/docs/build-with-claude/tool-use#controlling-claudes-output
|
| 489 |
+
impl From<forge_domain::ToolChoice> for ToolChoice {
|
| 490 |
+
fn from(value: forge_domain::ToolChoice) -> Self {
|
| 491 |
+
match value {
|
| 492 |
+
forge_domain::ToolChoice::Auto => ToolChoice::Auto { disable_parallel_tool_use: None },
|
| 493 |
+
forge_domain::ToolChoice::Call(tool_name) => {
|
| 494 |
+
ToolChoice::Tool { name: tool_name.to_string(), disable_parallel_tool_use: None }
|
| 495 |
+
}
|
| 496 |
+
forge_domain::ToolChoice::Required => {
|
| 497 |
+
ToolChoice::Any { disable_parallel_tool_use: None }
|
| 498 |
+
}
|
| 499 |
+
forge_domain::ToolChoice::None => ToolChoice::Auto { disable_parallel_tool_use: None },
|
| 500 |
+
}
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
#[derive(Serialize)]
|
| 505 |
+
pub struct ToolDefinition {
|
| 506 |
+
pub name: String,
|
| 507 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 508 |
+
pub description: Option<String>,
|
| 509 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 510 |
+
pub cache_control: Option<CacheControl>,
|
| 511 |
+
pub input_schema: serde_json::Value,
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
impl TryFrom<forge_domain::ToolDefinition> for ToolDefinition {
|
| 515 |
+
type Error = anyhow::Error;
|
| 516 |
+
fn try_from(value: forge_domain::ToolDefinition) -> std::result::Result<Self, Self::Error> {
|
| 517 |
+
Ok(ToolDefinition {
|
| 518 |
+
name: value.name.to_string(),
|
| 519 |
+
description: Some(value.description),
|
| 520 |
+
cache_control: None,
|
| 521 |
+
input_schema: serde_json::to_value(value.input_schema)?,
|
| 522 |
+
})
|
| 523 |
+
}
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
#[cfg(test)]
|
| 527 |
+
mod tests {
|
| 528 |
+
use forge_domain::{Context, ReasoningConfig};
|
| 529 |
+
use pretty_assertions::assert_eq;
|
| 530 |
+
|
| 531 |
+
use super::*;
|
| 532 |
+
|
| 533 |
+
#[test]
|
| 534 |
+
fn test_thinking_enabled_serializes_with_budget() {
|
| 535 |
+
let thinking = Thinking::Enabled { budget_tokens: 5000 };
|
| 536 |
+
let actual = serde_json::to_value(&thinking).unwrap();
|
| 537 |
+
let expected = serde_json::json!({
|
| 538 |
+
"type": "enabled",
|
| 539 |
+
"budget_tokens": 5000
|
| 540 |
+
});
|
| 541 |
+
|
| 542 |
+
assert_eq!(actual, expected);
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
#[test]
|
| 546 |
+
fn test_thinking_adaptive_serializes_without_display_when_none() {
|
| 547 |
+
let thinking = Thinking::Adaptive { display: None };
|
| 548 |
+
let actual = serde_json::to_value(&thinking).unwrap();
|
| 549 |
+
let expected = serde_json::json!({"type": "adaptive"});
|
| 550 |
+
|
| 551 |
+
assert_eq!(actual, expected);
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
#[test]
|
| 555 |
+
fn test_thinking_adaptive_serializes_with_summarized_display() {
|
| 556 |
+
let thinking = Thinking::Adaptive { display: Some(ThinkingDisplay::Summarized) };
|
| 557 |
+
let actual = serde_json::to_value(&thinking).unwrap();
|
| 558 |
+
let expected = serde_json::json!({
|
| 559 |
+
"type": "adaptive",
|
| 560 |
+
"display": "summarized"
|
| 561 |
+
});
|
| 562 |
+
|
| 563 |
+
assert_eq!(actual, expected);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
#[test]
|
| 567 |
+
fn test_thinking_disabled_serializes() {
|
| 568 |
+
let thinking = Thinking::Disabled;
|
| 569 |
+
let actual = serde_json::to_value(&thinking).unwrap();
|
| 570 |
+
let expected = serde_json::json!({"type": "disabled"});
|
| 571 |
+
|
| 572 |
+
assert_eq!(actual, expected);
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
#[test]
|
| 576 |
+
fn test_reasoning_enabled_with_max_tokens_creates_enabled_thinking() {
|
| 577 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 578 |
+
enabled: Some(true),
|
| 579 |
+
max_tokens: Some(8000),
|
| 580 |
+
effort: None,
|
| 581 |
+
exclude: None,
|
| 582 |
+
});
|
| 583 |
+
|
| 584 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 585 |
+
|
| 586 |
+
assert_eq!(
|
| 587 |
+
actual.thinking,
|
| 588 |
+
Some(Thinking::Enabled { budget_tokens: 8000 })
|
| 589 |
+
);
|
| 590 |
+
assert_eq!(actual.output_config, None);
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
#[test]
|
| 594 |
+
fn test_reasoning_max_tokens_and_effort_emit_both() {
|
| 595 |
+
// Effort and budget are independent knobs — neither should hide the other.
|
| 596 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 597 |
+
effort: Some(forge_domain::Effort::Low),
|
| 598 |
+
enabled: Some(true),
|
| 599 |
+
max_tokens: Some(8000),
|
| 600 |
+
exclude: None,
|
| 601 |
+
});
|
| 602 |
+
|
| 603 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 604 |
+
|
| 605 |
+
assert_eq!(
|
| 606 |
+
actual.thinking,
|
| 607 |
+
Some(Thinking::Enabled { budget_tokens: 8000 })
|
| 608 |
+
);
|
| 609 |
+
assert_eq!(
|
| 610 |
+
actual.output_config,
|
| 611 |
+
Some(OutputConfig { effort: OutputEffort::Low })
|
| 612 |
+
);
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
#[test]
|
| 616 |
+
fn test_reasoning_max_tokens_alone_emits_enabled_only() {
|
| 617 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 618 |
+
effort: None,
|
| 619 |
+
enabled: Some(true),
|
| 620 |
+
max_tokens: Some(8000),
|
| 621 |
+
exclude: None,
|
| 622 |
+
});
|
| 623 |
+
|
| 624 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 625 |
+
|
| 626 |
+
assert_eq!(
|
| 627 |
+
actual.thinking,
|
| 628 |
+
Some(Thinking::Enabled { budget_tokens: 8000 })
|
| 629 |
+
);
|
| 630 |
+
assert_eq!(actual.output_config, None);
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
#[test]
|
| 634 |
+
fn test_reasoning_effort_without_budget_creates_output_config_and_adaptive() {
|
| 635 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 636 |
+
effort: Some(forge_domain::Effort::Low),
|
| 637 |
+
enabled: Some(true),
|
| 638 |
+
max_tokens: None,
|
| 639 |
+
exclude: None,
|
| 640 |
+
});
|
| 641 |
+
|
| 642 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 643 |
+
|
| 644 |
+
assert_eq!(
|
| 645 |
+
actual.output_config,
|
| 646 |
+
Some(OutputConfig { effort: OutputEffort::Low })
|
| 647 |
+
);
|
| 648 |
+
assert_eq!(
|
| 649 |
+
actual.thinking,
|
| 650 |
+
Some(Thinking::Adaptive { display: Some(ThinkingDisplay::Summarized) })
|
| 651 |
+
);
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
#[test]
|
| 655 |
+
fn test_reasoning_effort_with_exclude_emits_adaptive_omitted() {
|
| 656 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 657 |
+
effort: Some(forge_domain::Effort::High),
|
| 658 |
+
enabled: Some(true),
|
| 659 |
+
max_tokens: None,
|
| 660 |
+
exclude: Some(true),
|
| 661 |
+
});
|
| 662 |
+
|
| 663 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 664 |
+
|
| 665 |
+
assert_eq!(
|
| 666 |
+
actual.thinking,
|
| 667 |
+
Some(Thinking::Adaptive { display: Some(ThinkingDisplay::Omitted) })
|
| 668 |
+
);
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
#[test]
|
| 672 |
+
fn test_reasoning_xhigh_effort_maps_to_xhigh() {
|
| 673 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 674 |
+
effort: Some(forge_domain::Effort::XHigh),
|
| 675 |
+
enabled: Some(true),
|
| 676 |
+
max_tokens: None,
|
| 677 |
+
exclude: None,
|
| 678 |
+
});
|
| 679 |
+
|
| 680 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 681 |
+
|
| 682 |
+
assert_eq!(
|
| 683 |
+
actual.output_config,
|
| 684 |
+
Some(OutputConfig { effort: OutputEffort::XHigh })
|
| 685 |
+
);
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
#[test]
|
| 689 |
+
fn test_reasoning_enabled_without_budget_or_effort_defaults_to_adaptive_summarized() {
|
| 690 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 691 |
+
enabled: Some(true),
|
| 692 |
+
max_tokens: None,
|
| 693 |
+
effort: None,
|
| 694 |
+
exclude: None,
|
| 695 |
+
});
|
| 696 |
+
|
| 697 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 698 |
+
|
| 699 |
+
assert_eq!(
|
| 700 |
+
actual.thinking,
|
| 701 |
+
Some(Thinking::Adaptive { display: Some(ThinkingDisplay::Summarized) })
|
| 702 |
+
);
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
#[test]
|
| 706 |
+
fn test_reasoning_enabled_with_exclude_uses_omitted_display() {
|
| 707 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 708 |
+
enabled: Some(true),
|
| 709 |
+
max_tokens: None,
|
| 710 |
+
effort: None,
|
| 711 |
+
exclude: Some(true),
|
| 712 |
+
});
|
| 713 |
+
|
| 714 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 715 |
+
|
| 716 |
+
assert_eq!(
|
| 717 |
+
actual.thinking,
|
| 718 |
+
Some(Thinking::Adaptive { display: Some(ThinkingDisplay::Omitted) })
|
| 719 |
+
);
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
#[test]
|
| 723 |
+
fn test_reasoning_disabled_does_not_create_thinking() {
|
| 724 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 725 |
+
enabled: Some(false),
|
| 726 |
+
max_tokens: Some(8000),
|
| 727 |
+
effort: None,
|
| 728 |
+
exclude: None,
|
| 729 |
+
});
|
| 730 |
+
|
| 731 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 732 |
+
|
| 733 |
+
assert_eq!(actual.thinking, None);
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
#[test]
|
| 737 |
+
fn test_reasoning_enabled_none_with_max_tokens_still_emits_thinking() {
|
| 738 |
+
// Matches the domain's `is_reasoning_supported` rule: enabled: None with a
|
| 739 |
+
// positive budget counts as on, so inherited/merged configs don't silently
|
| 740 |
+
// disable reasoning on the wire.
|
| 741 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 742 |
+
enabled: None,
|
| 743 |
+
max_tokens: Some(8000),
|
| 744 |
+
effort: None,
|
| 745 |
+
exclude: None,
|
| 746 |
+
});
|
| 747 |
+
|
| 748 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 749 |
+
|
| 750 |
+
assert_eq!(
|
| 751 |
+
actual.thinking,
|
| 752 |
+
Some(Thinking::Enabled { budget_tokens: 8000 })
|
| 753 |
+
);
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
#[test]
|
| 757 |
+
fn test_reasoning_enabled_none_with_effort_still_emits_output_config() {
|
| 758 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 759 |
+
enabled: None,
|
| 760 |
+
max_tokens: None,
|
| 761 |
+
effort: Some(forge_domain::Effort::High),
|
| 762 |
+
exclude: None,
|
| 763 |
+
});
|
| 764 |
+
|
| 765 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 766 |
+
|
| 767 |
+
assert_eq!(
|
| 768 |
+
actual.output_config,
|
| 769 |
+
Some(OutputConfig { effort: OutputEffort::High })
|
| 770 |
+
);
|
| 771 |
+
assert_eq!(
|
| 772 |
+
actual.thinking,
|
| 773 |
+
Some(Thinking::Adaptive { display: Some(ThinkingDisplay::Summarized) })
|
| 774 |
+
);
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
#[test]
|
| 778 |
+
fn test_reasoning_enabled_none_with_zero_max_tokens_does_not_emit() {
|
| 779 |
+
// Matches `is_reasoning_supported`: max_tokens > 0 is required.
|
| 780 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 781 |
+
enabled: None,
|
| 782 |
+
max_tokens: Some(0),
|
| 783 |
+
effort: None,
|
| 784 |
+
exclude: None,
|
| 785 |
+
});
|
| 786 |
+
|
| 787 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 788 |
+
|
| 789 |
+
assert_eq!(actual.thinking, None);
|
| 790 |
+
assert_eq!(actual.output_config, None);
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
#[test]
|
| 794 |
+
fn test_reasoning_effort_none_does_not_emit_anything() {
|
| 795 |
+
// Effort::None is an explicit opt-out — no thinking, no output_config.
|
| 796 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 797 |
+
enabled: None,
|
| 798 |
+
max_tokens: None,
|
| 799 |
+
effort: Some(forge_domain::Effort::None),
|
| 800 |
+
exclude: None,
|
| 801 |
+
});
|
| 802 |
+
|
| 803 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 804 |
+
|
| 805 |
+
assert_eq!(actual.thinking, None);
|
| 806 |
+
assert_eq!(actual.output_config, None);
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
#[test]
|
| 810 |
+
fn test_reasoning_effort_none_overrides_enabled_and_max_tokens() {
|
| 811 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 812 |
+
enabled: Some(true),
|
| 813 |
+
max_tokens: Some(8000),
|
| 814 |
+
effort: Some(forge_domain::Effort::None),
|
| 815 |
+
exclude: None,
|
| 816 |
+
});
|
| 817 |
+
|
| 818 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 819 |
+
|
| 820 |
+
assert_eq!(actual.thinking, None);
|
| 821 |
+
assert_eq!(actual.output_config, None);
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
#[test]
|
| 825 |
+
fn test_reasoning_enabled_false_overrides_effort() {
|
| 826 |
+
// Explicit opt-out beats inferred enablement.
|
| 827 |
+
let fixture = Context::default().reasoning(ReasoningConfig {
|
| 828 |
+
enabled: Some(false),
|
| 829 |
+
max_tokens: None,
|
| 830 |
+
effort: Some(forge_domain::Effort::High),
|
| 831 |
+
exclude: None,
|
| 832 |
+
});
|
| 833 |
+
|
| 834 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 835 |
+
|
| 836 |
+
assert_eq!(actual.thinking, None);
|
| 837 |
+
assert_eq!(actual.output_config, None);
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
#[test]
|
| 841 |
+
fn test_no_reasoning_config_does_not_create_thinking() {
|
| 842 |
+
let fixture = Context::default();
|
| 843 |
+
|
| 844 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 845 |
+
|
| 846 |
+
assert_eq!(actual.thinking, None);
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
#[test]
|
| 850 |
+
fn test_context_conversion_stream_defaults_to_true() {
|
| 851 |
+
let fixture = Context::default();
|
| 852 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 853 |
+
|
| 854 |
+
assert_eq!(actual.stream, Some(true));
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
#[test]
|
| 858 |
+
fn test_context_conversion_stream_explicit_true() {
|
| 859 |
+
let fixture = Context::default().stream(true);
|
| 860 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 861 |
+
|
| 862 |
+
assert_eq!(actual.stream, Some(true));
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
#[test]
|
| 866 |
+
fn test_context_conversion_stream_explicit_false() {
|
| 867 |
+
let fixture = Context::default().stream(false);
|
| 868 |
+
let actual = Request::try_from(fixture).unwrap();
|
| 869 |
+
|
| 870 |
+
assert_eq!(actual.stream, Some(false));
|
| 871 |
+
}
|
| 872 |
+
}
|
crates/forge_app/src/dto/anthropic/response.rs
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::{
|
| 2 |
+
ChatCompletionMessage, Content, ModelId, Reasoning, ReasoningPart, TokenCount, ToolCallId,
|
| 3 |
+
ToolCallPart, ToolName,
|
| 4 |
+
};
|
| 5 |
+
use serde::{Deserialize, Serialize};
|
| 6 |
+
|
| 7 |
+
/// Represents a value that may be either a JSON number or a numeric string.
|
| 8 |
+
#[derive(Deserialize, Debug, Clone, PartialEq, derive_more::TryInto, Serialize)]
|
| 9 |
+
#[serde(untagged)]
|
| 10 |
+
pub enum StringOrF64 {
|
| 11 |
+
Number(f64),
|
| 12 |
+
String(String),
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
use super::request::Role;
|
| 16 |
+
use crate::dto::anthropic::Error;
|
| 17 |
+
|
| 18 |
+
#[derive(Deserialize)]
|
| 19 |
+
pub struct ListModelResponse {
|
| 20 |
+
pub data: Vec<Model>,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
#[derive(Deserialize)]
|
| 24 |
+
pub struct Model {
|
| 25 |
+
pub id: String,
|
| 26 |
+
pub display_name: Option<String>,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
impl From<Model> for forge_domain::Model {
|
| 30 |
+
fn from(value: Model) -> Self {
|
| 31 |
+
let context_length = get_context_length(&value.id);
|
| 32 |
+
let input_modalities = if value.id.contains("claude-3")
|
| 33 |
+
|| value.id.contains("claude-4")
|
| 34 |
+
|| value.id.contains("claude-sonnet")
|
| 35 |
+
|| value.id.contains("claude-opus")
|
| 36 |
+
|| value.id.contains("claude-haiku")
|
| 37 |
+
|| value.id.contains("claude-mythos")
|
| 38 |
+
|| value.id.contains("claude-fable")
|
| 39 |
+
{
|
| 40 |
+
vec![
|
| 41 |
+
forge_domain::InputModality::Text,
|
| 42 |
+
forge_domain::InputModality::Image,
|
| 43 |
+
]
|
| 44 |
+
} else {
|
| 45 |
+
vec![forge_domain::InputModality::Text]
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
Self {
|
| 49 |
+
id: ModelId::new(value.id),
|
| 50 |
+
name: value.display_name,
|
| 51 |
+
description: None,
|
| 52 |
+
context_length,
|
| 53 |
+
tools_supported: Some(true),
|
| 54 |
+
supports_parallel_tool_calls: None,
|
| 55 |
+
supports_reasoning: None,
|
| 56 |
+
input_modalities,
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/// Returns the context window size for a given Claude model ID.
|
| 62 |
+
///
|
| 63 |
+
/// Context lengths are based on official Claude documentation:
|
| 64 |
+
/// <https://docs.claude.com/en/docs/about-claude/models/overview>
|
| 65 |
+
///
|
| 66 |
+
/// # Arguments
|
| 67 |
+
///
|
| 68 |
+
/// * `model_id` - The Claude model identifier (e.g.,
|
| 69 |
+
/// "claude-sonnet-4-5-20250929")
|
| 70 |
+
///
|
| 71 |
+
/// # Returns
|
| 72 |
+
///
|
| 73 |
+
/// Returns `Some(tokens)` for known models, `None` for unknown models.
|
| 74 |
+
///
|
| 75 |
+
/// # Notes
|
| 76 |
+
///
|
| 77 |
+
/// - Most current models support 200K tokens
|
| 78 |
+
/// - Claude Sonnet 4.5 supports 1M tokens with the `context-1m-2025-08-07` beta
|
| 79 |
+
/// header
|
| 80 |
+
/// - Legacy models may have different context lengths
|
| 81 |
+
fn get_context_length(model_id: &str) -> Option<u64> {
|
| 82 |
+
// Claude Mythos / Fable models (1M context)
|
| 83 |
+
if model_id.starts_with("claude-mythos") || model_id.starts_with("claude-fable") {
|
| 84 |
+
return Some(1_000_000);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// Claude Sonnet 5 and Opus 5 (1M context)
|
| 88 |
+
if model_id.starts_with("claude-sonnet-5") || model_id.starts_with("claude-opus-5") {
|
| 89 |
+
return Some(1_000_000);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// Current models (200K context)
|
| 93 |
+
if model_id.starts_with("claude-sonnet-4-5-")
|
| 94 |
+
|| model_id.starts_with("claude-haiku-4-5-")
|
| 95 |
+
|| model_id.starts_with("claude-opus-4-1-")
|
| 96 |
+
{
|
| 97 |
+
return Some(200_000);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Legacy Claude 4 models (200K context)
|
| 101 |
+
if model_id.starts_with("claude-sonnet-4-")
|
| 102 |
+
|| model_id.starts_with("claude-opus-4-")
|
| 103 |
+
|| model_id.starts_with("claude-3-7-sonnet-")
|
| 104 |
+
{
|
| 105 |
+
return Some(200_000);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// Claude 3.5 models (200K context)
|
| 109 |
+
if model_id.starts_with("claude-3-5-sonnet-") || model_id.starts_with("claude-3-5-haiku-") {
|
| 110 |
+
return Some(200_000);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Claude 3 Opus and Sonnet (200K context)
|
| 114 |
+
if model_id.starts_with("claude-3-opus-") || model_id.starts_with("claude-3-sonnet-") {
|
| 115 |
+
return Some(200_000);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Claude 3 Haiku (200K context)
|
| 119 |
+
if model_id.starts_with("claude-3-haiku-") {
|
| 120 |
+
return Some(200_000);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// Claude 2.1 (200K context)
|
| 124 |
+
if model_id.starts_with("claude-2.1") {
|
| 125 |
+
return Some(200_000);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// Claude 2.0 (100K context)
|
| 129 |
+
if model_id.starts_with("claude-2.0") {
|
| 130 |
+
return Some(100_000);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// Claude Instant (100K context)
|
| 134 |
+
if model_id.starts_with("claude-instant-") {
|
| 135 |
+
return Some(100_000);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Unknown model
|
| 139 |
+
None
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
#[derive(Deserialize, PartialEq, Clone, Debug)]
|
| 143 |
+
pub struct MessageStart {
|
| 144 |
+
pub id: String,
|
| 145 |
+
pub r#type: String,
|
| 146 |
+
pub role: Role,
|
| 147 |
+
pub content: Vec<ContentBlock>,
|
| 148 |
+
pub model: String,
|
| 149 |
+
pub stop_reason: Option<StopReason>,
|
| 150 |
+
pub stop_sequence: Option<String>,
|
| 151 |
+
pub usage: Usage,
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
#[derive(Deserialize, PartialEq, Clone, Debug)]
|
| 155 |
+
pub struct Usage {
|
| 156 |
+
pub input_tokens: Option<usize>,
|
| 157 |
+
pub output_tokens: Option<usize>,
|
| 158 |
+
|
| 159 |
+
pub cache_read_input_tokens: Option<usize>,
|
| 160 |
+
pub cache_creation_input_tokens: Option<usize>,
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
impl From<Usage> for forge_domain::Usage {
|
| 164 |
+
fn from(usage: Usage) -> Self {
|
| 165 |
+
// Anthropic token breakdown:
|
| 166 |
+
// - input_tokens: tokens NOT from cache (billed at full price)
|
| 167 |
+
// - cache_creation_input_tokens: tokens written to cache (billed at full price
|
| 168 |
+
// + write cost)
|
| 169 |
+
// - cache_read_input_tokens: tokens read from cache (billed at 90% discount)
|
| 170 |
+
// Total input = input_tokens + cache_creation_input_tokens +
|
| 171 |
+
// cache_read_input_tokens
|
| 172 |
+
|
| 173 |
+
let input_tokens = usage.input_tokens.unwrap_or_default();
|
| 174 |
+
let cache_creation = usage.cache_creation_input_tokens.unwrap_or_default();
|
| 175 |
+
let cache_read = usage.cache_read_input_tokens.unwrap_or_default();
|
| 176 |
+
|
| 177 |
+
// prompt_tokens should include ALL input tokens
|
| 178 |
+
let prompt_tokens = TokenCount::Actual(input_tokens + cache_creation + cache_read);
|
| 179 |
+
|
| 180 |
+
let completion_tokens = usage
|
| 181 |
+
.output_tokens
|
| 182 |
+
.map(TokenCount::Actual)
|
| 183 |
+
.unwrap_or_default();
|
| 184 |
+
|
| 185 |
+
// cached_tokens represents tokens that benefited from caching
|
| 186 |
+
// This includes both cache creation and cache reads
|
| 187 |
+
let cached_tokens = TokenCount::Actual(cache_read);
|
| 188 |
+
|
| 189 |
+
let total_tokens = prompt_tokens + completion_tokens;
|
| 190 |
+
|
| 191 |
+
forge_domain::Usage {
|
| 192 |
+
prompt_tokens,
|
| 193 |
+
completion_tokens,
|
| 194 |
+
total_tokens,
|
| 195 |
+
cached_tokens,
|
| 196 |
+
..Default::default()
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
#[derive(Debug, Deserialize, Clone, PartialEq)]
|
| 202 |
+
#[serde(rename_all = "snake_case")]
|
| 203 |
+
pub enum StopReason {
|
| 204 |
+
EndTurn,
|
| 205 |
+
MaxTokens,
|
| 206 |
+
StopSequence,
|
| 207 |
+
ToolUse,
|
| 208 |
+
Refusal,
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
impl From<StopReason> for forge_domain::FinishReason {
|
| 212 |
+
fn from(value: StopReason) -> Self {
|
| 213 |
+
match value {
|
| 214 |
+
StopReason::EndTurn => forge_domain::FinishReason::Stop,
|
| 215 |
+
StopReason::MaxTokens => forge_domain::FinishReason::Length,
|
| 216 |
+
StopReason::StopSequence => forge_domain::FinishReason::Stop,
|
| 217 |
+
StopReason::ToolUse => forge_domain::FinishReason::ToolCalls,
|
| 218 |
+
StopReason::Refusal => forge_domain::FinishReason::ContentFilter,
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
#[derive(Deserialize, PartialEq, Clone, Debug)]
|
| 224 |
+
#[serde(rename_all = "snake_case", tag = "type")]
|
| 225 |
+
pub enum Event {
|
| 226 |
+
Error {
|
| 227 |
+
error: Error,
|
| 228 |
+
},
|
| 229 |
+
MessageStart {
|
| 230 |
+
message: MessageStart,
|
| 231 |
+
},
|
| 232 |
+
ContentBlockStart {
|
| 233 |
+
index: u32,
|
| 234 |
+
content_block: ContentBlock,
|
| 235 |
+
},
|
| 236 |
+
Ping {
|
| 237 |
+
cost: Option<StringOrF64>,
|
| 238 |
+
},
|
| 239 |
+
ContentBlockDelta {
|
| 240 |
+
index: u32,
|
| 241 |
+
delta: ContentBlock,
|
| 242 |
+
},
|
| 243 |
+
ContentBlockStop {
|
| 244 |
+
index: u32,
|
| 245 |
+
},
|
| 246 |
+
MessageDelta {
|
| 247 |
+
delta: MessageDelta,
|
| 248 |
+
usage: Usage,
|
| 249 |
+
},
|
| 250 |
+
MessageStop,
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
#[derive(Deserialize, PartialEq, Clone, Debug)]
|
| 254 |
+
#[serde(untagged)]
|
| 255 |
+
pub enum EventData {
|
| 256 |
+
KnownEvent(Event),
|
| 257 |
+
// To handle any unknown events:
|
| 258 |
+
// ref: https://docs.anthropic.com/en/api/messages-streaming#other-events
|
| 259 |
+
Unknown(serde_json::Value),
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
#[derive(Deserialize, Clone, PartialEq, Debug)]
|
| 263 |
+
pub struct MessageDelta {
|
| 264 |
+
pub stop_reason: StopReason,
|
| 265 |
+
pub stop_sequence: Option<String>,
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
#[derive(Debug, Deserialize, Clone, PartialEq)]
|
| 269 |
+
#[serde(tag = "type")]
|
| 270 |
+
#[serde(rename_all = "snake_case")]
|
| 271 |
+
pub enum ContentBlock {
|
| 272 |
+
Text {
|
| 273 |
+
text: String,
|
| 274 |
+
},
|
| 275 |
+
TextDelta {
|
| 276 |
+
text: String,
|
| 277 |
+
},
|
| 278 |
+
ToolUse {
|
| 279 |
+
id: String,
|
| 280 |
+
name: String,
|
| 281 |
+
input: serde_json::Value,
|
| 282 |
+
},
|
| 283 |
+
InputJsonDelta {
|
| 284 |
+
partial_json: String,
|
| 285 |
+
},
|
| 286 |
+
Thinking {
|
| 287 |
+
thinking: Option<String>,
|
| 288 |
+
signature: Option<String>,
|
| 289 |
+
},
|
| 290 |
+
ThinkingDelta {
|
| 291 |
+
thinking: Option<String>,
|
| 292 |
+
},
|
| 293 |
+
SignatureDelta {
|
| 294 |
+
signature: Option<String>,
|
| 295 |
+
},
|
| 296 |
+
RedactedThinking {
|
| 297 |
+
data: Option<String>,
|
| 298 |
+
},
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
impl TryFrom<EventData> for ChatCompletionMessage {
|
| 302 |
+
type Error = anyhow::Error;
|
| 303 |
+
fn try_from(value: EventData) -> Result<Self, Self::Error> {
|
| 304 |
+
match value {
|
| 305 |
+
EventData::KnownEvent(event) => ChatCompletionMessage::try_from(event),
|
| 306 |
+
EventData::Unknown(_) => {
|
| 307 |
+
// Ignore any unknown events
|
| 308 |
+
Ok(ChatCompletionMessage::assistant(Content::part("")))
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
impl TryFrom<Event> for ChatCompletionMessage {
|
| 315 |
+
type Error = anyhow::Error;
|
| 316 |
+
fn try_from(value: Event) -> Result<Self, Self::Error> {
|
| 317 |
+
let result = match value {
|
| 318 |
+
Event::ContentBlockStart { content_block, .. }
|
| 319 |
+
| Event::ContentBlockDelta { delta: content_block, .. } => {
|
| 320 |
+
ChatCompletionMessage::try_from(content_block)?
|
| 321 |
+
}
|
| 322 |
+
Event::MessageStart { message } => {
|
| 323 |
+
// Extract usage from MessageStart - this contains input token counts
|
| 324 |
+
ChatCompletionMessage::assistant(Content::part("")).usage(message.usage)
|
| 325 |
+
}
|
| 326 |
+
Event::MessageDelta { delta, usage } => {
|
| 327 |
+
ChatCompletionMessage::assistant(Content::part(""))
|
| 328 |
+
.finish_reason(delta.stop_reason)
|
| 329 |
+
.usage(usage)
|
| 330 |
+
}
|
| 331 |
+
Event::Error { error } => {
|
| 332 |
+
return Err(error.into());
|
| 333 |
+
}
|
| 334 |
+
Event::Ping { cost: Some(cost) } => {
|
| 335 |
+
// OpenCode Zen sends cost in a ping event at the end of the stream
|
| 336 |
+
let cost_value = match cost {
|
| 337 |
+
StringOrF64::Number(n) => n,
|
| 338 |
+
StringOrF64::String(s) => s.parse().unwrap_or(0.0),
|
| 339 |
+
};
|
| 340 |
+
ChatCompletionMessage::assistant(Content::part(""))
|
| 341 |
+
.usage(forge_domain::Usage { cost: Some(cost_value), ..Default::default() })
|
| 342 |
+
}
|
| 343 |
+
_ => ChatCompletionMessage::assistant(Content::part("")),
|
| 344 |
+
};
|
| 345 |
+
|
| 346 |
+
Ok(result)
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
impl TryFrom<ContentBlock> for ChatCompletionMessage {
|
| 351 |
+
type Error = anyhow::Error;
|
| 352 |
+
fn try_from(value: ContentBlock) -> Result<Self, Self::Error> {
|
| 353 |
+
let result = match value {
|
| 354 |
+
ContentBlock::Text { text } | ContentBlock::TextDelta { text } => {
|
| 355 |
+
ChatCompletionMessage::assistant(Content::part(text))
|
| 356 |
+
}
|
| 357 |
+
ContentBlock::Thinking { thinking, signature } => {
|
| 358 |
+
if let Some(thinking) = thinking {
|
| 359 |
+
ChatCompletionMessage::assistant(Content::part(""))
|
| 360 |
+
.reasoning(Content::part(thinking.clone()))
|
| 361 |
+
.add_reasoning_detail(Reasoning::Part(vec![
|
| 362 |
+
ReasoningPart::default()
|
| 363 |
+
.text(Some(thinking))
|
| 364 |
+
.signature(signature),
|
| 365 |
+
]))
|
| 366 |
+
} else {
|
| 367 |
+
ChatCompletionMessage::assistant(Content::part(""))
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
ContentBlock::RedactedThinking { data } => {
|
| 371 |
+
if let Some(data) = data {
|
| 372 |
+
ChatCompletionMessage::assistant(Content::part(""))
|
| 373 |
+
.reasoning(Content::part(data.clone()))
|
| 374 |
+
.add_reasoning_detail(Reasoning::Part(vec![
|
| 375 |
+
ReasoningPart::default().text(Some(data)),
|
| 376 |
+
]))
|
| 377 |
+
} else {
|
| 378 |
+
ChatCompletionMessage::assistant(Content::part(""))
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
ContentBlock::ThinkingDelta { thinking } => {
|
| 382 |
+
if let Some(thinking) = thinking {
|
| 383 |
+
ChatCompletionMessage::assistant(Content::part(""))
|
| 384 |
+
.reasoning(Content::part(thinking.clone()))
|
| 385 |
+
.add_reasoning_detail(Reasoning::Part(vec![
|
| 386 |
+
ReasoningPart::default().text(Some(thinking)),
|
| 387 |
+
]))
|
| 388 |
+
} else {
|
| 389 |
+
ChatCompletionMessage::assistant(Content::part(""))
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
ContentBlock::SignatureDelta { signature } => {
|
| 393 |
+
ChatCompletionMessage::assistant(Content::part("")).add_reasoning_detail(
|
| 394 |
+
Reasoning::Part(vec![ReasoningPart::default().signature(signature)]),
|
| 395 |
+
)
|
| 396 |
+
}
|
| 397 |
+
ContentBlock::ToolUse { id, name, input } => {
|
| 398 |
+
// note: We've to check if the input is empty or null. else we end up adding
|
| 399 |
+
// empty object `{}` as prefix to tool args.
|
| 400 |
+
let is_empty =
|
| 401 |
+
input.is_null() || input.as_object().is_some_and(|map| map.is_empty());
|
| 402 |
+
ChatCompletionMessage::assistant(Content::part("")).add_tool_call(ToolCallPart {
|
| 403 |
+
call_id: Some(ToolCallId::new(id)),
|
| 404 |
+
name: Some(ToolName::new(name)),
|
| 405 |
+
arguments_part: if is_empty {
|
| 406 |
+
"".to_string()
|
| 407 |
+
} else {
|
| 408 |
+
serde_json::to_string(&input)?
|
| 409 |
+
},
|
| 410 |
+
thought_signature: None,
|
| 411 |
+
})
|
| 412 |
+
}
|
| 413 |
+
ContentBlock::InputJsonDelta { partial_json } => {
|
| 414 |
+
ChatCompletionMessage::assistant(Content::part("")).add_tool_call(ToolCallPart {
|
| 415 |
+
call_id: None,
|
| 416 |
+
name: None,
|
| 417 |
+
arguments_part: partial_json,
|
| 418 |
+
thought_signature: None,
|
| 419 |
+
})
|
| 420 |
+
}
|
| 421 |
+
};
|
| 422 |
+
|
| 423 |
+
Ok(result)
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
#[cfg(test)]
|
| 428 |
+
mod tests {
|
| 429 |
+
use super::*;
|
| 430 |
+
|
| 431 |
+
#[test]
|
| 432 |
+
fn test_unknow_event() {
|
| 433 |
+
let event = r#"{"type": "random_error", "error": {"type": "overloaded_error", "message": "Overloaded"}}"#;
|
| 434 |
+
let event_data = serde_json::from_str::<EventData>(event).unwrap();
|
| 435 |
+
assert!(matches!(event_data, EventData::Unknown(_)));
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
#[test]
|
| 439 |
+
fn test_event_deser() {
|
| 440 |
+
let tests = vec![
|
| 441 |
+
(
|
| 442 |
+
"error",
|
| 443 |
+
r#"{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}"#,
|
| 444 |
+
Event::Error {
|
| 445 |
+
error: Error::OverloadedError { message: "Overloaded".to_string() },
|
| 446 |
+
},
|
| 447 |
+
),
|
| 448 |
+
(
|
| 449 |
+
"message_start",
|
| 450 |
+
r#"{"type":"message_start","message":{"id":"msg_019LBLYFJ7fG3fuAqzuRQbyi","type":"message","role":"assistant","content":[],"model":"claude-3-opus-20240229","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":1}}}"#,
|
| 451 |
+
Event::MessageStart {
|
| 452 |
+
message: MessageStart {
|
| 453 |
+
id: "msg_019LBLYFJ7fG3fuAqzuRQbyi".to_string(),
|
| 454 |
+
r#type: "message".to_string(),
|
| 455 |
+
role: Role::Assistant,
|
| 456 |
+
content: vec![],
|
| 457 |
+
model: "claude-3-opus-20240229".to_string(),
|
| 458 |
+
stop_reason: None,
|
| 459 |
+
stop_sequence: None,
|
| 460 |
+
usage: Usage {
|
| 461 |
+
input_tokens: Some(10),
|
| 462 |
+
output_tokens: Some(1),
|
| 463 |
+
cache_creation_input_tokens: None,
|
| 464 |
+
cache_read_input_tokens: None,
|
| 465 |
+
},
|
| 466 |
+
},
|
| 467 |
+
},
|
| 468 |
+
),
|
| 469 |
+
(
|
| 470 |
+
"content_block_start",
|
| 471 |
+
r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
|
| 472 |
+
Event::ContentBlockStart {
|
| 473 |
+
index: 0,
|
| 474 |
+
content_block: ContentBlock::Text { text: "".to_string() },
|
| 475 |
+
},
|
| 476 |
+
),
|
| 477 |
+
("ping", r#"{"type": "ping"}"#, Event::Ping { cost: None }),
|
| 478 |
+
(
|
| 479 |
+
"content_block_delta",
|
| 480 |
+
r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#,
|
| 481 |
+
Event::ContentBlockDelta {
|
| 482 |
+
index: 0,
|
| 483 |
+
delta: ContentBlock::TextDelta { text: "Hello".to_string() },
|
| 484 |
+
},
|
| 485 |
+
),
|
| 486 |
+
(
|
| 487 |
+
"content_block_delta",
|
| 488 |
+
r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}}"#,
|
| 489 |
+
Event::ContentBlockDelta {
|
| 490 |
+
index: 0,
|
| 491 |
+
delta: ContentBlock::TextDelta { text: "!".to_string() },
|
| 492 |
+
},
|
| 493 |
+
),
|
| 494 |
+
(
|
| 495 |
+
"content_block_stop",
|
| 496 |
+
r#"{"type":"content_block_stop","index":0}"#,
|
| 497 |
+
Event::ContentBlockStop { index: 0 },
|
| 498 |
+
),
|
| 499 |
+
(
|
| 500 |
+
"message_delta",
|
| 501 |
+
r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}"#,
|
| 502 |
+
Event::MessageDelta {
|
| 503 |
+
delta: MessageDelta { stop_reason: StopReason::EndTurn, stop_sequence: None },
|
| 504 |
+
usage: Usage {
|
| 505 |
+
input_tokens: None,
|
| 506 |
+
output_tokens: Some(12),
|
| 507 |
+
cache_creation_input_tokens: None,
|
| 508 |
+
cache_read_input_tokens: None,
|
| 509 |
+
},
|
| 510 |
+
},
|
| 511 |
+
),
|
| 512 |
+
(
|
| 513 |
+
"message_stop",
|
| 514 |
+
r#"{"type":"message_stop"}"#,
|
| 515 |
+
Event::MessageStop,
|
| 516 |
+
),
|
| 517 |
+
];
|
| 518 |
+
for (name, input, expected) in tests {
|
| 519 |
+
let actual: Event = serde_json::from_str(input).unwrap();
|
| 520 |
+
assert_eq!(actual, expected, "test failed for event data: {name}");
|
| 521 |
+
}
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
#[test]
|
| 525 |
+
fn test_model_deser() {
|
| 526 |
+
let input = r#"{
|
| 527 |
+
"data": [
|
| 528 |
+
{
|
| 529 |
+
"type": "model",
|
| 530 |
+
"id": "claude-3-5-sonnet-20241022",
|
| 531 |
+
"display_name": "Claude 3.5 Sonnet (New)",
|
| 532 |
+
"created_at": "2024-10-22T00:00:00Z"
|
| 533 |
+
},
|
| 534 |
+
{
|
| 535 |
+
"type": "model",
|
| 536 |
+
"id": "claude-3-5-haiku-20241022",
|
| 537 |
+
"display_name": "Claude 3.5 Haiku",
|
| 538 |
+
"created_at": "2024-10-22T00:00:00Z"
|
| 539 |
+
}
|
| 540 |
+
],
|
| 541 |
+
"has_more": false,
|
| 542 |
+
"first_id": "claude-3-5-sonnet-20241022",
|
| 543 |
+
"last_id": "claude-3-opus-20240229"
|
| 544 |
+
}"#;
|
| 545 |
+
let response = serde_json::from_str::<ListModelResponse>(input);
|
| 546 |
+
assert!(response.is_ok());
|
| 547 |
+
assert!(response.unwrap().data.len() == 2);
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
#[test]
|
| 551 |
+
fn test_usage_conversion_with_cache_read_tokens() {
|
| 552 |
+
use forge_domain::TokenCount;
|
| 553 |
+
|
| 554 |
+
// Simulate a response with cache reads
|
| 555 |
+
let fixture = Usage {
|
| 556 |
+
input_tokens: Some(100),
|
| 557 |
+
output_tokens: Some(50),
|
| 558 |
+
cache_creation_input_tokens: Some(200),
|
| 559 |
+
cache_read_input_tokens: Some(300),
|
| 560 |
+
};
|
| 561 |
+
|
| 562 |
+
let actual: forge_domain::Usage = fixture.into();
|
| 563 |
+
|
| 564 |
+
// prompt_tokens should include ALL input tokens
|
| 565 |
+
let expected_prompt = TokenCount::Actual(100 + 200 + 300);
|
| 566 |
+
assert_eq!(actual.prompt_tokens, expected_prompt);
|
| 567 |
+
|
| 568 |
+
// cached_tokens should only include cache reads (tokens that benefited from
|
| 569 |
+
// caching)
|
| 570 |
+
let expected_cached = TokenCount::Actual(300);
|
| 571 |
+
assert_eq!(actual.cached_tokens, expected_cached);
|
| 572 |
+
|
| 573 |
+
// completion_tokens should be output tokens
|
| 574 |
+
let expected_completion = TokenCount::Actual(50);
|
| 575 |
+
assert_eq!(actual.completion_tokens, expected_completion);
|
| 576 |
+
|
| 577 |
+
// total_tokens should be prompt + completion
|
| 578 |
+
let expected_total = TokenCount::Actual(600 + 50);
|
| 579 |
+
assert_eq!(actual.total_tokens, expected_total);
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
#[test]
|
| 583 |
+
fn test_usage_conversion_without_cache() {
|
| 584 |
+
use forge_domain::TokenCount;
|
| 585 |
+
|
| 586 |
+
let fixture = Usage {
|
| 587 |
+
input_tokens: Some(100),
|
| 588 |
+
output_tokens: Some(50),
|
| 589 |
+
cache_creation_input_tokens: None,
|
| 590 |
+
cache_read_input_tokens: None,
|
| 591 |
+
};
|
| 592 |
+
|
| 593 |
+
let actual: forge_domain::Usage = fixture.into();
|
| 594 |
+
|
| 595 |
+
let expected_prompt = TokenCount::Actual(100);
|
| 596 |
+
assert_eq!(actual.prompt_tokens, expected_prompt);
|
| 597 |
+
|
| 598 |
+
let expected_cached = TokenCount::Actual(0);
|
| 599 |
+
assert_eq!(actual.cached_tokens, expected_cached);
|
| 600 |
+
|
| 601 |
+
let expected_completion = TokenCount::Actual(50);
|
| 602 |
+
assert_eq!(actual.completion_tokens, expected_completion);
|
| 603 |
+
|
| 604 |
+
let expected_total = TokenCount::Actual(150);
|
| 605 |
+
assert_eq!(actual.total_tokens, expected_total);
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
#[test]
|
| 609 |
+
fn test_usage_conversion_cache_read_only() {
|
| 610 |
+
use forge_domain::TokenCount;
|
| 611 |
+
|
| 612 |
+
// Scenario: All tokens came from cache (cache hit)
|
| 613 |
+
let fixture = Usage {
|
| 614 |
+
input_tokens: Some(0),
|
| 615 |
+
output_tokens: Some(50),
|
| 616 |
+
cache_creation_input_tokens: Some(0),
|
| 617 |
+
cache_read_input_tokens: Some(500),
|
| 618 |
+
};
|
| 619 |
+
|
| 620 |
+
let actual: forge_domain::Usage = fixture.into();
|
| 621 |
+
|
| 622 |
+
let expected_prompt = TokenCount::Actual(500);
|
| 623 |
+
assert_eq!(actual.prompt_tokens, expected_prompt);
|
| 624 |
+
|
| 625 |
+
let expected_cached = TokenCount::Actual(500);
|
| 626 |
+
assert_eq!(actual.cached_tokens, expected_cached);
|
| 627 |
+
|
| 628 |
+
// Cache percentage should be 100%
|
| 629 |
+
let cache_percentage = (*actual.cached_tokens * 100) / *actual.prompt_tokens;
|
| 630 |
+
assert_eq!(cache_percentage, 100);
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
#[test]
|
| 634 |
+
fn test_vertex_ai_streaming_usage() {
|
| 635 |
+
use forge_domain::TokenCount;
|
| 636 |
+
|
| 637 |
+
// Simulate Vertex AI Anthropic streaming response
|
| 638 |
+
// message_start event with initial usage
|
| 639 |
+
let message_start_json = r#"{"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-3-5-sonnet-v2@20241022","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":100,"cache_creation_input_tokens":0,"cache_read_input_tokens":50,"output_tokens":0}}}"#;
|
| 640 |
+
|
| 641 |
+
let message_start_event: Event = serde_json::from_str(message_start_json).unwrap();
|
| 642 |
+
|
| 643 |
+
// Extract usage from message_start
|
| 644 |
+
let initial_usage = match message_start_event {
|
| 645 |
+
Event::MessageStart { message } => message.usage,
|
| 646 |
+
_ => panic!("Expected MessageStart event"),
|
| 647 |
+
};
|
| 648 |
+
|
| 649 |
+
// message_delta event with final usage (includes output tokens)
|
| 650 |
+
let message_delta_json = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":75}}"#;
|
| 651 |
+
|
| 652 |
+
let message_delta_event: Event = serde_json::from_str(message_delta_json).unwrap();
|
| 653 |
+
|
| 654 |
+
// Extract usage from message_delta
|
| 655 |
+
let delta_usage = match message_delta_event {
|
| 656 |
+
Event::MessageDelta { usage, .. } => usage,
|
| 657 |
+
_ => panic!("Expected MessageDelta event"),
|
| 658 |
+
};
|
| 659 |
+
|
| 660 |
+
// Convert both to domain Usage
|
| 661 |
+
let initial_domain: forge_domain::Usage = initial_usage.into();
|
| 662 |
+
let delta_domain: forge_domain::Usage = delta_usage.into();
|
| 663 |
+
|
| 664 |
+
// Verify initial usage
|
| 665 |
+
assert_eq!(initial_domain.prompt_tokens, TokenCount::Actual(150)); // 100 + 0 + 50
|
| 666 |
+
assert_eq!(initial_domain.completion_tokens, TokenCount::Actual(0));
|
| 667 |
+
assert_eq!(initial_domain.cached_tokens, TokenCount::Actual(50));
|
| 668 |
+
|
| 669 |
+
// Verify delta usage (only has output_tokens)
|
| 670 |
+
assert_eq!(delta_domain.prompt_tokens, TokenCount::Actual(0));
|
| 671 |
+
assert_eq!(delta_domain.completion_tokens, TokenCount::Actual(75));
|
| 672 |
+
assert_eq!(delta_domain.cached_tokens, TokenCount::Actual(0));
|
| 673 |
+
|
| 674 |
+
// Merge usage (simulating how we'd combine them in practice)
|
| 675 |
+
// Using merge (max) instead of accumulate (sum) since Anthropic
|
| 676 |
+
// usage values are cumulative, not incremental deltas.
|
| 677 |
+
let merged = initial_domain.merge(&delta_domain);
|
| 678 |
+
assert_eq!(merged.prompt_tokens, TokenCount::Actual(150));
|
| 679 |
+
assert_eq!(merged.completion_tokens, TokenCount::Actual(75));
|
| 680 |
+
assert_eq!(merged.cached_tokens, TokenCount::Actual(50));
|
| 681 |
+
assert_eq!(merged.total_tokens, TokenCount::Actual(150)); // max(150, 75)
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
#[test]
|
| 685 |
+
fn test_message_start_event_includes_usage() {
|
| 686 |
+
use forge_domain::TokenCount;
|
| 687 |
+
|
| 688 |
+
// Test that MessageStart event properly extracts usage
|
| 689 |
+
let message_start_json = r#"{"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-3-5-sonnet-v2@20241022","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1000,"cache_creation_input_tokens":200,"cache_read_input_tokens":300,"output_tokens":0}}}"#;
|
| 690 |
+
|
| 691 |
+
let event: Event = serde_json::from_str(message_start_json).unwrap();
|
| 692 |
+
let message: ChatCompletionMessage = event.try_into().unwrap();
|
| 693 |
+
|
| 694 |
+
// Verify usage was extracted from MessageStart
|
| 695 |
+
let usage = message.usage.expect("Usage should be present");
|
| 696 |
+
assert_eq!(usage.prompt_tokens, TokenCount::Actual(1500)); // 1000 + 200 + 300
|
| 697 |
+
assert_eq!(usage.completion_tokens, TokenCount::Actual(0));
|
| 698 |
+
assert_eq!(usage.cached_tokens, TokenCount::Actual(300));
|
| 699 |
+
assert_eq!(usage.total_tokens, TokenCount::Actual(1500));
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
#[test]
|
| 703 |
+
fn test_get_context_length_current_models() {
|
| 704 |
+
// Current models (200K context)
|
| 705 |
+
assert_eq!(
|
| 706 |
+
get_context_length("claude-sonnet-4-5-20250929"),
|
| 707 |
+
Some(200_000)
|
| 708 |
+
);
|
| 709 |
+
assert_eq!(
|
| 710 |
+
get_context_length("claude-haiku-4-5-20251001"),
|
| 711 |
+
Some(200_000)
|
| 712 |
+
);
|
| 713 |
+
assert_eq!(
|
| 714 |
+
get_context_length("claude-opus-4-1-20250805"),
|
| 715 |
+
Some(200_000)
|
| 716 |
+
);
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
#[test]
|
| 720 |
+
fn test_get_context_length_legacy_claude_4() {
|
| 721 |
+
// Legacy Claude 4 models (200K context)
|
| 722 |
+
assert_eq!(
|
| 723 |
+
get_context_length("claude-sonnet-4-20250514"),
|
| 724 |
+
Some(200_000)
|
| 725 |
+
);
|
| 726 |
+
assert_eq!(get_context_length("claude-opus-4-20250514"), Some(200_000));
|
| 727 |
+
assert_eq!(
|
| 728 |
+
get_context_length("claude-3-7-sonnet-20250219"),
|
| 729 |
+
Some(200_000)
|
| 730 |
+
);
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
#[test]
|
| 734 |
+
fn test_get_context_length_claude_3_5() {
|
| 735 |
+
// Claude 3.5 models (200K context)
|
| 736 |
+
assert_eq!(
|
| 737 |
+
get_context_length("claude-3-5-sonnet-20241022"),
|
| 738 |
+
Some(200_000)
|
| 739 |
+
);
|
| 740 |
+
assert_eq!(
|
| 741 |
+
get_context_length("claude-3-5-haiku-20241022"),
|
| 742 |
+
Some(200_000)
|
| 743 |
+
);
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
#[test]
|
| 747 |
+
fn test_get_context_length_claude_3() {
|
| 748 |
+
// Claude 3 models (200K context)
|
| 749 |
+
assert_eq!(get_context_length("claude-3-opus-20240229"), Some(200_000));
|
| 750 |
+
assert_eq!(
|
| 751 |
+
get_context_length("claude-3-sonnet-20240229"),
|
| 752 |
+
Some(200_000)
|
| 753 |
+
);
|
| 754 |
+
assert_eq!(get_context_length("claude-3-haiku-20240307"), Some(200_000));
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
#[test]
|
| 758 |
+
fn test_get_context_length_claude_2() {
|
| 759 |
+
// Claude 2.1 (200K context)
|
| 760 |
+
assert_eq!(get_context_length("claude-2.1"), Some(200_000));
|
| 761 |
+
|
| 762 |
+
// Claude 2.0 (100K context)
|
| 763 |
+
assert_eq!(get_context_length("claude-2.0"), Some(100_000));
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
#[test]
|
| 767 |
+
fn test_get_context_length_claude_instant() {
|
| 768 |
+
// Claude Instant (100K context)
|
| 769 |
+
assert_eq!(get_context_length("claude-instant-1.2"), Some(100_000));
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
#[test]
|
| 773 |
+
fn test_get_context_length_unknown_model() {
|
| 774 |
+
// Unknown models should return None
|
| 775 |
+
assert_eq!(get_context_length("unknown-model"), None);
|
| 776 |
+
assert_eq!(get_context_length("claude-future-5-0"), None);
|
| 777 |
+
assert_eq!(get_context_length(""), None);
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
#[test]
|
| 781 |
+
fn test_model_conversion_includes_context_length() {
|
| 782 |
+
let fixture = Model {
|
| 783 |
+
id: "claude-sonnet-4-5-20250929".to_string(),
|
| 784 |
+
display_name: Some("Claude 3.5 Sonnet (New)".to_string()),
|
| 785 |
+
};
|
| 786 |
+
|
| 787 |
+
let actual: forge_domain::Model = fixture.into();
|
| 788 |
+
|
| 789 |
+
assert_eq!(actual.context_length, Some(200_000));
|
| 790 |
+
assert_eq!(actual.id.as_str(), "claude-sonnet-4-5-20250929");
|
| 791 |
+
assert_eq!(actual.name, Some("Claude 3.5 Sonnet (New)".to_string()));
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
#[test]
|
| 795 |
+
fn test_model_conversion_unknown_model_no_context() {
|
| 796 |
+
let fixture = Model {
|
| 797 |
+
id: "unknown-claude-model".to_string(),
|
| 798 |
+
display_name: Some("Unknown Model".to_string()),
|
| 799 |
+
};
|
| 800 |
+
|
| 801 |
+
let actual: forge_domain::Model = fixture.into();
|
| 802 |
+
|
| 803 |
+
assert_eq!(actual.context_length, None);
|
| 804 |
+
assert_eq!(actual.id.as_str(), "unknown-claude-model");
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
#[test]
|
| 808 |
+
fn test_ping_event_with_string_cost() {
|
| 809 |
+
// Fixture: OpenCode Zen sends cost as a string in a ping event
|
| 810 |
+
let fixture = r#"{"type":"ping","cost":"0.00724710"}"#;
|
| 811 |
+
|
| 812 |
+
let actual: Event = serde_json::from_str(fixture).unwrap();
|
| 813 |
+
|
| 814 |
+
let expected = Event::Ping { cost: Some(StringOrF64::String("0.00724710".into())) };
|
| 815 |
+
assert_eq!(actual, expected);
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
#[test]
|
| 819 |
+
fn test_ping_event_with_numeric_cost() {
|
| 820 |
+
// Fixture: Cost as a numeric value
|
| 821 |
+
let fixture = r#"{"type":"ping","cost":0.05}"#;
|
| 822 |
+
|
| 823 |
+
let actual: Event = serde_json::from_str(fixture).unwrap();
|
| 824 |
+
|
| 825 |
+
let expected = Event::Ping { cost: Some(StringOrF64::Number(0.05)) };
|
| 826 |
+
assert_eq!(actual, expected);
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
#[test]
|
| 830 |
+
fn test_ping_event_with_cost_produces_usage() {
|
| 831 |
+
// Fixture: Ping event with cost should produce a usage with cost
|
| 832 |
+
let fixture = Event::Ping { cost: Some(StringOrF64::Number(0.00724710)) };
|
| 833 |
+
|
| 834 |
+
let actual = ChatCompletionMessage::try_from(fixture).unwrap();
|
| 835 |
+
|
| 836 |
+
let expected_usage = forge_domain::Usage { cost: Some(0.00724710), ..Default::default() };
|
| 837 |
+
assert_eq!(actual.usage, Some(expected_usage));
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
#[test]
|
| 841 |
+
fn test_ping_event_without_cost_produces_empty_message() {
|
| 842 |
+
// Fixture: Standard ping without cost should produce empty message
|
| 843 |
+
let fixture = Event::Ping { cost: None };
|
| 844 |
+
|
| 845 |
+
let actual = ChatCompletionMessage::try_from(fixture).unwrap();
|
| 846 |
+
|
| 847 |
+
assert_eq!(actual.usage, None);
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
#[test]
|
| 851 |
+
fn test_message_delta_refusal_stop_reason() {
|
| 852 |
+
// Fable returns HTTP 200 with stop_reason "refusal" when safety
|
| 853 |
+
// classifiers decline a request. This must parse as a known event,
|
| 854 |
+
// not fall through to EventData::Unknown (which silently drops the
|
| 855 |
+
// finish reason and triggers an empty-completion retry loop).
|
| 856 |
+
let fixture = r#"{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null},"usage":{"output_tokens":0}}"#;
|
| 857 |
+
|
| 858 |
+
let actual = serde_json::from_str::<EventData>(fixture).unwrap();
|
| 859 |
+
|
| 860 |
+
let expected = EventData::KnownEvent(Event::MessageDelta {
|
| 861 |
+
delta: MessageDelta { stop_reason: StopReason::Refusal, stop_sequence: None },
|
| 862 |
+
usage: Usage {
|
| 863 |
+
input_tokens: None,
|
| 864 |
+
output_tokens: Some(0),
|
| 865 |
+
cache_creation_input_tokens: None,
|
| 866 |
+
cache_read_input_tokens: None,
|
| 867 |
+
},
|
| 868 |
+
});
|
| 869 |
+
assert_eq!(actual, expected);
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
#[test]
|
| 873 |
+
fn test_refusal_maps_to_content_filter() {
|
| 874 |
+
let fixture = StopReason::Refusal;
|
| 875 |
+
|
| 876 |
+
let actual = forge_domain::FinishReason::from(fixture);
|
| 877 |
+
|
| 878 |
+
let expected = forge_domain::FinishReason::ContentFilter;
|
| 879 |
+
assert_eq!(actual, expected);
|
| 880 |
+
}
|
| 881 |
+
}
|
crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::anthropic::{Request, SystemMessage};
|
| 4 |
+
|
| 5 |
+
/// Adds authentication system message when OAuth is enabled.
|
| 6 |
+
pub struct AuthSystemMessage {
|
| 7 |
+
message: String,
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
impl AuthSystemMessage {
|
| 11 |
+
const AUTH_MESSAGE: &'static str = include_str!("claude_code.md");
|
| 12 |
+
|
| 13 |
+
/// Creates a new AuthSystemMessage with the provided message.
|
| 14 |
+
pub fn new(message: String) -> Self {
|
| 15 |
+
Self { message }
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
impl Default for AuthSystemMessage {
|
| 20 |
+
fn default() -> Self {
|
| 21 |
+
Self::new(Self::AUTH_MESSAGE.to_string())
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
impl Transformer for AuthSystemMessage {
|
| 26 |
+
type Value = Request;
|
| 27 |
+
|
| 28 |
+
/// Prepends auth system message when enabled.
|
| 29 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 30 |
+
let auth_system_message = SystemMessage {
|
| 31 |
+
r#type: "text".to_string(),
|
| 32 |
+
text: self.message.trim().to_string(),
|
| 33 |
+
cache_control: None,
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
// Get or create the system messages vector
|
| 37 |
+
let mut system_messages = request.system.take().unwrap_or_default();
|
| 38 |
+
|
| 39 |
+
// Prepend the auth message at the beginning
|
| 40 |
+
system_messages.insert(0, auth_system_message);
|
| 41 |
+
|
| 42 |
+
request.system = Some(system_messages);
|
| 43 |
+
request
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
#[cfg(test)]
|
| 48 |
+
mod tests {
|
| 49 |
+
use forge_domain::{Context, ContextMessage, ModelId, Role, TextMessage};
|
| 50 |
+
use pretty_assertions::assert_eq;
|
| 51 |
+
|
| 52 |
+
use super::*;
|
| 53 |
+
|
| 54 |
+
async fn load_auth_message() -> String {
|
| 55 |
+
forge_test_kit::fixture!("/src/dto/anthropic/transforms/claude_code.md").await
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
fn create_request_with_system_messages(system_count: usize) -> Request {
|
| 59 |
+
let mut messages = Vec::new();
|
| 60 |
+
|
| 61 |
+
// Add system messages
|
| 62 |
+
for i in 0..system_count {
|
| 63 |
+
messages.push(
|
| 64 |
+
ContextMessage::Text(TextMessage::new(
|
| 65 |
+
Role::System,
|
| 66 |
+
format!("System message {}", i),
|
| 67 |
+
))
|
| 68 |
+
.into(),
|
| 69 |
+
);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Add at least one user message
|
| 73 |
+
messages.push(
|
| 74 |
+
ContextMessage::Text(
|
| 75 |
+
TextMessage::new(Role::User, "Hello")
|
| 76 |
+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 77 |
+
)
|
| 78 |
+
.into(),
|
| 79 |
+
);
|
| 80 |
+
|
| 81 |
+
let context = Context {
|
| 82 |
+
conversation_id: None,
|
| 83 |
+
messages,
|
| 84 |
+
tools: vec![],
|
| 85 |
+
tool_choice: None,
|
| 86 |
+
max_tokens: None,
|
| 87 |
+
temperature: None,
|
| 88 |
+
top_p: None,
|
| 89 |
+
top_k: None,
|
| 90 |
+
reasoning: None,
|
| 91 |
+
stream: None,
|
| 92 |
+
response_format: None,
|
| 93 |
+
initiator: None,
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
Request::try_from(context).unwrap()
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
#[tokio::test]
|
| 100 |
+
async fn test_enabled_adds_auth_message() {
|
| 101 |
+
let auth_message = load_auth_message().await;
|
| 102 |
+
let fixture = create_request_with_system_messages(0);
|
| 103 |
+
let mut transformer = AuthSystemMessage::new(auth_message.clone()).when(|_| true);
|
| 104 |
+
|
| 105 |
+
let actual = transformer.transform(fixture);
|
| 106 |
+
|
| 107 |
+
let system_messages = actual.system.unwrap();
|
| 108 |
+
assert_eq!(system_messages.len(), 1);
|
| 109 |
+
assert_eq!(system_messages[0].text, auth_message.trim());
|
| 110 |
+
assert_eq!(system_messages[0].r#type, "text");
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
#[tokio::test]
|
| 114 |
+
async fn test_disabled_does_not_add_auth_message() {
|
| 115 |
+
let auth_message = load_auth_message().await;
|
| 116 |
+
let fixture = create_request_with_system_messages(0);
|
| 117 |
+
let mut transformer = AuthSystemMessage::new(auth_message).when(|_| false);
|
| 118 |
+
|
| 119 |
+
let actual = transformer.transform(fixture);
|
| 120 |
+
|
| 121 |
+
// Should have no system messages or empty vector
|
| 122 |
+
let system_messages = actual.system.unwrap_or_default();
|
| 123 |
+
assert_eq!(system_messages.len(), 0);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
#[tokio::test]
|
| 127 |
+
async fn test_prepends_to_existing_system_messages() {
|
| 128 |
+
let auth_message = load_auth_message().await;
|
| 129 |
+
let fixture = create_request_with_system_messages(2);
|
| 130 |
+
let mut transformer = AuthSystemMessage::new(auth_message.clone()).when(|_| true);
|
| 131 |
+
|
| 132 |
+
let actual = transformer.transform(fixture);
|
| 133 |
+
|
| 134 |
+
let system_messages = actual.system.unwrap();
|
| 135 |
+
assert_eq!(system_messages.len(), 3);
|
| 136 |
+
|
| 137 |
+
// Auth message should be first
|
| 138 |
+
assert_eq!(system_messages[0].text, auth_message.trim());
|
| 139 |
+
assert_eq!(system_messages[0].r#type, "text");
|
| 140 |
+
|
| 141 |
+
// Existing messages should follow
|
| 142 |
+
assert_eq!(system_messages[1].text, "System message 0");
|
| 143 |
+
assert_eq!(system_messages[2].text, "System message 1");
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
#[tokio::test]
|
| 147 |
+
async fn test_auth_message_content_matches_file() {
|
| 148 |
+
let auth_message = load_auth_message().await;
|
| 149 |
+
let fixture = create_request_with_system_messages(0);
|
| 150 |
+
let mut transformer = AuthSystemMessage::new(auth_message).when(|_| true);
|
| 151 |
+
|
| 152 |
+
let actual = transformer.transform(fixture);
|
| 153 |
+
|
| 154 |
+
let system_messages = actual.system.unwrap();
|
| 155 |
+
let expected_content = "You are Claude Code, Anthropic's official CLI for Claude.";
|
| 156 |
+
assert_eq!(system_messages[0].text, expected_content);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
#[tokio::test]
|
| 160 |
+
async fn test_with_one_existing_system_message() {
|
| 161 |
+
let auth_message = load_auth_message().await;
|
| 162 |
+
let fixture = create_request_with_system_messages(1);
|
| 163 |
+
let mut transformer = AuthSystemMessage::new(auth_message.clone()).when(|_| true);
|
| 164 |
+
|
| 165 |
+
let actual = transformer.transform(fixture);
|
| 166 |
+
|
| 167 |
+
let system_messages = actual.system.unwrap();
|
| 168 |
+
assert_eq!(system_messages.len(), 2);
|
| 169 |
+
assert_eq!(system_messages[0].text, auth_message.trim());
|
| 170 |
+
assert_eq!(system_messages[1].text, "System message 0");
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
#[tokio::test]
|
| 174 |
+
async fn test_disabled_preserves_existing_system_messages() {
|
| 175 |
+
let auth_message = load_auth_message().await;
|
| 176 |
+
let fixture = create_request_with_system_messages(2);
|
| 177 |
+
let expected_count = fixture.system.as_ref().unwrap().len();
|
| 178 |
+
|
| 179 |
+
let mut transformer = AuthSystemMessage::new(auth_message).when(|_| false);
|
| 180 |
+
let actual = transformer.transform(fixture);
|
| 181 |
+
|
| 182 |
+
let system_messages = actual.system.unwrap();
|
| 183 |
+
assert_eq!(system_messages.len(), expected_count);
|
| 184 |
+
assert_eq!(system_messages[0].text, "System message 0");
|
| 185 |
+
assert_eq!(system_messages[1].text, "System message 1");
|
| 186 |
+
}
|
| 187 |
+
}
|
crates/forge_app/src/dto/anthropic/transforms/capitalize_tool_names.rs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::anthropic::Request;
|
| 4 |
+
|
| 5 |
+
/// Transformer that capitalizes specific tool names for Anthropic
|
| 6 |
+
/// compatibility.
|
| 7 |
+
///
|
| 8 |
+
/// This transformer modifies tool names to use PascalCase for certain tools:
|
| 9 |
+
/// - `read` -> `Read`
|
| 10 |
+
/// - `write` -> `Write`
|
| 11 |
+
///
|
| 12 |
+
/// When the LLM sends back tool calls, both the capitalized and lowercase
|
| 13 |
+
/// versions are supported through alias handling in the deserialization logic.
|
| 14 |
+
pub struct CapitalizeToolNames;
|
| 15 |
+
|
| 16 |
+
impl Transformer for CapitalizeToolNames {
|
| 17 |
+
type Value = Request;
|
| 18 |
+
|
| 19 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 20 |
+
for tool in &mut request.tools {
|
| 21 |
+
tool.name = match tool.name.as_str() {
|
| 22 |
+
"read" => "Read".to_string(),
|
| 23 |
+
"write" => "Write".to_string(),
|
| 24 |
+
"task" => "Task".to_string(),
|
| 25 |
+
_ => tool.name.clone(),
|
| 26 |
+
};
|
| 27 |
+
}
|
| 28 |
+
request
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
#[cfg(test)]
|
| 33 |
+
mod tests {
|
| 34 |
+
use forge_domain::{Context, ContextMessage, ModelId, ToolDefinition, Transformer};
|
| 35 |
+
|
| 36 |
+
use super::*;
|
| 37 |
+
|
| 38 |
+
#[test]
|
| 39 |
+
fn test_capitalizes_read_tool() {
|
| 40 |
+
let fixture = Context::default()
|
| 41 |
+
.add_tool(ToolDefinition::new("read").description("Read a file"))
|
| 42 |
+
.add_message(ContextMessage::user(
|
| 43 |
+
"test",
|
| 44 |
+
Some(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 45 |
+
));
|
| 46 |
+
|
| 47 |
+
let mut request = Request::try_from(fixture).unwrap();
|
| 48 |
+
request = CapitalizeToolNames.transform(request);
|
| 49 |
+
|
| 50 |
+
assert_eq!(request.tools[0].name, "Read");
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
#[test]
|
| 54 |
+
fn test_capitalizes_write_tool() {
|
| 55 |
+
let fixture = Context::default()
|
| 56 |
+
.add_tool(ToolDefinition::new("write").description("Write a file"))
|
| 57 |
+
.add_message(ContextMessage::user(
|
| 58 |
+
"test",
|
| 59 |
+
Some(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 60 |
+
));
|
| 61 |
+
|
| 62 |
+
let mut request = Request::try_from(fixture).unwrap();
|
| 63 |
+
request = CapitalizeToolNames.transform(request);
|
| 64 |
+
|
| 65 |
+
assert_eq!(request.tools[0].name, "Write");
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
#[test]
|
| 69 |
+
fn test_leaves_other_tools_unchanged() {
|
| 70 |
+
let fixture = Context::default()
|
| 71 |
+
.add_tool(ToolDefinition::new("shell").description("Execute shell command"))
|
| 72 |
+
.add_tool(ToolDefinition::new("fs_search").description("Search files"))
|
| 73 |
+
.add_message(ContextMessage::user(
|
| 74 |
+
"test",
|
| 75 |
+
Some(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 76 |
+
));
|
| 77 |
+
|
| 78 |
+
let mut request = Request::try_from(fixture).unwrap();
|
| 79 |
+
request = CapitalizeToolNames.transform(request);
|
| 80 |
+
|
| 81 |
+
assert_eq!(request.tools[0].name, "shell");
|
| 82 |
+
assert_eq!(request.tools[1].name, "fs_search");
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
#[test]
|
| 86 |
+
fn test_handles_multiple_tools_including_read_and_write() {
|
| 87 |
+
let fixture = Context::default()
|
| 88 |
+
.add_tool(ToolDefinition::new("read").description("Read a file"))
|
| 89 |
+
.add_tool(ToolDefinition::new("write").description("Write a file"))
|
| 90 |
+
.add_tool(ToolDefinition::new("shell").description("Execute shell command"))
|
| 91 |
+
.add_message(ContextMessage::user(
|
| 92 |
+
"test",
|
| 93 |
+
Some(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 94 |
+
));
|
| 95 |
+
|
| 96 |
+
let mut request = Request::try_from(fixture).unwrap();
|
| 97 |
+
request = CapitalizeToolNames.transform(request);
|
| 98 |
+
|
| 99 |
+
assert_eq!(request.tools[0].name, "Read");
|
| 100 |
+
assert_eq!(request.tools[1].name, "Write");
|
| 101 |
+
assert_eq!(request.tools[2].name, "shell");
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
#[test]
|
| 105 |
+
fn test_handles_empty_tools_list() {
|
| 106 |
+
let fixture = Context::default().add_message(ContextMessage::user(
|
| 107 |
+
"test",
|
| 108 |
+
Some(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 109 |
+
));
|
| 110 |
+
|
| 111 |
+
let mut request = Request::try_from(fixture).unwrap();
|
| 112 |
+
request = CapitalizeToolNames.transform(request);
|
| 113 |
+
|
| 114 |
+
assert!(request.tools.is_empty());
|
| 115 |
+
}
|
| 116 |
+
}
|
crates/forge_app/src/dto/anthropic/transforms/claude_code.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
You are Claude Code, Anthropic's official CLI for Claude.
|
crates/forge_app/src/dto/anthropic/transforms/drop_invalid_toolcalls.rs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
use serde_json::json;
|
| 3 |
+
|
| 4 |
+
use crate::dto::anthropic::{Content, Request};
|
| 5 |
+
|
| 6 |
+
/// Transformer that normalizes ToolUse content to ensure inputs are always
|
| 7 |
+
/// objects.
|
| 8 |
+
/// - Preserves `Content::Text` and other content types as-is
|
| 9 |
+
/// - For `Content::ToolUse`:
|
| 10 |
+
/// - If input is already an object, keeps it unchanged
|
| 11 |
+
/// - If input is None, keeps it as None
|
| 12 |
+
/// - If input is non-object (string, array, number, etc.), wraps it in
|
| 13 |
+
/// `{"json": value}`
|
| 14 |
+
pub struct DropInvalidToolUse;
|
| 15 |
+
|
| 16 |
+
impl Transformer for DropInvalidToolUse {
|
| 17 |
+
type Value = Request;
|
| 18 |
+
|
| 19 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 20 |
+
for message in request.get_messages_mut() {
|
| 21 |
+
for content in &mut message.content {
|
| 22 |
+
if let Content::ToolUse { input, .. } = content {
|
| 23 |
+
*input = match input.take() {
|
| 24 |
+
Some(value) if value.is_object() => Some(value),
|
| 25 |
+
Some(value) => Some(json!({ "json": value })),
|
| 26 |
+
None => None,
|
| 27 |
+
};
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
request
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
#[cfg(test)]
|
| 37 |
+
mod tests {
|
| 38 |
+
use forge_domain::{
|
| 39 |
+
Context, ContextMessage, ModelId, Role, TextMessage, ToolCallArguments, ToolCallFull,
|
| 40 |
+
Transformer,
|
| 41 |
+
};
|
| 42 |
+
use pretty_assertions::assert_eq;
|
| 43 |
+
use serde_json::json;
|
| 44 |
+
|
| 45 |
+
use super::*;
|
| 46 |
+
use crate::dto::anthropic::Request;
|
| 47 |
+
|
| 48 |
+
fn transform_tool_call(json_args: &str) -> Request {
|
| 49 |
+
let fixture = Context::default().messages(vec![
|
| 50 |
+
ContextMessage::Text(
|
| 51 |
+
TextMessage::new(Role::User, "Hello")
|
| 52 |
+
.tool_calls(vec![
|
| 53 |
+
ToolCallFull::new("test_tool")
|
| 54 |
+
.call_id("call_123")
|
| 55 |
+
.arguments(ToolCallArguments::from_json(json_args)),
|
| 56 |
+
])
|
| 57 |
+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 58 |
+
)
|
| 59 |
+
.into(),
|
| 60 |
+
]);
|
| 61 |
+
DropInvalidToolUse.transform(Request::try_from(fixture).unwrap())
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
fn get_tool_input(request: &Request) -> &Option<serde_json::Value> {
|
| 65 |
+
if let Content::ToolUse { input, .. } = &request.messages[0].content[1] {
|
| 66 |
+
input
|
| 67 |
+
} else {
|
| 68 |
+
panic!("Expected ToolUse content")
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
#[test]
|
| 73 |
+
fn test_preserves_tool_use_with_object_input() {
|
| 74 |
+
let actual = transform_tool_call(r#"{"key": "value"}"#);
|
| 75 |
+
assert_eq!(get_tool_input(&actual), &Some(json!({"key": "value"})));
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
#[test]
|
| 79 |
+
fn test_wraps_tool_use_with_string_input() {
|
| 80 |
+
let actual = transform_tool_call(r#""string_value""#);
|
| 81 |
+
assert_eq!(
|
| 82 |
+
get_tool_input(&actual),
|
| 83 |
+
&Some(json!({"json": "string_value"}))
|
| 84 |
+
);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
#[test]
|
| 88 |
+
fn test_wraps_tool_use_with_array_input() {
|
| 89 |
+
let actual = transform_tool_call(r#"[1, 2, 3]"#);
|
| 90 |
+
assert_eq!(get_tool_input(&actual), &Some(json!({"json": [1, 2, 3]})));
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
#[test]
|
| 94 |
+
fn test_wraps_tool_use_with_number_input() {
|
| 95 |
+
let actual = transform_tool_call(r#"42"#);
|
| 96 |
+
assert_eq!(get_tool_input(&actual), &Some(json!({"json": 42})));
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
#[test]
|
| 100 |
+
fn test_wraps_tool_use_with_none_input() {
|
| 101 |
+
let request = Request::default().messages(vec![crate::dto::anthropic::Message {
|
| 102 |
+
role: crate::dto::anthropic::Role::User,
|
| 103 |
+
content: vec![Content::ToolUse {
|
| 104 |
+
id: "call_123".to_string(),
|
| 105 |
+
name: "test_tool".to_string(),
|
| 106 |
+
input: None,
|
| 107 |
+
cache_control: None,
|
| 108 |
+
}],
|
| 109 |
+
}]);
|
| 110 |
+
let actual = DropInvalidToolUse.transform(request);
|
| 111 |
+
|
| 112 |
+
if let Content::ToolUse { input, .. } = &actual.messages[0].content[0] {
|
| 113 |
+
assert_eq!(input, &None);
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
#[test]
|
| 118 |
+
fn test_empty_messages_remain_empty() {
|
| 119 |
+
let actual = DropInvalidToolUse.transform(Request::try_from(Context::default()).unwrap());
|
| 120 |
+
assert_eq!(actual.messages.len(), 0);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
#[test]
|
| 124 |
+
fn test_preserves_text_content() {
|
| 125 |
+
let fixture = Context::default().messages(vec![
|
| 126 |
+
ContextMessage::Text(TextMessage::new(Role::User, "Hello")).into(),
|
| 127 |
+
]);
|
| 128 |
+
let actual = DropInvalidToolUse.transform(Request::try_from(fixture).unwrap());
|
| 129 |
+
|
| 130 |
+
assert_eq!(actual.messages.len(), 1);
|
| 131 |
+
assert!(matches!(
|
| 132 |
+
actual.messages[0].content[0],
|
| 133 |
+
Content::Text { .. }
|
| 134 |
+
));
|
| 135 |
+
}
|
| 136 |
+
}
|
crates/forge_app/src/dto/anthropic/transforms/enforce_schema.rs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::anthropic::{OutputFormat, Request};
|
| 4 |
+
use crate::utils::enforce_strict_schema;
|
| 5 |
+
|
| 6 |
+
/// Transformer that normalizes output_format schema to meet Anthropic API
|
| 7 |
+
/// requirements.
|
| 8 |
+
///
|
| 9 |
+
/// Anthropic requires that all object types in JSON schemas must explicitly set
|
| 10 |
+
/// `additionalProperties: false`. This transformer recursively processes the
|
| 11 |
+
/// schema to add this requirement.
|
| 12 |
+
///
|
| 13 |
+
/// # Example
|
| 14 |
+
///
|
| 15 |
+
/// Before normalization:
|
| 16 |
+
/// ```json
|
| 17 |
+
/// {
|
| 18 |
+
/// "type": "object",
|
| 19 |
+
/// "properties": { "name": { "type": "string" } }
|
| 20 |
+
/// }
|
| 21 |
+
/// ```
|
| 22 |
+
///
|
| 23 |
+
/// After normalization:
|
| 24 |
+
/// ```json
|
| 25 |
+
/// {
|
| 26 |
+
/// "type": "object",
|
| 27 |
+
/// "properties": { "name": { "type": "string" } },
|
| 28 |
+
/// "additionalProperties": false
|
| 29 |
+
/// }
|
| 30 |
+
/// ```
|
| 31 |
+
pub struct EnforceStrictObjectSchema;
|
| 32 |
+
|
| 33 |
+
impl Transformer for EnforceStrictObjectSchema {
|
| 34 |
+
type Value = Request;
|
| 35 |
+
|
| 36 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 37 |
+
if let Some(OutputFormat::JsonSchema { schema }) = request.output_format.take() {
|
| 38 |
+
// Convert schema to JSON value for normalization
|
| 39 |
+
if let Ok(mut schema_value) = serde_json::to_value(&schema) {
|
| 40 |
+
// Use non-strict mode (false) for Anthropic - only adds additionalProperties
|
| 41 |
+
enforce_strict_schema(&mut schema_value, false);
|
| 42 |
+
|
| 43 |
+
// Convert back to RootSchema
|
| 44 |
+
if let Ok(normalized_schema) = serde_json::from_value(schema_value) {
|
| 45 |
+
request.output_format =
|
| 46 |
+
Some(OutputFormat::JsonSchema { schema: normalized_schema });
|
| 47 |
+
} else {
|
| 48 |
+
// If deserialization fails, keep the original schema
|
| 49 |
+
request.output_format = Some(OutputFormat::JsonSchema { schema });
|
| 50 |
+
}
|
| 51 |
+
} else {
|
| 52 |
+
// If serialization fails, keep the original schema
|
| 53 |
+
request.output_format = Some(OutputFormat::JsonSchema { schema });
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
request
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
#[cfg(test)]
|
| 62 |
+
mod tests {
|
| 63 |
+
use pretty_assertions::assert_eq;
|
| 64 |
+
use schemars::JsonSchema;
|
| 65 |
+
use serde::Deserialize;
|
| 66 |
+
|
| 67 |
+
use super::*;
|
| 68 |
+
|
| 69 |
+
#[derive(Deserialize, JsonSchema)]
|
| 70 |
+
#[schemars(title = "test_response")]
|
| 71 |
+
#[allow(dead_code)]
|
| 72 |
+
struct TestResponse {
|
| 73 |
+
name: String,
|
| 74 |
+
nested: NestedObject,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
#[derive(Deserialize, JsonSchema)]
|
| 78 |
+
#[allow(dead_code)]
|
| 79 |
+
struct NestedObject {
|
| 80 |
+
value: String,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
#[test]
|
| 84 |
+
fn test_normalize_output_schema_adds_additional_properties() {
|
| 85 |
+
let schema = schemars::schema_for!(TestResponse);
|
| 86 |
+
let fixture = Request::default().output_format(OutputFormat::JsonSchema { schema });
|
| 87 |
+
|
| 88 |
+
let actual = EnforceStrictObjectSchema.transform(fixture);
|
| 89 |
+
|
| 90 |
+
// Convert to JSON to check if additionalProperties was added
|
| 91 |
+
if let Some(OutputFormat::JsonSchema { schema }) = actual.output_format {
|
| 92 |
+
let schema_json = serde_json::to_value(&schema).unwrap();
|
| 93 |
+
|
| 94 |
+
// Check top-level schema
|
| 95 |
+
assert_eq!(
|
| 96 |
+
schema_json["additionalProperties"],
|
| 97 |
+
serde_json::Value::Bool(false),
|
| 98 |
+
"Top-level additionalProperties should be false"
|
| 99 |
+
);
|
| 100 |
+
|
| 101 |
+
// Check nested object schema - it might be in definitions or $defs
|
| 102 |
+
if let Some(nested_schema) = schema_json
|
| 103 |
+
.get("properties")
|
| 104 |
+
.and_then(|p| p.get("nested"))
|
| 105 |
+
.and_then(|n| n.get("additionalProperties"))
|
| 106 |
+
{
|
| 107 |
+
assert_eq!(
|
| 108 |
+
nested_schema,
|
| 109 |
+
&serde_json::Value::Bool(false),
|
| 110 |
+
"Nested additionalProperties should be false"
|
| 111 |
+
);
|
| 112 |
+
} else if let Some(defs) = schema_json
|
| 113 |
+
.get("$defs")
|
| 114 |
+
.or_else(|| schema_json.get("definitions"))
|
| 115 |
+
{
|
| 116 |
+
// Check if NestedObject is in definitions
|
| 117 |
+
if let Some(nested_def) = defs.get("NestedObject") {
|
| 118 |
+
assert_eq!(
|
| 119 |
+
nested_def["additionalProperties"],
|
| 120 |
+
serde_json::Value::Bool(false),
|
| 121 |
+
"NestedObject in definitions should have additionalProperties: false"
|
| 122 |
+
);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
} else {
|
| 126 |
+
panic!("Expected output_format to be Some(OutputFormat::JsonSchema)");
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
#[test]
|
| 131 |
+
fn test_normalize_output_schema_preserves_none() {
|
| 132 |
+
let fixture = Request::default();
|
| 133 |
+
|
| 134 |
+
let actual = EnforceStrictObjectSchema.transform(fixture);
|
| 135 |
+
|
| 136 |
+
assert_eq!(actual.output_format, None);
|
| 137 |
+
}
|
| 138 |
+
}
|
crates/forge_app/src/dto/anthropic/transforms/mcp_tool_names.rs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::anthropic::Request;
|
| 4 |
+
|
| 5 |
+
/// Converts MCP tool names from Forge's internal format
|
| 6 |
+
/// (`mcp_{server}_tool_{tool}`) to the Claude Code compatible format
|
| 7 |
+
/// (`mcp__{server}__{tool}`).
|
| 8 |
+
///
|
| 9 |
+
/// Claude Code expects MCP tools to use double-underscore separators between
|
| 10 |
+
/// the `mcp` prefix, server name, and tool name. This transformer is applied
|
| 11 |
+
/// only when sending requests to the Claude Code provider (OAuth-authenticated
|
| 12 |
+
/// sessions).
|
| 13 |
+
pub struct McpToolNames;
|
| 14 |
+
|
| 15 |
+
impl Transformer for McpToolNames {
|
| 16 |
+
type Value = Request;
|
| 17 |
+
|
| 18 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 19 |
+
for tool in &mut request.tools {
|
| 20 |
+
tool.name = to_claude_code_format(&tool.name);
|
| 21 |
+
}
|
| 22 |
+
request
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/// Converts a tool name from the internal `mcp_{server}_tool_{tool}` format to
|
| 27 |
+
/// the Claude Code `mcp__{server}__{tool}` format.
|
| 28 |
+
///
|
| 29 |
+
/// Uses the last occurrence of `_tool_` as the server/tool separator to
|
| 30 |
+
/// correctly handle server names that themselves contain `_tool_` as a
|
| 31 |
+
/// substring. Names that do not match the internal format are returned
|
| 32 |
+
/// unchanged.
|
| 33 |
+
fn to_claude_code_format(name: &str) -> String {
|
| 34 |
+
let Some(rest) = name.strip_prefix("mcp_") else {
|
| 35 |
+
return name.to_string();
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
// Only convert names that contain the `_tool_` separator (internal format).
|
| 39 |
+
// rsplit_once handles edge-case server names containing `_tool_`.
|
| 40 |
+
if let Some((server, tool)) = rest.rsplit_once("_tool_") {
|
| 41 |
+
return format!("mcp__{server}__{tool}");
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
name.to_string()
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
#[cfg(test)]
|
| 48 |
+
mod tests {
|
| 49 |
+
use forge_domain::{Context, ContextMessage, ModelId, ToolDefinition, Transformer};
|
| 50 |
+
use pretty_assertions::assert_eq;
|
| 51 |
+
|
| 52 |
+
use super::*;
|
| 53 |
+
|
| 54 |
+
#[test]
|
| 55 |
+
fn test_converts_simple_mcp_tool_name() {
|
| 56 |
+
let actual = to_claude_code_format("mcp_github_tool_create_issue");
|
| 57 |
+
let expected = "mcp__github__create_issue";
|
| 58 |
+
assert_eq!(actual, expected);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
#[test]
|
| 62 |
+
fn test_converts_mcp_tool_name_with_underscored_server() {
|
| 63 |
+
let actual = to_claude_code_format("mcp_hugging_face_tool_read_channel");
|
| 64 |
+
let expected = "mcp__hugging_face__read_channel";
|
| 65 |
+
assert_eq!(actual, expected);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
#[test]
|
| 69 |
+
fn test_handles_server_name_containing_tool_substring() {
|
| 70 |
+
// Server named "my_tool_server", tool named "action"
|
| 71 |
+
// rfind gives the LAST `_tool_`, so server = "my_tool_server", tool = "action"
|
| 72 |
+
let actual = to_claude_code_format("mcp_my_tool_server_tool_action");
|
| 73 |
+
let expected = "mcp__my_tool_server__action";
|
| 74 |
+
assert_eq!(actual, expected);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
#[test]
|
| 78 |
+
fn test_leaves_already_converted_names_unchanged() {
|
| 79 |
+
// mcp__ prefix means it was already converted; no `_tool_` in sanitized names
|
| 80 |
+
let actual = to_claude_code_format("mcp__github__create_issue");
|
| 81 |
+
let expected = "mcp__github__create_issue";
|
| 82 |
+
assert_eq!(actual, expected);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
#[test]
|
| 86 |
+
fn test_leaves_non_mcp_tools_unchanged() {
|
| 87 |
+
let actual = to_claude_code_format("read");
|
| 88 |
+
let expected = "read";
|
| 89 |
+
assert_eq!(actual, expected);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
#[test]
|
| 93 |
+
fn test_transformer_converts_mcp_tools_in_request() {
|
| 94 |
+
let fixture = Context::default()
|
| 95 |
+
.add_tool(
|
| 96 |
+
ToolDefinition::new("mcp_github_tool_create_issue")
|
| 97 |
+
.description("Create a GitHub issue"),
|
| 98 |
+
)
|
| 99 |
+
.add_tool(ToolDefinition::new("read").description("Read a file"))
|
| 100 |
+
.add_tool(
|
| 101 |
+
ToolDefinition::new("mcp_slack_tool_send_message")
|
| 102 |
+
.description("Send a Slack message"),
|
| 103 |
+
)
|
| 104 |
+
.add_message(ContextMessage::user(
|
| 105 |
+
"test",
|
| 106 |
+
Some(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 107 |
+
));
|
| 108 |
+
|
| 109 |
+
let mut request = Request::try_from(fixture).unwrap();
|
| 110 |
+
request = McpToolNames.transform(request);
|
| 111 |
+
|
| 112 |
+
assert_eq!(request.tools[0].name, "mcp__github__create_issue");
|
| 113 |
+
assert_eq!(request.tools[1].name, "read");
|
| 114 |
+
assert_eq!(request.tools[2].name, "mcp__slack__send_message");
|
| 115 |
+
}
|
| 116 |
+
}
|
crates/forge_app/src/dto/anthropic/transforms/mod.rs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod auth_system_message;
|
| 2 |
+
mod capitalize_tool_names;
|
| 3 |
+
mod drop_invalid_toolcalls;
|
| 4 |
+
mod enforce_schema;
|
| 5 |
+
mod mcp_tool_names;
|
| 6 |
+
mod reasoning_transform;
|
| 7 |
+
mod remove_output_format;
|
| 8 |
+
mod sanitize_tool_ids;
|
| 9 |
+
mod set_cache;
|
| 10 |
+
|
| 11 |
+
pub use auth_system_message::AuthSystemMessage;
|
| 12 |
+
pub use capitalize_tool_names::CapitalizeToolNames;
|
| 13 |
+
pub use drop_invalid_toolcalls::DropInvalidToolUse;
|
| 14 |
+
pub use enforce_schema::EnforceStrictObjectSchema;
|
| 15 |
+
pub use mcp_tool_names::McpToolNames;
|
| 16 |
+
pub use reasoning_transform::ReasoningTransform;
|
| 17 |
+
pub use remove_output_format::RemoveOutputFormat;
|
| 18 |
+
pub use sanitize_tool_ids::SanitizeToolIds;
|
| 19 |
+
pub use set_cache::SetCache;
|
crates/forge_app/src/dto/anthropic/transforms/reasoning_transform.rs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::{Context, Transformer};
|
| 2 |
+
|
| 3 |
+
pub struct ReasoningTransform;
|
| 4 |
+
|
| 5 |
+
impl Transformer for ReasoningTransform {
|
| 6 |
+
type Value = Context;
|
| 7 |
+
fn transform(&mut self, mut context: Self::Value) -> Self::Value {
|
| 8 |
+
// Must stay in lockstep with the Anthropic request builder, which gates
|
| 9 |
+
// on the same predicate — otherwise `thinking`/`output_config` ship
|
| 10 |
+
// alongside sampling params that Anthropic rejects.
|
| 11 |
+
if context.is_reasoning_supported() {
|
| 12 |
+
context.top_k = None;
|
| 13 |
+
context.top_p = None;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
context
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
#[cfg(test)]
|
| 21 |
+
mod tests {
|
| 22 |
+
use forge_domain::{Context, ReasoningConfig, TopK, TopP, Transformer};
|
| 23 |
+
use pretty_assertions::assert_eq;
|
| 24 |
+
|
| 25 |
+
use super::*;
|
| 26 |
+
|
| 27 |
+
fn create_context_fixture() -> Context {
|
| 28 |
+
Context::default()
|
| 29 |
+
.top_k(TopK::new(50).unwrap())
|
| 30 |
+
.top_p(TopP::new(0.8).unwrap())
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
fn create_reasoning_config_fixture(
|
| 34 |
+
enabled: bool,
|
| 35 |
+
max_tokens: Option<usize>,
|
| 36 |
+
) -> ReasoningConfig {
|
| 37 |
+
ReasoningConfig {
|
| 38 |
+
enabled: Some(enabled),
|
| 39 |
+
max_tokens,
|
| 40 |
+
effort: None,
|
| 41 |
+
exclude: None,
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
#[test]
|
| 46 |
+
fn test_reasoning_enabled_with_max_tokens_removes_top_k_and_top_p() {
|
| 47 |
+
let fixture =
|
| 48 |
+
create_context_fixture().reasoning(create_reasoning_config_fixture(true, Some(1024)));
|
| 49 |
+
let mut transformer = ReasoningTransform;
|
| 50 |
+
let actual = transformer.transform(fixture);
|
| 51 |
+
let expected =
|
| 52 |
+
Context::default().reasoning(create_reasoning_config_fixture(true, Some(1024)));
|
| 53 |
+
|
| 54 |
+
assert_eq!(actual, expected);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
#[test]
|
| 58 |
+
fn test_reasoning_disabled_preserves_top_k_and_top_p() {
|
| 59 |
+
let fixture =
|
| 60 |
+
create_context_fixture().reasoning(create_reasoning_config_fixture(false, Some(1024)));
|
| 61 |
+
let mut transformer = ReasoningTransform;
|
| 62 |
+
let actual = transformer.transform(fixture.clone());
|
| 63 |
+
let expected = fixture;
|
| 64 |
+
|
| 65 |
+
assert_eq!(actual, expected);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
#[test]
|
| 69 |
+
fn test_reasoning_enabled_without_max_tokens_removes_top_k_and_top_p() {
|
| 70 |
+
let fixture =
|
| 71 |
+
create_context_fixture().reasoning(create_reasoning_config_fixture(true, None));
|
| 72 |
+
let mut transformer = ReasoningTransform;
|
| 73 |
+
let actual = transformer.transform(fixture);
|
| 74 |
+
let expected = Context::default().reasoning(create_reasoning_config_fixture(true, None));
|
| 75 |
+
|
| 76 |
+
assert_eq!(actual, expected);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
#[test]
|
| 80 |
+
fn test_no_reasoning_config_preserves_top_k_and_top_p() {
|
| 81 |
+
let fixture = create_context_fixture();
|
| 82 |
+
let mut transformer = ReasoningTransform;
|
| 83 |
+
let actual = transformer.transform(fixture.clone());
|
| 84 |
+
let expected = fixture;
|
| 85 |
+
|
| 86 |
+
assert_eq!(actual, expected);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
#[test]
|
| 90 |
+
fn test_enabled_none_with_effort_still_strips_top_k_and_top_p() {
|
| 91 |
+
// `enabled: None` + effort is treated as reasoning-on (domain rule).
|
| 92 |
+
let fixture = create_context_fixture().reasoning(ReasoningConfig {
|
| 93 |
+
enabled: None,
|
| 94 |
+
max_tokens: None,
|
| 95 |
+
effort: Some(forge_domain::Effort::High),
|
| 96 |
+
exclude: None,
|
| 97 |
+
});
|
| 98 |
+
let mut transformer = ReasoningTransform;
|
| 99 |
+
let actual = transformer.transform(fixture);
|
| 100 |
+
|
| 101 |
+
assert_eq!(actual.top_k, None);
|
| 102 |
+
assert_eq!(actual.top_p, None);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
#[test]
|
| 106 |
+
fn test_enabled_none_with_positive_max_tokens_still_strips_top_k_and_top_p() {
|
| 107 |
+
let fixture = create_context_fixture().reasoning(ReasoningConfig {
|
| 108 |
+
enabled: None,
|
| 109 |
+
max_tokens: Some(8000),
|
| 110 |
+
effort: None,
|
| 111 |
+
exclude: None,
|
| 112 |
+
});
|
| 113 |
+
let mut transformer = ReasoningTransform;
|
| 114 |
+
let actual = transformer.transform(fixture);
|
| 115 |
+
|
| 116 |
+
assert_eq!(actual.top_k, None);
|
| 117 |
+
assert_eq!(actual.top_p, None);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
#[test]
|
| 121 |
+
fn test_enabled_none_with_zero_max_tokens_preserves_top_k_and_top_p() {
|
| 122 |
+
// Matches `is_reasoning_supported`: max_tokens == 0 is treated as off.
|
| 123 |
+
let fixture = create_context_fixture().reasoning(ReasoningConfig {
|
| 124 |
+
enabled: None,
|
| 125 |
+
max_tokens: Some(0),
|
| 126 |
+
effort: None,
|
| 127 |
+
exclude: None,
|
| 128 |
+
});
|
| 129 |
+
let mut transformer = ReasoningTransform;
|
| 130 |
+
let actual = transformer.transform(fixture.clone());
|
| 131 |
+
|
| 132 |
+
assert_eq!(actual.top_k, fixture.top_k);
|
| 133 |
+
assert_eq!(actual.top_p, fixture.top_p);
|
| 134 |
+
}
|
| 135 |
+
}
|
crates/forge_app/src/dto/anthropic/transforms/remove_output_format.rs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::anthropic::Request;
|
| 4 |
+
|
| 5 |
+
/// Transformer that removes the output_format field from Anthropic requests.
|
| 6 |
+
///
|
| 7 |
+
/// This is specifically needed for Vertex AI Anthropic integration, which does
|
| 8 |
+
/// not support the `output_format` parameter. When present, it causes a 400
|
| 9 |
+
/// error: "output_format: Extra inputs are not permitted"
|
| 10 |
+
///
|
| 11 |
+
/// # Example
|
| 12 |
+
///
|
| 13 |
+
/// Before transformation:
|
| 14 |
+
/// ```json
|
| 15 |
+
/// {
|
| 16 |
+
/// "messages": [...],
|
| 17 |
+
/// "output_format": {
|
| 18 |
+
/// "type": "json_schema",
|
| 19 |
+
/// "schema": {...}
|
| 20 |
+
/// }
|
| 21 |
+
/// }
|
| 22 |
+
/// ```
|
| 23 |
+
///
|
| 24 |
+
/// After transformation:
|
| 25 |
+
/// ```json
|
| 26 |
+
/// {
|
| 27 |
+
/// "messages": [...]
|
| 28 |
+
/// }
|
| 29 |
+
/// ```
|
| 30 |
+
pub struct RemoveOutputFormat;
|
| 31 |
+
|
| 32 |
+
impl Transformer for RemoveOutputFormat {
|
| 33 |
+
type Value = Request;
|
| 34 |
+
|
| 35 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 36 |
+
request.output_format = None;
|
| 37 |
+
request
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
#[cfg(test)]
|
| 42 |
+
mod tests {
|
| 43 |
+
use pretty_assertions::assert_eq;
|
| 44 |
+
use schemars::JsonSchema;
|
| 45 |
+
use serde::Deserialize;
|
| 46 |
+
|
| 47 |
+
use super::*;
|
| 48 |
+
use crate::dto::anthropic::OutputFormat;
|
| 49 |
+
|
| 50 |
+
#[derive(Deserialize, JsonSchema)]
|
| 51 |
+
#[schemars(title = "test_response")]
|
| 52 |
+
#[allow(dead_code)]
|
| 53 |
+
struct TestResponse {
|
| 54 |
+
name: String,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
#[test]
|
| 58 |
+
fn test_removes_output_format() {
|
| 59 |
+
let schema = schemars::schema_for!(TestResponse);
|
| 60 |
+
let fixture = Request::default().output_format(OutputFormat::JsonSchema { schema });
|
| 61 |
+
|
| 62 |
+
let actual = RemoveOutputFormat.transform(fixture);
|
| 63 |
+
|
| 64 |
+
assert_eq!(actual.output_format, None);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
#[test]
|
| 68 |
+
fn test_preserves_none_output_format() {
|
| 69 |
+
let fixture = Request::default();
|
| 70 |
+
|
| 71 |
+
let actual = RemoveOutputFormat.transform(fixture);
|
| 72 |
+
|
| 73 |
+
assert_eq!(actual.output_format, None);
|
| 74 |
+
}
|
| 75 |
+
}
|
crates/forge_app/src/dto/anthropic/transforms/sanitize_tool_ids.rs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
use regex::Regex;
|
| 3 |
+
|
| 4 |
+
use crate::dto::anthropic::{Content, Request};
|
| 5 |
+
|
| 6 |
+
/// Transformer that sanitizes tool call IDs for Anthropic/Vertex Anthropic
|
| 7 |
+
/// compatibility.
|
| 8 |
+
///
|
| 9 |
+
/// Anthropic requires tool call IDs to match the pattern `^[a-zA-Z0-9_-]+$`.
|
| 10 |
+
/// This transformer replaces any invalid characters (non-alphanumeric,
|
| 11 |
+
/// non-underscore, non-hyphen) with underscores.
|
| 12 |
+
///
|
| 13 |
+
/// This is particularly important for Vertex AI Anthropic which strictly
|
| 14 |
+
/// validates tool_use.id and tool_result.tool_use_id fields and will reject
|
| 15 |
+
/// requests with IDs containing invalid characters with a 400 Bad Request
|
| 16 |
+
/// error.
|
| 17 |
+
///
|
| 18 |
+
/// # Example
|
| 19 |
+
///
|
| 20 |
+
/// ```ignore
|
| 21 |
+
/// // Before transformation:
|
| 22 |
+
/// tool_use.id = "call_123@#$%^&*()"
|
| 23 |
+
///
|
| 24 |
+
/// // After transformation:
|
| 25 |
+
/// tool_use.id = "call_123_________"
|
| 26 |
+
/// ```
|
| 27 |
+
pub struct SanitizeToolIds;
|
| 28 |
+
|
| 29 |
+
impl Transformer for SanitizeToolIds {
|
| 30 |
+
type Value = Request;
|
| 31 |
+
|
| 32 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 33 |
+
let regex = Regex::new(r"[^a-zA-Z0-9_-]").unwrap();
|
| 34 |
+
|
| 35 |
+
for message in &mut request.messages {
|
| 36 |
+
for content in &mut message.content {
|
| 37 |
+
match content {
|
| 38 |
+
Content::ToolUse { id, .. } => {
|
| 39 |
+
*id = regex.replace_all(id, "_").to_string();
|
| 40 |
+
}
|
| 41 |
+
Content::ToolResult { tool_use_id, .. } => {
|
| 42 |
+
*tool_use_id = regex.replace_all(tool_use_id, "_").to_string();
|
| 43 |
+
}
|
| 44 |
+
_ => {}
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
request
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
#[cfg(test)]
|
| 54 |
+
mod tests {
|
| 55 |
+
use forge_domain::{
|
| 56 |
+
Context, ContextMessage, ModelId, Role, TextMessage, ToolCallArguments, ToolCallFull,
|
| 57 |
+
ToolCallId, ToolResult, Transformer,
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
use super::*;
|
| 61 |
+
|
| 62 |
+
#[test]
|
| 63 |
+
fn test_sanitizes_tool_use_id_with_invalid_chars() {
|
| 64 |
+
let fixture = Context::default().messages(vec![
|
| 65 |
+
ContextMessage::Text(
|
| 66 |
+
TextMessage::new(Role::User, "test")
|
| 67 |
+
.tool_calls(vec![
|
| 68 |
+
ToolCallFull::new("test_tool")
|
| 69 |
+
.call_id("call_123@#$%^&*()")
|
| 70 |
+
.arguments(ToolCallArguments::from_json("{}")),
|
| 71 |
+
])
|
| 72 |
+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 73 |
+
)
|
| 74 |
+
.into(),
|
| 75 |
+
]);
|
| 76 |
+
|
| 77 |
+
let mut request = Request::try_from(fixture).unwrap();
|
| 78 |
+
request = SanitizeToolIds.transform(request);
|
| 79 |
+
|
| 80 |
+
// Find the ToolUse content
|
| 81 |
+
let tool_use = request.messages.iter().find_map(|msg| {
|
| 82 |
+
msg.content.iter().find_map(|content| {
|
| 83 |
+
if let Content::ToolUse { id, .. } = content {
|
| 84 |
+
Some(id.clone())
|
| 85 |
+
} else {
|
| 86 |
+
None
|
| 87 |
+
}
|
| 88 |
+
})
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
assert_eq!(tool_use, Some("call_123_________".to_string()));
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
#[test]
|
| 95 |
+
fn test_sanitizes_tool_result_id_with_invalid_chars() {
|
| 96 |
+
let fixture = Context::default().messages(vec![
|
| 97 |
+
ContextMessage::tool_result(
|
| 98 |
+
ToolResult::new("test_tool")
|
| 99 |
+
.call_id(ToolCallId::new("toolu_01!@#$ABC123"))
|
| 100 |
+
.success("result"),
|
| 101 |
+
)
|
| 102 |
+
.into(),
|
| 103 |
+
]);
|
| 104 |
+
|
| 105 |
+
let mut request = Request::try_from(fixture).unwrap();
|
| 106 |
+
request = SanitizeToolIds.transform(request);
|
| 107 |
+
|
| 108 |
+
// Find the ToolResult content
|
| 109 |
+
let tool_result_id = request.messages.iter().find_map(|msg| {
|
| 110 |
+
msg.content.iter().find_map(|content| {
|
| 111 |
+
if let Content::ToolResult { tool_use_id, .. } = content {
|
| 112 |
+
Some(tool_use_id.clone())
|
| 113 |
+
} else {
|
| 114 |
+
None
|
| 115 |
+
}
|
| 116 |
+
})
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
assert_eq!(tool_result_id, Some("toolu_01____ABC123".to_string()));
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
#[test]
|
| 123 |
+
fn test_leaves_valid_tool_ids_unchanged() {
|
| 124 |
+
let fixture = Context::default().messages(vec![
|
| 125 |
+
ContextMessage::Text(
|
| 126 |
+
TextMessage::new(Role::User, "test")
|
| 127 |
+
.tool_calls(vec![
|
| 128 |
+
ToolCallFull::new("test_tool")
|
| 129 |
+
.call_id("call_abc-123_XYZ")
|
| 130 |
+
.arguments(ToolCallArguments::from_json("{}")),
|
| 131 |
+
])
|
| 132 |
+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 133 |
+
)
|
| 134 |
+
.into(),
|
| 135 |
+
]);
|
| 136 |
+
|
| 137 |
+
let mut request = Request::try_from(fixture).unwrap();
|
| 138 |
+
request = SanitizeToolIds.transform(request);
|
| 139 |
+
|
| 140 |
+
// Find the ToolUse content
|
| 141 |
+
let tool_use = request.messages.iter().find_map(|msg| {
|
| 142 |
+
msg.content.iter().find_map(|content| {
|
| 143 |
+
if let Content::ToolUse { id, .. } = content {
|
| 144 |
+
Some(id.clone())
|
| 145 |
+
} else {
|
| 146 |
+
None
|
| 147 |
+
}
|
| 148 |
+
})
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
assert_eq!(tool_use, Some("call_abc-123_XYZ".to_string()));
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
#[test]
|
| 155 |
+
fn test_handles_multiple_tool_calls_and_results() {
|
| 156 |
+
let fixture = Context::default().messages(vec![
|
| 157 |
+
ContextMessage::Text(
|
| 158 |
+
TextMessage::new(Role::User, "test")
|
| 159 |
+
.tool_calls(vec![
|
| 160 |
+
ToolCallFull::new("tool1")
|
| 161 |
+
.call_id("call_1@#$")
|
| 162 |
+
.arguments(ToolCallArguments::from_json("{}")),
|
| 163 |
+
ToolCallFull::new("tool2")
|
| 164 |
+
.call_id("call_2!@#")
|
| 165 |
+
.arguments(ToolCallArguments::from_json("{}")),
|
| 166 |
+
])
|
| 167 |
+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 168 |
+
)
|
| 169 |
+
.into(),
|
| 170 |
+
ContextMessage::tool_result(
|
| 171 |
+
ToolResult::new("tool1")
|
| 172 |
+
.call_id(ToolCallId::new("call_1@#$"))
|
| 173 |
+
.success("result1"),
|
| 174 |
+
)
|
| 175 |
+
.into(),
|
| 176 |
+
ContextMessage::tool_result(
|
| 177 |
+
ToolResult::new("tool2")
|
| 178 |
+
.call_id(ToolCallId::new("call_2!@#"))
|
| 179 |
+
.success("result2"),
|
| 180 |
+
)
|
| 181 |
+
.into(),
|
| 182 |
+
]);
|
| 183 |
+
|
| 184 |
+
let mut request = Request::try_from(fixture).unwrap();
|
| 185 |
+
request = SanitizeToolIds.transform(request);
|
| 186 |
+
|
| 187 |
+
// Collect all tool use IDs
|
| 188 |
+
let mut tool_use_ids = Vec::new();
|
| 189 |
+
let mut tool_result_ids = Vec::new();
|
| 190 |
+
|
| 191 |
+
for msg in &request.messages {
|
| 192 |
+
for content in &msg.content {
|
| 193 |
+
match content {
|
| 194 |
+
Content::ToolUse { id, .. } => tool_use_ids.push(id.clone()),
|
| 195 |
+
Content::ToolResult { tool_use_id, .. } => {
|
| 196 |
+
tool_result_ids.push(tool_use_id.clone())
|
| 197 |
+
}
|
| 198 |
+
_ => {}
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
assert_eq!(tool_use_ids, vec!["call_1___", "call_2___"]);
|
| 204 |
+
assert_eq!(tool_result_ids, vec!["call_1___", "call_2___"]);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
#[test]
|
| 208 |
+
fn test_handles_empty_messages() {
|
| 209 |
+
let fixture = Context::default().messages(vec![
|
| 210 |
+
ContextMessage::Text(
|
| 211 |
+
TextMessage::new(Role::User, "test")
|
| 212 |
+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 213 |
+
)
|
| 214 |
+
.into(),
|
| 215 |
+
]);
|
| 216 |
+
|
| 217 |
+
let mut request = Request::try_from(fixture).unwrap();
|
| 218 |
+
request = SanitizeToolIds.transform(request);
|
| 219 |
+
|
| 220 |
+
// Should not panic and should preserve the message
|
| 221 |
+
assert_eq!(request.messages.len(), 1);
|
| 222 |
+
}
|
| 223 |
+
}
|
crates/forge_app/src/dto/anthropic/transforms/set_cache.rs
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::anthropic::Request;
|
| 4 |
+
|
| 5 |
+
/// Transformer that keeps Anthropic prompt-cache markers stable:
|
| 6 |
+
/// - Always caches every system message so the static system prefix remains
|
| 7 |
+
/// reusable
|
| 8 |
+
/// - Falls back to caching the first conversation message when there is no
|
| 9 |
+
/// system prompt so single-turn requests still establish a reusable prefix
|
| 10 |
+
/// - Uses exactly one rolling message-level marker on the newest message
|
| 11 |
+
pub struct SetCache;
|
| 12 |
+
|
| 13 |
+
impl Transformer for SetCache {
|
| 14 |
+
type Value = Request;
|
| 15 |
+
|
| 16 |
+
/// Applies the default Anthropic cache strategy:
|
| 17 |
+
/// 1. Cache every system message when present, otherwise cache the first
|
| 18 |
+
/// conversation message.
|
| 19 |
+
/// 2. Cache only the last message as the rolling message-level marker.
|
| 20 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 21 |
+
let len = request.get_messages().len();
|
| 22 |
+
let sys_len = request.system.as_ref().map_or(0, |msgs| msgs.len());
|
| 23 |
+
|
| 24 |
+
if len == 0 && sys_len == 0 {
|
| 25 |
+
return request;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
let has_system_prompt = request
|
| 29 |
+
.system
|
| 30 |
+
.as_ref()
|
| 31 |
+
.is_some_and(|messages| !messages.is_empty());
|
| 32 |
+
|
| 33 |
+
if let Some(system_messages) = request.system.as_mut() {
|
| 34 |
+
for message in system_messages.iter_mut() {
|
| 35 |
+
*message = std::mem::take(message).cached(true);
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
for message in request.get_messages_mut().iter_mut() {
|
| 40 |
+
*message = std::mem::take(message).cached(false);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if !has_system_prompt
|
| 44 |
+
&& len > 0
|
| 45 |
+
&& let Some(first_message) = request.get_messages_mut().first_mut()
|
| 46 |
+
{
|
| 47 |
+
*first_message = std::mem::take(first_message).cached(true);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if let Some(message) = request.get_messages_mut().last_mut() {
|
| 51 |
+
*message = std::mem::take(message).cached(true);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
request
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
#[cfg(test)]
|
| 59 |
+
mod tests {
|
| 60 |
+
use std::collections::HashSet;
|
| 61 |
+
|
| 62 |
+
use forge_domain::{Context, ContextMessage, ModelId, Role, TextMessage};
|
| 63 |
+
use pretty_assertions::assert_eq;
|
| 64 |
+
|
| 65 |
+
use super::*;
|
| 66 |
+
|
| 67 |
+
fn create_test_context_with_system(
|
| 68 |
+
system_messages: &str,
|
| 69 |
+
conversation_messages: &str,
|
| 70 |
+
) -> String {
|
| 71 |
+
let mut messages = Vec::new();
|
| 72 |
+
|
| 73 |
+
// Add system messages to the regular messages array for Anthropic format
|
| 74 |
+
for c in system_messages.chars() {
|
| 75 |
+
match c {
|
| 76 |
+
's' => messages.push(
|
| 77 |
+
ContextMessage::Text(TextMessage::new(Role::System, c.to_string())).into(),
|
| 78 |
+
),
|
| 79 |
+
_ => panic!("Invalid character in system message: {}", c),
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Add conversation messages
|
| 84 |
+
for c in conversation_messages.chars() {
|
| 85 |
+
match c {
|
| 86 |
+
'u' => messages.push(
|
| 87 |
+
ContextMessage::Text(
|
| 88 |
+
TextMessage::new(Role::User, c.to_string())
|
| 89 |
+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 90 |
+
)
|
| 91 |
+
.into(),
|
| 92 |
+
),
|
| 93 |
+
'a' => messages.push(
|
| 94 |
+
ContextMessage::Text(TextMessage::new(Role::Assistant, c.to_string())).into(),
|
| 95 |
+
),
|
| 96 |
+
_ => panic!("Invalid character in conversation message: {}", c),
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
let context = Context {
|
| 101 |
+
conversation_id: None,
|
| 102 |
+
messages,
|
| 103 |
+
tools: vec![],
|
| 104 |
+
tool_choice: None,
|
| 105 |
+
max_tokens: None,
|
| 106 |
+
temperature: None,
|
| 107 |
+
top_p: None,
|
| 108 |
+
top_k: None,
|
| 109 |
+
reasoning: None,
|
| 110 |
+
stream: None,
|
| 111 |
+
response_format: None,
|
| 112 |
+
initiator: None,
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
let request = Request::try_from(context).expect("Failed to convert context to request");
|
| 116 |
+
let mut transformer = SetCache;
|
| 117 |
+
let request = transformer.transform(request);
|
| 118 |
+
|
| 119 |
+
let mut output = String::new();
|
| 120 |
+
|
| 121 |
+
// Check if first system message is cached
|
| 122 |
+
let system_cached = request
|
| 123 |
+
.system
|
| 124 |
+
.as_ref()
|
| 125 |
+
.and_then(|sys| sys.first())
|
| 126 |
+
.map(|msg| msg.is_cached())
|
| 127 |
+
.unwrap_or(false);
|
| 128 |
+
|
| 129 |
+
if system_cached {
|
| 130 |
+
output.push('[');
|
| 131 |
+
}
|
| 132 |
+
output.push_str(system_messages);
|
| 133 |
+
|
| 134 |
+
// Check which regular messages are cached
|
| 135 |
+
let cached_indices = request
|
| 136 |
+
.get_messages()
|
| 137 |
+
.iter()
|
| 138 |
+
.enumerate()
|
| 139 |
+
.filter(|(_, m)| m.is_cached())
|
| 140 |
+
.map(|(i, _)| i)
|
| 141 |
+
.collect::<HashSet<usize>>();
|
| 142 |
+
|
| 143 |
+
for (i, c) in conversation_messages.chars().enumerate() {
|
| 144 |
+
if cached_indices.contains(&i) {
|
| 145 |
+
output.push('[');
|
| 146 |
+
}
|
| 147 |
+
output.push(c);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
output
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
fn create_test_context(message: impl ToString) -> String {
|
| 154 |
+
create_test_context_with_system("", &message.to_string())
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
#[test]
|
| 158 |
+
fn test_single_message() {
|
| 159 |
+
let actual = create_test_context("u");
|
| 160 |
+
let expected = "[u";
|
| 161 |
+
assert_eq!(actual, expected);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
#[test]
|
| 165 |
+
fn test_two_messages() {
|
| 166 |
+
let actual = create_test_context("ua");
|
| 167 |
+
let expected = "[u[a";
|
| 168 |
+
assert_eq!(actual, expected);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
#[test]
|
| 172 |
+
fn test_three_messages_cache_first_and_last_only() {
|
| 173 |
+
let actual = create_test_context("uau");
|
| 174 |
+
let expected = "[ua[u";
|
| 175 |
+
assert_eq!(actual, expected);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
#[test]
|
| 179 |
+
fn test_four_messages_cache_first_and_last_only() {
|
| 180 |
+
let actual = create_test_context("uaua");
|
| 181 |
+
let expected = "[uau[a";
|
| 182 |
+
assert_eq!(actual, expected);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
#[test]
|
| 186 |
+
fn test_five_messages_cache_first_and_last_only() {
|
| 187 |
+
let actual = create_test_context("uauau");
|
| 188 |
+
let expected = "[uaua[u";
|
| 189 |
+
assert_eq!(actual, expected);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
#[test]
|
| 193 |
+
fn test_longer_conversation_caches_first_and_last_only() {
|
| 194 |
+
let actual = create_test_context("uauauauaua");
|
| 195 |
+
let expected = "[uauauauau[a";
|
| 196 |
+
assert_eq!(actual, expected);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
#[test]
|
| 200 |
+
fn test_with_system_message_single_conversation_message() {
|
| 201 |
+
let actual = create_test_context_with_system("s", "u");
|
| 202 |
+
let expected = "[s[u";
|
| 203 |
+
assert_eq!(actual, expected);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
#[test]
|
| 207 |
+
fn test_with_system_message_multiple_conversation_messages() {
|
| 208 |
+
let actual = create_test_context_with_system("ss", "uaua");
|
| 209 |
+
let expected = "[ssuau[a";
|
| 210 |
+
assert_eq!(actual, expected);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
#[test]
|
| 214 |
+
fn test_with_system_message_long_conversation() {
|
| 215 |
+
let actual = create_test_context_with_system("s", "uauauauaua");
|
| 216 |
+
let expected = "[suauauauau[a";
|
| 217 |
+
assert_eq!(actual, expected);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
#[test]
|
| 221 |
+
fn test_only_system_message() {
|
| 222 |
+
let actual = create_test_context_with_system("s", "");
|
| 223 |
+
let expected = "[s";
|
| 224 |
+
assert_eq!(actual, expected);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
#[test]
|
| 228 |
+
fn test_multiple_system_messages_all_cached() {
|
| 229 |
+
let fixture = Context {
|
| 230 |
+
conversation_id: None,
|
| 231 |
+
messages: vec![
|
| 232 |
+
ContextMessage::Text(TextMessage::new(Role::System, "first")).into(),
|
| 233 |
+
ContextMessage::Text(TextMessage::new(Role::System, "second")).into(),
|
| 234 |
+
ContextMessage::Text(
|
| 235 |
+
TextMessage::new(Role::User, "user")
|
| 236 |
+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
|
| 237 |
+
)
|
| 238 |
+
.into(),
|
| 239 |
+
],
|
| 240 |
+
tools: vec![],
|
| 241 |
+
tool_choice: None,
|
| 242 |
+
max_tokens: None,
|
| 243 |
+
temperature: None,
|
| 244 |
+
top_p: None,
|
| 245 |
+
top_k: None,
|
| 246 |
+
reasoning: None,
|
| 247 |
+
stream: None,
|
| 248 |
+
response_format: None,
|
| 249 |
+
initiator: None,
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
let request = Request::try_from(fixture).expect("Failed to convert context to request");
|
| 253 |
+
let mut transformer = SetCache;
|
| 254 |
+
let request = transformer.transform(request);
|
| 255 |
+
|
| 256 |
+
let expected = vec![true, true];
|
| 257 |
+
let actual = request
|
| 258 |
+
.system
|
| 259 |
+
.as_ref()
|
| 260 |
+
.unwrap()
|
| 261 |
+
.iter()
|
| 262 |
+
.map(|message| message.is_cached())
|
| 263 |
+
.collect::<Vec<_>>();
|
| 264 |
+
assert_eq!(actual, expected);
|
| 265 |
+
assert!(request.get_messages()[0].is_cached());
|
| 266 |
+
}
|
| 267 |
+
}
|
crates/forge_app/src/dto/google/mod.rs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod request;
|
| 2 |
+
mod response;
|
| 3 |
+
|
| 4 |
+
pub use request::Request;
|
| 5 |
+
pub use response::{EventData, Model, Response};
|
crates/forge_app/src/dto/google/request.rs
ADDED
|
@@ -0,0 +1,982 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use derive_setters::Setters;
|
| 2 |
+
use forge_domain::{Context, ContextMessage};
|
| 3 |
+
use serde::Serialize;
|
| 4 |
+
|
| 5 |
+
#[derive(Serialize, Default, Setters)]
|
| 6 |
+
#[setters(into, strip_option)]
|
| 7 |
+
#[serde(rename_all = "camelCase")]
|
| 8 |
+
pub struct Request {
|
| 9 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 10 |
+
pub system_instruction: Option<Content>,
|
| 11 |
+
pub contents: Vec<Content>,
|
| 12 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 13 |
+
pub generation_config: Option<GenerationConfig>,
|
| 14 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 15 |
+
pub tools: Option<Vec<Tool>>,
|
| 16 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 17 |
+
pub tool_config: Option<ToolConfig>,
|
| 18 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 19 |
+
pub safety_settings: Option<Vec<SafetySetting>>,
|
| 20 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 21 |
+
pub cached_content: Option<String>,
|
| 22 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 23 |
+
pub labels: Option<serde_json::Value>,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
#[derive(Serialize, Default, Setters)]
|
| 27 |
+
#[setters(into, strip_option)]
|
| 28 |
+
#[serde(rename_all = "camelCase")]
|
| 29 |
+
pub struct GenerationConfig {
|
| 30 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 31 |
+
pub thinking_config: Option<ThinkingConfig>,
|
| 32 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 33 |
+
pub stop_sequences: Option<Vec<String>>,
|
| 34 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 35 |
+
pub temperature: Option<f64>,
|
| 36 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 37 |
+
pub top_p: Option<f64>,
|
| 38 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 39 |
+
pub top_k: Option<i32>,
|
| 40 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 41 |
+
pub response_mime_type: Option<String>,
|
| 42 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 43 |
+
pub response_schema: Option<serde_json::Value>,
|
| 44 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 45 |
+
pub max_output_tokens: Option<i32>,
|
| 46 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 47 |
+
pub frequency_penalty: Option<f64>,
|
| 48 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 49 |
+
pub presence_penalty: Option<f64>,
|
| 50 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 51 |
+
pub seed: Option<i32>,
|
| 52 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 53 |
+
pub response_modalities: Option<Vec<String>>,
|
| 54 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 55 |
+
pub audio_timestamp: Option<bool>,
|
| 56 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 57 |
+
pub media_resolution: Option<String>,
|
| 58 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 59 |
+
pub image_config: Option<serde_json::Value>,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
#[derive(Serialize, Setters)]
|
| 63 |
+
#[setters(into, strip_option)]
|
| 64 |
+
#[serde(rename_all = "camelCase")]
|
| 65 |
+
pub struct ThinkingConfig {
|
| 66 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 67 |
+
pub thinking_level: Option<Level>,
|
| 68 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 69 |
+
pub thinking_budget: Option<i32>,
|
| 70 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 71 |
+
pub include_thoughts: Option<bool>,
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
#[derive(Serialize)]
|
| 75 |
+
#[serde(rename_all = "lowercase")]
|
| 76 |
+
pub enum Level {
|
| 77 |
+
Minimal,
|
| 78 |
+
Low,
|
| 79 |
+
Medium,
|
| 80 |
+
High,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
#[derive(Serialize)]
|
| 84 |
+
pub struct Content {
|
| 85 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 86 |
+
pub role: Option<Role>,
|
| 87 |
+
pub parts: Vec<Part>,
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
#[derive(Serialize)]
|
| 91 |
+
#[serde(rename_all = "camelCase", untagged)]
|
| 92 |
+
pub enum Part {
|
| 93 |
+
Text {
|
| 94 |
+
text: String,
|
| 95 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 96 |
+
thought: Option<bool>,
|
| 97 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 98 |
+
thought_signature: Option<String>,
|
| 99 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 100 |
+
cache_control: Option<CacheControl>,
|
| 101 |
+
},
|
| 102 |
+
Image {
|
| 103 |
+
inline_data: ImageSource,
|
| 104 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 105 |
+
cache_control: Option<CacheControl>,
|
| 106 |
+
},
|
| 107 |
+
FunctionCall {
|
| 108 |
+
function_call: FunctionCallData,
|
| 109 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 110 |
+
thought_signature: Option<String>,
|
| 111 |
+
},
|
| 112 |
+
FunctionResponse {
|
| 113 |
+
function_response: FunctionResponseData,
|
| 114 |
+
},
|
| 115 |
+
FileData {
|
| 116 |
+
file_data: FileDataInfo,
|
| 117 |
+
},
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
#[derive(Serialize)]
|
| 121 |
+
pub struct ImageSource {
|
| 122 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 123 |
+
pub mime_type: Option<String>,
|
| 124 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 125 |
+
pub data: Option<String>,
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
#[derive(Serialize)]
|
| 129 |
+
#[serde(rename_all = "camelCase")]
|
| 130 |
+
pub struct FunctionCallData {
|
| 131 |
+
pub name: String,
|
| 132 |
+
pub args: serde_json::Value,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
#[derive(Serialize)]
|
| 136 |
+
#[serde(rename_all = "camelCase")]
|
| 137 |
+
pub struct FunctionResponseData {
|
| 138 |
+
pub name: String,
|
| 139 |
+
pub response: serde_json::Value,
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
#[derive(Serialize)]
|
| 143 |
+
#[serde(rename_all = "camelCase")]
|
| 144 |
+
pub struct FileDataInfo {
|
| 145 |
+
pub mime_type: String,
|
| 146 |
+
pub file_uri: String,
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
#[derive(Serialize)]
|
| 150 |
+
#[serde(tag = "type", rename_all = "snake_case")]
|
| 151 |
+
pub enum CacheControl {
|
| 152 |
+
Ephemeral,
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
#[derive(Serialize, Debug, PartialEq)]
|
| 156 |
+
#[serde(rename_all = "lowercase")]
|
| 157 |
+
pub enum Role {
|
| 158 |
+
User,
|
| 159 |
+
Model,
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
#[derive(Serialize)]
|
| 163 |
+
#[serde(rename_all = "camelCase", untagged)]
|
| 164 |
+
pub enum Tool {
|
| 165 |
+
FunctionDeclarations {
|
| 166 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 167 |
+
function_declarations: Option<Vec<FunctionDeclaration>>,
|
| 168 |
+
},
|
| 169 |
+
GoogleSearch {
|
| 170 |
+
google_search: GoogleSearchTool,
|
| 171 |
+
},
|
| 172 |
+
GoogleSearchRetrieval {
|
| 173 |
+
google_search_retrieval: GoogleSearchRetrievalTool,
|
| 174 |
+
},
|
| 175 |
+
EnterpriseWebSearch {
|
| 176 |
+
enterprise_web_search: serde_json::Value,
|
| 177 |
+
},
|
| 178 |
+
UrlContext {
|
| 179 |
+
url_context: serde_json::Value,
|
| 180 |
+
},
|
| 181 |
+
CodeExecution {
|
| 182 |
+
code_execution: serde_json::Value,
|
| 183 |
+
},
|
| 184 |
+
FileSearch {
|
| 185 |
+
file_search: FileSearchTool,
|
| 186 |
+
},
|
| 187 |
+
GoogleMaps {
|
| 188 |
+
google_maps: serde_json::Value,
|
| 189 |
+
},
|
| 190 |
+
Retrieval {
|
| 191 |
+
retrieval: RetrievalTool,
|
| 192 |
+
},
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
#[derive(Serialize, Default, Setters)]
|
| 196 |
+
#[setters(into, strip_option)]
|
| 197 |
+
#[serde(rename_all = "camelCase")]
|
| 198 |
+
pub struct GoogleSearchTool {
|
| 199 |
+
// Empty object for Gemini 2.0+
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
#[derive(Serialize, Setters)]
|
| 203 |
+
#[setters(into, strip_option)]
|
| 204 |
+
#[serde(rename_all = "camelCase")]
|
| 205 |
+
pub struct GoogleSearchRetrievalTool {
|
| 206 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 207 |
+
pub dynamic_retrieval_config: Option<DynamicRetrievalConfig>,
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
#[derive(Serialize, Setters)]
|
| 211 |
+
#[setters(into, strip_option)]
|
| 212 |
+
#[serde(rename_all = "camelCase")]
|
| 213 |
+
pub struct DynamicRetrievalConfig {
|
| 214 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 215 |
+
pub mode: Option<String>,
|
| 216 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 217 |
+
pub dynamic_threshold: Option<f64>,
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
#[derive(Serialize, Setters)]
|
| 221 |
+
#[setters(into, strip_option)]
|
| 222 |
+
#[serde(rename_all = "camelCase")]
|
| 223 |
+
pub struct FileSearchTool {
|
| 224 |
+
pub file_search_store_names: Vec<String>,
|
| 225 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 226 |
+
pub top_k: Option<i32>,
|
| 227 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 228 |
+
pub metadata_filter: Option<String>,
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
#[derive(Serialize, Setters)]
|
| 232 |
+
#[setters(into, strip_option)]
|
| 233 |
+
#[serde(rename_all = "camelCase")]
|
| 234 |
+
pub struct RetrievalTool {
|
| 235 |
+
pub vertex_rag_store: VertexRagStore,
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
#[derive(Serialize, Setters)]
|
| 239 |
+
#[setters(into, strip_option)]
|
| 240 |
+
#[serde(rename_all = "camelCase")]
|
| 241 |
+
pub struct VertexRagStore {
|
| 242 |
+
pub rag_resources: RagResources,
|
| 243 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 244 |
+
pub similarity_top_k: Option<i32>,
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
#[derive(Serialize, Setters)]
|
| 248 |
+
#[setters(into, strip_option)]
|
| 249 |
+
#[serde(rename_all = "camelCase")]
|
| 250 |
+
pub struct RagResources {
|
| 251 |
+
pub rag_corpus: String,
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
#[derive(Serialize, Setters)]
|
| 255 |
+
#[setters(into, strip_option)]
|
| 256 |
+
#[serde(rename_all = "camelCase")]
|
| 257 |
+
pub struct FunctionDeclaration {
|
| 258 |
+
pub name: String,
|
| 259 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 260 |
+
pub description: Option<String>,
|
| 261 |
+
pub parameters: serde_json::Value,
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
#[derive(Serialize, Setters)]
|
| 265 |
+
#[setters(into, strip_option)]
|
| 266 |
+
#[serde(rename_all = "camelCase")]
|
| 267 |
+
pub struct ToolConfig {
|
| 268 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 269 |
+
pub function_calling_config: Option<FunctionCallingConfig>,
|
| 270 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 271 |
+
pub retrieval_config: Option<serde_json::Value>,
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
#[derive(Serialize, Setters)]
|
| 275 |
+
#[setters(into, strip_option)]
|
| 276 |
+
#[serde(rename_all = "camelCase")]
|
| 277 |
+
pub struct FunctionCallingConfig {
|
| 278 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 279 |
+
pub mode: Option<FunctionCallingMode>,
|
| 280 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 281 |
+
pub allowed_function_names: Option<Vec<String>>,
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
#[derive(Serialize)]
|
| 285 |
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
| 286 |
+
pub enum FunctionCallingMode {
|
| 287 |
+
ModeUnspecified,
|
| 288 |
+
Auto,
|
| 289 |
+
Any,
|
| 290 |
+
None,
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
#[derive(Serialize, Setters)]
|
| 294 |
+
#[setters(into, strip_option)]
|
| 295 |
+
#[serde(rename_all = "camelCase")]
|
| 296 |
+
pub struct SafetySetting {
|
| 297 |
+
pub category: String,
|
| 298 |
+
pub threshold: String,
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
impl From<Context> for Request {
|
| 302 |
+
fn from(context: Context) -> Self {
|
| 303 |
+
// Extract system instruction from ALL system messages
|
| 304 |
+
let system_parts: Vec<Part> = context
|
| 305 |
+
.messages
|
| 306 |
+
.iter()
|
| 307 |
+
.filter(|msg| msg.has_role(forge_domain::Role::System))
|
| 308 |
+
.filter_map(|msg| {
|
| 309 |
+
msg.content().map(|content| Part::Text {
|
| 310 |
+
text: content.to_string(),
|
| 311 |
+
thought: None,
|
| 312 |
+
thought_signature: None,
|
| 313 |
+
cache_control: None,
|
| 314 |
+
})
|
| 315 |
+
})
|
| 316 |
+
.collect();
|
| 317 |
+
|
| 318 |
+
let system_instruction = if !system_parts.is_empty() {
|
| 319 |
+
Some(Content { role: None, parts: system_parts })
|
| 320 |
+
} else {
|
| 321 |
+
None
|
| 322 |
+
};
|
| 323 |
+
|
| 324 |
+
// Convert messages (excluding system messages)
|
| 325 |
+
// Group consecutive tool results into single Content objects to match Google's
|
| 326 |
+
// API requirements
|
| 327 |
+
let mut contents: Vec<Content> = Vec::new();
|
| 328 |
+
let mut pending_tool_parts: Vec<Part> = Vec::new();
|
| 329 |
+
|
| 330 |
+
for msg in context
|
| 331 |
+
.messages
|
| 332 |
+
.into_iter()
|
| 333 |
+
.filter(|msg| !msg.has_role(forge_domain::Role::System))
|
| 334 |
+
{
|
| 335 |
+
match msg.message {
|
| 336 |
+
ContextMessage::Tool(tool_result) => {
|
| 337 |
+
// Collect tool result parts to be grouped together
|
| 338 |
+
pending_tool_parts.push(Part::from(tool_result));
|
| 339 |
+
}
|
| 340 |
+
other => {
|
| 341 |
+
// Flush any pending tool results first
|
| 342 |
+
if !pending_tool_parts.is_empty() {
|
| 343 |
+
contents.push(Content {
|
| 344 |
+
role: Some(Role::User),
|
| 345 |
+
parts: std::mem::take(&mut pending_tool_parts),
|
| 346 |
+
});
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
// Add the current non-tool message
|
| 350 |
+
let content = Content::from(other);
|
| 351 |
+
if !content.parts.is_empty() {
|
| 352 |
+
contents.push(content);
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
// Flush any remaining tool results
|
| 359 |
+
if !pending_tool_parts.is_empty() {
|
| 360 |
+
contents.push(Content { role: Some(Role::User), parts: pending_tool_parts });
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
// Convert tools
|
| 364 |
+
let tools = if !context.tools.is_empty() {
|
| 365 |
+
Some(vec![Tool::FunctionDeclarations {
|
| 366 |
+
function_declarations: Some(
|
| 367 |
+
context
|
| 368 |
+
.tools
|
| 369 |
+
.into_iter()
|
| 370 |
+
.map(FunctionDeclaration::from)
|
| 371 |
+
.collect(),
|
| 372 |
+
),
|
| 373 |
+
}])
|
| 374 |
+
} else {
|
| 375 |
+
None
|
| 376 |
+
};
|
| 377 |
+
|
| 378 |
+
// Build generation config
|
| 379 |
+
let generation_config = Some(GenerationConfig {
|
| 380 |
+
max_output_tokens: context.max_tokens.map(|t| t as i32),
|
| 381 |
+
temperature: context.temperature.map(|t| t.value() as f64),
|
| 382 |
+
top_p: context.top_p.map(|t| t.value() as f64),
|
| 383 |
+
top_k: context.top_k.map(|t| t.value() as i32),
|
| 384 |
+
response_mime_type: context.response_format.as_ref().and_then(|rf| match rf {
|
| 385 |
+
forge_domain::ResponseFormat::JsonSchema(_) => Some("application/json".to_string()),
|
| 386 |
+
_ => None,
|
| 387 |
+
}),
|
| 388 |
+
response_schema: context.response_format.and_then(|rf| match rf {
|
| 389 |
+
forge_domain::ResponseFormat::JsonSchema(schema) => {
|
| 390 |
+
let mut schema_value = serde_json::to_value(*schema).ok()?;
|
| 391 |
+
// Sanitize schema for Gemini API compatibility
|
| 392 |
+
crate::utils::sanitize_gemini_schema(&mut schema_value);
|
| 393 |
+
Some(schema_value)
|
| 394 |
+
}
|
| 395 |
+
_ => None,
|
| 396 |
+
}),
|
| 397 |
+
thinking_config: context.reasoning.and_then(|reasoning| {
|
| 398 |
+
reasoning.enabled.and_then(|enabled| {
|
| 399 |
+
if enabled {
|
| 400 |
+
Some(ThinkingConfig {
|
| 401 |
+
thinking_level: None,
|
| 402 |
+
thinking_budget: reasoning.max_tokens.map(|t| t as i32),
|
| 403 |
+
include_thoughts: Some(true),
|
| 404 |
+
})
|
| 405 |
+
} else {
|
| 406 |
+
None
|
| 407 |
+
}
|
| 408 |
+
})
|
| 409 |
+
}),
|
| 410 |
+
..Default::default()
|
| 411 |
+
});
|
| 412 |
+
|
| 413 |
+
// Build tool config for tool choice
|
| 414 |
+
// Only set tool_config if there's an explicit tool choice
|
| 415 |
+
let tool_config = context.tool_choice.map(ToolConfig::from);
|
| 416 |
+
|
| 417 |
+
Request {
|
| 418 |
+
system_instruction,
|
| 419 |
+
contents,
|
| 420 |
+
generation_config,
|
| 421 |
+
tools,
|
| 422 |
+
tool_config,
|
| 423 |
+
safety_settings: None,
|
| 424 |
+
cached_content: None,
|
| 425 |
+
labels: None,
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
impl From<forge_domain::ToolChoice> for ToolConfig {
|
| 431 |
+
fn from(choice: forge_domain::ToolChoice) -> Self {
|
| 432 |
+
let (mode, allowed_function_names) = match choice {
|
| 433 |
+
forge_domain::ToolChoice::Auto => (FunctionCallingMode::Auto, None),
|
| 434 |
+
forge_domain::ToolChoice::None => (FunctionCallingMode::None, None),
|
| 435 |
+
forge_domain::ToolChoice::Required => (FunctionCallingMode::Any, None),
|
| 436 |
+
forge_domain::ToolChoice::Call(name) => {
|
| 437 |
+
(FunctionCallingMode::Any, Some(vec![name.to_string()]))
|
| 438 |
+
}
|
| 439 |
+
};
|
| 440 |
+
|
| 441 |
+
ToolConfig {
|
| 442 |
+
function_calling_config: Some(FunctionCallingConfig {
|
| 443 |
+
mode: Some(mode),
|
| 444 |
+
allowed_function_names,
|
| 445 |
+
}),
|
| 446 |
+
retrieval_config: None,
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
impl From<forge_domain::ToolDefinition> for FunctionDeclaration {
|
| 452 |
+
fn from(tool: forge_domain::ToolDefinition) -> Self {
|
| 453 |
+
let mut parameters =
|
| 454 |
+
serde_json::to_value(tool.input_schema).unwrap_or(serde_json::json!({}));
|
| 455 |
+
|
| 456 |
+
// Sanitize schema for Gemini API compatibility (strips $schema,
|
| 457 |
+
// removes additionalProperties, converts integer enums, ensures
|
| 458 |
+
// arrays have items, removes properties from non-objects, etc.)
|
| 459 |
+
crate::utils::sanitize_gemini_schema(&mut parameters);
|
| 460 |
+
|
| 461 |
+
FunctionDeclaration {
|
| 462 |
+
name: tool.name.to_string(),
|
| 463 |
+
description: Some(tool.description),
|
| 464 |
+
parameters,
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
impl From<ContextMessage> for Content {
|
| 470 |
+
fn from(message: ContextMessage) -> Self {
|
| 471 |
+
match message {
|
| 472 |
+
ContextMessage::Text(text_message) => Content::from(text_message),
|
| 473 |
+
ContextMessage::Tool(tool_result) => Content::from(tool_result),
|
| 474 |
+
ContextMessage::Image(image) => Content::from(image),
|
| 475 |
+
}
|
| 476 |
+
}
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
impl From<forge_domain::TextMessage> for Content {
|
| 480 |
+
fn from(text_message: forge_domain::TextMessage) -> Self {
|
| 481 |
+
let role = match text_message.role {
|
| 482 |
+
forge_domain::Role::User => Some(Role::User),
|
| 483 |
+
forge_domain::Role::Assistant => Some(Role::Model),
|
| 484 |
+
forge_domain::Role::System => None, // System messages are handled separately
|
| 485 |
+
};
|
| 486 |
+
|
| 487 |
+
let mut parts = Vec::new();
|
| 488 |
+
|
| 489 |
+
// Add text part if content is not empty
|
| 490 |
+
if !text_message.content.is_empty() {
|
| 491 |
+
parts.push(Part::Text {
|
| 492 |
+
text: text_message.content,
|
| 493 |
+
thought: None,
|
| 494 |
+
thought_signature: text_message.thought_signature.clone(),
|
| 495 |
+
cache_control: None,
|
| 496 |
+
});
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
// Add function calls if present
|
| 500 |
+
if let Some(tool_calls) = text_message.tool_calls {
|
| 501 |
+
parts.extend(tool_calls.into_iter().map(Part::from));
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
Content { role, parts }
|
| 505 |
+
}
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
impl From<forge_domain::ToolResult> for Content {
|
| 509 |
+
fn from(tool_result: forge_domain::ToolResult) -> Self {
|
| 510 |
+
Content {
|
| 511 |
+
role: Some(Role::User), // Tool results come back as user messages in Google's API
|
| 512 |
+
parts: vec![Part::from(tool_result)],
|
| 513 |
+
}
|
| 514 |
+
}
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
impl From<forge_domain::Image> for Content {
|
| 518 |
+
fn from(image: forge_domain::Image) -> Self {
|
| 519 |
+
Content { role: Some(Role::User), parts: vec![Part::from(image)] }
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
impl From<forge_domain::ToolCallFull> for Part {
|
| 524 |
+
fn from(tool_call: forge_domain::ToolCallFull) -> Self {
|
| 525 |
+
Part::FunctionCall {
|
| 526 |
+
function_call: FunctionCallData {
|
| 527 |
+
name: tool_call.name.to_string(),
|
| 528 |
+
args: tool_call.arguments.parse().unwrap_or(serde_json::json!({})),
|
| 529 |
+
},
|
| 530 |
+
thought_signature: tool_call.thought_signature,
|
| 531 |
+
}
|
| 532 |
+
}
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
impl From<forge_domain::ToolResult> for Part {
|
| 536 |
+
fn from(tool_result: forge_domain::ToolResult) -> Self {
|
| 537 |
+
Part::FunctionResponse {
|
| 538 |
+
function_response: FunctionResponseData {
|
| 539 |
+
name: tool_result.name.to_string(),
|
| 540 |
+
response: serde_json::to_value(&tool_result.output)
|
| 541 |
+
.unwrap_or(serde_json::json!({})),
|
| 542 |
+
},
|
| 543 |
+
}
|
| 544 |
+
}
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
impl From<forge_domain::Image> for Part {
|
| 548 |
+
fn from(image: forge_domain::Image) -> Self {
|
| 549 |
+
Part::Image {
|
| 550 |
+
inline_data: ImageSource {
|
| 551 |
+
mime_type: Some(image.mime_type().to_string()),
|
| 552 |
+
data: Some(image.data().to_string()),
|
| 553 |
+
},
|
| 554 |
+
cache_control: None,
|
| 555 |
+
}
|
| 556 |
+
}
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
#[cfg(test)]
|
| 560 |
+
mod tests {
|
| 561 |
+
use forge_domain::{ToolCallArguments, ToolCallFull, ToolCallId, ToolName, ToolResult};
|
| 562 |
+
|
| 563 |
+
use super::*;
|
| 564 |
+
|
| 565 |
+
#[test]
|
| 566 |
+
fn test_tool_call_args_serialization() {
|
| 567 |
+
// Create a ToolCallFull with Unparsed JSON arguments (as it would come from
|
| 568 |
+
// API)
|
| 569 |
+
let tool_call = ToolCallFull {
|
| 570 |
+
name: ToolName::new("patch"),
|
| 571 |
+
call_id: None,
|
| 572 |
+
arguments: ToolCallArguments::from_json(
|
| 573 |
+
r#"{"file_path":"test.rs","old_string":"foo","new_string":"bar"}"#,
|
| 574 |
+
),
|
| 575 |
+
thought_signature: None,
|
| 576 |
+
};
|
| 577 |
+
|
| 578 |
+
// Convert to Google Part
|
| 579 |
+
let part = Part::from(tool_call);
|
| 580 |
+
|
| 581 |
+
// Verify it serializes correctly
|
| 582 |
+
let serialized = serde_json::to_value(&part).unwrap();
|
| 583 |
+
|
| 584 |
+
// The Part enum serializes using snake_case for the variant name
|
| 585 |
+
if let Some(function_call) = serialized.get("function_call") {
|
| 586 |
+
if let Some(args) = function_call.get("args") {
|
| 587 |
+
// Args should be a proper JSON object, not a string
|
| 588 |
+
assert!(
|
| 589 |
+
args.is_object(),
|
| 590 |
+
"Args should be deserialized as JSON object, not string: {:?}",
|
| 591 |
+
args
|
| 592 |
+
);
|
| 593 |
+
|
| 594 |
+
// Verify fields are accessible
|
| 595 |
+
assert_eq!(args["file_path"], "test.rs");
|
| 596 |
+
assert_eq!(args["old_string"], "foo");
|
| 597 |
+
assert_eq!(args["new_string"], "bar");
|
| 598 |
+
} else {
|
| 599 |
+
panic!("Expected function_call.args to exist");
|
| 600 |
+
}
|
| 601 |
+
} else {
|
| 602 |
+
panic!("Expected function_call variant");
|
| 603 |
+
}
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
#[test]
|
| 607 |
+
fn test_multiple_tool_calls_serialization() {
|
| 608 |
+
// Simulate multiple tool calls with different args
|
| 609 |
+
let tool_calls = vec![
|
| 610 |
+
ToolCallFull {
|
| 611 |
+
name: ToolName::new("remove"),
|
| 612 |
+
call_id: None,
|
| 613 |
+
arguments: ToolCallArguments::from_json(r#"{"path":"file1.rs"}"#),
|
| 614 |
+
thought_signature: None,
|
| 615 |
+
},
|
| 616 |
+
ToolCallFull {
|
| 617 |
+
name: ToolName::new("remove"),
|
| 618 |
+
call_id: None,
|
| 619 |
+
arguments: ToolCallArguments::from_json(r#"{"path":"file2.rs"}"#),
|
| 620 |
+
thought_signature: None,
|
| 621 |
+
},
|
| 622 |
+
];
|
| 623 |
+
|
| 624 |
+
// Convert each to Part and serialize
|
| 625 |
+
for (i, tool_call) in tool_calls.into_iter().enumerate() {
|
| 626 |
+
let part = Part::from(tool_call);
|
| 627 |
+
let serialized = serde_json::to_value(&part).unwrap();
|
| 628 |
+
|
| 629 |
+
if let Some(function_call) = serialized.get("function_call") {
|
| 630 |
+
if let Some(args) = function_call.get("args") {
|
| 631 |
+
assert!(
|
| 632 |
+
args.is_object(),
|
| 633 |
+
"Tool call {} args should be object, got: {:?}",
|
| 634 |
+
i,
|
| 635 |
+
args
|
| 636 |
+
);
|
| 637 |
+
let expected_path = format!("file{}.rs", i + 1);
|
| 638 |
+
assert_eq!(args["path"], expected_path, "Tool call {} path mismatch", i);
|
| 639 |
+
} else {
|
| 640 |
+
panic!("Expected function_call.args for tool call {}", i);
|
| 641 |
+
}
|
| 642 |
+
} else {
|
| 643 |
+
panic!("Expected function_call variant for tool call {}", i);
|
| 644 |
+
}
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
#[test]
|
| 649 |
+
fn test_consecutive_tool_results_grouped() {
|
| 650 |
+
use forge_domain::{Context, ContextMessage, ModelId};
|
| 651 |
+
|
| 652 |
+
// Create a context with multiple consecutive tool results (simulating 13 read
|
| 653 |
+
// calls)
|
| 654 |
+
let mut context = Context::default();
|
| 655 |
+
|
| 656 |
+
// Add initial user message
|
| 657 |
+
context = context.add_message(ContextMessage::user(
|
| 658 |
+
"Read these files",
|
| 659 |
+
Some(ModelId::new("test")),
|
| 660 |
+
));
|
| 661 |
+
|
| 662 |
+
// Add assistant message with tool calls
|
| 663 |
+
context = context.add_message(ContextMessage::assistant("", None, None, None));
|
| 664 |
+
|
| 665 |
+
// Add 13 consecutive tool results (like in the dump)
|
| 666 |
+
for i in 1..=13 {
|
| 667 |
+
let tool_result = ToolResult::new("read")
|
| 668 |
+
.call_id(ToolCallId::new(format!("call_{}", i)))
|
| 669 |
+
.success(format!("Content of file {}", i));
|
| 670 |
+
context = context.add_message(ContextMessage::tool_result(tool_result));
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
// Convert to Google Request
|
| 674 |
+
let request = Request::from(context);
|
| 675 |
+
|
| 676 |
+
// Verify structure:
|
| 677 |
+
// 1. First content: user message
|
| 678 |
+
// 2. Second content: assistant message (might be empty and filtered out)
|
| 679 |
+
// 3. Third content: ALL 13 tool results grouped together
|
| 680 |
+
|
| 681 |
+
// Find the content with tool results
|
| 682 |
+
let tool_result_content = request
|
| 683 |
+
.contents
|
| 684 |
+
.iter()
|
| 685 |
+
.find(|c| {
|
| 686 |
+
c.parts
|
| 687 |
+
.iter()
|
| 688 |
+
.any(|p| matches!(p, Part::FunctionResponse { .. }))
|
| 689 |
+
})
|
| 690 |
+
.expect("Should have a content with function responses");
|
| 691 |
+
|
| 692 |
+
// Verify all 13 tool results are in ONE Content object
|
| 693 |
+
let function_response_count = tool_result_content
|
| 694 |
+
.parts
|
| 695 |
+
.iter()
|
| 696 |
+
.filter(|p| matches!(p, Part::FunctionResponse { .. }))
|
| 697 |
+
.count();
|
| 698 |
+
|
| 699 |
+
assert_eq!(
|
| 700 |
+
function_response_count, 13,
|
| 701 |
+
"All 13 tool results should be grouped into a single Content with 13 FunctionResponse parts"
|
| 702 |
+
);
|
| 703 |
+
|
| 704 |
+
// Verify the role is User
|
| 705 |
+
assert_eq!(
|
| 706 |
+
tool_result_content.role,
|
| 707 |
+
Some(Role::User),
|
| 708 |
+
"Tool results should have User role"
|
| 709 |
+
);
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
#[test]
|
| 713 |
+
fn test_non_consecutive_tool_results_not_grouped() {
|
| 714 |
+
use forge_domain::{Context, ContextMessage, ModelId};
|
| 715 |
+
|
| 716 |
+
// Create a context with tool results separated by other messages
|
| 717 |
+
let mut context = Context::default();
|
| 718 |
+
|
| 719 |
+
// User message
|
| 720 |
+
context = context.add_message(ContextMessage::user(
|
| 721 |
+
"First task",
|
| 722 |
+
Some(ModelId::new("test")),
|
| 723 |
+
));
|
| 724 |
+
|
| 725 |
+
// Tool result #1
|
| 726 |
+
let tool_result_1 = ToolResult::new("read")
|
| 727 |
+
.call_id(ToolCallId::new("call_1"))
|
| 728 |
+
.success("Result 1");
|
| 729 |
+
context = context.add_message(ContextMessage::tool_result(tool_result_1));
|
| 730 |
+
|
| 731 |
+
// Assistant message (breaks the sequence)
|
| 732 |
+
context = context.add_message(ContextMessage::assistant(
|
| 733 |
+
"Let me do more",
|
| 734 |
+
None,
|
| 735 |
+
None,
|
| 736 |
+
None,
|
| 737 |
+
));
|
| 738 |
+
|
| 739 |
+
// Tool result #2 (should be in a separate Content)
|
| 740 |
+
let tool_result_2 = ToolResult::new("read")
|
| 741 |
+
.call_id(ToolCallId::new("call_2"))
|
| 742 |
+
.success("Result 2");
|
| 743 |
+
context = context.add_message(ContextMessage::tool_result(tool_result_2));
|
| 744 |
+
|
| 745 |
+
// Convert to Google Request
|
| 746 |
+
let request = Request::from(context);
|
| 747 |
+
|
| 748 |
+
// Count how many Content objects have FunctionResponse parts
|
| 749 |
+
let contents_with_tool_results: Vec<_> = request
|
| 750 |
+
.contents
|
| 751 |
+
.iter()
|
| 752 |
+
.filter(|c| {
|
| 753 |
+
c.parts
|
| 754 |
+
.iter()
|
| 755 |
+
.any(|p| matches!(p, Part::FunctionResponse { .. }))
|
| 756 |
+
})
|
| 757 |
+
.collect();
|
| 758 |
+
|
| 759 |
+
// Should have 2 separate Content objects for the 2 non-consecutive tool results
|
| 760 |
+
assert_eq!(
|
| 761 |
+
contents_with_tool_results.len(),
|
| 762 |
+
2,
|
| 763 |
+
"Non-consecutive tool results should be in separate Content objects"
|
| 764 |
+
);
|
| 765 |
+
|
| 766 |
+
// Each should have exactly 1 FunctionResponse
|
| 767 |
+
for content in contents_with_tool_results {
|
| 768 |
+
let count = content
|
| 769 |
+
.parts
|
| 770 |
+
.iter()
|
| 771 |
+
.filter(|p| matches!(p, Part::FunctionResponse { .. }))
|
| 772 |
+
.count();
|
| 773 |
+
assert_eq!(
|
| 774 |
+
count, 1,
|
| 775 |
+
"Each separate tool result group should have 1 FunctionResponse"
|
| 776 |
+
);
|
| 777 |
+
}
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
#[test]
|
| 781 |
+
fn test_tool_choice_conversion() {
|
| 782 |
+
use forge_domain::ToolChoice;
|
| 783 |
+
|
| 784 |
+
// Test Auto
|
| 785 |
+
let config = ToolConfig::from(ToolChoice::Auto);
|
| 786 |
+
let fc_config = config.function_calling_config.unwrap();
|
| 787 |
+
assert!(matches!(fc_config.mode, Some(FunctionCallingMode::Auto)));
|
| 788 |
+
assert!(fc_config.allowed_function_names.is_none());
|
| 789 |
+
|
| 790 |
+
// Test None
|
| 791 |
+
let config = ToolConfig::from(ToolChoice::None);
|
| 792 |
+
let fc_config = config.function_calling_config.unwrap();
|
| 793 |
+
assert!(matches!(fc_config.mode, Some(FunctionCallingMode::None)));
|
| 794 |
+
assert!(fc_config.allowed_function_names.is_none());
|
| 795 |
+
|
| 796 |
+
// Test Required
|
| 797 |
+
let config = ToolConfig::from(ToolChoice::Required);
|
| 798 |
+
let fc_config = config.function_calling_config.unwrap();
|
| 799 |
+
assert!(matches!(fc_config.mode, Some(FunctionCallingMode::Any)));
|
| 800 |
+
assert!(fc_config.allowed_function_names.is_none());
|
| 801 |
+
|
| 802 |
+
// Test Call
|
| 803 |
+
let config = ToolConfig::from(ToolChoice::Call(ToolName::new("my_tool")));
|
| 804 |
+
let fc_config = config.function_calling_config.unwrap();
|
| 805 |
+
assert!(matches!(fc_config.mode, Some(FunctionCallingMode::Any)));
|
| 806 |
+
assert_eq!(fc_config.allowed_function_names.unwrap(), vec!["my_tool"]);
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
#[test]
|
| 810 |
+
fn test_tool_definition_conversion() {
|
| 811 |
+
use forge_domain::ToolDefinition;
|
| 812 |
+
use schemars::schema_for;
|
| 813 |
+
|
| 814 |
+
#[derive(schemars::JsonSchema)]
|
| 815 |
+
struct Args {
|
| 816 |
+
_arg1: String,
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
let tool_def = ToolDefinition {
|
| 820 |
+
name: ToolName::new("test_tool"),
|
| 821 |
+
description: "A test tool".to_string(),
|
| 822 |
+
input_schema: schema_for!(Args),
|
| 823 |
+
};
|
| 824 |
+
|
| 825 |
+
let decl = FunctionDeclaration::from(tool_def);
|
| 826 |
+
|
| 827 |
+
assert_eq!(decl.name, "test_tool");
|
| 828 |
+
assert_eq!(decl.description.unwrap(), "A test tool");
|
| 829 |
+
|
| 830 |
+
// Check Gemini schema sanitization
|
| 831 |
+
let params = decl.parameters;
|
| 832 |
+
assert!(params.is_object());
|
| 833 |
+
assert!(!params.as_object().unwrap().contains_key("$schema"));
|
| 834 |
+
assert!(params.as_object().unwrap().contains_key("type"));
|
| 835 |
+
// additionalProperties should be removed by Gemini sanitization
|
| 836 |
+
assert!(
|
| 837 |
+
!params
|
| 838 |
+
.as_object()
|
| 839 |
+
.unwrap()
|
| 840 |
+
.contains_key("additionalProperties")
|
| 841 |
+
);
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
#[test]
|
| 845 |
+
fn test_text_message_conversion() {
|
| 846 |
+
use forge_domain::{Role, TextMessage};
|
| 847 |
+
|
| 848 |
+
// User message
|
| 849 |
+
let msg = TextMessage::new(Role::User, "Hello");
|
| 850 |
+
let content = Content::from(msg);
|
| 851 |
+
assert_eq!(content.role, Some(self::Role::User));
|
| 852 |
+
assert_eq!(content.parts.len(), 1);
|
| 853 |
+
match &content.parts[0] {
|
| 854 |
+
Part::Text { text, .. } => assert_eq!(text, "Hello"),
|
| 855 |
+
_ => panic!("Expected Text part"),
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
// Assistant message with thought signature
|
| 859 |
+
let msg = TextMessage::assistant("Hi", None, None).thought_signature("sig");
|
| 860 |
+
let content = Content::from(msg);
|
| 861 |
+
assert_eq!(content.role, Some(self::Role::Model));
|
| 862 |
+
match &content.parts[0] {
|
| 863 |
+
Part::Text { text, thought_signature, .. } => {
|
| 864 |
+
assert_eq!(text, "Hi");
|
| 865 |
+
assert_eq!(thought_signature.as_deref(), Some("sig"));
|
| 866 |
+
}
|
| 867 |
+
_ => panic!("Expected Text part"),
|
| 868 |
+
}
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
#[test]
|
| 872 |
+
fn test_image_conversion() {
|
| 873 |
+
use forge_domain::Image;
|
| 874 |
+
|
| 875 |
+
let image = Image::new_base64("base64data".to_string(), "image/png");
|
| 876 |
+
let content = Content::from(image.clone());
|
| 877 |
+
|
| 878 |
+
assert_eq!(content.role, Some(self::Role::User));
|
| 879 |
+
assert_eq!(content.parts.len(), 1);
|
| 880 |
+
|
| 881 |
+
match &content.parts[0] {
|
| 882 |
+
Part::Image { inline_data, .. } => {
|
| 883 |
+
assert_eq!(inline_data.mime_type.as_deref(), Some("image/png"));
|
| 884 |
+
assert_eq!(inline_data.data.as_deref(), Some("base64data"));
|
| 885 |
+
}
|
| 886 |
+
_ => panic!("Expected Image part"),
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
// Test direct Part conversion
|
| 890 |
+
let part = Part::from(image);
|
| 891 |
+
match part {
|
| 892 |
+
Part::Image { inline_data, .. } => {
|
| 893 |
+
assert_eq!(inline_data.mime_type.as_deref(), Some("image/png"));
|
| 894 |
+
assert_eq!(inline_data.data.as_deref(), Some("base64data"));
|
| 895 |
+
}
|
| 896 |
+
_ => panic!("Expected Image part"),
|
| 897 |
+
}
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
#[test]
|
| 901 |
+
fn test_response_schema_strips_dollar_schema() {
|
| 902 |
+
use forge_domain::{Context, ResponseFormat};
|
| 903 |
+
use schemars::schema_for;
|
| 904 |
+
|
| 905 |
+
#[allow(dead_code)]
|
| 906 |
+
#[derive(schemars::JsonSchema)]
|
| 907 |
+
struct TestResponse {
|
| 908 |
+
result: String,
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
// Create a context with a JSON schema response format
|
| 912 |
+
let schema = schema_for!(TestResponse);
|
| 913 |
+
let context =
|
| 914 |
+
Context::default().response_format(ResponseFormat::JsonSchema(Box::new(schema)));
|
| 915 |
+
|
| 916 |
+
// Convert to Google Request
|
| 917 |
+
let request = Request::from(context);
|
| 918 |
+
|
| 919 |
+
// Verify generation_config has response_schema
|
| 920 |
+
let generation_config = request
|
| 921 |
+
.generation_config
|
| 922 |
+
.expect("Should have generation_config");
|
| 923 |
+
let response_schema = generation_config
|
| 924 |
+
.response_schema
|
| 925 |
+
.expect("Should have response_schema");
|
| 926 |
+
|
| 927 |
+
// Verify $schema field is removed
|
| 928 |
+
if let Some(obj) = response_schema.as_object() {
|
| 929 |
+
assert!(
|
| 930 |
+
!obj.contains_key("$schema"),
|
| 931 |
+
"$schema field should be removed from response_schema"
|
| 932 |
+
);
|
| 933 |
+
|
| 934 |
+
// Verify other schema properties are still present
|
| 935 |
+
assert!(
|
| 936 |
+
obj.contains_key("type")
|
| 937 |
+
|| obj.contains_key("properties")
|
| 938 |
+
|| obj.contains_key("title"),
|
| 939 |
+
"Schema should still contain other properties"
|
| 940 |
+
);
|
| 941 |
+
|
| 942 |
+
// Verify additionalProperties is also removed by Gemini sanitization
|
| 943 |
+
assert!(
|
| 944 |
+
!obj.contains_key("additionalProperties"),
|
| 945 |
+
"additionalProperties should be removed by Gemini sanitization"
|
| 946 |
+
);
|
| 947 |
+
} else {
|
| 948 |
+
panic!("response_schema should be an object");
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
// Verify response_mime_type is set to application/json
|
| 952 |
+
assert_eq!(
|
| 953 |
+
generation_config.response_mime_type,
|
| 954 |
+
Some("application/json".to_string()),
|
| 955 |
+
"response_mime_type should be set for JSON schema"
|
| 956 |
+
);
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
#[test]
|
| 960 |
+
fn test_tool_result_part_conversion() {
|
| 961 |
+
use forge_domain::{ToolCallId, ToolName, ToolResult};
|
| 962 |
+
|
| 963 |
+
let result = ToolResult::new(ToolName::new("test"))
|
| 964 |
+
.call_id(ToolCallId::new("call_1"))
|
| 965 |
+
.success("output");
|
| 966 |
+
|
| 967 |
+
let part = Part::from(result);
|
| 968 |
+
|
| 969 |
+
match part {
|
| 970 |
+
Part::FunctionResponse { function_response } => {
|
| 971 |
+
assert_eq!(function_response.name, "test");
|
| 972 |
+
// The response should be wrapped in a JSON value
|
| 973 |
+
let expected = serde_json::json!({
|
| 974 |
+
"is_error": false,
|
| 975 |
+
"values": [{"text": "output"}]
|
| 976 |
+
});
|
| 977 |
+
assert_eq!(function_response.response, expected);
|
| 978 |
+
}
|
| 979 |
+
_ => panic!("Expected FunctionResponse part"),
|
| 980 |
+
}
|
| 981 |
+
}
|
| 982 |
+
}
|
crates/forge_app/src/dto/google/response.rs
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::{
|
| 2 |
+
ChatCompletionMessage, FinishReason, Reasoning, ReasoningPart, TokenCount, ToolCallId,
|
| 3 |
+
ToolCallPart, ToolName,
|
| 4 |
+
};
|
| 5 |
+
use serde::Deserialize;
|
| 6 |
+
|
| 7 |
+
/// Model information from Google API
|
| 8 |
+
#[derive(Deserialize, Debug, Clone)]
|
| 9 |
+
#[serde(rename_all = "camelCase")]
|
| 10 |
+
pub struct Model {
|
| 11 |
+
pub name: String,
|
| 12 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 13 |
+
pub display_name: Option<String>,
|
| 14 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 15 |
+
pub description: Option<String>,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
impl From<Model> for forge_domain::Model {
|
| 19 |
+
fn from(value: Model) -> Self {
|
| 20 |
+
// Extract model ID from name (format: "models/gemini-2.0-flash")
|
| 21 |
+
let id = value
|
| 22 |
+
.name
|
| 23 |
+
.strip_prefix("models/")
|
| 24 |
+
.unwrap_or(&value.name)
|
| 25 |
+
.to_string();
|
| 26 |
+
|
| 27 |
+
// Determine context length based on model name
|
| 28 |
+
let context_length = if id.contains("gemini-2.0") || id.contains("gemini-1.5") {
|
| 29 |
+
2_000_000 // 2M tokens for Gemini 2.0 and 1.5
|
| 30 |
+
} else {
|
| 31 |
+
32_000 // Default for older models
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
forge_domain::Model {
|
| 35 |
+
id: forge_domain::ModelId::new(id),
|
| 36 |
+
name: Some(value.display_name.unwrap_or(value.name)),
|
| 37 |
+
description: value.description,
|
| 38 |
+
context_length: Some(context_length),
|
| 39 |
+
tools_supported: Some(true), // Google models support function calling
|
| 40 |
+
supports_parallel_tool_calls: Some(true),
|
| 41 |
+
supports_reasoning: Some(true), // Gemini 2.0+ supports thinking
|
| 42 |
+
input_modalities: vec![], // Google supports text, images, audio, video
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/// EventData for Google streaming responses
|
| 48 |
+
/// Google returns chunks directly without event wrappers
|
| 49 |
+
#[derive(Deserialize, PartialEq, Clone, Debug)]
|
| 50 |
+
#[serde(untagged)]
|
| 51 |
+
pub enum EventData {
|
| 52 |
+
Response(Response),
|
| 53 |
+
Error(ErrorResponse),
|
| 54 |
+
Ping(PingEvent),
|
| 55 |
+
Unknown(serde_json::Value),
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/// Represents a value that may be either a JSON number or a numeric string,
|
| 59 |
+
/// used for fields like `cost` that proxies sometimes encode as strings.
|
| 60 |
+
#[derive(Deserialize, Debug, Clone, PartialEq, derive_more::TryInto)]
|
| 61 |
+
#[serde(untagged)]
|
| 62 |
+
pub enum StringOrF64 {
|
| 63 |
+
Number(f64),
|
| 64 |
+
String(String),
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/// Heartbeat/cost event sent by some proxies (e.g. opencode.ai).
|
| 68 |
+
///
|
| 69 |
+
/// Example payload: `{"type":"ping","cost":"0.02889400"}`
|
| 70 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 71 |
+
pub struct PingEvent {
|
| 72 |
+
pub cost: StringOrF64,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 76 |
+
pub struct ErrorResponse {
|
| 77 |
+
pub error: ErrorContent,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 81 |
+
pub struct ErrorContent {
|
| 82 |
+
pub code: i32,
|
| 83 |
+
pub message: String,
|
| 84 |
+
pub status: String,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
impl TryFrom<EventData> for ChatCompletionMessage {
|
| 88 |
+
type Error = anyhow::Error;
|
| 89 |
+
|
| 90 |
+
fn try_from(value: EventData) -> Result<Self, Self::Error> {
|
| 91 |
+
match value {
|
| 92 |
+
EventData::Response(response) => ChatCompletionMessage::try_from(response),
|
| 93 |
+
EventData::Error(e) => Err(anyhow::anyhow!(
|
| 94 |
+
"Google API Error {}: {}",
|
| 95 |
+
e.error.code,
|
| 96 |
+
e.error.message
|
| 97 |
+
)),
|
| 98 |
+
EventData::Ping(ping) => {
|
| 99 |
+
// Extract cost from proxy ping events (e.g. opencode.ai)
|
| 100 |
+
let cost = match ping.cost {
|
| 101 |
+
StringOrF64::Number(n) => n,
|
| 102 |
+
StringOrF64::String(s) => s.parse().unwrap_or(0.0),
|
| 103 |
+
};
|
| 104 |
+
let usage = forge_domain::Usage { cost: Some(cost), ..Default::default() };
|
| 105 |
+
Ok(ChatCompletionMessage::assistant(forge_domain::Content::part("")).usage(usage))
|
| 106 |
+
}
|
| 107 |
+
EventData::Unknown(_) => {
|
| 108 |
+
// Silently ignore any other unrecognised events
|
| 109 |
+
Ok(ChatCompletionMessage::assistant(
|
| 110 |
+
forge_domain::Content::part(""),
|
| 111 |
+
))
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 117 |
+
#[serde(rename_all = "camelCase")]
|
| 118 |
+
pub struct Response {
|
| 119 |
+
pub candidates: Vec<Candidate>,
|
| 120 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 121 |
+
pub usage_metadata: Option<UsageMetadata>,
|
| 122 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 123 |
+
pub prompt_feedback: Option<PromptFeedback>,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 127 |
+
#[serde(rename_all = "camelCase")]
|
| 128 |
+
pub struct Candidate {
|
| 129 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 130 |
+
pub content: Option<Content>,
|
| 131 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 132 |
+
pub finish_reason: Option<String>,
|
| 133 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 134 |
+
pub safety_ratings: Option<Vec<SafetyRating>>,
|
| 135 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 136 |
+
pub grounding_metadata: Option<GroundingMetadata>,
|
| 137 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 138 |
+
pub url_context_metadata: Option<UrlContextMetadata>,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 142 |
+
pub struct Content {
|
| 143 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 144 |
+
pub role: Option<String>,
|
| 145 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 146 |
+
pub parts: Option<Vec<Part>>,
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 150 |
+
#[serde(untagged)]
|
| 151 |
+
pub enum Part {
|
| 152 |
+
FunctionCall {
|
| 153 |
+
#[serde(rename = "functionCall")]
|
| 154 |
+
function_call: FunctionCall,
|
| 155 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 156 |
+
#[serde(rename = "thoughtSignature")]
|
| 157 |
+
thought_signature: Option<String>,
|
| 158 |
+
},
|
| 159 |
+
InlineData {
|
| 160 |
+
#[serde(rename = "inlineData")]
|
| 161 |
+
inline_data: InlineData,
|
| 162 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 163 |
+
#[serde(rename = "thoughtSignature")]
|
| 164 |
+
thought_signature: Option<String>,
|
| 165 |
+
},
|
| 166 |
+
Text {
|
| 167 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 168 |
+
text: Option<String>,
|
| 169 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 170 |
+
thought: Option<bool>,
|
| 171 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 172 |
+
#[serde(rename = "thoughtSignature")]
|
| 173 |
+
thought_signature: Option<String>,
|
| 174 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 175 |
+
#[serde(rename = "executableCode")]
|
| 176 |
+
executable_code: Option<ExecutableCode>,
|
| 177 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 178 |
+
#[serde(rename = "codeExecutionResult")]
|
| 179 |
+
code_execution_result: Option<CodeExecutionResult>,
|
| 180 |
+
},
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 184 |
+
#[serde(rename_all = "camelCase")]
|
| 185 |
+
pub struct FunctionCall {
|
| 186 |
+
pub name: String,
|
| 187 |
+
pub args: serde_json::Value,
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 191 |
+
#[serde(rename_all = "camelCase")]
|
| 192 |
+
pub struct InlineData {
|
| 193 |
+
pub mime_type: String,
|
| 194 |
+
pub data: String,
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 198 |
+
pub struct ExecutableCode {
|
| 199 |
+
pub language: String,
|
| 200 |
+
pub code: String,
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 204 |
+
pub struct CodeExecutionResult {
|
| 205 |
+
pub outcome: String,
|
| 206 |
+
pub output: String,
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 210 |
+
#[serde(rename_all = "camelCase")]
|
| 211 |
+
pub struct SafetyRating {
|
| 212 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 213 |
+
pub category: Option<String>,
|
| 214 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 215 |
+
pub probability: Option<String>,
|
| 216 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 217 |
+
pub probability_score: Option<f64>,
|
| 218 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 219 |
+
pub severity: Option<String>,
|
| 220 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 221 |
+
pub severity_score: Option<f64>,
|
| 222 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 223 |
+
pub blocked: Option<bool>,
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 227 |
+
#[serde(rename_all = "camelCase")]
|
| 228 |
+
pub struct UsageMetadata {
|
| 229 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 230 |
+
pub cached_content_token_count: Option<i32>,
|
| 231 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 232 |
+
pub thoughts_token_count: Option<i32>,
|
| 233 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 234 |
+
pub prompt_token_count: Option<i32>,
|
| 235 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 236 |
+
pub candidates_token_count: Option<i32>,
|
| 237 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 238 |
+
pub total_token_count: Option<i32>,
|
| 239 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 240 |
+
pub traffic_type: Option<String>,
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 244 |
+
#[serde(rename_all = "camelCase")]
|
| 245 |
+
pub struct PromptFeedback {
|
| 246 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 247 |
+
pub block_reason: Option<String>,
|
| 248 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 249 |
+
pub safety_ratings: Option<Vec<SafetyRating>>,
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 253 |
+
#[serde(rename_all = "camelCase")]
|
| 254 |
+
pub struct GroundingMetadata {
|
| 255 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 256 |
+
pub web_search_queries: Option<Vec<String>>,
|
| 257 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 258 |
+
pub retrieval_queries: Option<Vec<String>>,
|
| 259 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 260 |
+
pub search_entry_point: Option<SearchEntryPoint>,
|
| 261 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 262 |
+
pub grounding_chunks: Option<Vec<GroundingChunk>>,
|
| 263 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 264 |
+
pub grounding_supports: Option<Vec<GroundingSupport>>,
|
| 265 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 266 |
+
pub retrieval_metadata: Option<serde_json::Value>,
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 270 |
+
#[serde(rename_all = "camelCase")]
|
| 271 |
+
pub struct SearchEntryPoint {
|
| 272 |
+
pub rendered_content: String,
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 276 |
+
pub struct GroundingChunk {
|
| 277 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 278 |
+
pub web: Option<WebChunk>,
|
| 279 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 280 |
+
pub retrieved_context: Option<RetrievedContext>,
|
| 281 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 282 |
+
pub maps: Option<MapsChunk>,
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 286 |
+
pub struct WebChunk {
|
| 287 |
+
pub uri: String,
|
| 288 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 289 |
+
pub title: Option<String>,
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 293 |
+
#[serde(rename_all = "camelCase")]
|
| 294 |
+
pub struct RetrievedContext {
|
| 295 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 296 |
+
pub uri: Option<String>,
|
| 297 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 298 |
+
pub title: Option<String>,
|
| 299 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 300 |
+
pub text: Option<String>,
|
| 301 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 302 |
+
pub file_search_store: Option<String>,
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 306 |
+
#[serde(rename_all = "camelCase")]
|
| 307 |
+
pub struct MapsChunk {
|
| 308 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 309 |
+
pub uri: Option<String>,
|
| 310 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 311 |
+
pub title: Option<String>,
|
| 312 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 313 |
+
pub text: Option<String>,
|
| 314 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 315 |
+
pub place_id: Option<String>,
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 319 |
+
#[serde(rename_all = "camelCase")]
|
| 320 |
+
pub struct GroundingSupport {
|
| 321 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 322 |
+
pub segment: Option<Segment>,
|
| 323 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 324 |
+
#[serde(rename = "segment_text")]
|
| 325 |
+
pub segment_text: Option<String>,
|
| 326 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 327 |
+
pub grounding_chunk_indices: Option<Vec<i32>>,
|
| 328 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 329 |
+
pub support_chunk_indices: Option<Vec<i32>>,
|
| 330 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 331 |
+
pub confidence_scores: Option<Vec<f64>>,
|
| 332 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 333 |
+
pub confidence_score: Option<Vec<f64>>,
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 337 |
+
#[serde(rename_all = "camelCase")]
|
| 338 |
+
pub struct Segment {
|
| 339 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 340 |
+
pub start_index: Option<i32>,
|
| 341 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 342 |
+
pub end_index: Option<i32>,
|
| 343 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 344 |
+
pub text: Option<String>,
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 348 |
+
#[serde(rename_all = "camelCase")]
|
| 349 |
+
pub struct UrlContextMetadata {
|
| 350 |
+
pub url_metadata: Vec<UrlMetadata>,
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
| 354 |
+
#[serde(rename_all = "camelCase")]
|
| 355 |
+
pub struct UrlMetadata {
|
| 356 |
+
pub retrieved_url: String,
|
| 357 |
+
pub url_retrieval_status: String,
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
impl From<UsageMetadata> for forge_domain::Usage {
|
| 361 |
+
fn from(usage: UsageMetadata) -> Self {
|
| 362 |
+
let prompt_tokens = usage.prompt_token_count.unwrap_or_default() as usize;
|
| 363 |
+
let completion_tokens = usage.candidates_token_count.unwrap_or_default() as usize;
|
| 364 |
+
let cached_tokens = usage.cached_content_token_count.unwrap_or_default() as usize;
|
| 365 |
+
let total_tokens = usage.total_token_count.unwrap_or_default() as usize;
|
| 366 |
+
|
| 367 |
+
forge_domain::Usage {
|
| 368 |
+
prompt_tokens: TokenCount::Actual(prompt_tokens),
|
| 369 |
+
completion_tokens: TokenCount::Actual(completion_tokens),
|
| 370 |
+
total_tokens: TokenCount::Actual(total_tokens),
|
| 371 |
+
cached_tokens: TokenCount::Actual(cached_tokens),
|
| 372 |
+
..Default::default()
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
/// Converts Google's finish reason strings to domain FinishReason
|
| 378 |
+
fn parse_finish_reason(reason: &str) -> Option<FinishReason> {
|
| 379 |
+
match reason {
|
| 380 |
+
"STOP" => Some(FinishReason::Stop),
|
| 381 |
+
"MAX_TOKENS" => Some(FinishReason::Length),
|
| 382 |
+
"SAFETY" | "RECITATION" => Some(FinishReason::ContentFilter),
|
| 383 |
+
_ => Some(FinishReason::Stop), // Default to Stop for unknown reasons
|
| 384 |
+
}
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
impl TryFrom<Part> for ChatCompletionMessage {
|
| 388 |
+
type Error = anyhow::Error;
|
| 389 |
+
|
| 390 |
+
fn try_from(part: Part) -> Result<Self, Self::Error> {
|
| 391 |
+
match part {
|
| 392 |
+
Part::Text {
|
| 393 |
+
text,
|
| 394 |
+
thought,
|
| 395 |
+
thought_signature,
|
| 396 |
+
executable_code: _,
|
| 397 |
+
code_execution_result: _,
|
| 398 |
+
} => {
|
| 399 |
+
let text_content = text.unwrap_or_default();
|
| 400 |
+
let is_thought = thought.unwrap_or(false);
|
| 401 |
+
|
| 402 |
+
if is_thought {
|
| 403 |
+
// This is a thinking/reasoning part
|
| 404 |
+
let mut msg = ChatCompletionMessage::assistant(forge_domain::Content::part(""))
|
| 405 |
+
.reasoning(forge_domain::Content::part(text_content.clone()))
|
| 406 |
+
.add_reasoning_detail(Reasoning::Part(vec![
|
| 407 |
+
ReasoningPart::default()
|
| 408 |
+
.text(Some(text_content))
|
| 409 |
+
.signature(thought_signature.clone()),
|
| 410 |
+
]));
|
| 411 |
+
|
| 412 |
+
if let Some(signature) = thought_signature {
|
| 413 |
+
msg = msg.thought_signature(signature);
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
Ok(msg)
|
| 417 |
+
} else {
|
| 418 |
+
// Regular text content
|
| 419 |
+
let mut msg =
|
| 420 |
+
ChatCompletionMessage::assistant(forge_domain::Content::part(text_content));
|
| 421 |
+
if let Some(signature) = thought_signature {
|
| 422 |
+
msg = msg.thought_signature(signature);
|
| 423 |
+
}
|
| 424 |
+
Ok(msg)
|
| 425 |
+
}
|
| 426 |
+
}
|
| 427 |
+
Part::FunctionCall { function_call, thought_signature } => Ok(
|
| 428 |
+
ChatCompletionMessage::assistant(forge_domain::Content::part("")).add_tool_call(
|
| 429 |
+
ToolCallPart {
|
| 430 |
+
call_id: Some(ToolCallId::generate()),
|
| 431 |
+
name: Some(ToolName::new(function_call.name)),
|
| 432 |
+
arguments_part: serde_json::to_string(&function_call.args)?,
|
| 433 |
+
thought_signature,
|
| 434 |
+
},
|
| 435 |
+
),
|
| 436 |
+
),
|
| 437 |
+
Part::InlineData { .. } => {
|
| 438 |
+
// For now, skip inline data in responses (it's typically for inputs)
|
| 439 |
+
Ok(ChatCompletionMessage::assistant(
|
| 440 |
+
forge_domain::Content::part(""),
|
| 441 |
+
))
|
| 442 |
+
}
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
impl TryFrom<Candidate> for ChatCompletionMessage {
|
| 448 |
+
type Error = anyhow::Error;
|
| 449 |
+
|
| 450 |
+
fn try_from(candidate: Candidate) -> Result<Self, Self::Error> {
|
| 451 |
+
let mut content_parts: Vec<String> = Vec::new();
|
| 452 |
+
let mut reasoning_parts: Vec<String> = Vec::new();
|
| 453 |
+
let mut tool_calls: Vec<forge_domain::ToolCall> = Vec::new();
|
| 454 |
+
let mut reasoning_details: Option<Vec<forge_domain::Reasoning>> = None;
|
| 455 |
+
let mut thought_signature: Option<String> = None;
|
| 456 |
+
let mut finish_reason: Option<FinishReason> = None;
|
| 457 |
+
|
| 458 |
+
// Add finish reason if present
|
| 459 |
+
if let Some(ref reason) = candidate.finish_reason {
|
| 460 |
+
finish_reason = parse_finish_reason(reason);
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
// Process content parts
|
| 464 |
+
if let Some(content) = candidate.content
|
| 465 |
+
&& let Some(parts) = content.parts
|
| 466 |
+
{
|
| 467 |
+
for part in parts {
|
| 468 |
+
let part_message = ChatCompletionMessage::try_from(part)?;
|
| 469 |
+
|
| 470 |
+
// Collect content text
|
| 471 |
+
if let Some(part_content) = part_message.content {
|
| 472 |
+
let text = part_content.as_str();
|
| 473 |
+
if !text.is_empty() {
|
| 474 |
+
content_parts.push(text.to_string());
|
| 475 |
+
}
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
// Collect reasoning text
|
| 479 |
+
if let Some(part_reasoning) = part_message.reasoning {
|
| 480 |
+
let text = part_reasoning.as_str();
|
| 481 |
+
if !text.is_empty() {
|
| 482 |
+
reasoning_parts.push(text.to_string());
|
| 483 |
+
}
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
// Collect reasoning details (accumulate)
|
| 487 |
+
if let Some(details) = part_message.reasoning_details {
|
| 488 |
+
if let Some(ref mut current) = reasoning_details {
|
| 489 |
+
current.extend(details);
|
| 490 |
+
} else {
|
| 491 |
+
reasoning_details = Some(details);
|
| 492 |
+
}
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
// Collect tool calls
|
| 496 |
+
tool_calls.extend(part_message.tool_calls);
|
| 497 |
+
|
| 498 |
+
// Take thought signature (last one wins)
|
| 499 |
+
if part_message.thought_signature.is_some() {
|
| 500 |
+
thought_signature = part_message.thought_signature;
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
// Build the final message
|
| 506 |
+
let content = content_parts.join("");
|
| 507 |
+
let mut message = ChatCompletionMessage::assistant(forge_domain::Content::part(content));
|
| 508 |
+
|
| 509 |
+
if let Some(finish) = finish_reason {
|
| 510 |
+
message = message.finish_reason(finish);
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
if !reasoning_parts.is_empty() {
|
| 514 |
+
message = message.reasoning(forge_domain::Content::part(reasoning_parts.join("")));
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
if let Some(details) = reasoning_details {
|
| 518 |
+
message.reasoning_details = Some(details);
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
if !tool_calls.is_empty() {
|
| 522 |
+
message.tool_calls = tool_calls;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
if let Some(signature) = thought_signature {
|
| 526 |
+
message = message.thought_signature(signature);
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
Ok(message)
|
| 530 |
+
}
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
impl TryFrom<Response> for ChatCompletionMessage {
|
| 534 |
+
type Error = anyhow::Error;
|
| 535 |
+
|
| 536 |
+
fn try_from(response: Response) -> Result<Self, Self::Error> {
|
| 537 |
+
// Get the first candidate
|
| 538 |
+
if let Some(candidate) = response.candidates.into_iter().next() {
|
| 539 |
+
let mut message = ChatCompletionMessage::try_from(candidate)?;
|
| 540 |
+
|
| 541 |
+
// Add usage metadata if present
|
| 542 |
+
if let Some(usage) = response.usage_metadata {
|
| 543 |
+
message.usage = Some(usage.into());
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
Ok(message)
|
| 547 |
+
} else {
|
| 548 |
+
// No candidates - return empty message
|
| 549 |
+
let mut message = ChatCompletionMessage::assistant(forge_domain::Content::part(""));
|
| 550 |
+
|
| 551 |
+
// Still add usage if present
|
| 552 |
+
if let Some(usage) = response.usage_metadata {
|
| 553 |
+
message.usage = Some(usage.into());
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
Ok(message)
|
| 557 |
+
}
|
| 558 |
+
}
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
#[cfg(test)]
|
| 562 |
+
mod tests {
|
| 563 |
+
use serde_json::json;
|
| 564 |
+
|
| 565 |
+
use super::*;
|
| 566 |
+
|
| 567 |
+
#[test]
|
| 568 |
+
fn test_chat_completion_message_from_part_function_call_generates_id() {
|
| 569 |
+
let function_call =
|
| 570 |
+
FunctionCall { name: "test_tool".to_string(), args: json!({"arg": "value"}) };
|
| 571 |
+
|
| 572 |
+
let part = Part::FunctionCall { function_call, thought_signature: None };
|
| 573 |
+
|
| 574 |
+
let message = ChatCompletionMessage::try_from(part).unwrap();
|
| 575 |
+
|
| 576 |
+
assert!(!message.tool_calls.is_empty());
|
| 577 |
+
let tool_calls = message.tool_calls;
|
| 578 |
+
assert_eq!(tool_calls.len(), 1);
|
| 579 |
+
|
| 580 |
+
let tool_call = &tool_calls[0];
|
| 581 |
+
match tool_call {
|
| 582 |
+
forge_domain::ToolCall::Part(part) => {
|
| 583 |
+
assert!(part.call_id.is_some());
|
| 584 |
+
let call_id = part.call_id.as_ref().unwrap();
|
| 585 |
+
assert!(call_id.as_str().starts_with("forge_call_id_"));
|
| 586 |
+
}
|
| 587 |
+
_ => panic!("Expected ToolCall::Part"),
|
| 588 |
+
}
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
#[test]
|
| 592 |
+
fn test_model_conversion() {
|
| 593 |
+
let model = Model {
|
| 594 |
+
name: "models/gemini-pro".to_string(),
|
| 595 |
+
display_name: Some("Gemini Pro".to_string()),
|
| 596 |
+
description: Some("A model".to_string()),
|
| 597 |
+
};
|
| 598 |
+
let domain_model: forge_domain::Model = model.into();
|
| 599 |
+
assert_eq!(domain_model.id.as_str(), "gemini-pro");
|
| 600 |
+
assert_eq!(domain_model.name.unwrap(), "Gemini Pro");
|
| 601 |
+
assert_eq!(domain_model.context_length.unwrap(), 32_000);
|
| 602 |
+
|
| 603 |
+
let model_v2 = Model {
|
| 604 |
+
name: "models/gemini-2.0-flash".to_string(),
|
| 605 |
+
display_name: None,
|
| 606 |
+
description: None,
|
| 607 |
+
};
|
| 608 |
+
let domain_model_v2: forge_domain::Model = model_v2.clone().into();
|
| 609 |
+
assert_eq!(domain_model_v2.id.as_str(), "gemini-2.0-flash");
|
| 610 |
+
assert_eq!(domain_model_v2.name.unwrap(), "models/gemini-2.0-flash");
|
| 611 |
+
assert_eq!(domain_model_v2.context_length.unwrap(), 2_000_000);
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
#[test]
|
| 615 |
+
fn test_ping_event_extracts_cost() {
|
| 616 |
+
let fixture = json!({"type": "ping", "cost": "0.02889400"});
|
| 617 |
+
let event_data: EventData = serde_json::from_value(fixture).unwrap();
|
| 618 |
+
assert!(matches!(event_data, EventData::Ping(_)));
|
| 619 |
+
|
| 620 |
+
let actual = ChatCompletionMessage::try_from(event_data).unwrap();
|
| 621 |
+
let expected = ChatCompletionMessage::assistant(forge_domain::Content::part(""))
|
| 622 |
+
.usage(forge_domain::Usage { cost: Some(0.028894), ..Default::default() });
|
| 623 |
+
assert_eq!(actual, expected);
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
#[test]
|
| 627 |
+
fn test_ping_event_with_numeric_cost() {
|
| 628 |
+
let fixture = json!({"type": "ping", "cost": 0.05});
|
| 629 |
+
let event_data: EventData = serde_json::from_value(fixture).unwrap();
|
| 630 |
+
assert!(matches!(event_data, EventData::Ping(_)));
|
| 631 |
+
|
| 632 |
+
let actual = ChatCompletionMessage::try_from(event_data).unwrap();
|
| 633 |
+
assert_eq!(actual.usage.unwrap().cost, Some(0.05));
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
#[test]
|
| 637 |
+
fn test_unknown_event_returns_empty_message() {
|
| 638 |
+
let fixture = json!({"type": "something_else", "data": 123});
|
| 639 |
+
let event_data: EventData = serde_json::from_value(fixture).unwrap();
|
| 640 |
+
assert!(matches!(event_data, EventData::Unknown(_)));
|
| 641 |
+
|
| 642 |
+
let actual = ChatCompletionMessage::try_from(event_data).unwrap();
|
| 643 |
+
let expected = ChatCompletionMessage::assistant(forge_domain::Content::part(""));
|
| 644 |
+
assert_eq!(actual, expected);
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
#[test]
|
| 648 |
+
fn test_event_data_parsing() {
|
| 649 |
+
let response_json = json!({
|
| 650 |
+
"candidates": [{
|
| 651 |
+
"content": {
|
| 652 |
+
"parts": [{"text": "Hello"}]
|
| 653 |
+
}
|
| 654 |
+
}]
|
| 655 |
+
});
|
| 656 |
+
let event_data: EventData = serde_json::from_value(response_json).unwrap();
|
| 657 |
+
match event_data {
|
| 658 |
+
EventData::Response(_) => {}
|
| 659 |
+
_ => panic!("Expected Response"),
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
let error_json = json!({
|
| 663 |
+
"error": {
|
| 664 |
+
"code": 400,
|
| 665 |
+
"message": "Bad Request",
|
| 666 |
+
"status": "INVALID_ARGUMENT"
|
| 667 |
+
}
|
| 668 |
+
});
|
| 669 |
+
let event_data_err: EventData = serde_json::from_value(error_json).unwrap();
|
| 670 |
+
match event_data_err {
|
| 671 |
+
EventData::Error(e) => {
|
| 672 |
+
assert_eq!(e.error.code, 400);
|
| 673 |
+
assert_eq!(e.error.message, "Bad Request");
|
| 674 |
+
}
|
| 675 |
+
_ => panic!("Expected Error"),
|
| 676 |
+
}
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
#[test]
|
| 680 |
+
fn test_candidate_to_message_conversion() {
|
| 681 |
+
let candidate = Candidate {
|
| 682 |
+
content: Some(Content {
|
| 683 |
+
role: Some("model".to_string()),
|
| 684 |
+
parts: Some(vec![
|
| 685 |
+
Part::Text {
|
| 686 |
+
text: Some("Hello".to_string()),
|
| 687 |
+
thought: None,
|
| 688 |
+
thought_signature: None,
|
| 689 |
+
executable_code: None,
|
| 690 |
+
code_execution_result: None,
|
| 691 |
+
},
|
| 692 |
+
Part::Text {
|
| 693 |
+
text: Some("Thinking...".to_string()),
|
| 694 |
+
thought: Some(true),
|
| 695 |
+
thought_signature: Some("sig123".to_string()),
|
| 696 |
+
executable_code: None,
|
| 697 |
+
code_execution_result: None,
|
| 698 |
+
},
|
| 699 |
+
]),
|
| 700 |
+
}),
|
| 701 |
+
finish_reason: Some("STOP".to_string()),
|
| 702 |
+
safety_ratings: None,
|
| 703 |
+
grounding_metadata: None,
|
| 704 |
+
url_context_metadata: None,
|
| 705 |
+
};
|
| 706 |
+
|
| 707 |
+
let message = ChatCompletionMessage::try_from(candidate).unwrap();
|
| 708 |
+
|
| 709 |
+
// Check content
|
| 710 |
+
assert_eq!(message.content.unwrap().as_str(), "Hello");
|
| 711 |
+
|
| 712 |
+
// Check reasoning
|
| 713 |
+
assert_eq!(message.reasoning.unwrap().as_str(), "Thinking...");
|
| 714 |
+
|
| 715 |
+
// Check finish reason
|
| 716 |
+
assert_eq!(message.finish_reason.unwrap(), FinishReason::Stop);
|
| 717 |
+
|
| 718 |
+
// Check thought signature
|
| 719 |
+
assert_eq!(message.thought_signature.unwrap(), "sig123");
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
#[test]
|
| 723 |
+
fn test_usage_metadata_conversion() {
|
| 724 |
+
let usage = UsageMetadata {
|
| 725 |
+
prompt_token_count: Some(10),
|
| 726 |
+
candidates_token_count: Some(20),
|
| 727 |
+
total_token_count: Some(30),
|
| 728 |
+
cached_content_token_count: Some(5),
|
| 729 |
+
thoughts_token_count: None,
|
| 730 |
+
traffic_type: None,
|
| 731 |
+
};
|
| 732 |
+
|
| 733 |
+
let domain_usage: forge_domain::Usage = usage.into();
|
| 734 |
+
|
| 735 |
+
assert_eq!(domain_usage.prompt_tokens, TokenCount::Actual(10));
|
| 736 |
+
assert_eq!(domain_usage.completion_tokens, TokenCount::Actual(20));
|
| 737 |
+
assert_eq!(domain_usage.total_tokens, TokenCount::Actual(30));
|
| 738 |
+
assert_eq!(domain_usage.cached_tokens, TokenCount::Actual(5));
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
#[test]
|
| 742 |
+
fn test_part_text_conversion() {
|
| 743 |
+
let part = Part::Text {
|
| 744 |
+
text: Some("Hello".to_string()),
|
| 745 |
+
thought: None,
|
| 746 |
+
thought_signature: None,
|
| 747 |
+
executable_code: None,
|
| 748 |
+
code_execution_result: None,
|
| 749 |
+
};
|
| 750 |
+
let msg = ChatCompletionMessage::try_from(part).unwrap();
|
| 751 |
+
assert_eq!(msg.content.unwrap().as_str(), "Hello");
|
| 752 |
+
assert!(msg.reasoning.is_none());
|
| 753 |
+
|
| 754 |
+
// Test thought
|
| 755 |
+
let part = Part::Text {
|
| 756 |
+
text: Some("Thinking...".to_string()),
|
| 757 |
+
thought: Some(true),
|
| 758 |
+
thought_signature: Some("sig".to_string()),
|
| 759 |
+
executable_code: None,
|
| 760 |
+
code_execution_result: None,
|
| 761 |
+
};
|
| 762 |
+
let msg = ChatCompletionMessage::try_from(part).unwrap();
|
| 763 |
+
assert_eq!(msg.content.unwrap().as_str(), ""); // Content should be empty for pure thought part
|
| 764 |
+
assert_eq!(msg.reasoning.unwrap().as_str(), "Thinking...");
|
| 765 |
+
assert_eq!(msg.thought_signature.unwrap(), "sig");
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
#[test]
|
| 769 |
+
fn test_response_no_candidates() {
|
| 770 |
+
let response = Response {
|
| 771 |
+
candidates: vec![],
|
| 772 |
+
usage_metadata: Some(UsageMetadata {
|
| 773 |
+
prompt_token_count: Some(10),
|
| 774 |
+
candidates_token_count: Some(20),
|
| 775 |
+
total_token_count: Some(30),
|
| 776 |
+
cached_content_token_count: None,
|
| 777 |
+
thoughts_token_count: None,
|
| 778 |
+
traffic_type: None,
|
| 779 |
+
}),
|
| 780 |
+
prompt_feedback: None,
|
| 781 |
+
};
|
| 782 |
+
|
| 783 |
+
let msg = ChatCompletionMessage::try_from(response).unwrap();
|
| 784 |
+
assert_eq!(msg.content.unwrap().as_str(), "");
|
| 785 |
+
|
| 786 |
+
let usage = msg.usage.unwrap();
|
| 787 |
+
assert_eq!(usage.prompt_tokens, TokenCount::Actual(10));
|
| 788 |
+
assert_eq!(usage.completion_tokens, TokenCount::Actual(20));
|
| 789 |
+
}
|
| 790 |
+
}
|
crates/forge_app/src/dto/openai/copilot_model.rs
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::ModelId;
|
| 2 |
+
use serde::{Deserialize, Serialize};
|
| 3 |
+
|
| 4 |
+
/// Identifier of the synthetic GitHub Copilot "auto" model.
|
| 5 |
+
///
|
| 6 |
+
/// Copilot's API has no `auto` model id; official clients implement "Auto"
|
| 7 |
+
/// by letting the request omit the model so the service selects a default.
|
| 8 |
+
/// Requests made with this id must not include a `model` field.
|
| 9 |
+
pub const COPILOT_AUTO_MODEL_ID: &str = "auto";
|
| 10 |
+
|
| 11 |
+
/// Creates the synthetic "auto" model entry for GitHub Copilot.
|
| 12 |
+
///
|
| 13 |
+
/// Selecting this model omits the `model` field from chat requests so the
|
| 14 |
+
/// Copilot service chooses the model server-side. This mirrors the "Auto"
|
| 15 |
+
/// option in official clients and is the included option on free and
|
| 16 |
+
/// student plans where premium model requests are limited.
|
| 17 |
+
pub fn copilot_auto_model() -> forge_domain::Model {
|
| 18 |
+
forge_domain::Model {
|
| 19 |
+
id: ModelId::new(COPILOT_AUTO_MODEL_ID),
|
| 20 |
+
name: Some("Auto (server default)".to_string()),
|
| 21 |
+
description: Some(
|
| 22 |
+
"Lets GitHub Copilot choose the model. Does not consume premium requests on plans with limited premium quota."
|
| 23 |
+
.to_string(),
|
| 24 |
+
),
|
| 25 |
+
context_length: None,
|
| 26 |
+
tools_supported: Some(true),
|
| 27 |
+
supports_parallel_tool_calls: None,
|
| 28 |
+
supports_reasoning: None,
|
| 29 |
+
input_modalities: vec![forge_domain::InputModality::Text],
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/// Response returned by the GitHub Copilot `/models` endpoint.
|
| 34 |
+
#[derive(Debug, Deserialize, Clone, Serialize)]
|
| 35 |
+
pub struct CopilotListModelResponse {
|
| 36 |
+
pub data: Vec<CopilotModel>,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/// A model entry as returned by the GitHub Copilot `/models` endpoint.
|
| 40 |
+
///
|
| 41 |
+
/// GitHub Copilot uses a schema that differs from the standard OpenAI
|
| 42 |
+
/// models response: capabilities, limits and access policy are nested
|
| 43 |
+
/// under dedicated objects.
|
| 44 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 45 |
+
pub struct CopilotModel {
|
| 46 |
+
pub id: ModelId,
|
| 47 |
+
pub name: Option<String>,
|
| 48 |
+
/// Whether the model is shown in the model picker of official clients
|
| 49 |
+
#[serde(default)]
|
| 50 |
+
pub model_picker_enabled: bool,
|
| 51 |
+
/// Access policy for the model; models with a `disabled` state cannot
|
| 52 |
+
/// be used by the authenticated account
|
| 53 |
+
pub policy: Option<CopilotPolicy>,
|
| 54 |
+
pub capabilities: Option<CopilotCapabilities>,
|
| 55 |
+
pub supported_endpoints: Option<Vec<String>>,
|
| 56 |
+
pub vendor: Option<String>,
|
| 57 |
+
pub preview: Option<bool>,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/// Access policy attached to a Copilot model.
|
| 61 |
+
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
| 62 |
+
pub struct CopilotPolicy {
|
| 63 |
+
pub state: Option<String>,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/// Capabilities of a Copilot model.
|
| 67 |
+
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
| 68 |
+
pub struct CopilotCapabilities {
|
| 69 |
+
/// Model type, e.g. `chat` or `embeddings`
|
| 70 |
+
#[serde(rename = "type")]
|
| 71 |
+
pub model_type: Option<String>,
|
| 72 |
+
pub limits: Option<CopilotLimits>,
|
| 73 |
+
pub supports: Option<CopilotSupports>,
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/// Token and vision limits of a Copilot model.
|
| 77 |
+
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
| 78 |
+
pub struct CopilotLimits {
|
| 79 |
+
pub max_context_window_tokens: Option<u64>,
|
| 80 |
+
pub max_prompt_tokens: Option<u64>,
|
| 81 |
+
pub max_output_tokens: Option<u64>,
|
| 82 |
+
pub vision: Option<CopilotVisionLimits>,
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/// Vision-specific limits of a Copilot model.
|
| 86 |
+
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
| 87 |
+
pub struct CopilotVisionLimits {
|
| 88 |
+
pub supported_media_types: Option<Vec<String>>,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/// Feature support flags of a Copilot model.
|
| 92 |
+
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
| 93 |
+
pub struct CopilotSupports {
|
| 94 |
+
pub tool_calls: Option<bool>,
|
| 95 |
+
pub parallel_tool_calls: Option<bool>,
|
| 96 |
+
pub streaming: Option<bool>,
|
| 97 |
+
pub vision: Option<bool>,
|
| 98 |
+
pub adaptive_thinking: Option<bool>,
|
| 99 |
+
pub max_thinking_budget: Option<u64>,
|
| 100 |
+
pub min_thinking_budget: Option<u64>,
|
| 101 |
+
pub reasoning_effort: Option<Vec<String>>,
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
impl CopilotModel {
|
| 105 |
+
/// Returns true when the model can actually be used for chat by the
|
| 106 |
+
/// authenticated account.
|
| 107 |
+
///
|
| 108 |
+
/// Filters out models that are policy-disabled for the current
|
| 109 |
+
/// subscription (requests to them fail with `model_not_supported`) as
|
| 110 |
+
/// well as non-chat models such as embeddings.
|
| 111 |
+
pub fn is_usable(&self) -> bool {
|
| 112 |
+
// Models explicitly disabled by policy cannot be used and return
|
| 113 |
+
// `model_not_supported` errors when requested
|
| 114 |
+
let policy_disabled = self
|
| 115 |
+
.policy
|
| 116 |
+
.as_ref()
|
| 117 |
+
.and_then(|p| p.state.as_deref())
|
| 118 |
+
.is_some_and(|state| state == "disabled");
|
| 119 |
+
if policy_disabled {
|
| 120 |
+
return false;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
let Some(capabilities) = self.capabilities.as_ref() else {
|
| 124 |
+
return false;
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
// Only chat models are usable for conversations (excludes embeddings)
|
| 128 |
+
if capabilities.model_type.as_deref() != Some("chat") {
|
| 129 |
+
return false;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// Require known token limits and tool call information, mirroring the
|
| 133 |
+
// checks official clients perform before offering a model
|
| 134 |
+
let has_limits = capabilities.limits.as_ref().is_some_and(|limits| {
|
| 135 |
+
limits.max_output_tokens.is_some() && limits.max_prompt_tokens.is_some()
|
| 136 |
+
});
|
| 137 |
+
let has_tool_info = capabilities
|
| 138 |
+
.supports
|
| 139 |
+
.as_ref()
|
| 140 |
+
.is_some_and(|supports| supports.tool_calls.is_some());
|
| 141 |
+
|
| 142 |
+
has_limits && has_tool_info
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
impl From<CopilotModel> for forge_domain::Model {
|
| 147 |
+
fn from(value: CopilotModel) -> Self {
|
| 148 |
+
let supports = value
|
| 149 |
+
.capabilities
|
| 150 |
+
.as_ref()
|
| 151 |
+
.and_then(|c| c.supports.as_ref());
|
| 152 |
+
let limits = value.capabilities.as_ref().and_then(|c| c.limits.as_ref());
|
| 153 |
+
|
| 154 |
+
let context_length =
|
| 155 |
+
limits.and_then(|l| l.max_context_window_tokens.or(l.max_prompt_tokens));
|
| 156 |
+
|
| 157 |
+
let tools_supported = supports.and_then(|s| s.tool_calls);
|
| 158 |
+
let supports_parallel_tool_calls = supports.and_then(|s| s.parallel_tool_calls);
|
| 159 |
+
|
| 160 |
+
// A model supports reasoning when it advertises adaptive thinking,
|
| 161 |
+
// reasoning effort levels or a thinking budget
|
| 162 |
+
let supports_reasoning = supports.map(|s| {
|
| 163 |
+
s.adaptive_thinking.unwrap_or(false)
|
| 164 |
+
|| s.reasoning_effort
|
| 165 |
+
.as_ref()
|
| 166 |
+
.is_some_and(|efforts| !efforts.is_empty())
|
| 167 |
+
|| s.max_thinking_budget.is_some()
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
let supports_image = supports.and_then(|s| s.vision).unwrap_or(false)
|
| 171 |
+
|| limits
|
| 172 |
+
.and_then(|l| l.vision.as_ref())
|
| 173 |
+
.and_then(|v| v.supported_media_types.as_ref())
|
| 174 |
+
.is_some_and(|types| types.iter().any(|t| t.starts_with("image/")));
|
| 175 |
+
|
| 176 |
+
let mut input_modalities = vec![forge_domain::InputModality::Text];
|
| 177 |
+
if supports_image {
|
| 178 |
+
input_modalities.push(forge_domain::InputModality::Image);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
forge_domain::Model {
|
| 182 |
+
id: value.id,
|
| 183 |
+
name: value.name,
|
| 184 |
+
description: None,
|
| 185 |
+
context_length,
|
| 186 |
+
tools_supported,
|
| 187 |
+
supports_parallel_tool_calls,
|
| 188 |
+
supports_reasoning,
|
| 189 |
+
input_modalities,
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
#[cfg(test)]
|
| 195 |
+
mod tests {
|
| 196 |
+
use pretty_assertions::assert_eq;
|
| 197 |
+
|
| 198 |
+
use super::*;
|
| 199 |
+
|
| 200 |
+
fn fixture_chat_model() -> CopilotModel {
|
| 201 |
+
serde_json::from_value(serde_json::json!({
|
| 202 |
+
"id": "claude-sonnet-4.6",
|
| 203 |
+
"name": "Claude Sonnet 4.6",
|
| 204 |
+
"model_picker_enabled": true,
|
| 205 |
+
"policy": { "state": "enabled" },
|
| 206 |
+
"vendor": "Anthropic",
|
| 207 |
+
"preview": false,
|
| 208 |
+
"supported_endpoints": ["/chat/completions", "/v1/messages"],
|
| 209 |
+
"capabilities": {
|
| 210 |
+
"type": "chat",
|
| 211 |
+
"family": "claude-sonnet-4.6",
|
| 212 |
+
"limits": {
|
| 213 |
+
"max_context_window_tokens": 264000,
|
| 214 |
+
"max_output_tokens": 64000,
|
| 215 |
+
"max_prompt_tokens": 200000,
|
| 216 |
+
"vision": {
|
| 217 |
+
"max_prompt_image_size": 3145728,
|
| 218 |
+
"max_prompt_images": 5,
|
| 219 |
+
"supported_media_types": ["image/jpeg", "image/png"]
|
| 220 |
+
}
|
| 221 |
+
},
|
| 222 |
+
"supports": {
|
| 223 |
+
"adaptive_thinking": true,
|
| 224 |
+
"max_thinking_budget": 32000,
|
| 225 |
+
"min_thinking_budget": 1024,
|
| 226 |
+
"parallel_tool_calls": true,
|
| 227 |
+
"reasoning_effort": ["low", "medium", "high", "max"],
|
| 228 |
+
"streaming": true,
|
| 229 |
+
"structured_outputs": true,
|
| 230 |
+
"tool_calls": true,
|
| 231 |
+
"vision": true
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
}))
|
| 235 |
+
.unwrap()
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
fn fixture_disabled_model() -> CopilotModel {
|
| 239 |
+
serde_json::from_value(serde_json::json!({
|
| 240 |
+
"id": "gpt-5.5",
|
| 241 |
+
"name": "GPT-5.5",
|
| 242 |
+
"model_picker_enabled": true,
|
| 243 |
+
"policy": { "state": "disabled" },
|
| 244 |
+
"capabilities": {
|
| 245 |
+
"type": "chat",
|
| 246 |
+
"limits": {
|
| 247 |
+
"max_context_window_tokens": 272000,
|
| 248 |
+
"max_output_tokens": 128000,
|
| 249 |
+
"max_prompt_tokens": 144000
|
| 250 |
+
},
|
| 251 |
+
"supports": { "tool_calls": true, "streaming": true }
|
| 252 |
+
}
|
| 253 |
+
}))
|
| 254 |
+
.unwrap()
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
fn fixture_embedding_model() -> CopilotModel {
|
| 258 |
+
serde_json::from_value(serde_json::json!({
|
| 259 |
+
"id": "text-embedding-3-small",
|
| 260 |
+
"name": "Embedding V3 small",
|
| 261 |
+
"model_picker_enabled": false,
|
| 262 |
+
"capabilities": {
|
| 263 |
+
"type": "embeddings",
|
| 264 |
+
"limits": { "max_inputs": 256 },
|
| 265 |
+
"supports": { "dimensions": true }
|
| 266 |
+
}
|
| 267 |
+
}))
|
| 268 |
+
.unwrap()
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
#[test]
|
| 272 |
+
fn test_usable_chat_model() {
|
| 273 |
+
let fixture = fixture_chat_model();
|
| 274 |
+
let actual = fixture.is_usable();
|
| 275 |
+
let expected = true;
|
| 276 |
+
assert_eq!(actual, expected);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
#[test]
|
| 280 |
+
fn test_policy_disabled_model_is_not_usable() {
|
| 281 |
+
let fixture = fixture_disabled_model();
|
| 282 |
+
let actual = fixture.is_usable();
|
| 283 |
+
let expected = false;
|
| 284 |
+
assert_eq!(actual, expected);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
#[test]
|
| 288 |
+
fn test_embedding_model_is_not_usable() {
|
| 289 |
+
let fixture = fixture_embedding_model();
|
| 290 |
+
let actual = fixture.is_usable();
|
| 291 |
+
let expected = false;
|
| 292 |
+
assert_eq!(actual, expected);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
#[test]
|
| 296 |
+
fn test_model_without_policy_is_usable() {
|
| 297 |
+
let fixture = serde_json::from_value::<CopilotModel>(serde_json::json!({
|
| 298 |
+
"id": "gpt-4o",
|
| 299 |
+
"name": "GPT-4o",
|
| 300 |
+
"model_picker_enabled": false,
|
| 301 |
+
"capabilities": {
|
| 302 |
+
"type": "chat",
|
| 303 |
+
"limits": {
|
| 304 |
+
"max_context_window_tokens": 128000,
|
| 305 |
+
"max_output_tokens": 16384,
|
| 306 |
+
"max_prompt_tokens": 64000
|
| 307 |
+
},
|
| 308 |
+
"supports": { "tool_calls": true, "parallel_tool_calls": true }
|
| 309 |
+
}
|
| 310 |
+
}))
|
| 311 |
+
.unwrap();
|
| 312 |
+
|
| 313 |
+
let actual = fixture.is_usable();
|
| 314 |
+
let expected = true;
|
| 315 |
+
assert_eq!(actual, expected);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
#[test]
|
| 319 |
+
fn test_conversion_to_domain_model() {
|
| 320 |
+
let fixture = fixture_chat_model();
|
| 321 |
+
|
| 322 |
+
let actual: forge_domain::Model = fixture.into();
|
| 323 |
+
|
| 324 |
+
let expected = forge_domain::Model {
|
| 325 |
+
id: ModelId::new("claude-sonnet-4.6"),
|
| 326 |
+
name: Some("Claude Sonnet 4.6".to_string()),
|
| 327 |
+
description: None,
|
| 328 |
+
context_length: Some(264000),
|
| 329 |
+
tools_supported: Some(true),
|
| 330 |
+
supports_parallel_tool_calls: Some(true),
|
| 331 |
+
supports_reasoning: Some(true),
|
| 332 |
+
input_modalities: vec![
|
| 333 |
+
forge_domain::InputModality::Text,
|
| 334 |
+
forge_domain::InputModality::Image,
|
| 335 |
+
],
|
| 336 |
+
};
|
| 337 |
+
|
| 338 |
+
assert_eq!(actual, expected);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
#[test]
|
| 342 |
+
fn test_conversion_falls_back_to_max_prompt_tokens() {
|
| 343 |
+
let fixture = serde_json::from_value::<CopilotModel>(serde_json::json!({
|
| 344 |
+
"id": "some-model",
|
| 345 |
+
"name": "Some Model",
|
| 346 |
+
"capabilities": {
|
| 347 |
+
"type": "chat",
|
| 348 |
+
"limits": { "max_prompt_tokens": 100000, "max_output_tokens": 4096 },
|
| 349 |
+
"supports": { "tool_calls": true }
|
| 350 |
+
}
|
| 351 |
+
}))
|
| 352 |
+
.unwrap();
|
| 353 |
+
|
| 354 |
+
let actual: forge_domain::Model = fixture.into();
|
| 355 |
+
|
| 356 |
+
assert_eq!(actual.context_length, Some(100000));
|
| 357 |
+
assert_eq!(actual.supports_reasoning, Some(false));
|
| 358 |
+
assert_eq!(
|
| 359 |
+
actual.input_modalities,
|
| 360 |
+
vec![forge_domain::InputModality::Text]
|
| 361 |
+
);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
#[test]
|
| 365 |
+
fn test_list_response_deserialization_ignores_unknown_fields() {
|
| 366 |
+
let fixture = serde_json::json!({
|
| 367 |
+
"data": [
|
| 368 |
+
{
|
| 369 |
+
"id": "claude-sonnet-4.6",
|
| 370 |
+
"name": "Claude Sonnet 4.6",
|
| 371 |
+
"object": "model",
|
| 372 |
+
"model_picker_category": "versatile",
|
| 373 |
+
"warning_message": "some warning",
|
| 374 |
+
"capabilities": {
|
| 375 |
+
"type": "chat",
|
| 376 |
+
"object": "model_capabilities",
|
| 377 |
+
"tokenizer": "o200k_base",
|
| 378 |
+
"limits": { "max_prompt_tokens": 200000, "max_output_tokens": 64000 },
|
| 379 |
+
"supports": { "tool_calls": true }
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
]
|
| 383 |
+
});
|
| 384 |
+
|
| 385 |
+
let actual = serde_json::from_value::<CopilotListModelResponse>(fixture).unwrap();
|
| 386 |
+
|
| 387 |
+
assert_eq!(actual.data.len(), 1);
|
| 388 |
+
assert_eq!(actual.data[0].id.as_str(), "claude-sonnet-4.6");
|
| 389 |
+
assert_eq!(actual.data[0].is_usable(), true);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
#[test]
|
| 393 |
+
fn test_copilot_auto_model_id() {
|
| 394 |
+
let fixture = copilot_auto_model();
|
| 395 |
+
let actual = fixture.id.as_str();
|
| 396 |
+
let expected = COPILOT_AUTO_MODEL_ID;
|
| 397 |
+
assert_eq!(actual, expected);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
#[test]
|
| 401 |
+
fn test_copilot_auto_model_supports_tools() {
|
| 402 |
+
let fixture = copilot_auto_model();
|
| 403 |
+
let actual = fixture.tools_supported;
|
| 404 |
+
let expected = Some(true);
|
| 405 |
+
assert_eq!(actual, expected);
|
| 406 |
+
}
|
| 407 |
+
}
|
crates/forge_app/src/dto/openai/error.rs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::collections::BTreeMap;
|
| 2 |
+
|
| 3 |
+
use derive_setters::Setters;
|
| 4 |
+
use serde::{Deserialize, Serialize};
|
| 5 |
+
use thiserror::Error;
|
| 6 |
+
|
| 7 |
+
#[derive(Debug, derive_more::From, Error)]
|
| 8 |
+
pub enum Error {
|
| 9 |
+
#[error("{0}")]
|
| 10 |
+
Response(ErrorResponse),
|
| 11 |
+
|
| 12 |
+
#[error("Invalid Status Code: {0}")]
|
| 13 |
+
InvalidStatusCode(u16),
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
| 17 |
+
#[serde(untagged)]
|
| 18 |
+
pub enum ErrorCode {
|
| 19 |
+
String(String),
|
| 20 |
+
Number(u16),
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
impl ErrorCode {
|
| 24 |
+
pub fn as_number(&self) -> Option<u16> {
|
| 25 |
+
match self {
|
| 26 |
+
ErrorCode::String(s) => s.parse::<u16>().ok(),
|
| 27 |
+
ErrorCode::Number(code) => Some(*code),
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
pub fn as_str(&self) -> Option<&str> {
|
| 32 |
+
match self {
|
| 33 |
+
ErrorCode::String(s) => Some(s),
|
| 34 |
+
ErrorCode::Number(_) => None,
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
#[derive(Default, Debug, Deserialize, Serialize, Clone, Setters)]
|
| 40 |
+
#[setters(strip_option)]
|
| 41 |
+
pub struct ErrorResponse {
|
| 42 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 43 |
+
pub error: Option<Box<ErrorResponse>>,
|
| 44 |
+
|
| 45 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 46 |
+
pub message: Option<String>,
|
| 47 |
+
|
| 48 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 49 |
+
pub errno: Option<i32>,
|
| 50 |
+
|
| 51 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 52 |
+
pub code: Option<ErrorCode>,
|
| 53 |
+
|
| 54 |
+
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
| 55 |
+
pub metadata: BTreeMap<String, serde_json::Value>,
|
| 56 |
+
|
| 57 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 58 |
+
pub syscall: Option<String>,
|
| 59 |
+
|
| 60 |
+
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
| 61 |
+
pub type_of: Option<serde_json::Value>,
|
| 62 |
+
|
| 63 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 64 |
+
pub param: Option<serde_json::Value>,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
impl ErrorResponse {
|
| 68 |
+
/// Deeply introspects the error structure to determine the ErrorCode
|
| 69 |
+
pub fn get_code_deep(&self) -> Option<&ErrorCode> {
|
| 70 |
+
if let Some(ref code) = self.code {
|
| 71 |
+
return Some(code);
|
| 72 |
+
}
|
| 73 |
+
if let Some(ref error) = self.error {
|
| 74 |
+
return error.get_code_deep();
|
| 75 |
+
}
|
| 76 |
+
None
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
impl std::fmt::Display for ErrorResponse {
|
| 81 |
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
| 82 |
+
serde_json::to_string(self)
|
| 83 |
+
.map_err(|_| std::fmt::Error)?
|
| 84 |
+
.fmt(f)
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
#[cfg(test)]
|
| 89 |
+
mod tests {
|
| 90 |
+
use pretty_assertions::assert_eq;
|
| 91 |
+
|
| 92 |
+
use super::*;
|
| 93 |
+
|
| 94 |
+
#[test]
|
| 95 |
+
fn test_error_code_as_number() {
|
| 96 |
+
// Test with numeric error code
|
| 97 |
+
let code_number = ErrorCode::Number(404);
|
| 98 |
+
assert_eq!(code_number.as_number(), Some(404));
|
| 99 |
+
|
| 100 |
+
// Test with string error code containing a valid number
|
| 101 |
+
let code_string_numeric = ErrorCode::String("500".to_string());
|
| 102 |
+
assert_eq!(code_string_numeric.as_number(), Some(500));
|
| 103 |
+
|
| 104 |
+
// Test with string error code containing a non-numeric value
|
| 105 |
+
let code_string_non_numeric = ErrorCode::String("ERR_STREAM_PREMATURE_CLOSE".to_string());
|
| 106 |
+
assert_eq!(code_string_non_numeric.as_number(), None);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
#[test]
|
| 110 |
+
fn test_error_code_as_str() {
|
| 111 |
+
// Test with string error code
|
| 112 |
+
let code_string = ErrorCode::String("ERR_STREAM_PREMATURE_CLOSE".to_string());
|
| 113 |
+
assert_eq!(code_string.as_str(), Some("ERR_STREAM_PREMATURE_CLOSE"));
|
| 114 |
+
|
| 115 |
+
// Test with numeric error code
|
| 116 |
+
let code_number = ErrorCode::Number(404);
|
| 117 |
+
assert_eq!(code_number.as_str(), None);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
#[test]
|
| 121 |
+
fn test_get_code_deep_direct() {
|
| 122 |
+
// Test with an error that has a direct code
|
| 123 |
+
let error_code = ErrorCode::Number(404);
|
| 124 |
+
|
| 125 |
+
// Use derived setters for a cleaner initialization
|
| 126 |
+
let fixture = ErrorResponse::default()
|
| 127 |
+
.message("Error message".to_string())
|
| 128 |
+
.code(error_code);
|
| 129 |
+
|
| 130 |
+
let actual = fixture.get_code_deep();
|
| 131 |
+
|
| 132 |
+
// Create a new ErrorCode to compare against
|
| 133 |
+
let expected_code = ErrorCode::Number(404);
|
| 134 |
+
assert_eq!(actual, Some(&expected_code));
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
#[test]
|
| 138 |
+
fn test_get_code_deep_nested() {
|
| 139 |
+
// Test with an error that has no direct code but has an inner error with a code
|
| 140 |
+
let error_code = ErrorCode::String("ERR_STREAM_PREMATURE_CLOSE".to_string());
|
| 141 |
+
|
| 142 |
+
// Use derived setters for cleaner initialization
|
| 143 |
+
let inner_error = ErrorResponse::default()
|
| 144 |
+
.message("Inner error".to_string())
|
| 145 |
+
.code(error_code);
|
| 146 |
+
|
| 147 |
+
let fixture = ErrorResponse::default()
|
| 148 |
+
.error(Box::new(inner_error))
|
| 149 |
+
.message("Outer error".to_string());
|
| 150 |
+
|
| 151 |
+
let actual = fixture.get_code_deep();
|
| 152 |
+
|
| 153 |
+
// Create a new ErrorCode to compare against
|
| 154 |
+
let expected_code = ErrorCode::String("ERR_STREAM_PREMATURE_CLOSE".to_string());
|
| 155 |
+
assert_eq!(actual, Some(&expected_code));
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
#[test]
|
| 159 |
+
fn test_get_code_deep_no_code() {
|
| 160 |
+
// Test with an error that has no code and no inner error
|
| 161 |
+
let fixture = ErrorResponse::default().message("Error message".to_string());
|
| 162 |
+
|
| 163 |
+
let actual = fixture.get_code_deep();
|
| 164 |
+
let expected = None;
|
| 165 |
+
assert_eq!(actual, expected);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
#[test]
|
| 169 |
+
fn test_get_code_deep_multiple_nested() {
|
| 170 |
+
// Test with deeply nested errors
|
| 171 |
+
let error_code = ErrorCode::Number(500);
|
| 172 |
+
|
| 173 |
+
let deepest_error = ErrorResponse::default()
|
| 174 |
+
.message("Deepest error".to_string())
|
| 175 |
+
.code(error_code);
|
| 176 |
+
|
| 177 |
+
let middle_error = ErrorResponse::default()
|
| 178 |
+
.error(Box::new(deepest_error))
|
| 179 |
+
.message("Middle error".to_string());
|
| 180 |
+
// No code here, should find deepest
|
| 181 |
+
|
| 182 |
+
let fixture = ErrorResponse::default()
|
| 183 |
+
.error(Box::new(middle_error))
|
| 184 |
+
.message("Outer error".to_string());
|
| 185 |
+
// No code here, should find deepest
|
| 186 |
+
|
| 187 |
+
let actual = fixture.get_code_deep();
|
| 188 |
+
|
| 189 |
+
// Create a new ErrorCode to compare against
|
| 190 |
+
let expected_code = ErrorCode::Number(500);
|
| 191 |
+
assert_eq!(actual, Some(&expected_code));
|
| 192 |
+
}
|
| 193 |
+
}
|
crates/forge_app/src/dto/openai/fixtures/chutes_api_response.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"data": [
|
| 3 |
+
{
|
| 4 |
+
"id": "moonshotai/Kimi-K2-Instruct-75k",
|
| 5 |
+
"name": "Kimi K2 Instruct 75k",
|
| 6 |
+
"created": 1234567890,
|
| 7 |
+
"description": "Kimi K2 model with 75k context length",
|
| 8 |
+
"context_length": 75000,
|
| 9 |
+
"pricing": {
|
| 10 |
+
"prompt": 0.17992692,
|
| 11 |
+
"completion": 0.17992692
|
| 12 |
+
},
|
| 13 |
+
"supported_parameters": ["tools", "supports_parallel_tool_calls"]
|
| 14 |
+
}
|
| 15 |
+
]
|
| 16 |
+
}
|
crates/forge_app/src/dto/openai/fixtures/chutes_completion_response.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "chatcmpl-a9db7cc9005c6568",
|
| 3 |
+
"object": "chat.completion.chunk",
|
| 4 |
+
"created": 1775233185,
|
| 5 |
+
"model": "moonshotai/Kimi-K2.5-TEE",
|
| 6 |
+
"choices": [
|
| 7 |
+
{
|
| 8 |
+
"index": 0,
|
| 9 |
+
"delta": { "reasoning": " The", "reasoning_content": " The" },
|
| 10 |
+
"logprobs": null,
|
| 11 |
+
"finish_reason": null,
|
| 12 |
+
"token_ids": null,
|
| 13 |
+
"hidden_states": null
|
| 14 |
+
}
|
| 15 |
+
],
|
| 16 |
+
"usage": {
|
| 17 |
+
"prompt_tokens": 8764,
|
| 18 |
+
"total_tokens": 8765,
|
| 19 |
+
"completion_tokens": 1,
|
| 20 |
+
"reasoning_tokens": 1
|
| 21 |
+
},
|
| 22 |
+
"chutes_verification": "6bbeaf04d5800d774cc497b0a619acee"
|
| 23 |
+
}
|
crates/forge_app/src/dto/openai/fixtures/model_invalid_pricing.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "invalid-pricing-model",
|
| 3 |
+
"name": "Invalid Pricing Model",
|
| 4 |
+
"pricing": {
|
| 5 |
+
"prompt": "not-a-number",
|
| 6 |
+
"completion": 0.002
|
| 7 |
+
}
|
| 8 |
+
}
|
crates/forge_app/src/dto/openai/fixtures/model_mixed_pricing.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "mixed-model",
|
| 3 |
+
"name": "Mixed Pricing Model",
|
| 4 |
+
"pricing": {
|
| 5 |
+
"prompt": "0.001",
|
| 6 |
+
"completion": 0.002,
|
| 7 |
+
"image": null
|
| 8 |
+
}
|
| 9 |
+
}
|
crates/forge_app/src/dto/openai/fixtures/model_no_pricing.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "no-pricing-model",
|
| 3 |
+
"name": "No Pricing Model"
|
| 4 |
+
}
|
crates/forge_app/src/dto/openai/fixtures/model_numeric_pricing.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "moonshotai/Kimi-K2-Instruct-75k",
|
| 3 |
+
"name": "Kimi K2 Instruct 75k",
|
| 4 |
+
"pricing": {
|
| 5 |
+
"prompt": 0.17992692,
|
| 6 |
+
"completion": 0.17992692
|
| 7 |
+
}
|
| 8 |
+
}
|
crates/forge_app/src/dto/openai/fixtures/model_scientific_notation.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "scientific-model",
|
| 3 |
+
"name": "Scientific Notation Model",
|
| 4 |
+
"pricing": {
|
| 5 |
+
"prompt": "1.5e-3",
|
| 6 |
+
"completion": "2.0E-4"
|
| 7 |
+
}
|
| 8 |
+
}
|
crates/forge_app/src/dto/openai/fixtures/model_string_pricing.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "test-model",
|
| 3 |
+
"name": "Test Model",
|
| 4 |
+
"pricing": {
|
| 5 |
+
"prompt": "0.001",
|
| 6 |
+
"completion": "0.002"
|
| 7 |
+
}
|
| 8 |
+
}
|
crates/forge_app/src/dto/openai/fixtures/orcarouter_api_response.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"data": [
|
| 3 |
+
{
|
| 4 |
+
"id": "openai/gpt-4o-mini",
|
| 5 |
+
"object": "model",
|
| 6 |
+
"created": 1626777600,
|
| 7 |
+
"owned_by": "OpenAI",
|
| 8 |
+
"supported_endpoint_types": ["openai", "openai-response"],
|
| 9 |
+
"name": "OpenAI: GPT-4o-mini",
|
| 10 |
+
"description": "GPT-4o mini is OpenAI's most advanced small model, supporting both text and image inputs with text outputs.",
|
| 11 |
+
"context_length": 128000,
|
| 12 |
+
"max_completion_tokens": 16384,
|
| 13 |
+
"architecture": {
|
| 14 |
+
"input_modalities": ["text", "image", "file"],
|
| 15 |
+
"output_modalities": ["text"]
|
| 16 |
+
},
|
| 17 |
+
"top_provider": {
|
| 18 |
+
"context_length": 128000,
|
| 19 |
+
"max_completion_tokens": 16384
|
| 20 |
+
},
|
| 21 |
+
"pricing": {
|
| 22 |
+
"prompt": "0.0000001500",
|
| 23 |
+
"completion": "0.0000006000",
|
| 24 |
+
"prompt_per_million": "0.150000",
|
| 25 |
+
"completion_per_million": "0.600000"
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
],
|
| 29 |
+
"object": "list",
|
| 30 |
+
"success": true
|
| 31 |
+
}
|
crates/forge_app/src/dto/openai/fixtures/zai_api_delta_response.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"id":"20250822012952a2d93c459ed54892","created":1755797392,"model":"glm-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi","reasoning_content":""}}]}
|
crates/forge_app/src/dto/openai/fixtures/zai_api_response.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"id":"20250822012952a2d93c459ed54892","created":1755797392,"model":"glm-4.5","choices":[{"index":0,"finish_reason":"stop","delta":{"role":"assistant","content":"","reasoning_content":""}}],"usage":{"prompt_tokens":8,"completion_tokens":14,"total_tokens":22,"prompt_tokens_details":{"cached_tokens":0}}}
|
crates/forge_app/src/dto/openai/mod.rs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod copilot_model;
|
| 2 |
+
mod error;
|
| 3 |
+
mod model;
|
| 4 |
+
mod reasoning;
|
| 5 |
+
mod request;
|
| 6 |
+
mod response;
|
| 7 |
+
mod tool_choice;
|
| 8 |
+
mod transformers;
|
| 9 |
+
|
| 10 |
+
pub use copilot_model::*;
|
| 11 |
+
pub use error::*;
|
| 12 |
+
pub use model::*;
|
| 13 |
+
pub use reasoning::*;
|
| 14 |
+
pub use request::*;
|
| 15 |
+
pub use response::*;
|
| 16 |
+
pub use tool_choice::*;
|
| 17 |
+
pub use transformers::ProviderPipeline;
|
crates/forge_app/src/dto/openai/model.rs
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::ModelId;
|
| 2 |
+
use serde::{Deserialize, Serialize};
|
| 3 |
+
|
| 4 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 5 |
+
#[serde(untagged)]
|
| 6 |
+
enum PriceValue {
|
| 7 |
+
Number(f32),
|
| 8 |
+
String(String),
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
impl From<PriceValue> for Option<f32> {
|
| 12 |
+
fn from(value: PriceValue) -> Self {
|
| 13 |
+
match value {
|
| 14 |
+
PriceValue::Number(n) => Some(n),
|
| 15 |
+
PriceValue::String(s) => s.parse().ok(),
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
impl TryFrom<PriceValue> for f32 {
|
| 21 |
+
type Error = std::num::ParseFloatError;
|
| 22 |
+
|
| 23 |
+
fn try_from(value: PriceValue) -> Result<Self, Self::Error> {
|
| 24 |
+
match value {
|
| 25 |
+
PriceValue::Number(n) => Ok(n),
|
| 26 |
+
PriceValue::String(s) => s.parse(),
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 32 |
+
pub struct Model {
|
| 33 |
+
pub id: ModelId,
|
| 34 |
+
pub name: Option<String>,
|
| 35 |
+
pub created: Option<u64>,
|
| 36 |
+
pub description: Option<String>,
|
| 37 |
+
pub context_length: Option<u64>,
|
| 38 |
+
pub architecture: Option<Architecture>,
|
| 39 |
+
pub pricing: Option<Pricing>,
|
| 40 |
+
pub top_provider: Option<TopProvider>,
|
| 41 |
+
pub per_request_limits: Option<serde_json::Value>,
|
| 42 |
+
pub supported_parameters: Option<Vec<String>>,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 46 |
+
pub struct Architecture {
|
| 47 |
+
// Some OpenAI-compatible providers (e.g. OrcaRouter) return an
|
| 48 |
+
// `architecture` object carrying only the modality arrays, so every
|
| 49 |
+
// field must tolerate being absent.
|
| 50 |
+
pub modality: Option<String>,
|
| 51 |
+
pub tokenizer: Option<String>,
|
| 52 |
+
pub instruct_type: Option<String>,
|
| 53 |
+
pub input_modalities: Option<Vec<String>>,
|
| 54 |
+
pub output_modalities: Option<Vec<String>>,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
| 58 |
+
pub struct Pricing {
|
| 59 |
+
#[serde(default, deserialize_with = "deserialize_optional_price")]
|
| 60 |
+
pub prompt: Option<f32>,
|
| 61 |
+
#[serde(default, deserialize_with = "deserialize_optional_price")]
|
| 62 |
+
pub completion: Option<f32>,
|
| 63 |
+
#[serde(default, deserialize_with = "deserialize_optional_price")]
|
| 64 |
+
pub image: Option<f32>,
|
| 65 |
+
#[serde(default, deserialize_with = "deserialize_optional_price")]
|
| 66 |
+
pub request: Option<f32>,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
fn deserialize_optional_price<'de, D>(deserializer: D) -> Result<Option<f32>, D::Error>
|
| 70 |
+
where
|
| 71 |
+
D: serde::Deserializer<'de>,
|
| 72 |
+
{
|
| 73 |
+
use serde::de::Error;
|
| 74 |
+
|
| 75 |
+
match Option::<PriceValue>::deserialize(deserializer)? {
|
| 76 |
+
Some(price_value) => match f32::try_from(price_value) {
|
| 77 |
+
Ok(value) => Ok(Some(value)),
|
| 78 |
+
Err(_) => Err(Error::custom("invalid string format for pricing value")),
|
| 79 |
+
},
|
| 80 |
+
None => Ok(None),
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 85 |
+
pub struct TopProvider {
|
| 86 |
+
pub context_length: Option<u64>,
|
| 87 |
+
pub max_completion_tokens: Option<u64>,
|
| 88 |
+
#[serde(default)]
|
| 89 |
+
pub is_moderated: bool,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
#[derive(Debug, Deserialize, Clone, Serialize)]
|
| 93 |
+
pub struct ListModelResponse {
|
| 94 |
+
pub data: Vec<Model>,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
impl From<Model> for forge_domain::Model {
|
| 98 |
+
fn from(value: Model) -> Self {
|
| 99 |
+
let has_param = |name: &str| {
|
| 100 |
+
value
|
| 101 |
+
.supported_parameters
|
| 102 |
+
.as_ref()
|
| 103 |
+
.map(|params| params.iter().any(|p| p == name))
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
// Parse input modalities from OpenRouter's input_modalities field
|
| 107 |
+
let input_modalities = value
|
| 108 |
+
.architecture
|
| 109 |
+
.as_ref()
|
| 110 |
+
.and_then(|arch| arch.input_modalities.as_ref())
|
| 111 |
+
.map(|modalities| {
|
| 112 |
+
modalities
|
| 113 |
+
.iter()
|
| 114 |
+
.filter_map(|s| s.parse::<forge_domain::InputModality>().ok())
|
| 115 |
+
.collect::<Vec<_>>()
|
| 116 |
+
})
|
| 117 |
+
.unwrap_or_else(|| vec![forge_domain::InputModality::Text]);
|
| 118 |
+
|
| 119 |
+
let tools_supported = has_param("tools");
|
| 120 |
+
let supports_parallel_tool_calls = has_param("supports_parallel_tool_calls");
|
| 121 |
+
let supports_reasoning = has_param("reasoning");
|
| 122 |
+
|
| 123 |
+
forge_domain::Model {
|
| 124 |
+
id: value.id,
|
| 125 |
+
name: value.name,
|
| 126 |
+
description: value.description,
|
| 127 |
+
context_length: value.context_length,
|
| 128 |
+
tools_supported,
|
| 129 |
+
supports_parallel_tool_calls,
|
| 130 |
+
supports_reasoning,
|
| 131 |
+
input_modalities,
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
#[cfg(test)]
|
| 137 |
+
mod tests {
|
| 138 |
+
use pretty_assertions::assert_eq;
|
| 139 |
+
|
| 140 |
+
use super::*;
|
| 141 |
+
|
| 142 |
+
pub async fn load_fixture(filename: &str) -> serde_json::Value {
|
| 143 |
+
let fixture_path = format!("src/dto/openai/fixtures/{}", filename);
|
| 144 |
+
let fixture_content = tokio::fs::read_to_string(&fixture_path)
|
| 145 |
+
.await
|
| 146 |
+
.unwrap_or_else(|_| panic!("Failed to read fixture file: {}", fixture_path));
|
| 147 |
+
serde_json::from_str(&fixture_content)
|
| 148 |
+
.unwrap_or_else(|_| panic!("Failed to parse JSON fixture: {}", fixture_path))
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
#[tokio::test]
|
| 152 |
+
async fn test_deserialize_model_with_numeric_pricing() {
|
| 153 |
+
// This reproduces the issue where Chutes API returns numeric pricing instead of
|
| 154 |
+
// strings
|
| 155 |
+
let fixture = load_fixture("model_numeric_pricing.json").await;
|
| 156 |
+
|
| 157 |
+
let actual = serde_json::from_value::<Model>(fixture).unwrap();
|
| 158 |
+
|
| 159 |
+
// This should not fail - we should be able to handle numeric pricing
|
| 160 |
+
assert_eq!(actual.pricing.as_ref().unwrap().prompt, Some(0.17992692));
|
| 161 |
+
assert_eq!(
|
| 162 |
+
actual.pricing.as_ref().unwrap().completion,
|
| 163 |
+
Some(0.17992692)
|
| 164 |
+
);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
#[tokio::test]
|
| 168 |
+
async fn test_deserialize_model_with_string_pricing() {
|
| 169 |
+
let fixture = load_fixture("model_string_pricing.json").await;
|
| 170 |
+
|
| 171 |
+
let actual = serde_json::from_value::<Model>(fixture).unwrap();
|
| 172 |
+
let expected = Model {
|
| 173 |
+
id: "test-model".into(),
|
| 174 |
+
name: Some("Test Model".to_string()),
|
| 175 |
+
created: None,
|
| 176 |
+
description: None,
|
| 177 |
+
context_length: None,
|
| 178 |
+
architecture: None,
|
| 179 |
+
pricing: Some(Pricing {
|
| 180 |
+
prompt: Some(0.001),
|
| 181 |
+
completion: Some(0.002),
|
| 182 |
+
image: None,
|
| 183 |
+
request: None,
|
| 184 |
+
}),
|
| 185 |
+
top_provider: None,
|
| 186 |
+
per_request_limits: None,
|
| 187 |
+
supported_parameters: None,
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
assert_eq!(actual.id, expected.id);
|
| 191 |
+
assert_eq!(actual.name, expected.name);
|
| 192 |
+
assert_eq!(
|
| 193 |
+
actual.pricing.as_ref().unwrap().prompt,
|
| 194 |
+
expected.pricing.as_ref().unwrap().prompt
|
| 195 |
+
);
|
| 196 |
+
assert_eq!(
|
| 197 |
+
actual.pricing.as_ref().unwrap().completion,
|
| 198 |
+
expected.pricing.as_ref().unwrap().completion
|
| 199 |
+
);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
#[tokio::test]
|
| 203 |
+
async fn test_deserialize_model_with_mixed_pricing() {
|
| 204 |
+
// Test with mixed string, numeric, and null pricing values
|
| 205 |
+
let fixture = load_fixture("model_mixed_pricing.json").await;
|
| 206 |
+
|
| 207 |
+
let actual = serde_json::from_value::<Model>(fixture).unwrap();
|
| 208 |
+
|
| 209 |
+
assert_eq!(actual.pricing.as_ref().unwrap().prompt, Some(0.001));
|
| 210 |
+
assert_eq!(actual.pricing.as_ref().unwrap().completion, Some(0.002));
|
| 211 |
+
assert_eq!(actual.pricing.as_ref().unwrap().image, None);
|
| 212 |
+
assert_eq!(actual.pricing.as_ref().unwrap().request, None);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
#[tokio::test]
|
| 216 |
+
async fn test_deserialize_model_without_pricing() {
|
| 217 |
+
// Test that models without pricing field work correctly
|
| 218 |
+
let fixture = load_fixture("model_no_pricing.json").await;
|
| 219 |
+
|
| 220 |
+
let actual = serde_json::from_value::<Model>(fixture).unwrap();
|
| 221 |
+
|
| 222 |
+
assert_eq!(actual.id.as_str(), "no-pricing-model");
|
| 223 |
+
assert_eq!(actual.name, Some("No Pricing Model".to_string()));
|
| 224 |
+
assert_eq!(actual.pricing, None);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
#[tokio::test]
|
| 228 |
+
async fn test_chutes_api_response_format() {
|
| 229 |
+
// This simulates the actual Chutes API response format that was causing the
|
| 230 |
+
// issue
|
| 231 |
+
let fixture = load_fixture("chutes_api_response.json").await;
|
| 232 |
+
|
| 233 |
+
let actual = serde_json::from_value::<ListModelResponse>(fixture).unwrap();
|
| 234 |
+
|
| 235 |
+
assert_eq!(actual.data.len(), 1);
|
| 236 |
+
let model = &actual.data[0];
|
| 237 |
+
assert_eq!(model.id.as_str(), "moonshotai/Kimi-K2-Instruct-75k");
|
| 238 |
+
assert_eq!(model.name, Some("Kimi K2 Instruct 75k".to_string()));
|
| 239 |
+
assert_eq!(model.context_length, Some(75000));
|
| 240 |
+
|
| 241 |
+
let pricing = model.pricing.as_ref().unwrap();
|
| 242 |
+
assert_eq!(pricing.prompt, Some(0.17992692));
|
| 243 |
+
assert_eq!(pricing.completion, Some(0.17992692));
|
| 244 |
+
assert_eq!(pricing.image, None);
|
| 245 |
+
assert_eq!(pricing.request, None);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
#[tokio::test]
|
| 249 |
+
async fn test_orcarouter_api_response_format() {
|
| 250 |
+
// OrcaRouter returns an `architecture` object with only the modality
|
| 251 |
+
// arrays (no `modality`/`tokenizer`) and a `top_provider` without
|
| 252 |
+
// `is_moderated`; all of these must deserialize.
|
| 253 |
+
let fixture = load_fixture("orcarouter_api_response.json").await;
|
| 254 |
+
|
| 255 |
+
let actual = serde_json::from_value::<ListModelResponse>(fixture).unwrap();
|
| 256 |
+
|
| 257 |
+
assert_eq!(actual.data.len(), 1);
|
| 258 |
+
let model = &actual.data[0];
|
| 259 |
+
assert_eq!(model.id.as_str(), "openai/gpt-4o-mini");
|
| 260 |
+
assert_eq!(model.name, Some("OpenAI: GPT-4o-mini".to_string()));
|
| 261 |
+
assert_eq!(model.context_length, Some(128000));
|
| 262 |
+
|
| 263 |
+
let architecture = model.architecture.as_ref().unwrap();
|
| 264 |
+
assert_eq!(architecture.modality, None);
|
| 265 |
+
assert_eq!(architecture.tokenizer, None);
|
| 266 |
+
assert_eq!(
|
| 267 |
+
architecture.input_modalities,
|
| 268 |
+
Some(vec![
|
| 269 |
+
"text".to_string(),
|
| 270 |
+
"image".to_string(),
|
| 271 |
+
"file".to_string()
|
| 272 |
+
])
|
| 273 |
+
);
|
| 274 |
+
|
| 275 |
+
let top_provider = model.top_provider.as_ref().unwrap();
|
| 276 |
+
assert_eq!(top_provider.context_length, Some(128000));
|
| 277 |
+
assert_eq!(top_provider.is_moderated, false);
|
| 278 |
+
|
| 279 |
+
let pricing = model.pricing.as_ref().unwrap();
|
| 280 |
+
assert_eq!(pricing.prompt, Some(0.00000015));
|
| 281 |
+
assert_eq!(pricing.completion, Some(0.0000006));
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
#[tokio::test]
|
| 285 |
+
async fn test_deserialize_model_with_invalid_string_pricing() {
|
| 286 |
+
// Test that invalid string pricing formats fail gracefully
|
| 287 |
+
let fixture = load_fixture("model_invalid_pricing.json").await;
|
| 288 |
+
|
| 289 |
+
let actual = serde_json::from_value::<Model>(fixture);
|
| 290 |
+
|
| 291 |
+
// This should fail with a parsing error
|
| 292 |
+
assert!(actual.is_err());
|
| 293 |
+
let error_message = format!("{}", actual.unwrap_err());
|
| 294 |
+
assert!(error_message.contains("invalid string format for pricing value"));
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
#[tokio::test]
|
| 298 |
+
async fn test_deserialize_model_with_scientific_notation_string() {
|
| 299 |
+
// Test that scientific notation in strings works
|
| 300 |
+
let fixture = load_fixture("model_scientific_notation.json").await;
|
| 301 |
+
|
| 302 |
+
let actual = serde_json::from_value::<Model>(fixture).unwrap();
|
| 303 |
+
|
| 304 |
+
assert_eq!(actual.pricing.as_ref().unwrap().prompt, Some(0.0015));
|
| 305 |
+
assert_eq!(actual.pricing.as_ref().unwrap().completion, Some(0.0002));
|
| 306 |
+
}
|
| 307 |
+
#[tokio::test]
|
| 308 |
+
async fn test_model_conversion_without_supported_parameters() {
|
| 309 |
+
let model = Model {
|
| 310 |
+
id: "test-model".into(),
|
| 311 |
+
name: Some("Test Model".to_string()),
|
| 312 |
+
created: None,
|
| 313 |
+
description: Some("A test model".to_string()),
|
| 314 |
+
context_length: Some(4096),
|
| 315 |
+
architecture: None,
|
| 316 |
+
pricing: None,
|
| 317 |
+
top_provider: None,
|
| 318 |
+
per_request_limits: None,
|
| 319 |
+
supported_parameters: None, // No supported_parameters field
|
| 320 |
+
};
|
| 321 |
+
|
| 322 |
+
let domain_model: forge_domain::Model = model.into();
|
| 323 |
+
|
| 324 |
+
// When supported_parameters is None, capabilities should be None (unknown)
|
| 325 |
+
assert_eq!(domain_model.tools_supported, None);
|
| 326 |
+
assert_eq!(domain_model.supports_parallel_tool_calls, None);
|
| 327 |
+
assert_eq!(domain_model.supports_reasoning, None);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
#[tokio::test]
|
| 331 |
+
async fn test_model_conversion_with_supported_parameters() {
|
| 332 |
+
let model = Model {
|
| 333 |
+
id: "test-model".into(),
|
| 334 |
+
name: Some("Test Model".to_string()),
|
| 335 |
+
created: None,
|
| 336 |
+
description: Some("A test model".to_string()),
|
| 337 |
+
context_length: Some(4096),
|
| 338 |
+
architecture: None,
|
| 339 |
+
pricing: None,
|
| 340 |
+
top_provider: None,
|
| 341 |
+
per_request_limits: None,
|
| 342 |
+
supported_parameters: Some(vec![
|
| 343 |
+
"tools".to_string(),
|
| 344 |
+
"reasoning".to_string(),
|
| 345 |
+
// Note: "supports_parallel_tool_calls" is not included
|
| 346 |
+
]),
|
| 347 |
+
};
|
| 348 |
+
|
| 349 |
+
let domain_model: forge_domain::Model = model.into();
|
| 350 |
+
|
| 351 |
+
// Should reflect what's actually in supported_parameters
|
| 352 |
+
assert_eq!(domain_model.tools_supported, Some(true));
|
| 353 |
+
assert_eq!(domain_model.supports_parallel_tool_calls, Some(false));
|
| 354 |
+
assert_eq!(domain_model.supports_reasoning, Some(true));
|
| 355 |
+
}
|
| 356 |
+
}
|
crates/forge_app/src/dto/openai/reasoning.rs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use serde::{Deserialize, Serialize};
|
| 2 |
+
|
| 3 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 4 |
+
pub struct ReasoningDetail {
|
| 5 |
+
pub r#type: String,
|
| 6 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 7 |
+
pub text: Option<String>,
|
| 8 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 9 |
+
pub signature: Option<String>,
|
| 10 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 11 |
+
pub data: Option<String>,
|
| 12 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 13 |
+
pub id: Option<String>,
|
| 14 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 15 |
+
pub format: Option<String>,
|
| 16 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 17 |
+
pub index: Option<i32>,
|
| 18 |
+
}
|
crates/forge_app/src/dto/openai/request.rs
ADDED
|
@@ -0,0 +1,937 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::vec;
|
| 2 |
+
|
| 3 |
+
use derive_more::derive::Display;
|
| 4 |
+
use derive_setters::Setters;
|
| 5 |
+
use forge_json_repair::coerce_to_schema;
|
| 6 |
+
use serde::{Deserialize, Serialize};
|
| 7 |
+
use strum::IntoEnumIterator;
|
| 8 |
+
|
| 9 |
+
use super::response::{ExtraContent, FunctionCall, ToolCall};
|
| 10 |
+
use super::tool_choice::{FunctionType, ToolChoice};
|
| 11 |
+
use crate::domain::{
|
| 12 |
+
Context, ContextMessage, ModelId, ToolCallFull, ToolCallId, ToolCatalog, ToolDefinition,
|
| 13 |
+
ToolName, ToolResult, ToolValue,
|
| 14 |
+
};
|
| 15 |
+
use crate::dto::openai::ReasoningDetail;
|
| 16 |
+
|
| 17 |
+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
| 18 |
+
pub struct ImageUrl {
|
| 19 |
+
pub url: String,
|
| 20 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 21 |
+
pub detail: Option<String>,
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 25 |
+
pub struct Message {
|
| 26 |
+
pub role: Role,
|
| 27 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 28 |
+
pub content: Option<MessageContent>,
|
| 29 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 30 |
+
pub name: Option<ToolName>,
|
| 31 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 32 |
+
pub tool_call_id: Option<ToolCallId>,
|
| 33 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 34 |
+
pub tool_calls: Option<Vec<ToolCall>>,
|
| 35 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 36 |
+
pub reasoning_details: Option<Vec<ReasoningDetail>>,
|
| 37 |
+
// GitHub Copilot format (flat fields instead of array)
|
| 38 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 39 |
+
pub reasoning_text: Option<String>,
|
| 40 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 41 |
+
pub reasoning_opaque: Option<String>,
|
| 42 |
+
// kimi_k2 uses reasoning_content as flat string (similar to reasoning_text but aliased)
|
| 43 |
+
#[serde(skip_serializing_if = "Option::is_none", rename = "reasoning_content")]
|
| 44 |
+
pub reasoning_content: Option<String>,
|
| 45 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 46 |
+
pub extra_content: Option<ExtraContent>,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
| 50 |
+
#[serde(untagged)]
|
| 51 |
+
pub enum MessageContent {
|
| 52 |
+
Text(String),
|
| 53 |
+
Parts(Vec<ContentPart>),
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
impl MessageContent {
|
| 57 |
+
pub fn cached(self, enable_cache: bool) -> Self {
|
| 58 |
+
let cache_control =
|
| 59 |
+
enable_cache.then_some(CacheControl { type_: CacheControlType::Ephemeral });
|
| 60 |
+
|
| 61 |
+
match self {
|
| 62 |
+
MessageContent::Text(text) => {
|
| 63 |
+
if let Some(cc) = cache_control {
|
| 64 |
+
MessageContent::Parts(vec![ContentPart::Text { text, cache_control: Some(cc) }])
|
| 65 |
+
} else {
|
| 66 |
+
MessageContent::Text(text)
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
MessageContent::Parts(mut parts) => {
|
| 70 |
+
parts.iter_mut().for_each(ContentPart::reset_cache);
|
| 71 |
+
match cache_control {
|
| 72 |
+
Some(_) => {
|
| 73 |
+
// cache the last part of the message
|
| 74 |
+
if let Some(part) = parts.last_mut() {
|
| 75 |
+
part.cached(enable_cache)
|
| 76 |
+
}
|
| 77 |
+
MessageContent::Parts(parts)
|
| 78 |
+
}
|
| 79 |
+
None => MessageContent::Parts(parts),
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
pub fn is_cached(&self) -> bool {
|
| 86 |
+
match self {
|
| 87 |
+
MessageContent::Text(_) => false,
|
| 88 |
+
MessageContent::Parts(parts) => parts.iter().any(|part| {
|
| 89 |
+
if let ContentPart::Text { cache_control, .. } = part {
|
| 90 |
+
cache_control.is_some()
|
| 91 |
+
} else {
|
| 92 |
+
false
|
| 93 |
+
}
|
| 94 |
+
}),
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
| 100 |
+
#[serde(tag = "type", rename_all = "snake_case")]
|
| 101 |
+
pub enum ContentPart {
|
| 102 |
+
Text {
|
| 103 |
+
text: String,
|
| 104 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 105 |
+
cache_control: Option<CacheControl>,
|
| 106 |
+
},
|
| 107 |
+
ImageUrl {
|
| 108 |
+
image_url: ImageUrl,
|
| 109 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 110 |
+
cache_control: Option<CacheControl>,
|
| 111 |
+
},
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
impl ContentPart {
|
| 115 |
+
pub fn reset_cache(&mut self) {
|
| 116 |
+
match self {
|
| 117 |
+
ContentPart::Text { cache_control, .. } => {
|
| 118 |
+
*cache_control = None;
|
| 119 |
+
}
|
| 120 |
+
ContentPart::ImageUrl { cache_control, .. } => {
|
| 121 |
+
*cache_control = None;
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
pub fn cached(&mut self, enable_cache: bool) {
|
| 127 |
+
let src_cache_control =
|
| 128 |
+
enable_cache.then_some(CacheControl { type_: CacheControlType::Ephemeral });
|
| 129 |
+
match self {
|
| 130 |
+
ContentPart::Text { cache_control, .. } => {
|
| 131 |
+
*cache_control = src_cache_control;
|
| 132 |
+
}
|
| 133 |
+
ContentPart::ImageUrl { cache_control, .. } => {
|
| 134 |
+
*cache_control = src_cache_control;
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
| 141 |
+
pub struct CacheControl {
|
| 142 |
+
#[serde(rename = "type")]
|
| 143 |
+
pub type_: CacheControlType,
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
| 147 |
+
#[serde(rename_all = "snake_case")]
|
| 148 |
+
pub enum CacheControlType {
|
| 149 |
+
Ephemeral,
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
| 153 |
+
pub struct FunctionDescription {
|
| 154 |
+
pub description: Option<String>,
|
| 155 |
+
pub name: String,
|
| 156 |
+
pub parameters: serde_json::Value,
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
| 160 |
+
pub struct Tool {
|
| 161 |
+
// TODO: should be an enum
|
| 162 |
+
pub r#type: FunctionType,
|
| 163 |
+
pub function: FunctionDescription,
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/// Response format configuration for OpenAI API
|
| 167 |
+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
| 168 |
+
#[serde(tag = "type", content = "json_schema")]
|
| 169 |
+
pub enum ResponseFormat {
|
| 170 |
+
#[serde(rename = "text")]
|
| 171 |
+
Text,
|
| 172 |
+
#[serde(rename = "json_schema")]
|
| 173 |
+
JsonSchema {
|
| 174 |
+
name: String,
|
| 175 |
+
schema: Box<schemars::Schema>,
|
| 176 |
+
},
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
| 180 |
+
pub struct Prediction {
|
| 181 |
+
pub r#type: String,
|
| 182 |
+
pub content: String,
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
| 186 |
+
pub struct ProviderPreferences {
|
| 187 |
+
// Define fields as necessary
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
/// Z.ai-specific thinking type
|
| 191 |
+
///
|
| 192 |
+
/// Represents the state of thinking for z.ai providers
|
| 193 |
+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
| 194 |
+
#[serde(rename_all = "lowercase")]
|
| 195 |
+
pub enum ThinkingType {
|
| 196 |
+
Enabled,
|
| 197 |
+
Disabled,
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/// Z.ai-specific thinking configuration structure
|
| 201 |
+
///
|
| 202 |
+
/// Z.ai uses a different format than standard OpenAI reasoning configuration.
|
| 203 |
+
/// This struct represents z.ai's thinking format: `{"type": "enabled"}` or
|
| 204 |
+
/// `{"type": "disabled"}`
|
| 205 |
+
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
| 206 |
+
pub struct ThinkingConfig {
|
| 207 |
+
/// Type of thinking configuration - enabled or disabled
|
| 208 |
+
#[serde(rename = "type")]
|
| 209 |
+
pub r#type: ThinkingType,
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
#[derive(Debug, Deserialize, Serialize, Clone, Setters, Default)]
|
| 213 |
+
#[setters(strip_option)]
|
| 214 |
+
pub struct Request {
|
| 215 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 216 |
+
pub messages: Option<Vec<Message>>,
|
| 217 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 218 |
+
pub prompt: Option<String>,
|
| 219 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 220 |
+
pub model: Option<ModelId>,
|
| 221 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 222 |
+
pub response_format: Option<ResponseFormat>,
|
| 223 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 224 |
+
pub stop: Option<Vec<String>>,
|
| 225 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 226 |
+
pub stream: Option<bool>,
|
| 227 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 228 |
+
pub max_tokens: Option<u32>,
|
| 229 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 230 |
+
pub temperature: Option<f32>,
|
| 231 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 232 |
+
pub tools: Option<Vec<Tool>>,
|
| 233 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 234 |
+
pub tool_choice: Option<ToolChoice>,
|
| 235 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 236 |
+
pub seed: Option<u32>,
|
| 237 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 238 |
+
pub top_p: Option<f32>,
|
| 239 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 240 |
+
pub top_k: Option<u32>,
|
| 241 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 242 |
+
pub frequency_penalty: Option<f32>,
|
| 243 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 244 |
+
pub presence_penalty: Option<f32>,
|
| 245 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 246 |
+
pub repetition_penalty: Option<f32>,
|
| 247 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 248 |
+
pub logit_bias: Option<std::collections::HashMap<u32, f32>>,
|
| 249 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 250 |
+
pub top_logprobs: Option<u32>,
|
| 251 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 252 |
+
pub min_p: Option<f32>,
|
| 253 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 254 |
+
pub top_a: Option<f32>,
|
| 255 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 256 |
+
pub prediction: Option<Prediction>,
|
| 257 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 258 |
+
pub transforms: Option<Vec<Transform>>,
|
| 259 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 260 |
+
pub models: Option<Vec<String>>,
|
| 261 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 262 |
+
pub route: Option<String>,
|
| 263 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 264 |
+
pub provider: Option<ProviderPreferences>,
|
| 265 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 266 |
+
pub parallel_tool_calls: Option<bool>,
|
| 267 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 268 |
+
pub session_id: Option<String>,
|
| 269 |
+
/// Indicates who initiated the conversation: "user" or "agent".
|
| 270 |
+
/// Used for GitHub Copilot billing optimization. Not serialized to API.
|
| 271 |
+
#[serde(skip_serializing)]
|
| 272 |
+
pub initiator: Option<String>,
|
| 273 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 274 |
+
pub stream_options: Option<StreamOptions>,
|
| 275 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 276 |
+
pub reasoning: Option<forge_domain::ReasoningConfig>,
|
| 277 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 278 |
+
pub reasoning_effort: Option<String>,
|
| 279 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 280 |
+
pub max_completion_tokens: Option<u32>,
|
| 281 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 282 |
+
pub thinking: Option<ThinkingConfig>,
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
| 286 |
+
pub struct StreamOptions {
|
| 287 |
+
pub include_usage: Option<bool>,
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
impl Request {
|
| 291 |
+
pub fn message_count(&self) -> usize {
|
| 292 |
+
self.messages
|
| 293 |
+
.as_ref()
|
| 294 |
+
.map(|messages| messages.len())
|
| 295 |
+
.unwrap_or(0)
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
pub fn message_cache_count(&self) -> usize {
|
| 299 |
+
self.messages
|
| 300 |
+
.iter()
|
| 301 |
+
.flatten()
|
| 302 |
+
.flat_map(|a| a.content.as_ref())
|
| 303 |
+
.enumerate()
|
| 304 |
+
.map(|(i, _)| i)
|
| 305 |
+
.max()
|
| 306 |
+
.unwrap_or(0)
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
/// ref: https://openrouter.ai/docs/transforms
|
| 311 |
+
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
| 312 |
+
pub enum Transform {
|
| 313 |
+
#[default]
|
| 314 |
+
#[serde(rename = "middle-out")]
|
| 315 |
+
MiddleOut,
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
impl From<ToolDefinition> for Tool {
|
| 319 |
+
fn from(value: ToolDefinition) -> Self {
|
| 320 |
+
Tool {
|
| 321 |
+
r#type: FunctionType,
|
| 322 |
+
function: FunctionDescription {
|
| 323 |
+
description: Some(value.description),
|
| 324 |
+
name: value.name.to_string(),
|
| 325 |
+
parameters: {
|
| 326 |
+
let mut params = serde_json::to_value(value.input_schema).unwrap();
|
| 327 |
+
// Ensure OpenAI compatibility by adding properties field if missing
|
| 328 |
+
if let Some(obj) = params.as_object_mut()
|
| 329 |
+
&& obj.get("type") == Some(&serde_json::Value::String("object".to_string()))
|
| 330 |
+
&& !obj.contains_key("properties")
|
| 331 |
+
{
|
| 332 |
+
obj.insert(
|
| 333 |
+
"properties".to_string(),
|
| 334 |
+
serde_json::Value::Object(serde_json::Map::new()),
|
| 335 |
+
);
|
| 336 |
+
}
|
| 337 |
+
params
|
| 338 |
+
},
|
| 339 |
+
},
|
| 340 |
+
}
|
| 341 |
+
}
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
impl From<Context> for Request {
|
| 345 |
+
fn from(context: Context) -> Self {
|
| 346 |
+
Request {
|
| 347 |
+
messages: {
|
| 348 |
+
let messages = context
|
| 349 |
+
.messages
|
| 350 |
+
.into_iter()
|
| 351 |
+
.map(|msg| Message::from(msg.message))
|
| 352 |
+
.collect::<Vec<_>>();
|
| 353 |
+
|
| 354 |
+
Some(messages)
|
| 355 |
+
},
|
| 356 |
+
tools: {
|
| 357 |
+
let tools = context
|
| 358 |
+
.tools
|
| 359 |
+
.into_iter()
|
| 360 |
+
.map(Tool::from)
|
| 361 |
+
.collect::<Vec<_>>();
|
| 362 |
+
if tools.is_empty() { None } else { Some(tools) }
|
| 363 |
+
},
|
| 364 |
+
model: None,
|
| 365 |
+
prompt: Default::default(),
|
| 366 |
+
response_format: context.response_format.map(|rf| match rf {
|
| 367 |
+
forge_domain::ResponseFormat::Text => ResponseFormat::Text,
|
| 368 |
+
forge_domain::ResponseFormat::JsonSchema(schema) => {
|
| 369 |
+
// Extract name from schema title
|
| 370 |
+
let name = schema
|
| 371 |
+
.as_value()
|
| 372 |
+
.as_object()
|
| 373 |
+
.and_then(|obj| obj.get("title"))
|
| 374 |
+
.and_then(|t| t.as_str())
|
| 375 |
+
.map(String::from)
|
| 376 |
+
.expect("Schema must have a title");
|
| 377 |
+
|
| 378 |
+
ResponseFormat::JsonSchema { name, schema }
|
| 379 |
+
}
|
| 380 |
+
}),
|
| 381 |
+
stop: Default::default(),
|
| 382 |
+
stream: Some(context.stream.unwrap_or(true)),
|
| 383 |
+
max_tokens: context.max_tokens.map(|t| t as u32),
|
| 384 |
+
temperature: context.temperature.map(|t| t.value()),
|
| 385 |
+
tool_choice: context.tool_choice.map(|tc| tc.into()),
|
| 386 |
+
seed: Default::default(),
|
| 387 |
+
top_p: context.top_p.map(|t| t.value()),
|
| 388 |
+
top_k: context.top_k.map(|t| t.value()),
|
| 389 |
+
frequency_penalty: Default::default(),
|
| 390 |
+
presence_penalty: Default::default(),
|
| 391 |
+
repetition_penalty: Default::default(),
|
| 392 |
+
logit_bias: Default::default(),
|
| 393 |
+
top_logprobs: Default::default(),
|
| 394 |
+
min_p: Default::default(),
|
| 395 |
+
top_a: Default::default(),
|
| 396 |
+
prediction: Default::default(),
|
| 397 |
+
// Since compaction is support on the client we don't need middle-out transforms any
|
| 398 |
+
// more
|
| 399 |
+
transforms: Default::default(),
|
| 400 |
+
models: Default::default(),
|
| 401 |
+
route: Default::default(),
|
| 402 |
+
provider: Default::default(),
|
| 403 |
+
parallel_tool_calls: Some(true), /* Default to true, transformers will adjust based
|
| 404 |
+
* on model capabilities */
|
| 405 |
+
stream_options: Some(StreamOptions { include_usage: Some(true) }),
|
| 406 |
+
session_id: context.conversation_id.map(|id| id.to_string()),
|
| 407 |
+
initiator: context.initiator,
|
| 408 |
+
reasoning: context.reasoning,
|
| 409 |
+
reasoning_effort: Default::default(),
|
| 410 |
+
max_completion_tokens: Default::default(),
|
| 411 |
+
thinking: Default::default(),
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
fn serialize_tool_call_arguments(tool_call: &ToolCallFull) -> String {
|
| 417 |
+
let serialized_arguments = || serde_json::to_string(&tool_call.arguments).unwrap();
|
| 418 |
+
|
| 419 |
+
let Ok(parsed_arguments) = tool_call.arguments.parse() else {
|
| 420 |
+
return serialized_arguments();
|
| 421 |
+
};
|
| 422 |
+
|
| 423 |
+
let normalized_arguments = ToolCatalog::iter()
|
| 424 |
+
.find(|tool| tool.definition().name == tool_call.name)
|
| 425 |
+
.map(|tool| coerce_to_schema(parsed_arguments.clone(), &tool.definition().input_schema))
|
| 426 |
+
.unwrap_or(parsed_arguments);
|
| 427 |
+
|
| 428 |
+
serde_json::to_string(&normalized_arguments).unwrap_or_else(|_| serialized_arguments())
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
impl From<ToolCallFull> for ToolCall {
|
| 432 |
+
fn from(value: ToolCallFull) -> Self {
|
| 433 |
+
let arguments = serialize_tool_call_arguments(&value);
|
| 434 |
+
let extra_content = value.thought_signature.map(ExtraContent::from);
|
| 435 |
+
|
| 436 |
+
Self {
|
| 437 |
+
id: value.call_id,
|
| 438 |
+
r#type: FunctionType,
|
| 439 |
+
function: FunctionCall { arguments, name: Some(value.name) },
|
| 440 |
+
extra_content,
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
impl From<ContextMessage> for Message {
|
| 446 |
+
fn from(value: ContextMessage) -> Self {
|
| 447 |
+
match value {
|
| 448 |
+
ContextMessage::Text(chat_message) => Message {
|
| 449 |
+
role: chat_message.role.into(),
|
| 450 |
+
content: Some(MessageContent::Text(chat_message.content)),
|
| 451 |
+
name: None,
|
| 452 |
+
tool_call_id: None,
|
| 453 |
+
tool_calls: chat_message
|
| 454 |
+
.tool_calls
|
| 455 |
+
.map(|tool_calls| tool_calls.into_iter().map(ToolCall::from).collect()),
|
| 456 |
+
reasoning_details: chat_message.reasoning_details.map(|details| {
|
| 457 |
+
details
|
| 458 |
+
.into_iter()
|
| 459 |
+
.map(|detail| ReasoningDetail {
|
| 460 |
+
r#type: detail
|
| 461 |
+
.type_of
|
| 462 |
+
.unwrap_or_else(|| "reasoning.text".to_string()),
|
| 463 |
+
text: detail.text,
|
| 464 |
+
signature: detail.signature,
|
| 465 |
+
data: detail.data,
|
| 466 |
+
id: detail.id,
|
| 467 |
+
format: detail.format,
|
| 468 |
+
index: detail.index,
|
| 469 |
+
})
|
| 470 |
+
.collect::<Vec<ReasoningDetail>>()
|
| 471 |
+
}),
|
| 472 |
+
reasoning_text: None,
|
| 473 |
+
reasoning_opaque: None,
|
| 474 |
+
reasoning_content: None,
|
| 475 |
+
extra_content: chat_message.thought_signature.map(ExtraContent::from),
|
| 476 |
+
},
|
| 477 |
+
ContextMessage::Tool(tool_result) => Message {
|
| 478 |
+
role: Role::Tool,
|
| 479 |
+
tool_call_id: tool_result.call_id.clone(),
|
| 480 |
+
name: Some(tool_result.name.clone()),
|
| 481 |
+
content: Some(tool_result.into()),
|
| 482 |
+
tool_calls: None,
|
| 483 |
+
reasoning_details: None,
|
| 484 |
+
reasoning_text: None,
|
| 485 |
+
reasoning_opaque: None,
|
| 486 |
+
reasoning_content: None,
|
| 487 |
+
extra_content: None,
|
| 488 |
+
},
|
| 489 |
+
ContextMessage::Image(img) => {
|
| 490 |
+
let content = vec![ContentPart::ImageUrl {
|
| 491 |
+
image_url: ImageUrl { url: img.url().clone(), detail: None },
|
| 492 |
+
cache_control: None,
|
| 493 |
+
}];
|
| 494 |
+
Message {
|
| 495 |
+
role: Role::User,
|
| 496 |
+
content: Some(MessageContent::Parts(content)),
|
| 497 |
+
name: None,
|
| 498 |
+
tool_call_id: None,
|
| 499 |
+
tool_calls: None,
|
| 500 |
+
reasoning_details: None,
|
| 501 |
+
reasoning_text: None,
|
| 502 |
+
reasoning_opaque: None,
|
| 503 |
+
reasoning_content: None,
|
| 504 |
+
extra_content: None,
|
| 505 |
+
}
|
| 506 |
+
}
|
| 507 |
+
}
|
| 508 |
+
}
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
impl From<ToolResult> for MessageContent {
|
| 512 |
+
fn from(result: ToolResult) -> Self {
|
| 513 |
+
if result.output.values.len() == 1
|
| 514 |
+
&& let Some(text) = result.output.as_str()
|
| 515 |
+
{
|
| 516 |
+
return MessageContent::Text(text.to_string());
|
| 517 |
+
}
|
| 518 |
+
let mut parts = Vec::new();
|
| 519 |
+
for value in result.output.values.into_iter() {
|
| 520 |
+
match value {
|
| 521 |
+
ToolValue::Text(text) => {
|
| 522 |
+
parts.push(ContentPart::Text { text, cache_control: None });
|
| 523 |
+
}
|
| 524 |
+
ToolValue::Image(img) => {
|
| 525 |
+
let content = ContentPart::ImageUrl {
|
| 526 |
+
image_url: ImageUrl { url: img.url().clone(), detail: None },
|
| 527 |
+
cache_control: None,
|
| 528 |
+
};
|
| 529 |
+
parts.push(content);
|
| 530 |
+
}
|
| 531 |
+
ToolValue::Empty => {
|
| 532 |
+
// Handle empty case if needed
|
| 533 |
+
}
|
| 534 |
+
ToolValue::AI { value, .. } => {
|
| 535 |
+
parts.push(ContentPart::Text { text: value, cache_control: None })
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
MessageContent::Parts(parts)
|
| 541 |
+
}
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
impl From<forge_domain::Role> for Role {
|
| 545 |
+
fn from(role: forge_domain::Role) -> Self {
|
| 546 |
+
match role {
|
| 547 |
+
forge_domain::Role::System => Role::System,
|
| 548 |
+
forge_domain::Role::User => Role::User,
|
| 549 |
+
forge_domain::Role::Assistant => Role::Assistant,
|
| 550 |
+
}
|
| 551 |
+
}
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
#[derive(Debug, Deserialize, Display, Serialize, Clone, PartialEq, Eq)]
|
| 555 |
+
#[serde(rename_all = "snake_case")]
|
| 556 |
+
pub enum Role {
|
| 557 |
+
System,
|
| 558 |
+
User,
|
| 559 |
+
Assistant,
|
| 560 |
+
Tool,
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
#[cfg(test)]
|
| 564 |
+
mod tests {
|
| 565 |
+
use pretty_assertions::assert_eq;
|
| 566 |
+
|
| 567 |
+
use super::*;
|
| 568 |
+
|
| 569 |
+
#[test]
|
| 570 |
+
fn test_cached_text_true() {
|
| 571 |
+
let fixture = MessageContent::Text("hello".to_string());
|
| 572 |
+
let actual = fixture.cached(true);
|
| 573 |
+
let expected = MessageContent::Parts(vec![ContentPart::Text {
|
| 574 |
+
text: "hello".to_string(),
|
| 575 |
+
cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
|
| 576 |
+
}]);
|
| 577 |
+
assert_eq!(actual, expected);
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
#[test]
|
| 581 |
+
fn test_cached_text_false() {
|
| 582 |
+
let fixture = MessageContent::Text("hello".to_string());
|
| 583 |
+
let actual = fixture.cached(false);
|
| 584 |
+
let expected = MessageContent::Text("hello".to_string());
|
| 585 |
+
assert_eq!(actual, expected);
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
#[test]
|
| 589 |
+
fn test_cached_parts_true() {
|
| 590 |
+
let fixture = MessageContent::Parts(vec![
|
| 591 |
+
ContentPart::Text { text: "a".to_string(), cache_control: None },
|
| 592 |
+
ContentPart::ImageUrl {
|
| 593 |
+
image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
|
| 594 |
+
cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
|
| 595 |
+
},
|
| 596 |
+
]);
|
| 597 |
+
let actual = fixture.cached(true);
|
| 598 |
+
let expected = MessageContent::Parts(vec![
|
| 599 |
+
ContentPart::Text { text: "a".to_string(), cache_control: None },
|
| 600 |
+
ContentPart::ImageUrl {
|
| 601 |
+
image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
|
| 602 |
+
cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
|
| 603 |
+
},
|
| 604 |
+
]);
|
| 605 |
+
assert_eq!(actual, expected);
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
#[test]
|
| 609 |
+
fn test_cached_parts_multi_false() {
|
| 610 |
+
let fixture = MessageContent::Parts(vec![
|
| 611 |
+
ContentPart::Text {
|
| 612 |
+
text: "a".to_string(),
|
| 613 |
+
cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
|
| 614 |
+
},
|
| 615 |
+
ContentPart::Text {
|
| 616 |
+
text: "b".to_string(),
|
| 617 |
+
cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
|
| 618 |
+
},
|
| 619 |
+
ContentPart::ImageUrl {
|
| 620 |
+
image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
|
| 621 |
+
cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
|
| 622 |
+
},
|
| 623 |
+
]);
|
| 624 |
+
let actual = fixture.cached(false);
|
| 625 |
+
let expected = MessageContent::Parts(vec![
|
| 626 |
+
ContentPart::Text { text: "a".to_string(), cache_control: None },
|
| 627 |
+
ContentPart::Text { text: "b".to_string(), cache_control: None },
|
| 628 |
+
ContentPart::ImageUrl {
|
| 629 |
+
image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
|
| 630 |
+
cache_control: None,
|
| 631 |
+
},
|
| 632 |
+
]);
|
| 633 |
+
assert_eq!(actual, expected);
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
#[test]
|
| 637 |
+
fn test_cached_parts_already_true() {
|
| 638 |
+
let fixture = MessageContent::Parts(vec![
|
| 639 |
+
ContentPart::Text {
|
| 640 |
+
text: "a".to_string(),
|
| 641 |
+
cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
|
| 642 |
+
},
|
| 643 |
+
ContentPart::Text { text: "b".to_string(), cache_control: None },
|
| 644 |
+
ContentPart::ImageUrl {
|
| 645 |
+
image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
|
| 646 |
+
cache_control: None,
|
| 647 |
+
},
|
| 648 |
+
]);
|
| 649 |
+
let actual = fixture.cached(true);
|
| 650 |
+
let expected = MessageContent::Parts(vec![
|
| 651 |
+
ContentPart::Text { text: "a".to_string(), cache_control: None },
|
| 652 |
+
ContentPart::Text { text: "b".to_string(), cache_control: None },
|
| 653 |
+
ContentPart::ImageUrl {
|
| 654 |
+
image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
|
| 655 |
+
cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
|
| 656 |
+
},
|
| 657 |
+
]);
|
| 658 |
+
assert_eq!(actual, expected);
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
#[test]
|
| 662 |
+
fn test_cached_parts_multi_true() {
|
| 663 |
+
let fixture = MessageContent::Parts(vec![
|
| 664 |
+
ContentPart::Text { text: "a".to_string(), cache_control: None },
|
| 665 |
+
ContentPart::Text { text: "b".to_string(), cache_control: None },
|
| 666 |
+
ContentPart::ImageUrl {
|
| 667 |
+
image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
|
| 668 |
+
cache_control: None,
|
| 669 |
+
},
|
| 670 |
+
]);
|
| 671 |
+
let actual = fixture.cached(true);
|
| 672 |
+
let expected = MessageContent::Parts(vec![
|
| 673 |
+
ContentPart::Text { text: "a".to_string(), cache_control: None },
|
| 674 |
+
ContentPart::Text { text: "b".to_string(), cache_control: None },
|
| 675 |
+
ContentPart::ImageUrl {
|
| 676 |
+
image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
|
| 677 |
+
cache_control: Some(CacheControl { type_: CacheControlType::Ephemeral }),
|
| 678 |
+
},
|
| 679 |
+
]);
|
| 680 |
+
assert_eq!(actual, expected);
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
#[test]
|
| 684 |
+
fn test_cached_parts_false() {
|
| 685 |
+
let fixture = MessageContent::Parts(vec![
|
| 686 |
+
ContentPart::Text { text: "a".to_string(), cache_control: None },
|
| 687 |
+
ContentPart::ImageUrl {
|
| 688 |
+
image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
|
| 689 |
+
cache_control: None,
|
| 690 |
+
},
|
| 691 |
+
]);
|
| 692 |
+
let actual = fixture.cached(false);
|
| 693 |
+
let expected = MessageContent::Parts(vec![
|
| 694 |
+
ContentPart::Text { text: "a".to_string(), cache_control: None },
|
| 695 |
+
ContentPart::ImageUrl {
|
| 696 |
+
image_url: ImageUrl { url: "http://example.com/a.png".to_string(), detail: None },
|
| 697 |
+
cache_control: None,
|
| 698 |
+
},
|
| 699 |
+
]);
|
| 700 |
+
assert_eq!(actual, expected);
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
use forge_domain::{
|
| 704 |
+
ContextMessage, Role, TextMessage, ToolCallFull, ToolCallId, ToolCatalog, ToolName,
|
| 705 |
+
ToolResult,
|
| 706 |
+
};
|
| 707 |
+
use insta::assert_json_snapshot;
|
| 708 |
+
|
| 709 |
+
#[test]
|
| 710 |
+
fn test_user_message_conversion() {
|
| 711 |
+
let user_message = ContextMessage::Text(
|
| 712 |
+
TextMessage::new(Role::User, "Hello").model(ModelId::new("gpt-3.5-turbo")),
|
| 713 |
+
);
|
| 714 |
+
let router_message = Message::from(user_message);
|
| 715 |
+
assert_json_snapshot!(router_message);
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
#[test]
|
| 719 |
+
fn test_message_with_special_chars() {
|
| 720 |
+
let xml_content = r#"Here's some XML content:
|
| 721 |
+
<task>
|
| 722 |
+
<id>123</id>
|
| 723 |
+
<description>Test <special> characters</description>
|
| 724 |
+
<data key="value">
|
| 725 |
+
<item>1</item>
|
| 726 |
+
<item>2</item>
|
| 727 |
+
</data>
|
| 728 |
+
</task>"#;
|
| 729 |
+
|
| 730 |
+
let message = ContextMessage::Text(
|
| 731 |
+
TextMessage::new(Role::User, xml_content).model(ModelId::new("gpt-3.5-turbo")),
|
| 732 |
+
);
|
| 733 |
+
let router_message = Message::from(message);
|
| 734 |
+
assert_json_snapshot!(router_message);
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
#[test]
|
| 738 |
+
fn test_assistant_message_with_tool_call_conversion() {
|
| 739 |
+
let tool_call = ToolCallFull {
|
| 740 |
+
call_id: Some(ToolCallId::new("123")),
|
| 741 |
+
name: ToolName::new("test_tool"),
|
| 742 |
+
arguments: serde_json::json!({"key": "value"}).into(),
|
| 743 |
+
thought_signature: None,
|
| 744 |
+
};
|
| 745 |
+
|
| 746 |
+
let assistant_message = ContextMessage::Text(
|
| 747 |
+
TextMessage::new(Role::Assistant, "Using tool")
|
| 748 |
+
.tool_calls(vec![tool_call])
|
| 749 |
+
.model(ModelId::new("gpt-3.5-turbo")),
|
| 750 |
+
);
|
| 751 |
+
let router_message = Message::from(assistant_message);
|
| 752 |
+
assert_json_snapshot!(router_message);
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
#[test]
|
| 756 |
+
fn test_assistant_message_with_dump_style_tool_call_arguments_conversion() {
|
| 757 |
+
let fixture = ToolCatalog::tool_call_patch(
|
| 758 |
+
"/tmp/file.txt",
|
| 759 |
+
"new text",
|
| 760 |
+
"old text",
|
| 761 |
+
false,
|
| 762 |
+
)
|
| 763 |
+
.arguments(
|
| 764 |
+
serde_json::from_str::<forge_domain::ToolCallArguments>(
|
| 765 |
+
r#""{\"file_path\":\"/tmp/file.txt\",\"old_string\":\"old text\",\"new_string\":\"new text\",\"replace_all\":false}""#,
|
| 766 |
+
)
|
| 767 |
+
.unwrap(),
|
| 768 |
+
)
|
| 769 |
+
.call_id(ToolCallId::new("123"));
|
| 770 |
+
|
| 771 |
+
let assistant_message = ContextMessage::Text(
|
| 772 |
+
TextMessage::new(Role::Assistant, "Using tool")
|
| 773 |
+
.tool_calls(vec![fixture])
|
| 774 |
+
.model(ModelId::new("gpt-3.5-turbo")),
|
| 775 |
+
);
|
| 776 |
+
let actual = Message::from(assistant_message);
|
| 777 |
+
let actual =
|
| 778 |
+
serde_json::to_value(actual.tool_calls.expect("Tool calls should exist")).unwrap();
|
| 779 |
+
let expected = serde_json::json!([
|
| 780 |
+
{
|
| 781 |
+
"id": "123",
|
| 782 |
+
"type": "function",
|
| 783 |
+
"function": {
|
| 784 |
+
"arguments": "{\"file_path\":\"/tmp/file.txt\",\"new_string\":\"new text\",\"old_string\":\"old text\",\"replace_all\":false}",
|
| 785 |
+
"name": "patch"
|
| 786 |
+
}
|
| 787 |
+
}
|
| 788 |
+
]);
|
| 789 |
+
assert_eq!(actual, expected);
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
#[test]
|
| 793 |
+
fn test_tool_message_conversion() {
|
| 794 |
+
let tool_result = ToolResult::new(ToolName::new("test_tool"))
|
| 795 |
+
.call_id(ToolCallId::new("123"))
|
| 796 |
+
.success(
|
| 797 |
+
r#"{
|
| 798 |
+
"user": "John",
|
| 799 |
+
"age": 30,
|
| 800 |
+
"address": [{"city": "New York"}, {"city": "San Francisco"}]
|
| 801 |
+
}"#,
|
| 802 |
+
);
|
| 803 |
+
|
| 804 |
+
let tool_message = ContextMessage::Tool(tool_result);
|
| 805 |
+
let router_message = Message::from(tool_message);
|
| 806 |
+
assert_json_snapshot!(router_message);
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
#[test]
|
| 810 |
+
fn test_tool_message_with_special_chars() {
|
| 811 |
+
let tool_result = ToolResult::new(ToolName::new("html_tool"))
|
| 812 |
+
.call_id(ToolCallId::new("456"))
|
| 813 |
+
.success(
|
| 814 |
+
r#"{
|
| 815 |
+
"html": "<div class=\"container\"><p>Hello <World></p></div>",
|
| 816 |
+
"elements": ["<span>", "<br/>", "<hr>"],
|
| 817 |
+
"attributes": {
|
| 818 |
+
"style": "color: blue; font-size: 12px;",
|
| 819 |
+
"data-test": "<test>&value</test>"
|
| 820 |
+
}
|
| 821 |
+
}"#,
|
| 822 |
+
);
|
| 823 |
+
|
| 824 |
+
let tool_message = ContextMessage::Tool(tool_result);
|
| 825 |
+
let router_message = Message::from(tool_message);
|
| 826 |
+
assert_json_snapshot!(router_message);
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
#[test]
|
| 830 |
+
fn test_tool_message_typescript_code() {
|
| 831 |
+
let tool_result = ToolResult::new(ToolName::new("rust_tool"))
|
| 832 |
+
.call_id(ToolCallId::new("456"))
|
| 833 |
+
.success(r#"{ "code": "fn main<T>(gt: T) {let b = > }"}"#);
|
| 834 |
+
|
| 835 |
+
let tool_message = ContextMessage::Tool(tool_result);
|
| 836 |
+
let router_message = Message::from(tool_message);
|
| 837 |
+
assert_json_snapshot!(router_message);
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
#[test]
|
| 841 |
+
fn test_transform_display() {
|
| 842 |
+
assert_eq!(
|
| 843 |
+
serde_json::to_string(&Transform::MiddleOut).unwrap(),
|
| 844 |
+
"\"middle-out\""
|
| 845 |
+
);
|
| 846 |
+
}
|
| 847 |
+
#[test]
|
| 848 |
+
fn test_tool_definition_conversion_missing_properties() {
|
| 849 |
+
// Test case where input_schema is an object type but missing properties field
|
| 850 |
+
let fixture = {
|
| 851 |
+
// In schemars 1.0, Schema wraps serde_json::Value, so we create JSON directly
|
| 852 |
+
let schema_value = serde_json::json!({
|
| 853 |
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
| 854 |
+
"title": "Null",
|
| 855 |
+
"type": "object",
|
| 856 |
+
"properties": {}
|
| 857 |
+
});
|
| 858 |
+
let schema = schemars::Schema::try_from(schema_value).unwrap();
|
| 859 |
+
|
| 860 |
+
ToolDefinition::new("test_tool")
|
| 861 |
+
.description("Test tool")
|
| 862 |
+
.input_schema(schema)
|
| 863 |
+
};
|
| 864 |
+
|
| 865 |
+
let actual = Tool::from(fixture);
|
| 866 |
+
|
| 867 |
+
let expected = Tool {
|
| 868 |
+
r#type: FunctionType,
|
| 869 |
+
function: FunctionDescription {
|
| 870 |
+
description: Some("Test tool".to_string()),
|
| 871 |
+
name: "test_tool".to_string(),
|
| 872 |
+
parameters: serde_json::json!({
|
| 873 |
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
| 874 |
+
"properties": {},
|
| 875 |
+
"title": "Null",
|
| 876 |
+
"type": "object"
|
| 877 |
+
}),
|
| 878 |
+
},
|
| 879 |
+
};
|
| 880 |
+
|
| 881 |
+
assert_eq!(actual, expected);
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
#[test]
|
| 885 |
+
fn test_context_conversion_stream_defaults_to_true() {
|
| 886 |
+
let fixture = forge_domain::Context::default();
|
| 887 |
+
let actual = Request::from(fixture);
|
| 888 |
+
|
| 889 |
+
assert_eq!(actual.stream, Some(true));
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
#[test]
|
| 893 |
+
fn test_context_conversion_stream_explicit_true() {
|
| 894 |
+
let fixture = forge_domain::Context::default().stream(true);
|
| 895 |
+
let actual = Request::from(fixture);
|
| 896 |
+
|
| 897 |
+
assert_eq!(actual.stream, Some(true));
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
#[test]
|
| 901 |
+
fn test_context_conversion_stream_explicit_false() {
|
| 902 |
+
let fixture = forge_domain::Context::default().stream(false);
|
| 903 |
+
let actual = Request::from(fixture);
|
| 904 |
+
|
| 905 |
+
assert_eq!(actual.stream, Some(false));
|
| 906 |
+
}
|
| 907 |
+
|
| 908 |
+
#[test]
|
| 909 |
+
fn test_response_format_json_schema_serialization() {
|
| 910 |
+
use schemars::JsonSchema;
|
| 911 |
+
use serde::Deserialize;
|
| 912 |
+
|
| 913 |
+
#[derive(Deserialize, JsonSchema)]
|
| 914 |
+
#[allow(dead_code)]
|
| 915 |
+
#[schemars(title = "test_response")]
|
| 916 |
+
struct TestResponse {
|
| 917 |
+
message: String,
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
let schema = schemars::schema_for!(TestResponse);
|
| 921 |
+
let fixture = forge_domain::Context::default()
|
| 922 |
+
.response_format(forge_domain::ResponseFormat::JsonSchema(Box::new(schema)));
|
| 923 |
+
|
| 924 |
+
let actual = Request::from(fixture);
|
| 925 |
+
|
| 926 |
+
assert!(actual.response_format.is_some());
|
| 927 |
+
let rf = actual.response_format.unwrap();
|
| 928 |
+
|
| 929 |
+
// Serialize to JSON to verify the format
|
| 930 |
+
let json = serde_json::to_string(&rf).unwrap();
|
| 931 |
+
println!("Serialized response_format: {}", json);
|
| 932 |
+
|
| 933 |
+
// Should contain type and json_schema fields
|
| 934 |
+
assert!(json.contains("\"type\":\"json_schema\""));
|
| 935 |
+
assert!(json.contains("\"json_schema\""));
|
| 936 |
+
}
|
| 937 |
+
}
|
crates/forge_app/src/dto/openai/response.rs
ADDED
|
@@ -0,0 +1,1120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::str::FromStr;
|
| 2 |
+
|
| 3 |
+
use forge_domain::{
|
| 4 |
+
ChatCompletionMessage, Content, FinishReason, TokenCount, ToolCallFull, ToolCallId,
|
| 5 |
+
ToolCallPart, ToolName, Usage,
|
| 6 |
+
};
|
| 7 |
+
use serde::{Deserialize, Serialize};
|
| 8 |
+
|
| 9 |
+
use super::tool_choice::FunctionType;
|
| 10 |
+
use crate::dto::openai::ReasoningDetail;
|
| 11 |
+
use crate::dto::openai::error::{Error, ErrorCode, ErrorResponse};
|
| 12 |
+
|
| 13 |
+
/// Represents a value that may be either a JSON number or a numeric string.
|
| 14 |
+
#[derive(Deserialize, Debug, Clone, PartialEq, derive_more::TryInto, Serialize)]
|
| 15 |
+
#[serde(untagged)]
|
| 16 |
+
pub enum StringOrF64 {
|
| 17 |
+
Number(f64),
|
| 18 |
+
String(String),
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/// Epsilon for floating point comparison to handle near-zero costs
|
| 22 |
+
const COST_EPSILON: f64 = 1e-9;
|
| 23 |
+
|
| 24 |
+
/// Checks if a cost value is non-zero considering floating point precision
|
| 25 |
+
#[inline]
|
| 26 |
+
fn is_non_zero_cost(cost: &f64) -> bool {
|
| 27 |
+
cost.abs() > COST_EPSILON
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 31 |
+
#[serde(untagged)]
|
| 32 |
+
pub enum Response {
|
| 33 |
+
Success {
|
| 34 |
+
id: String,
|
| 35 |
+
provider: Option<String>,
|
| 36 |
+
#[serde(default)]
|
| 37 |
+
model: Option<String>,
|
| 38 |
+
choices: Vec<Choice>,
|
| 39 |
+
#[serde(default)]
|
| 40 |
+
created: u64,
|
| 41 |
+
object: Option<String>,
|
| 42 |
+
system_fingerprint: Option<String>,
|
| 43 |
+
usage: Option<ResponseUsage>,
|
| 44 |
+
#[serde(default)]
|
| 45 |
+
prompt_filter_results: Option<Vec<PromptFilterResult>>,
|
| 46 |
+
},
|
| 47 |
+
CostOnly {
|
| 48 |
+
choices: Vec<Choice>,
|
| 49 |
+
cost: Option<StringOrF64>,
|
| 50 |
+
},
|
| 51 |
+
Failure {
|
| 52 |
+
error: ErrorResponse,
|
| 53 |
+
},
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 57 |
+
pub struct PromptFilterResult {
|
| 58 |
+
pub prompt_index: u32,
|
| 59 |
+
pub content_filter_results: ContentFilterResults,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 63 |
+
pub struct ContentFilterResults {
|
| 64 |
+
pub hate: Option<FilterResult>,
|
| 65 |
+
pub self_harm: Option<FilterResult>,
|
| 66 |
+
pub sexual: Option<FilterResult>,
|
| 67 |
+
pub violence: Option<FilterResult>,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 71 |
+
pub struct FilterResult {
|
| 72 |
+
pub filtered: bool,
|
| 73 |
+
pub severity: String,
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 77 |
+
pub struct ResponseUsage {
|
| 78 |
+
pub prompt_tokens: usize,
|
| 79 |
+
pub completion_tokens: usize,
|
| 80 |
+
pub total_tokens: usize,
|
| 81 |
+
pub cost: Option<f64>,
|
| 82 |
+
pub prompt_tokens_details: Option<PromptTokenDetails>,
|
| 83 |
+
pub cost_details: Option<CostDetails>,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 87 |
+
pub struct CostDetails {
|
| 88 |
+
pub upstream_inference_cost: Option<f64>,
|
| 89 |
+
pub upstream_inference_prompt_cost: Option<f64>,
|
| 90 |
+
pub upstream_inference_completions_cost: Option<f64>,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
impl CostDetails {
|
| 94 |
+
fn total_cost(&self) -> Option<f64> {
|
| 95 |
+
self.upstream_inference_cost
|
| 96 |
+
.filter(is_non_zero_cost)
|
| 97 |
+
.or({
|
| 98 |
+
match (
|
| 99 |
+
self.upstream_inference_prompt_cost,
|
| 100 |
+
self.upstream_inference_completions_cost,
|
| 101 |
+
) {
|
| 102 |
+
(None, None) => None,
|
| 103 |
+
(Some(p), None) => Some(p),
|
| 104 |
+
(None, Some(c)) => Some(c),
|
| 105 |
+
(Some(p), Some(c)) => Some(p + c),
|
| 106 |
+
}
|
| 107 |
+
})
|
| 108 |
+
.filter(is_non_zero_cost)
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 113 |
+
pub struct PromptTokenDetails {
|
| 114 |
+
pub cached_tokens: usize,
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 118 |
+
#[serde(untagged)]
|
| 119 |
+
pub enum Choice {
|
| 120 |
+
NonChat {
|
| 121 |
+
finish_reason: Option<String>,
|
| 122 |
+
text: String,
|
| 123 |
+
error: Option<ErrorResponse>,
|
| 124 |
+
},
|
| 125 |
+
NonStreaming {
|
| 126 |
+
logprobs: Option<serde_json::Value>,
|
| 127 |
+
index: u32,
|
| 128 |
+
finish_reason: Option<String>,
|
| 129 |
+
message: ResponseMessage,
|
| 130 |
+
error: Option<ErrorResponse>,
|
| 131 |
+
},
|
| 132 |
+
Streaming {
|
| 133 |
+
finish_reason: Option<String>,
|
| 134 |
+
delta: ResponseMessage,
|
| 135 |
+
error: Option<ErrorResponse>,
|
| 136 |
+
},
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/// A message returned by a provider, used for both streaming deltas and
|
| 140 |
+
/// non-streaming responses.
|
| 141 |
+
///
|
| 142 |
+
/// `reasoning` and `reasoning_content` are kept as separate private fields
|
| 143 |
+
/// because some providers (e.g. `moonshotai/Kimi-K2.5-TEE`) emit **both**
|
| 144 |
+
/// keys in the same delta object. Using `#[serde(alias)]` would cause a
|
| 145 |
+
/// `duplicate_field` error in that case. Use [`ResponseMessage::reasoning`]
|
| 146 |
+
/// to read the value in preference order.
|
| 147 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 148 |
+
pub struct ResponseMessage {
|
| 149 |
+
pub content: Option<String>,
|
| 150 |
+
// Private: some providers (e.g. moonshotai/Kimi-K2.5-TEE) emit both keys
|
| 151 |
+
// in the same delta object. Exposing them directly would let callers
|
| 152 |
+
// accidentally read only one and miss the other. Use `reasoning()` instead,
|
| 153 |
+
// which merges them in preference order.
|
| 154 |
+
reasoning: Option<String>,
|
| 155 |
+
reasoning_content: Option<String>,
|
| 156 |
+
pub role: Option<String>,
|
| 157 |
+
pub tool_calls: Option<Vec<ToolCall>>,
|
| 158 |
+
pub refusal: Option<String>,
|
| 159 |
+
pub reasoning_details: Option<Vec<ReasoningDetail>>,
|
| 160 |
+
// GitHub Copilot format (flat fields instead of array)
|
| 161 |
+
pub reasoning_text: Option<String>,
|
| 162 |
+
pub reasoning_opaque: Option<String>,
|
| 163 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 164 |
+
pub extra_content: Option<ExtraContent>,
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
impl ResponseMessage {
|
| 168 |
+
/// Returns the reasoning text. When both `reasoning` and
|
| 169 |
+
/// `reasoning_content` are present, the longer non-empty value is
|
| 170 |
+
/// returned; otherwise whichever is non-empty is used.
|
| 171 |
+
pub fn reasoning(&self) -> Option<&str> {
|
| 172 |
+
match (self.reasoning.as_deref(), self.reasoning_content.as_deref()) {
|
| 173 |
+
(Some(a), Some(b)) => {
|
| 174 |
+
let a = a.trim();
|
| 175 |
+
let b = b.trim();
|
| 176 |
+
match (a.is_empty(), b.is_empty()) {
|
| 177 |
+
(true, _) => Some(b).filter(|s| !s.is_empty()),
|
| 178 |
+
(_, true) => Some(a).filter(|s| !s.is_empty()),
|
| 179 |
+
_ => Some(if b.len() > a.len() { b } else { a }),
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
(Some(a), None) => Some(a).filter(|s| !s.trim().is_empty()),
|
| 183 |
+
(None, Some(b)) => Some(b).filter(|s| !s.trim().is_empty()),
|
| 184 |
+
(None, None) => None,
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
impl From<ReasoningDetail> for forge_domain::ReasoningDetail {
|
| 190 |
+
fn from(detail: ReasoningDetail) -> Self {
|
| 191 |
+
forge_domain::ReasoningDetail {
|
| 192 |
+
text: detail.text,
|
| 193 |
+
signature: detail.signature,
|
| 194 |
+
data: detail.data,
|
| 195 |
+
id: detail.id,
|
| 196 |
+
format: detail.format,
|
| 197 |
+
index: detail.index,
|
| 198 |
+
type_of: Some(detail.r#type),
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/// Google-specific metadata for Vertex AI thought signatures
|
| 204 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 205 |
+
pub struct GoogleMetadata {
|
| 206 |
+
pub thought_signature: Option<String>,
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/// Extra content that may be included by certain providers (e.g., Vertex AI)
|
| 210 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 211 |
+
pub struct ExtraContent {
|
| 212 |
+
pub google: Option<GoogleMetadata>,
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
impl ExtraContent {
|
| 216 |
+
/// Extracts the thought_signature from the extra content if present
|
| 217 |
+
pub fn thought_signature(&self) -> Option<String> {
|
| 218 |
+
self.google
|
| 219 |
+
.as_ref()
|
| 220 |
+
.and_then(|g| g.thought_signature.clone())
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
impl From<String> for ExtraContent {
|
| 225 |
+
fn from(thought_signature: String) -> Self {
|
| 226 |
+
Self {
|
| 227 |
+
google: Some(GoogleMetadata { thought_signature: Some(thought_signature) }),
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 233 |
+
pub struct ToolCall {
|
| 234 |
+
pub id: Option<ToolCallId>,
|
| 235 |
+
pub r#type: FunctionType,
|
| 236 |
+
pub function: FunctionCall,
|
| 237 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 238 |
+
pub extra_content: Option<ExtraContent>,
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
#[derive(Debug, Deserialize, Serialize, Clone)]
|
| 242 |
+
pub struct FunctionCall {
|
| 243 |
+
// Only the first event typically has the name of the function call
|
| 244 |
+
pub name: Option<ToolName>,
|
| 245 |
+
#[serde(default)]
|
| 246 |
+
pub arguments: String,
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
impl From<ResponseUsage> for Usage {
|
| 250 |
+
fn from(usage: ResponseUsage) -> Self {
|
| 251 |
+
let cost = usage
|
| 252 |
+
.cost
|
| 253 |
+
.filter(is_non_zero_cost)
|
| 254 |
+
.or_else(|| {
|
| 255 |
+
usage
|
| 256 |
+
.cost_details
|
| 257 |
+
.as_ref()
|
| 258 |
+
.and_then(CostDetails::total_cost)
|
| 259 |
+
})
|
| 260 |
+
.filter(is_non_zero_cost);
|
| 261 |
+
|
| 262 |
+
Usage {
|
| 263 |
+
prompt_tokens: TokenCount::Actual(usage.prompt_tokens),
|
| 264 |
+
completion_tokens: TokenCount::Actual(usage.completion_tokens),
|
| 265 |
+
total_tokens: TokenCount::Actual(usage.total_tokens),
|
| 266 |
+
cached_tokens: usage
|
| 267 |
+
.prompt_tokens_details
|
| 268 |
+
.map(|token_details| TokenCount::Actual(token_details.cached_tokens))
|
| 269 |
+
.unwrap_or_default(),
|
| 270 |
+
cost,
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/// Intermediate representation of GitHub Copilot reasoning fields
|
| 276 |
+
struct GitHubCopilotReasoning {
|
| 277 |
+
text: Option<String>,
|
| 278 |
+
data: Option<String>,
|
| 279 |
+
r#type: Option<String>,
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
impl GitHubCopilotReasoning {
|
| 283 |
+
fn into_reasoning_detail(self) -> forge_domain::ReasoningDetail {
|
| 284 |
+
forge_domain::ReasoningDetail {
|
| 285 |
+
text: self.text,
|
| 286 |
+
data: self.data,
|
| 287 |
+
type_of: self.r#type,
|
| 288 |
+
..Default::default()
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
/// Converts GitHub Copilot flat reasoning fields to structured reasoning
|
| 294 |
+
/// details
|
| 295 |
+
fn convert_github_copilot_reasoning(
|
| 296 |
+
reasoning_text: &Option<String>,
|
| 297 |
+
reasoning_opaque: &Option<String>,
|
| 298 |
+
) -> Option<Vec<GitHubCopilotReasoning>> {
|
| 299 |
+
if reasoning_text.is_some() || reasoning_opaque.is_some() {
|
| 300 |
+
let mut details = Vec::new();
|
| 301 |
+
if let Some(text) = reasoning_text {
|
| 302 |
+
details.push(GitHubCopilotReasoning {
|
| 303 |
+
text: Some(text.clone()),
|
| 304 |
+
data: None,
|
| 305 |
+
r#type: Some("reasoning.text".to_string()),
|
| 306 |
+
});
|
| 307 |
+
}
|
| 308 |
+
if let Some(opaque) = reasoning_opaque {
|
| 309 |
+
details.push(GitHubCopilotReasoning {
|
| 310 |
+
text: None,
|
| 311 |
+
data: Some(opaque.clone()),
|
| 312 |
+
r#type: Some("reasoning.encrypted".to_string()),
|
| 313 |
+
});
|
| 314 |
+
}
|
| 315 |
+
Some(details)
|
| 316 |
+
} else {
|
| 317 |
+
None
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
impl TryFrom<Response> for ChatCompletionMessage {
|
| 322 |
+
type Error = anyhow::Error;
|
| 323 |
+
|
| 324 |
+
fn try_from(res: Response) -> Result<Self, Self::Error> {
|
| 325 |
+
match res {
|
| 326 |
+
Response::Success { choices, usage, prompt_filter_results, .. } => {
|
| 327 |
+
if let Some(choice) = choices.first() {
|
| 328 |
+
// Check if the choice has an error first
|
| 329 |
+
let error = match choice {
|
| 330 |
+
Choice::NonChat { error, .. } => error,
|
| 331 |
+
Choice::NonStreaming { error, .. } => error,
|
| 332 |
+
Choice::Streaming { error, .. } => error,
|
| 333 |
+
};
|
| 334 |
+
|
| 335 |
+
if let Some(error) = error {
|
| 336 |
+
return Err(Error::Response(error.clone()).into());
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
let mut response = match choice {
|
| 340 |
+
Choice::NonChat { text, finish_reason, .. } => {
|
| 341 |
+
ChatCompletionMessage::assistant(Content::full(text)).finish_reason_opt(
|
| 342 |
+
finish_reason
|
| 343 |
+
.clone()
|
| 344 |
+
.and_then(|s| FinishReason::from_str(&s).ok()),
|
| 345 |
+
)
|
| 346 |
+
}
|
| 347 |
+
Choice::NonStreaming { message, finish_reason, .. } => {
|
| 348 |
+
let mut resp = ChatCompletionMessage::assistant(Content::full(
|
| 349 |
+
message.content.clone().unwrap_or_default(),
|
| 350 |
+
))
|
| 351 |
+
.finish_reason_opt(
|
| 352 |
+
finish_reason
|
| 353 |
+
.clone()
|
| 354 |
+
.and_then(|s| FinishReason::from_str(&s).ok()),
|
| 355 |
+
);
|
| 356 |
+
if let Some(reasoning) = message.reasoning() {
|
| 357 |
+
resp = resp.reasoning(Content::full(reasoning.to_owned()));
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
if let Some(thought_signature) = message
|
| 361 |
+
.extra_content
|
| 362 |
+
.as_ref()
|
| 363 |
+
.and_then(ExtraContent::thought_signature)
|
| 364 |
+
{
|
| 365 |
+
resp = resp.thought_signature(thought_signature);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
if let Some(reasoning_details) = &message.reasoning_details {
|
| 369 |
+
let converted_details: Vec<forge_domain::ReasoningFull> =
|
| 370 |
+
reasoning_details
|
| 371 |
+
.clone()
|
| 372 |
+
.into_iter()
|
| 373 |
+
.map(forge_domain::ReasoningFull::from)
|
| 374 |
+
.collect();
|
| 375 |
+
|
| 376 |
+
resp = resp.add_reasoning_detail(forge_domain::Reasoning::Full(
|
| 377 |
+
converted_details,
|
| 378 |
+
));
|
| 379 |
+
} else if let Some(details) = convert_github_copilot_reasoning(
|
| 380 |
+
&message.reasoning_text,
|
| 381 |
+
&message.reasoning_opaque,
|
| 382 |
+
) {
|
| 383 |
+
resp = resp.add_reasoning_detail(forge_domain::Reasoning::Full(
|
| 384 |
+
details
|
| 385 |
+
.into_iter()
|
| 386 |
+
.map(GitHubCopilotReasoning::into_reasoning_detail)
|
| 387 |
+
.collect(),
|
| 388 |
+
));
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
if let Some(tool_calls) = &message.tool_calls {
|
| 392 |
+
for tool_call in tool_calls {
|
| 393 |
+
let thought_signature = tool_call
|
| 394 |
+
.extra_content
|
| 395 |
+
.as_ref()
|
| 396 |
+
.and_then(ExtraContent::thought_signature);
|
| 397 |
+
|
| 398 |
+
resp = resp.add_tool_call(ToolCallFull {
|
| 399 |
+
call_id: tool_call.id.clone(),
|
| 400 |
+
name: tool_call
|
| 401 |
+
.function
|
| 402 |
+
.name
|
| 403 |
+
.clone()
|
| 404 |
+
.ok_or(forge_domain::Error::ToolCallMissingName)?,
|
| 405 |
+
arguments: serde_json::from_str(
|
| 406 |
+
&tool_call.function.arguments,
|
| 407 |
+
)?,
|
| 408 |
+
thought_signature,
|
| 409 |
+
});
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
resp
|
| 413 |
+
}
|
| 414 |
+
Choice::Streaming { delta, finish_reason, .. } => {
|
| 415 |
+
let mut resp = ChatCompletionMessage::assistant(Content::part(
|
| 416 |
+
delta.content.clone().unwrap_or_default(),
|
| 417 |
+
))
|
| 418 |
+
.finish_reason_opt(
|
| 419 |
+
finish_reason
|
| 420 |
+
.clone()
|
| 421 |
+
.and_then(|s| FinishReason::from_str(&s).ok()),
|
| 422 |
+
);
|
| 423 |
+
|
| 424 |
+
if let Some(reasoning) = delta.reasoning() {
|
| 425 |
+
resp = resp.reasoning(Content::part(reasoning.to_owned()));
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
if let Some(thought_signature) = delta
|
| 429 |
+
.extra_content
|
| 430 |
+
.as_ref()
|
| 431 |
+
.and_then(ExtraContent::thought_signature)
|
| 432 |
+
{
|
| 433 |
+
resp = resp.thought_signature(thought_signature);
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
if let Some(reasoning_details) = &delta.reasoning_details {
|
| 437 |
+
let converted_details: Vec<forge_domain::ReasoningPart> =
|
| 438 |
+
reasoning_details
|
| 439 |
+
.clone()
|
| 440 |
+
.into_iter()
|
| 441 |
+
.map(forge_domain::ReasoningPart::from)
|
| 442 |
+
.collect();
|
| 443 |
+
resp = resp.add_reasoning_detail(forge_domain::Reasoning::Part(
|
| 444 |
+
converted_details,
|
| 445 |
+
));
|
| 446 |
+
} else if let Some(details) = convert_github_copilot_reasoning(
|
| 447 |
+
&delta.reasoning_text,
|
| 448 |
+
&delta.reasoning_opaque,
|
| 449 |
+
) {
|
| 450 |
+
resp = resp.add_reasoning_detail(forge_domain::Reasoning::Part(
|
| 451 |
+
details
|
| 452 |
+
.into_iter()
|
| 453 |
+
.map(GitHubCopilotReasoning::into_reasoning_detail)
|
| 454 |
+
.collect(),
|
| 455 |
+
));
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
if let Some(tool_calls) = &delta.tool_calls {
|
| 459 |
+
for tool_call in tool_calls {
|
| 460 |
+
let thought_signature = tool_call
|
| 461 |
+
.extra_content
|
| 462 |
+
.as_ref()
|
| 463 |
+
.and_then(ExtraContent::thought_signature);
|
| 464 |
+
|
| 465 |
+
resp = resp.add_tool_call(ToolCallPart {
|
| 466 |
+
call_id: tool_call.id.clone(),
|
| 467 |
+
name: tool_call.function.name.clone(),
|
| 468 |
+
arguments_part: tool_call.function.arguments.clone(),
|
| 469 |
+
thought_signature,
|
| 470 |
+
});
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
resp
|
| 474 |
+
}
|
| 475 |
+
};
|
| 476 |
+
|
| 477 |
+
if let Some(usage) = usage {
|
| 478 |
+
response.usage = Some(usage.into());
|
| 479 |
+
}
|
| 480 |
+
Ok(response)
|
| 481 |
+
} else {
|
| 482 |
+
// Check if content was filtered
|
| 483 |
+
if let Some(filter_results) = prompt_filter_results
|
| 484 |
+
&& let Some(filter_result) = filter_results.first()
|
| 485 |
+
{
|
| 486 |
+
let filtered_categories: Vec<String> = [
|
| 487 |
+
filter_result
|
| 488 |
+
.content_filter_results
|
| 489 |
+
.hate
|
| 490 |
+
.as_ref()
|
| 491 |
+
.filter(|f| f.filtered)
|
| 492 |
+
.map(|_| "hate"),
|
| 493 |
+
filter_result
|
| 494 |
+
.content_filter_results
|
| 495 |
+
.self_harm
|
| 496 |
+
.as_ref()
|
| 497 |
+
.filter(|f| f.filtered)
|
| 498 |
+
.map(|_| "self_harm"),
|
| 499 |
+
filter_result
|
| 500 |
+
.content_filter_results
|
| 501 |
+
.sexual
|
| 502 |
+
.as_ref()
|
| 503 |
+
.filter(|f| f.filtered)
|
| 504 |
+
.map(|_| "sexual"),
|
| 505 |
+
filter_result
|
| 506 |
+
.content_filter_results
|
| 507 |
+
.violence
|
| 508 |
+
.as_ref()
|
| 509 |
+
.filter(|f| f.filtered)
|
| 510 |
+
.map(|_| "violence"),
|
| 511 |
+
]
|
| 512 |
+
.into_iter()
|
| 513 |
+
.flatten()
|
| 514 |
+
.map(String::from)
|
| 515 |
+
.collect();
|
| 516 |
+
|
| 517 |
+
if !filtered_categories.is_empty() {
|
| 518 |
+
let error = ErrorResponse::default()
|
| 519 |
+
.message(format!(
|
| 520 |
+
"Content was filtered due to: {}",
|
| 521 |
+
filtered_categories.join(", ")
|
| 522 |
+
))
|
| 523 |
+
.code(ErrorCode::String("content_filter".to_string()));
|
| 524 |
+
return Err(Error::Response(error).into());
|
| 525 |
+
}
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
let mut default_response = ChatCompletionMessage::assistant(Content::full(""));
|
| 529 |
+
// No choices – this can happen with Ollama/LMStudio streaming where the final
|
| 530 |
+
// chunk only contains usage information.
|
| 531 |
+
if let Some(u) = usage {
|
| 532 |
+
default_response.usage = Some(u.into());
|
| 533 |
+
}
|
| 534 |
+
Ok(default_response)
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
Response::CostOnly { cost, .. } => {
|
| 538 |
+
let mut msg = ChatCompletionMessage::default();
|
| 539 |
+
if let Some(c) = cost {
|
| 540 |
+
let cost_value = match c {
|
| 541 |
+
StringOrF64::Number(n) => n,
|
| 542 |
+
StringOrF64::String(s) => s.parse().unwrap_or(0.0),
|
| 543 |
+
};
|
| 544 |
+
msg.usage = Some(Usage {
|
| 545 |
+
prompt_tokens: TokenCount::Actual(0),
|
| 546 |
+
completion_tokens: TokenCount::Actual(0),
|
| 547 |
+
total_tokens: TokenCount::Actual(0),
|
| 548 |
+
cached_tokens: TokenCount::Actual(0),
|
| 549 |
+
cost: Some(cost_value),
|
| 550 |
+
});
|
| 551 |
+
}
|
| 552 |
+
Ok(msg)
|
| 553 |
+
}
|
| 554 |
+
Response::Failure { error } => Err(Error::Response(error).into()),
|
| 555 |
+
}
|
| 556 |
+
}
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
#[cfg(test)]
|
| 560 |
+
mod tests {
|
| 561 |
+
use anyhow::Context;
|
| 562 |
+
use forge_domain::ChatCompletionMessage;
|
| 563 |
+
|
| 564 |
+
use super::*;
|
| 565 |
+
|
| 566 |
+
struct Fixture;
|
| 567 |
+
|
| 568 |
+
fn response_message(
|
| 569 |
+
reasoning: Option<&str>,
|
| 570 |
+
reasoning_content: Option<&str>,
|
| 571 |
+
) -> ResponseMessage {
|
| 572 |
+
ResponseMessage {
|
| 573 |
+
content: None,
|
| 574 |
+
reasoning: reasoning.map(str::to_owned),
|
| 575 |
+
reasoning_content: reasoning_content.map(str::to_owned),
|
| 576 |
+
role: None,
|
| 577 |
+
tool_calls: None,
|
| 578 |
+
refusal: None,
|
| 579 |
+
reasoning_details: None,
|
| 580 |
+
reasoning_text: None,
|
| 581 |
+
reasoning_opaque: None,
|
| 582 |
+
extra_content: None,
|
| 583 |
+
}
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
#[test]
|
| 587 |
+
fn test_reasoning_only_reasoning_field() {
|
| 588 |
+
let fixture = response_message(Some("hello"), None);
|
| 589 |
+
assert_eq!(fixture.reasoning(), Some("hello"));
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
#[test]
|
| 593 |
+
fn test_reasoning_only_reasoning_content_field() {
|
| 594 |
+
let fixture = response_message(None, Some("hello"));
|
| 595 |
+
assert_eq!(fixture.reasoning(), Some("hello"));
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
#[test]
|
| 599 |
+
fn test_reasoning_both_returns_longer() {
|
| 600 |
+
let fixture = response_message(Some("short"), Some("much longer text"));
|
| 601 |
+
assert_eq!(fixture.reasoning(), Some("much longer text"));
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
#[test]
|
| 605 |
+
fn test_reasoning_both_equal_length_returns_reasoning() {
|
| 606 |
+
let fixture = response_message(Some("aaa"), Some("bbb"));
|
| 607 |
+
assert_eq!(fixture.reasoning(), Some("aaa"));
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
#[test]
|
| 611 |
+
fn test_reasoning_both_present_one_empty_returns_non_empty() {
|
| 612 |
+
let fixture = response_message(Some(""), Some("content"));
|
| 613 |
+
assert_eq!(fixture.reasoning(), Some("content"));
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
#[test]
|
| 617 |
+
fn test_reasoning_both_empty_returns_none() {
|
| 618 |
+
let fixture = response_message(Some(""), Some(""));
|
| 619 |
+
assert_eq!(fixture.reasoning(), None);
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
#[test]
|
| 623 |
+
fn test_reasoning_neither_present_returns_none() {
|
| 624 |
+
let fixture = response_message(None, None);
|
| 625 |
+
assert_eq!(fixture.reasoning(), None);
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
async fn load_fixture(filename: &str) -> serde_json::Value {
|
| 629 |
+
let fixture_path = format!("src/dto/openai/fixtures/{}", filename);
|
| 630 |
+
let fixture_content = tokio::fs::read_to_string(&fixture_path)
|
| 631 |
+
.await
|
| 632 |
+
.unwrap_or_else(|_| panic!("Failed to read fixture file: {}", fixture_path));
|
| 633 |
+
serde_json::from_str(&fixture_content)
|
| 634 |
+
.unwrap_or_else(|_| panic!("Failed to parse JSON fixture: {}", fixture_path))
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
impl Fixture {
|
| 638 |
+
// check if the response is compatible with the
|
| 639 |
+
fn test_response_compatibility(message: &str) -> bool {
|
| 640 |
+
let response = serde_json::from_str::<Response>(message)
|
| 641 |
+
.with_context(|| format!("Failed to parse response: {message}"))
|
| 642 |
+
.and_then(|event| {
|
| 643 |
+
ChatCompletionMessage::try_from(event.clone())
|
| 644 |
+
.with_context(|| "Failed to create completion message")
|
| 645 |
+
});
|
| 646 |
+
response.is_ok()
|
| 647 |
+
}
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
#[test]
|
| 651 |
+
fn test_open_ai_response_event() {
|
| 652 |
+
let event = "{\"id\":\"chatcmpl-B2YVxGR9TaLBrEcFMVCv2B4IcNe4g\",\"object\":\"chat.completion.chunk\",\"created\":1739949029,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_00428b782a\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_fmuXMsHhKD5eM2k0CvgNed53\",\"type\":\"function\",\"function\":{\"name\":\"shell\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}";
|
| 653 |
+
assert!(Fixture::test_response_compatibility(event));
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
#[test]
|
| 657 |
+
fn test_forge_response_event() {
|
| 658 |
+
let event = "{\"id\":\"gen-1739949430-JZMcABaj4fg8oFDtRNDZ\",\"provider\":\"OpenAI\",\"model\":\"openai/gpt-4o-mini\",\"object\":\"chat.completion.chunk\",\"created\":1739949430,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_bhjvz9w48ov4DSRhM15qLMmh\",\"type\":\"function\",\"function\":{\"name\":\"shell\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null,\"native_finish_reason\":null}],\"system_fingerprint\":\"fp_00428b782a\"}";
|
| 659 |
+
assert!(Fixture::test_response_compatibility(event));
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
#[test]
|
| 663 |
+
fn test_reasoning_response_event() {
|
| 664 |
+
let event = "{\"id\":\"gen-1751626123-nYRpHzdA0thRXF0LoQi0\",\"provider\":\"Google\",\"model\":\"anthropic/claude-3.7-sonnet:thinking\",\"object\":\"chat.completion.chunk\",\"created\":1751626123,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning\":\"I need to check\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"I need to check\"}]},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}]}";
|
| 665 |
+
assert!(Fixture::test_response_compatibility(event));
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
#[tokio::test]
|
| 669 |
+
async fn test_kimi_k2_both_reasoning_keys_event() {
|
| 670 |
+
// moonshotai/Kimi-K2.5-TEE emits both "reasoning" and "reasoning_content"
|
| 671 |
+
// in the same delta object. This must parse without a duplicate_field error.
|
| 672 |
+
let fixture = load_fixture("chutes_completion_response.json").await;
|
| 673 |
+
let actual = serde_json::from_value::<Response>(fixture);
|
| 674 |
+
assert!(actual.is_ok(), "Failed to parse: {:?}", actual.err());
|
| 675 |
+
let completion_result = ChatCompletionMessage::try_from(actual.unwrap());
|
| 676 |
+
assert!(completion_result.is_ok());
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
#[test]
|
| 680 |
+
fn test_fireworks_response_event_missing_arguments() {
|
| 681 |
+
let event = "{\"id\":\"gen-1749331907-SttL6PXleVHnrdLMABfU\",\"provider\":\"Fireworks\",\"model\":\"qwen/qwen3-235b-a22b\",\"object\":\"chat.completion.chunk\",\"created\":1749331907,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_Wl2L8rrzHwrXSeiciIvU65IS\",\"type\":\"function\",\"function\":{\"name\":\"attempt_completion\"}}]},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}]}";
|
| 682 |
+
assert!(Fixture::test_response_compatibility(event));
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
#[tokio::test]
|
| 686 |
+
async fn test_responses() -> anyhow::Result<()> {
|
| 687 |
+
let content = forge_test_kit::fixture!("/src/dto/openai/responses.jsonl").await;
|
| 688 |
+
|
| 689 |
+
for (i, line) in content.split('\n').enumerate() {
|
| 690 |
+
let i = i + 1;
|
| 691 |
+
let _: Response = serde_json::from_str(line).with_context(|| {
|
| 692 |
+
format!("Failed to parse response [responses.jsonl:{i}]: {line}")
|
| 693 |
+
})?;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
Ok(())
|
| 697 |
+
}
|
| 698 |
+
#[test]
|
| 699 |
+
fn test_choice_error_handling_non_chat() {
|
| 700 |
+
let error_response = ErrorResponse::default().message("Test error message".to_string());
|
| 701 |
+
|
| 702 |
+
let response = Response::Success {
|
| 703 |
+
id: "test-id".to_string(),
|
| 704 |
+
provider: Some("test".to_string()),
|
| 705 |
+
model: Some("test-model".to_string()),
|
| 706 |
+
choices: vec![Choice::NonChat {
|
| 707 |
+
text: "test content".to_string(),
|
| 708 |
+
finish_reason: None,
|
| 709 |
+
error: Some(error_response.clone()),
|
| 710 |
+
}],
|
| 711 |
+
created: 123456789,
|
| 712 |
+
object: Some("chat.completion".to_string()),
|
| 713 |
+
system_fingerprint: None,
|
| 714 |
+
usage: None,
|
| 715 |
+
prompt_filter_results: None,
|
| 716 |
+
};
|
| 717 |
+
|
| 718 |
+
let result = ChatCompletionMessage::try_from(response);
|
| 719 |
+
assert!(result.is_err());
|
| 720 |
+
let error = result.unwrap_err();
|
| 721 |
+
let error_string = format!("{:?}", error);
|
| 722 |
+
assert!(error_string.contains("Test error message"));
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
#[test]
|
| 726 |
+
fn test_choice_error_handling_non_streaming() {
|
| 727 |
+
let error_response = ErrorResponse::default().message("API limit exceeded".to_string());
|
| 728 |
+
|
| 729 |
+
let response = Response::Success {
|
| 730 |
+
id: "test-id".to_string(),
|
| 731 |
+
provider: Some("test".to_string()),
|
| 732 |
+
model: Some("test-model".to_string()),
|
| 733 |
+
choices: vec![Choice::NonStreaming {
|
| 734 |
+
logprobs: None,
|
| 735 |
+
index: 0,
|
| 736 |
+
finish_reason: None,
|
| 737 |
+
message: ResponseMessage {
|
| 738 |
+
content: Some("test content".to_string()),
|
| 739 |
+
reasoning: None,
|
| 740 |
+
reasoning_content: None,
|
| 741 |
+
role: Some("assistant".to_string()),
|
| 742 |
+
tool_calls: None,
|
| 743 |
+
refusal: None,
|
| 744 |
+
reasoning_details: None,
|
| 745 |
+
reasoning_text: None,
|
| 746 |
+
reasoning_opaque: None,
|
| 747 |
+
extra_content: None,
|
| 748 |
+
},
|
| 749 |
+
error: Some(error_response.clone()),
|
| 750 |
+
}],
|
| 751 |
+
created: 123456789,
|
| 752 |
+
object: Some("chat.completion".to_string()),
|
| 753 |
+
system_fingerprint: None,
|
| 754 |
+
usage: None,
|
| 755 |
+
prompt_filter_results: None,
|
| 756 |
+
};
|
| 757 |
+
|
| 758 |
+
let result = ChatCompletionMessage::try_from(response);
|
| 759 |
+
assert!(result.is_err());
|
| 760 |
+
let error = result.unwrap_err();
|
| 761 |
+
let error_string = format!("{:?}", error);
|
| 762 |
+
assert!(error_string.contains("API limit exceeded"));
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
#[test]
|
| 766 |
+
fn test_choice_error_handling_streaming() {
|
| 767 |
+
let error_response = ErrorResponse::default().message("Stream interrupted".to_string());
|
| 768 |
+
|
| 769 |
+
let response = Response::Success {
|
| 770 |
+
id: "test-id".to_string(),
|
| 771 |
+
provider: Some("test".to_string()),
|
| 772 |
+
model: Some("test-model".to_string()),
|
| 773 |
+
choices: vec![Choice::Streaming {
|
| 774 |
+
finish_reason: None,
|
| 775 |
+
delta: ResponseMessage {
|
| 776 |
+
content: Some("test content".to_string()),
|
| 777 |
+
reasoning: None,
|
| 778 |
+
reasoning_content: None,
|
| 779 |
+
role: Some("assistant".to_string()),
|
| 780 |
+
tool_calls: None,
|
| 781 |
+
refusal: None,
|
| 782 |
+
reasoning_details: None,
|
| 783 |
+
reasoning_text: None,
|
| 784 |
+
reasoning_opaque: None,
|
| 785 |
+
extra_content: None,
|
| 786 |
+
},
|
| 787 |
+
error: Some(error_response.clone()),
|
| 788 |
+
}],
|
| 789 |
+
created: 123456789,
|
| 790 |
+
object: Some("chat.completion".to_string()),
|
| 791 |
+
system_fingerprint: None,
|
| 792 |
+
usage: None,
|
| 793 |
+
prompt_filter_results: None,
|
| 794 |
+
};
|
| 795 |
+
|
| 796 |
+
let result = ChatCompletionMessage::try_from(response);
|
| 797 |
+
assert!(result.is_err());
|
| 798 |
+
let error = result.unwrap_err();
|
| 799 |
+
let error_string = format!("{:?}", error);
|
| 800 |
+
assert!(error_string.contains("Stream interrupted"));
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
#[test]
|
| 804 |
+
fn test_choice_no_error_success() {
|
| 805 |
+
let response = Response::Success {
|
| 806 |
+
id: "test-id".to_string(),
|
| 807 |
+
provider: Some("test".to_string()),
|
| 808 |
+
model: Some("test-model".to_string()),
|
| 809 |
+
choices: vec![Choice::NonStreaming {
|
| 810 |
+
logprobs: None,
|
| 811 |
+
index: 0,
|
| 812 |
+
finish_reason: Some("stop".to_string()),
|
| 813 |
+
message: ResponseMessage {
|
| 814 |
+
content: Some("Hello, world!".to_string()),
|
| 815 |
+
reasoning: None,
|
| 816 |
+
reasoning_content: None,
|
| 817 |
+
role: Some("assistant".to_string()),
|
| 818 |
+
tool_calls: None,
|
| 819 |
+
refusal: None,
|
| 820 |
+
reasoning_details: None,
|
| 821 |
+
reasoning_text: None,
|
| 822 |
+
reasoning_opaque: None,
|
| 823 |
+
extra_content: None,
|
| 824 |
+
},
|
| 825 |
+
error: None,
|
| 826 |
+
}],
|
| 827 |
+
created: 123456789,
|
| 828 |
+
object: Some("chat.completion".to_string()),
|
| 829 |
+
system_fingerprint: None,
|
| 830 |
+
usage: None,
|
| 831 |
+
prompt_filter_results: None,
|
| 832 |
+
};
|
| 833 |
+
|
| 834 |
+
let result = ChatCompletionMessage::try_from(response);
|
| 835 |
+
assert!(result.is_ok());
|
| 836 |
+
let message = result.unwrap();
|
| 837 |
+
assert_eq!(message.content.unwrap().as_str(), "Hello, world!");
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
#[test]
|
| 841 |
+
fn test_empty_choices_no_error() {
|
| 842 |
+
let response = Response::Success {
|
| 843 |
+
id: "test-id".to_string(),
|
| 844 |
+
provider: Some("test".to_string()),
|
| 845 |
+
model: Some("test-model".to_string()),
|
| 846 |
+
choices: vec![],
|
| 847 |
+
created: 123456789,
|
| 848 |
+
object: Some("chat.completion".to_string()),
|
| 849 |
+
system_fingerprint: None,
|
| 850 |
+
usage: None,
|
| 851 |
+
prompt_filter_results: None,
|
| 852 |
+
};
|
| 853 |
+
|
| 854 |
+
let result = ChatCompletionMessage::try_from(response);
|
| 855 |
+
assert!(result.is_ok());
|
| 856 |
+
let message = result.unwrap();
|
| 857 |
+
assert_eq!(message.content.unwrap().as_str(), "");
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
#[test]
|
| 861 |
+
fn test_cost_only_response_parses_and_returns_empty_message() {
|
| 862 |
+
let fixture = r#"{"choices":[],"cost":"0"}"#;
|
| 863 |
+
let actual = serde_json::from_str::<Response>(fixture).unwrap();
|
| 864 |
+
|
| 865 |
+
let actual = ChatCompletionMessage::try_from(actual).unwrap();
|
| 866 |
+
|
| 867 |
+
// CostOnly events now include the cost in the usage
|
| 868 |
+
let expected = ChatCompletionMessage::default().usage(Usage {
|
| 869 |
+
prompt_tokens: TokenCount::Actual(0),
|
| 870 |
+
completion_tokens: TokenCount::Actual(0),
|
| 871 |
+
total_tokens: TokenCount::Actual(0),
|
| 872 |
+
cached_tokens: TokenCount::Actual(0),
|
| 873 |
+
cost: Some(0.0),
|
| 874 |
+
});
|
| 875 |
+
assert_eq!(actual, expected);
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
#[test]
|
| 879 |
+
fn test_cost_only_response_numeric_cost_parses() {
|
| 880 |
+
let fixture = r#"{"choices":[],"cost":0.0}"#;
|
| 881 |
+
let actual = serde_json::from_str::<Response>(fixture);
|
| 882 |
+
|
| 883 |
+
assert!(actual.is_ok());
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
#[tokio::test]
|
| 887 |
+
async fn test_z_ai_response_compatibility() {
|
| 888 |
+
let fixture = load_fixture("zai_api_delta_response.json").await;
|
| 889 |
+
let actual = serde_json::from_value::<Response>(fixture);
|
| 890 |
+
|
| 891 |
+
assert!(actual.is_ok());
|
| 892 |
+
|
| 893 |
+
let response = actual.unwrap();
|
| 894 |
+
let completion_result = ChatCompletionMessage::try_from(response);
|
| 895 |
+
assert!(completion_result.is_ok());
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
+
#[tokio::test]
|
| 899 |
+
async fn test_z_ai_response_complete_with_usage() {
|
| 900 |
+
let fixture = load_fixture("zai_api_response.json").await;
|
| 901 |
+
let actual = serde_json::from_value::<Response>(fixture);
|
| 902 |
+
|
| 903 |
+
assert!(actual.is_ok());
|
| 904 |
+
|
| 905 |
+
let response = actual.unwrap();
|
| 906 |
+
let completion_result = ChatCompletionMessage::try_from(response);
|
| 907 |
+
assert!(completion_result.is_ok());
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
#[test]
|
| 911 |
+
fn test_response_usage_cost_priority_chain() {
|
| 912 |
+
// Priority 1: cost field (non-zero) beats everything
|
| 913 |
+
let fixture_cost_wins = ResponseUsage {
|
| 914 |
+
prompt_tokens: 100,
|
| 915 |
+
completion_tokens: 50,
|
| 916 |
+
total_tokens: 150,
|
| 917 |
+
cost: Some(0.001),
|
| 918 |
+
prompt_tokens_details: None,
|
| 919 |
+
cost_details: Some(CostDetails {
|
| 920 |
+
upstream_inference_cost: Some(0.005),
|
| 921 |
+
upstream_inference_prompt_cost: Some(0.003),
|
| 922 |
+
upstream_inference_completions_cost: Some(0.002),
|
| 923 |
+
}),
|
| 924 |
+
};
|
| 925 |
+
|
| 926 |
+
let actual: Usage = fixture_cost_wins.into();
|
| 927 |
+
assert_eq!(actual.cost, Some(0.001));
|
| 928 |
+
|
| 929 |
+
// Priority 2: upstream_inference_cost beats partial costs
|
| 930 |
+
let fixture_upstream_wins = ResponseUsage {
|
| 931 |
+
prompt_tokens: 100,
|
| 932 |
+
completion_tokens: 50,
|
| 933 |
+
total_tokens: 150,
|
| 934 |
+
cost: None,
|
| 935 |
+
prompt_tokens_details: None,
|
| 936 |
+
cost_details: Some(CostDetails {
|
| 937 |
+
upstream_inference_cost: Some(0.005),
|
| 938 |
+
upstream_inference_prompt_cost: Some(0.003),
|
| 939 |
+
upstream_inference_completions_cost: Some(0.002),
|
| 940 |
+
}),
|
| 941 |
+
};
|
| 942 |
+
|
| 943 |
+
let actual: Usage = fixture_upstream_wins.into();
|
| 944 |
+
assert_eq!(actual.cost, Some(0.005));
|
| 945 |
+
|
| 946 |
+
// Priority 3: partial costs are summed when upstream_inference_cost is None
|
| 947 |
+
let fixture_partial_sum = ResponseUsage {
|
| 948 |
+
prompt_tokens: 100,
|
| 949 |
+
completion_tokens: 50,
|
| 950 |
+
total_tokens: 150,
|
| 951 |
+
cost: None,
|
| 952 |
+
prompt_tokens_details: None,
|
| 953 |
+
cost_details: Some(CostDetails {
|
| 954 |
+
upstream_inference_cost: None,
|
| 955 |
+
upstream_inference_prompt_cost: Some(0.003),
|
| 956 |
+
upstream_inference_completions_cost: Some(0.002),
|
| 957 |
+
}),
|
| 958 |
+
};
|
| 959 |
+
|
| 960 |
+
let actual: Usage = fixture_partial_sum.into();
|
| 961 |
+
assert_eq!(actual.cost, Some(0.005));
|
| 962 |
+
|
| 963 |
+
// Priority 4: when upstream_inference_cost is 0 then compute it from other
|
| 964 |
+
// parameters.
|
| 965 |
+
let fixture = ResponseUsage {
|
| 966 |
+
prompt_tokens: 100,
|
| 967 |
+
completion_tokens: 50,
|
| 968 |
+
total_tokens: 150,
|
| 969 |
+
cost: None,
|
| 970 |
+
prompt_tokens_details: None,
|
| 971 |
+
cost_details: Some(CostDetails {
|
| 972 |
+
upstream_inference_cost: Some(0.0),
|
| 973 |
+
upstream_inference_prompt_cost: Some(0.003),
|
| 974 |
+
upstream_inference_completions_cost: Some(0.002),
|
| 975 |
+
}),
|
| 976 |
+
};
|
| 977 |
+
|
| 978 |
+
let actual: Usage = fixture.into();
|
| 979 |
+
assert_eq!(actual.cost, Some(0.005));
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
#[test]
|
| 983 |
+
fn test_zero_cost_should_fallback_to_cost_details() {
|
| 984 |
+
let fixture = ResponseUsage {
|
| 985 |
+
prompt_tokens: 100,
|
| 986 |
+
completion_tokens: 50,
|
| 987 |
+
total_tokens: 150,
|
| 988 |
+
cost: Some(0.0),
|
| 989 |
+
prompt_tokens_details: None,
|
| 990 |
+
cost_details: Some(CostDetails {
|
| 991 |
+
upstream_inference_cost: Some(0.005),
|
| 992 |
+
upstream_inference_prompt_cost: None,
|
| 993 |
+
upstream_inference_completions_cost: None,
|
| 994 |
+
}),
|
| 995 |
+
};
|
| 996 |
+
|
| 997 |
+
let actual: Usage = fixture.into();
|
| 998 |
+
assert_eq!(actual.cost, Some(0.005));
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
#[test]
|
| 1002 |
+
fn test_near_zero_cost_should_fallback_to_cost_details() {
|
| 1003 |
+
let fixture = ResponseUsage {
|
| 1004 |
+
prompt_tokens: 100,
|
| 1005 |
+
completion_tokens: 50,
|
| 1006 |
+
total_tokens: 150,
|
| 1007 |
+
cost: Some(1e-10),
|
| 1008 |
+
prompt_tokens_details: None,
|
| 1009 |
+
cost_details: Some(CostDetails {
|
| 1010 |
+
upstream_inference_cost: Some(0.005),
|
| 1011 |
+
upstream_inference_prompt_cost: None,
|
| 1012 |
+
upstream_inference_completions_cost: None,
|
| 1013 |
+
}),
|
| 1014 |
+
};
|
| 1015 |
+
|
| 1016 |
+
let actual: Usage = fixture.into();
|
| 1017 |
+
assert_eq!(actual.cost, Some(0.005));
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
#[test]
|
| 1021 |
+
fn test_github_copilot_content_filter_response() {
|
| 1022 |
+
let response_json = r#"{
|
| 1023 |
+
"choices": [],
|
| 1024 |
+
"created": 0,
|
| 1025 |
+
"id": "",
|
| 1026 |
+
"prompt_filter_results": [{
|
| 1027 |
+
"content_filter_results": {
|
| 1028 |
+
"hate": {"filtered": false, "severity": "safe"},
|
| 1029 |
+
"self_harm": {"filtered": false, "severity": "safe"},
|
| 1030 |
+
"sexual": {"filtered": false, "severity": "safe"},
|
| 1031 |
+
"violence": {"filtered": false, "severity": "safe"}
|
| 1032 |
+
},
|
| 1033 |
+
"prompt_index": 0
|
| 1034 |
+
}]
|
| 1035 |
+
}"#;
|
| 1036 |
+
|
| 1037 |
+
let actual = serde_json::from_str::<Response>(response_json);
|
| 1038 |
+
assert!(
|
| 1039 |
+
actual.is_ok(),
|
| 1040 |
+
"Should parse GitHub Copilot filter response: {:?}",
|
| 1041 |
+
actual.err()
|
| 1042 |
+
);
|
| 1043 |
+
}
|
| 1044 |
+
|
| 1045 |
+
#[test]
|
| 1046 |
+
fn test_github_copilot_filtered_content_error() {
|
| 1047 |
+
let response = Response::Success {
|
| 1048 |
+
id: "".to_string(),
|
| 1049 |
+
provider: None,
|
| 1050 |
+
model: Some("gpt-5".to_string()),
|
| 1051 |
+
choices: vec![],
|
| 1052 |
+
created: 0,
|
| 1053 |
+
object: None,
|
| 1054 |
+
system_fingerprint: None,
|
| 1055 |
+
usage: None,
|
| 1056 |
+
prompt_filter_results: Some(vec![PromptFilterResult {
|
| 1057 |
+
prompt_index: 0,
|
| 1058 |
+
content_filter_results: ContentFilterResults {
|
| 1059 |
+
hate: Some(FilterResult { filtered: true, severity: "high".to_string() }),
|
| 1060 |
+
self_harm: Some(FilterResult { filtered: false, severity: "safe".to_string() }),
|
| 1061 |
+
sexual: Some(FilterResult { filtered: false, severity: "safe".to_string() }),
|
| 1062 |
+
violence: Some(FilterResult { filtered: false, severity: "safe".to_string() }),
|
| 1063 |
+
},
|
| 1064 |
+
}]),
|
| 1065 |
+
};
|
| 1066 |
+
|
| 1067 |
+
let result = ChatCompletionMessage::try_from(response);
|
| 1068 |
+
assert!(result.is_err());
|
| 1069 |
+
let error = result.unwrap_err();
|
| 1070 |
+
let error_string = format!("{:?}", error);
|
| 1071 |
+
assert!(error_string.contains("Content was filtered"));
|
| 1072 |
+
assert!(error_string.contains("hate"));
|
| 1073 |
+
}
|
| 1074 |
+
|
| 1075 |
+
#[test]
|
| 1076 |
+
fn test_nvidia_tool_call_streaming_chunk() {
|
| 1077 |
+
let response_json = r#"{"id":"chatcmpl-994182aa3bf1d873","object":"chat.completion.chunk","created":1775363363,"model":"qwen/qwen3.5-397b-a17b","choices":[{"index":0,"delta":{"content":null,"reasoning":null,"reasoning_content":null,"tool_calls":[{"index":1,"function":{"arguments":"}"}}]},"logprobs":null,"finish_reason":"tool_calls","stop_reason":null,"token_ids":null}]}"#;
|
| 1078 |
+
|
| 1079 |
+
let actual = serde_json::from_str::<Response>(response_json);
|
| 1080 |
+
assert!(
|
| 1081 |
+
actual.is_ok(),
|
| 1082 |
+
"Should parse NVIDIA tool call streaming chunk: {:?}",
|
| 1083 |
+
actual.err()
|
| 1084 |
+
);
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
#[test]
|
| 1088 |
+
fn test_nvidia_tool_call_deserialization() {
|
| 1089 |
+
// NVIDIA sends tool calls without "id" and "type" fields
|
| 1090 |
+
let tool_call_json = r#"{"index":1,"function":{"arguments":"}"}}"#;
|
| 1091 |
+
let actual = serde_json::from_str::<ToolCall>(tool_call_json);
|
| 1092 |
+
assert!(
|
| 1093 |
+
actual.is_ok(),
|
| 1094 |
+
"Should parse NVIDIA tool call: {:?}",
|
| 1095 |
+
actual.err()
|
| 1096 |
+
);
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
#[test]
|
| 1100 |
+
fn test_nvidia_response_message_deserialization() {
|
| 1101 |
+
let msg_json = r#"{"content":null,"reasoning":null,"reasoning_content":null,"tool_calls":[{"index":1,"function":{"arguments":"}"}}]}"#;
|
| 1102 |
+
let actual = serde_json::from_str::<ResponseMessage>(msg_json);
|
| 1103 |
+
assert!(
|
| 1104 |
+
actual.is_ok(),
|
| 1105 |
+
"Should parse NVIDIA response message: {:?}",
|
| 1106 |
+
actual.err()
|
| 1107 |
+
);
|
| 1108 |
+
}
|
| 1109 |
+
|
| 1110 |
+
#[test]
|
| 1111 |
+
fn test_nvidia_choice_deserialization() {
|
| 1112 |
+
let choice_json = r#"{"index":0,"delta":{"content":null,"reasoning":null,"reasoning_content":null,"tool_calls":[{"index":1,"function":{"arguments":"}"}}]},"logprobs":null,"finish_reason":"tool_calls","stop_reason":null,"token_ids":null}"#;
|
| 1113 |
+
let actual = serde_json::from_str::<Choice>(choice_json);
|
| 1114 |
+
assert!(
|
| 1115 |
+
actual.is_ok(),
|
| 1116 |
+
"Should parse NVIDIA choice: {:?}",
|
| 1117 |
+
actual.err()
|
| 1118 |
+
);
|
| 1119 |
+
}
|
| 1120 |
+
}
|
crates/forge_app/src/dto/openai/responses.jsonl
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"error": {"code": "ERR_STREAM_PREMATURE_CLOSE"}}
|
| 2 |
+
{"error": {"errno": -104, "code": "ECONNRESET", "syscall": "read"}}
|
| 3 |
+
{"error": {"errno": -110, "code": "ETIMEDOUT", "syscall": "read"}}
|
| 4 |
+
{"error": {"error": {"code": "ERR_STREAM_PREMATURE_CLOSE"}}}
|
| 5 |
+
{"error": {"error": {"message": "Provider returned error", "code": 400, "metadata": {"raw": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"input length and `max_tokens` exceed context limit: MIN + MAX > 200000, decrease input length or `max_tokens` and try again\"}}", "provider_name": "Google"}}, "code": 400}}
|
| 6 |
+
{"error": {"error": {"message": "Provider returned error", "code": 413, "metadata": {"raw": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"Prompt is too long\"}}", "provider_name": "Google"}}, "code": 413}}
|
| 7 |
+
{"error": {"error": {"message": "Provider returned error", "code": 503, "metadata": {"raw": "{\n \"error\": {\n \"code\": 503,\n \"message\": \"The service is currently unavailable.\",\n \"status\": \"UNAVAILABLE\"\n }\n}\n", "provider_name": "Google AI Studio"}}, "code": 503}}
|
| 8 |
+
{"error": {"error": {"message": "Rate limit exceeded: limit_rpm/google/gemini-2.5-pro-exp-03-25/e5b7a905-dd02-4f6e-b61b-3aa1a1b8b9c8. ", "code": 429, "metadata": {"headers": {"X-RateLimit-Limit": "1", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "1746315180000"}, "provider_name": null}}, "code": 429}}
|
| 9 |
+
{"error": {"error": {"message": "Rate limit exceeded: limit_rpm/google/gemini-2.5-pro-exp-03-25/e5b7a905-dd02-4f6e-b61b-3aa1a1b8b9c8. High demand for google/gemini-2.5-pro-exp-03-25 on OpenRouter - limited to 1 requests per minute. Please retry shortly.", "code": 429, "metadata": {"headers": {"X-RateLimit-Limit": "1", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "1746937440000"}, "provider_name": null}}, "code": 429}}
|
| 10 |
+
{"error": {"error": {"message": "This endpoint's maximum context length is 200000 tokens. However, you requested about X tokens (Y of text input). Please reduce the length of either one, or use the \"middle-out\" transform to compress your prompt automatically.", "code": 400, "metadata": {"provider_name": null}}, "code": 400}}
|
| 11 |
+
{"error": {"error": {"message": "Upstream error from Chutes: Requested token count exceeds the model's maximum context length of 32768 tokens. You requested a total of 34689 tokens: 22892 tokens from the input messages and 11797 tokens for the completion. Please reduce the number of tokens in the input messages or the completion to fit within the limit.", "code": 502, "metadata": {"raw": {"object": "error", "message": "Requested token count exceeds the model's maximum context length of 32768 tokens. You requested a total of 34689 tokens: 22892 tokens from the input messages and 11797 tokens for the completion. Please reduce the number of tokens in the input messages or the completion to fit within the limit.", "type": "BadRequestError", "param": null, "code": 400}, "provider_name": "Chutes"}}, "code": 502}}
|
| 12 |
+
{"error": {"message": "400: An error occurred (ValidationException) when calling the ConverseStream operation: The text field in the ContentBlock object at messages.19.content.0 is blank. Add text to the text field, and try again."}}
|
| 13 |
+
{"error": {"message": "500: An error occurred (AccessDeniedException) when calling the ConverseStream operation: User: arn:aws:sts::275481790157:assumed-role/bedrockpartnerawsv1-ProxyApiHandlerServiceRoleBE71B-LZI8y019SUOG/bedrockpartnerawsv1-ProxyApiHandlerEC15A492-1JsCZPLohxs3 is not authorized to perform: bedrock:InvokeModelWithResponseStream on resource: arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-3-7-sonnet-20250219-v1:0 with an explicit deny in a service control policy"}}
|
| 14 |
+
{"error": {"message": "An error occurred (modelStreamErrorException) when calling the ConverseStream operation: The system encountered an unexpected error during processing. Try your request again."}}
|
| 15 |
+
{"error": {"message": "An error occurred (serviceUnavailableException) when calling the ConverseStream operation: Bedrock is unable to process your request."}}
|
| 16 |
+
{"error": {"message": "An error occurred (validationException) when calling the ConverseStream operation: The model returned the following errors: Input is too long for requested model."}}
|
| 17 |
+
{"error": {"message": "litellm.APIConnectionError: AnthropicException - ", "type": null, "param": null, "code": "500"}}
|
| 18 |
+
{"error": {"message": "litellm.BadRequestError: BedrockException - validationException {\"message\":\"The model returned the following errors: input length and `max_tokens` exceed context limit: 197717 + 8192 > 204698, decrease input length or `max_tokens` and try again\"}", "type": null, "param": null, "code": "400"}}
|
| 19 |
+
{"error": {"message": "litellm.ContextWindowExceededError: litellm.BadRequestError: BedrockException: Context Window Error - validationException {\"message\":\"The model returned the following errors: Input is too long for requested model.\"}", "type": null, "param": null, "code": "400"}}
|
| 20 |
+
{"error": {"message": "litellm.InternalServerError: AnthropicException - Overloaded. Handle with `litellm.InternalServerError`.", "type": null, "param": null, "code": "500"}}
|
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__assistant_message_with_tool_call_conversion.snap
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
source: crates/forge_app/src/dto/openai/request.rs
|
| 3 |
+
expression: router_message
|
| 4 |
+
---
|
| 5 |
+
{
|
| 6 |
+
"role": "assistant",
|
| 7 |
+
"content": "Using tool",
|
| 8 |
+
"tool_calls": [
|
| 9 |
+
{
|
| 10 |
+
"id": "123",
|
| 11 |
+
"type": "function",
|
| 12 |
+
"function": {
|
| 13 |
+
"name": "test_tool",
|
| 14 |
+
"arguments": "{\"key\":\"value\"}"
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
]
|
| 18 |
+
}
|
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__message_with_special_chars.snap
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
source: crates/forge_app/src/dto/openai/request.rs
|
| 3 |
+
expression: router_message
|
| 4 |
+
---
|
| 5 |
+
{
|
| 6 |
+
"role": "user",
|
| 7 |
+
"content": "Here's some XML content:\n<task>\n <id>123</id>\n <description>Test <special> characters</description>\n <data key=\"value\">\n <item>1</item>\n <item>2</item>\n </data>\n</task>"
|
| 8 |
+
}
|
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_conversion.snap
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
source: crates/forge_app/src/dto/openai/request.rs
|
| 3 |
+
expression: router_message
|
| 4 |
+
---
|
| 5 |
+
{
|
| 6 |
+
"role": "tool",
|
| 7 |
+
"content": "{\n \"user\": \"John\",\n \"age\": 30,\n \"address\": [{\"city\": \"New York\"}, {\"city\": \"San Francisco\"}]\n }",
|
| 8 |
+
"name": "test_tool",
|
| 9 |
+
"tool_call_id": "123"
|
| 10 |
+
}
|
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_typescript_code.snap
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
source: crates/forge_app/src/dto/openai/request.rs
|
| 3 |
+
expression: router_message
|
| 4 |
+
---
|
| 5 |
+
{
|
| 6 |
+
"role": "tool",
|
| 7 |
+
"content": "{ \"code\": \"fn main<T>(gt: T) {let b = > }\"}",
|
| 8 |
+
"name": "rust_tool",
|
| 9 |
+
"tool_call_id": "456"
|
| 10 |
+
}
|
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__tool_message_with_special_chars.snap
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
source: crates/forge_app/src/dto/openai/request.rs
|
| 3 |
+
expression: router_message
|
| 4 |
+
---
|
| 5 |
+
{
|
| 6 |
+
"role": "tool",
|
| 7 |
+
"content": "{\n \"html\": \"<div class=\\\"container\\\"><p>Hello <World></p></div>\",\n \"elements\": [\"<span>\", \"<br/>\", \"<hr>\"],\n \"attributes\": {\n \"style\": \"color: blue; font-size: 12px;\",\n \"data-test\": \"<test>&value</test>\"\n }\n }",
|
| 8 |
+
"name": "html_tool",
|
| 9 |
+
"tool_call_id": "456"
|
| 10 |
+
}
|
crates/forge_app/src/dto/openai/snapshots/forge_app__dto__openai__request__tests__user_message_conversion.snap
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
source: crates/forge_app/src/dto/openai/request.rs
|
| 3 |
+
expression: router_message
|
| 4 |
+
---
|
| 5 |
+
{
|
| 6 |
+
"role": "user",
|
| 7 |
+
"content": "Hello"
|
| 8 |
+
}
|
crates/forge_app/src/dto/openai/tool_choice.rs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use serde::{Deserialize, Serialize};
|
| 2 |
+
|
| 3 |
+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
| 4 |
+
#[serde(rename_all = "snake_case")]
|
| 5 |
+
pub enum ToolChoice {
|
| 6 |
+
None,
|
| 7 |
+
Auto,
|
| 8 |
+
Required,
|
| 9 |
+
#[serde(untagged)]
|
| 10 |
+
Function {
|
| 11 |
+
r#type: FunctionType,
|
| 12 |
+
function: FunctionName,
|
| 13 |
+
},
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
| 17 |
+
#[serde(rename_all = "snake_case")]
|
| 18 |
+
pub struct FunctionName {
|
| 19 |
+
pub name: String,
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
#[derive(Default, Debug, Clone, PartialEq, Eq)]
|
| 23 |
+
pub struct FunctionType;
|
| 24 |
+
|
| 25 |
+
impl Serialize for FunctionType {
|
| 26 |
+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
| 27 |
+
where
|
| 28 |
+
S: serde::Serializer,
|
| 29 |
+
{
|
| 30 |
+
serializer.serialize_str("function")
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
impl<'de> Deserialize<'de> for FunctionType {
|
| 35 |
+
fn deserialize<D>(_deserializer: D) -> Result<FunctionType, D::Error>
|
| 36 |
+
where
|
| 37 |
+
D: serde::Deserializer<'de>,
|
| 38 |
+
{
|
| 39 |
+
Ok(FunctionType)
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
impl From<forge_domain::ToolChoice> for ToolChoice {
|
| 44 |
+
fn from(value: forge_domain::ToolChoice) -> Self {
|
| 45 |
+
match value {
|
| 46 |
+
forge_domain::ToolChoice::None => ToolChoice::None,
|
| 47 |
+
forge_domain::ToolChoice::Auto => ToolChoice::Auto,
|
| 48 |
+
forge_domain::ToolChoice::Required => ToolChoice::Required,
|
| 49 |
+
forge_domain::ToolChoice::Call(tool_name) => ToolChoice::Function {
|
| 50 |
+
function: FunctionName { name: tool_name.to_string() },
|
| 51 |
+
r#type: FunctionType,
|
| 52 |
+
},
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
#[cfg(test)]
|
| 58 |
+
mod tests {
|
| 59 |
+
use super::*;
|
| 60 |
+
|
| 61 |
+
#[test]
|
| 62 |
+
fn test_tool_choice_serialization() {
|
| 63 |
+
// Test None variant
|
| 64 |
+
let choice_none = ToolChoice::None;
|
| 65 |
+
assert_eq!(serde_json::to_string(&choice_none).unwrap(), r#""none""#);
|
| 66 |
+
|
| 67 |
+
// Test Auto variant
|
| 68 |
+
let choice_auto = ToolChoice::Auto;
|
| 69 |
+
assert_eq!(serde_json::to_string(&choice_auto).unwrap(), r#""auto""#);
|
| 70 |
+
|
| 71 |
+
// Test Function variant
|
| 72 |
+
let choice_function = ToolChoice::Function {
|
| 73 |
+
function: FunctionName { name: "test_tool".to_string() },
|
| 74 |
+
r#type: FunctionType,
|
| 75 |
+
};
|
| 76 |
+
assert_eq!(
|
| 77 |
+
serde_json::to_string(&choice_function).unwrap(),
|
| 78 |
+
r#"{"type":"function","function":{"name":"test_tool"}}"#
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
}
|
crates/forge_app/src/dto/openai/transformers/default_reasoning_content.rs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::openai::Request;
|
| 4 |
+
|
| 5 |
+
/// Transformer that ensures every message has a `reasoning_content` field set.
|
| 6 |
+
///
|
| 7 |
+
/// DeepSeek requires the `reasoning_content` field to be present on assistant
|
| 8 |
+
/// messages even when there is no reasoning text. When the field is `None` this
|
| 9 |
+
/// transformer falls back to an empty string.
|
| 10 |
+
pub struct DefaultReasoningContent;
|
| 11 |
+
|
| 12 |
+
impl Transformer for DefaultReasoningContent {
|
| 13 |
+
type Value = Request;
|
| 14 |
+
|
| 15 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 16 |
+
if let Some(ref mut messages) = request.messages {
|
| 17 |
+
for message in messages.iter_mut() {
|
| 18 |
+
if message.reasoning_content.is_none() {
|
| 19 |
+
message.reasoning_content = Some(String::new());
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
request
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
#[cfg(test)]
|
| 28 |
+
mod tests {
|
| 29 |
+
use pretty_assertions::assert_eq;
|
| 30 |
+
|
| 31 |
+
use super::*;
|
| 32 |
+
use crate::dto::openai::{Message, MessageContent, Request, Role};
|
| 33 |
+
|
| 34 |
+
#[test]
|
| 35 |
+
fn test_sets_empty_string_when_reasoning_content_is_none() {
|
| 36 |
+
let fixture = Request::default().messages(vec![Message {
|
| 37 |
+
role: Role::Assistant,
|
| 38 |
+
content: Some(MessageContent::Text("test".to_string())),
|
| 39 |
+
name: None,
|
| 40 |
+
tool_call_id: None,
|
| 41 |
+
tool_calls: None,
|
| 42 |
+
reasoning_details: None,
|
| 43 |
+
reasoning_text: None,
|
| 44 |
+
reasoning_opaque: None,
|
| 45 |
+
reasoning_content: None,
|
| 46 |
+
extra_content: None,
|
| 47 |
+
}]);
|
| 48 |
+
|
| 49 |
+
let actual = DefaultReasoningContent.transform(fixture);
|
| 50 |
+
|
| 51 |
+
let messages = actual.messages.unwrap();
|
| 52 |
+
let msg = messages.first().unwrap();
|
| 53 |
+
assert_eq!(msg.reasoning_content, Some(String::new()));
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
#[test]
|
| 57 |
+
fn test_preserves_existing_reasoning_content() {
|
| 58 |
+
let fixture = Request::default().messages(vec![Message {
|
| 59 |
+
role: Role::Assistant,
|
| 60 |
+
content: Some(MessageContent::Text("test".to_string())),
|
| 61 |
+
name: None,
|
| 62 |
+
tool_call_id: None,
|
| 63 |
+
tool_calls: None,
|
| 64 |
+
reasoning_details: None,
|
| 65 |
+
reasoning_text: None,
|
| 66 |
+
reasoning_opaque: None,
|
| 67 |
+
reasoning_content: Some("thinking...".to_string()),
|
| 68 |
+
extra_content: None,
|
| 69 |
+
}]);
|
| 70 |
+
|
| 71 |
+
let actual = DefaultReasoningContent.transform(fixture);
|
| 72 |
+
|
| 73 |
+
let messages = actual.messages.unwrap();
|
| 74 |
+
let msg = messages.first().unwrap();
|
| 75 |
+
assert_eq!(msg.reasoning_content, Some("thinking...".to_string()));
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
#[test]
|
| 79 |
+
fn test_preserves_preserves_empty_string_when_already_set() {
|
| 80 |
+
let fixture = Request::default().messages(vec![Message {
|
| 81 |
+
role: Role::Assistant,
|
| 82 |
+
content: Some(MessageContent::Text("test".to_string())),
|
| 83 |
+
name: None,
|
| 84 |
+
tool_call_id: None,
|
| 85 |
+
tool_calls: None,
|
| 86 |
+
reasoning_details: None,
|
| 87 |
+
reasoning_text: None,
|
| 88 |
+
reasoning_opaque: None,
|
| 89 |
+
reasoning_content: Some(String::new()),
|
| 90 |
+
extra_content: None,
|
| 91 |
+
}]);
|
| 92 |
+
|
| 93 |
+
let actual = DefaultReasoningContent.transform(fixture);
|
| 94 |
+
|
| 95 |
+
let messages = actual.messages.unwrap();
|
| 96 |
+
let msg = messages.first().unwrap();
|
| 97 |
+
assert_eq!(msg.reasoning_content, Some(String::new()));
|
| 98 |
+
}
|
| 99 |
+
}
|
crates/forge_app/src/dto/openai/transformers/drop_tool_call.rs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::openai::{Request, Role};
|
| 4 |
+
|
| 5 |
+
/// Drops all tool call messages and converts them to user/assistant messages
|
| 6 |
+
pub struct DropToolCalls;
|
| 7 |
+
|
| 8 |
+
impl Transformer for DropToolCalls {
|
| 9 |
+
type Value = Request;
|
| 10 |
+
|
| 11 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 12 |
+
if let Some(messages) = request.messages.as_mut() {
|
| 13 |
+
for message in messages.iter_mut() {
|
| 14 |
+
// Convert tool messages to user messages
|
| 15 |
+
if message.role == Role::Tool {
|
| 16 |
+
message.role = Role::User;
|
| 17 |
+
message.tool_calls = None;
|
| 18 |
+
message.tool_call_id = None;
|
| 19 |
+
message.name = None;
|
| 20 |
+
}
|
| 21 |
+
// Remove tool calls from assistant messages
|
| 22 |
+
if message.role == Role::Assistant {
|
| 23 |
+
message.tool_calls = None;
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Reset the tools field
|
| 29 |
+
request.tools = None;
|
| 30 |
+
|
| 31 |
+
request
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
#[cfg(test)]
|
| 36 |
+
mod tests {
|
| 37 |
+
use forge_domain::{
|
| 38 |
+
Context, ContextMessage, Role, TextMessage, ToolCallFull, ToolCallId, ToolName, ToolResult,
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
use super::*;
|
| 42 |
+
|
| 43 |
+
#[test]
|
| 44 |
+
fn test_mistral_transformer_tools_not_supported() {
|
| 45 |
+
let tool_call = ToolCallFull {
|
| 46 |
+
call_id: Some(ToolCallId::new("123")),
|
| 47 |
+
name: ToolName::new("test_tool"),
|
| 48 |
+
arguments: serde_json::json!({"key": "value"}).into(),
|
| 49 |
+
thought_signature: None,
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
let tool_result = ToolResult::new(ToolName::new("test_tool"))
|
| 53 |
+
.call_id(ToolCallId::new("123"))
|
| 54 |
+
.success("test result");
|
| 55 |
+
|
| 56 |
+
let context = Context {
|
| 57 |
+
conversation_id: None,
|
| 58 |
+
messages: vec![
|
| 59 |
+
ContextMessage::Text(
|
| 60 |
+
TextMessage::new(Role::Assistant, "Using tool").tool_calls(vec![tool_call]),
|
| 61 |
+
)
|
| 62 |
+
.into(),
|
| 63 |
+
ContextMessage::Tool(tool_result).into(),
|
| 64 |
+
],
|
| 65 |
+
tools: vec![forge_domain::ToolDefinition::new("test_tool").description("A test tool")],
|
| 66 |
+
tool_choice: None,
|
| 67 |
+
max_tokens: None,
|
| 68 |
+
temperature: None,
|
| 69 |
+
top_p: None,
|
| 70 |
+
top_k: None,
|
| 71 |
+
reasoning: None,
|
| 72 |
+
stream: None,
|
| 73 |
+
response_format: None,
|
| 74 |
+
initiator: None,
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
let request = Request::from(context);
|
| 78 |
+
let mut transformer = DropToolCalls;
|
| 79 |
+
let transformed = transformer.transform(request);
|
| 80 |
+
|
| 81 |
+
let messages = transformed.messages.unwrap();
|
| 82 |
+
// Assistant message
|
| 83 |
+
assert!(messages[0].tool_calls.is_none());
|
| 84 |
+
// Converted tool message
|
| 85 |
+
assert_eq!(messages[1].role, Role::User.into());
|
| 86 |
+
// Tools field should be reset
|
| 87 |
+
assert!(transformed.tools.is_none());
|
| 88 |
+
}
|
| 89 |
+
}
|
crates/forge_app/src/dto/openai/transformers/ensure_system_first.rs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::openai::{Message, MessageContent, Request, Role};
|
| 4 |
+
|
| 5 |
+
/// Merges all system messages into a single system message at the beginning of
|
| 6 |
+
/// the messages array.
|
| 7 |
+
///
|
| 8 |
+
/// Some providers (e.g. NVIDIA, vLLM) reject requests with multiple system
|
| 9 |
+
/// messages or system messages that are not positioned at the start of the
|
| 10 |
+
/// conversation.
|
| 11 |
+
pub struct MergeSystemMessages;
|
| 12 |
+
|
| 13 |
+
impl Transformer for MergeSystemMessages {
|
| 14 |
+
type Value = Request;
|
| 15 |
+
|
| 16 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 17 |
+
if let Some(messages) = request.messages.take() {
|
| 18 |
+
let (system, rest): (Vec<_>, Vec<_>) =
|
| 19 |
+
messages.into_iter().partition(|m| m.role == Role::System);
|
| 20 |
+
|
| 21 |
+
let merged = if system.is_empty() {
|
| 22 |
+
rest
|
| 23 |
+
} else {
|
| 24 |
+
let combined_content = system
|
| 25 |
+
.iter()
|
| 26 |
+
.filter_map(|m| match &m.content {
|
| 27 |
+
Some(MessageContent::Text(text)) => Some(text.clone()),
|
| 28 |
+
Some(MessageContent::Parts(parts)) => Some(
|
| 29 |
+
parts
|
| 30 |
+
.iter()
|
| 31 |
+
.filter_map(|p| match p {
|
| 32 |
+
crate::dto::openai::ContentPart::Text { text, .. } => {
|
| 33 |
+
Some(text.clone())
|
| 34 |
+
}
|
| 35 |
+
_ => None,
|
| 36 |
+
})
|
| 37 |
+
.collect::<Vec<_>>()
|
| 38 |
+
.join(""),
|
| 39 |
+
),
|
| 40 |
+
None => None,
|
| 41 |
+
})
|
| 42 |
+
.collect::<Vec<_>>()
|
| 43 |
+
.join("\n\n");
|
| 44 |
+
|
| 45 |
+
if combined_content.is_empty() {
|
| 46 |
+
// All system messages had no content, don't create empty system message
|
| 47 |
+
rest
|
| 48 |
+
} else {
|
| 49 |
+
let mut result = vec![Message {
|
| 50 |
+
role: Role::System,
|
| 51 |
+
content: Some(MessageContent::Text(combined_content)),
|
| 52 |
+
name: None,
|
| 53 |
+
tool_call_id: None,
|
| 54 |
+
tool_calls: None,
|
| 55 |
+
reasoning_details: None,
|
| 56 |
+
reasoning_text: None,
|
| 57 |
+
reasoning_opaque: None,
|
| 58 |
+
reasoning_content: None,
|
| 59 |
+
extra_content: None,
|
| 60 |
+
}];
|
| 61 |
+
result.extend(rest);
|
| 62 |
+
result
|
| 63 |
+
}
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
request.messages = Some(merged);
|
| 67 |
+
}
|
| 68 |
+
request
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
#[cfg(test)]
|
| 73 |
+
mod tests {
|
| 74 |
+
use pretty_assertions::assert_eq;
|
| 75 |
+
|
| 76 |
+
use super::*;
|
| 77 |
+
use crate::dto::openai::{Message, MessageContent, Role};
|
| 78 |
+
|
| 79 |
+
fn system_msg(content: &str) -> Message {
|
| 80 |
+
Message {
|
| 81 |
+
role: Role::System,
|
| 82 |
+
content: Some(MessageContent::Text(content.to_string())),
|
| 83 |
+
name: None,
|
| 84 |
+
tool_call_id: None,
|
| 85 |
+
tool_calls: None,
|
| 86 |
+
reasoning_details: None,
|
| 87 |
+
reasoning_text: None,
|
| 88 |
+
reasoning_opaque: None,
|
| 89 |
+
reasoning_content: None,
|
| 90 |
+
extra_content: None,
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
fn user_msg(content: &str) -> Message {
|
| 95 |
+
Message {
|
| 96 |
+
role: Role::User,
|
| 97 |
+
content: Some(MessageContent::Text(content.to_string())),
|
| 98 |
+
name: None,
|
| 99 |
+
tool_call_id: None,
|
| 100 |
+
tool_calls: None,
|
| 101 |
+
reasoning_details: None,
|
| 102 |
+
reasoning_text: None,
|
| 103 |
+
reasoning_opaque: None,
|
| 104 |
+
reasoning_content: None,
|
| 105 |
+
extra_content: None,
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
fn assistant_msg(content: &str) -> Message {
|
| 110 |
+
Message {
|
| 111 |
+
role: Role::Assistant,
|
| 112 |
+
content: Some(MessageContent::Text(content.to_string())),
|
| 113 |
+
name: None,
|
| 114 |
+
tool_call_id: None,
|
| 115 |
+
tool_calls: None,
|
| 116 |
+
reasoning_details: None,
|
| 117 |
+
reasoning_text: None,
|
| 118 |
+
reasoning_opaque: None,
|
| 119 |
+
reasoning_content: None,
|
| 120 |
+
extra_content: None,
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
fn get_text_content(msg: &Message) -> Option<&str> {
|
| 125 |
+
match msg.content.as_ref() {
|
| 126 |
+
Some(MessageContent::Text(text)) => Some(text.as_str()),
|
| 127 |
+
_ => None,
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
#[test]
|
| 132 |
+
fn test_multiple_system_messages_merged() {
|
| 133 |
+
let fixture = Request::default().messages(vec![
|
| 134 |
+
user_msg("hello"),
|
| 135 |
+
system_msg("you are helpful"),
|
| 136 |
+
assistant_msg("hi"),
|
| 137 |
+
system_msg("be concise"),
|
| 138 |
+
user_msg("how are you"),
|
| 139 |
+
]);
|
| 140 |
+
|
| 141 |
+
let actual = MergeSystemMessages.transform(fixture);
|
| 142 |
+
|
| 143 |
+
let messages = actual.messages.unwrap();
|
| 144 |
+
assert_eq!(messages.len(), 4);
|
| 145 |
+
assert_eq!(messages[0].role, Role::System);
|
| 146 |
+
assert_eq!(
|
| 147 |
+
get_text_content(&messages[0]),
|
| 148 |
+
Some("you are helpful\n\nbe concise")
|
| 149 |
+
);
|
| 150 |
+
assert_eq!(messages[1].role, Role::User);
|
| 151 |
+
assert_eq!(messages[2].role, Role::Assistant);
|
| 152 |
+
assert_eq!(messages[3].role, Role::User);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
#[test]
|
| 156 |
+
fn test_single_system_message_unchanged() {
|
| 157 |
+
let fixture = Request::default().messages(vec![
|
| 158 |
+
system_msg("you are helpful"),
|
| 159 |
+
user_msg("hello"),
|
| 160 |
+
assistant_msg("hi"),
|
| 161 |
+
]);
|
| 162 |
+
|
| 163 |
+
let actual = MergeSystemMessages.transform(fixture);
|
| 164 |
+
|
| 165 |
+
let messages = actual.messages.unwrap();
|
| 166 |
+
assert_eq!(messages.len(), 3);
|
| 167 |
+
assert_eq!(messages[0].role, Role::System);
|
| 168 |
+
assert_eq!(get_text_content(&messages[0]), Some("you are helpful"));
|
| 169 |
+
assert_eq!(messages[1].role, Role::User);
|
| 170 |
+
assert_eq!(messages[2].role, Role::Assistant);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
#[test]
|
| 174 |
+
fn test_no_system_messages_unchanged() {
|
| 175 |
+
let fixture = Request::default().messages(vec![
|
| 176 |
+
user_msg("hello"),
|
| 177 |
+
assistant_msg("hi"),
|
| 178 |
+
user_msg("how are you"),
|
| 179 |
+
]);
|
| 180 |
+
|
| 181 |
+
let actual = MergeSystemMessages.transform(fixture);
|
| 182 |
+
|
| 183 |
+
let messages = actual.messages.unwrap();
|
| 184 |
+
assert_eq!(messages.len(), 3);
|
| 185 |
+
assert_eq!(messages[0].role, Role::User);
|
| 186 |
+
assert_eq!(messages[1].role, Role::Assistant);
|
| 187 |
+
assert_eq!(messages[2].role, Role::User);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
#[test]
|
| 191 |
+
fn test_no_messages_unchanged() {
|
| 192 |
+
let fixture = Request::default();
|
| 193 |
+
|
| 194 |
+
let actual = MergeSystemMessages.transform(fixture);
|
| 195 |
+
|
| 196 |
+
assert!(actual.messages.is_none());
|
| 197 |
+
}
|
| 198 |
+
}
|
crates/forge_app/src/dto/openai/transformers/github_copilot_reasoning.rs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::openai::Request;
|
| 4 |
+
|
| 5 |
+
/// Transformer that converts reasoning_details to GitHub Copilot's flat format.
|
| 6 |
+
///
|
| 7 |
+
/// GitHub Copilot uses `reasoning_text` and `reasoning_opaque` fields directly
|
| 8 |
+
/// in messages instead of the OpenRouter-style `reasoning_details` array.
|
| 9 |
+
///
|
| 10 |
+
/// This transformer:
|
| 11 |
+
/// 1. Extracts `reasoning.text` type entries → `reasoning_text`
|
| 12 |
+
/// 2. Extracts `reasoning.encrypted` type entries → `reasoning_opaque`
|
| 13 |
+
/// 3. Removes `reasoning_details` array
|
| 14 |
+
pub struct GitHubCopilotReasoning;
|
| 15 |
+
|
| 16 |
+
impl Transformer for GitHubCopilotReasoning {
|
| 17 |
+
type Value = Request;
|
| 18 |
+
|
| 19 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 20 |
+
if let Some(ref mut messages) = request.messages {
|
| 21 |
+
for message in messages.iter_mut() {
|
| 22 |
+
if let Some(reasoning_details) = &message.reasoning_details {
|
| 23 |
+
// Extract reasoning_text (type: "reasoning.text")
|
| 24 |
+
let reasoning_text = reasoning_details
|
| 25 |
+
.iter()
|
| 26 |
+
.find(|d| d.r#type == "reasoning.text")
|
| 27 |
+
.and_then(|d| d.text.clone());
|
| 28 |
+
|
| 29 |
+
// Extract reasoning_opaque (type: "reasoning.encrypted")
|
| 30 |
+
let reasoning_opaque = reasoning_details
|
| 31 |
+
.iter()
|
| 32 |
+
.find(|d| d.r#type == "reasoning.encrypted")
|
| 33 |
+
.and_then(|d| d.data.clone());
|
| 34 |
+
|
| 35 |
+
// Set flat fields
|
| 36 |
+
message.reasoning_text = reasoning_text;
|
| 37 |
+
message.reasoning_opaque = reasoning_opaque;
|
| 38 |
+
|
| 39 |
+
// Remove reasoning_details array (GitHub Copilot doesn't accept it)
|
| 40 |
+
message.reasoning_details = None;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
request
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
#[cfg(test)]
|
| 49 |
+
mod tests {
|
| 50 |
+
use pretty_assertions::assert_eq;
|
| 51 |
+
|
| 52 |
+
use super::*;
|
| 53 |
+
use crate::dto::openai::{Message, MessageContent, ReasoningDetail, Request, Role};
|
| 54 |
+
|
| 55 |
+
#[test]
|
| 56 |
+
fn test_converts_reasoning_details_to_flat_format() {
|
| 57 |
+
// Setup
|
| 58 |
+
let fixture = Request::default().messages(vec![Message {
|
| 59 |
+
role: Role::Assistant,
|
| 60 |
+
content: Some(MessageContent::Text("test".to_string())),
|
| 61 |
+
reasoning_details: Some(vec![
|
| 62 |
+
ReasoningDetail {
|
| 63 |
+
r#type: "reasoning.text".to_string(),
|
| 64 |
+
text: Some("thinking...".to_string()),
|
| 65 |
+
signature: None,
|
| 66 |
+
data: None,
|
| 67 |
+
id: None,
|
| 68 |
+
format: None,
|
| 69 |
+
index: None,
|
| 70 |
+
},
|
| 71 |
+
ReasoningDetail {
|
| 72 |
+
r#type: "reasoning.encrypted".to_string(),
|
| 73 |
+
text: None,
|
| 74 |
+
signature: None,
|
| 75 |
+
data: Some("encrypted_data".to_string()),
|
| 76 |
+
id: None,
|
| 77 |
+
format: None,
|
| 78 |
+
index: None,
|
| 79 |
+
},
|
| 80 |
+
]),
|
| 81 |
+
reasoning_text: None,
|
| 82 |
+
reasoning_opaque: None,
|
| 83 |
+
reasoning_content: None,
|
| 84 |
+
tool_calls: None,
|
| 85 |
+
tool_call_id: None,
|
| 86 |
+
name: None,
|
| 87 |
+
extra_content: None,
|
| 88 |
+
}]);
|
| 89 |
+
|
| 90 |
+
// Execute
|
| 91 |
+
let actual = GitHubCopilotReasoning.transform(fixture);
|
| 92 |
+
|
| 93 |
+
// Verify transformation
|
| 94 |
+
let messages = actual.messages.unwrap();
|
| 95 |
+
let msg = messages.first().unwrap();
|
| 96 |
+
assert_eq!(msg.reasoning_text, Some("thinking...".to_string()));
|
| 97 |
+
assert_eq!(msg.reasoning_opaque, Some("encrypted_data".to_string()));
|
| 98 |
+
assert!(msg.reasoning_details.is_none());
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
#[test]
|
| 102 |
+
fn test_handles_missing_reasoning_details() {
|
| 103 |
+
// Setup
|
| 104 |
+
let fixture = Request::default().messages(vec![Message {
|
| 105 |
+
role: Role::User,
|
| 106 |
+
content: Some(MessageContent::Text("test".to_string())),
|
| 107 |
+
reasoning_details: None,
|
| 108 |
+
reasoning_text: None,
|
| 109 |
+
reasoning_opaque: None,
|
| 110 |
+
reasoning_content: None,
|
| 111 |
+
tool_calls: None,
|
| 112 |
+
tool_call_id: None,
|
| 113 |
+
name: None,
|
| 114 |
+
extra_content: None,
|
| 115 |
+
}]);
|
| 116 |
+
|
| 117 |
+
// Execute
|
| 118 |
+
let actual = GitHubCopilotReasoning.transform(fixture.clone());
|
| 119 |
+
|
| 120 |
+
// Verify - no change since there were no reasoning_details
|
| 121 |
+
let messages = actual.messages.unwrap();
|
| 122 |
+
let msg = messages.first().unwrap();
|
| 123 |
+
assert!(msg.reasoning_text.is_none());
|
| 124 |
+
assert!(msg.reasoning_opaque.is_none());
|
| 125 |
+
}
|
| 126 |
+
}
|
crates/forge_app/src/dto/openai/transformers/make_cerebras_compat.rs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::openai::Request;
|
| 4 |
+
|
| 5 |
+
/// makes the Request compatible with the OpenAI API.
|
| 6 |
+
pub struct MakeCerebrasCompat;
|
| 7 |
+
|
| 8 |
+
impl Transformer for MakeCerebrasCompat {
|
| 9 |
+
type Value = Request;
|
| 10 |
+
|
| 11 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 12 |
+
// remove fields that are not supported by cerebras.
|
| 13 |
+
request.parallel_tool_calls = None;
|
| 14 |
+
request
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
#[cfg(test)]
|
| 19 |
+
mod tests {
|
| 20 |
+
use pretty_assertions::assert_eq;
|
| 21 |
+
|
| 22 |
+
use super::*;
|
| 23 |
+
|
| 24 |
+
#[test]
|
| 25 |
+
fn test_parallel_tool_calls_dropped() {
|
| 26 |
+
use crate::dto::openai::{FunctionDescription, FunctionType, Request, Tool};
|
| 27 |
+
|
| 28 |
+
let fixture = Request::default()
|
| 29 |
+
.tools(vec![Tool {
|
| 30 |
+
r#type: FunctionType,
|
| 31 |
+
function: FunctionDescription {
|
| 32 |
+
description: Some("test".to_string()),
|
| 33 |
+
name: "test".to_string(),
|
| 34 |
+
parameters: serde_json::json!({}),
|
| 35 |
+
},
|
| 36 |
+
}])
|
| 37 |
+
.parallel_tool_calls(true);
|
| 38 |
+
let mut transformer = MakeCerebrasCompat;
|
| 39 |
+
let actual = transformer.transform(fixture);
|
| 40 |
+
let expected = None;
|
| 41 |
+
assert_eq!(actual.parallel_tool_calls, expected);
|
| 42 |
+
}
|
| 43 |
+
}
|
crates/forge_app/src/dto/openai/transformers/make_openai_compat.rs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_domain::Transformer;
|
| 2 |
+
|
| 3 |
+
use crate::dto::openai::Request;
|
| 4 |
+
|
| 5 |
+
/// makes the Request compatible with the OpenAI API.
|
| 6 |
+
pub struct MakeOpenAiCompat;
|
| 7 |
+
|
| 8 |
+
impl Transformer for MakeOpenAiCompat {
|
| 9 |
+
type Value = Request;
|
| 10 |
+
|
| 11 |
+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
|
| 12 |
+
// remove fields that are not supported by open-ai.
|
| 13 |
+
request.provider = None;
|
| 14 |
+
request.transforms = None;
|
| 15 |
+
request.prompt = None;
|
| 16 |
+
request.models = None;
|
| 17 |
+
request.route = None;
|
| 18 |
+
request.top_k = None;
|
| 19 |
+
request.top_p = None;
|
| 20 |
+
request.repetition_penalty = None;
|
| 21 |
+
request.min_p = None;
|
| 22 |
+
request.top_a = None;
|
| 23 |
+
request.session_id = None;
|
| 24 |
+
request.reasoning = None;
|
| 25 |
+
|
| 26 |
+
let tools_present = request
|
| 27 |
+
.tools
|
| 28 |
+
.as_ref()
|
| 29 |
+
.is_some_and(|tools| !tools.is_empty());
|
| 30 |
+
|
| 31 |
+
if !tools_present {
|
| 32 |
+
// drop `parallel_tool_calls` field if tools are not passed to the request.
|
| 33 |
+
request.parallel_tool_calls = None;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// OpenAI has deprecated `max_tokens`, now it is `max_completion_tokens`.
|
| 37 |
+
request.max_completion_tokens = request.max_tokens.take();
|
| 38 |
+
|
| 39 |
+
request
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
#[cfg(test)]
|
| 44 |
+
mod tests {
|
| 45 |
+
use pretty_assertions::assert_eq;
|
| 46 |
+
|
| 47 |
+
use super::*;
|
| 48 |
+
|
| 49 |
+
#[test]
|
| 50 |
+
fn test_parallel_tool_calls_removed_when_no_tools() {
|
| 51 |
+
use crate::dto::openai::Request;
|
| 52 |
+
|
| 53 |
+
let fixture = Request::default().parallel_tool_calls(true);
|
| 54 |
+
let mut transformer = MakeOpenAiCompat;
|
| 55 |
+
let actual = transformer.transform(fixture);
|
| 56 |
+
let expected = None;
|
| 57 |
+
assert_eq!(actual.parallel_tool_calls, expected);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
#[test]
|
| 61 |
+
fn test_parallel_tool_calls_removed_when_empty_tools() {
|
| 62 |
+
let fixture = Request::default().tools(vec![]).parallel_tool_calls(true);
|
| 63 |
+
let mut transformer = MakeOpenAiCompat;
|
| 64 |
+
let actual = transformer.transform(fixture);
|
| 65 |
+
let expected = None;
|
| 66 |
+
assert_eq!(actual.parallel_tool_calls, expected);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
#[test]
|
| 70 |
+
fn test_parallel_tool_calls_preserved_when_tools_present() {
|
| 71 |
+
use crate::dto::openai::{FunctionDescription, FunctionType, Request, Tool};
|
| 72 |
+
|
| 73 |
+
let fixture = Request::default()
|
| 74 |
+
.tools(vec![Tool {
|
| 75 |
+
r#type: FunctionType,
|
| 76 |
+
function: FunctionDescription {
|
| 77 |
+
description: Some("test".to_string()),
|
| 78 |
+
name: "test".to_string(),
|
| 79 |
+
parameters: serde_json::json!({}),
|
| 80 |
+
},
|
| 81 |
+
}])
|
| 82 |
+
.parallel_tool_calls(true);
|
| 83 |
+
let mut transformer = MakeOpenAiCompat;
|
| 84 |
+
let actual = transformer.transform(fixture);
|
| 85 |
+
let expected = Some(true);
|
| 86 |
+
assert_eq!(actual.parallel_tool_calls, expected);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
#[test]
|
| 90 |
+
fn test_reasoning_removed() {
|
| 91 |
+
let fixture = Request::default().reasoning(forge_domain::ReasoningConfig {
|
| 92 |
+
enabled: Some(true),
|
| 93 |
+
effort: None,
|
| 94 |
+
max_tokens: None,
|
| 95 |
+
exclude: None,
|
| 96 |
+
});
|
| 97 |
+
let mut transformer = MakeOpenAiCompat;
|
| 98 |
+
let actual = transformer.transform(fixture);
|
| 99 |
+
let expected = None;
|
| 100 |
+
assert_eq!(actual.reasoning, expected);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
#[test]
|
| 104 |
+
fn test_max_tokens_mapped_correctly() {
|
| 105 |
+
let fixture = Request::default().max_tokens(100);
|
| 106 |
+
let mut transformer = MakeOpenAiCompat;
|
| 107 |
+
let actual = transformer.transform(fixture);
|
| 108 |
+
let expected = Some(100);
|
| 109 |
+
assert_eq!(actual.max_completion_tokens, expected);
|
| 110 |
+
assert_eq!(actual.max_tokens, None);
|
| 111 |
+
}
|
| 112 |
+
}
|