Skip to content

Commit f438279

Browse files
committed
fix(ai): isolate and align subscription provider requests
1 parent c88b25f commit f438279

20 files changed

Lines changed: 1449 additions & 468 deletions

File tree

‎src/apps/desktop/src/api/commands.rs‎

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -992,23 +992,7 @@ pub async fn initialize_ai(state: State<'_, AppState>) -> Result<String, String>
992992
.iter()
993993
.find(|m| m.id == primary_model_id)
994994
.ok_or_else(|| format!("Primary model '{}' does not exist", primary_model_id))?;
995-
let stream_options = openbitfun_core::infrastructure::ai::build_stream_options_for_model(
996-
&global_config.ai,
997-
Some(model_config),
998-
);
999-
1000-
let ai_config = openbitfun_core::util::types::AIConfig::try_from(model_config.clone())
1001-
.map_err(|e| format!("Failed to convert AI configuration: {}", e))?;
1002-
let proxy_config = if global_config.ai.proxy.enabled {
1003-
Some(global_config.ai.proxy.clone())
1004-
} else {
1005-
None
1006-
};
1007-
let ai_client = openbitfun_core::infrastructure::ai::AIClient::new_with_runtime_options(
1008-
ai_config,
1009-
proxy_config,
1010-
stream_options,
1011-
);
995+
let ai_client = create_transient_ai_client_for_config(&state, model_config.clone()).await?;
1012996

1013997
{
1014998
let mut ai_client_guard = state.ai_client.write().await;
@@ -1063,10 +1047,13 @@ async fn create_transient_ai_client_for_config(
10631047
.map_err(|e| format!("Failed to resolve subscription auth: {}", e))?;
10641048

10651049
Ok(
1066-
openbitfun_core::infrastructure::ai::AIClient::new_with_runtime_options(
1067-
ai_config,
1068-
proxy_config,
1069-
stream_options,
1050+
openbitfun_core::infrastructure::ai::client_factory::apply_subscription_request_profile(
1051+
&auth,
1052+
openbitfun_core::infrastructure::ai::AIClient::new_with_runtime_options(
1053+
ai_config,
1054+
proxy_config,
1055+
stream_options,
1056+
),
10701057
),
10711058
)
10721059
}

‎src/crates/adapters/ai-adapters/AGENTS.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,36 @@ provider-neutral contracts owned by `openbitfun-agent-stream`.
2727
service/process dependencies by default. Never scan or reuse third-party CLI
2828
credential files on disk; tokens come only from the in-app OAuth store.
2929

30+
## Subscription protocol references
31+
32+
Compared on 2026-09-08 against [OpenCode v1.18.29](https://github.com/anomalyco/opencode/tree/16747470f976aca3d362ad730bcd3fe82ecc2c9a)
33+
(`account/account.ts`, `plugin/openai/codex.ts`, `plugin/xai.ts`, and
34+
`session/llm/request.ts` under `packages/opencode/src`) and
35+
[Hermes Agent](https://github.com/NousResearch/hermes-agent/tree/6e2b8e070d28b1a3381a3fb290b6b8d6cce13cef)
36+
(`hermes_cli/auth_nous.py`, `hermes_cli/providers.py`, `agent/codex_headers.py`,
37+
`agent/opencode_affinity.py`, and `agent/transports/codex.py`).
38+
39+
- OpenCode's account catalog chooses each model's protocol within its plan;
40+
users select a plan/model, not a wire format. Preserve unknown legacy/manual
41+
routes, and pin catalog-derived endpoints to OpenCode's production origin.
42+
- Hermes currently defaults even `anthropic/*` to Chat Completions while the
43+
Portal native Messages cache issue is unresolved. Preserve the Nous bearer
44+
and `x-nous-refresh-token` refresh contract, including rotated-token storage.
45+
- Subscription credentials own authentication and account headers regardless
46+
of saved replace mode or header casing. Use OpenBitFun attribution for Codex
47+
and OpenCode; retain provider-required compatibility headers for xAI and
48+
Antigravity. Public API-key configurations retain their existing behavior.
49+
- Additional subscription request policy is enabled only by an explicit runtime
50+
subscription identity attached after resolving AuthConfig::Subscription; URLs
51+
and model names never opt ordinary API-key clients into it.
52+
- Request affinity comes from `ModelRequestContext` on each call, never from a
53+
random ID on a cached client. Standalone OpenCode calls without runtime context
54+
still require `x-opencode-session`: generate one opaque identity per logical
55+
call and reuse it across all retries, including aggregate stream retries.
56+
Only the matching provider origin receives it.
57+
Client caches also compare the durable credential revision so login, logout,
58+
refresh, and account catalog changes invalidate old credentials/routes.
59+
3060
## Verification
3161

3262
Subscription model discovery must use the authenticated account catalog.

‎src/crates/adapters/ai-adapters/src/client.rs‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ pub struct AIClient {
6262
pub(crate) stream_options: StreamOptions,
6363
pub(crate) model_reasoning_preset: Option<ReasoningPresetDescriptor>,
6464
pub(crate) selected_reasoning_preset: Option<ReasoningPresetDescriptor>,
65+
#[cfg(feature = "subscription-auth")]
66+
subscription_provider: Option<crate::subscription_auth::SubscriptionProvider>,
6567
}
6668

6769
impl AIClient {
@@ -95,6 +97,31 @@ impl AIClient {
9597
stream_options,
9698
model_reasoning_preset: None,
9799
selected_reasoning_preset: None,
100+
#[cfg(feature = "subscription-auth")]
101+
subscription_provider: None,
102+
}
103+
}
104+
105+
/// Enable provider policy only after resolving an explicit subscription login.
106+
/// This runtime identity is not inferred from URLs or serialized in AIConfig.
107+
#[cfg(feature = "subscription-auth")]
108+
pub fn with_subscription_provider(
109+
mut self,
110+
provider: crate::subscription_auth::SubscriptionProvider,
111+
) -> Self {
112+
self.subscription_provider = Some(provider);
113+
self
114+
}
115+
116+
/// Explicit subscription identity; ordinary API-key clients return None.
117+
pub fn subscription_provider_key(&self) -> Option<&'static str> {
118+
#[cfg(feature = "subscription-auth")]
119+
{
120+
self.subscription_provider.map(|provider| provider.key())
121+
}
122+
#[cfg(not(feature = "subscription-auth"))]
123+
{
124+
None
98125
}
99126
}
100127

@@ -196,15 +223,9 @@ impl AIClient {
196223
/// Clone this client with a different max output token limit while
197224
/// reusing the HTTP client.
198225
pub fn with_max_tokens(&self, max_tokens: Option<u32>) -> Self {
199-
let mut config = self.config.clone();
200-
config.max_tokens = max_tokens;
201-
Self {
202-
client: self.client.clone(),
203-
config,
204-
stream_options: self.stream_options.clone(),
205-
model_reasoning_preset: self.model_reasoning_preset.clone(),
206-
selected_reasoning_preset: self.selected_reasoning_preset.clone(),
207-
}
226+
let mut cloned = self.clone();
227+
cloned.config.max_tokens = max_tokens;
228+
cloned
208229
}
209230

210231
pub async fn send_message_stream(
@@ -366,6 +387,8 @@ impl AIClient {
366387
trace: Option<ModelExchangeTraceConfig>,
367388
max_attempts: usize,
368389
) -> Result<GeminiResponse> {
390+
let request_context =
391+
crate::providers::shared::prepare_request_context(self, request_context);
369392
for attempt in 0..max_attempts {
370393
let stream_response = match self
371394
.send_message_stream_with_extra_body_and_max_attempts(

‎src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,7 @@ pub(crate) async fn send_stream(
502502
request_context: Option<ModelRequestContext>,
503503
) -> Result<StreamResponse> {
504504
let url = client.config.request_url.clone();
505+
let request_context = shared::prepare_request_context(client, request_context);
505506
debug!(
506507
"Anthropic config: model={}, request_url={}, max_tries={}",
507508
client.config.model, client.config.request_url, max_tries
@@ -530,7 +531,14 @@ pub(crate) async fn send_stream(
530531
max_tries,
531532
ttft_timeout,
532533
trace,
533-
|| apply_headers(client, client.client.post(&url), &url),
534+
|| {
535+
shared::apply_affinity_headers(
536+
client,
537+
apply_headers(client, client.client.post(&url), &url),
538+
&url,
539+
request_context.as_ref(),
540+
)
541+
},
534542
move |response, tx, tx_raw, remaining_ttft_timeout| {
535543
handle_anthropic_stream(
536544
response,

‎src/crates/adapters/ai-adapters/src/providers/openai/chat.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ pub(crate) async fn send_stream(
194194
request_context: Option<ModelRequestContext>,
195195
) -> Result<StreamResponse> {
196196
let url = client.config.request_url.clone();
197+
let request_context = shared::prepare_request_context(client, request_context);
197198
debug!(
198199
"OpenAI config: model={}, request_url={}, max_tries={}",
199200
client.config.model, client.config.request_url, max_tries
@@ -220,7 +221,14 @@ pub(crate) async fn send_stream(
220221
max_tries,
221222
ttft_timeout,
222223
trace,
223-
|| common::apply_headers(client, client.client.post(&url)),
224+
|| {
225+
shared::apply_affinity_headers(
226+
client,
227+
common::apply_headers(client, client.client.post(&url)),
228+
&url,
229+
request_context.as_ref(),
230+
)
231+
},
224232
move |response, tx, tx_raw, remaining_ttft_timeout| {
225233
handle_openai_stream(
226234
response,

0 commit comments

Comments
 (0)