mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 21:44:38 +00:00
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
@@ -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<TeamUsage | null>(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,
|
||||
|
||||
@@ -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<ConfirmationModalType>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
|
||||
@@ -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:<slug> profile is stored', async () => {
|
||||
mockOpenhumanGetClientConfig.mockResolvedValue(
|
||||
makeClientConfigResult({
|
||||
@@ -592,6 +612,7 @@ describe('saveAISettings', () => {
|
||||
subconscious: { kind: 'openhuman' },
|
||||
},
|
||||
modelRegistry: [],
|
||||
creditsBypass: { chat: false, reasoning: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<AISettings> {
|
||||
// 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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 "<slug>:<model>" — 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).
|
||||
|
||||
@@ -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:<slug>` 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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user