From 42aefd585ceb9d209a23555fb1b07581fce7c1f2 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 6 Sep 2026 11:12:34 +0800 Subject: [PATCH 1/2] fix(ai): refresh subscription models and harden OAuth flows --- src/crates/adapters/ai-adapters/AGENTS.md | 11 + src/crates/adapters/ai-adapters/Cargo.toml | 2 +- .../src/providers/anthropic/request.rs | 14 +- .../src/providers/gemini/code_assist.rs | 219 +++++++++++++++-- .../src/providers/openai/common.rs | 231 ++++++------------ .../src/subscription_auth/codex.rs | 6 +- .../src/subscription_auth/device_flow.rs | 155 ++++++++++++ .../ai-adapters/src/subscription_auth/grok.rs | 49 ++-- .../src/subscription_auth/hermes.rs | 48 ++-- .../ai-adapters/src/subscription_auth/jwt.rs | 23 ++ .../ai-adapters/src/subscription_auth/mod.rs | 44 ++++ .../src/subscription_auth/opencode.rs | 133 +++++----- src/web-ui/README.md | 20 ++ src/web-ui/README.zh-CN.md | 16 ++ .../config/components/ModelSettingsPage.tsx | 105 ++++---- .../modelDiscoveryCoordinator.test.ts | 42 ++++ .../components/modelDiscoveryCoordinator.ts | 46 ++++ .../src/locales/en-US/settings/models.json | 3 + .../src/locales/zh-CN/settings/models.json | 3 + .../src/locales/zh-TW/settings/models.json | 3 + 20 files changed, 807 insertions(+), 366 deletions(-) create mode 100644 src/crates/adapters/ai-adapters/src/subscription_auth/device_flow.rs create mode 100644 src/web-ui/src/infrastructure/config/components/modelDiscoveryCoordinator.test.ts create mode 100644 src/web-ui/src/infrastructure/config/components/modelDiscoveryCoordinator.ts diff --git a/src/crates/adapters/ai-adapters/AGENTS.md b/src/crates/adapters/ai-adapters/AGENTS.md index 6b5b736088..913d43e12a 100644 --- a/src/crates/adapters/ai-adapters/AGENTS.md +++ b/src/crates/adapters/ai-adapters/AGENTS.md @@ -29,6 +29,17 @@ provider-neutral contracts owned by `openbitfun-agent-stream`. ## Verification +Subscription model discovery must use the authenticated account catalog. +Antigravity uses `v1internal:fetchAvailableModels`; preserve returned wire IDs +and restrict alias translation to known legacy names. Codex's `supported_in_api` +flag describes the public API, not subscription availability. OpenCode catalog +models must stay grouped by plan and wire format. Never mask a failed account +lookup with a static catalog or another application's local model cache. + +For the auth/discovery path, use `cargo test -p openbitfun-ai-adapters --features +subscription-auth --lib`. Device-grant timing tests use the dev-only Tokio +test clock and synthetic tokens; they do not authorize real accounts. + ```bash cargo test -p openbitfun-agent-stream cargo test -p openbitfun-ai-adapters diff --git a/src/crates/adapters/ai-adapters/Cargo.toml b/src/crates/adapters/ai-adapters/Cargo.toml index ce1254aef6..8432d49a5e 100644 --- a/src/crates/adapters/ai-adapters/Cargo.toml +++ b/src/crates/adapters/ai-adapters/Cargo.toml @@ -72,7 +72,7 @@ subscription-auth = [ [dev-dependencies] axum = { workspace = true } openbitfun-events = { path = "../../contracts/events" } -tokio = { workspace = true, features = ["io-util", "net", "rt-multi-thread"] } +tokio = { workspace = true, features = ["io-util", "net", "rt-multi-thread", "test-util"] } [lints] workspace = true 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 033405bd68..7d7b445627 100644 --- a/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs +++ b/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs @@ -34,7 +34,15 @@ struct ClaudeModelVersion { /// (ANTHROPIC_AUTH_TOKEN) instead of `x-api-key`: Zhipu bigmodel.cn / Z.AI, /// Moonshot's /anthropic gateway, and Kimi For Coding. fn wants_bearer_auth(url: &str) -> bool { - url.contains("bigmodel.cn") + // Nous Portal uses the same OAuth bearer for its Messages and model-list + // endpoints. Do not send that subscription token as an Anthropic API key. + let nous_portal = reqwest::Url::parse(url).ok().is_some_and(|url| { + url.scheme() == "https" + && url.host_str() == Some("inference-api.nousresearch.com") + && url.port_or_known_default() == Some(443) + }); + nous_portal + || url.contains("bigmodel.cn") || url.contains("api.z.ai") || url.contains("api.kimi.com/coding") || ((url.contains("api.moonshot.cn") || url.contains("api.moonshot.ai")) @@ -545,6 +553,8 @@ mod tests { fn bearer_auth_matches_each_gateway_documented_scheme() { // Vendors that document ANTHROPIC_AUTH_TOKEN for their Claude-compatible gateway. for url in [ + "https://inference-api.nousresearch.com/v1/messages", + "https://inference-api.nousresearch.com/v1/models", "https://open.bigmodel.cn/api/anthropic/v1/messages", "https://api.z.ai/api/anthropic/v1/messages", "https://api.kimi.com/coding/v1/messages", @@ -560,6 +570,8 @@ mod tests { // These document ANTHROPIC_API_KEY (x-api-key) instead, so they must stay on the // default branch even though their paths look similar. for url in [ + "https://inference-api.nousresearch.com.evil.test/v1/messages", + "http://inference-api.nousresearch.com/v1/messages", "https://api.deepseek.com/anthropic/v1/messages", "https://api.minimaxi.com/anthropic/v1/messages", "https://api.minimax.io/anthropic/v1/messages", diff --git a/src/crates/adapters/ai-adapters/src/providers/gemini/code_assist.rs b/src/crates/adapters/ai-adapters/src/providers/gemini/code_assist.rs index 01cb3c316b..399af23a74 100644 --- a/src/crates/adapters/ai-adapters/src/providers/gemini/code_assist.rs +++ b/src/crates/adapters/ai-adapters/src/providers/gemini/code_assist.rs @@ -12,7 +12,7 @@ use crate::providers::shared; use crate::stream::handle_gemini_stream; use crate::trace::ModelExchangeTraceConfig; use crate::types::{Message, RemoteModelInfo, ToolDefinition}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use log::{debug, warn}; use openbitfun_core_types::errors::AiProviderError; use reqwest::RequestBuilder; @@ -28,6 +28,7 @@ const ANTIGRAVITY_DEFAULT_PROJECT: &str = "rising-fact-p41fc"; const STREAM_ENDPOINT: &str = "/v1internal:streamGenerateContent?alt=sse"; const LOAD_CODE_ASSIST_ENDPOINT: &str = "/v1internal:loadCodeAssist"; const ONBOARD_USER_ENDPOINT: &str = "/v1internal:onboardUser"; +const AVAILABLE_MODELS_ENDPOINT: &str = "/v1internal:fetchAvailableModels"; fn cached_project() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); @@ -148,6 +149,10 @@ fn resolve_antigravity_model( configured_model: &str, request: &serde_json::Value, ) -> AntigravityModelRoute { + let wire_model = configured_model.trim(); + let wire_model = wire_model + .strip_prefix("antigravity-") + .unwrap_or(wire_model); let normalized = configured_model.trim().to_ascii_lowercase(); let normalized = normalized .strip_prefix("antigravity-") @@ -161,7 +166,7 @@ fn resolve_antigravity_model( let (base, requested_tier) = strip_thinking_tier(&normalized); let configured_level = configured_thinking_level(request); - if base.starts_with("gemini-3") && base.contains("-pro") && !base.contains("image") { + if matches!(base, "gemini-3-pro" | "gemini-3.1-pro") { let level = match requested_tier.or(configured_level.as_deref()) { Some("high") => "high", _ => "low", @@ -173,7 +178,7 @@ fn resolve_antigravity_model( }; } - if base.starts_with("gemini-3") && base.contains("-flash") { + if base == "gemini-3-flash" { let level = match requested_tier.or(configured_level.as_deref()) { Some(level @ ("minimal" | "low" | "medium" | "high")) => level, _ => "low", @@ -200,7 +205,9 @@ fn resolve_antigravity_model( } AntigravityModelRoute { - model: normalized, + // New catalog IDs are already wire IDs. Only the legacy aliases above + // need translation; do not strip a future model's preview/tier suffix. + model: wire_model.to_string(), thinking_level: None, thinking_budget: None, } @@ -673,15 +680,79 @@ const DEFAULT_CODE_ASSIST_MODELS: &[(&str, &str)] = &[ ("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite"), ]; -const DEFAULT_ANTIGRAVITY_MODELS: &[(&str, &str)] = &[ - ("gemini-3.1-pro-high", "Gemini 3.1 Pro (High)"), - ("gemini-3.1-pro-low", "Gemini 3.1 Pro (Low)"), - ("gemini-3-pro-high", "Gemini 3 Pro (High)"), - ("gemini-3-pro-low", "Gemini 3 Pro (Low)"), - ("gemini-3-flash", "Gemini 3 Flash"), - ("claude-sonnet-4-6", "Claude Sonnet 4.6"), - ("claude-opus-4-6-thinking", "Claude Opus 4.6 Thinking"), -]; +#[derive(Deserialize)] +struct AvailableModelsResponse { + models: std::collections::BTreeMap, +} + +#[derive(Deserialize)] +struct AvailableModel { + #[serde(default, rename = "displayName")] + display_name: Option, +} + +async fn list_antigravity_models( + client: &AIClient, + endpoints: &[&str], +) -> Result> { + // The upstream Antigravity plugin calls the production endpoint with an + // optional project. Listing must not provision a project just to open a + // picker, or borrow a different account's cached project. + let mut body = serde_json::json!({}); + if let Some((credential, project)) = cached_project().lock().await.as_ref() { + if credential == &client.config.api_key && project != ANTIGRAVITY_DEFAULT_PROJECT { + body["project"] = serde_json::json!(project); + } + } + let mut last_error = None; + for endpoint in endpoints { + let url = format!("{endpoint}{AVAILABLE_MODELS_ENDPOINT}"); + let response = match apply_headers(client, client.client.post(&url)) + .timeout(std::time::Duration::from_secs(10)) + .json(&body) + .send() + .await + { + Ok(response) => response, + Err(error) => { + last_error = Some(anyhow!(error).context("fetch Antigravity model catalog")); + continue; + } + }; + let status = response.status(); + if !status.is_success() { + let error = anyhow!("Antigravity model discovery failed: HTTP {status}"); + if matches!(status.as_u16(), 403 | 404) || status.is_server_error() { + last_error = Some(error); + continue; + } + return Err(error); + } + let payload = response + .json::() + .await + .context("parse Antigravity model catalog")?; + let models = crate::client::utils::dedupe_remote_models( + payload + .models + .into_iter() + .map(|(id, model)| RemoteModelInfo { + // Map keys are wire IDs. Display names and quota state must + // never rename or hide newly published/account-specific IDs. + id, + display_name: model.display_name, + }) + .collect(), + ); + if models.is_empty() { + return Err(anyhow!( + "Antigravity returned no available models for this account" + )); + } + return Ok(models); + } + Err(last_error.unwrap_or_else(|| anyhow!("No Antigravity model endpoint was available"))) +} fn gemini_home_dir() -> Option { std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".gemini")) @@ -750,19 +821,19 @@ fn read_gemini_env_model(gemini_home: &Path) -> Option { }) } -/// Code Assist (`cloudcode-pa.googleapis.com`) does not expose a list-models -/// endpoint; the upstream `gemini-cli` ships a hard-coded `VALID_GEMINI_MODELS` -/// set in `packages/core/src/config/models.ts`. We mirror its stable entries and -/// preserve the user's local configured model when present. +/// Antigravity exposes an authenticated model catalog, distinct from Gemini +/// CLI's static defaults. Never report those defaults as live account models. pub(crate) async fn list_models(client: &AIClient) -> Result> { if is_antigravity(client) { - return Ok(DEFAULT_ANTIGRAVITY_MODELS - .iter() - .map(|(id, display_name)| RemoteModelInfo { - id: (*id).to_string(), - display_name: Some((*display_name).to_string()), - }) - .collect()); + return list_antigravity_models( + client, + &[ + CODE_ASSIST_BASE, + ANTIGRAVITY_DAILY_BASE, + ANTIGRAVITY_AUTOPUSH_BASE, + ], + ) + .await; } let mut models = Vec::new(); @@ -796,6 +867,106 @@ mod tests { CodeAssistTier, LoadCodeAssistResponse, ANTIGRAVITY_DEFAULT_PROJECT, }; + #[tokio::test] + async fn discovers_live_account_models_and_preserves_their_wire_ids() { + use axum::{ + http::{HeaderMap, StatusCode}, + routing::post, + Json, Router, + }; + use serde_json::{json, Value}; + let app = Router::new() + .route("/unavailable/v1internal:fetchAvailableModels", post(|| async { StatusCode::NOT_FOUND })) + .route("/live/v1internal:fetchAvailableModels", post(|headers: HeaderMap, Json(body): Json| async move { + assert_eq!(headers["authorization"], "Bearer antigravity-catalog-test"); + assert_eq!(headers["user-agent"], "antigravity/test"); + assert_eq!(body, json!({})); + Json(json!({"models": { + "gemini-3.8-flash-medium": {"displayName": "Gemini 3.8 Flash (Medium)"}, + "future-preview": {"displayName": "Future model", "quotaInfo": {"remainingFraction": 0}}, + "gpt-oss-120b-medium": {"displayName": "GPT-OSS 120B"} + }})) + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = super::AIClient::new(serde_json::from_value(json!({ + "name": "catalog-test", "base_url": base, "request_url": base, + "api_key": "antigravity-catalog-test", "model": "", "format": "gemini-code-assist", + "context_window": 128000, "inline_think_in_text": false, "skip_ssl_verify": false, + "custom_headers": {"User-Agent": "antigravity/test", "Client-Metadata": "ANTIGRAVITY"} + })).unwrap()); + let unavailable = format!("{base}/unavailable"); + let live = format!("{base}/live"); + let models = super::list_antigravity_models(&client, &[&unavailable, &live]) + .await + .unwrap(); + server.abort(); + assert_eq!( + models + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + [ + "future-preview", + "gemini-3.8-flash-medium", + "gpt-oss-120b-medium" + ] + ); + assert_eq!( + models[1].display_name.as_deref(), + Some("Gemini 3.8 Flash (Medium)") + ); + for model in models { + assert_eq!( + resolve_antigravity_model(&model.id, &json!({})).model, + model.id + ); + } + } + + #[tokio::test] + async fn catalog_failure_does_not_masquerade_as_a_static_success() { + use axum::{http::StatusCode, routing::post, Json, Router}; + use serde_json::json; + let app = Router::new() + .route( + "/denied/v1internal:fetchAvailableModels", + post(|| async { StatusCode::UNAUTHORIZED }), + ) + .route( + "/empty/v1internal:fetchAvailableModels", + post(|| async { Json(json!({"models": {}})) }), + ) + .route( + "/malformed/v1internal:fetchAvailableModels", + post(|| async { Json(json!({"unrecognized": []})) }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = super::AIClient::new( + serde_json::from_value(json!({ + "name": "catalog-test", "base_url": base, "request_url": base, + "api_key": "synthetic", "model": "", "format": "gemini-code-assist", + "context_window": 128000, "inline_think_in_text": false, "skip_ssl_verify": false + })) + .unwrap(), + ); + for path in ["denied", "empty", "malformed"] { + let endpoint = format!("{base}/{path}"); + let error = super::list_antigravity_models(&client, &[&endpoint]) + .await + .unwrap_err(); + assert!(error.to_string().contains("Antigravity"), "{error:#}"); + } + server.abort(); + } + #[test] fn accepts_string_and_object_project_shapes() { assert_eq!( diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/common.rs b/src/crates/adapters/ai-adapters/src/providers/openai/common.rs index 27856a928d..49e8d52c02 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/common.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/common.rs @@ -9,11 +9,9 @@ use crate::providers::shared; use crate::types::{ ReasoningPresetAction, ReasoningPresetDescriptor, RemoteModelInfo, ToolDefinition, }; -use anyhow::{anyhow, Result}; -use log::warn; +use anyhow::{anyhow, Context, Result}; use reqwest::RequestBuilder; use serde::Deserialize; -use std::path::{Path, PathBuf}; #[derive(Debug, Deserialize)] struct OpenAIModelsResponse { @@ -190,9 +188,6 @@ struct CodexBackendModelsResponse { #[derive(Debug, Deserialize)] struct CodexBackendModelEntry { slug: String, - /// Returned by the backend but unused — see comment in the mapping below - /// (display_name is dropped to avoid duplicate-looking entries). - #[allow(dead_code)] #[serde(default)] display_name: Option, /// Codex backend marks deprecated/internal slugs with `visibility = "hide"`. @@ -200,8 +195,6 @@ struct CodexBackendModelEntry { #[serde(default)] visibility: Option, #[serde(default)] - supported_in_api: Option, - #[serde(default)] priority: Option, } @@ -236,135 +229,31 @@ pub(crate) fn is_known_codex_reasoning_model(model_id: &str) -> bool { model_id == "gpt-5-codex" || codex_subscription_model_allowed(&model_id) } -fn codex_home_dir() -> PathBuf { - std::env::var("CODEX_HOME") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex"))) - .unwrap_or_else(|| PathBuf::from(".codex")) -} - -fn add_unique_model_id(ordered: &mut Vec, id: String) { - if !id.trim().is_empty() && !ordered.iter().any(|existing| existing == &id) { - ordered.push(id); - } -} - -fn read_codex_config_model(codex_home: &Path) -> Option { - let config_path = codex_home.join("config.toml"); - let text = match std::fs::read_to_string(&config_path) { - Ok(t) => t, - Err(e) => { - if e.kind() != std::io::ErrorKind::NotFound { - warn!( - "Failed to read Codex config from {}: {}", - config_path.display(), - e - ); - } - return None; - } - }; - text.lines().find_map(|line| { - let line = line.trim(); - if line.starts_with('#') { - return None; - } - let (key, value) = line.split_once('=')?; - if key.trim() != "model" { - return None; - } - let model = value.trim().trim_matches(|ch| ch == '"' || ch == '\''); - (!model.is_empty()).then(|| model.to_string()) - }) -} - -fn read_codex_cached_models(codex_home: &Path) -> Vec { - let cache_path = codex_home.join("models_cache.json"); - let bytes = match std::fs::read(&cache_path) { - Ok(b) => b, - Err(e) => { - if e.kind() != std::io::ErrorKind::NotFound { - warn!( - "Failed to read Codex models cache from {}: {}", - cache_path.display(), - e - ); - } - return Vec::new(); - } - }; - let payload: CodexBackendModelsResponse = match serde_json::from_slice(&bytes) { - Ok(p) => p, - Err(e) => { - warn!( - "Failed to parse Codex models cache JSON from {}: {}", - cache_path.display(), - e - ); - return Vec::new(); - } - }; - codex_models_from_entries(payload.models) -} - -fn codex_models_from_entries(entries: Vec) -> Vec { - let mut sortable = Vec::new(); - for model in entries { - if model.supported_in_api == Some(false) { - continue; - } - if model - .visibility - .as_deref() - .map(|v| { - let normalized = v.trim().to_ascii_lowercase(); - normalized == "hide" || normalized == "hidden" +/// The account's live catalog is authoritative, including subscription-only +/// models whose supported_in_api flag is false (that flag is for public API +/// billing). Do not apply the offline reasoning-model heuristic here. +fn codex_models_from_entries(mut entries: Vec) -> Vec { + entries.retain(|model| { + !model.slug.trim().is_empty() + && !model.visibility.as_deref().is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "hide" | "hidden" + ) }) - .unwrap_or(false) - { - continue; - } - if !codex_subscription_model_allowed(&model.slug) { - continue; - } - sortable.push((model.priority.unwrap_or(10_000), model.slug)); - } - sortable.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); - - let mut ordered = Vec::new(); - for (_, slug) in sortable { - add_unique_model_id(&mut ordered, slug); - } - ordered -} - -fn codex_fallback_model_ids() -> Vec { - let codex_home = codex_home_dir(); - let mut ordered = Vec::new(); - if let Some(model) = read_codex_config_model(&codex_home) { - if codex_subscription_model_allowed(&model) { - add_unique_model_id(&mut ordered, model); - } - } - for model in read_codex_cached_models(&codex_home) { - add_unique_model_id(&mut ordered, model); - } - for model in DEFAULT_CODEX_MODELS { - add_unique_model_id(&mut ordered, (*model).to_string()); - } - ordered -} - -fn codex_model_infos(model_ids: Vec) -> Vec { + }); + entries.sort_by(|a, b| { + a.priority + .unwrap_or(10_000) + .cmp(&b.priority.unwrap_or(10_000)) + .then_with(|| a.slug.cmp(&b.slug)) + }); dedupe_remote_models( - model_ids + entries .into_iter() - .map(|id| RemoteModelInfo { - id, - display_name: None, + .map(|model| RemoteModelInfo { + id: model.slug, + display_name: model.display_name, }) .collect(), ) @@ -390,35 +279,21 @@ async fn list_codex_chatgpt_models( ) -> Result> { let url = codex_models_url(base_models_url); - let live_models = async { - let response = apply_headers(client, client.client.get(&url)) - .send() - .await? - .error_for_status()?; - - let payload: CodexBackendModelsResponse = response.json().await?; - Ok::, anyhow::Error>(codex_models_from_entries(payload.models)) + let response = apply_headers(client, client.client.get(&url)) + .send() + .await + .context("fetch Codex subscription models")? + .error_for_status() + .context("Codex subscription model discovery failed")?; + let payload: CodexBackendModelsResponse = response + .json() + .await + .context("parse Codex subscription models")?; + let models = codex_models_from_entries(payload.models); + if models.is_empty() { + return Err(anyhow!("Codex returned no visible models for this account")); } - .await; - - let model_ids = match live_models { - Ok(models) if !models.is_empty() => models, - Ok(_) => { - log::warn!( - "Codex backend model discovery returned no models; using local fallback catalog" - ); - codex_fallback_model_ids() - } - Err(error) => { - log::warn!( - "Codex backend model discovery failed: {}; using local fallback catalog", - error - ); - codex_fallback_model_ids() - } - }; - - Ok(codex_model_infos(model_ids)) + Ok(models) } pub(crate) fn extract_tool_name(tool: &serde_json::Value) -> String { @@ -485,6 +360,38 @@ mod tests { use super::{attach_tools, codex_subscription_model_allowed, is_known_codex_reasoning_model}; use serde_json::json; + #[test] + fn live_codex_catalog_keeps_subscription_only_and_new_models() { + let payload: super::CodexBackendModelsResponse = serde_json::from_value(json!({ + "models": [ + {"slug": "gpt-5.3-codex-spark", "supported_in_api": false, "priority": 1, + "display_name": "GPT-5.3 Codex Spark", "visibility": "list"}, + {"slug": "future-subscription-model", "priority": 2}, + {"slug": "gpt-5.5-pro", "priority": 3}, + {"slug": "internal", "visibility": "hidden"}, + {"slug": " "}, + {"slug": "gpt-5.3-codex-spark", "priority": 8} + ] + })) + .unwrap(); + let models = super::codex_models_from_entries(payload.models); + assert_eq!( + models + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + [ + "gpt-5.3-codex-spark", + "future-subscription-model", + "gpt-5.5-pro" + ] + ); + assert_eq!( + models[0].display_name.as_deref(), + Some("GPT-5.3 Codex Spark") + ); + } + #[test] fn attach_tools_removes_tool_choice_without_tools() { let mut request_body = 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 2630690160..83ad6ae2c1 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/codex.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/codex.rs @@ -250,6 +250,7 @@ async fn persist_tokens(tokens: TokenResponse, expected_revision: u64) -> Result .clone() .ok_or_else(|| anyhow!("codex token response missing refresh_token"))?; let expires = now_ms() + tokens.expires_in.unwrap_or(3600) * 1000; + let expires = jwt::effective_expiry_ms(&access, expires); let account_id = account_id_from(&tokens); let metadata = metadata_from(&tokens); let outcome = store::upsert_if_revision( @@ -398,6 +399,7 @@ async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result<(String, Opti return Err(anyhow!("Codex credential is not an OAuth login")); }; + let expires = jwt::effective_expiry_ms(&access, expires); if expires > now_ms() + REFRESH_LEEWAY_MS { return Ok((access, account_id, expires)); } @@ -409,6 +411,7 @@ async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result<(String, Opti .ok_or_else(|| anyhow!("codex refresh response missing access_token"))?; let new_refresh = refreshed.refresh_token.clone().unwrap_or(refresh_token); let new_expires = now_ms() + refreshed.expires_in.unwrap_or(3600) * 1000; + let new_expires = jwt::effective_expiry_ms(&new_access, new_expires); let new_account_id = account_id_from(&refreshed).or(account_id); let new_metadata = metadata_from(&refreshed).or(metadata); let outcome = store::upsert_if_revision( @@ -440,8 +443,9 @@ async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result<(String, Opti expires, account_id, .. - }) if expires > now_ms() => { + }) if jwt::effective_expiry_ms(&access, expires) > now_ms() => { log::info!("codex refresh reused tokens committed by a concurrent refresh"); + let expires = jwt::effective_expiry_ms(&access, expires); Ok((access, account_id, expires)) } _ => Err(super::store_revision_conflict( diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/device_flow.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/device_flow.rs new file mode 100644 index 0000000000..fa75cb7f61 --- /dev/null +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/device_flow.rs @@ -0,0 +1,155 @@ +//! RFC 8628 polling shared by the OpenCode, xAI and Nous device grants. + +use anyhow::{anyhow, Result}; +use std::future::Future; +use std::time::Duration; +use tokio::time::{sleep, timeout_at, Instant}; + +pub(super) enum DevicePoll { + Authorized(T), + Pending, + SlowDown, +} + +/// Keep the server's interval for the entire grant, including after pending +/// responses. RFC 8628 section 3.5 increases it by five seconds on slow_down +/// for this AND all subsequent requests. The deadline also bounds an in-flight +/// HTTP request, rather than only the sleeps between requests. +pub(super) async fn poll_device_code( + interval: Duration, + expires_in: Duration, + safety_margin: Duration, + poll_immediately: bool, + mut poll: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>>, +{ + let deadline = Instant::now() + expires_in; + timeout_at(deadline, async { + let mut interval = interval.max(Duration::from_secs(1)); + if !poll_immediately { + sleep(interval.min(expires_in)).await; + } + loop { + // timeout_at polls its inner future first. Check explicitly so an + // already expired grant cannot make one more network request. + if Instant::now() >= deadline { + return Err(anyhow!( + "Device authorization code expired; start sign-in again" + )); + } + match poll().await? { + DevicePoll::Authorized(tokens) => return Ok(tokens), + DevicePoll::Pending => {} + DevicePoll::SlowDown => { + interval = interval.saturating_add(Duration::from_secs(5)); + } + } + // Clamp before sleeping so malformed server intervals cannot + // overflow Instant arithmetic or outlive the grant. + let remaining = deadline.saturating_duration_since(Instant::now()); + sleep(interval.saturating_add(safety_margin).min(remaining)).await; + } + }) + .await + .map_err(|_| anyhow!("Device authorization code expired; start sign-in again"))? +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use std::future::{pending, ready}; + + #[tokio::test(start_paused = true)] + async fn pending_preserves_cumulative_slow_down_and_provider_margin() { + let start = Instant::now(); + let mut calls = Vec::new(); + let mut replies = VecDeque::from([ + DevicePoll::Pending, + DevicePoll::SlowDown, + DevicePoll::Pending, + DevicePoll::SlowDown, + DevicePoll::Authorized("token"), + ]); + let token = poll_device_code( + Duration::from_secs(5), + Duration::from_secs(300), + Duration::from_secs(3), + true, + || { + calls.push(Instant::now().duration_since(start).as_secs()); + ready(Ok(replies.pop_front().unwrap())) + }, + ) + .await + .unwrap(); + assert_eq!(token, "token"); + assert_eq!(calls, [0, 8, 21, 34, 52]); + } + + #[tokio::test(start_paused = true)] + async fn waits_for_advertised_interval_before_opencode_first_poll() { + let start = Instant::now(); + poll_device_code( + Duration::from_secs(7), + Duration::from_secs(300), + Duration::ZERO, + false, + || ready(Ok(DevicePoll::Authorized(()))), + ) + .await + .unwrap(); + assert_eq!(start.elapsed(), Duration::from_secs(7)); + } + + #[tokio::test(start_paused = true)] + async fn deadline_cancels_an_in_flight_request() { + let start = Instant::now(); + let error = poll_device_code( + Duration::from_secs(5), + Duration::from_secs(10), + Duration::ZERO, + true, + pending::>>, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("expired")); + assert_eq!(start.elapsed(), Duration::from_secs(10)); + } + + #[tokio::test(start_paused = true)] + async fn never_polls_after_expiry_or_retries_a_terminal_error() { + let mut calls = 0; + let error = poll_device_code( + Duration::from_secs(5), + Duration::from_secs(5), + Duration::ZERO, + true, + || { + calls += 1; + ready(Ok(DevicePoll::<()>::Pending)) + }, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("expired")); + assert_eq!(calls, 1); + + let start = Instant::now(); + let error = poll_device_code( + Duration::from_secs(5), + Duration::from_secs(300), + Duration::ZERO, + true, + || ready(Err::, _>(anyhow!("authorization denied"))), + ) + .await + .unwrap_err(); + assert_eq!(error.to_string(), "authorization denied"); + assert_eq!(start.elapsed(), Duration::ZERO); + } +} 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 74db252b64..0c4a594d0e 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/grok.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/grok.rs @@ -4,6 +4,7 @@ //! device flow. Subscription inference uses xAI's normal Responses endpoint, //! matching OpenCode's built-in xAI auth plugin. +use super::device_flow::{poll_device_code, DevicePoll}; use super::jwt; use super::store::{self, StoredCredential}; use super::{ResolvedCredential, StartedLogin, SubscriptionHttpOptions}; @@ -25,7 +26,6 @@ const STORE_KEY: &str = "grok"; const DEFAULT_TOKEN_LIFETIME_SECS: i64 = 60 * 60; const DEFAULT_DEVICE_LIFETIME_SECS: i64 = 5 * 60; const DEFAULT_POLL_INTERVAL_SECS: i64 = 5; -const SLOW_DOWN_INCREMENT_SECS: i64 = 5; const SHORT_TOKEN_REFRESH_LEEWAY_MS: i64 = 2 * 60 * 1000; const LONG_TOKEN_REFRESH_LEEWAY_MS: i64 = 60 * 60 * 1000; const SHORT_TOKEN_THRESHOLD_MS: i64 = 45 * 60 * 1000; @@ -177,16 +177,10 @@ async fn request_device_code(options: &SubscriptionHttpOptions) -> Result Result { +) -> Result> { match error.error.as_str() { "authorization_pending" => Ok(DevicePoll::Pending), "slow_down" => Ok(DevicePoll::SlowDown), @@ -213,7 +207,10 @@ fn classify_device_poll_error( } } -async fn poll_once(device_code: &str, options: &SubscriptionHttpOptions) -> Result { +async fn poll_once( + device_code: &str, + options: &SubscriptionHttpOptions, +) -> Result> { let client = http_client(options)?; let response = oauth_request(client.post(TOKEN_URL)) .form(&[ @@ -277,6 +274,7 @@ async fn persist_tokens(tokens: TokenResponse, expected_revision: u64) -> Result .filter(|token| !token.trim().is_empty()) .ok_or_else(|| anyhow!("xAI token response missing refresh_token"))?; let expires = expires_at_ms(tokens.expires_in); + let expires = jwt::effective_expiry_ms(&tokens.access_token, expires); let account_id = account_id_from(&tokens); let metadata = metadata_from(&tokens, None); let outcome = store::upsert_if_revision( @@ -341,25 +339,15 @@ pub(crate) async fn begin_login( super::SubscriptionProvider::Grok, cancel, async { - let deadline = tokio::time::Instant::now() + Duration::from_secs(expires_in as u64); - let mut wait = interval; - loop { - match poll_once(&device_code, &options).await? { - DevicePoll::Authorized(tokens) => return Ok(tokens), - DevicePoll::Pending => wait = interval, - DevicePoll::SlowDown => { - wait = wait.saturating_add(SLOW_DOWN_INCREMENT_SECS) - } - } - // OpenCode adds a small safety margin after the provider's - // advertised interval and increases it by five seconds on - // RFC 8628 `slow_down` responses. - let sleep = Duration::from_secs(wait.saturating_add(3) as u64); - if tokio::time::Instant::now() + sleep > deadline { - return Err(anyhow!("xAI device authorization code expired")); - } - tokio::time::sleep(sleep).await; - } + poll_device_code( + Duration::from_secs(interval as u64), + Duration::from_secs(expires_in as u64), + Duration::from_secs(3), + true, + || poll_once(&device_code, &options), + ) + .await + .context("complete xAI device authorization") }, move |tokens| persist_tokens(tokens, expected_revision), ) @@ -394,6 +382,7 @@ async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result<(String, i64) }; let now = now_ms(); + let expires = jwt::effective_expiry_ms(&access, expires); let refresh_leeway = refresh_leeway_ms(&access, expires, now); if expires > now + refresh_leeway && !super::jwt::expires_within(&access, now, refresh_leeway) { return Ok((access, expires)); @@ -409,6 +398,7 @@ async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result<(String, i64) .filter(|token| !token.trim().is_empty()) .unwrap_or(refresh_token); let new_expires = expires_at_ms(refreshed.expires_in); + let new_expires = jwt::effective_expiry_ms(&refreshed.access_token, new_expires); let new_account_id = account_id_from(&refreshed).or(account_id); let new_metadata = metadata_from(&refreshed, metadata); let new_access = refreshed.access_token.clone(); @@ -438,8 +428,9 @@ async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result<(String, i64) match current.credential { Some(StoredCredential::Oauth { access, expires, .. - }) if expires > now_ms() => { + }) if jwt::effective_expiry_ms(&access, expires) > now_ms() => { log::info!("xAI refresh reused tokens committed by a concurrent refresh"); + let expires = jwt::effective_expiry_ms(&access, expires); Ok((access, expires)) } _ => Err(super::store_revision_conflict( 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 0a262a5433..2ba635c3bf 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/hermes.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/hermes.rs @@ -6,6 +6,7 @@ //! inference host, with `anthropic/*` models using Messages and all other //! models using OpenAI Chat Completions. +use super::device_flow::{poll_device_code, DevicePoll}; use super::jwt; use super::store::{self, StoredCredential}; use super::{ResolvedCredential, StartedLogin, SubscriptionHttpOptions}; @@ -15,7 +16,6 @@ use std::collections::{HashMap, HashSet}; use std::time::Duration; use tokio_util::sync::CancellationToken; -const PORTAL_BASE_URL: &str = "https://portal.nousresearch.com"; const DEVICE_CODE_URL: &str = "https://portal.nousresearch.com/api/oauth/device/code"; const TOKEN_URL: &str = "https://portal.nousresearch.com/api/oauth/token"; const CLIENT_ID: &str = "hermes-cli"; @@ -28,8 +28,7 @@ const DEFAULT_MODEL: &str = "z-ai/glm-5.2"; const STORE_KEY: &str = "hermes"; const DEFAULT_TOKEN_LIFETIME_SECS: i64 = 60 * 60; const DEFAULT_DEVICE_LIFETIME_SECS: i64 = 5 * 60; -const DEFAULT_POLL_INTERVAL_SECS: i64 = 1; -const MAX_POLL_INTERVAL_SECS: i64 = 30; +const DEFAULT_POLL_INTERVAL_SECS: i64 = 5; const REFRESH_LEEWAY_MS: i64 = 2 * 60 * 1000; pub(crate) const MANAGEMENT_URL: &str = "https://portal.nousresearch.com/manage-subscription"; @@ -69,12 +68,6 @@ struct TokenErrorResponse { error_description: Option, } -enum DevicePoll { - Authorized(TokenResponse), - Pending, - SlowDown, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct HermesRoute { format: &'static str, @@ -272,7 +265,7 @@ async fn request_device_code(client: &reqwest::Client) -> Result Result { +) -> Result> { match error.error.as_str() { "authorization_pending" => Ok(DevicePoll::Pending), "slow_down" => Ok(DevicePoll::SlowDown), @@ -299,7 +292,10 @@ fn classify_device_poll_error( } } -async fn poll_once(client: &reqwest::Client, device_code: &str) -> Result { +async fn poll_once( + client: &reqwest::Client, + device_code: &str, +) -> Result> { let response = client .post(TOKEN_URL) .header(reqwest::header::ACCEPT, "application/json") @@ -394,8 +390,7 @@ pub(crate) async fn begin_login( ) -> Result { let client = http_client(&options)?; let device = request_device_code(&client).await?; - let interval = positive_seconds(device.interval, DEFAULT_POLL_INTERVAL_SECS) - .min(DEFAULT_POLL_INTERVAL_SECS); + let interval = positive_seconds(device.interval, DEFAULT_POLL_INTERVAL_SECS); let expires_in = positive_seconds(device.expires_in, DEFAULT_DEVICE_LIFETIME_SECS) .min(super::LOGIN_TIMEOUT.as_secs() as i64); let device_code = device.device_code.clone(); @@ -407,24 +402,15 @@ pub(crate) async fn begin_login( super::SubscriptionProvider::Hermes, cancel, async { - let deadline = tokio::time::Instant::now() + Duration::from_secs(expires_in as u64); - let mut wait = interval; - loop { - match poll_once(&client, &device_code).await? { - DevicePoll::Authorized(tokens) => return Ok(tokens), - DevicePoll::Pending => wait = interval, - DevicePoll::SlowDown => { - wait = wait.saturating_add(1).min(MAX_POLL_INTERVAL_SECS) - } - } - let sleep = Duration::from_secs(wait as u64); - if tokio::time::Instant::now() + sleep > deadline { - return Err(anyhow!( - "Nous Portal device authorization timed out; finish signing in at {PORTAL_BASE_URL}/login and try again" - )); - } - tokio::time::sleep(sleep).await; - } + poll_device_code( + Duration::from_secs(interval as u64), + Duration::from_secs(expires_in as u64), + Duration::ZERO, + true, + || poll_once(&client, &device_code), + ) + .await + .context("complete Nous Portal device authorization") }, move |tokens| persist_tokens(tokens, expected_revision), ) diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/jwt.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/jwt.rs index c30234c4e6..5c37e593b9 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/jwt.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/jwt.rs @@ -41,6 +41,15 @@ pub(crate) fn expires_at_ms(token: &str) -> Option { .map(|expires| expires.saturating_mul(1000)) } +/// Bound both proactive refresh and the caller's model-client cache by the +/// earlier deadline. Legacy stores may have guessed an hour when the provider +/// omitted expires_in, even though the issued JWT only lasts a few minutes. +pub(crate) fn effective_expiry_ms(token: &str, stored_expiry: i64) -> i64 { + expires_at_ms(token) + .map(|expiry| expiry.min(stored_expiry)) + .unwrap_or(stored_expiry) +} + fn extend_scopes(scopes: &mut HashSet, value: &Value) { match value { Value::String(raw) => { @@ -169,6 +178,20 @@ mod tests { assert!(!expires_within("opaque-token", 1_799_999_900_000, 120_000)); } + #[test] + fn effective_expiry_never_extends_either_deadline() { + let token = make_token(serde_json::json!({ "exp": 1_800_000_000i64 })); + assert_eq!( + effective_expiry_ms(&token, 1_800_003_600_000), + 1_800_000_000_000 + ); + assert_eq!( + effective_expiry_ms(&token, 1_799_999_000_000), + 1_799_999_000_000 + ); + assert_eq!(effective_expiry_ms("opaque", 1234), 1234); + } + #[test] fn extracts_string_and_array_scope_claims() { let token = make_token(serde_json::json!({ 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 4b8640d976..e1c13f5e69 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs @@ -10,6 +10,7 @@ mod antigravity; mod codex; +mod device_flow; mod grok; mod hermes; mod jwt; @@ -1021,6 +1022,49 @@ mod tests { uuid::Uuid::new_v4().to_string() } + #[tokio::test] + async fn legacy_codex_and_grok_expiry_is_bounded_for_client_caches() { + 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 now = chrono::Utc::now().timestamp(); + let actual_expiry = now + 20 * 60; + let body = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&serde_json::json!({ + "exp": actual_expiry, "chatgpt_account_id": "test-account" + })) + .unwrap(), + ); + let token = format!("e30.{body}.test"); + for provider in [SubscriptionProvider::Codex, SubscriptionProvider::Grok] { + // Shape written by older builds: metadata assumes a one-hour + // lifetime even though the actual JWT expires after twenty minutes. + let credential: StoredCredential = serde_json::from_value(serde_json::json!({ + "type": "oauth", "access": token, "refresh": "unused-synthetic-refresh", + "expires": (now + 3600) * 1000 + })) + .unwrap(); + store::upsert(provider.key(), credential).await.unwrap(); + let revision = store::load_entry_with_revision(provider.key()) + .await + .unwrap() + .revision; + let resolved = resolve_with_options(provider, &SubscriptionHttpOptions::default()) + .await + .unwrap(); + assert_eq!(resolved.expires_at, Some(actual_expiry)); + assert_eq!(resolved.api_key, token); + // No rotation or mutation is needed for a still-usable legacy JWT. + assert_eq!( + store::load_entry_with_revision(provider.key()) + .await + .unwrap() + .revision, + revision + ); + } + } + #[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 9f9ef91992..d1fb18816c 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/opencode.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/opencode.rs @@ -4,6 +4,7 @@ //! `opencode.ai/console`, aligned with OpenCode's `provider/opencode.ts`. //! One OAuth identity can authenticate both the Zen and Go API products. +use super::device_flow::{poll_device_code, DevicePoll}; use super::store::{self, StoredCredential}; use super::{ OpenCodePlan, ResolvedCredential, StartedLogin, SubscriptionApiOffering, @@ -153,17 +154,10 @@ async fn request_device_code(options: &SubscriptionHttpOptions) -> Result Result { +) -> Result> { match pending.error.as_str() { "authorization_pending" => Ok(DevicePoll::Pending), "slow_down" => Ok(DevicePoll::SlowDown), @@ -185,7 +179,10 @@ fn classify_device_poll_error( } /// One poll attempt against the device-token endpoint. -async fn poll_once(device_code: &str, options: &SubscriptionHttpOptions) -> Result { +async fn poll_once( + device_code: &str, + options: &SubscriptionHttpOptions, +) -> Result> { let client = http_client(options)?; let resp = client .post(format!("{SERVER}/auth/device/token")) @@ -431,47 +428,42 @@ async fn fetch_remote_offerings( client: &reqwest::Client, access: &str, org_id: Option<&str>, -) -> Option> { +) -> Result>> { let mut request = client .get(format!("{SERVER}/api/config")) .bearer_auth(access); if let Some(org_id) = org_id { request = request.header("x-org-id", org_id); } - let response = match request.send().await { - Ok(response) => response, - Err(error) => { - log::warn!("fetch OpenCode provider catalog failed: {error}"); - return None; - } - }; + let response = request + .send() + .await + .context("fetch OpenCode provider catalog")?; // OpenCode treats 404 as "no remote provider override" rather than an - // authentication or transport failure. Keep the existing catalog/fallback - // without emitting a misleading warning. + // authentication or transport failure. Let the caller clear stale catalog + // metadata without emitting a misleading warning. if response.status() == reqwest::StatusCode::NOT_FOUND { - return None; + return Ok(None); } if !response.status().is_success() { - log::warn!( - "fetch OpenCode provider catalog failed: status={}", + return Err(anyhow!( + "OpenCode provider catalog failed: HTTP {}", response.status() - ); - return None; - } - match response.json::().await { - Ok(remote) => Some(offerings_from_remote_config(remote.config)), - Err(error) => { - log::warn!("parse OpenCode provider catalog failed: {error}"); - None - } + )); } + let remote = response + .json::() + .await + .context("parse OpenCode provider catalog")?; + Ok(Some(offerings_from_remote_config(remote.config))) } async fn fetch_metadata( access: &str, existing: Option<&serde_json::Value>, options: &SubscriptionHttpOptions, -) -> serde_json::Value { + require_catalog: bool, +) -> Result { let mut metadata = existing .and_then(serde_json::Value::as_object) .cloned() @@ -480,10 +472,7 @@ async fn fetch_metadata( "server".to_string(), serde_json::Value::String(SERVER.to_string()), ); - let client = match http_client(options) { - Ok(client) => client, - Err(_) => return serde_json::Value::Object(metadata), - }; + let client = http_client(options)?; if let Ok(resp) = client .get(format!("{SERVER}/api/user")) @@ -531,13 +520,20 @@ async fn fetch_metadata( } let org_id = metadata.get("org_id").and_then(serde_json::Value::as_str); - if let Some(offerings) = fetch_remote_offerings(&client, access, org_id).await { - if let Ok(value) = serde_json::to_value(offerings) { - metadata.insert(OFFERINGS_METADATA_KEY.to_string(), value); + match fetch_remote_offerings(&client, access, org_id).await { + Ok(offerings) => { + // 404 means no override. Do not continue advertising a removed + // remote catalog from a prior account/profile snapshot. + metadata.insert( + OFFERINGS_METADATA_KEY.to_string(), + serde_json::to_value(offerings.unwrap_or_else(fallback_offerings))?, + ); } + Err(error) if require_catalog => return Err(error), + Err(error) => log::warn!("OpenCode signed in without a refreshed model catalog: {error:#}"), } - serde_json::Value::Object(metadata) + Ok(serde_json::Value::Object(metadata)) } async fn persist_tokens( @@ -636,35 +632,18 @@ pub(crate) async fn begin_login( super::SubscriptionProvider::Opencode, cancel, async { - let mut wait = interval; - let deadline = tokio::time::Instant::now() + Duration::from_secs(expires_in); - loop { - let sleep = Duration::from_secs(wait); - if tokio::time::Instant::now() + sleep > deadline { - return Err(anyhow!("OpenCode device authorization code expired")); - } - tokio::time::sleep(sleep).await; - match poll_once(&device_code, &options).await? { - DevicePoll::Authorized(tokens) => { - // Optional profile/org network calls belong to the - // cancellable authorization phase. The provider - // commit lock should cover only the credential - // store transaction, never up to 60 seconds of - // metadata fetching. - let metadata = - fetch_metadata(&tokens.access_token, None, &options).await; - return Ok((tokens, metadata)); - } - DevicePoll::Pending => { - wait = interval; - } - // RFC 8628: on slow_down, increase the poll interval - // by 5 seconds. - DevicePoll::SlowDown => { - wait = wait.saturating_add(5); - } - } - } + let tokens = poll_device_code( + Duration::from_secs(interval), + Duration::from_secs(expires_in), + Duration::ZERO, + false, + || poll_once(&device_code, &options), + ) + .await + .context("complete OpenCode device authorization")?; + // Keep optional profile/catalog IO outside the credential commit lock. + let metadata = fetch_metadata(&tokens.access_token, None, &options, false).await?; + Ok((tokens, metadata)) }, move |(tokens, metadata)| persist_tokens(tokens, metadata, expected_revision), ) @@ -777,17 +756,21 @@ async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result Result<()> { - let access = ensure_fresh(options).await?.access; + ensure_fresh(options).await?; let snapshot = store::load_entry_with_revision(STORE_KEY).await?; let entry = snapshot .credential .ok_or_else(|| anyhow!("OpenCode is not connected; sign in first"))?; - let existing_metadata = match &entry { - StoredCredential::Oauth { metadata, .. } | StoredCredential::Api { metadata, .. } => { - metadata.as_ref() - } + // Bind the network credential to the same snapshot that is updated by CAS. + // A sign-in between refresh and snapshot load must not attach the old + // account's profile/catalog to the new account's credential. + let (access, existing_metadata) = match &entry { + StoredCredential::Oauth { + access, metadata, .. + } => (access, metadata.as_ref()), + StoredCredential::Api { key, metadata } => (key, metadata.as_ref()), }; - let metadata = fetch_metadata(&access, existing_metadata, options).await; + let metadata = fetch_metadata(access, existing_metadata, options, true).await?; if existing_metadata == Some(&metadata) { return Ok(()); } diff --git a/src/web-ui/README.md b/src/web-ui/README.md index c4687c3062..1a2c00012b 100644 --- a/src/web-ui/README.md +++ b/src/web-ui/README.md @@ -88,6 +88,26 @@ VITE_BUILD_TARGET=web pnpm --dir src/web-ui run build # output: dist/ ``` +## Subscription models + +In **Settings → Models → Subscription accounts**, sign in, choose **Use**, and +open the model picker. **Refresh models** fetches the account's current list +without signing out or reopening the editor. Saved models remain selectable; +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. + +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 +is not necessarily available through every subscription or OAuth client. A +failed subscription lookup shows an error instead of presenting preset models +as an account result. Antigravity browser login requires the local desktop; +device-code login can authorize the other providers from another browser. + ## Related docs (within this package) - [Logging guide](LOGGING.md) diff --git a/src/web-ui/README.zh-CN.md b/src/web-ui/README.zh-CN.md index cc7f545ef2..ff1c1ba497 100644 --- a/src/web-ui/README.zh-CN.md +++ b/src/web-ui/README.zh-CN.md @@ -108,3 +108,19 @@ VITE_BUILD_TARGET=web pnpm --dir src/web-ui run build 1. **不要在组件里直接调用 Tauri API**,应通过适配器层统一封装。 2. **注意 Web 兼容性**(浏览器环境不一定具备所有能力)。 3. **优先使用 CSS 变量**,避免硬编码颜色/尺寸。 + +## 订阅账号与模型列表 + +在 **设置 → 模型 → 订阅账号** 中登录、选择使用账号,再打开模型选择器。 +“刷新模型列表”会重新获取账号当前可用的模型,无需退出登录或重开编辑器。 +已保存的模型不会被删除,也可以手动填写服务商支持的模型 ID。 + +反重力通过账号的 `fetchAvailableModels` 接口获取模型;Codex 使用订阅模型目录, +保留公共 API 不提供的订阅专属模型。OpenCode 按 Go/Zen 和请求格式分别展示。 +xAI、Hermes 查询各自的模型接口;Hermes 的 `anthropic/*` 模型使用 Messages 协议 +和 Nous OAuth Bearer 认证。 + +模型是否可用以当前账号接口返回的 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 bae703cf33..df34278019 100644 --- a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx +++ b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx @@ -92,6 +92,7 @@ import { i18nService } from '@/infrastructure/i18n'; import { isTauriRuntime } from '@/infrastructure/runtime'; import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import { LONG_CONTEXT_WARNING_THRESHOLD_TOKENS } from '@/shared/constants/modelContext'; import { preferredSubscriptionLoginMethod, @@ -100,6 +101,7 @@ import { SubscriptionLoginCoordinator, type SubscriptionLoginOperation, } from './subscriptionLoginCoordinator'; +import { ModelDiscoveryCoordinator, openCodeOfferingModels } from './modelDiscoveryCoordinator'; import './ModelSettingsPage.scss'; const log = createLogger('ModelSettings'); @@ -455,8 +457,7 @@ const ModelSettingsPage: React.FC = () => { const [subscriptionLoginClock, setSubscriptionLoginClock] = useState(() => Date.now()); const [subscriptionLogoutRequest, setSubscriptionLogoutRequest] = useState(null); const [deleteRequest, setDeleteRequest] = useState(null); - const lastRemoteFetchSignatureRef = React.useRef(null); - const activeRemoteFetchSignatureRef = React.useRef(null); + const modelDiscoveryRef = React.useRef(new ModelDiscoveryCoordinator()); const editorSavingRef = React.useRef(false); const pendingEditorOpenRef = React.useRef(null); const activeConnectionTestsRef = React.useRef>({}); @@ -739,10 +740,15 @@ const ModelSettingsPage: React.FC = () => { setIsFetchingRemoteModels(false); setRemoteModelsError(null); setHasAttemptedRemoteFetch(false); - lastRemoteFetchSignatureRef.current = null; - activeRemoteFetchSignatureRef.current = null; + modelDiscoveryRef.current.reset(); }, []); + const modelDiscoverySurface = peerDevice?.peerMode.active ? peerDevice.peerMode.deviceId : 'local'; + useEffect(() => { + resetRemoteModelDiscovery(); + return () => modelDiscoveryRef.current.reset(); + }, [modelDiscoverySurface, resetRemoteModelDiscovery]); + const getOpenCodePlanLabel = useCallback((plan: OpenCodePlan): string => ( plan === 'go' ? t('subscriptionAuth.openCodePlans.go.label') @@ -981,9 +987,8 @@ const ModelSettingsPage: React.FC = () => { auth: config.auth || { type: 'api_key' }, }); - const fetchRemoteModels = async (config: Partial | null) => { + const fetchRemoteModels = async (config: Partial | null, force = false) => { if (!config) return; - const discoveryConfig = buildModelDiscoveryConfig(config); if (!discoveryConfig) { setRemoteModelOptions([]); @@ -991,44 +996,48 @@ const ModelSettingsPage: React.FC = () => { setHasAttemptedRemoteFetch(true); return; } - - const requestSignature = buildModelDiscoverySignature(discoveryConfig); - if (activeRemoteFetchSignatureRef.current === requestSignature) { - return; - } - if (lastRemoteFetchSignatureRef.current === requestSignature) { - return; - } - + const coordinator = modelDiscoveryRef.current; + const scope = getActiveSurfaceScope(); + const operation = coordinator.begin(scope.key(buildModelDiscoverySignature(discoveryConfig)), force); + if (!operation) return; + const subscription = discoveryConfig.auth?.type === 'subscription'; setIsFetchingRemoteModels(true); setRemoteModelsError(null); setHasAttemptedRemoteFetch(true); - lastRemoteFetchSignatureRef.current = requestSignature; - activeRemoteFetchSignatureRef.current = requestSignature; - + let succeeded = false; try { - const remoteModels = await aiApi.listModelsByConfig(discoveryConfig); + let remoteModels: RemoteModelOption[]; + if (discoveryConfig.auth?.type === 'subscription' && discoveryConfig.auth.provider === 'opencode') { + const account = await aiApi.refreshSubscriptionAccount('opencode'); + 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, + ).map(model => ({ id: model.id, display_name: model.display_name || undefined })); + } else { + remoteModels = await aiApi.listModelsByConfig(discoveryConfig); + } + if (!scope.isCurrent() || !coordinator.isCurrent(operation)) return; const dedupedModels = remoteModels.filter((model, index, arr) => ( !!model.id && arr.findIndex(item => item.id === model.id) === index )); - + setRemoteModelOptions(dedupedModels); if (dedupedModels.length === 0) { - setRemoteModelOptions([]); - setRemoteModelsError(t('providerSelection.fetchEmptyFallback')); + setRemoteModelsError(t(subscription + ? 'providerSelection.subscriptionFetchEmpty' + : 'providerSelection.fetchEmptyFallback')); return; } - - setRemoteModelOptions(dedupedModels); - setRemoteModelsError(null); + succeeded = true; } catch (error) { - log.warn('Failed to fetch remote model list, falling back to presets', { error }); + if (!scope.isCurrent() || !coordinator.isCurrent(operation)) return; + log.warn('Failed to fetch remote model list', { error }); setRemoteModelOptions([]); - setRemoteModelsError(t('providerSelection.fetchFailedFallback')); + setRemoteModelsError(t(subscription + ? 'providerSelection.subscriptionFetchFailed' + : 'providerSelection.fetchFailedFallback')); } finally { - setIsFetchingRemoteModels(false); - if (activeRemoteFetchSignatureRef.current === requestSignature) { - activeRemoteFetchSignatureRef.current = null; - } + if (coordinator.complete(operation, succeeded) && scope.isCurrent()) setIsFetchingRemoteModels(false); } }; @@ -1076,17 +1085,9 @@ const ModelSettingsPage: React.FC = () => { account: SubscriptionAccount, offering?: SubscriptionApiOffering, ) => { - const targetKey = `new-provider:subscription:${account.provider}:${offering?.plan || offering?.format || 'default'}`; + const targetKey = `new-provider:subscription:${account.provider}:${offering?.plan || 'default'}:${offering?.format || 'default'}`; requestEditorOpen(targetKey, () => { resetRemoteModelDiscovery(); - const offeringModels = (offering?.models || []).map((model) => ({ - id: model.id, - display_name: model.display_name || undefined, - })); - if (offeringModels.length > 0) { - setRemoteModelOptions(offeringModels); - setHasAttemptedRemoteFetch(true); - } setManualModelInput(''); setShowApiKey(false); setSelectedProviderId(null); @@ -1171,7 +1172,7 @@ const ModelSettingsPage: React.FC = () => { }, []); const handleSubscriptionLogin = useCallback(async (provider: SubscriptionProvider) => { - if (isPeerDeviceModeActive() && subscriptionLoginRequiresLocalDevice(provider)) { + if ((!isTauriRuntime() || isPeerDeviceModeActive()) && subscriptionLoginRequiresLocalDevice(provider)) { notification.error(t('subscriptionAuth.peerLoginRequiresLocalDevice')); return; } @@ -2369,7 +2370,9 @@ const ModelSettingsPage: React.FC = () => { 'data-model-name': model.id, }, })) - : catalogModelOptions.length > 0 + : editingConfig.auth?.type === 'subscription' + ? [] + : catalogModelOptions.length > 0 ? catalogModelOptions : (currentTemplate?.models || []).map(model => ({ label: model, @@ -2871,6 +2874,15 @@ const ModelSettingsPage: React.FC = () => { {modelFetchHint} )} + {renderSelectedModelRows()} @@ -2994,6 +3006,15 @@ const ModelSettingsPage: React.FC = () => { {modelFetchHint} )} + {renderSelectedModelRows()} diff --git a/src/web-ui/src/infrastructure/config/components/modelDiscoveryCoordinator.test.ts b/src/web-ui/src/infrastructure/config/components/modelDiscoveryCoordinator.test.ts new file mode 100644 index 0000000000..4405fdd50f --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/modelDiscoveryCoordinator.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { ModelDiscoveryCoordinator, openCodeOfferingModels } from './modelDiscoveryCoordinator'; +import type { SubscriptionApiOffering } from '@/infrastructure/api/service-api/AIApi'; + +describe('model discovery', () => { + it('allows retry after failure and explicit refresh after success', () => { + const coordinator = new ModelDiscoveryCoordinator(); + const failed = coordinator.begin('account-a')!; + coordinator.complete(failed, false); + const retry = coordinator.begin('account-a')!; + expect(retry).not.toBeNull(); + coordinator.complete(retry, true); + expect(coordinator.begin('account-a')).toBeNull(); + expect(coordinator.begin('account-a', true)).not.toBeNull(); + }); + + it('rejects old responses even when a reset reopens the same account', () => { + const coordinator = new ModelDiscoveryCoordinator(); + const first = coordinator.begin('account-a')!; + coordinator.reset(); + const second = coordinator.begin('account-a')!; + expect(coordinator.isCurrent(first)).toBe(false); + expect(coordinator.complete(first, true)).toBe(false); + expect(coordinator.isCurrent(second)).toBe(true); + const third = coordinator.begin('account-b')!; + expect(coordinator.complete(second, false)).toBe(false); + expect(coordinator.isCurrent(third)).toBe(true); + }); + + it('keeps OpenCode plan and protocol aligned and preserves the legacy route', () => { + const offerings: SubscriptionApiOffering[] = [ + { plan: 'zen', format: 'openai', base_url: '', suggested_model: '', models: [{ id: 'zen-chat' }] }, + { plan: 'go', format: 'openai', base_url: '', suggested_model: '', models: [{ id: 'go-chat' }] }, + { plan: 'go', format: 'anthropic', base_url: '', suggested_model: '', models: [{ id: 'go-messages' }] }, + { plan: 'zen', format: 'responses', base_url: '', suggested_model: '', models: [{ id: 'zen-responses' }] }, + ]; + expect(openCodeOfferingModels(offerings, 'go', 'anthropic')).toEqual([{ id: 'go-messages' }]); + expect(openCodeOfferingModels(offerings, 'zen', 'response')).toEqual([{ id: 'zen-responses' }]); + expect(openCodeOfferingModels(offerings, undefined, 'responses')).toEqual([{ id: 'zen-chat' }]); + expect(openCodeOfferingModels(offerings, 'go', 'responses')).toEqual([]); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/modelDiscoveryCoordinator.ts b/src/web-ui/src/infrastructure/config/components/modelDiscoveryCoordinator.ts new file mode 100644 index 0000000000..bc0bac6cda --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/modelDiscoveryCoordinator.ts @@ -0,0 +1,46 @@ +import type { SubscriptionApiOffering } from '@/infrastructure/api/service-api/AIApi'; +import type { OpenCodePlan } from '../types'; + +interface DiscoveryOperation { + signature: string; +} + +/** Discards late results after a provider/format change or editor reset. */ +export class ModelDiscoveryCoordinator { + private active: DiscoveryOperation | null = null; + private completed: string | null = null; + + begin(signature: string, force = false): DiscoveryOperation | null { + if (this.active?.signature === signature || (!force && this.completed === signature)) return null; + const operation = { signature }; + this.active = operation; + return operation; + } + + isCurrent(operation: DiscoveryOperation): boolean { + return this.active === operation; + } + + complete(operation: DiscoveryOperation, succeeded: boolean): boolean { + if (!this.isCurrent(operation)) return false; + this.completed = succeeded ? operation.signature : null; + this.active = null; + return true; + } + + reset(): void { + this.active = null; + this.completed = null; + } +} + +/** A plan-wide /models response mixes incompatible wire formats. */ +export function openCodeOfferingModels( + offerings: SubscriptionApiOffering[], + plan: OpenCodePlan | undefined, + format: string, +) { + // Legacy model configs without a plan still execute as Zen Chat Completions. + const selectedFormat = plan ? (format === 'response' ? 'responses' : format) : 'openai'; + return offerings.find(item => item.plan === (plan ?? 'zen') && item.format === selectedFormat)?.models ?? []; +} diff --git a/src/web-ui/src/locales/en-US/settings/models.json b/src/web-ui/src/locales/en-US/settings/models.json index ac5099c92c..38783ce321 100644 --- a/src/web-ui/src/locales/en-US/settings/models.json +++ b/src/web-ui/src/locales/en-US/settings/models.json @@ -128,6 +128,9 @@ "fetchSuccessCount": "Fetched {{count}} models from the provider", "fetchFailedFallback": "Failed to fetch model list, fell back to common preset models", "fetchEmptyFallback": "Provider returned no models, fell back to common preset models", + "refreshModels": "Refresh models", + "subscriptionFetchFailed": "Could not load models for this subscription. Refresh to retry, or enter a model ID supported by your account.", + "subscriptionFetchEmpty": "This account returned no models for the selected interface. Refresh to retry, or enter a supported model ID.", "fillApiKeyBeforeFetch": "Enter the API key before fetching models", "usingPresetModels": "Currently showing common preset models", "noPresetModels": "No models available. Fetch the model list or enter a model name manually", diff --git a/src/web-ui/src/locales/zh-CN/settings/models.json b/src/web-ui/src/locales/zh-CN/settings/models.json index b0c07f6dc7..289272fbaa 100644 --- a/src/web-ui/src/locales/zh-CN/settings/models.json +++ b/src/web-ui/src/locales/zh-CN/settings/models.json @@ -128,6 +128,9 @@ "fetchSuccessCount": "已从供应商拉取 {{count}} 个模型", "fetchFailedFallback": "拉取模型列表失败,已回退到常用预设模型", "fetchEmptyFallback": "供应商未返回可用模型,已回退到常用预设模型", + "refreshModels": "刷新模型列表", + "subscriptionFetchFailed": "未能获取订阅账号的模型。请刷新重试,或手动填写该账号支持的模型 ID。", + "subscriptionFetchEmpty": "该账号未返回适用于当前接口的模型。请刷新重试,或手动填写支持的模型 ID。", "fillApiKeyBeforeFetch": "请先填写 API Key 再获取模型列表", "usingPresetModels": "当前显示的是常用预设模型", "noPresetModels": "暂无可用模型,请获取模型列表或手动输入模型名", diff --git a/src/web-ui/src/locales/zh-TW/settings/models.json b/src/web-ui/src/locales/zh-TW/settings/models.json index d80f010013..014c2b41e1 100644 --- a/src/web-ui/src/locales/zh-TW/settings/models.json +++ b/src/web-ui/src/locales/zh-TW/settings/models.json @@ -128,6 +128,9 @@ "fetchSuccessCount": "已從供應商拉取 {{count}} 個模型", "fetchFailedFallback": "拉取模型列表失敗,已回退到常用預設模型", "fetchEmptyFallback": "供應商未返回可用模型,已回退到常用預設模型", + "refreshModels": "重新整理模型清單", + "subscriptionFetchFailed": "無法取得訂閱帳號的模型。請重新整理再試,或手動填入該帳號支援的模型 ID。", + "subscriptionFetchEmpty": "該帳號未傳回適用於目前介面的模型。請重新整理再試,或手動填入支援的模型 ID。", "fillApiKeyBeforeFetch": "請先填寫 API Key 再獲取模型列表", "usingPresetModels": "目前顯示的是常用預設模型", "noPresetModels": "暫無可用模型,請獲取模型列表或手動輸入模型名", From 97184995309392147fe57a846ad6d9d348724043 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 6 Sep 2026 11:18:54 +0800 Subject: [PATCH 2/2] fix(web): capture model discovery coordinator for effect cleanup --- .../src/infrastructure/config/components/ModelSettingsPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx index df34278019..73b341f22b 100644 --- a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx +++ b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx @@ -745,8 +745,9 @@ const ModelSettingsPage: React.FC = () => { const modelDiscoverySurface = peerDevice?.peerMode.active ? peerDevice.peerMode.deviceId : 'local'; useEffect(() => { + const coordinator = modelDiscoveryRef.current; resetRemoteModelDiscovery(); - return () => modelDiscoveryRef.current.reset(); + return () => coordinator.reset(); }, [modelDiscoverySurface, resetRemoteModelDiscovery]); const getOpenCodePlanLabel = useCallback((plan: OpenCodePlan): string => (