Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 8 additions & 21 deletions src/apps/desktop/src/api/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -992,23 +992,7 @@ pub async fn initialize_ai(state: State<'_, AppState>) -> Result<String, String>
.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;
Expand Down Expand Up @@ -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,
),
),
)
}
Expand Down
30 changes: 30 additions & 0 deletions src/crates/adapters/ai-adapters/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 32 additions & 9 deletions src/crates/adapters/ai-adapters/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ pub struct AIClient {
pub(crate) stream_options: StreamOptions,
pub(crate) model_reasoning_preset: Option<ReasoningPresetDescriptor>,
pub(crate) selected_reasoning_preset: Option<ReasoningPresetDescriptor>,
#[cfg(feature = "subscription-auth")]
subscription_provider: Option<crate::subscription_auth::SubscriptionProvider>,
}

impl AIClient {
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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<u32>) -> 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(
Expand Down Expand Up @@ -366,6 +387,8 @@ impl AIClient {
trace: Option<ModelExchangeTraceConfig>,
max_attempts: usize,
) -> Result<GeminiResponse> {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ pub(crate) async fn send_stream(
request_context: Option<ModelRequestContext>,
) -> Result<StreamResponse> {
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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion src/crates/adapters/ai-adapters/src/providers/openai/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ pub(crate) async fn send_stream(
request_context: Option<ModelRequestContext>,
) -> Result<StreamResponse> {
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
Expand All @@ -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,
Expand Down
Loading
Loading