Skip to content
Closed
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
28 changes: 28 additions & 0 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ Settings → **Providers** → provider detail → choose browser → Import.
Manual cookie header paste is the fallback (required under WSL for Chromium DPAPI).
Details: [COOKIES.md](./COOKIES.md).

## Ollama: browser-priority cookie starvation (fixed, PR #430)

`resolve_browser_cookie_header` (`rust/src/providers/ollama/cookies.rs`) used to call the shared `browser_cookies_for_domain` helper, which returns the **first installed browser** that has *any* cookies for `ollama.com` — even stale/irrelevant ones (analytics, consent) with no session cookie. On a machine with multiple Chromium browsers, an earlier-priority browser (Chrome, Edge) with only junk cookies silently starved out a later one (often Brave) that actually held the logged-in session, surfacing `No cookies available for web API` despite a valid session existing on disk.

Fixed by walking every detected browser and using the first one whose cookies contain a recognized Ollama session cookie name, instead of stopping at the first non-empty result. See `first_recognized_cookie_header` and its regression test.

## MiniMax: client-rendered console pages (fixed, PR #431)

MiniMax redesigned `platform.minimax.io`: `/console/usage` and `/console/plan` are Next.js pages loaded via `next/dynamic(..., { ssr: false })`. The server never emits real quota numbers in the initial HTML — `__NEXT_DATA__.props.pageProps.userConfig` is always `null` — regardless of cookie validity. Every cookie/HTML-scraping path is therefore structurally unable to read usage on the current site; it silently fell back to `probe_cli()`'s hardcoded 0% "configured" stub.

The coding-plan `remains` endpoint (`/v1/api/openplatform/coding_plan/remains`) that the HTML scraper already falls back to also accepts a plain `Authorization: Bearer <api_key>` with **no cookie at all**, returning the same `model_remains` JSON the existing parser (`coding_plan.rs`) already handles. `fetch_via_web` now tries this Bearer path first (key from Settings or `MINIMAX_API_KEY`) before the legacy `group_id`+`api_key` billing endpoint. MiniMax is now also registered in `get_api_key_providers()` so a plain API key can be set via Settings / `codexbar config set-api-key minimax` — previously only a paired `group_id`+`api_key` (env vars or a local `minimax`-CLI-style config file) was supported.

## Codex: external OAuth staleness gate

Codex reads the CLI-owned OAuth session from `~/.codex/auth.json` (`rust/src/providers/codex/api.rs`). This file is not managed by CodexBar — it is written and refreshed by the `codex` CLI itself.

Upstream 0.50.1 #2944 added a fail-closed gate for this case: when `auth.json` has a `refresh_token` (i.e. it is a CLI-owned "external OAuth" credential, not an API key) and `last_refresh` is **older than `EXTERNAL_OAUTH_STALENESS_WINDOW`** (8 days, `codex/api.rs`), CodexBar refuses to use it and reports `AuthRequired` instead of silently trusting a possibly-stale/compromised token. `Settings.codex_external_oauth_sources_allowed` (default `false`) bypasses this gate when explicitly enabled.

Symptom: Codex shows "Authentication required" / `codexbar diagnose -p codex` reports `error.category: "auth"` even though `auth.json` exists and looks otherwise valid — the fix is not a CodexBar config change, it's refreshing the CLI's own session:

```powershell
codex doctor # does a real reachability handshake; updates last_refresh
# or
codex login status # lighter, but only checks local state — does not always refresh
```

Any Codex CLI use that reaches the network (interactive session, `codex exec`, `codex doctor`) updates `last_refresh` and clears the gate on CodexBar's next poll. No CodexBar restart is required.

## Listing what is enabled

