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
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:
@@ -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 == ¤t) {
|
||||
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
Reference in New Issue
Block a user