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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added
- Grok: Settings and tray **Add account** flow matching Codex/Claude — isolated `grok login --oauth`, save current CLI login, switch, and remove without logging out the active session.
- Replicate: cookie-authenticated monthly spend and optional prepaid credit balance from the billing page, with user and organization account isolation.
- DeepSeek: show reported per-model spend in the provider details while preserving the billing currency, reporting period, zero values, and incomplete-total safeguards.

### Fixed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md).
| Venice | API Key | USD / DIEM Balance |
| OpenAI | Admin API / API Key | Usage, Requests, Project-scoped cost, Credit Balance |
| Grok | Cookies / auth.json | Billing |
| Replicate | Cookies / token accounts | Monthly spend, credit balance |
| ElevenLabs | API Key | Subscription Credits, Voice Slots |
| Deepgram | API Key | Project Usage |
| Groq | API Key | Enterprise Metrics |
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop-tauri/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ use std::collections::HashSet;
use std::sync::Mutex;

use codexbar::core::{
FetchContext, ProviderAccountData, ProviderFetchResult, ProviderId, ProviderMetadata,
RateWindow, SourceMode, TokenAccount, TokenAccountOverride, TokenAccountStore,
instantiate_provider,
FetchContext, ManualEmptyCookiePolicy, ProviderAccountData, ProviderFetchResult, ProviderId,
ProviderMetadata, RateWindow, SourceMode, TokenAccount, TokenAccountOverride,
TokenAccountStore, instantiate_provider,
};
use codexbar::locale;
use codexbar::login::{self, LoginOutcome, LoginPhase};
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ fn cookie_source_provider(provider_id: &str) -> Option<codexbar::core::ProviderI
"sakana" => ProviderId::Sakana,
"notion" => ProviderId::Notion,
"grok" => ProviderId::Grok,
"replicate" => ProviderId::Replicate,
_ => return None,
})
}
Expand Down Expand Up @@ -728,6 +729,22 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec<Cooki
),
cookie_option(lang, "off", "", "", Some("Notion cookies are disabled.")),
],
"replicate" => vec![
cookie_option(
lang,
"auto",
"Automatic imports the signed-in replicate.com browser session.",
"Paste a Cookie header from the Replicate billing page.",
None,
),
cookie_option(
lang,
"manual",
"",
"Paste a Cookie header from https://replicate.com/account/billing.",
None,
),
],
_ => Vec::new(),
}
}
Expand Down
153 changes: 82 additions & 71 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,81 +85,91 @@ pub(crate) fn build_fetch_context(
let has_opencodego_api_key = id == ProviderId::OpenCodeGo
&& api_key.as_deref().is_some_and(|key| !key.trim().is_empty());

let (mut source_mode, mut cookie_header) = if id.cookie_domain().is_none() {
let source_mode = if active_token_env.is_some() {
SourceMode::OAuth
let (mut source_mode, mut cookie_header, fails_closed_without_cookie) =
if id.cookie_domain().is_none() {
let source_mode = if active_token_env.is_some() {
SourceMode::OAuth
} else {
usage_source
};
(source_mode, None, false)
} else {
usage_source
};
(source_mode, None)
} else {
match cookie_source {
// #433: an explicitly selected, non-empty Claude manual cookie is
// authoritative. Do not let an active OAuth token account silently
// replace it; this keeps tray refresh behavior aligned with diagnose,
// whose Claude Auto path tries the supplied Web cookie before OAuth.
"manual"
if provider.manual_cookie_precedes_token_account()
&& stored_cookie
.as_deref()
.is_some_and(|cookie| !cookie.trim().is_empty()) =>
{
(SourceMode::Web, stored_cookie.clone())
}
_ if active_token_env.is_some() => (SourceMode::OAuth, None),
"off" if provider_uses_oauth_without_cookies(id, usage_source) => {
(SourceMode::OAuth, None)
}
"off"
if (has_kimi_code_api_key || has_opencodego_api_key)
&& usage_source == SourceMode::Auto =>
{
(SourceMode::Auto, None)
}
// Droid/Factory: cookie-off must never scrape browser cookies. Map to
// Cli (API-only in the provider) so Auto does not fall through to web.
"off" if id == ProviderId::Factory => (SourceMode::Cli, None),
"off" => (SourceMode::Cli, None),
"manual" => {
let cookie_header = active_token_cookie.or(stored_cookie);
let source_mode = if (has_kimi_code_api_key || has_opencodego_api_key)
&& usage_source == SourceMode::Auto
match cookie_source {
// #433: an explicitly selected, non-empty Claude manual cookie is
// authoritative. Do not let an active OAuth token account silently
// replace it; this keeps tray refresh behavior aligned with diagnose,
// whose Claude Auto path tries the supplied Web cookie before OAuth.
"manual"
if provider.manual_cookie_precedes_token_account()
&& stored_cookie
.as_deref()
.is_some_and(|cookie| !cookie.trim().is_empty()) =>
{
SourceMode::Auto
} else if let Some(mode) = grok_source_mode_for_manual_cookie(id, usage_source) {
// Grok Switch writes ~/.grok/auth.json. Leftover grok.com
// cookies must not force Web, or Weekly/notifications keep
// showing the previous browser account.
mode
} else if cookie_header.is_some() {
SourceMode::Web
} else if provider_uses_oauth_without_cookies(id, usage_source) {
SourceMode::OAuth
} else {
SourceMode::Cli
};
(source_mode, cookie_header)
}
// `browser` is accepted as a legacy alias from older settings.
"auto" | "browser" | "web" => {
// Claude resolves its cached cookie and browser fallback inside
// the provider; other providers retain the shell fallback.
let cookie_header = active_token_cookie.or(stored_cookie).or_else(|| {
if defer_provider_browser_cookie_lookup {
None
(SourceMode::Web, stored_cookie.clone(), false)
}
_ if active_token_env.is_some() => (SourceMode::OAuth, None, false),
"off" if provider_uses_oauth_without_cookies(id, usage_source) => {
(SourceMode::OAuth, None, false)
}
"off"
if (has_kimi_code_api_key || has_opencodego_api_key)
&& usage_source == SourceMode::Auto =>
{
(SourceMode::Auto, None, false)
}
// Droid/Factory: cookie-off must never scrape browser cookies. Map to
// Cli (API-only in the provider) so Auto does not fall through to web.
"off" if id == ProviderId::Factory => (SourceMode::Cli, None, false),
"off" => (SourceMode::Cli, None, false),
"manual" => {
let cookie_header = active_token_cookie.or(stored_cookie);
let fails_closed_without_cookie = cookie_header.is_none()
&& provider.manual_empty_cookie_policy()
== ManualEmptyCookiePolicy::FailClosedWeb;
let source_mode = if (has_kimi_code_api_key || has_opencodego_api_key)
&& usage_source == SourceMode::Auto
{
SourceMode::Auto
} else if let Some(mode) = grok_source_mode_for_manual_cookie(id, usage_source)
{
// Grok Switch writes ~/.grok/auth.json. Leftover grok.com
// cookies must not force Web, or Weekly/notifications keep
// showing the previous browser account.
mode
} else if cookie_header.is_some() {
SourceMode::Web
} else if fails_closed_without_cookie {
// The provider owns this policy; Web with no header means
// it fails closed instead of importing a browser account
// the user did not select.
SourceMode::Web
} else if provider_uses_oauth_without_cookies(id, usage_source) {
SourceMode::OAuth
} else {
provider_cookie_domain(id, settings).and_then(|domain| {
codexbar::browser::cookies::get_cookie_header(domain)
.ok()
.filter(|h| !h.is_empty())
})
}
});
(usage_source, cookie_header)
SourceMode::Cli
};
(source_mode, cookie_header, fails_closed_without_cookie)
}
// `browser` is accepted as a legacy alias from older settings.
"auto" | "browser" | "web" => {
// Claude resolves its cached cookie and browser fallback inside
// the provider; other providers retain the shell fallback.
let cookie_header = active_token_cookie.or(stored_cookie).or_else(|| {
if defer_provider_browser_cookie_lookup {
None
} else {
provider_cookie_domain(id, settings).and_then(|domain| {
codexbar::browser::cookies::get_cookie_header(domain)
.ok()
.filter(|h| !h.is_empty())
})
}
});
(usage_source, cookie_header, false)
}
_ => (usage_source, stored_cookie, false),
}
_ => (usage_source, stored_cookie),
}
};
};

// Cookie-web providers (Cursor, OpenCode, …) reject SourceMode::Cli. The shell
// historically mapped "manual + no cookie" to Cli, which surfaces as
Expand Down Expand Up @@ -207,6 +217,7 @@ pub(crate) fn build_fetch_context(
FetchContext {
source_mode,
manual_cookie_header: cookie_header,
manual_cookie_missing: fails_closed_without_cookie,
api_key,
workspace_id: (!workspace_id.is_empty()).then_some(workspace_id),
seat_credit_entitlement: settings.seat_credit_entitlement(id),
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,20 @@ fn minimax_cookie_domain_follows_selected_region() {
);
}

#[test]
fn replicate_cookie_source_and_domain_are_exposed() {
let mut settings = Settings::default();
super::provider_cookie_source_set(&mut settings, "replicate", "manual".to_string()).unwrap();
assert_eq!(
provider_cookie_source_lookup(&settings, "replicate").as_deref(),
Some("manual")
);
assert_eq!(
super::provider_cookie_domain(ProviderId::Replicate, &settings),
Some("replicate.com")
);
}

#[test]
fn provider_cookie_source_set_rejects_unknown_provider() {
let mut s = Settings::default();
Expand Down Expand Up @@ -455,6 +469,22 @@ fn fetch_context_opencode_empty_manual_remaps_to_web() {
assert_eq!(ctx.source_mode, SourceMode::Web);
}

#[test]
fn fetch_context_replicate_empty_manual_fails_closed_without_browser_import() {
let settings = Settings::default();
let ctx = super::build_fetch_context(
ProviderId::Replicate,
&settings,
&ManualCookies::default(),
&ApiKeys::default(),
&HashMap::new(),
);

assert_eq!(ctx.source_mode, SourceMode::Web);
assert!(ctx.manual_cookie_header.is_none());
assert!(ctx.manual_cookie_missing);
}

#[test]
fn fetch_context_codex_manual_cookie_never_forces_unsupported_web() {
// Default cookie source is "manual". Pasting a chatgpt.com cookie used to flip
Expand Down Expand Up @@ -1710,6 +1740,13 @@ fn cookie_options_for_cookie_supporting_provider() {
assert!(opts.iter().any(|o| o.label == "Disabled"));
}

#[test]
fn replicate_cookie_options_allow_automatic_and_manual_sessions() {
let opts = super::cookie_source_options_for("replicate", Language::English);
let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect();
assert_eq!(values, vec!["auto", "manual"]);
}

#[test]
fn cookie_options_empty_for_providers_without_picker() {
assert!(super::cookie_source_options_for("anthropic", Language::English).is_empty());
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/desktop-tauri/src/components/providers/providerIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import opencodego from "./icons/ProviderIcon-opencodego.svg?raw";
import openrouter from "./icons/ProviderIcon-openrouter.svg?raw";
import perplexity from "./icons/ProviderIcon-perplexity.svg?raw";
import qoder from "./icons/ProviderIcon-qoder.svg?raw";
import replicate from "./icons/ProviderIcon-replicate.svg?raw";
import sakana from "./icons/ProviderIcon-sakana.svg?raw";
import stepfun from "./icons/ProviderIcon-stepfun.svg?raw";
import sub2api from "./icons/ProviderIcon-sub2api.svg?raw";
Expand Down Expand Up @@ -139,6 +140,7 @@ const RAW: Record<string, string> = {
openrouter: tint(openrouter),
perplexity: tint(perplexity),
qoder: tint(qoder),
replicate: tint(replicate),
sakana: tint(sakana),
stepfun: tint(stepfun),
sub2api: tint(sub2api),
Expand Down Expand Up @@ -216,6 +218,7 @@ export const PROVIDER_ICON_REGISTRY: Record<string, ProviderIcon> = {
crof: { id: "crof", brandColor: "#7c3aed", fallbackLetter: "C", svgPath: RAW.crof },
crossmodel: { id: "crossmodel", brandColor: "#c084fc", fallbackLetter: "X", svgPath: RAW.crossmodel },
qoder: { id: "qoder", brandColor: "#2563eb", fallbackLetter: "Q", svgPath: RAW.qoder },
replicate: { id: "replicate", brandColor: "#000000", fallbackLetter: "R", svgPath: RAW.replicate },
codebuddy: { id: "codebuddy", brandColor: "#0052d9", fallbackLetter: "C" },
sakana: { id: "sakana", brandColor: "#0ea5e9", fallbackLetter: "S", svgPath: RAW.sakana },
stepfun: { id: "stepfun", brandColor: "#999999", fallbackLetter: "S", svgPath: RAW.stepfun },
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop-tauri/src/surfaces/TrayPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const HAS_DASHBOARD = new Set([
"azureopenai", "bedrock", "claude", "codex", "codebuff",
"aiand", "commandcode", "copilot", "crof", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate",
"doubao", "elevenlabs", "factory", "gemini", "grok", "groq",
"infini", "jetbrains", "kilo", "kimi", "kimik2", "kiro", "manus",
"infini", "jetbrains", "kilo", "kimi", "kimik2", "kiro", "manus", "replicate",
"mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi",
"opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun",
"t3chat", "venice", "vertexai", "warp", "windsurf",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/test/providerCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [
["sub2api", "sub2api"],
["qwencloud", "Qwen Cloud"],
["notion", "Notion AI"],
["replicate", "Replicate"],
["meta", "Meta"],
["muse", "Muse Code"],
];
10 changes: 10 additions & 0 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ 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).

### Replicate billing

Replicate uses the signed-in `replicate.com` session cookie for its billing
page and read-only account endpoints. Automatic mode reuses a validated local
cookie before importing the browser session; manual mode accepts a Cookie
header containing a nonempty `sessionid`. The provider reports this month's
spend and, when the optional balance request succeeds, prepaid credit balance.
It keeps those values in the cost/detail surfaces and does not invent a quota
percentage or use a Replicate API token as a website credential.

## Listing what is enabled

```powershell
Expand Down
Loading