```powershell
Expand Down
145 changes: 142 additions & 3 deletions rust/src/providers/minimax/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,10 +267,26 @@ impl MiniMaxProvider {
/// Fetch usage via MiniMax API with region fallback
async fn fetch_via_web(
&self,
ctx: &FetchContext,
region: MiniMaxRegion,
) -> Result<ProviderFetchResult, ProviderError> {
let (group_id, api_key) = self.read_api_key().await?;
// Prefer the coding-plan remains endpoint (Win-CodexBar #425): the
// console's usage/plan pages are client-rendered (Next.js `ssr:false`),
// so no server HTML ever contains real numbers, even with a valid
// cookie. The underlying `coding_plan/remains` endpoint instead
// accepts a plain `Authorization: Bearer <api_key>` with no cookie at
// all (no group_id needed), and returns the same `model_remains`
// shape the cookie-based parser already understands. This key can
// come from Settings (GUI-stored) or the environment, independent of
// the dual group_id+api_key credential the legacy billing endpoint
// below requires.
if let Some(key) = Self::read_plain_api_key(ctx)
&& let Ok(result) = self.fetch_remains_via_api_key(&key, region).await
{
return Ok(result);
}

let (group_id, api_key) = self.read_api_key().await?;
match self.fetch_from_region(&group_id, &api_key, region).await {
Ok(result) => Ok(result),
Err(ProviderError::AuthRequired) if region == MiniMaxRegion::Global => {
Expand All @@ -281,6 +297,88 @@ impl MiniMaxProvider {
}
}

/// A plain MiniMax API key from Settings (GUI-stored, via `ctx.api_key`)
/// or the `MINIMAX_API_KEY` environment variable. Unlike `read_api_key`,
/// this does not require a paired group_id.
fn read_plain_api_key(ctx: &FetchContext) -> Option<String> {
ctx.api_key
.as_deref()
.map(str::trim)
.filter(|key| !key.is_empty())
.map(str::to_string)
.or_else(|| {
std::env::var("MINIMAX_API_KEY")
.ok()
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty())
})
}

/// Fetch coding-plan quota from the remains endpoint using a Bearer API
/// key instead of a browser cookie. Tries the platform-host URL, then the
/// www-host URL, mirroring the cookie-based fallback chain.
async fn fetch_remains_via_api_key(
&self,
api_key: &str,
region: MiniMaxRegion,
) -> Result<ProviderFetchResult, ProviderError> {
let now = Utc::now();
let urls = [region.coding_plan_remains_url(), region.www_remains_url()];
let mut last_err: Option<ProviderError> = None;
for url in urls {
match self.fetch_remains_once_via_api_key(api_key, &url).await {
Ok(snapshot) => {
let usage = coding_plan_html::to_usage_snapshot(&snapshot, now)?;
return Ok(ProviderFetchResult::new(usage, "api"));
}
Err(err @ ProviderError::Parse(_)) => {
last_err = Some(err);
}
Err(err) => return Err(err),
}
}
Err(last_err.unwrap_or_else(|| ProviderError::Parse("Missing MiniMax remains URL.".into())))
}

/// One Bearer-authenticated remains-API request, returning the parsed snapshot.
async fn fetch_remains_once_via_api_key(
&self,
api_key: &str,
url: &str,
) -> Result<coding_plan::MiniMaxCodingPlanSnapshot, ProviderError> {
let client = crate::core::credentialed_http_client_builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| ProviderError::Other(e.to_string()))?;

let response = client
.get(url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json, text/plain, */*")
.send()
.await?;

let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(ProviderError::AuthRequired);
}
if !status.is_success() {
let msg = format!("MiniMax remains (api key) returned status {status}");
if status == reqwest::StatusCode::NOT_FOUND
|| status == reqwest::StatusCode::METHOD_NOT_ALLOWED
{
return Err(ProviderError::Parse(msg));
}
return Err(ProviderError::Other(msg));
}

let json: serde_json::Value = response
.json()
.await
.map_err(|e| ProviderError::Parse(format!("Failed to parse remains JSON: {e}")))?;
coding_plan::parse_coding_plan_value(&json, Utc::now())
}

/// Fetch from a specific region endpoint
async fn fetch_from_region(
&self,
Expand Down Expand Up @@ -1019,15 +1117,15 @@ impl Provider for MiniMaxProvider {
return Ok(result);
}
// Fall through to API keys.
if let Ok(result) = self.fetch_via_web(region).await {
if let Ok(result) = self.fetch_via_web(ctx, region).await {
return Ok(result);
}
let usage = self.probe_cli().await?;
Ok(ProviderFetchResult::new(usage, "cli"))
}
SourceMode::Web => match self.resolve_web_cookie(ctx, region)? {
Some(cookie) => self.fetch_with_cookie(&cookie, region).await,
None => self.fetch_via_web(region).await,
None => self.fetch_via_web(ctx, region).await,
},
SourceMode::Cli => {
let usage = self.probe_cli().await?;
Expand All @@ -1054,6 +1152,47 @@ impl Provider for MiniMaxProvider {
mod tests {
use super::*;

#[test]
fn read_plain_api_key_prefers_ctx_then_env_then_none() {
let ctx_with_key = FetchContext {
api_key: Some(" ctx-key ".to_string()),
..FetchContext::default()
};
assert_eq!(
MiniMaxProvider::read_plain_api_key(&ctx_with_key).as_deref(),
Some("ctx-key")
);

// Isolate from any MINIMAX_API_KEY already set in the ambient
// environment (e.g. a developer's own shell), and restore it after.
let previous = std::env::var("MINIMAX_API_KEY").ok();
// SAFETY: test-only env var; saved above and restored below within
// this single test, with no other test reading MINIMAX_API_KEY.
unsafe { std::env::remove_var("MINIMAX_API_KEY") };

let ctx_empty = FetchContext {
api_key: Some(" ".to_string()),
..FetchContext::default()
};
assert_eq!(MiniMaxProvider::read_plain_api_key(&ctx_empty), None);

// SAFETY: see above.
unsafe { std::env::set_var("MINIMAX_API_KEY", "env-key") };
let result = MiniMaxProvider::read_plain_api_key(&FetchContext::default());
assert_eq!(result.as_deref(), Some("env-key"));

match previous {
Some(value) => {
// SAFETY: see above.
unsafe { std::env::set_var("MINIMAX_API_KEY", value) }
}
None => {
// SAFETY: see above.
unsafe { std::env::remove_var("MINIMAX_API_KEY") }
}
}
}

#[test]
fn minimax_region_defaults_to_global_io_urls() {
let region = MiniMaxRegion::from_settings_value(None);
Expand Down
95 changes: 85 additions & 10 deletions rust/src/providers/ollama/cookies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,16 +153,35 @@ pub(super) fn resolve_browser_cookie_header(
return Ok(Some(cached.cookie_header));
}

match crate::providers::browser_cookies_for_domain(OLLAMA_COOKIE_DOMAIN) {
Ok(cookies) => {
let url = Url::parse("https://ollama.com/settings")
.map_err(|e| ProviderError::Other(e.to_string()))?;
Ok(ollama_cookie_header_for_url(&cookies, &url)
.filter(|h| has_recognized_ollama_session_cookie(h)))
}
Err(ProviderError::NoCookies) => Ok(None),
Err(err) => Err(err),
}
let url = Url::parse("https://ollama.com/settings")
.map_err(|e| ProviderError::Other(e.to_string()))?;

// Upstream Win-CodexBar #426: the generic `browser_cookies_for_domain` helper
// stops at the FIRST installed browser that has *any* cookies for the domain,
// even if those cookies are stale/irrelevant (e.g. a consent or CDN cookie left
// behind in Chrome/Edge from a one-off visit) and don't include a recognized
// Ollama session cookie. That starves out a later browser (often Brave) that
// actually holds the logged-in session. Walk every detected browser ourselves
// and keep going until one yields a header with a recognized session cookie.
use crate::browser::cookies::CookieExtractor;
use crate::browser::detection::BrowserDetector;

let cookie_sets = BrowserDetector::detect_all()
.into_iter()
.filter_map(|browser| {
CookieExtractor::extract_for_domain(&browser, OLLAMA_COOKIE_DOMAIN).ok()
});
Ok(first_recognized_cookie_header(cookie_sets, &url))
}

/// Return the header for the first cookie set (in order) that contains a
/// recognized Ollama session cookie for `url`, skipping sets that decrypt
/// fine but carry no usable session (see `resolve_browser_cookie_header`).
fn first_recognized_cookie_header(
mut cookie_sets: impl Iterator<Item = Vec<Cookie>>,
url: &Url,
) -> Option<String> {
cookie_sets.find_map(|cookies| ollama_cookie_header_for_url(&cookies, url))
}

pub(super) fn should_attach_ollama_cookie(url: &Url) -> bool {
Expand Down Expand Up @@ -285,6 +304,62 @@ mod tests {
);
}

#[test]
fn first_recognized_cookie_header_skips_browsers_without_a_session_cookie() {
// Regression for #426: an earlier-priority browser (e.g. Chrome/Edge)
// may hold only stale/irrelevant ollama.com cookies (analytics,
// consent) with no session cookie at all. The old
// `browser_cookies_for_domain` helper stopped at that first non-empty
// result and never reached a later browser (e.g. Brave) that actually
// holds the logged-in session.
let irrelevant_only = vec![Cookie {
name: "aid".to_string(),
value: "device-id".to_string(),
domain: "ollama.com".to_string(),
path: "/".to_string(),
expires: None,
is_secure: true,
is_http_only: false,
}];
let real_session = vec![Cookie {
name: OLLAMA_SESSION_COOKIE_NAME.to_string(),
value: "abc123".to_string(),
domain: "ollama.com".to_string(),
path: "/".to_string(),
expires: None,
is_secure: true,
is_http_only: true,
}];

let url = Url::parse("https://ollama.com/settings").unwrap();
let header =
first_recognized_cookie_header(vec![irrelevant_only, real_session].into_iter(), &url);

assert_eq!(
header.as_deref(),
Some("__Secure-session=abc123"),
"should skip the first (irrelevant) cookie set and use the second (real session)"
);
}

#[test]
fn first_recognized_cookie_header_none_when_no_set_has_a_session_cookie() {
let only_irrelevant = vec![Cookie {
name: "aid".to_string(),
value: "device-id".to_string(),
domain: "ollama.com".to_string(),
path: "/".to_string(),
expires: None,
is_secure: true,
is_http_only: false,
}];

let url = Url::parse("https://ollama.com/settings").unwrap();
let header = first_recognized_cookie_header(vec![only_irrelevant].into_iter(), &url);

assert_eq!(header, None);
}

#[test]
fn ignores_empty_cookie_input() {
assert_eq!(normalize_cookie_header(" "), None);
Expand Down
13 changes: 13 additions & 0 deletions rust/src/settings/api_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,19 @@ pub fn get_api_key_providers() -> Vec<ProviderConfigInfo> {
config_file_path: None,
dashboard_url: Some("https://ollama.com/settings"),
},
ProviderConfigInfo {
id: ProviderId::MiniMax,
name: "MiniMax",
requires_api_key: false,
api_key_env_var: Some("MINIMAX_API_KEY"),
api_key_help: Some(
"Optional: a MiniMax API key reads real coding-plan quota via the console's remains API, bypassing the client-rendered usage/plan pages that browser cookies alone cannot scrape.",
),
config_file_path: None,
dashboard_url: Some(
"https://platform.minimax.io/user-center/basic-information/interface-key",
),
},
ProviderConfigInfo {
id: ProviderId::AzureOpenAI,
name: "Azure OpenAI",
Expand Down
Loading