chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
@@ -0,0 +1,35 @@
[package]
name = "jcode-provider-openrouter-runtime"
version = "0.1.0"
edition = "2024"
description = "OpenRouter / OpenAI-compatible provider runtime (aggregator + direct profile endpoints) for jcode, kept downstream of jcode-base so provider edits do not rebuild the app spine"
[lib]
name = "jcode_provider_openrouter_runtime"
path = "src/lib.rs"
[dependencies]
anyhow = "1"
async-trait = "0.1"
futures = "0.3"
# default-features = false: the top-level binary decides heavy optional base
# features (embeddings/bedrock). Runtime crates must not re-enable them via
# feature unification, or --no-default-features release targets (e.g. Windows
# ARM64, which cannot build tract-linalg asm) break.
jcode-base = { path = "../jcode-base", default-features = false }
jcode-message-types = { path = "../jcode-message-types" }
jcode-provider-core = { path = "../jcode-provider-core" }
jcode-provider-openrouter = { path = "../jcode-provider-openrouter" }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
bytes = "1"
tokio = { version = "1", features = ["sync", "time", "rt"] }
tokio-stream = "0.1"
[dev-dependencies]
# The migrated openrouter tests use jcode-base's test-env sandbox.
jcode-base = { path = "../jcode-base", features = ["test-support"] }
jcode-provider-metadata = { path = "../jcode-provider-metadata" }
tempfile = "3"
toml = "0.8"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,751 @@
use super::openrouter_sse_stream::run_stream_with_retries;
use super::*;
use jcode_base::provider::{ModelCatalogRefreshSummary, summarize_model_catalog_refresh};
#[async_trait]
impl Provider for OpenRouterProvider {
fn runtime_display_name(&self) -> String {
OpenRouterProvider::runtime_display_name(self)
}
fn supports_provider_routing_features(&self) -> bool {
OpenRouterProvider::supports_provider_routing_features(self)
}
fn direct_openai_compatible_route_parts(&self) -> Option<(String, String, String)> {
OpenRouterProvider::direct_openai_compatible_route_parts(self)
}
fn explicit_provider_pin_for_current_model(&self) -> Option<String> {
OpenRouterProvider::explicit_provider_pin_for_current_model(self)
}
fn maybe_schedule_endpoint_refresh_for_display(
&self,
model: &str,
cache_age_secs: Option<u64>,
context: &'static str,
) -> bool {
OpenRouterProvider::maybe_schedule_endpoint_refresh_for_display(
self,
model,
cache_age_secs,
context,
)
}
async fn complete(
&self,
messages: &[Message],
tools: &[ToolDefinition],
system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
let model = self.model.read().await.clone();
let reasoning_effort = self.reasoning_effort();
let thinking_override = Self::thinking_override();
// Moonshot's dedicated Kimi coding endpoint enables thinking server-side
// by default and rejects any assistant tool-call message that lacks
// `reasoning_content`, even though its model id (`kimi-for-coding`) is
// not a moonshotai/kimi-k2 model and the profile runs without OpenRouter
// provider features (issue #322). We must attach `reasoning_content` to
// those messages, but must NOT add the OpenRouter-specific top-level
// `thinking` field (the endpoint already manages thinking itself), so
// this is kept separate from `thinking_enabled`.
let kimi_coding_endpoint = self.is_kimi_coding_endpoint(&model);
let thinking_enabled = thinking_override.or_else(|| {
if Self::is_kimi_model(&model) {
Some(true)
} else {
None
}
});
let allow_reasoning = (self.supports_provider_features || kimi_coding_endpoint)
&& thinking_enabled != Some(false);
let include_reasoning_content = thinking_enabled == Some(true)
|| (allow_reasoning && Self::is_kimi_model(&model))
|| kimi_coding_endpoint;
// Some OpenAI-compatible providers (e.g. Mistral) strictly enforce the
// OpenAI schema and reject the non-standard `reasoning_content` message
// field and top-level `thinking` request field with a 422 error
// ("Extra inputs are not permitted"). Suppress both for those endpoints
// regardless of any thinking override (issue #261).
let strict_openai_schema =
Self::strict_openai_schema_endpoint(self.profile_id.as_deref(), &self.api_base);
let allow_reasoning = allow_reasoning && !strict_openai_schema;
let include_reasoning_content = include_reasoning_content && !strict_openai_schema;
let allow_image_input = self.supports_image_input();
let mut effective_messages: Vec<Message> = messages.to_vec();
let cache_supported = self.model_supports_cache(&model).await;
let cache_control_added = if cache_supported {
add_cache_breakpoint(&mut effective_messages)
} else {
false
};
let api_messages = jcode_provider_openrouter::request::build_chat_messages(
&effective_messages,
system,
allow_reasoning,
include_reasoning_content,
allow_image_input,
);
// Build tools in OpenAI format
let api_tools: Vec<Value> = tools
.iter()
.map(|t| {
serde_json::json!({
"type": "function",
"function": {
"name": t.name,
// Prompt-visible. Approximate token cost for this field:
// t.description_token_estimate().
"description": t.description,
// Sanitized so bare `{"type":"object"}` MCP tool
// schemas do not 400 on strict endpoints (issue #446).
"parameters": jcode_provider_openrouter::request::sanitize_tool_parameters_schema(&t.input_schema),
}
})
})
.collect();
// Build request
let mut request = serde_json::json!({
"model": model,
"messages": api_messages,
"stream": true,
});
if let Some(max_tokens) = self.max_tokens {
request["max_tokens"] = serde_json::json!(max_tokens);
}
let mut sent_reasoning_config = false;
if let Some(effort) = reasoning_effort.as_deref() {
if self.supports_deepseek_reasoning_effort() {
// The `swarm` sentinel maps to the strongest real effort.
let effort = if jcode_base::prompt::is_swarm_effort(effort) {
"max"
} else {
effort
};
if effort != "none" {
request["reasoning_effort"] = serde_json::json!(effort);
sent_reasoning_config = true;
}
} else if self.supports_openai_reasoning_effort() {
// GPT-family models on direct compat gateways (e.g. OpenCode
// Zen serving gpt-5.3-codex-spark) take the standard OpenAI
// `reasoning_effort` field with OpenAI's effort vocabulary.
let effort = if jcode_base::prompt::is_swarm_effort(effort) {
"xhigh"
} else {
effort
};
if effort != "none" {
request["reasoning_effort"] = serde_json::json!(effort);
sent_reasoning_config = true;
}
} else if Self::profile_supports_unified_reasoning(
self.profile_id.as_deref(),
self.send_openrouter_headers,
) {
let effort = if jcode_base::prompt::is_swarm_effort(effort) {
"xhigh"
} else {
effort
};
request["reasoning"] = serde_json::json!({
"effort": effort,
});
sent_reasoning_config = true;
}
}
if !api_tools.is_empty() {
request["tools"] = serde_json::json!(api_tools);
if self.profile_id.as_deref() != Some("fpt") && !self.api_base.contains("fptcloud.com")
{
request["tool_choice"] = serde_json::json!("auto");
}
}
// Optional thinking override for OpenRouter (provider-specific).
// Skip for strict OpenAI-schema endpoints (e.g. Mistral) which reject
// the non-standard top-level `thinking` field with a 422 (issue #261).
if let Some(enable) = thinking_enabled
&& !sent_reasoning_config
&& !strict_openai_schema
{
request["thinking"] = serde_json::json!({
"type": if enable { "enabled" } else { "disabled" }
});
}
// Add provider routing if configured and supported by backend.
let mut provider_obj = None;
if self.supports_provider_features {
let routing = self.effective_routing(&model).await;
if !routing.is_empty() {
let mut obj = serde_json::json!({});
if let Some(ref order) = routing.order {
obj["order"] = serde_json::json!(order);
}
if !routing.allow_fallbacks {
obj["allow_fallbacks"] = serde_json::json!(false);
}
if let Some(ref sort) = routing.sort {
obj["sort"] = serde_json::json!(sort);
}
if let Some(min_tp) = routing.preferred_min_throughput {
obj["preferred_min_throughput"] = serde_json::json!(min_tp);
}
if let Some(max_latency) = routing.preferred_max_latency {
obj["preferred_max_latency"] = serde_json::json!(max_latency);
}
if let Some(max_price) = routing.max_price {
obj["max_price"] = serde_json::json!(max_price);
}
if let Some(require_parameters) = routing.require_parameters {
obj["require_parameters"] = serde_json::json!(require_parameters);
}
provider_obj = Some(obj);
}
}
if cache_control_added && self.supports_provider_features {
let mut obj = provider_obj.unwrap_or_else(|| serde_json::json!({}));
obj["require_parameters"] = serde_json::json!(true);
provider_obj = Some(obj);
}
if let Some(obj) = provider_obj {
request["provider"] = obj;
}
// Merge user-configured extra request-body fields last so they can
// satisfy non-standard backend requirements (e.g. NVIDIA NIM
// DeepSeek-V4 `chat_template_kwargs`) and intentionally override any
// jcode-generated field with the same key (issue #341).
if let Some(extra) = self.extra_body.as_ref()
&& let Some(request_obj) = request.as_object_mut()
{
for (key, value) in extra {
request_obj.insert(key.clone(), value.clone());
}
}
let message_items = request
.get("messages")
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default();
let tools_value = request.get("tools").cloned();
let system_value = message_items
.first()
.filter(|message| message.get("role").and_then(|role| role.as_str()) == Some("system"))
.cloned();
let tool_count = tools_value
.as_ref()
.and_then(|value| value.as_array())
.map(|tools| tools.len())
.unwrap_or(0);
jcode_provider_core::fingerprint::log_provider_canonical_input(
if self.supports_provider_features {
"openrouter"
} else {
"openai-compatible"
},
&model,
"chat_completions",
&request,
&message_items,
system_value.as_ref(),
tools_value.as_ref(),
Some(tool_count),
&[
("cache_supported", cache_supported.to_string()),
("cache_control_added", cache_control_added.to_string()),
("thinking_enabled", format!("{:?}", thinking_enabled)),
(
"provider_features",
self.supports_provider_features.to_string(),
),
],
);
// OpenRouter uses HTTPS/SSE transport only
jcode_base::logging::info("OpenRouter transport: HTTPS (SSE)");
let (tx, rx) = mpsc::channel::<Result<StreamEvent>>(100);
let client = self.client.clone();
let api_base = self.api_base.clone();
let auth = self.auth.clone();
let send_openrouter_headers = self.send_openrouter_headers;
let request_for_retries = request;
let model_for_stream = model.clone();
let provider_pin = Arc::clone(&self.provider_pin);
tokio::spawn(async move {
if tx
.send(Ok(StreamEvent::ConnectionType {
connection: "https/sse".to_string(),
}))
.await
.is_err()
{
return;
}
run_stream_with_retries(
client,
api_base,
auth,
send_openrouter_headers,
request_for_retries,
tx,
provider_pin,
model_for_stream,
)
.await;
});
Ok(Box::pin(ReceiverStream::new(rx)))
}
fn name(&self) -> &str {
"openrouter"
}
fn display_name(&self) -> String {
self.runtime_display_name()
}
fn model(&self) -> String {
self.model
.try_read()
.map(|m| m.clone())
.unwrap_or_else(|_| DEFAULT_MODEL.to_string())
}
fn supports_image_input(&self) -> bool {
if Self::profile_rejects_image_input(self.profile_id.as_deref()) {
return false;
}
// Direct OpenAI-compatible local providers such as Ollama and LM Studio
// document image content support on /v1/chat/completions. We already
// serialize image blocks using OpenAI's image_url content-part shape in
// complete(), so advertise support for direct compatibility profiles.
// Keep the legacy OpenRouter aggregator behavior unchanged here because
// image availability is model/provider-route dependent there.
!self.supports_provider_features
}
fn set_model(&self, model: &str) -> Result<()> {
// OpenRouter accepts any model ID - validation happens at API call time
// This allows using any model without needing to pre-fetch the list
let trimmed = model.trim();
if trimmed.is_empty() {
anyhow::bail!("OpenRouter/OpenAI-compatible model cannot be empty");
}
// Session restore persists the model as `<provider-key>:<model>` so the
// right slot can be reconstructed (see
// `MultiProvider::model_switch_request_for_session_*`). `MultiProvider`
// strips this prefix when routing, but the standalone `OpenRouterProvider`
// used for a named OpenAI-compatible profile does not, so the prefixed
// string would leak to the upstream API and be rejected as an invalid
// model id. Normalize the session-routing prefix back to the bare model
// id here, while leaving built-in routing prefixes (claude:, openai:, ...)
// untouched so cross-provider switches from a saved session still work.
let trimmed = self.strip_session_profile_prefix(trimmed);
let (model_id, provider) = if self.supports_provider_features {
let (model_id, provider) = parse_model_spec(trimmed);
let model_id = if provider.is_some() {
jcode_base::provider::openrouter_catalog_model_id(&model_id).unwrap_or(model_id)
} else {
model_id
};
(model_id, provider)
} else {
// Generic OpenAI-compatible backends often use arbitrary model IDs.
// Only real OpenRouter supports the model@provider pin syntax, so
// preserve the caller's model string exactly for custom endpoints.
(trimmed.to_string(), None)
};
if let Some(profile_id) = self.profile_id.as_deref()
&& !jcode_base::provider_catalog::openai_compatible_profile_model_supports_chat(
profile_id, &model_id,
)
{
anyhow::bail!(
"Model '{}' is listed by the provider catalog but is not currently usable for chat completions through this direct provider. Choose another model from `/model`.",
model_id
);
}
if let Ok(mut current) = self.model.try_write() {
*current = model_id.clone();
} else {
return Err(anyhow::anyhow!(
"Cannot change model while a request is in progress"
));
}
if self.supports_provider_features {
if let Some(provider) = provider {
self.set_explicit_pin(&model_id, provider);
} else {
self.clear_pin_if_model_changed(&model_id, true);
}
} else {
self.clear_pin_if_model_changed(&model_id, true);
}
Ok(())
}
fn reasoning_effort(&self) -> Option<String> {
if !self.supports_any_reasoning_effort() {
return None;
}
self.reasoning_effort
.try_read()
.ok()
.and_then(|effort| effort.clone())
}
fn set_reasoning_effort(&self, effort: &str) -> Result<()> {
if !self.supports_any_reasoning_effort() {
anyhow::bail!(
"Reasoning effort is not supported by the current model/profile. It works for OpenRouter, DeepSeek-family and GPT-family reasoning models, and profiles with supports_reasoning_effort = true."
);
}
let normalized = self.normalize_reasoning_effort_for_self(effort);
let mut current = self.reasoning_effort.try_write().map_err(|_| {
anyhow::anyhow!("Cannot change reasoning effort while a request is in progress")
})?;
*current = normalized;
Ok(())
}
fn available_efforts(&self) -> Vec<&'static str> {
if self.supports_deepseek_reasoning_effort() {
vec![
"none",
"low",
"medium",
"high",
"max",
"swarm",
"swarm-deep",
]
} else if self.supports_openai_reasoning_effort()
|| Self::profile_supports_unified_reasoning(
self.profile_id.as_deref(),
self.send_openrouter_headers,
)
{
vec![
"none",
"low",
"medium",
"high",
"xhigh",
"swarm",
"swarm-deep",
]
} else {
vec![]
}
}
fn available_models(&self) -> Vec<&'static str> {
// OpenRouter models are fetched dynamically from the API.
// Static list is empty; use available_models_display for cached list.
vec![]
}
fn available_models_display(&self) -> Vec<String> {
let finalize = |models: Vec<String>| self.filter_profile_chat_supported_models(models);
let with_current_model = |mut models: Vec<String>| {
let current = self.model();
if !current.trim().is_empty() && !models.iter().any(|model| model == &current) {
models.insert(0, current);
}
models
};
let should_merge_static_models = self.should_merge_static_models_with_live_catalog();
let merge_static_models = |mut models: Vec<String>| {
if !should_merge_static_models {
return with_current_model(models);
}
for model in &self.static_models {
if !model.trim().is_empty() && !models.iter().any(|existing| existing == model) {
models.push(model.clone());
}
}
with_current_model(models)
};
if !self.supports_model_catalog {
if !self.static_models.is_empty() {
return finalize(with_current_model(self.static_models.clone()));
}
let model = self.model();
return finalize(if model.trim().is_empty() {
Vec::new()
} else {
vec![model]
});
}
if let Ok(cache) = self.models_cache.try_read()
&& cache.fetched
&& !cache.models.is_empty()
{
if let Some(cache_age) = cache
.cached_at
.and_then(|cached_at| current_unix_secs().map(|now| now.saturating_sub(cached_at)))
{
self.maybe_schedule_model_catalog_refresh(cache_age, "display memory cache");
}
return finalize(merge_static_models(
cache.models.iter().map(|m| m.id.clone()).collect(),
));
}
if let Some(cache_entry) = self.load_usable_model_disk_cache_entry() {
let cache_age = current_unix_secs()
.map(|now| now.saturating_sub(cache_entry.cached_at))
.unwrap_or(0);
if let Ok(mut cache) = self.models_cache.try_write() {
cache.models = cache_entry.models.clone();
cache.fetched = true;
cache.cached_at = Some(cache_entry.cached_at);
}
self.maybe_schedule_model_catalog_refresh(cache_age, "display disk cache");
return finalize(merge_static_models(
cache_entry.models.into_iter().map(|m| m.id).collect(),
));
}
// No memory or disk catalog yet. This commonly happens immediately after
// adding a new OpenAI-compatible endpoint from `/login`: the provider is
// hot-initialized, but the picker may render before the post-auth
// prefetch has completed. Make the picker path self-healing by starting
// the first `/models` fetch here, then return the best immediate
// fallback. The background refresh publishes ModelsUpdated, which
// invalidates/reopens the picker with the newly discovered models.
self.maybe_schedule_model_catalog_refresh(u64::MAX, "display cache miss");
if !self.static_models.is_empty() {
return finalize(with_current_model(self.static_models.clone()));
}
let model = self.model();
finalize(if model.trim().is_empty() {
Vec::new()
} else {
vec![model]
})
}
fn available_models_for_switching(&self) -> Vec<String> {
self.available_models_display()
}
fn model_routes(&self) -> Vec<jcode_provider_core::ModelRoute> {
let (provider_label, api_method, detail) = self
.direct_openai_compatible_route_parts()
.unwrap_or_else(|| {
(
"OpenRouter".to_string(),
"openrouter".to_string(),
String::new(),
)
});
let live_model_ids = self.cached_live_model_ids_for_display();
let static_model_ids: HashSet<String> = self.static_models.iter().cloned().collect();
let is_direct_profile = self.profile_id.is_some();
self.available_models_display()
.into_iter()
.filter(|model| jcode_base::provider::is_listable_model_name(model))
.map(|model| {
let fallback_not_live = is_direct_profile
&& live_model_ids
.as_ref()
.map(|live| !live.contains(&model))
.unwrap_or_else(|| static_model_ids.contains(&model));
let route_detail = if fallback_not_live {
if detail.trim().is_empty() {
"fallback: static provider model list".to_string()
} else {
format!("{}; fallback: static provider model list", detail)
}
} else {
detail.clone()
};
jcode_provider_core::ModelRoute {
model,
provider: provider_label.clone(),
api_method: api_method.clone(),
available: true,
detail: route_detail,
cheapness: None,
}
})
.collect()
}
async fn prefetch_models(&self) -> Result<()> {
if !self.supports_model_catalog {
return Ok(());
}
let _ = self.fetch_models().await?;
if self.supports_provider_features {
// Also prefetch endpoints for the current model so preferred_provider() works immediately.
let model = self.model();
if load_endpoints_disk_cache(&model).is_none() {
let _ = self.fetch_endpoints(&model).await;
}
}
Ok(())
}
async fn refresh_model_catalog(&self) -> Result<ModelCatalogRefreshSummary> {
let before_models = self.available_models_display();
let before_routes = self.model_routes();
let refreshed_models = self.refresh_models().await?;
if self.supports_provider_features {
let mut targets = Vec::new();
let mut seen = HashSet::new();
let push_target =
|targets: &mut Vec<String>, seen: &mut HashSet<String>, model: String| {
if !model.trim().is_empty() && seen.insert(model.clone()) {
targets.push(model);
}
};
push_target(&mut targets, &mut seen, self.model());
for model in refreshed_models.iter().map(|info| info.id.clone()).take(16) {
push_target(&mut targets, &mut seen, model);
}
for model in refreshed_models.iter().map(|info| info.id.clone()) {
if load_endpoints_disk_cache_public(&model).is_some() {
push_target(&mut targets, &mut seen, model);
}
if targets.len() >= 24 {
break;
}
}
futures::stream::iter(targets)
.for_each_concurrent(4, |model| async move {
let _ = self.refresh_endpoints(&model).await;
})
.await;
}
let after_models = self.available_models_display();
let after_routes = self.model_routes();
Ok(summarize_model_catalog_refresh(
before_models,
after_models,
before_routes,
after_routes,
))
}
fn supports_compaction(&self) -> bool {
true
}
fn preferred_provider(&self) -> Option<String> {
self.preferred_provider()
}
fn context_window(&self) -> usize {
// Defensive: the runtime model may transiently carry a session-routing
// `<profile>:<model>` prefix (e.g. right after session restore, before
// set_model normalizes it). Strip it so the per-model context_window
// lookups below hit on the bare model id instead of falling through to
// the (large) provider default and over-budgeting the request. See #403.
let raw_model = self.model();
let model_id = self.strip_session_profile_prefix(&raw_model).to_string();
// Try cached model data from OpenRouter API
let cache = self.models_cache.try_read();
if let Ok(cache) = cache
&& let Some(model) = cache.models.iter().find(|m| m.id == model_id)
&& let Some(ctx) = model.context_length
{
return ctx as usize;
}
// A background/profile catalog refresh may have already persisted live
// /models metadata before this provider instance has hydrated its
// in-memory cache. Use that live catalog context length before falling
// back to static defaults.
if let Some(cache_entry) = self.load_usable_model_disk_cache_entry()
&& let Some(model) = cache_entry.models.iter().find(|m| m.id == model_id)
&& let Some(ctx) = model.context_length
{
return ctx as usize;
}
let normalized_model_id = model_id.trim().to_ascii_lowercase();
if let Some(limit) = self.static_context_limits.get(&normalized_model_id) {
return *limit;
}
if let Some(profile_id) = self.profile_id.as_deref()
&& let Some(limit) =
jcode_base::provider_catalog::openai_compatible_profile_context_limit(
profile_id, &model_id,
)
{
return limit;
}
jcode_provider_core::context_limit_for_model_with_provider(&model_id, Some(self.name()))
.unwrap_or(jcode_provider_core::DEFAULT_CONTEXT_LIMIT)
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(Self {
client: self.client.clone(),
model: Arc::new(RwLock::new(
self.model.try_read().map(|m| m.clone()).unwrap_or_default(),
)),
reasoning_effort: Arc::new(RwLock::new(self.reasoning_effort())),
api_base: self.api_base.clone(),
auth: self.auth.clone(),
supports_provider_features: self.supports_provider_features,
supports_model_catalog: self.supports_model_catalog,
profile_id: self.profile_id.clone(),
reasoning_effort_support: self.reasoning_effort_support,
max_tokens: self.max_tokens,
extra_body: self.extra_body.clone(),
static_models: self.static_models.clone(),
static_context_limits: self.static_context_limits.clone(),
send_openrouter_headers: self.send_openrouter_headers,
models_cache: Arc::clone(&self.models_cache),
model_catalog_refresh: Arc::clone(&self.model_catalog_refresh),
provider_routing: Arc::new(RwLock::new(
self.provider_routing
.try_read()
.map(|r| r.clone())
.unwrap_or_default(),
)),
provider_pin: Arc::new(Mutex::new(None)),
endpoints_cache: Arc::clone(&self.endpoints_cache),
endpoint_refresh: Arc::clone(&self.endpoint_refresh),
})
}
}
@@ -0,0 +1,393 @@
use super::*;
use jcode_provider_openrouter::stream::OpenRouterStream;
fn local_endpoint_troubleshooting_hint(api_base: &str, model: &str) -> &'static str {
let lower = api_base.to_ascii_lowercase();
if lower.contains("localhost:11434") || lower.contains("127.0.0.1:11434") {
return "Ollama hint: make sure `ollama serve` is running, the model is installed with `ollama pull <model>`, and run jcode with an installed model, for example `jcode --provider ollama --model llama3.2 run 'hello'`.";
}
if lower.contains("localhost:1234") || lower.contains("127.0.0.1:1234") {
return "LM Studio hint: start the Local Server in LM Studio, load a chat model, and run jcode with the exact model id shown by LM Studio's /v1/models endpoint.";
}
if lower.contains("localhost") || lower.contains("127.0.0.1") || lower.contains("[::1]") {
return "Local endpoint hint: make sure the server is running, the base URL includes /v1, the selected model is loaded, and the server supports streaming POST /chat/completions.";
}
let _ = model;
"Hint: check network connectivity, DNS/TLS, that the base URL includes the API version (usually /v1), and that the model exists on the provider."
}
// ============================================================================
// SSE Stream Parser
// ============================================================================
#[expect(
clippy::too_many_arguments,
reason = "stream helpers thread transport, auth, request, event channel, and pin state explicitly"
)]
pub(super) async fn run_stream_with_retries(
client: Client,
api_base: String,
auth: ProviderAuth,
send_openrouter_headers: bool,
request: Value,
tx: mpsc::Sender<Result<StreamEvent>>,
provider_pin: Arc<Mutex<Option<ProviderPin>>>,
model: String,
) {
let mut last_error = None;
let mut next_retry_delay = None;
for attempt in 0..MAX_RETRIES {
if attempt > 0 {
let delay = jcode_provider_core::retry_after::retry_delay(
attempt,
RETRY_BASE_DELAY_MS,
next_retry_delay.take(),
);
tokio::time::sleep(delay).await;
jcode_base::logging::info(&format!(
"Retrying API request using {} (attempt {}/{})",
auth.label(),
attempt + 1,
MAX_RETRIES
));
}
jcode_base::logging::info(&format!(
"API stream attempt {}/{} over HTTPS transport (model: {}, endpoint: {}, auth: {})",
attempt + 1,
MAX_RETRIES,
model,
api_base,
auth.label()
));
// Track whether this attempt streams replay-visible output so a
// mid-stream transport fault can roll the partial output back on the
// consumer before the retry replays the response from the top.
let (attempt_tx, attempt_guard) =
jcode_provider_core::attempt_tracker::track_attempt_output(tx.clone());
// Retries use a fresh unpooled client: the fault that broke attempt N
// (e.g. TLS BadRecordMac from a corrupting middlebox) may also have
// poisoned other idle pooled connections opened through the same path,
// so reusing the shared pool can fail identically. A fresh client
// guarantees a brand-new TCP+TLS connection.
let attempt_client = if attempt == 0 {
client.clone()
} else {
jcode_provider_core::fresh_transport_client()
};
match stream_response(
attempt_client,
api_base.clone(),
auth.clone(),
send_openrouter_headers,
request.clone(),
attempt_tx,
Arc::clone(&provider_pin),
model.clone(),
)
.await
{
Ok(()) => {
let _ = attempt_guard.finish().await;
return;
}
Err(e) => {
let saw_output = attempt_guard.finish().await;
// Full anyhow chain ({:#}) so a `.context(...)`-wrapped transport
// cause (e.g. TLS BadRecordMac) is visible to the classifier.
let error_str = format!("{e:#}").to_lowercase();
if is_retryable_error(&error_str) && attempt + 1 < MAX_RETRIES {
if saw_output {
// Partial output already reached the consumer; tell it
// to discard the partial attempt so the retried
// response replays cleanly instead of duplicating.
jcode_base::logging::warn(&format!(
"Transient API error after partial output; rolling back partial attempt and retrying: {}",
e
));
let _ = tx
.send(Ok(StreamEvent::RetryRollback {
attempt: attempt + 2,
max: MAX_RETRIES,
}))
.await;
} else {
jcode_base::logging::info(&format!(
"Transient API error, will retry: {}",
e
));
}
next_retry_delay = jcode_provider_core::retry_after::retry_after_from_error(&e);
last_error = Some(e);
continue;
}
let _ = tx.send(Err(e)).await;
return;
}
}
}
if let Some(e) = last_error {
let _ = tx
.send(Err(anyhow::anyhow!(
"Failed after {} retries: {}",
MAX_RETRIES,
e
)))
.await;
}
}
#[expect(
clippy::too_many_arguments,
reason = "stream helpers thread transport, auth, request, event channel, and pin state explicitly"
)]
async fn stream_response(
client: Client,
api_base: String,
auth: ProviderAuth,
send_openrouter_headers: bool,
request: Value,
tx: mpsc::Sender<Result<StreamEvent>>,
provider_pin: Arc<Mutex<Option<ProviderPin>>>,
model: String,
) -> Result<()> {
use jcode_message_types::ConnectionPhase;
let _ = tx
.send(Ok(StreamEvent::ConnectionPhase {
phase: ConnectionPhase::Connecting,
}))
.await;
let connect_start = std::time::Instant::now();
let url = format!("{}/chat/completions", api_base);
let mut req = apply_kimi_coding_agent_headers(
auth.apply(
client
.post(&url)
.header("Content-Type", "application/json")
.header("Accept-Encoding", "identity"),
)
.await?,
&api_base,
Some(&model),
);
if send_openrouter_headers {
req = req
.header("HTTP-Referer", "https://github.com/jcode")
.header("X-Title", "jcode");
}
let response = req
.json(&request)
.send()
.await
.with_context(|| {
let hint = local_endpoint_troubleshooting_hint(&api_base, &model);
format!(
"Failed to send OpenAI-compatible chat request\n endpoint: {}\n model: {}\n auth: {}\n{}",
url,
model,
auth.label(),
hint
)
})?;
let connect_ms = connect_start.elapsed().as_millis();
jcode_base::logging::info(&format!(
"HTTP connection established in {}ms (status={})",
connect_ms,
response.status()
));
if !response.status().is_success() {
let status = response.status();
let retry_after = jcode_provider_core::retry_after::retry_after(response.headers());
let body = jcode_base::util::http_error_body(response, "HTTP error").await;
let hint = local_endpoint_troubleshooting_hint(&api_base, &model);
return Err(jcode_provider_core::retry_after::error_with_retry_after(
format!(
"OpenAI-compatible chat request failed\n endpoint: {}\n model: {}\n auth: {}\n status: {}\n response: {}\n{}",
url,
model,
auth.label(),
status,
body,
hint
),
retry_after,
));
}
let _ = tx
.send(Ok(StreamEvent::ConnectionPhase {
phase: ConnectionPhase::WaitingForResponse,
}))
.await;
let mut stream = OpenRouterStream::new(response.bytes_stream(), model.clone(), provider_pin);
// Idle timeout between streamed chunks. Configurable so slow reasoning
// models (e.g. DeepSeek) that think silently for minutes before emitting
// tokens don't trip a premature timeout (issue #196). Resolved from
// `[provider] stream_idle_timeout_secs` / `JCODE_STREAM_IDLE_TIMEOUT_SECS`,
// defaulting to 180s. Shared with the native provider paths (issue #434).
let sse_chunk_timeout = jcode_base::provider::stream_idle_timeout();
let idle_timeout_secs = sse_chunk_timeout.as_secs();
loop {
let event = match tokio::time::timeout(sse_chunk_timeout, stream.next()).await {
Ok(Some(Ok(event))) => event,
Ok(Some(Err(e))) => anyhow::bail!(
"OpenAI-compatible stream error\n endpoint: {}\n model: {}\n auth: {}\n error: {}",
url,
model,
auth.label(),
e
),
Ok(None) => break, // stream ended normally
Err(_) => {
jcode_base::logging::warn(&format!(
"OpenRouter SSE stream timed out (no data for {}s)",
idle_timeout_secs
));
anyhow::bail!(
"OpenAI-compatible stream timeout\n endpoint: {}\n model: {}\n auth: {}\n timeout: no data received for {} seconds\n{}",
url,
model,
auth.label(),
idle_timeout_secs,
local_endpoint_troubleshooting_hint(&api_base, &model)
);
}
};
if tx.send(Ok(event)).await.is_err() {
return Ok(());
}
}
Ok(())
}
/// Extract the HTTP status code reported in a formatted provider error string.
///
/// Error strings produced in this module embed the status as `status: <code>`
/// (e.g. `status: 402 Payment Required`). The input may be lowercased before
/// it reaches here, so matching is case-insensitive.
fn parsed_http_status(error_str: &str) -> Option<u16> {
let lower = error_str.to_ascii_lowercase();
let idx = lower.find("status:")?;
let rest = lower[idx + "status:".len()..].trim_start();
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.len() == 3 {
digits.parse().ok()
} else {
None
}
}
fn is_retryable_error(error_str: &str) -> bool {
// Explicit non-retryable HTTP statuses take precedence over the loose
// substring heuristics below. These are deterministic client-side failures
// (auth, billing, malformed request) where retrying is futile and just
// burns time/credits. 429 (rate limit) is classified explicitly so it does
// not depend on provider-specific body wording.
match parsed_http_status(error_str) {
Some(400 | 401 | 402 | 403 | 404 | 405 | 406 | 422) => return false,
Some(429) => return true,
_ => {}
}
jcode_provider_core::is_transient_transport_error(error_str)
|| error_str.contains("stream error")
|| error_str.contains("eof")
|| error_str.contains("5")
&& (error_str.contains("50")
|| error_str.contains("502")
|| error_str.contains("503")
|| error_str.contains("504")
|| error_str.contains("internal server error"))
|| error_str.contains("overloaded")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_endpoint_hint_mentions_ollama_actions() {
let hint = local_endpoint_troubleshooting_hint("http://localhost:11434/v1", "llama3.2");
assert!(hint.contains("ollama serve"));
assert!(hint.contains("ollama pull"));
assert!(hint.contains("--provider ollama"));
}
#[test]
fn local_endpoint_hint_mentions_lm_studio_server() {
let hint = local_endpoint_troubleshooting_hint("http://127.0.0.1:1234/v1", "local-model");
assert!(hint.contains("LM Studio"));
assert!(hint.contains("Local Server"));
assert!(hint.contains("/v1/models"));
}
#[test]
fn parsed_http_status_extracts_code() {
assert_eq!(
parsed_http_status("status: 402 payment required"),
Some(402)
);
assert_eq!(parsed_http_status(" status:404 not found"), Some(404));
assert_eq!(parsed_http_status("no status here"), None);
// Embedded numbers elsewhere must not be misread as a status.
assert_eq!(parsed_http_status("you requested 65536 tokens"), None);
}
#[test]
fn payment_required_is_not_retryable() {
let err = "openai-compatible chat request failed\n endpoint: \
https://openrouter.ai/api/v1/chat/completions\n model: openai/gpt-5.4\n \
auth: openrouter_api_key\n status: 402 payment required\n response: \
{\"error\":{\"message\":\"this request requires more credits, or fewer \
max_tokens. you requested up to 65536 tokens, but can only afford 34424\"}}";
assert!(!is_retryable_error(err));
}
#[test]
fn client_errors_are_not_retryable() {
for status in [400u16, 401, 402, 403, 404, 405, 406, 422] {
let err = format!("chat request failed\n status: {status} client error");
assert!(
!is_retryable_error(&err),
"status {status} should not be retryable"
);
}
}
#[test]
fn server_errors_remain_retryable() {
assert!(is_retryable_error(
"chat request failed\n status: 503 service unavailable"
));
assert!(is_retryable_error(
"chat request failed\n status: 500 internal server error"
));
// Provider overload messages should still be retried.
assert!(is_retryable_error("overloaded"));
}
#[test]
fn http_429_is_retryable_without_rate_limit_words_in_body() {
assert!(is_retryable_error(
"chat request failed\n status: 429 unknown\n response: {}"
));
}
}
File diff suppressed because it is too large Load Diff