feat(core/config): support openrouter routing metadata\n\ncloses #241

This commit is contained in:
James Peter
2025-09-25 14:42:48 +10:00
parent a2102509b6
commit 060cd5e2b7
10 changed files with 286 additions and 3 deletions
+17
View File
@@ -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.
+20
View File
@@ -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;
+1
View File
@@ -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();
+2
View File
@@ -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;
+92 -1
View File
@@ -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() {
+101
View File
@@ -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()