diff --git a/app/src/hooks/useUsageState.test.ts b/app/src/hooks/useUsageState.test.ts index 190283bbb..f4887143b 100644 --- a/app/src/hooks/useUsageState.test.ts +++ b/app/src/hooks/useUsageState.test.ts @@ -332,6 +332,79 @@ describe('useUsageState', () => { expect(result.current.isAtLimit).toBe(false); }); + // -- #3767 — authoritative core-side BYO-key bypass flag -------------------- + + it('suppresses the budget banner when the core reports creditsBypass for the active mode (#3767)', async () => { + const { useUsageState } = await import('./useUsageState'); + + // Budget exhausted, and the raw routing strings still read as managed + // (kind=openhuman). The core reports the chat tier runs on a usable BYO + // provider (creditsBypass.chat=true), which must win for the default mode. + mockGetCurrentPlan.mockResolvedValue(basicPlan()); + mockGetTeamUsage.mockResolvedValue(buildUsage({ remainingUsd: 0, cycleBudgetUsd: 10 })); + mockLoadAISettings.mockResolvedValue({ + ...ALL_OPENHUMAN_AI_SETTINGS, + creditsBypass: { chat: true, reasoning: true }, + }); + + const { result } = renderHook(() => useUsageState()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isFullyRoutedAway).toBe(true); + expect(result.current.shouldShowBudgetCompletedMessage).toBe(false); + expect(result.current.isBudgetExhausted).toBe(false); + expect(result.current.isAtLimit).toBe(false); + }); + + it('still shows the budget banner when creditsBypass is false for the active mode (#3767)', async () => { + const { useUsageState } = await import('./useUsageState'); + + // Neither chat-mode tier on a usable BYO provider → gate stays on. + mockGetCurrentPlan.mockResolvedValue(basicPlan()); + mockGetTeamUsage.mockResolvedValue(buildUsage({ remainingUsd: 0, cycleBudgetUsd: 10 })); + mockLoadAISettings.mockResolvedValue({ + ...ALL_OPENHUMAN_AI_SETTINGS, + creditsBypass: { chat: false, reasoning: false }, + }); + + const { result } = renderHook(() => useUsageState()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isFullyRoutedAway).toBe(false); + expect(result.current.shouldShowBudgetCompletedMessage).toBe(true); + expect(result.current.isBudgetExhausted).toBe(true); + expect(result.current.isAtLimit).toBe(true); + }); + + it('gates per selected chat mode — Quick bypassed, Reasoning gated when only chat is BYO (#3767)', async () => { + const { useUsageState } = await import('./useUsageState'); + + // chat tier on BYO, reasoning tier still managed. Quick mode (chat) should + // bypass; Reasoning mode (reasoning) should stay gated. + mockGetCurrentPlan.mockResolvedValue(basicPlan()); + mockGetTeamUsage.mockResolvedValue(buildUsage({ remainingUsd: 0, cycleBudgetUsd: 10 })); + mockLoadAISettings.mockResolvedValue({ + ...ALL_OPENHUMAN_AI_SETTINGS, + creditsBypass: { chat: true, reasoning: false }, + }); + + const quick = renderHook(() => useUsageState('chat')); + await waitFor(() => expect(quick.result.current.isLoading).toBe(false)); + expect(quick.result.current.isAtLimit).toBe(false); + expect(quick.result.current.shouldShowBudgetCompletedMessage).toBe(false); + + const reasoning = renderHook(() => useUsageState('reasoning')); + await waitFor(() => expect(reasoning.result.current.isLoading).toBe(false)); + expect(reasoning.result.current.isAtLimit).toBe(true); + expect(reasoning.result.current.shouldShowBudgetCompletedMessage).toBe(true); + }); + it('still shows the budget banner when at least one chat workload remains on OpenHuman', async () => { const { useUsageState } = await import('./useUsageState'); diff --git a/app/src/hooks/useUsageState.ts b/app/src/hooks/useUsageState.ts index 9bcd8f23f..8a7ca1256 100644 --- a/app/src/hooks/useUsageState.ts +++ b/app/src/hooks/useUsageState.ts @@ -1,3 +1,4 @@ +import debug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { useCoreState } from '../providers/CoreStateProvider'; @@ -35,6 +36,8 @@ export interface UsageState { refresh: () => void; } +const logBillingGate = debug('openhuman:billing:gate'); + const CACHE_TTL_MS = 60_000; let _cache: { @@ -114,7 +117,12 @@ async function fetchUsageData(): Promise<{ return data; } -export function useUsageState(): UsageState { +/** + * @param activeChatRole the chat-mode tier the caller is gating on — `chat` for + * Quick mode (default), `reasoning` for Reasoning mode. The credits bypass is + * checked against this tier so the prompt reflects the mode the user selected. + */ +export function useUsageState(activeChatRole: 'chat' | 'reasoning' = 'chat'): UsageState { const { snapshot } = useCoreState(); const isAuthenticated = snapshot.auth.isAuthenticated; const [teamUsage, setTeamUsage] = useState(null); @@ -192,7 +200,17 @@ export function useUsageState(): UsageState { // user. Conservative on missing aiSettings (treat as still using // openhuman) so we never silently disable the gate after a transient // fetch failure (#2040, #2041). - const isFullyRoutedAway = aiSettings ? workloadsRoutedAway(aiSettings, CHAT_WORKLOADS) : false; + // + // #3767: prefer the authoritative, core-side `creditsBypass` decision for the + // selected chat-mode tier (Quick → `chat`, Reasoning → `reasoning`) — true when + // that tier runs on a usable non-managed provider the user funds themselves — + // and OR it with the existing routing-string heuristic. This closes the gap + // where the selected mode runs on a BYO provider but the raw routing strings + // still read as managed, so the buy-credits prompt stayed up. + const creditsBypassForMode = aiSettings?.creditsBypass?.[activeChatRole] === true; + const isFullyRoutedAway = aiSettings + ? creditsBypassForMode || workloadsRoutedAway(aiSettings, CHAT_WORKLOADS) + : false; const rawBudgetExhausted = teamUsage ? teamUsage.cycleBudgetUsd > 0.01 && teamUsage.remainingUsd <= 0.01 @@ -215,6 +233,24 @@ export function useUsageState(): UsageState { // despite custom provider). const isNearLimit = !isAtLimit && !isFullyRoutedAway && teamUsage !== null && usagePct >= 0.8; + // #3767: verbose gate-decision diagnostics — which branch (gated vs bypassed) + // and why. Keyed on the inputs so it only fires when the decision changes. + useEffect(() => { + if (!teamUsage && !aiSettings) return; + logBillingGate( + `[billing][gate] mode=${activeChatRole} budgetExhausted=${rawBudgetExhausted} ` + + `creditsBypass=${creditsBypassForMode} ` + + `fullyRoutedAway=${isFullyRoutedAway} -> ${isAtLimit ? 'GATED' : 'bypassed'}` + ); + }, [ + activeChatRole, + creditsBypassForMode, + rawBudgetExhausted, + isFullyRoutedAway, + isAtLimit, + teamUsage, + ]); + return { teamUsage, currentPlan, diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index cbb3cf000..edb97d084 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -319,7 +319,10 @@ const Conversations = ({ isFreeTier, shouldShowBudgetCompletedMessage, usagePct, - } = useUsageState(); + // #3767: gate on the tier for the selected chat mode — Quick runs on the + // `chat` tier, Reasoning on the `reasoning` tier — so the credits prompt + // reflects the mode the user actually picked. + } = useUsageState(selectedAgentProfileId === 'reasoning' ? 'reasoning' : 'chat'); const [deleteModal, setDeleteModal] = useState({ isOpen: false, title: '', diff --git a/app/src/services/api/__tests__/aiSettingsApi.test.ts b/app/src/services/api/__tests__/aiSettingsApi.test.ts index cf1490915..d3479fd70 100644 --- a/app/src/services/api/__tests__/aiSettingsApi.test.ts +++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts @@ -311,6 +311,26 @@ describe('loadAISettings', () => { expect(settings.cloudProviders[0].has_api_key).toBe(false); }); + it('parses per-tier credits_bypass into creditsBypass, defaulting to false (#3767)', async () => { + mockAuthListProviderCredentials.mockResolvedValue(makeAuthProfileResult([])); + + // Absent in an older snapshot → both tiers conservative false. + mockOpenhumanGetClientConfig.mockResolvedValue(makeClientConfigResult({})); + expect((await loadAISettings()).creditsBypass).toEqual({ chat: false, reasoning: false }); + + // Per-tier: chat true, reasoning absent → chat true, reasoning false. + mockOpenhumanGetClientConfig.mockResolvedValue( + makeClientConfigResult({ credits_bypass: { chat: true } }) + ); + expect((await loadAISettings()).creditsBypass).toEqual({ chat: true, reasoning: false }); + + // Both present. + mockOpenhumanGetClientConfig.mockResolvedValue( + makeClientConfigResult({ credits_bypass: { chat: true, reasoning: true } }) + ); + expect((await loadAISettings()).creditsBypass).toEqual({ chat: true, reasoning: true }); + }); + it('sets has_api_key=true when a matching provider: profile is stored', async () => { mockOpenhumanGetClientConfig.mockResolvedValue( makeClientConfigResult({ @@ -592,6 +612,7 @@ describe('saveAISettings', () => { subconscious: { kind: 'openhuman' }, }, modelRegistry: [], + creditsBypass: { chat: false, reasoning: false }, ...overrides, }; } diff --git a/app/src/services/api/aiSettingsApi.ts b/app/src/services/api/aiSettingsApi.ts index b2867d7ca..12c3b4c5d 100644 --- a/app/src/services/api/aiSettingsApi.ts +++ b/app/src/services/api/aiSettingsApi.ts @@ -148,6 +148,21 @@ export interface AISettings { * image attachments for custom/BYOK models. */ modelRegistry: ModelRegistryEntry[]; + /** + * #3767: authoritative, core-side per-tier decision (mirrors the Rust factory's + * real routing resolution). For each chat-mode tier (`chat` = Quick mode, + * `reasoning` = Reasoning mode), true when that tier runs on a non-managed + * provider the user funds themselves (a usable BYO key, local runtime, or + * claude-code). `useUsageState` checks the tier matching the selected mode so + * the "buy credits" prompt is hidden exactly when the core says that mode does + * not bill managed credits. Each entry is `false` when the core snapshot + * predates this field (conservative — keep gating). + * + * Optional: `loadAISettings` always populates it, but AIPanel reconstructs a + * draft `AISettings` for `saveAISettings` (which ignores this read-only field) + * and test fixtures may omit it — consumers treat a missing entry as `false`. + */ + creditsBypass?: { chat: boolean; reasoning: boolean }; } /** Re-export so callers (e.g. the AI panel) can reference the entry type. */ @@ -319,7 +334,15 @@ export async function loadAISettings(): Promise { // Per-model registry (vision flags). Defensive default for older snapshots. const modelRegistry: ModelRegistryEntry[] = config.model_registry ?? []; - return { cloudProviders, routing, modelRegistry }; + // #3767: authoritative per-tier bypass flags from the core. Each entry + // defaults to false for older snapshots that don't carry it (conservative — + // keep the credits gate on). + const creditsBypass = { + chat: config.credits_bypass?.chat === true, + reasoning: config.credits_bypass?.reasoning === true, + }; + + return { cloudProviders, routing, modelRegistry, creditsBypass }; } // ─── Write path: diff + save ─────────────────────────────────────────────── diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 6a03e5ec2..40a1ab44a 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -238,6 +238,15 @@ export interface ClientConfig { model_registry: ModelRegistryEntry[]; /** Id of the `cloud_providers` entry resolved by the `"cloud"` sentinel. */ primary_cloud: string | null; + /** + * #3767: authoritative, core-side per-tier flags — for each chat-mode tier + * (`chat` = Quick mode, `reasoning` = Reasoning mode), true when that tier runs + * on a non-managed provider the user funds themselves (a usable BYO key, local + * runtime, or claude-code). The UI checks whichever tier the user has selected; + * when true the "buy credits" prompt is suppressed for that mode. Optional for + * back-compat with older snapshots. + */ + credits_bypass?: { chat?: boolean; reasoning?: boolean }; /** Per-workload provider strings (e.g. `"cloud"`, `"ollama:llama3.1:8b"`, `"openai:gpt-4o"`). */ chat_provider: string | null; reasoning_provider: string | null; diff --git a/src/openhuman/config/ops/loader.rs b/src/openhuman/config/ops/loader.rs index 7fa938cb1..f740c9d3d 100644 --- a/src/openhuman/config/ops/loader.rs +++ b/src/openhuman/config/ops/loader.rs @@ -277,6 +277,23 @@ pub fn client_config_json(config: &Config) -> serde_json::Value { "cloud_providers": cloud_providers, "model_registry": model_registry, "primary_cloud": config.primary_cloud, + // #3767: authoritative, core-side decision telling the UI whether the + // managed-credits gate should be bypassed, per chat-mode tier. The chat + // header's "Quick" mode runs on the `chat` tier and "Reasoning" mode on + // the `reasoning` tier, so each is reported separately and the UI checks + // the tier the user actually selected. True for a tier when it runs on a + // non-managed provider the user funds themselves (BYO key / local / + // claude-code) with usable creds. Managed tiers that run anyway surface + // credit errors per-call. + "credits_bypass": { + "chat": crate::openhuman::inference::provider::factory::role_bypasses_managed_credits( + "chat", config, + ), + "reasoning": + crate::openhuman::inference::provider::factory::role_bypasses_managed_credits( + "reasoning", config, + ), + }, "chat_provider": config.chat_provider, "reasoning_provider": config.reasoning_provider, "agentic_provider": config.agentic_provider, diff --git a/src/openhuman/inference/openai_oauth/flow_tests.rs b/src/openhuman/inference/openai_oauth/flow_tests.rs index aaea4df19..0f45fc7d5 100644 --- a/src/openhuman/inference/openai_oauth/flow_tests.rs +++ b/src/openhuman/inference/openai_oauth/flow_tests.rs @@ -602,6 +602,41 @@ fn lookup_openai_bearer_token_uses_oauth_when_api_key_missing() { assert_eq!(token.as_deref(), Some("oauth-access")); } +#[test] +fn credits_gate_bypassed_with_oauth_only_credentials() { + // #3767 regression: the per-tier credits-gate bypass chains through + // route_has_usable_credentials → lookup_key_for_slug, which falls back to + // the OpenAI OAuth token for the `openai` slug. Pin that OAuth-only + // credentials (no new-style provider key) bypass the gate when the chat tier + // is routed to a concrete OpenAI model. + use crate::openhuman::inference::provider::factory::role_bypasses_managed_credits; + + let tmp = tempdir().unwrap(); + let mut config = test_config(&tmp); + config.chat_provider = Some("openai:gpt-4o".into()); + + // No credential yet → gate stays on. + assert!(!role_bypasses_managed_credits("chat", &config)); + + let store = AuthProfilesStore::new(tmp.path(), false); + let oauth_profile = AuthProfile::new_oauth( + OPENAI_PROVIDER_KEY, + OPENAI_OAUTH_PROFILE_NAME, + TokenSet { + access_token: "oauth-access".into(), + refresh_token: Some("refresh".into()), + id_token: None, + expires_at: Some(Utc::now() + Duration::hours(1)), + token_type: Some("Bearer".into()), + scope: None, + }, + ); + store.upsert_profile(oauth_profile, true).unwrap(); + + // OAuth-only credential now backs the route → gate bypassed. + assert!(role_bypasses_managed_credits("chat", &config)); +} + #[test] fn lookup_key_for_slug_uses_legacy_openai_api_key_when_new_style_is_empty() { let tmp = tempdir().unwrap(); diff --git a/src/openhuman/inference/ops_tests.rs b/src/openhuman/inference/ops_tests.rs index 79abd073a..a176cf73d 100644 --- a/src/openhuman/inference/ops_tests.rs +++ b/src/openhuman/inference/ops_tests.rs @@ -161,6 +161,21 @@ async fn inference_get_client_config_returns_safe_snapshot() { .expect("client config snapshot"); assert!(outcome.value.get("cloud_providers").is_some()); assert!(outcome.value.get("api_key_set").is_some()); + // #3767: authoritative per-tier credits-gate bypass map is present and, with + // no BYO provider configured, every tier defaults to false (inference still + // bills managed credits). + let credits_bypass = outcome + .value + .get("credits_bypass") + .expect("credits_bypass present"); + assert_eq!( + credits_bypass.get("chat"), + Some(&serde_json::Value::Bool(false)) + ); + assert_eq!( + credits_bypass.get("reasoning"), + Some(&serde_json::Value::Bool(false)) + ); } #[tokio::test] diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index e207823e6..9d415d170 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -289,6 +289,77 @@ pub fn provider_for_role(role: &str, config: &Config) -> String { } } +/// #3767: Whether the OpenHuman managed-credits gate should be bypassed for a +/// single workload role. +/// +/// Returns true when `role` resolves (via [`provider_for_role`]) to a non-managed +/// provider the user funds themselves — a BYO cloud key (incl. OpenAI OAuth), a +/// local runtime, or claude-code — with usable credentials. When the role is on +/// the OpenHuman managed backend, or a BYO route has no usable key, it returns +/// false (the gate stays on; #3767: "BYO key present but invalid/unverified → +/// still gated"). +/// +/// The gate is evaluated per-tier so the UI can check the tier the user actually +/// selected: the chat header's "Quick" mode runs on the `chat` tier and +/// "Reasoning" mode on the `reasoning` tier, so each is checked respectively. +/// These per-role results are surfaced under `credits_bypass` in the +/// client-config snapshot. Tiers that stay managed and run anyway surface the +/// per-call `USER_INSUFFICIENT_CREDITS` (402) error reactively. +pub fn role_bypasses_managed_credits(role: &str, config: &Config) -> bool { + let resolved = provider_for_role(role, config); + let r = resolved.trim(); + let is_managed = + r.is_empty() || r == "cloud" || r == PROVIDER_OPENHUMAN || r == BYOK_INCOMPLETE_SENTINEL; + let usable_byo = !is_managed && route_has_usable_credentials(r, config); + log::debug!( + "[billing] role_bypasses_managed_credits role={role} resolved={resolved} \ + is_managed={is_managed} usable_byo={usable_byo}" + ); + usable_byo +} + +/// True when a resolved chat-tier provider string can actually run on the +/// user's own funding: local runtimes / claude-code carry their own creds; a +/// concrete cloud slug requires a non-empty stored key. Managed/sentinel +/// strings are filtered by the caller and never reach here as "usable". +fn route_has_usable_credentials(resolved: &str, config: &Config) -> bool { + let r = resolved.trim(); + // Local runtimes (ollama/lmstudio/mlx/local-openai) and the local CLI + // delegates carry their own credentials / run on-device. + if crate::openhuman::inference::local::profile::is_local_provider_string(r) + || r.starts_with(crate::openhuman::inference::provider::claude_code::PROVIDER_PREFIX) + || r == CLAUDE_AGENT_SDK_PROVIDER + || r.starts_with(CLAUDE_AGENT_SDK_PREFIX) + { + return true; + } + // Concrete cloud slug ":" — require a usable stored key. + if let Some((slug, _)) = r.split_once(':') { + let slug = slug.trim(); + if !slug.is_empty() { + // Don't silently swallow auth-store / OAuth lookup failures — a + // transient Err would otherwise keep the credits gate on for a + // valid BYO setup with no diagnostics. Log and treat as not-usable. + match lookup_key_for_slug(slug, config) { + Ok(key) => { + let usable = !key.trim().is_empty(); + log::debug!( + "[billing] route_has_usable_credentials slug={slug} usable={usable}" + ); + return usable; + } + Err(e) => { + log::debug!( + "[billing] route_has_usable_credentials slug={slug} lookup_error={e}" + ); + return false; + } + } + } + } + false +} + /// Find the first BYOK cloud provider string configured across all workload /// routes, skipping local providers (ollama, lmstudio) and managed-backend /// sentinels ("openhuman", "cloud", empty). diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index e8a133221..777494407 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -1927,3 +1927,141 @@ fn resolve_model_for_hint_handles_unknown_hint_passthrough() { let result = resolve_model_for_hint("hint:unknown_tier", &config); assert_eq!(result, "hint:unknown_tier"); } + +// ── #3767: managed-credits gate bypass (gate-only, per-tier) ─────────────── +// +// Routing is NOT changed by this fix — selecting a BYO provider already routes +// inference correctly. The gate is evaluated PER TIER so the UI checks whichever +// tier the user actually selected: the chat header's "Quick" mode runs on the +// `chat` tier and "Reasoning" mode on the `reasoning` tier. `role_bypasses_ +// managed_credits(role)` is true when that role runs on the user's own funding +// (a BYO cloud key, a local runtime, or claude-code) with usable credentials. +// Tiers that stay managed and run anyway surface the per-call 402 error. + +/// Store a usable provider key under the new-style `provider:` profile so +/// `lookup_key_for_slug` resolves it. +fn store_byo_key(config: &Config, slug: &str, token: &str) { + let auth = AuthService::from_config(config); + auth.store_provider_token( + &format!("provider:{slug}"), + "default", + token, + Default::default(), + true, + ) + .expect("store provider token"); +} + +#[test] +fn byo_chat_tier_with_key_bypasses() { + let tmp = TempDir::new().expect("tempdir"); + // Quick mode runs on `chat`; routed to the user's own OpenAI provider + key. + let mut config = config_with_providers_in_tempdir(&tmp, vec![openai_entry("p_oai", "openai")]); + config.chat_provider = Some("openai:gpt-4o".to_string()); + store_byo_key(&config, "openai", "sk-byo-test"); + + assert!(role_bypasses_managed_credits("chat", &config)); +} + +#[test] +fn byo_reasoning_tier_with_key_bypasses() { + let tmp = TempDir::new().expect("tempdir"); + // Reasoning mode runs on `reasoning`; routed to the user's own provider + key. + let mut config = config_with_providers_in_tempdir(&tmp, vec![openai_entry("p_oai", "openai")]); + config.reasoning_provider = Some("openai:gpt-4o".to_string()); + store_byo_key(&config, "openai", "sk-byo-test"); + + assert!(role_bypasses_managed_credits("reasoning", &config)); +} + +#[test] +fn per_tier_diverges_chat_byo_reasoning_managed() { + let tmp = TempDir::new().expect("tempdir"); + // The crux of the per-tier check: chat on BYOK, reasoning explicitly managed. + // Quick mode (chat) bypasses; Reasoning mode (reasoning) stays gated. + let mut config = config_with_providers_in_tempdir(&tmp, vec![openai_entry("p_oai", "openai")]); + config.chat_provider = Some("openai:gpt-4o".to_string()); + config.reasoning_provider = Some("openhuman".to_string()); + store_byo_key(&config, "openai", "sk-byo-test"); + + assert!(role_bypasses_managed_credits("chat", &config)); + assert!(!role_bypasses_managed_credits("reasoning", &config)); +} + +#[test] +fn local_tier_bypasses_without_any_key() { + // A tier on a local on-device runtime → bypass, no cloud key needed. + let mut config = Config::default(); + config.chat_provider = Some("ollama:qwen3:8b".to_string()); + assert!(role_bypasses_managed_credits("chat", &config)); +} + +#[test] +fn managed_chat_with_byo_agentic_stays_gated() { + let tmp = TempDir::new().expect("tempdir"); + // chat explicitly managed; only tool-use (agentic) is BYOK. The chat tier + // still bills managed credits → chat role stays gated. (agentic itself is a + // BYO route, but it is not a chat-mode tier and surfaces errors per-call.) + let mut config = config_with_providers_in_tempdir(&tmp, vec![openai_entry("p_oai", "openai")]); + config.chat_provider = Some("openhuman".to_string()); + config.reasoning_provider = Some("openhuman".to_string()); + config.agentic_provider = Some("openai:gpt-4o".to_string()); + store_byo_key(&config, "openai", "sk-byo-test"); + + assert!(!role_bypasses_managed_credits("chat", &config)); + assert!(!role_bypasses_managed_credits("reasoning", &config)); +} + +#[test] +fn managed_chat_with_byo_vision_stays_gated() { + let tmp = TempDir::new().expect("tempdir"); + // Vision on BYOK but the chat-mode tiers stay managed → chat/reasoning gated. + let mut config = config_with_providers_in_tempdir(&tmp, vec![openai_entry("p_oai", "openai")]); + config.chat_provider = Some("openhuman".to_string()); + config.reasoning_provider = Some("openhuman".to_string()); + config.vision_provider = Some("openai:gpt-4o".to_string()); + store_byo_key(&config, "openai", "sk-byo-test"); + + assert!(!role_bypasses_managed_credits("chat", &config)); + assert!(!role_bypasses_managed_credits("reasoning", &config)); +} + +#[test] +fn no_byo_provider_stays_gated() { + let tmp = TempDir::new().expect("tempdir"); + // OpenAI entry exists but every tier is left on the managed default and no + // key is stored → chat-mode tiers managed → must NOT bypass. + let config = config_with_providers_in_tempdir(&tmp, vec![openai_entry("p_oai", "openai")]); + + assert_eq!(provider_for_role("chat", &config), "openhuman"); + assert!(!role_bypasses_managed_credits("chat", &config)); + assert!(!role_bypasses_managed_credits("reasoning", &config)); +} + +#[test] +fn default_config_with_no_key_stays_gated() { + // No BYO provider at all → both chat-mode tiers gated. + let config = Config::default(); + assert!(!role_bypasses_managed_credits("chat", &config)); + assert!(!role_bypasses_managed_credits("reasoning", &config)); +} + +#[test] +fn byo_route_without_usable_key_stays_gated() { + let tmp = TempDir::new().expect("tempdir"); + // chat tier points at a BYO slug with NO stored key — the route would fail + // with an auth error, not bill managed credits, but we must not bypass for a + // route that cannot run on the user's dime (#3767: "BYO key present but + // invalid/unverified → still gated"). + let mut config = config_with_providers_in_tempdir(&tmp, vec![openai_entry("p_oai", "openai")]); + config.chat_provider = Some("openai:gpt-4o".to_string()); + + // The explicit route is still honored verbatim by provider_for_role… + assert_eq!(provider_for_role("chat", &config), "openai:gpt-4o"); + // …but with no usable key the gate stays on. + assert!(!role_bypasses_managed_credits("chat", &config)); + + // Once a key is stored, the route becomes a genuine bypass. + store_byo_key(&config, "openai", "sk-byo-test"); + assert!(role_bypasses_managed_credits("chat", &config)); +}