From f438279dfbf199b22186e5d7fb2041ccb2b412b0 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 8 Sep 2026 11:16:59 +0800 Subject: [PATCH 1/2] fix(ai): isolate and align subscription provider requests --- src/apps/desktop/src/api/commands.rs | 29 +- src/crates/adapters/ai-adapters/AGENTS.md | 30 + src/crates/adapters/ai-adapters/src/client.rs | 41 +- .../src/providers/anthropic/request.rs | 10 +- .../ai-adapters/src/providers/openai/chat.rs | 10 +- .../src/providers/openai/codex_chatgpt.rs | 169 ++++- .../src/providers/openai/responses.rs | 18 +- .../ai-adapters/src/providers/shared.rs | 355 ++++++++++ .../src/subscription_auth/codex.rs | 38 +- .../ai-adapters/src/subscription_auth/grok.rs | 4 +- .../src/subscription_auth/hermes.rs | 59 +- .../ai-adapters/src/subscription_auth/mod.rs | 184 ++++- .../src/subscription_auth/opencode.rs | 110 ++- .../src/subscription_auth/store.rs | 7 +- .../src/infrastructure/ai/client_factory.rs | 151 +++- src/web-ui/README.md | 14 +- src/web-ui/README.zh-CN.md | 12 +- .../config/components/ModelSettingsPage.tsx | 642 +++++++++--------- .../modelDiscoveryCoordinator.test.ts | 14 +- .../components/modelDiscoveryCoordinator.ts | 20 +- 20 files changed, 1449 insertions(+), 468 deletions(-) diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index cf63274bdc..125f470c14 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -992,23 +992,7 @@ pub async fn initialize_ai(state: State<'_, AppState>) -> Result .iter() .find(|m| m.id == primary_model_id) .ok_or_else(|| format!("Primary model '{}' does not exist", primary_model_id))?; - let stream_options = openbitfun_core::infrastructure::ai::build_stream_options_for_model( - &global_config.ai, - Some(model_config), - ); - - let ai_config = openbitfun_core::util::types::AIConfig::try_from(model_config.clone()) - .map_err(|e| format!("Failed to convert AI configuration: {}", e))?; - let proxy_config = if global_config.ai.proxy.enabled { - Some(global_config.ai.proxy.clone()) - } else { - None - }; - let ai_client = openbitfun_core::infrastructure::ai::AIClient::new_with_runtime_options( - ai_config, - proxy_config, - stream_options, - ); + let ai_client = create_transient_ai_client_for_config(&state, model_config.clone()).await?; { let mut ai_client_guard = state.ai_client.write().await; @@ -1063,10 +1047,13 @@ async fn create_transient_ai_client_for_config( .map_err(|e| format!("Failed to resolve subscription auth: {}", e))?; Ok( - openbitfun_core::infrastructure::ai::AIClient::new_with_runtime_options( - ai_config, - proxy_config, - stream_options, + openbitfun_core::infrastructure::ai::client_factory::apply_subscription_request_profile( + &auth, + openbitfun_core::infrastructure::ai::AIClient::new_with_runtime_options( + ai_config, + proxy_config, + stream_options, + ), ), ) } diff --git a/src/crates/adapters/ai-adapters/AGENTS.md b/src/crates/adapters/ai-adapters/AGENTS.md index 913d43e12a..f7f8b63879 100644 --- a/src/crates/adapters/ai-adapters/AGENTS.md +++ b/src/crates/adapters/ai-adapters/AGENTS.md @@ -27,6 +27,36 @@ provider-neutral contracts owned by `openbitfun-agent-stream`. service/process dependencies by default. Never scan or reuse third-party CLI credential files on disk; tokens come only from the in-app OAuth store. +## Subscription protocol references + +Compared on 2026-09-08 against [OpenCode v1.18.29](https://github.com/anomalyco/opencode/tree/16747470f976aca3d362ad730bcd3fe82ecc2c9a) +(`account/account.ts`, `plugin/openai/codex.ts`, `plugin/xai.ts`, and +`session/llm/request.ts` under `packages/opencode/src`) and +[Hermes Agent](https://github.com/NousResearch/hermes-agent/tree/6e2b8e070d28b1a3381a3fb290b6b8d6cce13cef) +(`hermes_cli/auth_nous.py`, `hermes_cli/providers.py`, `agent/codex_headers.py`, +`agent/opencode_affinity.py`, and `agent/transports/codex.py`). + +- OpenCode's account catalog chooses each model's protocol within its plan; + users select a plan/model, not a wire format. Preserve unknown legacy/manual + routes, and pin catalog-derived endpoints to OpenCode's production origin. +- Hermes currently defaults even `anthropic/*` to Chat Completions while the + Portal native Messages cache issue is unresolved. Preserve the Nous bearer + and `x-nous-refresh-token` refresh contract, including rotated-token storage. +- Subscription credentials own authentication and account headers regardless + of saved replace mode or header casing. Use OpenBitFun attribution for Codex + and OpenCode; retain provider-required compatibility headers for xAI and + Antigravity. Public API-key configurations retain their existing behavior. +- Additional subscription request policy is enabled only by an explicit runtime + subscription identity attached after resolving AuthConfig::Subscription; URLs + and model names never opt ordinary API-key clients into it. +- Request affinity comes from `ModelRequestContext` on each call, never from a + random ID on a cached client. Standalone OpenCode calls without runtime context + still require `x-opencode-session`: generate one opaque identity per logical + call and reuse it across all retries, including aggregate stream retries. + Only the matching provider origin receives it. + Client caches also compare the durable credential revision so login, logout, + refresh, and account catalog changes invalidate old credentials/routes. + ## Verification Subscription model discovery must use the authenticated account catalog. diff --git a/src/crates/adapters/ai-adapters/src/client.rs b/src/crates/adapters/ai-adapters/src/client.rs index b037e141e6..60ecc06bd7 100644 --- a/src/crates/adapters/ai-adapters/src/client.rs +++ b/src/crates/adapters/ai-adapters/src/client.rs @@ -62,6 +62,8 @@ pub struct AIClient { pub(crate) stream_options: StreamOptions, pub(crate) model_reasoning_preset: Option, pub(crate) selected_reasoning_preset: Option, + #[cfg(feature = "subscription-auth")] + subscription_provider: Option, } impl AIClient { @@ -95,6 +97,31 @@ impl AIClient { stream_options, model_reasoning_preset: None, selected_reasoning_preset: None, + #[cfg(feature = "subscription-auth")] + subscription_provider: None, + } + } + + /// Enable provider policy only after resolving an explicit subscription login. + /// This runtime identity is not inferred from URLs or serialized in AIConfig. + #[cfg(feature = "subscription-auth")] + pub fn with_subscription_provider( + mut self, + provider: crate::subscription_auth::SubscriptionProvider, + ) -> Self { + self.subscription_provider = Some(provider); + self + } + + /// Explicit subscription identity; ordinary API-key clients return None. + pub fn subscription_provider_key(&self) -> Option<&'static str> { + #[cfg(feature = "subscription-auth")] + { + self.subscription_provider.map(|provider| provider.key()) + } + #[cfg(not(feature = "subscription-auth"))] + { + None } } @@ -196,15 +223,9 @@ impl AIClient { /// Clone this client with a different max output token limit while /// reusing the HTTP client. pub fn with_max_tokens(&self, max_tokens: Option) -> Self { - let mut config = self.config.clone(); - config.max_tokens = max_tokens; - Self { - client: self.client.clone(), - config, - stream_options: self.stream_options.clone(), - model_reasoning_preset: self.model_reasoning_preset.clone(), - selected_reasoning_preset: self.selected_reasoning_preset.clone(), - } + let mut cloned = self.clone(); + cloned.config.max_tokens = max_tokens; + cloned } pub async fn send_message_stream( @@ -366,6 +387,8 @@ impl AIClient { trace: Option, max_attempts: usize, ) -> Result { + let request_context = + crate::providers::shared::prepare_request_context(self, request_context); for attempt in 0..max_attempts { let stream_response = match self .send_message_stream_with_extra_body_and_max_attempts( diff --git a/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs b/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs index 7d7b445627..5fd36310b7 100644 --- a/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs +++ b/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs @@ -502,6 +502,7 @@ pub(crate) async fn send_stream( request_context: Option, ) -> Result { let url = client.config.request_url.clone(); + let request_context = shared::prepare_request_context(client, request_context); debug!( "Anthropic config: model={}, request_url={}, max_tries={}", client.config.model, client.config.request_url, max_tries @@ -530,7 +531,14 @@ pub(crate) async fn send_stream( max_tries, ttft_timeout, trace, - || apply_headers(client, client.client.post(&url), &url), + || { + shared::apply_affinity_headers( + client, + apply_headers(client, client.client.post(&url), &url), + &url, + request_context.as_ref(), + ) + }, move |response, tx, tx_raw, remaining_ttft_timeout| { handle_anthropic_stream( response, diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/chat.rs b/src/crates/adapters/ai-adapters/src/providers/openai/chat.rs index a559a3d677..dfa08ad3bd 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/chat.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/chat.rs @@ -194,6 +194,7 @@ pub(crate) async fn send_stream( request_context: Option, ) -> Result { let url = client.config.request_url.clone(); + let request_context = shared::prepare_request_context(client, request_context); debug!( "OpenAI config: model={}, request_url={}, max_tries={}", client.config.model, client.config.request_url, max_tries @@ -220,7 +221,14 @@ pub(crate) async fn send_stream( max_tries, ttft_timeout, trace, - || common::apply_headers(client, client.client.post(&url)), + || { + shared::apply_affinity_headers( + client, + common::apply_headers(client, client.client.post(&url)), + &url, + request_context.as_ref(), + ) + }, move |response, tx, tx_raw, remaining_ttft_timeout| { handle_openai_stream( response, diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/codex_chatgpt.rs b/src/crates/adapters/ai-adapters/src/providers/openai/codex_chatgpt.rs index 5d27afaca4..dffec2054a 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/codex_chatgpt.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/codex_chatgpt.rs @@ -24,7 +24,7 @@ use crate::client::{AIClient, StreamResponse}; use crate::providers::shared; use crate::stream::handle_responses_stream; use crate::trace::ModelExchangeTraceConfig; -use crate::types::{Message, ReasoningPresetAction, ToolDefinition}; +use crate::types::{Message, ModelRequestContext, ReasoningPresetAction, ToolDefinition}; use anyhow::Result; use log::debug; use serde_json::{json, Value}; @@ -73,6 +73,24 @@ pub(crate) fn try_build_request_body( response_input: Vec, tools_flat: Option>, extra_body: Option, +) -> Result { + try_build_request_body_with_context( + client, + instructions, + response_input, + tools_flat, + extra_body, + None, + ) +} + +fn try_build_request_body_with_context( + client: &AIClient, + instructions: Option, + response_input: Vec, + tools_flat: Option>, + extra_body: Option, + request_context: Option<&ModelRequestContext>, ) -> Result { let mut body = json!({ "model": client.config.model, @@ -85,7 +103,7 @@ pub(crate) fn try_build_request_body( let resolved_instructions = instructions .filter(|v| !v.trim().is_empty()) .unwrap_or_else(|| DEFAULT_INSTRUCTIONS.to_string()); - body["instructions"] = Value::String(resolved_instructions); + body["instructions"] = Value::String(resolved_instructions.clone()); // Reasoning — mirror hermes-agent: default effort `medium` when enabled, // clamp `minimal -> low`, request encrypted reasoning trace for chain @@ -166,14 +184,44 @@ pub(crate) fn try_build_request_body( shared::apply_reasoning_actions(preset, &mut body, protected_keys, &[], compile)?; } + attach_tools(&mut body, tools_flat); + if client.subscription_provider_key() == Some("codex") + && shared::is_https_endpoint( + &client.config.request_url, + "chatgpt.com", + "/backend-api/codex", + ) + { + // These are backend requirements even for legacy configs with a custom body. + body["store"] = json!(false); + body["stream"] = json!(true); + if body + .get("instructions") + .and_then(Value::as_str) + .is_none_or(|text| text.trim().is_empty()) + { + body["instructions"] = json!(resolved_instructions); + } + if let Some(object) = body.as_object_mut() { + for unsupported in ["max_output_tokens", "max_tokens", "temperature", "top_p"] { + object.remove(unsupported); + } + } + if let Some(key) = request_context + .and_then(|context| context.prompt_cache_route_key.as_deref()) + .map(str::trim) + .filter(|key| !key.is_empty()) + { + body["prompt_cache_key"] = json!(key); + } + } + shared::log_request_body( TARGET, "Codex ChatGPT request body (excluding tools):", &body, ); - attach_tools(&mut body, tools_flat); - Ok(body) } @@ -184,6 +232,7 @@ pub(crate) async fn send_stream( extra_body: Option, max_tries: usize, trace: Option, + request_context: Option, ) -> Result { let url = client.config.request_url.clone(); debug!( @@ -194,8 +243,14 @@ pub(crate) async fn send_stream( let (instructions, response_input) = OpenAIMessageConverter::convert_messages_to_responses_input(messages); let tools_flat = common::convert_tools_flat(tools); - let request_body = - try_build_request_body(client, instructions, response_input, tools_flat, extra_body)?; + let request_body = try_build_request_body_with_context( + client, + instructions, + response_input, + tools_flat, + extra_body, + request_context.as_ref(), + )?; let idle_timeout = client.stream_options.idle_timeout; let ttft_timeout = client.stream_options.ttft_timeout; @@ -206,7 +261,14 @@ pub(crate) async fn send_stream( max_tries, ttft_timeout, trace, - || common::apply_headers(client, client.client.post(&url)), + || { + shared::apply_affinity_headers( + client, + common::apply_headers(client, client.client.post(&url)), + &url, + request_context.as_ref(), + ) + }, move |response, tx, tx_raw, remaining_ttft_timeout| { handle_responses_stream( response, @@ -220,3 +282,96 @@ pub(crate) async fn send_stream( ) .await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordinary_api_codex_endpoint_preserves_custom_body_and_does_not_gain_affinity() { + let client = AIClient::new( + serde_json::from_value(json!({ + "name": "test", "base_url": "https://chatgpt.com/backend-api/codex", + "request_url": "https://chatgpt.com/backend-api/codex/responses", + "api_key": "synthetic", "model": "gpt-5.5", "format": "responses", + "context_window": 128000, "inline_think_in_text": false, "skip_ssl_verify": false + })) + .unwrap(), + ); + let context = ModelRequestContext { + prompt_cache_route_key: Some("runtime".into()), + ..Default::default() + }; + let custom = json!({"max_output_tokens": 8000, "max_tokens": 4000, "temperature": 0.5, + "top_p": 0.9, "prompt_cache_key": "user-managed"}); + let before = + try_build_request_body(&client, None, vec![], None, Some(custom.clone())).unwrap(); + let after = try_build_request_body_with_context( + &client, + None, + vec![], + None, + Some(custom.clone()), + Some(&context), + ) + .unwrap(); + assert_eq!(before, after); + for (key, value) in custom.as_object().unwrap() { + assert_eq!(&after[key], value); + } + let request = shared::apply_affinity_headers( + &client, + common::apply_headers(&client, client.client.post(&client.config.request_url)), + &client.config.request_url, + Some(&context), + ) + .build() + .unwrap(); + assert!(!request.headers().contains_key("session_id")); + assert!(!request.headers().contains_key("x-client-request-id")); + assert_eq!(request.headers()["authorization"], "Bearer synthetic"); + } + + #[cfg(feature = "subscription-auth")] + #[test] + fn legacy_custom_body_cannot_break_codex_contract_or_cache_routing() { + let client = AIClient::new(serde_json::from_value(json!({ + "name": "test", "base_url": "https://chatgpt.com/backend-api/codex", + "request_url": "https://chatgpt.com/backend-api/codex/responses", + "api_key": "synthetic", "model": "gpt-5.5", "format": "responses", "context_window": 128000, "inline_think_in_text": false, "skip_ssl_verify": false + })).unwrap()).with_subscription_provider(crate::subscription_auth::SubscriptionProvider::Codex); + let context = ModelRequestContext { + prompt_cache_route_key: Some("conversation-a".into()), + ..Default::default() + }; + let body = try_build_request_body_with_context( + &client, Some("Help with this task".into()), vec![], + Some(vec![json!({"type": "function", "name": "read_file", "parameters": {"type": "object"}})]), + Some(json!({"store": true, "stream": false, "instructions": "", "max_output_tokens": 8000, + "max_tokens": 8000, "temperature": 0.5, "top_p": 0.9, "prompt_cache_key": "stale"})), Some(&context), + ).unwrap(); + assert_eq!(body["store"], false); + assert_eq!(body["stream"], true); + assert_eq!(body["instructions"], "Help with this task"); + assert_eq!(body["prompt_cache_key"], "conversation-a"); + assert_eq!(body["tools"][0]["name"], "read_file"); + for unsupported in ["max_output_tokens", "max_tokens", "temperature", "top_p"] { + assert!(body.get(unsupported).is_none()); + } + let request = shared::apply_affinity_headers( + &client, + common::apply_headers(&client, client.client.post(&client.config.request_url)), + &client.config.request_url, + Some(&context), + ) + .json(&body) + .build() + .unwrap(); + assert_eq!( + request.headers()["x-client-request-id"], + body["prompt_cache_key"].as_str().unwrap() + ); + assert_eq!(request.headers()["session_id"], "conversation-a"); + assert_eq!(request.headers()["authorization"], "Bearer synthetic"); + } +} diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs b/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs index e5c34c7a7f..f6482713d9 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs @@ -300,12 +300,19 @@ pub(crate) async fn send_stream( // self-contained so the standard Responses path stays untouched. if super::codex_chatgpt::is_codex_chatgpt_endpoint(&client.config.request_url) { return super::codex_chatgpt::send_stream( - client, messages, tools, extra_body, max_tries, trace, + client, + messages, + tools, + extra_body, + max_tries, + trace, + request_context, ) .await; } let url = client.config.request_url.clone(); + let request_context = shared::prepare_request_context(client, request_context); debug!( "Responses config: model={}, request_url={}, max_tries={}", client.config.model, client.config.request_url, max_tries @@ -333,7 +340,14 @@ pub(crate) async fn send_stream( max_tries, ttft_timeout, trace, - || common::apply_headers(client, client.client.post(&url)), + || { + shared::apply_affinity_headers( + client, + common::apply_headers(client, client.client.post(&url)), + &url, + request_context.as_ref(), + ) + }, move |response, tx, tx_raw, remaining_ttft_timeout| { handle_responses_stream( response, diff --git a/src/crates/adapters/ai-adapters/src/providers/shared.rs b/src/crates/adapters/ai-adapters/src/providers/shared.rs index 885dde0d14..bfb62303de 100644 --- a/src/crates/adapters/ai-adapters/src/providers/shared.rs +++ b/src/crates/adapters/ai-adapters/src/providers/shared.rs @@ -24,6 +24,117 @@ pub(crate) fn normalize_generic_reasoning_effort(value: &str) -> Option<&'static } } +/// Attribution for APIs that accept third-party harnesses under their own name. +#[cfg(feature = "subscription-auth")] +pub(crate) fn product_user_agent() -> String { + format!( + "OpenBitFun/{} ({}; {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH + ) +} + +pub(crate) fn is_https_endpoint(raw: &str, host: &str, path: &str) -> bool { + reqwest::Url::parse(raw).ok().is_some_and(|url| { + url.scheme() == "https" + && url.host_str() == Some(host) + && url.port_or_known_default() == Some(443) + && url.username().is_empty() + && url.password().is_none() + && (url.path() == path + || url + .path() + .strip_prefix(path) + .is_some_and(|suffix| suffix.starts_with('/'))) + }) +} + +/// OpenCode requires an affinity header even for standalone calls such as +/// connection tests and auxiliary summaries. Allocate their identity once per +/// logical call, before retries; never share a fallback across a cached client. +pub(crate) fn prepare_request_context( + client: &AIClient, + context: Option, +) -> Option { + if client.subscription_provider_key() != Some("opencode") + || !is_https_endpoint(&client.config.request_url, "opencode.ai", "/zen") + || context + .as_ref() + .and_then(|context| context.prompt_cache_route_key.as_deref()) + .is_some_and(|key| !key.trim().is_empty()) + { + return context; + } + use std::collections::hash_map::RandomState; + use std::hash::BuildHasher; + use std::sync::{ + atomic::{AtomicU64, Ordering}, + OnceLock, + }; + // Process-random hashing plus a monotonic nonce gives concurrent calls + // distinct opaque routing labels without exposing prompts or machine IDs. + // This is a cache label, not an authentication credential. + static HASHER: OnceLock = OnceLock::new(); + static NEXT: AtomicU64 = AtomicU64::new(0); + let nonce = NEXT.fetch_add(1, Ordering::Relaxed); + let hasher = HASHER.get_or_init(RandomState::new); + let mut context = context.unwrap_or_default(); + context.prompt_cache_route_key = Some(format!( + "openbitfun-call-{:016x}{:016x}", + hasher.hash_one((nonce, 0_u8)), + hasher.hash_one((nonce, 1_u8)), + )); + Some(context) +} + +/// A client is cached across conversations; affinity belongs to each request. +/// Only forward the runtime's opaque routing key to the owning provider origin. +/// HeaderMap replacement ensures a stale custom header cannot create duplicates. +pub(crate) fn apply_affinity_headers( + client: &AIClient, + builder: RequestBuilder, + url: &str, + context: Option<&crate::types::ModelRequestContext>, +) -> RequestBuilder { + let Some(key) = context + .and_then(|context| context.prompt_cache_route_key.as_deref()) + .map(str::trim) + .filter(|key| !key.is_empty()) + else { + return builder; + }; + let names: &[&'static str] = if client.subscription_provider_key() == Some("codex") + && is_https_endpoint(url, "chatgpt.com", "/backend-api/codex") + { + &["session_id", "x-client-request-id"] + } else if client.subscription_provider_key() == Some("opencode") + && is_https_endpoint(url, "opencode.ai", "/zen") + { + &["x-opencode-session"] + } else if client.subscription_provider_key() == Some("grok") + && is_https_endpoint(url, "api.x.ai", "/v1/responses") + { + &["x-grok-conv-id"] + } else { + return builder; + }; + let Ok(value) = reqwest::header::HeaderValue::from_str(key) else { + // Let reqwest report invalid header input through its normal error path. + return builder.header(names[0], key); + }; + let headers = names + .iter() + .map(|name| { + ( + reqwest::header::HeaderName::from_static(name), + value.clone(), + ) + }) + .collect(); + builder.headers(headers) +} + pub(crate) fn apply_header_policy( client: &AIClient, builder: RequestBuilder, @@ -460,6 +571,250 @@ mod tests { use super::should_log_full_request_body; use super::summarize_request_body_for_log; + fn request_client(url: &str) -> crate::client::AIClient { + crate::client::AIClient::new( + serde_json::from_value(serde_json::json!({ + "name": "synthetic", "base_url": url, "request_url": url, "api_key": "synthetic", + "model": "test", "format": "openai", "context_window": 4096, + "inline_think_in_text": false, "skip_ssl_verify": false + })) + .unwrap(), + ) + } + + #[test] + fn ordinary_api_requests_keep_headers_and_context_even_at_subscription_origins() { + use crate::types::ModelRequestContext; + for url in [ + "https://opencode.ai/zen/v1/chat/completions", + "https://opencode.ai/zen/go/v1/responses", + "https://opencode.ai/zen/go/v1/messages", + "https://chatgpt.com/backend-api/codex/responses", + "https://api.x.ai/v1/responses", + "https://inference-api.nousresearch.com/v1/chat/completions", + ] { + for mode in ["merge", "replace"] { + let mut client = request_client(url); + client.config.custom_headers_mode = Some(mode.into()); + client.config.custom_headers = Some(std::collections::HashMap::from([ + ("x-opencode-session".into(), "user-managed".into()), + ("session_id".into(), "user-session".into()), + ("x-grok-conv-id".into(), "user-grok".into()), + ("user-agent".into(), "user-agent-value".into()), + ])); + for context in [ + None, + Some(ModelRequestContext { + prompt_cache_route_key: Some("runtime-lineage".into()), + output_schema: Some(serde_json::json!({"type": "object"})), + }), + ] { + assert_eq!( + super::prepare_request_context(&client, context.clone()), + context + ); + let original = + super::apply_header_policy(&client, client.client.post(url), |builder| { + builder.bearer_auth("synthetic") + }); + let before = original.try_clone().unwrap().build().unwrap(); + let after = + super::apply_affinity_headers(&client, original, url, context.as_ref()) + .build() + .unwrap(); + assert_eq!(before.headers(), after.headers(), "{url} {mode}"); + assert!(!after.headers().contains_key("x-client-request-id")); + } + let empty = super::apply_affinity_headers( + &client, + client.client.post(url), + url, + Some(&ModelRequestContext { + prompt_cache_route_key: Some("runtime-lineage".into()), + ..Default::default() + }), + ) + .build() + .unwrap(); + assert!(empty.headers().is_empty(), "{url}"); + } + } + } + + #[cfg(feature = "subscription-auth")] + mod subscription { + use super::request_client; + use crate::providers::shared::{apply_affinity_headers, prepare_request_context}; + use crate::subscription_auth::SubscriptionProvider; + use crate::types::ModelRequestContext; + + #[test] + fn standalone_opencode_calls_send_affinity_on_every_wire_and_retry() { + let mut call_keys = std::collections::HashSet::new(); + for plan in ["zen", "zen/go"] { + for wire in ["chat/completions", "responses", "messages"] { + let url = format!("https://opencode.ai/{plan}/v1/{wire}"); + let client = request_client(&url) + .with_subscription_provider(SubscriptionProvider::Opencode); + for initial in [ + None, + Some(ModelRequestContext::default()), + Some(ModelRequestContext { + prompt_cache_route_key: Some(" ".into()), + output_schema: Some(serde_json::json!({"type": "object"})), + }), + ] { + let schema = initial + .as_ref() + .and_then(|context| context.output_schema.clone()); + let call = prepare_request_context(&client, initial).unwrap(); + assert_eq!(call.output_schema, schema); + let key = call.prompt_cache_route_key.as_ref().unwrap(); + assert!( + call_keys.insert(key.clone()), + "standalone calls must not share affinity" + ); + for _ in 0..3 { + let retry = prepare_request_context(&client, Some(call.clone())); + let request = apply_affinity_headers( + &client, + client.client.post(&url), + &url, + retry.as_ref(), + ) + .build() + .unwrap(); + assert_eq!(request.headers()["x-opencode-session"], key.as_str()); + assert_eq!( + request + .headers() + .get_all("x-opencode-session") + .iter() + .count(), + 1 + ); + } + } + let context = ModelRequestContext { + prompt_cache_route_key: Some("runtime-lineage".into()), + ..Default::default() + }; + assert_eq!( + prepare_request_context(&client, Some(context.clone())), + Some(context) + ); + } + } + } + + #[test] + fn affinity_is_scoped_to_each_request_and_replaces_stale_headers() { + for (provider, url, names) in [ + ( + SubscriptionProvider::Codex, + "https://chatgpt.com/backend-api/codex/responses", + vec!["session_id", "x-client-request-id"], + ), + ( + SubscriptionProvider::Grok, + "https://api.x.ai/v1/responses", + vec!["x-grok-conv-id"], + ), + ( + SubscriptionProvider::Opencode, + "https://opencode.ai/zen/v1/chat/completions", + vec!["x-opencode-session"], + ), + ( + SubscriptionProvider::Opencode, + "https://opencode.ai/zen/go/v1/responses", + vec!["x-opencode-session"], + ), + ( + SubscriptionProvider::Opencode, + "https://opencode.ai/zen/go/v1/messages", + vec!["x-opencode-session"], + ), + ] { + let client = request_client(url) + .with_subscription_provider(provider) + .with_max_tokens(Some(2048)); + for scope in ["lineage-a", "lineage-b", "lineage-a"] { + let context = ModelRequestContext { + prompt_cache_route_key: Some(scope.into()), + ..Default::default() + }; + let mut builder = client.client.post(url); + for name in &names { + builder = builder.header(name.to_ascii_uppercase(), "stale"); + } + let request = apply_affinity_headers(&client, builder, url, Some(&context)) + .build() + .unwrap(); + for name in &names { + assert_eq!(request.headers().get_all(*name).iter().count(), 1); + assert_eq!(request.headers()[*name], scope); + } + } + // A different subscription provider must not activate this origin's policy. + let mismatch = + request_client(url).with_subscription_provider(SubscriptionProvider::Hermes); + assert!(prepare_request_context(&mismatch, None).is_none()); + let context = ModelRequestContext { + prompt_cache_route_key: Some("scope".into()), + ..Default::default() + }; + assert!(apply_affinity_headers( + &mismatch, + mismatch.client.post(url), + url, + Some(&context) + ) + .build() + .unwrap() + .headers() + .is_empty()); + } + } + + #[test] + fn affinity_never_leaks_to_other_origins_or_lookalike_paths() { + let context = ModelRequestContext { + prompt_cache_route_key: Some("opaque-scope".into()), + ..Default::default() + }; + for provider in SubscriptionProvider::ALL { + for url in [ + "https://api.openai.com/v1/responses", + "https://example.test/chatgpt.com/backend-api/codex/responses", + "https://chatgpt.com.evil.test/backend-api/codex/responses", + "https://chatgpt.com/backend-api/codex-other/responses", + "http://chatgpt.com/backend-api/codex/responses", + "https://chatgpt.com:444/backend-api/codex/responses", + "https://opencode.ai/zen-other/v1/messages", + "https://opencode.ai.evil.test/zen/v1/messages", + "https://api.x.ai/v1/chat/completions", + ] { + let client = request_client(url).with_subscription_provider(provider); + assert!(prepare_request_context(&client, None).is_none()); + assert!( + apply_affinity_headers( + &client, + client.client.post(url), + url, + Some(&context) + ) + .build() + .unwrap() + .headers() + .is_empty(), + "{provider:?} {url}" + ); + } + } + } + } + #[test] fn request_body_log_summary_keeps_shape_without_message_contents() { let request_body = serde_json::json!({ diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/codex.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/codex.rs index 83ad6ae2c1..80c0a643b9 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/codex.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/codex.rs @@ -13,7 +13,6 @@ use serde::Deserialize; use std::collections::HashMap; use std::time::Duration; use tokio_util::sync::CancellationToken; -use uuid::Uuid; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const ISSUER: &str = "https://auth.openai.com"; @@ -60,13 +59,8 @@ struct DeviceAuthorizationResponse { code_verifier: String, } -fn opencode_user_agent() -> String { - format!( - "opencode/{} ({}; {})", - super::OPENCODE_COMPAT_VERSION, - std::env::consts::OS, - std::env::consts::ARCH - ) +fn user_agent() -> String { + crate::providers::shared::product_user_agent() } fn device_poll_interval(value: &serde_json::Value) -> u64 { @@ -89,7 +83,7 @@ fn build_authorize_url(pkce: &Pkce, state: &str, redirect_uri: &str) -> String { ("id_token_add_organizations", "true"), ("codex_cli_simplified_flow", "true"), ("state", state), - ("originator", "opencode"), + ("originator", "openbitfun"), ]; let query = params .iter() @@ -119,6 +113,8 @@ async fn exchange_code( ]; let resp = client .post(format!("{ISSUER}/oauth/token")) + .header(reqwest::header::USER_AGENT, user_agent()) + .header(reqwest::header::ACCEPT, "application/json") .form(¶ms) .send() .await @@ -142,6 +138,8 @@ async fn refresh(refresh_token: &str, options: &SubscriptionHttpOptions) -> Resu ]; let resp = client .post(format!("{ISSUER}/oauth/token")) + .header(reqwest::header::USER_AGENT, user_agent()) + .header(reqwest::header::ACCEPT, "application/json") .form(¶ms) .send() .await @@ -157,7 +155,7 @@ async fn refresh(refresh_token: &str, options: &SubscriptionHttpOptions) -> Resu async fn request_device_code(options: &SubscriptionHttpOptions) -> Result { let response = http_client(options)? .post(DEVICE_USER_CODE_URL) - .header(reqwest::header::USER_AGENT, opencode_user_agent()) + .header(reqwest::header::USER_AGENT, user_agent()) .json(&serde_json::json!({ "client_id": CLIENT_ID })) .send() .await @@ -190,7 +188,7 @@ async fn poll_device_authorization( loop { let response = http_client(options)? .post(DEVICE_TOKEN_URL) - .header(reqwest::header::USER_AGENT, opencode_user_agent()) + .header(reqwest::header::USER_AGENT, user_agent()) .json(&serde_json::json!({ "device_auth_id": device.device_auth_id, "user_code": device.user_code, @@ -461,12 +459,11 @@ async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result<(String, Opti pub(crate) async fn resolve(options: &SubscriptionHttpOptions) -> Result { let (access, account_id, expires) = ensure_fresh(options).await?; let mut headers = HashMap::new(); - if let Some(account) = account_id { + if let Some(account) = account_id.or_else(|| jwt::chatgpt_account_id(&access)) { headers.insert("ChatGPT-Account-ID".to_string(), account); } - headers.insert("originator".to_string(), "opencode".to_string()); - headers.insert("session-id".to_string(), Uuid::new_v4().to_string()); - headers.insert("User-Agent".to_string(), opencode_user_agent()); + headers.insert("originator".to_string(), "openbitfun".to_string()); + headers.insert("User-Agent".to_string(), user_agent()); if let Some(residency) = jwt::chatgpt_compute_residency(&access) { headers.insert("x-openai-internal-codex-residency".to_string(), residency); } @@ -489,8 +486,8 @@ pub(crate) fn suggested() -> (&'static str, &'static str, &'static str) { #[cfg(test)] mod tests { use super::{ - build_authorize_url, device_poll_interval, opencode_user_agent, redirect_uri, - CALLBACK_PORT, DEFAULT_MODEL, + build_authorize_url, device_poll_interval, redirect_uri, user_agent, CALLBACK_PORT, + DEFAULT_MODEL, }; use crate::subscription_auth::pkce::Pkce; @@ -511,7 +508,10 @@ mod tests { assert_eq!(device_poll_interval(&serde_json::json!(2)), 2); assert_eq!(device_poll_interval(&serde_json::json!(0)), 5); assert_eq!(DEFAULT_MODEL, "gpt-5.5"); - assert_eq!(super::super::OPENCODE_COMPAT_VERSION, "1.18.25"); - assert!(opencode_user_agent().starts_with("opencode/1.18.25 (")); + assert!( + build_authorize_url(&Pkce::generate(), "state", &redirect_uri(CALLBACK_PORT)) + .contains("originator=openbitfun") + ); + assert!(user_agent().starts_with("OpenBitFun/")); } } diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/grok.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/grok.rs index 0c4a594d0e..66d0f64cdc 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/grok.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/grok.rs @@ -530,9 +530,9 @@ mod tests { let headers = inference_headers(); assert_eq!( headers.get("User-Agent").map(String::as_str), - Some(concat!("opencode/", "1.18.25")) + Some(concat!("opencode/", "1.18.29")) ); - assert_eq!(super::super::OPENCODE_COMPAT_VERSION, "1.18.25"); + assert_eq!(super::super::OPENCODE_COMPAT_VERSION, "1.18.29"); assert!(!headers.contains_key("X-XAI-Token-Auth")); assert!(!headers.contains_key("x-grok-model-override")); } diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/hermes.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/hermes.rs index 2ba635c3bf..ba3784ca80 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/hermes.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/hermes.rs @@ -3,8 +3,9 @@ //! Authentication follows Hermes Agent's OAuth 2.0 device-code flow. The //! access token is an inference-scoped JWT and the refresh token rotates on //! every use. Runtime requests are pinned to Nous Research's trusted -//! inference host, with `anthropic/*` models using Messages and all other -//! models using OpenAI Chat Completions. +//! inference host using OpenAI Chat Completions, including `anthropic/*`. +//! Hermes defaults to this wire while the Portal native Messages cache issue +//! is unresolved (hermes_cli/providers.py, upstream 2026-09-08). use super::device_flow::{poll_device_code, DevicePoll}; use super::jwt; @@ -348,13 +349,17 @@ async fn persist_tokens(tokens: TokenResponse, expected_revision: u64) -> Result Ok(()) } -async fn refresh(refresh_token: &str, options: &SubscriptionHttpOptions) -> Result { - let client = http_client(options)?; - let response = client +fn refresh_request(client: &reqwest::Client, refresh_token: &str) -> reqwest::RequestBuilder { + client .post(TOKEN_URL) .header(reqwest::header::ACCEPT, "application/json") .header("x-nous-refresh-token", refresh_token) .form(&[("grant_type", "refresh_token"), ("client_id", CLIENT_ID)]) +} + +async fn refresh(refresh_token: &str, options: &SubscriptionHttpOptions) -> Result { + let client = http_client(options)?; + let response = refresh_request(&client, refresh_token) .send() .await .context("call Nous Portal token refresh endpoint")?; @@ -556,17 +561,12 @@ async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result<(String, i64, } } -fn route_for(model: &str) -> HermesRoute { - if model.trim().to_ascii_lowercase().starts_with("anthropic/") { - HermesRoute { - format: "anthropic", - suffix: "messages", - } - } else { - HermesRoute { - format: "openai", - suffix: "chat/completions", - } +fn route_for(_model: &str) -> HermesRoute { + // Hermes currently uses chat even for anthropic/*: concurrent native + // Messages calls can rewrite the previous prompt-cache breakpoint. + HermesRoute { + format: "openai", + suffix: "chat/completions", } } @@ -651,12 +651,33 @@ mod tests { } #[test] - fn selects_messages_only_for_anthropic_catalog_ids() { + fn refresh_uses_the_portal_header_without_a_token_in_the_form() { + let request = refresh_request(&reqwest::Client::new(), "synthetic-refresh") + .build() + .unwrap(); + assert_eq!(request.url().as_str(), TOKEN_URL); + assert_eq!(request.method(), reqwest::Method::POST); + assert_eq!(request.headers()["accept"], "application/json"); + assert_eq!( + request.headers()["x-nous-refresh-token"], + "synthetic-refresh" + ); + assert_eq!( + request.headers()["content-type"], + "application/x-www-form-urlencoded" + ); + let body = std::str::from_utf8(request.body().unwrap().as_bytes().unwrap()).unwrap(); + assert_eq!(body, "grant_type=refresh_token&client_id=hermes-cli"); + assert!(!body.contains("synthetic-refresh")); + } + + #[test] + fn defaults_to_chat_for_all_portal_catalog_ids() { let anthropic = route_for("anthropic/claude-sonnet-5"); - assert_eq!(anthropic.format, "anthropic"); + assert_eq!(anthropic.format, "openai"); assert_eq!( request_url(INFERENCE_BASE_URL, anthropic), - "https://inference-api.nousresearch.com/v1/messages" + "https://inference-api.nousresearch.com/v1/chat/completions" ); let openai = route_for("openai/gpt-5.6-sol"); diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs index e1c13f5e69..82f9391a38 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs @@ -35,7 +35,7 @@ use tokio_util::sync::CancellationToken; const LOGIN_TIMEOUT: Duration = Duration::from_secs(5 * 60); /// OpenCode release whose built-in subscription protocols these adapters mirror. -pub(crate) const OPENCODE_COMPAT_VERSION: &str = "1.18.25"; +pub(crate) const OPENCODE_COMPAT_VERSION: &str = "1.18.29"; /// One of the subscription providers OpenBitFun can sign in to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -241,6 +241,12 @@ pub struct SubscriptionLogoutResult { pub warning: Option, } +/// Durable account epoch used to invalidate cached model clients after login, +/// logout, refresh, or profile changes, including changes from another host process. +pub async fn credential_revision(provider: SubscriptionProvider) -> Result { + store::credential_revision(provider.key()).await +} + /// Runtime-resolved credential that overrides fields in the AI client config. #[derive(Debug, Clone)] pub struct ResolvedCredential { @@ -253,6 +259,52 @@ pub struct ResolvedCredential { pub expires_at: Option, } +impl ResolvedCredential { + /// Applies account-owned authentication to a transient client configuration. + /// Saved API-key headers and replace mode must not suppress OAuth auth, or + /// select a different account after login/refresh. HTTP names ignore case. + pub fn apply_to(self, config: &mut crate::types::AIConfig) -> Option { + config.api_key = self.api_key; + if let Some(base_url) = self.base_url { + config.base_url = base_url; + } + if let Some(request_url) = self.request_url { + config.request_url = request_url; + } + if let Some(format) = self.format { + config.format = format; + } + let mut headers = config.custom_headers.take().unwrap_or_default(); + headers.retain(|name, _| { + ![ + "authorization", + "x-api-key", + "x-goog-api-key", + "content-type", + "anthropic-version", + "chatgpt-account-id", + "x-openai-internal-codex-residency", + "x-org-id", + "session-id", + "session_id", + "x-client-request-id", + "x-opencode-session", + "x-grok-conv-id", + ] + .iter() + .any(|reserved| name.eq_ignore_ascii_case(reserved)) + && !self + .extra_headers + .keys() + .any(|required| name.eq_ignore_ascii_case(required)) + }); + headers.extend(self.extra_headers); + config.custom_headers = (!headers.is_empty()).then_some(headers); + config.custom_headers_mode = Some("merge".to_string()); + self.expires_at + } +} + /// Returned by `start_login`; contains what the UI needs to guide the user. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoginStartResult { @@ -944,6 +996,17 @@ pub async fn resolve_opencode_with_options( opencode::resolve_for(plan, format, options).await } +/// Resolves the OpenCode wire format from the signed-in account's catalog. +/// Legacy callers may omit the plan; known models still get their correct wire. +pub async fn resolve_opencode_model_with_options( + plan: Option, + configured_format: &str, + model: &str, + options: &SubscriptionHttpOptions, +) -> Result { + opencode::resolve_for_model(plan, configured_format, model, options).await +} + /// Resolves an xAI subscription credential for a concrete model. The adapter /// owns the trusted Responses endpoint so the OAuth token can never be sent to /// an arbitrary URL supplied by model configuration. @@ -959,9 +1022,9 @@ pub async fn resolve_grok_with_options( grok::resolve_for(model, options).await } -/// Resolves a Hermes subscription credential for a concrete model. Nous uses -/// Anthropic Messages for `anthropic/*` model ids and OpenAI Chat Completions -/// for the rest; the adapter pins both routes to the trusted inference host. +/// Resolves a Hermes subscription credential for a concrete model. All catalog +/// models use the current Hermes Chat Completions default, pinned to the trusted +/// Nous inference host. Saved model IDs and credentials remain unchanged. pub async fn resolve_hermes(model: &str) -> Result { resolve_hermes_with_options(model, &SubscriptionHttpOptions::default()).await } @@ -1054,6 +1117,12 @@ mod tests { .unwrap(); assert_eq!(resolved.expires_at, Some(actual_expiry)); assert_eq!(resolved.api_key, token); + if provider == SubscriptionProvider::Codex { + assert_eq!(resolved.extra_headers["originator"], "openbitfun"); + assert!(resolved.extra_headers["User-Agent"].starts_with("OpenBitFun/")); + assert_eq!(resolved.extra_headers["ChatGPT-Account-ID"], "test-account"); + assert!(!resolved.extra_headers.contains_key("session-id")); + } // No rotation or mutation is needed for a still-usable legacy JWT. assert_eq!( store::load_entry_with_revision(provider.key()) @@ -1065,6 +1134,113 @@ mod tests { } } + #[test] + fn subscription_headers_survive_legacy_replace_mode_on_each_wire() { + use crate::{ + client::AIClient, + providers::{anthropic, gemini, openai}, + types::AIConfig, + }; + // Deserialized legacy user settings, including differently cased stale + // auth headers. Assert the final request, not just the merged HashMap. + for (format, url, headers, auth_header) in [ + ("responses", "https://chatgpt.com/backend-api/codex/responses", vec![("originator", "openbitfun"), ("User-Agent", "OpenBitFun/test"), ("ChatGPT-Account-ID", "current-account")], "authorization"), + ("responses", "https://api.x.ai/v1/responses", vec![("User-Agent", "opencode/test")], "authorization"), + ("openai", "https://opencode.ai/zen/go/v1/chat/completions", vec![("x-org-id", "current-org"), ("User-Agent", "OpenBitFun/test")], "authorization"), + ("anthropic", "https://opencode.ai/zen/v1/messages", vec![("x-org-id", "current-org"), ("User-Agent", "OpenBitFun/test")], "x-api-key"), + ("openai", "https://inference-api.nousresearch.com/v1/chat/completions", vec![], "authorization"), + ("anthropic", "https://inference-api.nousresearch.com/v1/messages", vec![], "authorization"), + ("gemini-code-assist", "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse", vec![("User-Agent", "antigravity/test"), ("X-Goog-Api-Client", "google-cloud-sdk vscode_cloudshelleditor/0.1"), ("Client-Metadata", "ANTIGRAVITY")], "authorization"), + ] { + let saved = serde_json::json!({ + "name": "legacy", "model": "saved-model", "format": "anthropic", + "base_url": "https://old.invalid", "request_url": "https://old.invalid/messages", + "api_key": "old-api-key", "context_window": 128000, "inline_think_in_text": false, "skip_ssl_verify": false, + "custom_headers_mode": "replace", "custom_headers": { + "AUTHORIZATION": "Bearer stale", "X-Api-Key": "stale-key", + "x-goog-api-key": "stale-google-key", "Content-Type": "text/plain", + "ANTHROPIC-VERSION": "invalid", "user-agent": "stale-client", + "X-ORG-ID": "stale-org", "chatgpt-account-id": "stale-account", + "x-openai-internal-codex-residency": "stale-residency", "session-id": "stale-session", "X-Trace-Test": "keep" + } + }); + let mut config: AIConfig = serde_json::from_value(saved.clone()).unwrap(); + let required: HashMap = headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); + let expires = ResolvedCredential { + api_key: "current-token".into(), base_url: Some(url.into()), request_url: Some(url.into()), + format: Some(format.into()), extra_headers: required.clone(), expires_at: Some(12345), + }.apply_to(&mut config); + assert_eq!(expires, Some(12345)); + assert_eq!(config.model, "saved-model"); + assert_eq!(config.custom_headers_mode.as_deref(), Some("merge")); + let client = AIClient::new(config); + for method in [reqwest::Method::GET, reqwest::Method::POST] { + let builder = client.client.request(method, url); + let request = match format { + "anthropic" => anthropic::request::apply_headers(&client, builder, url), + "gemini-code-assist" => gemini::code_assist::apply_headers(&client, builder), + _ => openai::common::apply_headers(&client, builder), + }.build().unwrap(); + let actual = request.headers(); + assert_eq!(actual.get_all(auth_header).iter().count(), 1, "{url}"); + assert_eq!(actual[auth_header], if auth_header == "authorization" { "Bearer current-token" } else { "current-token" }); + assert!(!actual.contains_key(if auth_header == "authorization" { "x-api-key" } else { "authorization" })); + assert!(!actual.contains_key("x-goog-api-key")); + assert!(!actual.contains_key("session-id")); + assert!(!actual.contains_key("x-openai-internal-codex-residency")); + assert_eq!(actual.get_all("content-type").iter().count(), 1); + assert_eq!(actual["content-type"], "application/json"); + assert_eq!(actual["x-trace-test"], "keep"); + for (name, value) in &required { + assert_eq!(actual.get_all(name).iter().count(), 1, "{url}: {name}"); + assert_eq!(actual[name], value); + } + } + // Runtime application does not rewrite the persisted legacy settings. + let restored: AIConfig = serde_json::from_value(saved).unwrap(); + assert_eq!(restored.custom_headers_mode.as_deref(), Some("replace")); + assert_eq!(restored.api_key, "old-api-key"); + } + } + + #[tokio::test] + async fn legacy_hermes_anthropic_config_uses_current_chat_route_without_relogin() { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + let _guard = test_lock().lock().await; + store::set_store_path_for_test(temp_store_path()); + let expires = chrono::Utc::now().timestamp() + 3600; + let body = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&serde_json::json!({ + "exp": expires, "scope": "inference:invoke", "sub": "fixture-account" + })) + .unwrap(), + ); + let token = format!("e30.{body}.fixture"); + let legacy: StoredCredential = serde_json::from_value(serde_json::json!({ + "type": "oauth", "access": token, "refresh": "unchanged-refresh", "expires": expires * 1000 + })).unwrap(); + store::upsert("hermes", legacy).await.unwrap(); + let before = store::load_entry_with_revision("hermes") + .await + .unwrap() + .revision; + let resolved = resolve_hermes("anthropic/claude-sonnet-5").await.unwrap(); + assert_eq!(resolved.format.as_deref(), Some("openai")); + assert_eq!( + resolved.request_url.as_deref(), + Some("https://inference-api.nousresearch.com/v1/chat/completions") + ); + assert_eq!(resolved.api_key, token); + let after = store::load_entry_with_revision("hermes").await.unwrap(); + assert_eq!(after.revision, before); + let roundtrip: StoredCredential = + serde_json::from_value(serde_json::to_value(after.credential.unwrap()).unwrap()) + .unwrap(); + assert!( + matches!(roundtrip, StoredCredential::Oauth { refresh, .. } if refresh == "unchanged-refresh") + ); + } + #[test] fn subscription_provider_serde_roundtrip() { assert_eq!( diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/opencode.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/opencode.rs index d1fb18816c..50fc77d25d 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/opencode.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/opencode.rs @@ -808,9 +808,17 @@ fn inference_headers(metadata: Option<&serde_json::Value>) -> HashMap, + configured_format: &str, + model: &str, + metadata: Option<&serde_json::Value>, +) -> Result { + let normalized_format = configured_format.trim().to_ascii_lowercase(); + let fallback_format = match (plan, normalized_format.as_str()) { + (None, _) => "openai", + (Some(_), "response") => "responses", + (Some(_), format) => format, + }; + let plan = plan.unwrap_or(OpenCodePlan::Zen); + let offerings = offerings_from_metadata(metadata); + let matches = |offering: &&SubscriptionApiOffering| { + offering.plan == plan && offering.models.iter().any(|item| item.id == model.trim()) + }; + let offering = offerings + .iter() + .filter(matches) + .find(|offering| offering.format == fallback_format) + .or_else(|| offerings.iter().find(matches)); + route_for( + plan, + offering + .map(|offering| offering.format.as_str()) + .unwrap_or(fallback_format), + ) +} + +/// Account catalog owns the protocol; callers select only a plan and model. +/// Unknown legacy/manual IDs keep their previous route instead of being deleted. +pub(crate) async fn resolve_for_model( + plan: Option, + configured_format: &str, + model: &str, + options: &SubscriptionHttpOptions, +) -> Result { + let credential = ensure_fresh(options).await?; + let route = route_for_model(plan, configured_format, model, credential.metadata.as_ref())?; + Ok(ResolvedCredential { + api_key: credential.access, + base_url: Some(route.base_url.to_string()), + request_url: Some(route.request_url.to_string()), + format: Some(route.format.to_string()), + extra_headers: inference_headers(credential.metadata.as_ref()), + expires_at: credential.expires_at_ms.map(|expires| expires / 1000), + }) +} + /// Resolves the legacy OpenCode target. Models saved before plan-aware auth /// are kept on their historical Zen Chat Completions route. pub(crate) async fn resolve(options: &SubscriptionHttpOptions) -> Result { @@ -1028,6 +1086,42 @@ mod tests { assert!(offerings.iter().any(|item| item.plan == OpenCodePlan::Go)); } + #[test] + fn account_catalog_selects_the_wire_without_user_protocol_configuration() { + let metadata = serde_json::json!({ "api_offerings": [ + {"plan": "go", "format": "anthropic", "base_url": "https://ignored.invalid", "suggested_model": "", "models": [{"id": "claude-fixture"}]}, + {"plan": "zen", "format": "responses", "base_url": "https://ignored.invalid", "suggested_model": "", "models": [{"id": "gpt-fixture"}]}, + {"plan": "zen", "format": "openai", "base_url": "https://ignored.invalid", "suggested_model": "", "models": [{"id": "chat-fixture"}]} + ]}); + let route = super::route_for_model( + Some(OpenCodePlan::Go), + "openai", + "claude-fixture", + Some(&metadata), + ) + .unwrap(); + assert_eq!(route.format, "anthropic"); + assert_eq!(route.request_url, "https://opencode.ai/zen/go/v1/messages"); + // Old configs omitted plan and recorded the generic chat wire. + let route = super::route_for_model(None, "openai", "gpt-fixture", Some(&metadata)).unwrap(); + assert_eq!(route.format, "responses"); + assert_eq!(route.request_url, "https://opencode.ai/zen/v1/responses"); + let unknown = + super::route_for_model(None, "anthropic", "legacy-manual-model", None).unwrap(); + assert_eq!(unknown.format, "openai"); + let wrong_plan = super::route_for_model( + Some(OpenCodePlan::Go), + "openai", + "gpt-fixture", + Some(&metadata), + ) + .unwrap(); + assert_eq!(wrong_plan.format, "openai"); + assert!(wrong_plan + .request_url + .starts_with("https://opencode.ai/zen/go/")); + } + #[test] fn forwards_current_and_legacy_org_ids_to_subscription_inference() { for metadata in [ @@ -1035,12 +1129,16 @@ mod tests { serde_json::json!({ "orgID": "org-legacy" }), ] { let headers = inference_headers(Some(&metadata)); - assert_eq!(headers.len(), 1); + assert_eq!(headers["x-opencode-client"], "openbitfun"); + assert!(headers["User-Agent"].starts_with("OpenBitFun/")); assert!(headers .get("x-org-id") .is_some_and(|value| value.starts_with("org-"))); } - assert!(inference_headers(Some(&serde_json::json!({ "org_id": " " }))).is_empty()); - assert!(inference_headers(None).is_empty()); + assert!( + !inference_headers(Some(&serde_json::json!({ "org_id": " " }))) + .contains_key("x-org-id") + ); + assert!(!inference_headers(None).contains_key("x-org-id")); } } diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs index 342e5a0d04..eabcecf27e 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs @@ -1413,8 +1413,11 @@ pub(crate) async fn load_entry_with_revision(provider: &str) -> Result Result { - let state = load_with_state().await?; - Ok(state.provider_revisions.get(provider).copied().unwrap_or(0)) + let (path, _transaction) = acquire_store_transaction().await?; + // Cache validation needs only the durable epoch, never decrypted secrets + // or opportunistic vault cleanup on every model call. + let file = read_secure_file(&path).await?; + Ok(file.provider_revisions.get(provider).copied().unwrap_or(0)) } /// Inserts or replaces a provider credential. Secret material is committed to diff --git a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs index b626810065..7f2a0b7a22 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs @@ -41,6 +41,8 @@ struct CachedAIClient { /// Unix seconds when the resolved subscription credential expires. #[cfg(feature = "subscription-auth")] credential_expires_at: Option, + #[cfg(feature = "subscription-auth")] + credential_revision: Option, } /// Once a cached subscription credential is within this window of expiry, the @@ -283,6 +285,14 @@ impl AIClientFactory { let default_reasoning_preset = resolve_default_reasoning_preset(&reasoning_projection).cloned(); + #[cfg(feature = "subscription-auth")] + let credential_revision = match &model_config.auth { + AuthConfig::Subscription { provider, .. } => { + Some(subscription_auth::credential_revision(to_adapter_provider(*provider)).await?) + } + AuthConfig::ApiKey => None, + }; + { let cache = match self.client_cache.read() { Ok(cache) => cache, @@ -294,7 +304,12 @@ impl AIClientFactory { } }; if let Some(cached) = cache.get(&normalized_model_id) { - if cached.configuration_fingerprint == configuration_fingerprint + #[cfg(feature = "subscription-auth")] + let account_unchanged = cached.credential_revision == credential_revision; + #[cfg(not(feature = "subscription-auth"))] + let account_unchanged = true; + if account_unchanged + && cached.configuration_fingerprint == configuration_fingerprint && cached.default_reasoning_preset == default_reasoning_preset && !subscription_credential_stale(&model_config.auth, cached) { @@ -323,7 +338,10 @@ impl AIClientFactory { let stream_options = build_stream_options_for_model(&global_config.ai, Some(&model_config)); let client = apply_default_reasoning_preset( - AIClient::new_with_runtime_options(ai_config, proxy_config, stream_options), + apply_subscription_request_profile( + &model_config.auth, + AIClient::new_with_runtime_options(ai_config, proxy_config, stream_options), + ), &reasoning_projection, ); let client = Arc::new(client); @@ -346,6 +364,11 @@ impl AIClientFactory { client: client.clone(), #[cfg(feature = "subscription-auth")] credential_expires_at, + // Capture before resolution: a concurrent mutation or token + // rotation conservatively causes another rebuild, never a + // stale client stamped with a newer account's epoch. + #[cfg(feature = "subscription-auth")] + credential_revision, }, ); } @@ -449,6 +472,17 @@ fn to_adapter_opencode_plan(plan: OpenCodePlan) -> AdapterOpenCodePlan { } } +/// Attach request policy from explicit auth identity after credential resolution. +pub fn apply_subscription_request_profile(auth: &AuthConfig, client: AIClient) -> AIClient { + #[cfg(feature = "subscription-auth")] + if let AuthConfig::Subscription { provider, .. } = auth { + return client.with_subscription_provider(to_adapter_provider(*provider)); + } + #[cfg(not(feature = "subscription-auth"))] + let _ = auth; + client +} + /// Resolve a subscription `AuthConfig` and overlay it onto the runtime /// `AIConfig`. No-op when `auth == AuthConfig::ApiKey`. Returns the resolved /// credential's expiry (Unix seconds) so callers can invalidate cached @@ -515,10 +549,11 @@ pub async fn apply_subscription_auth_with_options( ai_config.model = model.to_string(); } let resolved = match (*provider, *plan) { - (SubscriptionProvider::Opencode, Some(plan)) => { - subscription_auth::resolve_opencode_with_options( - to_adapter_opencode_plan(plan), + (SubscriptionProvider::Opencode, plan) => { + subscription_auth::resolve_opencode_model_with_options( + plan.map(to_adapter_opencode_plan), &ai_config.format, + &ai_config.model, options, ) .await @@ -546,34 +581,7 @@ pub async fn apply_subscription_auth_with_options( } }; - ai_config.api_key = resolved.api_key; - if let Some(base) = resolved.base_url { - ai_config.base_url = base; - } - if let Some(req) = resolved.request_url { - ai_config.request_url = req; - } - if let Some(format) = resolved.format { - ai_config.format = format; - } - if !resolved.extra_headers.is_empty() { - let merged = match ai_config.custom_headers.take() { - Some(mut existing) => { - for (k, v) in resolved.extra_headers { - existing.insert(k, v); - } - existing - } - None => resolved.extra_headers, - }; - ai_config.custom_headers = Some(merged); - // Default to merge so adapter-specific headers (Authorization etc.) are - // still applied alongside the injected ones. - if ai_config.custom_headers_mode.is_none() { - ai_config.custom_headers_mode = Some("merge".to_string()); - } - } - Ok(resolved.expires_at) + Ok(resolved.apply_to(ai_config)) } /// List subscription accounts (Codex / Antigravity / OpenCode / xAI / Hermes). @@ -657,6 +665,78 @@ mod tests { .expect("runtime model should resolve through the AI client factory"); } + #[cfg(feature = "subscription-auth")] + #[tokio::test] + async fn subscription_cache_rebuilds_after_account_changes_and_rejects_logout() { + use crate::infrastructure::subscription_auth::{self, store, StoredCredential}; + let dir = tempfile::tempdir().unwrap(); + subscription_auth::set_store_path_for_test(dir.path().join("subscription.json")); + let config = Arc::new( + ConfigService::with_settings(ConfigManagerSettings { + path_manager: Some(Arc::new(PathManager::with_user_root_for_tests( + dir.path().join("config"), + ))), + auto_save: true, + backup_count: 0, + }) + .await + .unwrap(), + ); + let mut model = build_model("subscription:fixture", "OpenCode", "fixture-model"); + model.provider = "openai".into(); + model.base_url = "https://opencode.ai/zen/v1".into(); + model.auth = AuthConfig::Subscription { + provider: super::SubscriptionProvider::Opencode, + plan: None, + }; + config.install_runtime_ai_model(model).await.unwrap(); + let factory = AIClientFactory::new(config); + store::upsert( + "opencode", + StoredCredential::Api { + key: "first-synthetic-key".into(), + metadata: None, + }, + ) + .await + .unwrap(); + let first = factory + .get_client_by_id("subscription:fixture") + .await + .unwrap(); + assert_eq!(first.subscription_provider_key(), Some("opencode")); + assert!(Arc::ptr_eq( + &first, + &factory + .get_client_by_id("subscription:fixture") + .await + .unwrap() + )); + // A different process would advance the same on-disk provider epoch. + store::upsert( + "opencode", + StoredCredential::Api { + key: "replacement-synthetic-key".into(), + metadata: None, + }, + ) + .await + .unwrap(); + let replacement = factory + .get_client_by_id("subscription:fixture") + .await + .unwrap(); + assert!(!Arc::ptr_eq(&first, &replacement)); + assert_eq!(replacement.config.api_key, "replacement-synthetic-key"); + subscription_auth::logout(subscription_auth::SubscriptionProvider::Opencode) + .await + .unwrap(); + assert!(factory + .get_client_by_id("subscription:fixture") + .await + .is_err()); + } + #[cfg(feature = "subscription-auth")] #[tokio::test] async fn api_key_auth_remains_a_noop_when_subscription_support_is_compiled() { @@ -669,6 +749,11 @@ mod tests { assert_eq!(expires_at, None); assert_eq!(config.api_key, "unchanged"); assert_eq!(config.base_url, "https://example.test"); + let client = super::apply_subscription_request_profile( + &AuthConfig::ApiKey, + super::AIClient::new(config), + ); + assert_eq!(client.subscription_provider_key(), None); } #[cfg(not(feature = "subscription-auth"))] diff --git a/src/web-ui/README.md b/src/web-ui/README.md index 70308c43d6..3950ee28b7 100644 --- a/src/web-ui/README.md +++ b/src/web-ui/README.md @@ -97,9 +97,17 @@ you can also enter a provider-supported model ID manually. Antigravity queries its authenticated `fetchAvailableModels` endpoint; Codex uses its subscription catalog, including models unavailable through the public -OpenAI API. OpenCode separates Go/Zen and Chat Completions/Responses/Messages. -xAI and Hermes query their model endpoints; Hermes routes `anthropic/*` models -through Messages with the Nous OAuth bearer. +OpenAI API. For OpenCode, choose Go/Zen and a model; OpenBitFun selects the +matching Chat Completions, Responses, or Messages protocol from the account catalog. +xAI and Hermes query their model endpoints. Hermes uses Chat Completions with +Nous OAuth bearer authentication for all models, including `anthropic/*`, matching +the current upstream default while its native Messages cache issue is unresolved. +Saved model IDs and subscription credentials remain valid. + +Subscription login supplies the required authentication and account headers even +if a saved model used custom-header replace mode. There is no need to paste tokens +or provider identity headers into the model editor. These policies apply only to +subscription models; API-key models continue to use their saved request settings. The account's returned IDs determine availability. A familiar or older ID does not prove the underlying model is outdated, and a model advertised by a vendor diff --git a/src/web-ui/README.zh-CN.md b/src/web-ui/README.zh-CN.md index ff1c1ba497..e1be6d956e 100644 --- a/src/web-ui/README.zh-CN.md +++ b/src/web-ui/README.zh-CN.md @@ -116,9 +116,15 @@ VITE_BUILD_TARGET=web pnpm --dir src/web-ui run build 已保存的模型不会被删除,也可以手动填写服务商支持的模型 ID。 反重力通过账号的 `fetchAvailableModels` 接口获取模型;Codex 使用订阅模型目录, -保留公共 API 不提供的订阅专属模型。OpenCode 按 Go/Zen 和请求格式分别展示。 -xAI、Hermes 查询各自的模型接口;Hermes 的 `anthropic/*` 模型使用 Messages 协议 -和 Nous OAuth Bearer 认证。 +保留公共 API 不提供的订阅专属模型。OpenCode 只需选择 Go/Zen 和模型,OpenBitFun +根据账号目录自动匹配 Chat Completions、Responses 或 Messages 协议。 +xAI、Hermes 查询各自的模型接口。Hermes 所有模型(包括 `anthropic/*`)使用 +Chat Completions 和 Nous OAuth Bearer 认证,与上游在原生 Messages 缓存问题解决前的 +默认路由保持一致。已保存的模型 ID 和订阅凭据继续有效。 + +订阅登录会自动提供必需的认证头和账号头,即使旧模型配置使用了“替换自定义请求头”模式, +也无需在模型编辑器中手动粘贴令牌或提供商身份请求头。这些适配仅对订阅模型启用, +API Key 模型继续使用原有请求配置。 模型是否可用以当前账号接口返回的 ID 为准。旧名称不一定代表底层模型没有更新, 服务商公布的新模型也不保证对每种订阅或 OAuth 客户端开放。获取失败时会显示错误, diff --git a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx index 202db4fe61..f613a049e7 100644 --- a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx +++ b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx @@ -59,7 +59,6 @@ import { import { aiApi, systemAPI } from '@/infrastructure/api'; import type { SubscriptionAccount, - SubscriptionApiOffering, SubscriptionLoginMethod, } from '@/infrastructure/api/service-api/AIApi'; import type { ProviderRegion } from '@/shared/types'; @@ -101,7 +100,7 @@ import { SubscriptionLoginCoordinator, type SubscriptionLoginOperation, } from './subscriptionLoginCoordinator'; -import { ModelDiscoveryCoordinator, openCodeOfferingModels } from './modelDiscoveryCoordinator'; +import { ModelDiscoveryCoordinator, openCodeOfferingModels, openCodeModelOffering } from './modelDiscoveryCoordinator'; import './ModelSettingsPage.scss'; const log = createLogger('ModelSettings'); @@ -762,12 +761,6 @@ const ModelSettingsPage: React.FC = () => { : t('subscriptionAuth.openCodePlans.zen.description') ), [t]); - const getOpenCodeFormatLabel = useCallback((format: SubscriptionApiOffering['format']): string => { - if (format === 'responses') return t('subscriptionAuth.openCodeFormats.responses'); - if (format === 'anthropic') return t('subscriptionAuth.openCodeFormats.messages'); - return t('subscriptionAuth.openCodeFormats.chatCompletions'); - }, [t]); - const syncSelectedModelDrafts = ( modelNames: string[], baseConfig?: Partial, @@ -1013,7 +1006,7 @@ const ModelSettingsPage: React.FC = () => { if (!scope.isCurrent() || !coordinator.isCurrent(operation)) return; setSubscriptionAccounts(current => current.map(item => item.provider === 'opencode' ? account : item)); remoteModels = openCodeOfferingModels( - account.api_offerings ?? [], discoveryConfig.auth.plan, discoveryConfig.provider, + account.api_offerings ?? [], discoveryConfig.auth.plan, ).map(model => ({ id: model.id, display_name: model.display_name || undefined })); } else { remoteModels = await aiApi.listModelsByConfig(discoveryConfig); @@ -1084,9 +1077,9 @@ const ModelSettingsPage: React.FC = () => { const handleImportFromSubscription = useCallback(( account: SubscriptionAccount, - offering?: SubscriptionApiOffering, + plan?: OpenCodePlan, ) => { - const targetKey = `new-provider:subscription:${account.provider}:${offering?.plan || 'default'}:${offering?.format || 'default'}`; + const targetKey = `new-provider:subscription:${account.provider}:${plan || 'default'}`; requestEditorOpen(targetKey, () => { resetRemoteModelDiscovery(); setManualModelInput(''); @@ -1094,11 +1087,9 @@ const ModelSettingsPage: React.FC = () => { setSelectedProviderId(null); setEditingTargetKey(targetKey); setEditingConfig({ - name: offering - ? getOpenCodePlanLabel(offering.plan) - : account.display_label, - provider: offering?.format || account.suggested_format, - base_url: offering?.base_url || account.suggested_base_url, + name: plan ? getOpenCodePlanLabel(plan) : account.display_label, + provider: account.suggested_format, + base_url: plan === 'go' ? 'https://opencode.ai/zen/go/v1' : account.suggested_base_url, // Leave request_url + model_name empty so the user must pick a model // from the live list. We never inject a hard-coded default slug. request_url: '', @@ -1114,7 +1105,7 @@ const ModelSettingsPage: React.FC = () => { auth: { type: 'subscription', provider: account.provider, - ...(offering ? { plan: offering.plan } : {}), + ...(plan ? { plan } : {}), }, }); setSelectedModelDrafts([]); @@ -1707,31 +1698,40 @@ const ModelSettingsPage: React.FC = () => { || allocateModelConfigId(draft.modelName, allocatedConfigIds); allocatedConfigIds.add(id); + const auth = editingConfig.auth; + const offering = auth?.type === 'subscription' && auth.provider === 'opencode' + ? openCodeModelOffering( + subscriptionAccounts.find(account => account.provider === 'opencode')?.api_offerings ?? [], + auth.plan, draft.modelName, editingConfig.provider, + ) + : undefined; + const format = offering?.format || editingConfig.provider || 'openai'; + const modelBaseUrl = offering?.base_url || baseUrl; return { id, name: providerName, - base_url: baseUrl, + base_url: modelBaseUrl, request_url: resolveRequestUrl( - baseUrl, - editingConfig.provider || 'openai', + modelBaseUrl, + format, draft.modelName ), api_key: editingConfig.api_key || '', model_name: draft.modelName, - provider: editingConfig.provider || 'openai', + provider: format, enabled: editingConfig.enabled ?? true, context_window: draft.contextWindow, max_tokens: draft.maxTokens, category: resolveModelCategory( draft.modelName, draft.category, - editingConfig.provider || 'openai' + format ), capabilities: getCapabilitiesByCategory( resolveModelCategory( draft.modelName, draft.category, - editingConfig.provider || 'openai' + format ) ), recommended_for: editingConfig.recommended_for || [], @@ -1746,7 +1746,9 @@ const ModelSettingsPage: React.FC = () => { skip_ssl_verify: editingConfig.skip_ssl_verify ?? false, custom_request_body: editingConfig.custom_request_body, custom_request_body_mode: editingConfig.custom_request_body_mode, - auth: editingConfig.auth || { type: 'api_key' }, + auth: offering && auth?.type === 'subscription' + ? { ...auth, plan: offering.plan } + : editingConfig.auth || { type: 'api_key' }, }; }); let previousModelsBeforeSave: AIModelConfigType[] = []; @@ -2684,11 +2686,16 @@ const ModelSettingsPage: React.FC = () => { const plan = provider === 'opencode' ? (planValue || 'zen') as OpenCodePlan : undefined; + resetRemoteModelDiscovery(); + const account = subscriptionAccounts.find(item => item.provider === provider); setEditingConfig((prev) => { if (!prev) return prev; if (provider !== 'opencode') { return { ...prev, + provider: account?.suggested_format || prev.provider, + base_url: account?.suggested_base_url || prev.base_url, + request_url: '', auth: { type: 'subscription', provider }, }; } @@ -2770,69 +2777,73 @@ const ModelSettingsPage: React.FC = () => { {renderAuthRow()} {!authIsSubscription && renderApiKeyRow(t('form.apiKey'))} - -
- {currentTemplate?.baseUrlOptions && currentTemplate.baseUrlOptions.length > 0 && ( - opt.url === editingConfig.base_url) ? editingConfig.base_url : ''} + {!authIsSubscription && ( + <> + +
+ {currentTemplate?.baseUrlOptions && currentTemplate.baseUrlOptions.length > 0 && ( + opt.url === editingConfig.base_url) ? editingConfig.base_url : ''} + onValueChange={(value) => { + const selectedOption = currentTemplate.baseUrlOptions!.find(opt => opt.url === value); + const newProvider = selectedOption?.format || editingConfig.provider || 'openai'; + resetRemoteModelDiscovery(); + setEditingConfig(prev => ({ + ...prev, + base_url: value as string, + request_url: resolveRequestUrl(value as string, newProvider, editingConfig.model_name || ''), + provider: newProvider + })); + }} + placeholder={t('form.baseUrl')} + options={currentTemplate.baseUrlOptions.map(opt => ({ label: opt.note || opt.url, value: opt.url, description: `${opt.format.toUpperCase()} · ${opt.url}` }))} + size="sm" + /> + )} + { + resetRemoteModelDiscovery(); + setEditingConfig(prev => ({ + ...prev, + base_url: e.target.value, + request_url: resolveRequestUrl(e.target.value, prev?.provider || 'openai', prev?.model_name || '') + })); + }} + onFocus={(e) => e.target.select()} + placeholder={currentTemplate?.baseUrl} + size="sm" + /> + {editingConfig.base_url && ( +
+ {t('form.resolvedUrlLabel')} + {previewRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai')} +
+ )} +
+
+ + { - resetRemoteModelDiscovery(); - setEditingConfig(prev => ({ - ...prev, - base_url: e.target.value, - request_url: resolveRequestUrl(e.target.value, prev?.provider || 'openai', prev?.model_name || '') - })); - }} - onFocus={(e) => e.target.select()} - placeholder={currentTemplate?.baseUrl} - size="sm" - /> - {editingConfig.base_url && ( -
- {t('form.resolvedUrlLabel')} - {previewRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai')} -
- )} -
-
- - { + {!authIsSubscription && ( + <> + +
+ { + resetRemoteModelDiscovery(); + setEditingConfig(prev => ({ + ...prev, + base_url: e.target.value, + request_url: resolveRequestUrl(e.target.value, prev?.provider || 'openai', prev?.model_name || '') + })); + }} + onFocus={(e) => e.target.select()} + placeholder={'https://open.bigmodel.cn/api/paas/v4/chat/completions'} + size="sm" + /> + {editingConfig.base_url && ( +
+ {t('form.resolvedUrlLabel')} + {previewRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai')} +
+ )} +
+
+ + { - const provider = value as string; - resetRemoteModelDiscovery(); - setEditingConfig(prev => ({ - ...prev, - provider, - request_url: resolveRequestUrl(prev?.base_url || '', provider, prev?.model_name || ''), - })); - }} placeholder={t('form.providerPlaceholder')} options={requestFormatOptions} size="sm" /> - + }} placeholder={t('form.providerPlaceholder')} options={requestFormatOptions} size="sm" /> +
+ + )} )} @@ -3023,199 +3038,201 @@ const ModelSettingsPage: React.FC = () => { )} - - - setShowAdvancedSettings(e.target.checked)} /> - + {!authIsSubscription && ( + + + setShowAdvancedSettings(e.target.checked)} /> + - {showAdvancedSettings && ( - <> - {(editingConfig.provider === 'openai' || editingConfig.provider === 'anthropic') && ( + {showAdvancedSettings && ( + <> + {(editingConfig.provider === 'openai' || editingConfig.provider === 'anthropic') && ( + + setEditingConfig(prev => ({ ...prev, inline_think_in_text: e.target.checked }))} + /> + + )} + + {t('advancedSettings.skipSslVerify.warning')} + + ) : undefined} align="center" className="openbitfun-model-settings__toggle-row" > setEditingConfig(prev => ({ ...prev, inline_think_in_text: e.target.checked }))} + checked={editingConfig.skip_ssl_verify || false} + onChange={(e) => setEditingConfig(prev => ({ ...prev, skip_ssl_verify: e.target.checked }))} /> - )} - - - {t('advancedSettings.skipSslVerify.warning')} - - ) : undefined} - align="center" - className="openbitfun-model-settings__toggle-row" - > - setEditingConfig(prev => ({ ...prev, skip_ssl_verify: e.target.checked }))} - /> - - - - {t('advancedSettings.customHeaders.label')} - - {t('advancedSettings.customHeaders.hint')} - - {(editingConfig.custom_headers_mode || 'merge') === 'replace' - ? t('advancedSettings.customHeaders.modeReplaceHint') - : t('advancedSettings.customHeaders.modeMergeHint')} + + + {t('advancedSettings.customHeaders.label')} + + {t('advancedSettings.customHeaders.hint')} + + {(editingConfig.custom_headers_mode || 'merge') === 'replace' + ? t('advancedSettings.customHeaders.modeReplaceHint') + : t('advancedSettings.customHeaders.modeMergeHint')} + - - )} - placement="top" - > - - - - - - - - - - - - + + + + + + + + + + + + + - - )} - multiline - className="openbitfun-model-settings__custom-headers-row" - > -
-
- {Object.entries(editingConfig.custom_headers || {}).map(([key, value], index) => ( -
- { const nh = { ...editingConfig.custom_headers }; const ov = nh[key]; delete nh[key]; if (e.target.value) nh[e.target.value] = ov; setEditingConfig(prev => ({ ...prev, custom_headers: nh })); }} - placeholder={t('advancedSettings.customHeaders.keyPlaceholder')} - className="openbitfun-model-settings__header-key" - size="sm" - /> - { const nh = { ...editingConfig.custom_headers }; nh[key] = e.target.value; setEditingConfig(prev => ({ ...prev, custom_headers: nh })); }} - placeholder={t('advancedSettings.customHeaders.valuePlaceholder')} - className="openbitfun-model-settings__header-value" - size="sm" - /> - - +
+
+ {Object.entries(editingConfig.custom_headers || {}).map(([key, value], index) => ( +
+ { const nh = { ...editingConfig.custom_headers }; const ov = nh[key]; delete nh[key]; if (e.target.value) nh[e.target.value] = ov; setEditingConfig(prev => ({ ...prev, custom_headers: nh })); }} + placeholder={t('advancedSettings.customHeaders.keyPlaceholder')} + className="openbitfun-model-settings__header-key" size="sm" - onClick={() => { const nh = { ...editingConfig.custom_headers }; delete nh[key]; setEditingConfig(prev => ({ ...prev, custom_headers: Object.keys(nh).length > 0 ? nh : undefined })); }} - icon={} /> - -
- ))} - + { const nh = { ...editingConfig.custom_headers }; nh[key] = e.target.value; setEditingConfig(prev => ({ ...prev, custom_headers: nh })); }} + placeholder={t('advancedSettings.customHeaders.valuePlaceholder')} + className="openbitfun-model-settings__header-value" + size="sm" + /> + + { const nh = { ...editingConfig.custom_headers }; delete nh[key]; setEditingConfig(prev => ({ ...prev, custom_headers: Object.keys(nh).length > 0 ? nh : undefined })); }} + icon={} + /> + +
+ ))} + +
-
- - - - {t('advancedSettings.customRequestBody.label')} - - {t('advancedSettings.customRequestBody.hint')} - {getCustomRequestBodyModeHint(editingConfig.provider, editingConfig.custom_request_body_mode)} - - )} - placement="top" - > - - - - - - - - - - - - + + + + + + + + + + + + + - - )} - multiline - className="openbitfun-model-settings__custom-request-body-row" - > -
-