fix(billing): gate credits per selected chat-mode tier (BYO) (#3767) (#3775)

This commit is contained in:
sanil-23
2026-06-22 16:08:46 +05:30
committed by GitHub
parent 6a6bcdcd57
commit d4db739b22
11 changed files with 445 additions and 4 deletions
+73
View File
@@ -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');
+38 -2
View File
@@ -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,
+4 -1
View File
@@ -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,
};
}
+24 -1
View File
@@ -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 ───────────────────────────────────────────────
+9
View File
@@ -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;
+17
View File
@@ -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();
+15
View File
@@ -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));
}