mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(core/config): support openrouter routing metadata\n\ncloses #241
This commit is contained in:
@@ -297,6 +297,23 @@ pub(crate) async fn stream_chat_completions(
|
||||
"tools": tools_json,
|
||||
});
|
||||
|
||||
if let Some(openrouter_cfg) = provider.openrouter_config() {
|
||||
if let Some(obj) = payload.as_object_mut() {
|
||||
if let Some(provider_cfg) = &openrouter_cfg.provider {
|
||||
obj.insert(
|
||||
"provider".to_string(),
|
||||
serde_json::to_value(provider_cfg)?
|
||||
);
|
||||
}
|
||||
if let Some(route) = &openrouter_cfg.route {
|
||||
obj.insert("route".to_string(), route.clone());
|
||||
}
|
||||
for (key, value) in &openrouter_cfg.extra {
|
||||
obj.entry(key.clone()).or_insert(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If an Ollama context override is present, propagate it. Some Ollama
|
||||
// builds honor `num_ctx` directly in OpenAI-compatible Chat Completions,
|
||||
// and others accept it under an `options` object – include both.
|
||||
|
||||
@@ -297,6 +297,22 @@ impl ModelClient {
|
||||
if azure_workaround {
|
||||
attach_item_ids(&mut payload_json, &input_with_instructions);
|
||||
}
|
||||
if let Some(openrouter_cfg) = self.provider.openrouter_config() {
|
||||
if let Some(obj) = payload_json.as_object_mut() {
|
||||
if let Some(provider) = &openrouter_cfg.provider {
|
||||
obj.insert(
|
||||
"provider".to_string(),
|
||||
serde_json::to_value(provider)?
|
||||
);
|
||||
}
|
||||
if let Some(route) = &openrouter_cfg.route {
|
||||
obj.insert("route".to_string(), route.clone());
|
||||
}
|
||||
for (key, value) in &openrouter_cfg.extra {
|
||||
obj.entry(key.clone()).or_insert(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let payload_body = serde_json::to_string(&payload_json)?;
|
||||
|
||||
let mut attempt = 0;
|
||||
@@ -1249,6 +1265,7 @@ mod tests {
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(1000),
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
let events = collect_events(
|
||||
@@ -1309,6 +1326,7 @@ mod tests {
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(1000),
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes()], provider).await;
|
||||
@@ -1343,6 +1361,7 @@ mod tests {
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(1000),
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes()], provider).await;
|
||||
@@ -1448,6 +1467,7 @@ mod tests {
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(1000),
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
let out = run_sse(evs, provider).await;
|
||||
|
||||
@@ -2552,6 +2552,7 @@ model_verbosity = "high"
|
||||
stream_max_retries: Some(10),
|
||||
stream_idle_timeout_ms: Some(300_000),
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
let model_provider_map = {
|
||||
let mut model_provider_map = built_in_model_providers();
|
||||
|
||||
@@ -52,6 +52,8 @@ mod unified_exec;
|
||||
mod user_instructions;
|
||||
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
pub use model_provider_info::ModelProviderInfo;
|
||||
pub use model_provider_info::OpenRouterConfig;
|
||||
pub use model_provider_info::OpenRouterProviderConfig;
|
||||
pub use model_provider_info::WireApi;
|
||||
pub use model_provider_info::built_in_model_providers;
|
||||
pub use model_provider_info::create_oss_provider_with_base_url;
|
||||
|
||||
@@ -10,7 +10,8 @@ use crate::CodexAuth;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::env::VarError;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -84,6 +85,84 @@ pub struct ModelProviderInfo {
|
||||
/// Whether this provider requires some form of standard authentication (API key, ChatGPT token).
|
||||
#[serde(default)]
|
||||
pub requires_openai_auth: bool,
|
||||
|
||||
/// Optional OpenRouter-specific configuration for routing preferences and metadata.
|
||||
#[serde(default)]
|
||||
pub openrouter: Option<OpenRouterConfig>,
|
||||
}
|
||||
|
||||
/// OpenRouter-specific configuration, allowing users to control routing and pricing metadata.
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct OpenRouterConfig {
|
||||
/// Provider-level routing preferences forwarded to OpenRouter.
|
||||
pub provider: Option<OpenRouterProviderConfig>,
|
||||
|
||||
/// Optional `route` payload forwarded as-is to OpenRouter for advanced routing.
|
||||
pub route: Option<Value>,
|
||||
|
||||
/// Additional top-level fields that may be forwarded to OpenRouter as the API evolves.
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Provider routing preferences supported by OpenRouter.
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct OpenRouterProviderConfig {
|
||||
pub order: Option<Vec<String>>,
|
||||
pub allow_fallbacks: Option<bool>,
|
||||
pub require_parameters: Option<bool>,
|
||||
pub data_collection: Option<OpenRouterDataCollectionPolicy>,
|
||||
pub zdr: Option<bool>,
|
||||
pub only: Option<Vec<String>>,
|
||||
pub ignore: Option<Vec<String>>,
|
||||
pub quantizations: Option<Vec<String>>,
|
||||
pub sort: Option<OpenRouterProviderSort>,
|
||||
pub max_price: Option<OpenRouterMaxPrice>,
|
||||
|
||||
/// Catch-all for additional provider keys so new OpenRouter features do not break deserialization.
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OpenRouterDataCollectionPolicy {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
impl Default for OpenRouterDataCollectionPolicy {
|
||||
fn default() -> Self {
|
||||
OpenRouterDataCollectionPolicy::Allow
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OpenRouterProviderSort {
|
||||
Price,
|
||||
Throughput,
|
||||
Latency,
|
||||
}
|
||||
|
||||
impl Default for OpenRouterProviderSort {
|
||||
fn default() -> Self {
|
||||
OpenRouterProviderSort::Price
|
||||
}
|
||||
}
|
||||
|
||||
/// `max_price` envelope for OpenRouter provider routing controls.
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct OpenRouterMaxPrice {
|
||||
pub total: Option<f64>,
|
||||
pub prompt: Option<f64>,
|
||||
pub completion: Option<f64>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
impl ModelProviderInfo {
|
||||
@@ -141,6 +220,11 @@ impl ModelProviderInfo {
|
||||
Ok(self.apply_http_headers(builder))
|
||||
}
|
||||
|
||||
/// Returns the OpenRouter-specific configuration, if this provider declares one.
|
||||
pub fn openrouter_config(&self) -> Option<&OpenRouterConfig> {
|
||||
self.openrouter.as_ref()
|
||||
}
|
||||
|
||||
fn get_query_string(&self) -> String {
|
||||
self.query_params
|
||||
.as_ref()
|
||||
@@ -318,6 +402,7 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_openai_auth: true,
|
||||
openrouter: None,
|
||||
},
|
||||
),
|
||||
(BUILT_IN_OSS_MODEL_PROVIDER_ID, create_oss_provider()),
|
||||
@@ -362,6 +447,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo {
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +487,7 @@ base_url = "http://localhost:11434/v1"
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
@@ -430,6 +517,7 @@ query_params = { api-version = "2025-04-01-preview" }
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
@@ -462,6 +550,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
@@ -484,6 +573,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +606,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
assert!(named_provider.is_azure_responses_endpoint());
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ use codex_core::LocalShellExecAction;
|
||||
use codex_core::LocalShellStatus;
|
||||
use codex_core::ModelClient;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::OpenRouterConfig;
|
||||
use codex_core::OpenRouterProviderConfig;
|
||||
use codex_core::Prompt;
|
||||
use codex_core::ReasoningItemContent;
|
||||
use codex_core::ResponseItem;
|
||||
@@ -14,7 +16,8 @@ use codex_core::debug_logger::DebugLogger;
|
||||
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use futures::StreamExt;
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
use wiremock::Mock;
|
||||
@@ -28,6 +31,16 @@ fn network_disabled() -> bool {
|
||||
}
|
||||
|
||||
async fn run_request(input: Vec<ResponseItem>) -> Value {
|
||||
run_request_with_provider(input, |_| {}).await
|
||||
}
|
||||
|
||||
async fn run_request_with_provider<F>(
|
||||
input: Vec<ResponseItem>,
|
||||
mutator: F,
|
||||
) -> Value
|
||||
where
|
||||
F: FnOnce(&mut ModelProviderInfo),
|
||||
{
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let template = ResponseTemplate::new(200)
|
||||
@@ -44,7 +57,7 @@ async fn run_request(input: Vec<ResponseItem>) -> Value {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = ModelProviderInfo {
|
||||
let mut provider = ModelProviderInfo {
|
||||
name: "mock".into(),
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
env_key: None,
|
||||
@@ -57,7 +70,9 @@ async fn run_request(input: Vec<ResponseItem>) -> Value {
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(5_000),
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
mutator(&mut provider);
|
||||
|
||||
let codex_home = match TempDir::new() {
|
||||
Ok(dir) => dir,
|
||||
@@ -193,6 +208,39 @@ async fn omits_reasoning_when_none_present() {
|
||||
assert!(assistant.get("reasoning").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn openrouter_provider_metadata_is_forwarded() {
|
||||
if network_disabled() {
|
||||
println!(
|
||||
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let body = run_request_with_provider(vec![user_message("hello")], |provider| {
|
||||
let mut extra = BTreeMap::new();
|
||||
extra.insert("dry_run".to_string(), Value::Bool(true));
|
||||
provider.openrouter = Some(OpenRouterConfig {
|
||||
provider: Some(OpenRouterProviderConfig {
|
||||
order: Some(vec!["openai/gpt-4o-mini".to_string()]),
|
||||
allow_fallbacks: Some(false),
|
||||
..OpenRouterProviderConfig::default()
|
||||
}),
|
||||
route: Some(json!({ "path": ["primary", "secondary"] })),
|
||||
extra,
|
||||
});
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(body["provider"]["order"], json!(["openai/gpt-4o-mini"]));
|
||||
assert_eq!(body["provider"]["allow_fallbacks"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
body["route"],
|
||||
json!({ "path": ["primary", "secondary"] })
|
||||
);
|
||||
assert_eq!(body["dry_run"], Value::Bool(true));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn attaches_reasoning_to_previous_assistant() {
|
||||
if network_disabled() {
|
||||
|
||||
@@ -50,6 +50,7 @@ async fn run_stream(sse_body: &str) -> Vec<ResponseEvent> {
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(5_000),
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
let codex_home = match TempDir::new() {
|
||||
|
||||
@@ -6,6 +6,8 @@ use codex_core::LocalShellExecAction;
|
||||
use codex_core::LocalShellStatus;
|
||||
use codex_core::ModelClient;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::OpenRouterConfig;
|
||||
use codex_core::OpenRouterProviderConfig;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::Prompt;
|
||||
use codex_core::ReasoningItemContent;
|
||||
@@ -27,6 +29,8 @@ use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use futures::StreamExt;
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
@@ -751,6 +755,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(5_000),
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -1125,6 +1130,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
// Init session
|
||||
@@ -1201,6 +1207,7 @@ async fn env_var_overrides_loaded_auth() {
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
// Init session
|
||||
@@ -1368,3 +1375,97 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
"request 3 tail mismatch",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn openrouter_metadata_is_forwarded_in_responses_payload() {
|
||||
non_sandbox_test!();
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let template = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp1\"}}\n\n",
|
||||
"text/event-stream",
|
||||
);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(template)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut extra = BTreeMap::new();
|
||||
extra.insert("dry_run".to_string(), Value::Bool(true));
|
||||
|
||||
let mut provider = ModelProviderInfo {
|
||||
name: "openrouter".into(),
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
env_key: None,
|
||||
env_key_instructions: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(5_000),
|
||||
requires_openai_auth: false,
|
||||
openrouter: Some(OpenRouterConfig {
|
||||
provider: Some(OpenRouterProviderConfig {
|
||||
order: Some(vec!["openai/gpt-4o-mini".to_string()]),
|
||||
allow_fallbacks: Some(true),
|
||||
..OpenRouterProviderConfig::default()
|
||||
}),
|
||||
route: Some(json!({ "strategy": "balanced" })),
|
||||
extra,
|
||||
}),
|
||||
};
|
||||
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider_id = provider.name.clone();
|
||||
config.model_provider = provider.clone();
|
||||
let effort = config.model_reasoning_effort;
|
||||
let summary = config.model_reasoning_summary;
|
||||
let config = Arc::new(config);
|
||||
|
||||
let client = ModelClient::new(
|
||||
Arc::clone(&config),
|
||||
None,
|
||||
provider,
|
||||
effort,
|
||||
summary,
|
||||
ConversationId::new(),
|
||||
);
|
||||
|
||||
let mut prompt = Prompt::default();
|
||||
prompt.input.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
});
|
||||
|
||||
let mut stream = client.stream(&prompt).await.expect("stream responses");
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("stream event");
|
||||
}
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request captured");
|
||||
let request_body = requests[0]
|
||||
.body_json::<serde_json::Value>()
|
||||
.expect("request body json");
|
||||
|
||||
assert_eq!(
|
||||
request_body["provider"]["order"],
|
||||
json!(["openai/gpt-4o-mini"])
|
||||
);
|
||||
assert_eq!(request_body["provider"]["allow_fallbacks"], Value::Bool(true));
|
||||
assert_eq!(request_body["route"], json!({ "strategy": "balanced" }));
|
||||
assert_eq!(request_body["dry_run"], Value::Bool(true));
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ async fn continue_after_stream_error() {
|
||||
stream_max_retries: Some(1),
|
||||
stream_idle_timeout_ms: Some(2_000),
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
|
||||
@@ -82,6 +82,7 @@ async fn retries_on_early_close() {
|
||||
stream_max_retries: Some(1),
|
||||
stream_idle_timeout_ms: Some(2000),
|
||||
requires_openai_auth: false,
|
||||
openrouter: None,
|
||||
};
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
|
||||
Reference in New Issue
Block a user