lewtun HF Staff OpenAI Codex commited on
Commit
ded9881
·
unverified ·
1 Parent(s): cbf5d33

Implement user-billed model policy (#289)

Browse files

* Implement user-billed model policy

- bill hosted inference to user tokens and remove subsidy quota state

- switch web and CLI defaults and remove Sonnet from model lists

- add plan-aware inference credit CTAs

Co-authored-by: OpenAI Codex <codex@openai.com>

* Recommend Opus in model picker

Co-authored-by: OpenAI Codex <codex@openai.com>

* Remove minimum plan from model config

Co-authored-by: OpenAI Codex <codex@openai.com>

* Link message failures to Space discussions

Co-authored-by: OpenAI Codex <codex@openai.com>

* Remove compatibility quota endpoint

Co-authored-by: OpenAI Codex <codex@openai.com>

* Remove obsolete bill_to_user argument

Co-authored-by: OpenAI Codex <codex@openai.com>

* Remove model tier metadata

Co-authored-by: OpenAI Codex <codex@openai.com>

* Remove model picker provider subtitles

Co-authored-by: OpenAI Codex <codex@openai.com>

---------

Co-authored-by: OpenAI Codex <codex@openai.com>

Files changed (41) hide show
  1. AGENTS.md +1 -1
  2. README.md +5 -4
  3. agent/context_manager/manager.py +4 -5
  4. agent/core/agent_loop.py +3 -5
  5. agent/core/effort_probe.py +0 -1
  6. agent/core/hf_tokens.py +3 -11
  7. agent/core/llm_params.py +4 -23
  8. agent/core/model_ids.py +3 -23
  9. agent/core/model_switcher.py +0 -2
  10. agent/core/session.py +0 -5
  11. agent/core/session_persistence.py +1 -61
  12. agent/main.py +1 -1
  13. agent/tools/research_tool.py +0 -1
  14. backend/dependencies.py +4 -4
  15. backend/main.py +2 -2
  16. backend/models.py +0 -2
  17. backend/routes/agent.py +26 -180
  18. backend/session_manager.py +6 -62
  19. backend/user_quotas.py +0 -135
  20. configs/cli_agent_config.json +1 -1
  21. configs/frontend_agent_config.json +1 -1
  22. frontend/src/components/Chat/BillingBanner.tsx +0 -81
  23. frontend/src/components/Chat/ChatErrorBanner.tsx +66 -4
  24. frontend/src/components/Chat/ChatInput.tsx +13 -88
  25. frontend/src/components/Layout/AppLayout.tsx +44 -0
  26. frontend/src/components/SessionChat.tsx +0 -46
  27. frontend/src/hooks/useAuth.ts +3 -2
  28. frontend/src/hooks/useUserQuota.ts +0 -51
  29. frontend/src/store/agentStore.ts +1 -1
  30. frontend/src/store/sessionStore.ts +2 -12
  31. frontend/src/types/agent.ts +1 -2
  32. frontend/src/utils/inferenceBilling.ts +40 -0
  33. frontend/src/utils/model.ts +1 -21
  34. tests/integration/test_live_thinking_models.py +0 -1
  35. tests/unit/test_agent_model_gating.py +40 -382
  36. tests/unit/test_cli_local_models.py +8 -1
  37. tests/unit/test_llm_params.py +18 -59
  38. tests/unit/test_prompt_caching.py +2 -2
  39. tests/unit/test_session_manager_persistence.py +3 -71
  40. tests/unit/test_session_persistence.py +0 -1
  41. tests/unit/test_user_quotas.py +0 -141
AGENTS.md CHANGED
@@ -13,7 +13,7 @@ Notes:
13
  - Vite proxies `/api` and `/auth` to `http://localhost:7860`.
14
  - If `127.0.0.1:7860` is already owned by another local process, binding the backend to `::1` lets the Vite proxy resolve `localhost` cleanly.
15
  - Prefer `npm ci` over `npm install` for setup, since `npm install` may rewrite `frontend/package-lock.json` metadata depending on npm version.
16
- - Production defaults to Claude Sonnet 4.6 through the HF Router (`anthropic/claude-sonnet-4-6:fal-ai`). Non-local LLM calls use `https://router.huggingface.co/v1` with Hugging Face tokens. Subsidized premium calls use the Space/operator `INFERENCE_TOKEN` plus `X-HF-Bill-To` from `HF_BILL_TO` (default `smolagents`); Opus and GPT-5.5 daily sessions are Pro-only, and after the daily allowance, premium calls bill the user's own HF token. For local development, set `HF_TOKEN` and optionally `ML_INTERN_DEFAULT_MODEL_ID`.
17
 
18
  ## Development Checks
19
 
 
13
  - Vite proxies `/api` and `/auth` to `http://localhost:7860`.
14
  - If `127.0.0.1:7860` is already owned by another local process, binding the backend to `::1` lets the Vite proxy resolve `localhost` cleanly.
15
  - Prefer `npm ci` over `npm install` for setup, since `npm install` may rewrite `frontend/package-lock.json` metadata depending on npm version.
16
+ - Non-local LLM calls use `https://router.huggingface.co/v1` with the active Hugging Face user's token. Web sessions default to Kimi K2.6 for free users and Claude Opus 4.8 for Pro users; the CLI default is Claude Opus 4.8. For local development, set `HF_TOKEN` and optionally `ML_INTERN_DEFAULT_MODEL_ID`.
17
 
18
  ## Development Checks
19
 
README.md CHANGED
@@ -54,7 +54,7 @@ ml-intern "fine-tune llama on my dataset"
54
  **Options:**
55
 
56
  ```bash
57
- ml-intern --model anthropic/claude-sonnet-4-6:fal-ai "your prompt"
58
  ml-intern --model moonshotai/Kimi-K2.6 "your prompt"
59
  ml-intern --model openai/gpt-5.5:fal-ai "your prompt"
60
  ml-intern --model ollama/llama3.1:8b "your prompt"
@@ -68,8 +68,9 @@ Run `ml-intern` then `/model` to see the full list of suggested model ids
68
  (Claude, GPT, HF Router models like MiniMax, Kimi, GLM, DeepSeek, and local
69
  model prefixes).
70
 
71
- In the web app, subsidized daily sessions for Claude Opus 4.8 and GPT-5.5 are
72
- available only to HF Pro users; Claude Sonnet 4.6 is the default premium model.
 
73
 
74
  #### Local models
75
 
@@ -386,7 +387,7 @@ Edit `configs/cli_agent_config.json` for CLI defaults, or
386
 
387
  ```json
388
  {
389
- "model_name": "anthropic/claude-sonnet-4-6:fal-ai",
390
  "mcpServers": {
391
  "your-server-name": {
392
  "transport": "http",
 
54
  **Options:**
55
 
56
  ```bash
57
+ ml-intern --model anthropic/claude-opus-4.8:fal-ai "your prompt"
58
  ml-intern --model moonshotai/Kimi-K2.6 "your prompt"
59
  ml-intern --model openai/gpt-5.5:fal-ai "your prompt"
60
  ml-intern --model ollama/llama3.1:8b "your prompt"
 
68
  (Claude, GPT, HF Router models like MiniMax, Kimi, GLM, DeepSeek, and local
69
  model prefixes).
70
 
71
+ Hosted inference is billed to the active Hugging Face user. In the web app,
72
+ free users start on Kimi K2.6 and HF Pro users start on Claude Opus 4.8; the
73
+ CLI default is Claude Opus 4.8.
74
 
75
  #### Local models
76
 
 
387
 
388
  ```json
389
  {
390
+ "model_name": "anthropic/claude-opus-4.8:fal-ai",
391
  "mcpServers": {
392
  "your-server-name": {
393
  "transport": "http",
agent/context_manager/manager.py CHANGED
@@ -92,7 +92,7 @@ class CompactionFailedError(Exception):
92
  Typically means an individual preserved message (system, first user, or
93
  untouched tail) exceeds what truncation can fix in one pass. The caller
94
  must terminate the session; retrying produces an infinite loop that burns
95
- premium inference budget.
96
  """
97
 
98
 
@@ -133,7 +133,7 @@ async def summarize_messages(
133
  ``session`` is optional; when provided, the call is recorded via
134
  ``telemetry.record_llm_call`` so its cost lands in the session's
135
  ``total_cost_usd``. Without it, the call still happens but is
136
- invisible in telemetry, which used to hide a significant share of premium
137
  inference spend.
138
 
139
  Returns ``(summary_text, completion_tokens)``.
@@ -145,7 +145,6 @@ async def summarize_messages(
145
  model_name,
146
  hf_token,
147
  reasoning_effort="high",
148
- bill_to_user=getattr(session, "premium_user_billed", False),
149
  )
150
  llm_params = with_prompt_cache_params(
151
  llm_params, session_id=getattr(session, "session_id", None)
@@ -527,7 +526,7 @@ class ContextManager:
527
  a giant tool output stuck in the untouched tail) is too large for
528
  truncation to fix. The caller must terminate the session — retrying
529
  is what caused the 2026-05-03 infinite-compaction-loop pattern that
530
- burned premium inference budget invisibly.
531
  """
532
  if not self.needs_compaction:
533
  return
@@ -617,7 +616,7 @@ class ContextManager:
617
 
618
  # Hard verify: if compaction didn't bring us below the threshold even
619
  # after truncating oversized preserved messages, retrying just burns
620
- # premium inference budget on the same useless compaction call. Raise so the
621
  # caller can terminate the session cleanly. Pre-2026-05-04, the
622
  # caller looped indefinitely (~$3/Opus retry) until the pod was
623
  # killed — invisible to the dataset because the session never
 
92
  Typically means an individual preserved message (system, first user, or
93
  untouched tail) exceeds what truncation can fix in one pass. The caller
94
  must terminate the session; retrying produces an infinite loop that burns
95
+ hosted inference budget.
96
  """
97
 
98
 
 
133
  ``session`` is optional; when provided, the call is recorded via
134
  ``telemetry.record_llm_call`` so its cost lands in the session's
135
  ``total_cost_usd``. Without it, the call still happens but is
136
+ invisible in telemetry, which used to hide a significant share of hosted
137
  inference spend.
138
 
139
  Returns ``(summary_text, completion_tokens)``.
 
145
  model_name,
146
  hf_token,
147
  reasoning_effort="high",
 
148
  )
149
  llm_params = with_prompt_cache_params(
150
  llm_params, session_id=getattr(session, "session_id", None)
 
526
  a giant tool output stuck in the untouched tail) is too large for
527
  truncation to fix. The caller must terminate the session — retrying
528
  is what caused the 2026-05-03 infinite-compaction-loop pattern that
529
+ burned hosted inference budget invisibly.
530
  """
531
  if not self.needs_compaction:
532
  return
 
616
 
617
  # Hard verify: if compaction didn't bring us below the threshold even
618
  # after truncating oversized preserved messages, retrying just burns
619
+ # hosted inference budget on the same useless compaction call. Raise so the
620
  # caller can terminate the session cleanly. Pre-2026-05-04, the
621
  # caller looped indefinitely (~$3/Opus retry) until the pod was
622
  # killed — invisible to the dataset because the session never
agent/core/agent_loop.py CHANGED
@@ -540,7 +540,6 @@ async def _heal_effort_and_rebuild_params(
540
  model,
541
  session.hf_token,
542
  reasoning_effort=session.effective_effort_for(model),
543
- bill_to_user=getattr(session, "premium_user_billed", False),
544
  )
545
 
546
 
@@ -562,8 +561,8 @@ def _friendly_error_message(error: Exception) -> str | None:
562
 
563
  if "insufficient" in err_str and "credit" in err_str:
564
  return (
565
- "Insufficient API credits. Please check your account balance "
566
- "at your model provider's dashboard."
567
  )
568
 
569
  if "not supported by provider" in err_str or "no provider supports" in err_str:
@@ -591,7 +590,7 @@ async def _compact_and_notify(session: Session) -> None:
591
 
592
  Catches ``CompactionFailedError`` and ends the session cleanly instead
593
  of letting the caller retry. Pre-2026-05-04 the caller looped on
594
- ContextWindowExceededError → compact → re-trigger, burning premium
595
  inference budget while the session never reached the upload path.
596
  """
597
  from agent.context_manager.manager import CompactionFailedError
@@ -1207,7 +1206,6 @@ class Handlers:
1207
  reasoning_effort=session.effective_effort_for(
1208
  session.config.model_name
1209
  ),
1210
- bill_to_user=getattr(session, "premium_user_billed", False),
1211
  )
1212
  if session.stream:
1213
  llm_result = await _call_llm_streaming(
 
540
  model,
541
  session.hf_token,
542
  reasoning_effort=session.effective_effort_for(model),
 
543
  )
544
 
545
 
 
561
 
562
  if "insufficient" in err_str and "credit" in err_str:
563
  return (
564
+ "Insufficient Hugging Face Inference Providers credits. Add credits "
565
+ "or upgrade your HF account to continue."
566
  )
567
 
568
  if "not supported by provider" in err_str or "no provider supports" in err_str:
 
590
 
591
  Catches ``CompactionFailedError`` and ends the session cleanly instead
592
  of letting the caller retry. Pre-2026-05-04 the caller looped on
593
+ ContextWindowExceededError → compact → re-trigger, burning hosted
594
  inference budget while the session never reached the upload path.
595
  """
596
  from agent.context_manager.manager import CompactionFailedError
 
1206
  reasoning_effort=session.effective_effort_for(
1207
  session.config.model_name
1208
  ),
 
1209
  )
1210
  if session.stream:
1211
  llm_result = await _call_llm_streaming(
agent/core/effort_probe.py CHANGED
@@ -188,7 +188,6 @@ async def probe_effort(
188
  hf_token,
189
  reasoning_effort=effort,
190
  strict=True,
191
- bill_to_user=getattr(session, "premium_user_billed", False),
192
  )
193
  except UnsupportedEffortError:
194
  # Provider can't even accept this effort name (e.g. "max" on
 
188
  hf_token,
189
  reasoning_effort=effort,
190
  strict=True,
 
191
  )
192
  except UnsupportedEffortError:
193
  # Provider can't even accept this effort name (e.g. "max" on
agent/core/hf_tokens.py CHANGED
@@ -41,19 +41,11 @@ def resolve_hf_router_token(session_hf_token: str | None = None) -> str | None:
41
  """Resolve the token used for Hugging Face Router LLM calls.
42
 
43
  App-specific precedence:
44
- 1. INFERENCE_TOKEN: shared hosted-Space inference token.
45
- 2. session_hf_token: the active user/session token.
46
- 3. huggingface_hub.get_token(): HF_TOKEN/HUGGING_FACE_HUB_TOKEN or
47
  local ``hf auth login`` cache.
48
  """
49
- return resolve_hf_token(os.environ.get("INFERENCE_TOKEN"), session_hf_token)
50
-
51
-
52
- def get_hf_bill_to() -> str | None:
53
- """Return X-HF-Bill-To only when a shared inference token is active."""
54
- if clean_hf_token(os.environ.get("INFERENCE_TOKEN")):
55
- return os.environ.get("HF_BILL_TO", "smolagents")
56
- return None
57
 
58
 
59
  def bearer_token_from_header(auth_header: str | None) -> str | None:
 
41
  """Resolve the token used for Hugging Face Router LLM calls.
42
 
43
  App-specific precedence:
44
+ 1. session_hf_token: the active user/session token.
45
+ 2. huggingface_hub.get_token(): HF_TOKEN/HUGGING_FACE_HUB_TOKEN or
 
46
  local ``hf auth login`` cache.
47
  """
48
+ return resolve_hf_token(session_hf_token)
 
 
 
 
 
 
 
49
 
50
 
51
  def bearer_token_from_header(auth_header: str | None) -> str | None:
agent/core/llm_params.py CHANGED
@@ -7,11 +7,7 @@ creating circular imports.
7
 
8
  import os
9
 
10
- from agent.core.hf_tokens import (
11
- get_hf_bill_to,
12
- resolve_hf_router_token,
13
- resolve_hf_token,
14
- )
15
  from agent.core.local_models import (
16
  LOCAL_MODEL_API_KEY_DEFAULT,
17
  LOCAL_MODEL_API_KEY_ENV,
@@ -22,7 +18,6 @@ from agent.core.local_models import (
22
  )
23
  from agent.core.model_ids import (
24
  HF_ROUTER_BASE_URL,
25
- is_premium_model_id,
26
  strip_huggingface_model_prefix,
27
  )
28
 
@@ -97,7 +92,6 @@ def _resolve_llm_params(
97
  session_hf_token: str | None = None,
98
  reasoning_effort: str | None = None,
99
  strict: bool = False,
100
- bill_to_user: bool = False,
101
  ) -> dict:
102
  """
103
  Build LiteLLM kwargs for a given model id.
@@ -123,15 +117,9 @@ def _resolve_llm_params(
123
  can't crash a turn — it just doesn't get sent.
124
 
125
  Token precedence for HF-router calls (first non-empty wins):
126
- 1. INFERENCE_TOKEN env shared key on the hosted Space (inference is
127
- free for users, billed to the Space owner via ``X-HF-Bill-To``).
128
- 2. session.hf_token — the user's own token (CLI / OAuth / cache file).
129
- 3. huggingface_hub cache — ``HF_TOKEN`` / ``HUGGING_FACE_HUB_TOKEN`` /
130
  local ``hf auth login`` cache.
131
-
132
- Pass ``bill_to_user=True`` only after the daily subsidized allowance is
133
- spent. Premium router ids then use the caller's own token, skip
134
- ``INFERENCE_TOKEN``, and omit ``X-HF-Bill-To``.
135
  """
136
  normalized_model = strip_huggingface_model_prefix(model_name) or model_name
137
 
@@ -142,19 +130,12 @@ def _resolve_llm_params(
142
  return _resolve_local_model_params(normalized_model, reasoning_effort, strict)
143
 
144
  hf_model = normalized_model
145
- bill_user = bill_to_user and is_premium_model_id(hf_model)
146
- api_key = (
147
- resolve_hf_token(session_hf_token, include_cached=False)
148
- if bill_user
149
- else _resolve_hf_router_token(session_hf_token)
150
- )
151
  params = {
152
  "model": f"openai/{hf_model}",
153
  "api_base": HF_ROUTER_BASE_URL,
154
  "api_key": api_key,
155
  }
156
- if not bill_user and (bill_to := get_hf_bill_to()):
157
- params["extra_headers"] = {"X-HF-Bill-To": bill_to}
158
  if reasoning_effort:
159
  hf_level = _hf_router_effort_level(reasoning_effort)
160
  if hf_level not in _HF_EFFORTS:
 
7
 
8
  import os
9
 
10
+ from agent.core.hf_tokens import resolve_hf_router_token
 
 
 
 
11
  from agent.core.local_models import (
12
  LOCAL_MODEL_API_KEY_DEFAULT,
13
  LOCAL_MODEL_API_KEY_ENV,
 
18
  )
19
  from agent.core.model_ids import (
20
  HF_ROUTER_BASE_URL,
 
21
  strip_huggingface_model_prefix,
22
  )
23
 
 
92
  session_hf_token: str | None = None,
93
  reasoning_effort: str | None = None,
94
  strict: bool = False,
 
95
  ) -> dict:
96
  """
97
  Build LiteLLM kwargs for a given model id.
 
117
  can't crash a turn — it just doesn't get sent.
118
 
119
  Token precedence for HF-router calls (first non-empty wins):
120
+ 1. session.hf_tokenthe user's own token (CLI / OAuth / cache file).
121
+ 2. huggingface_hub cache ``HF_TOKEN`` / ``HUGGING_FACE_HUB_TOKEN`` /
 
 
122
  local ``hf auth login`` cache.
 
 
 
 
123
  """
124
  normalized_model = strip_huggingface_model_prefix(model_name) or model_name
125
 
 
130
  return _resolve_local_model_params(normalized_model, reasoning_effort, strict)
131
 
132
  hf_model = normalized_model
133
+ api_key = _resolve_hf_router_token(session_hf_token)
 
 
 
 
 
134
  params = {
135
  "model": f"openai/{hf_model}",
136
  "api_base": HF_ROUTER_BASE_URL,
137
  "api_key": api_key,
138
  }
 
 
139
  if reasoning_effort:
140
  hf_level = _hf_router_effort_level(reasoning_effort)
141
  if hf_level not in _HF_EFFORTS:
agent/core/model_ids.py CHANGED
@@ -4,27 +4,17 @@ HF_ROUTER_BASE_URL = "https://router.huggingface.co/v1"
4
 
5
  # Keep these as verbatim HF Router ids; version punctuation differs by model.
6
  CLAUDE_OPUS_48_MODEL_ID = "anthropic/claude-opus-4.8:fal-ai"
7
- CLAUDE_SONNET_46_MODEL_ID = "anthropic/claude-sonnet-4-6:fal-ai"
8
  GPT_55_MODEL_ID = "openai/gpt-5.5:fal-ai"
9
  KIMI_K26_MODEL_ID = "moonshotai/Kimi-K2.6"
10
  MINIMAX_M27_MODEL_ID = "MiniMaxAI/MiniMax-M2.7"
11
  GLM_51_MODEL_ID = "zai-org/GLM-5.1"
12
  DEEPSEEK_V4_PRO_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:deepinfra"
13
 
14
- DEFAULT_MODEL_ID = CLAUDE_SONNET_46_MODEL_ID
15
 
16
- PREMIUM_MODEL_IDS = {
17
- CLAUDE_SONNET_46_MODEL_ID,
18
  CLAUDE_OPUS_48_MODEL_ID,
19
  GPT_55_MODEL_ID,
20
- }
21
-
22
- PRO_ONLY_PREMIUM_MODEL_IDS = {
23
- CLAUDE_OPUS_48_MODEL_ID,
24
- GPT_55_MODEL_ID,
25
- }
26
-
27
- KNOWN_ROUTER_MODEL_IDS = PREMIUM_MODEL_IDS | {
28
  KIMI_K26_MODEL_ID,
29
  MINIMAX_M27_MODEL_ID,
30
  GLM_51_MODEL_ID,
@@ -39,16 +29,6 @@ def strip_huggingface_model_prefix(model_id: str | None) -> str | None:
39
  return model_id.removeprefix("huggingface/")
40
 
41
 
42
- def is_premium_model_id(model_id: str | None) -> bool:
43
- normalized = strip_huggingface_model_prefix(model_id)
44
- return bool(normalized and normalized in PREMIUM_MODEL_IDS)
45
-
46
-
47
- def is_pro_only_premium_model_id(model_id: str | None) -> bool:
48
- normalized = strip_huggingface_model_prefix(model_id)
49
- return bool(normalized and normalized in PRO_ONLY_PREMIUM_MODEL_IDS)
50
-
51
-
52
  def is_known_router_model_id(model_id: str | None) -> bool:
53
  normalized = strip_huggingface_model_prefix(model_id)
54
- return bool(normalized and normalized in KNOWN_ROUTER_MODEL_IDS)
 
4
 
5
  # Keep these as verbatim HF Router ids; version punctuation differs by model.
6
  CLAUDE_OPUS_48_MODEL_ID = "anthropic/claude-opus-4.8:fal-ai"
 
7
  GPT_55_MODEL_ID = "openai/gpt-5.5:fal-ai"
8
  KIMI_K26_MODEL_ID = "moonshotai/Kimi-K2.6"
9
  MINIMAX_M27_MODEL_ID = "MiniMaxAI/MiniMax-M2.7"
10
  GLM_51_MODEL_ID = "zai-org/GLM-5.1"
11
  DEEPSEEK_V4_PRO_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:deepinfra"
12
 
13
+ DEFAULT_MODEL_ID = CLAUDE_OPUS_48_MODEL_ID
14
 
15
+ HOSTED_MODEL_IDS = {
 
16
  CLAUDE_OPUS_48_MODEL_ID,
17
  GPT_55_MODEL_ID,
 
 
 
 
 
 
 
 
18
  KIMI_K26_MODEL_ID,
19
  MINIMAX_M27_MODEL_ID,
20
  GLM_51_MODEL_ID,
 
29
  return model_id.removeprefix("huggingface/")
30
 
31
 
 
 
 
 
 
 
 
 
 
 
32
  def is_known_router_model_id(model_id: str | None) -> bool:
33
  normalized = strip_huggingface_model_prefix(model_id)
34
+ return bool(normalized and normalized in HOSTED_MODEL_IDS)
agent/core/model_switcher.py CHANGED
@@ -28,7 +28,6 @@ from agent.core.local_models import (
28
  )
29
  from agent.core.model_ids import (
30
  CLAUDE_OPUS_48_MODEL_ID,
31
- CLAUDE_SONNET_46_MODEL_ID,
32
  GPT_55_MODEL_ID,
33
  KIMI_K26_MODEL_ID,
34
  strip_huggingface_model_prefix,
@@ -40,7 +39,6 @@ from agent.core.model_ids import (
40
  # ":cheapest", ":preferred", or ":<provider>" to override the default routing
41
  # policy (auto = fastest with failover).
42
  SUGGESTED_MODELS = [
43
- {"id": CLAUDE_SONNET_46_MODEL_ID, "label": "Claude Sonnet 4.6"},
44
  {"id": CLAUDE_OPUS_48_MODEL_ID, "label": "Claude Opus 4.8"},
45
  {"id": GPT_55_MODEL_ID, "label": "GPT-5.5"},
46
  {"id": "MiniMaxAI/MiniMax-M2.7", "label": "MiniMax M2.7"},
 
28
  )
29
  from agent.core.model_ids import (
30
  CLAUDE_OPUS_48_MODEL_ID,
 
31
  GPT_55_MODEL_ID,
32
  KIMI_K26_MODEL_ID,
33
  strip_huggingface_model_prefix,
 
39
  # ":cheapest", ":preferred", or ":<provider>" to override the default routing
40
  # policy (auto = fastest with failover).
41
  SUGGESTED_MODELS = [
 
42
  {"id": CLAUDE_OPUS_48_MODEL_ID, "label": "Claude Opus 4.8"},
43
  {"id": GPT_55_MODEL_ID, "label": "GPT-5.5"},
44
  {"id": "MiniMaxAI/MiniMax-M2.7", "label": "MiniMax M2.7"},
agent/core/session.py CHANGED
@@ -119,11 +119,6 @@ class Session:
119
  self.session_id = session_id or str(uuid.uuid4())
120
  self.config = config
121
  self.is_running = True
122
- # Billing mode for premium HF Router usage. The backend quota gate
123
- # flips this on once the user is past their subsidized daily allowance,
124
- # so the LLM call bills the user's own HF token instead of the Space.
125
- # Persisted with the session so it survives idle-reclaim.
126
- self.premium_user_billed: bool = False
127
  self.current_plan: list[dict[str, str]] = []
128
  self._cancelled = asyncio.Event()
129
  self.pending_approval: Optional[dict[str, Any]] = None
 
119
  self.session_id = session_id or str(uuid.uuid4())
120
  self.config = config
121
  self.is_running = True
 
 
 
 
 
122
  self.current_plan: list[dict[str, str]] = []
123
  self._cancelled = asyncio.Event()
124
  self.pending_approval: Optional[dict[str, Any]] = None
agent/core/session_persistence.py CHANGED
@@ -14,7 +14,7 @@ from typing import Any
14
 
15
  from bson import BSON
16
  from pymongo import AsyncMongoClient, DeleteMany, ReturnDocument, UpdateOne
17
- from pymongo.errors import DuplicateKeyError, InvalidDocument, PyMongoError
18
 
19
  logger = logging.getLogger(__name__)
20
 
@@ -89,15 +89,6 @@ class NoopSessionStore:
89
  async def append_trace_message(self, *_: Any, **__: Any) -> int | None:
90
  return None
91
 
92
- async def get_quota(self, *_: Any, **__: Any) -> int | None:
93
- return None
94
-
95
- async def try_increment_quota(self, *_: Any, **__: Any) -> int | None:
96
- return None
97
-
98
- async def refund_quota(self, *_: Any, **__: Any) -> None:
99
- return None
100
-
101
  async def mark_pro_seen(self, *_: Any, **__: Any) -> dict[str, Any] | None:
102
  return None
103
 
@@ -174,9 +165,6 @@ class MongoSessionStore(NoopSessionStore):
174
  message_count: int = 0,
175
  turn_count: int = 0,
176
  pending_approval: list[dict[str, Any]] | None = None,
177
- claude_counted: bool = False,
178
- claude_counted_day: str | None = None,
179
- premium_user_billed: bool = False,
180
  notification_destinations: list[str] | None = None,
181
  auto_approval_enabled: bool = False,
182
  auto_approval_cost_cap_usd: float | None = None,
@@ -207,9 +195,6 @@ class MongoSessionStore(NoopSessionStore):
207
  "message_count": message_count,
208
  "turn_count": turn_count,
209
  "pending_approval": pending_approval or [],
210
- "claude_counted": claude_counted,
211
- "claude_counted_day": claude_counted_day,
212
- "premium_user_billed": premium_user_billed,
213
  "notification_destinations": notification_destinations or [],
214
  "auto_approval_enabled": auto_approval_enabled,
215
  "auto_approval_cost_cap_usd": auto_approval_cost_cap_usd,
@@ -231,9 +216,6 @@ class MongoSessionStore(NoopSessionStore):
231
  status: str = "active",
232
  turn_count: int = 0,
233
  pending_approval: list[dict[str, Any]] | None = None,
234
- claude_counted: bool = False,
235
- claude_counted_day: str | None = None,
236
- premium_user_billed: bool = False,
237
  created_at: datetime | None = None,
238
  notification_destinations: list[str] | None = None,
239
  auto_approval_enabled: bool = False,
@@ -257,9 +239,6 @@ class MongoSessionStore(NoopSessionStore):
257
  message_count=len(messages),
258
  turn_count=turn_count,
259
  pending_approval=pending_approval,
260
- claude_counted=claude_counted,
261
- claude_counted_day=claude_counted_day,
262
- premium_user_billed=premium_user_billed,
263
  notification_destinations=notification_destinations,
264
  auto_approval_enabled=auto_approval_enabled,
265
  auto_approval_cost_cap_usd=auto_approval_cost_cap_usd,
@@ -409,45 +388,6 @@ class MongoSessionStore(NoopSessionStore):
409
  logger.debug("Failed to append trace message for %s: %s", session_id, e)
410
  return None
411
 
412
- async def get_quota(self, user_id: str, day: str) -> int | None:
413
- if not self._ready():
414
- return None
415
- doc = await self.db.claude_quotas.find_one({"_id": f"{user_id}:{day}"})
416
- return int(doc.get("count", 0)) if doc else 0
417
-
418
- async def try_increment_quota(self, user_id: str, day: str, cap: int) -> int | None:
419
- if not self._ready():
420
- return None
421
- key = f"{user_id}:{day}"
422
- now = _now()
423
- try:
424
- await self.db.claude_quotas.insert_one(
425
- {
426
- "_id": key,
427
- "user_id": user_id,
428
- "day": day,
429
- "count": 1,
430
- "updated_at": now,
431
- }
432
- )
433
- return 1
434
- except DuplicateKeyError:
435
- pass
436
- doc = await self.db.claude_quotas.find_one_and_update(
437
- {"_id": key, "count": {"$lt": cap}},
438
- {"$inc": {"count": 1}, "$set": {"updated_at": now}},
439
- return_document=ReturnDocument.AFTER,
440
- )
441
- return int(doc["count"]) if doc else None
442
-
443
- async def refund_quota(self, user_id: str, day: str) -> None:
444
- if not self._ready():
445
- return
446
- await self.db.claude_quotas.update_one(
447
- {"_id": f"{user_id}:{day}", "count": {"$gt": 0}},
448
- {"$inc": {"count": -1}, "$set": {"updated_at": _now()}},
449
- )
450
-
451
  async def mark_pro_seen(
452
  self, user_id: str, *, is_pro: bool
453
  ) -> dict[str, Any] | None:
 
14
 
15
  from bson import BSON
16
  from pymongo import AsyncMongoClient, DeleteMany, ReturnDocument, UpdateOne
17
+ from pymongo.errors import InvalidDocument, PyMongoError
18
 
19
  logger = logging.getLogger(__name__)
20
 
 
89
  async def append_trace_message(self, *_: Any, **__: Any) -> int | None:
90
  return None
91
 
 
 
 
 
 
 
 
 
 
92
  async def mark_pro_seen(self, *_: Any, **__: Any) -> dict[str, Any] | None:
93
  return None
94
 
 
165
  message_count: int = 0,
166
  turn_count: int = 0,
167
  pending_approval: list[dict[str, Any]] | None = None,
 
 
 
168
  notification_destinations: list[str] | None = None,
169
  auto_approval_enabled: bool = False,
170
  auto_approval_cost_cap_usd: float | None = None,
 
195
  "message_count": message_count,
196
  "turn_count": turn_count,
197
  "pending_approval": pending_approval or [],
 
 
 
198
  "notification_destinations": notification_destinations or [],
199
  "auto_approval_enabled": auto_approval_enabled,
200
  "auto_approval_cost_cap_usd": auto_approval_cost_cap_usd,
 
216
  status: str = "active",
217
  turn_count: int = 0,
218
  pending_approval: list[dict[str, Any]] | None = None,
 
 
 
219
  created_at: datetime | None = None,
220
  notification_destinations: list[str] | None = None,
221
  auto_approval_enabled: bool = False,
 
239
  message_count=len(messages),
240
  turn_count=turn_count,
241
  pending_approval=pending_approval,
 
 
 
242
  notification_destinations=notification_destinations,
243
  auto_approval_enabled=auto_approval_enabled,
244
  auto_approval_cost_cap_usd=auto_approval_cost_cap_usd,
 
388
  logger.debug("Failed to append trace message for %s: %s", session_id, e)
389
  return None
390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  async def mark_pro_seen(
392
  self, user_id: str, *, is_pro: bool
393
  ) -> dict[str, Any] | None:
agent/main.py CHANGED
@@ -85,7 +85,7 @@ def _validate_cli_model_override(model: str) -> str:
85
  if not model_switcher.is_valid_model_id(model):
86
  raise ValueError(
87
  "Invalid model id. Use an HF Router id like "
88
- "'anthropic/claude-sonnet-4-6:fal-ai' or a supported local prefix."
89
  )
90
  return model.removeprefix("huggingface/")
91
 
 
85
  if not model_switcher.is_valid_model_id(model):
86
  raise ValueError(
87
  "Invalid model id. Use an HF Router id like "
88
+ "'anthropic/claude-opus-4.8:fal-ai' or a supported local prefix."
89
  )
90
  return model.removeprefix("huggingface/")
91
 
agent/tools/research_tool.py CHANGED
@@ -258,7 +258,6 @@ async def research_handler(
258
  research_model,
259
  getattr(session, "hf_token", None),
260
  reasoning_effort=_capped,
261
- bill_to_user=getattr(session, "premium_user_billed", False),
262
  )
263
  llm_params = with_prompt_cache_params(
264
  llm_params, session_id=getattr(session, "session_id", None)
 
258
  research_model,
259
  getattr(session, "hf_token", None),
260
  reasoning_effort=_capped,
 
261
  )
262
  llm_params = with_prompt_cache_params(
263
  llm_params, session_id=getattr(session, "session_id", None)
backend/dependencies.py CHANGED
@@ -35,7 +35,7 @@ DEV_USER: dict[str, Any] = {
35
  "user_id": "dev",
36
  "username": "dev",
37
  "authenticated": True,
38
- "plan": "pro", # Dev runs at the Pro quota tier so local testing isn't capped.
39
  }
40
 
41
  INTERNAL_HF_TOKEN_KEY = "_hf_token"
@@ -137,7 +137,7 @@ def _user_from_info(user_info: dict[str, Any]) -> dict[str, Any]:
137
 
138
 
139
  def _normalize_user_plan(whoami: Any) -> str:
140
- """Normalize a whoami-v2 payload to the app's personal quota tiers."""
141
  if not isinstance(whoami, dict):
142
  return "free"
143
 
@@ -151,8 +151,8 @@ async def _fetch_user_plan(token: str) -> str:
151
  """Look up the user's HF plan via /api/whoami-v2.
152
 
153
  Returns 'free' | 'pro'. Non-200, network errors, or an unknown
154
- payload shape all collapse to 'free' — safe default; we'd rather under-
155
- grant the Pro cap than over-grant it on bad data.
156
  """
157
  global _WHOAMI_SHAPE_LOGGED
158
  whoami = await fetch_whoami_v2(token)
 
35
  "user_id": "dev",
36
  "username": "dev",
37
  "authenticated": True,
38
+ "plan": "pro", # Dev uses the Pro web default model.
39
  }
40
 
41
  INTERNAL_HF_TOKEN_KEY = "_hf_token"
 
137
 
138
 
139
  def _normalize_user_plan(whoami: Any) -> str:
140
+ """Normalize a whoami-v2 payload to the app's supported plan tiers."""
141
  if not isinstance(whoami, dict):
142
  return "free"
143
 
 
151
  """Look up the user's HF plan via /api/whoami-v2.
152
 
153
  Returns 'free' | 'pro'. Non-200, network errors, or an unknown
154
+ payload shape all collapse to 'free' — safe default; we'd rather avoid
155
+ selecting the Pro default on bad data.
156
  """
157
  global _WHOAMI_SHAPE_LOGGED
158
  whoami = await fetch_whoami_v2(token)
backend/main.py CHANGED
@@ -10,8 +10,8 @@ from fastapi import FastAPI
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from fastapi.staticfiles import StaticFiles
12
 
13
- # Load .env before importing routes/session_manager so persistence and quota
14
- # modules see local Mongo settings during startup.
15
  load_dotenv(Path(__file__).parent.parent / ".env")
16
 
17
  from routes.agent import router as agent_router # noqa: E402
 
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from fastapi.staticfiles import StaticFiles
12
 
13
+ # Load .env before importing routes/session_manager so persistence and model
14
+ # modules see local settings during startup.
15
  load_dotenv(Path(__file__).parent.parent / ".env")
16
 
17
  from routes.agent import router as agent_router # noqa: E402
backend/models.py CHANGED
@@ -105,8 +105,6 @@ class SessionInfo(BaseModel):
105
  auto_approval: SessionAutoApprovalInfo = Field(
106
  default_factory=SessionAutoApprovalInfo
107
  )
108
- premium_user_billed: bool = False
109
- premium_quota_counted: bool = False
110
 
111
 
112
  class SessionNotificationsRequest(BaseModel):
 
105
  auto_approval: SessionAutoApprovalInfo = Field(
106
  default_factory=SessionAutoApprovalInfo
107
  )
 
 
108
 
109
 
110
  class SessionNotificationsRequest(BaseModel):
backend/routes/agent.py CHANGED
@@ -49,21 +49,16 @@ from session_manager import (
49
  session_manager,
50
  )
51
 
52
- import user_quotas
53
-
54
  from agent.core.hf_access import get_jobs_access
55
  from agent.core.hf_tokens import resolve_hf_request_token
56
  from agent.core.llm_params import _resolve_llm_params
57
  from agent.core.model_ids import (
58
  CLAUDE_OPUS_48_MODEL_ID,
59
  DEEPSEEK_V4_PRO_MODEL_ID,
60
- DEFAULT_MODEL_ID,
61
  GLM_51_MODEL_ID,
62
  GPT_55_MODEL_ID,
63
  KIMI_K26_MODEL_ID,
64
  MINIMAX_M27_MODEL_ID,
65
- is_premium_model_id,
66
- is_pro_only_premium_model_id,
67
  )
68
 
69
  logger = logging.getLogger(__name__)
@@ -71,7 +66,6 @@ logger = logging.getLogger(__name__)
71
  router = APIRouter(prefix="/api", tags=["agent"])
72
  _background_teardown_tasks: set[asyncio.Task] = set()
73
 
74
- DEFAULT_PREMIUM_MODEL_ID = DEFAULT_MODEL_ID
75
  DEFAULT_OPUS_MODEL_ID = CLAUDE_OPUS_48_MODEL_ID
76
  DEFAULT_GPT_MODEL_ID = GPT_55_MODEL_ID
77
  DEFAULT_FREE_MODEL_ID = KIMI_K26_MODEL_ID
@@ -80,55 +74,36 @@ DATASET_UPLOAD_MULTIPART_SLACK_BYTES = 1024 * 1024
80
 
81
  def _available_models() -> list[dict[str, Any]]:
82
  models = [
83
- {
84
- "id": DEFAULT_PREMIUM_MODEL_ID,
85
- "label": "Claude Sonnet 4.6",
86
- "provider": "huggingface",
87
- "tier": "pro",
88
- "recommended": True,
89
- "minimum_plan": "free",
90
- },
91
  {
92
  "id": DEFAULT_OPUS_MODEL_ID,
93
  "label": "Claude Opus 4.8",
94
  "provider": "huggingface",
95
- "tier": "pro",
96
- "minimum_plan": "pro",
97
  },
98
  {
99
  "id": DEFAULT_GPT_MODEL_ID,
100
  "label": "GPT-5.5",
101
  "provider": "huggingface",
102
- "tier": "pro",
103
- "minimum_plan": "pro",
104
  },
105
  {
106
  "id": DEFAULT_FREE_MODEL_ID,
107
  "label": "Kimi K2.6",
108
  "provider": "huggingface",
109
- "tier": "free",
110
- "minimum_plan": "free",
111
  },
112
  {
113
  "id": MINIMAX_M27_MODEL_ID,
114
  "label": "MiniMax M2.7",
115
  "provider": "huggingface",
116
- "tier": "free",
117
- "minimum_plan": "free",
118
  },
119
  {
120
  "id": GLM_51_MODEL_ID,
121
  "label": "GLM 5.1",
122
  "provider": "huggingface",
123
- "tier": "free",
124
- "minimum_plan": "free",
125
  },
126
  {
127
  "id": DEEPSEEK_V4_PRO_MODEL_ID,
128
  "label": "DeepSeek V4 Pro",
129
  "provider": "huggingface",
130
- "tier": "free",
131
- "minimum_plan": "free",
132
  },
133
  ]
134
  return models
@@ -137,119 +112,30 @@ def _available_models() -> list[dict[str, Any]]:
137
  AVAILABLE_MODELS = _available_models()
138
 
139
 
140
- def _is_premium_model(model_id: str) -> bool:
141
- return is_premium_model_id(model_id)
142
-
143
-
144
- def _is_pro_only_premium_model(model_id: str | None) -> bool:
145
- return is_pro_only_premium_model_id(model_id)
146
-
147
-
148
- def _model_unavailable_for_plan(model_id: str | None, plan: str | None) -> bool:
149
- return plan != "pro" and _is_pro_only_premium_model(model_id)
150
 
151
 
152
- def _reject_model_unavailable_for_plan(
153
- model_id: str | None,
154
- user: dict[str, Any],
155
- ) -> None:
156
- plan = user.get("plan", "free")
157
- if not _model_unavailable_for_plan(model_id, plan):
158
  return
159
- raise HTTPException(
160
- status_code=403,
161
- detail={
162
- "error": "model_requires_pro",
163
- "plan": plan,
164
- "model": model_id,
165
- "message": "Claude Opus 4.8 and GPT-5.5 daily sessions require HF Pro.",
166
- },
167
- )
168
 
169
 
170
- def _is_user_billed(model_id: str) -> bool:
171
- return _is_premium_model(model_id)
172
 
173
 
174
  async def _model_override_for_new_session(
175
- request: Request,
176
  requested_model: str | None,
 
177
  ) -> str | None:
178
  """Return the model override to use when creating a new session.
179
 
180
- Explicit model requests are allowed and charged at message-submit time
181
- when premium. Empty requests use the configured default model.
182
- """
183
- return requested_model
184
-
185
-
186
- def _premium_cap_message(plan: str) -> str:
187
- """Over-allowance message for a premium model that can't fall back to user
188
- billing. Defensive: every current premium model is user-billable and
189
- overflows to the user's own HF account instead of reaching this.
190
- """
191
- if plan == "pro":
192
- return (
193
- "Daily premium model limit reached. Use a free model and try premium "
194
- "models again tomorrow."
195
- )
196
- return (
197
- "Daily premium model limit reached. Upgrade to HF Pro for "
198
- f"{user_quotas.CLAUDE_PRO_DAILY}/day or use a free model."
199
- )
200
-
201
-
202
- async def _enforce_premium_model_quota(
203
- user: dict[str, Any],
204
- agent_session: AgentSession,
205
- ) -> None:
206
- """Charge the user's daily premium-model quota on first use in a session.
207
-
208
- Runs at *message-submit* time, not session-create time — so spinning up a
209
- premium-model session to look around doesn't burn quota. The
210
- ``claude_counted_day`` flag on ``AgentSession`` guards against re-counting
211
- the same session on the same day, while still counting old sessions when
212
- they are used again on a later day.
213
-
214
- Subsidizes the daily allowance (free = 2 for default premium, pro = 20
215
- across premium models), organization-billed through the HF Router. Opus and
216
- GPT-5.5 are pro-only before quota is charged. Past the allowance, premium
217
- router models flip the session to ``premium_user_billed`` so the call bills
218
- the user's own HF token instead of blocking. No-ops when the model isn't
219
- premium or when this session's billing has already been decided for today.
220
  """
221
- model_name = agent_session.session.config.model_name
222
- if not _is_premium_model(model_name):
223
- return
224
- _reject_model_unavailable_for_plan(model_name, user)
225
- quota_day = user_quotas.current_quota_day()
226
- if agent_session.claude_counted and agent_session.claude_counted_day == quota_day:
227
- return
228
- user_id = user["user_id"]
229
- plan = user.get("plan", "free")
230
- cap = user_quotas.daily_cap_for(plan)
231
- within_allowance = await user_quotas.try_increment_claude(user_id, cap) is not None
232
- if not within_allowance:
233
- if not _is_user_billed(model_name):
234
- raise HTTPException(
235
- status_code=429,
236
- detail={
237
- "error": "premium_model_daily_cap",
238
- "plan": plan,
239
- "cap": cap,
240
- "message": _premium_cap_message(plan),
241
- },
242
- )
243
- # Past the subsidized allowance on a user-billable model: bill the
244
- # user's own HF (OAuth) token for this session instead of blocking.
245
- agent_session.session.premium_user_billed = True
246
- else:
247
- # A session that overflowed on a previous day can use today's
248
- # subsidized allowance again if quota is available.
249
- agent_session.session.premium_user_billed = False
250
- agent_session.claude_counted = True
251
- agent_session.claude_counted_day = quota_day
252
- await session_manager.persist_session_snapshot(agent_session)
253
 
254
 
255
  def _user_hf_token(user: dict[str, Any] | None) -> str | None:
@@ -494,8 +380,7 @@ async def create_session(
494
  behalf of the user.
495
 
496
  Optional body ``{"model"?: <id>}`` selects the session's LLM; unknown
497
- ids are rejected (400). The premium-model quota runs at message-submit
498
- time, not here — spinning up a session to look around is free.
499
 
500
  Returns 503 if the server or user has reached the session limit.
501
  """
@@ -511,13 +396,10 @@ async def create_session(
511
  if isinstance(body, dict):
512
  model = body.get("model")
513
 
514
- valid_ids = {m["id"] for m in AVAILABLE_MODELS}
515
- if model and model not in valid_ids:
516
- raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
517
- _reject_model_unavailable_for_plan(model, user)
518
 
519
- # Empty requests use the configured default, which may be premium.
520
- model = await _model_override_for_new_session(request, model)
521
 
522
  try:
523
  session_id = await session_manager.create_session(
@@ -533,7 +415,7 @@ async def create_session(
533
  return SessionResponse(
534
  session_id=session_id,
535
  ready=True,
536
- model=model or session_manager.config.model_name,
537
  )
538
 
539
 
@@ -546,9 +428,8 @@ async def restore_session_summary(
546
  summarization prompt on them and drop the result into the new
547
  session's context as a user-role system note.
548
 
549
- Optional ``"model"`` in the body overrides the session's LLM. The
550
- premium-model quota runs before summarization because that call uses the
551
- session model immediately.
552
  """
553
  messages = body.get("messages")
554
  if not isinstance(messages, list) or not messages:
@@ -557,12 +438,9 @@ async def restore_session_summary(
557
  hf_token = resolve_hf_request_token(request)
558
 
559
  model = body.get("model")
560
- valid_ids = {m["id"] for m in AVAILABLE_MODELS}
561
- if model and model not in valid_ids:
562
- raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
563
- _reject_model_unavailable_for_plan(model, user)
564
 
565
- model = await _model_override_for_new_session(request, model)
566
 
567
  try:
568
  session_id = await session_manager.create_session(
@@ -575,14 +453,12 @@ async def restore_session_summary(
575
  except SessionCapacityError as e:
576
  raise HTTPException(status_code=503, detail=str(e))
577
 
578
- agent_session = await _check_session_access(
579
  session_id,
580
  user,
581
  request,
582
  preload_sandbox=False,
583
  )
584
- await _enforce_premium_model_quota(user, agent_session)
585
-
586
  try:
587
  summarized = await session_manager.seed_from_summary(session_id, messages)
588
  except ValueError as e:
@@ -598,7 +474,7 @@ async def restore_session_summary(
598
  return SessionResponse(
599
  session_id=session_id,
600
  ready=True,
601
- model=model or session_manager.config.model_name,
602
  )
603
 
604
 
@@ -622,17 +498,13 @@ async def set_session_model(
622
  """Switch the active model for a single session (tab-scoped).
623
 
624
  Takes effect on the next LLM call in that session — other sessions
625
- (including other browser tabs) are unaffected. Model switches don't
626
- charge quota — the premium-model quota only fires at message-submit time.
627
  """
628
  agent_session = await _check_session_access(session_id, user, request)
629
  model_id = body.get("model")
630
  if not model_id:
631
  raise HTTPException(status_code=400, detail="Missing 'model' field")
632
- valid_ids = {m["id"] for m in AVAILABLE_MODELS}
633
- if model_id not in valid_ids:
634
- raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
635
- _reject_model_unavailable_for_plan(model_id, user)
636
  if not agent_session:
637
  raise HTTPException(status_code=404, detail="Session not found")
638
  await session_manager.update_session_model(session_id, model_id)
@@ -765,21 +637,6 @@ async def set_session_yolo(
765
  return {"session_id": session_id, **summary}
766
 
767
 
768
- @router.get("/user/quota")
769
- async def get_user_quota(user: dict = Depends(get_current_user)) -> dict:
770
- """Return the user's plan tier and today's premium-model quota state."""
771
- plan = user.get("plan", "free")
772
- used = await user_quotas.get_claude_used_today(user["user_id"])
773
- cap = user_quotas.daily_cap_for(plan)
774
- remaining = max(0, cap - used)
775
- return {
776
- "plan": plan,
777
- "premium_used_today": used,
778
- "premium_daily_cap": cap,
779
- "premium_remaining": remaining,
780
- }
781
-
782
-
783
  @router.get("/user/jobs-access")
784
  async def get_jobs_access_info(
785
  request: Request, user: dict = Depends(get_current_user)
@@ -858,12 +715,11 @@ async def submit_input(
858
  }
859
  ]
860
  )
861
- agent_session = await _check_session_access(raw_session_id, user)
862
  try:
863
  body = SubmitRequest(**payload)
864
  except ValidationError as exc:
865
  raise RequestValidationError(exc.errors()) from exc
866
- await _enforce_premium_model_quota(user, agent_session)
867
  success = await session_manager.submit_user_input(body.session_id, body.text)
868
  if not success:
869
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -915,16 +771,6 @@ async def chat_sse(
915
  text = body.get("text")
916
  approvals = body.get("approvals")
917
 
918
- # Gate user-message sends against the daily premium-model quota. Approvals are
919
- # continuations of an in-progress turn, so the relevant quota decision was
920
- # made when that user message was submitted.
921
- if text is not None and not approvals:
922
- try:
923
- await _enforce_premium_model_quota(user, agent_session)
924
- except HTTPException:
925
- broadcaster.unsubscribe(sub_id)
926
- raise
927
-
928
  try:
929
  if approvals:
930
  formatted = [
 
49
  session_manager,
50
  )
51
 
 
 
52
  from agent.core.hf_access import get_jobs_access
53
  from agent.core.hf_tokens import resolve_hf_request_token
54
  from agent.core.llm_params import _resolve_llm_params
55
  from agent.core.model_ids import (
56
  CLAUDE_OPUS_48_MODEL_ID,
57
  DEEPSEEK_V4_PRO_MODEL_ID,
 
58
  GLM_51_MODEL_ID,
59
  GPT_55_MODEL_ID,
60
  KIMI_K26_MODEL_ID,
61
  MINIMAX_M27_MODEL_ID,
 
 
62
  )
63
 
64
  logger = logging.getLogger(__name__)
 
66
  router = APIRouter(prefix="/api", tags=["agent"])
67
  _background_teardown_tasks: set[asyncio.Task] = set()
68
 
 
69
  DEFAULT_OPUS_MODEL_ID = CLAUDE_OPUS_48_MODEL_ID
70
  DEFAULT_GPT_MODEL_ID = GPT_55_MODEL_ID
71
  DEFAULT_FREE_MODEL_ID = KIMI_K26_MODEL_ID
 
74
 
75
  def _available_models() -> list[dict[str, Any]]:
76
  models = [
 
 
 
 
 
 
 
 
77
  {
78
  "id": DEFAULT_OPUS_MODEL_ID,
79
  "label": "Claude Opus 4.8",
80
  "provider": "huggingface",
81
+ "recommended": True,
 
82
  },
83
  {
84
  "id": DEFAULT_GPT_MODEL_ID,
85
  "label": "GPT-5.5",
86
  "provider": "huggingface",
 
 
87
  },
88
  {
89
  "id": DEFAULT_FREE_MODEL_ID,
90
  "label": "Kimi K2.6",
91
  "provider": "huggingface",
 
 
92
  },
93
  {
94
  "id": MINIMAX_M27_MODEL_ID,
95
  "label": "MiniMax M2.7",
96
  "provider": "huggingface",
 
 
97
  },
98
  {
99
  "id": GLM_51_MODEL_ID,
100
  "label": "GLM 5.1",
101
  "provider": "huggingface",
 
 
102
  },
103
  {
104
  "id": DEEPSEEK_V4_PRO_MODEL_ID,
105
  "label": "DeepSeek V4 Pro",
106
  "provider": "huggingface",
 
 
107
  },
108
  ]
109
  return models
 
112
  AVAILABLE_MODELS = _available_models()
113
 
114
 
115
+ def _valid_model_ids() -> set[str]:
116
+ return {m["id"] for m in AVAILABLE_MODELS}
 
 
 
 
 
 
 
 
117
 
118
 
119
+ def _validate_model_id(model_id: str | None) -> None:
120
+ if not model_id or model_id in _valid_model_ids():
 
 
 
 
121
  return
122
+ raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
 
 
 
 
 
 
 
 
123
 
124
 
125
+ def _default_model_for_user(user: dict[str, Any]) -> str:
126
+ return DEFAULT_OPUS_MODEL_ID if user.get("plan") == "pro" else DEFAULT_FREE_MODEL_ID
127
 
128
 
129
  async def _model_override_for_new_session(
 
130
  requested_model: str | None,
131
+ user: dict[str, Any],
132
  ) -> str | None:
133
  """Return the model override to use when creating a new session.
134
 
135
+ Explicit model requests are honored. Empty web requests default to Kimi for
136
+ non-Pro users and Opus for Pro users.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  """
138
+ return requested_model or _default_model_for_user(user)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
 
141
  def _user_hf_token(user: dict[str, Any] | None) -> str | None:
 
380
  behalf of the user.
381
 
382
  Optional body ``{"model"?: <id>}`` selects the session's LLM; unknown
383
+ ids are rejected (400). Empty requests use the plan-aware web default.
 
384
 
385
  Returns 503 if the server or user has reached the session limit.
386
  """
 
396
  if isinstance(body, dict):
397
  model = body.get("model")
398
 
399
+ _validate_model_id(model)
 
 
 
400
 
401
+ # Empty requests use the plan-aware web default.
402
+ model = await _model_override_for_new_session(model, user)
403
 
404
  try:
405
  session_id = await session_manager.create_session(
 
415
  return SessionResponse(
416
  session_id=session_id,
417
  ready=True,
418
+ model=model,
419
  )
420
 
421
 
 
428
  summarization prompt on them and drop the result into the new
429
  session's context as a user-role system note.
430
 
431
+ Optional ``"model"`` in the body overrides the session's LLM; otherwise
432
+ the new session uses the plan-aware web default.
 
433
  """
434
  messages = body.get("messages")
435
  if not isinstance(messages, list) or not messages:
 
438
  hf_token = resolve_hf_request_token(request)
439
 
440
  model = body.get("model")
441
+ _validate_model_id(model)
 
 
 
442
 
443
+ model = await _model_override_for_new_session(model, user)
444
 
445
  try:
446
  session_id = await session_manager.create_session(
 
453
  except SessionCapacityError as e:
454
  raise HTTPException(status_code=503, detail=str(e))
455
 
456
+ await _check_session_access(
457
  session_id,
458
  user,
459
  request,
460
  preload_sandbox=False,
461
  )
 
 
462
  try:
463
  summarized = await session_manager.seed_from_summary(session_id, messages)
464
  except ValueError as e:
 
474
  return SessionResponse(
475
  session_id=session_id,
476
  ready=True,
477
+ model=model,
478
  )
479
 
480
 
 
498
  """Switch the active model for a single session (tab-scoped).
499
 
500
  Takes effect on the next LLM call in that session — other sessions
501
+ (including other browser tabs) are unaffected.
 
502
  """
503
  agent_session = await _check_session_access(session_id, user, request)
504
  model_id = body.get("model")
505
  if not model_id:
506
  raise HTTPException(status_code=400, detail="Missing 'model' field")
507
+ _validate_model_id(model_id)
 
 
 
508
  if not agent_session:
509
  raise HTTPException(status_code=404, detail="Session not found")
510
  await session_manager.update_session_model(session_id, model_id)
 
637
  return {"session_id": session_id, **summary}
638
 
639
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  @router.get("/user/jobs-access")
641
  async def get_jobs_access_info(
642
  request: Request, user: dict = Depends(get_current_user)
 
715
  }
716
  ]
717
  )
718
+ await _check_session_access(raw_session_id, user)
719
  try:
720
  body = SubmitRequest(**payload)
721
  except ValidationError as exc:
722
  raise RequestValidationError(exc.errors()) from exc
 
723
  success = await session_manager.submit_user_input(body.session_id, body.text)
724
  if not success:
725
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
771
  text = body.get("text")
772
  approvals = body.get("approvals")
773
 
 
 
 
 
 
 
 
 
 
 
774
  try:
775
  if approvals:
776
  formatted = [
backend/session_manager.py CHANGED
@@ -6,14 +6,13 @@ import logging
6
  import os
7
  import uuid
8
  from dataclasses import dataclass, field
9
- from datetime import UTC, datetime, timedelta
10
  from pathlib import Path
11
  from typing import Any, Optional
12
 
13
  from agent.config import load_config
14
  from agent.core.agent_loop import process_submission
15
  from agent.core.model_ids import (
16
- DEFAULT_MODEL_ID,
17
  KIMI_K26_MODEL_ID,
18
  is_known_router_model_id,
19
  strip_huggingface_model_prefix,
@@ -113,28 +112,6 @@ class AgentSession:
113
  is_reaping: bool = False
114
  broadcaster: Any = None
115
  title: str | None = None
116
- # True once this session has ever been counted against premium quota.
117
- # claude_counted_day decides whether it has already consumed today's cap.
118
- claude_counted: bool = False
119
- claude_counted_day: str | None = None
120
-
121
-
122
- def _quota_day_today() -> str:
123
- return datetime.now(UTC).date().isoformat()
124
-
125
-
126
- def _quota_counted_today(agent_session: AgentSession) -> bool:
127
- return (
128
- agent_session.claude_counted
129
- and agent_session.claude_counted_day == _quota_day_today()
130
- )
131
-
132
-
133
- def _premium_user_billed_today(agent_session: AgentSession) -> bool:
134
- return bool(
135
- getattr(agent_session.session, "premium_user_billed", False)
136
- and _quota_counted_today(agent_session)
137
- )
138
 
139
 
140
  class SessionCapacityError(Exception):
@@ -231,23 +208,18 @@ class SessionManager:
231
  @staticmethod
232
  def _model_from_saved_metadata(
233
  model: str | None,
234
- *,
235
- premium_user_billed: bool,
236
- claude_counted: bool,
237
- ) -> tuple[str, bool, bool]:
238
  normalized = strip_huggingface_model_prefix(model)
239
  if normalized and is_known_router_model_id(normalized):
240
- return normalized, premium_user_billed, claude_counted
241
 
242
- fallback_model = KIMI_K26_MODEL_ID if premium_user_billed else DEFAULT_MODEL_ID
243
  logger.warning(
244
  "Saved session model %r failed validation; using %r",
245
  model,
246
  fallback_model,
247
  )
248
- if fallback_model == KIMI_K26_MODEL_ID:
249
- return fallback_model, False, False
250
- return fallback_model, premium_user_billed, claude_counted
251
 
252
  def _create_session_sync(
253
  self,
@@ -618,11 +590,6 @@ class SessionManager:
618
  pending_approval=self._serialize_pending_approval(
619
  agent_session.session
620
  ),
621
- claude_counted=agent_session.claude_counted,
622
- claude_counted_day=agent_session.claude_counted_day,
623
- premium_user_billed=getattr(
624
- agent_session.session, "premium_user_billed", False
625
- ),
626
  created_at=agent_session.created_at,
627
  notification_destinations=list(
628
  agent_session.session.notification_destinations
@@ -711,18 +678,9 @@ class SessionManager:
711
 
712
  from litellm import Message
713
 
714
- model, premium_user_billed, claude_counted = self._model_from_saved_metadata(
715
  meta.get("model") or self.config.model_name,
716
- premium_user_billed=bool(meta.get("premium_user_billed", False)),
717
- claude_counted=bool(meta.get("claude_counted")),
718
  )
719
- claude_counted_day = (
720
- str(meta.get("claude_counted_day"))
721
- if meta.get("claude_counted_day")
722
- else None
723
- )
724
- if not claude_counted:
725
- claude_counted_day = None
726
  event_queue: asyncio.Queue = asyncio.Queue()
727
  submission_queue: asyncio.Queue = asyncio.Queue()
728
  tool_router, session = await asyncio.to_thread(
@@ -781,7 +739,6 @@ class SessionManager:
781
  self._restore_pending_approval(session, meta.get("pending_approval") or [])
782
  session.turn_count = int(meta.get("turn_count") or 0)
783
  session.auto_approval_enabled = bool(meta.get("auto_approval_enabled", False))
784
- session.premium_user_billed = premium_user_billed
785
  raw_cap = meta.get("auto_approval_cost_cap_usd")
786
  session.auto_approval_cost_cap_usd = (
787
  float(raw_cap) if isinstance(raw_cap, int | float) else None
@@ -805,8 +762,6 @@ class SessionManager:
805
  created_at=created_at,
806
  is_active=True,
807
  is_processing=False,
808
- claude_counted=claude_counted,
809
- claude_counted_day=claude_counted_day,
810
  title=meta.get("title"),
811
  )
812
  started = await self._start_agent_session(
@@ -1533,8 +1488,6 @@ class SessionManager:
1533
  agent_session.session.notification_destinations
1534
  ),
1535
  "auto_approval": self._auto_approval_summary(agent_session.session),
1536
- "premium_user_billed": _premium_user_billed_today(agent_session),
1537
- "premium_quota_counted": _quota_counted_today(agent_session),
1538
  }
1539
 
1540
  def set_notification_destinations(
@@ -1588,10 +1541,6 @@ class SessionManager:
1588
  else:
1589
  created_at_str = str(created_at or datetime.utcnow().isoformat())
1590
  pending = self._pending_docs_for_api(row.get("pending_approval") or [])
1591
- quota_counted_today = (
1592
- bool(row.get("claude_counted", False))
1593
- and row.get("claude_counted_day") == _quota_day_today()
1594
- )
1595
  results.append(
1596
  {
1597
  "session_id": str(sid),
@@ -1603,11 +1552,6 @@ class SessionManager:
1603
  "pending_approval": pending or None,
1604
  "model": row.get("model"),
1605
  "title": row.get("title"),
1606
- "premium_user_billed": bool(
1607
- row.get("premium_user_billed", False)
1608
- and quota_counted_today
1609
- ),
1610
- "premium_quota_counted": quota_counted_today,
1611
  "notification_destinations": row.get(
1612
  "notification_destinations"
1613
  )
 
6
  import os
7
  import uuid
8
  from dataclasses import dataclass, field
9
+ from datetime import datetime, timedelta
10
  from pathlib import Path
11
  from typing import Any, Optional
12
 
13
  from agent.config import load_config
14
  from agent.core.agent_loop import process_submission
15
  from agent.core.model_ids import (
 
16
  KIMI_K26_MODEL_ID,
17
  is_known_router_model_id,
18
  strip_huggingface_model_prefix,
 
112
  is_reaping: bool = False
113
  broadcaster: Any = None
114
  title: str | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
 
117
  class SessionCapacityError(Exception):
 
208
  @staticmethod
209
  def _model_from_saved_metadata(
210
  model: str | None,
211
+ ) -> str:
 
 
 
212
  normalized = strip_huggingface_model_prefix(model)
213
  if normalized and is_known_router_model_id(normalized):
214
+ return normalized
215
 
216
+ fallback_model = KIMI_K26_MODEL_ID
217
  logger.warning(
218
  "Saved session model %r failed validation; using %r",
219
  model,
220
  fallback_model,
221
  )
222
+ return fallback_model
 
 
223
 
224
  def _create_session_sync(
225
  self,
 
590
  pending_approval=self._serialize_pending_approval(
591
  agent_session.session
592
  ),
 
 
 
 
 
593
  created_at=agent_session.created_at,
594
  notification_destinations=list(
595
  agent_session.session.notification_destinations
 
678
 
679
  from litellm import Message
680
 
681
+ model = self._model_from_saved_metadata(
682
  meta.get("model") or self.config.model_name,
 
 
683
  )
 
 
 
 
 
 
 
684
  event_queue: asyncio.Queue = asyncio.Queue()
685
  submission_queue: asyncio.Queue = asyncio.Queue()
686
  tool_router, session = await asyncio.to_thread(
 
739
  self._restore_pending_approval(session, meta.get("pending_approval") or [])
740
  session.turn_count = int(meta.get("turn_count") or 0)
741
  session.auto_approval_enabled = bool(meta.get("auto_approval_enabled", False))
 
742
  raw_cap = meta.get("auto_approval_cost_cap_usd")
743
  session.auto_approval_cost_cap_usd = (
744
  float(raw_cap) if isinstance(raw_cap, int | float) else None
 
762
  created_at=created_at,
763
  is_active=True,
764
  is_processing=False,
 
 
765
  title=meta.get("title"),
766
  )
767
  started = await self._start_agent_session(
 
1488
  agent_session.session.notification_destinations
1489
  ),
1490
  "auto_approval": self._auto_approval_summary(agent_session.session),
 
 
1491
  }
1492
 
1493
  def set_notification_destinations(
 
1541
  else:
1542
  created_at_str = str(created_at or datetime.utcnow().isoformat())
1543
  pending = self._pending_docs_for_api(row.get("pending_approval") or [])
 
 
 
 
1544
  results.append(
1545
  {
1546
  "session_id": str(sid),
 
1552
  "pending_approval": pending or None,
1553
  "model": row.get("model"),
1554
  "title": row.get("title"),
 
 
 
 
 
1555
  "notification_destinations": row.get(
1556
  "notification_destinations"
1557
  )
backend/user_quotas.py DELETED
@@ -1,135 +0,0 @@
1
- """Daily quota for subsidized premium model sessions.
2
-
3
- Tracks per-user premium model session starts against a daily cap derived from
4
- the user's HF plan. MongoDB is the source of truth when configured; the
5
- in-process dict remains the fallback for local/dev/test runs.
6
-
7
- The public names still say ``claude`` because this quota bucket originally
8
- only covered Claude and the persisted session field uses that name.
9
-
10
- Unit: first premium-model submit per session per UTC day, not raw messages. A
11
- user who sends with an allowed premium model in any session consumes one quota
12
- point for that day; continuing the same session on the same day doesn't
13
- (`AgentSession.claude_counted_day` guards that). Model-level plan gates live in
14
- ``backend.routes.agent``; this module only tracks the per-plan daily cap.
15
-
16
- Cap tiers:
17
- free user → CLAUDE_FREE_DAILY (2) for the default premium model
18
- pro user → CLAUDE_PRO_DAILY (20)
19
- """
20
-
21
- import asyncio
22
- import os
23
- from datetime import UTC, datetime
24
-
25
- from agent.core.session_persistence import (
26
- NoopSessionStore,
27
- get_session_store,
28
- _reset_store_for_tests,
29
- )
30
-
31
- CLAUDE_FREE_DAILY: int = int(os.environ.get("CLAUDE_FREE_DAILY", "2"))
32
- CLAUDE_PRO_DAILY: int = int(os.environ.get("CLAUDE_PRO_DAILY", "20"))
33
-
34
- # user_id -> (day_utc_iso, count_for_that_day)
35
- _claude_counts: dict[str, tuple[str, int]] = {}
36
- _lock = asyncio.Lock()
37
-
38
-
39
- def _today() -> str:
40
- return datetime.now(UTC).date().isoformat()
41
-
42
-
43
- def current_quota_day() -> str:
44
- """Return the UTC date key used for today's premium-model quota bucket."""
45
- return _today()
46
-
47
-
48
- def daily_cap_for(plan: str | None) -> int:
49
- """Return the daily Claude-session cap for the given plan."""
50
- return CLAUDE_PRO_DAILY if plan == "pro" else CLAUDE_FREE_DAILY
51
-
52
-
53
- async def get_claude_used_today(user_id: str) -> int:
54
- """Return today's Claude session count for the user (0 if none / stale day)."""
55
- store = get_session_store()
56
- if getattr(store, "enabled", False):
57
- db_count = await store.get_quota(user_id, _today())
58
- return db_count or 0
59
-
60
- async with _lock:
61
- entry = _claude_counts.get(user_id)
62
- if entry is None:
63
- return 0
64
- day, count = entry
65
- if day != _today():
66
- # Stale day — drop the entry so the first increment starts fresh.
67
- _claude_counts.pop(user_id, None)
68
- return 0
69
- return count
70
-
71
-
72
- async def increment_claude(user_id: str) -> int:
73
- """Bump today's Claude session count for the user. Returns the new value."""
74
- store = get_session_store()
75
- if getattr(store, "enabled", False):
76
- db_count = await store.try_increment_quota(user_id, _today(), cap=10**9)
77
- return db_count or 0
78
-
79
- async with _lock:
80
- today = _today()
81
- day, count = _claude_counts.get(user_id, (today, 0))
82
- if day != today:
83
- count = 0
84
- count += 1
85
- _claude_counts[user_id] = (today, count)
86
- return count
87
-
88
-
89
- async def try_increment_claude(user_id: str, cap: int) -> int | None:
90
- """Atomically bump today's count if below *cap*.
91
-
92
- Returns the new count, or None when the user is already at the cap.
93
- """
94
- store = get_session_store()
95
- if getattr(store, "enabled", False):
96
- return await store.try_increment_quota(user_id, _today(), cap)
97
-
98
- async with _lock:
99
- today = _today()
100
- day, count = _claude_counts.get(user_id, (today, 0))
101
- if day != today:
102
- count = 0
103
- if count >= cap:
104
- return None
105
- count += 1
106
- _claude_counts[user_id] = (today, count)
107
- return count
108
-
109
-
110
- async def refund_claude(user_id: str) -> None:
111
- """Decrement today's count — used when session creation fails after a successful gate."""
112
- store = get_session_store()
113
- if getattr(store, "enabled", False):
114
- await store.refund_quota(user_id, _today())
115
- return
116
-
117
- async with _lock:
118
- entry = _claude_counts.get(user_id)
119
- if entry is None:
120
- return
121
- day, count = entry
122
- if day != _today():
123
- _claude_counts.pop(user_id, None)
124
- return
125
- new_count = max(0, count - 1)
126
- if new_count == 0:
127
- _claude_counts.pop(user_id, None)
128
- else:
129
- _claude_counts[user_id] = (day, new_count)
130
-
131
-
132
- def _reset_for_tests() -> None:
133
- """Test-only: clear the in-memory store."""
134
- _claude_counts.clear()
135
- _reset_store_for_tests(NoopSessionStore())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
configs/cli_agent_config.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "model_name": "anthropic/claude-sonnet-4-6:fal-ai",
3
  "save_sessions": true,
4
  "session_dataset_repo": "smolagents/ml-intern-sessions",
5
  "share_traces": true,
 
1
  {
2
+ "model_name": "anthropic/claude-opus-4.8:fal-ai",
3
  "save_sessions": true,
4
  "session_dataset_repo": "smolagents/ml-intern-sessions",
5
  "share_traces": true,
configs/frontend_agent_config.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "model_name": "${ML_INTERN_DEFAULT_MODEL_ID:-anthropic/claude-sonnet-4-6:fal-ai}",
3
  "save_sessions": true,
4
  "session_dataset_repo": "smolagents/ml-intern-sessions",
5
  "share_traces": true,
 
1
  {
2
+ "model_name": "${ML_INTERN_DEFAULT_MODEL_ID:-moonshotai/Kimi-K2.6}",
3
  "save_sessions": true,
4
  "session_dataset_repo": "smolagents/ml-intern-sessions",
5
  "share_traces": true,
frontend/src/components/Chat/BillingBanner.tsx DELETED
@@ -1,81 +0,0 @@
1
- /**
2
- * Shown above the composer when the active session's premium usage is being
3
- * billed to the user's own HF account — i.e. they're past the subsidized daily
4
- * allowance and the session flipped to `premium_user_billed`. Dismissible once
5
- * per day so we stay transparent about billing without nagging every session.
6
- */
7
- import { useState } from 'react';
8
- import { Box, Link, Typography } from '@mui/material';
9
-
10
- const DISMISS_KEY = 'ml-intern:billing-banner-dismissed';
11
- const today = () => new Date().toISOString().slice(0, 10);
12
-
13
- export default function BillingBanner() {
14
- const [dismissed, setDismissed] = useState(() => {
15
- try {
16
- return localStorage.getItem(DISMISS_KEY) === today();
17
- } catch {
18
- return false;
19
- }
20
- });
21
-
22
- if (dismissed) return null;
23
-
24
- const dismiss = () => {
25
- try {
26
- localStorage.setItem(DISMISS_KEY, today());
27
- } catch {
28
- /* ignore storage failures */
29
- }
30
- setDismissed(true);
31
- };
32
-
33
- return (
34
- <Box sx={{ maxWidth: '880px', mx: 'auto', width: '100%', px: { xs: 0, sm: 1, md: 2 }, mb: 1 }}>
35
- <Box
36
- sx={{
37
- display: 'flex',
38
- alignItems: 'center',
39
- gap: 1.5,
40
- p: '8px 12px',
41
- borderRadius: 'var(--radius-md)',
42
- bgcolor: 'var(--accent-yellow-weak)',
43
- border: '1px solid var(--border)',
44
- }}
45
- >
46
- <Typography
47
- variant="caption"
48
- sx={{ flex: 1, color: 'var(--text)', fontSize: '0.78rem', lineHeight: 1.5 }}
49
- >
50
- You've used today's subsidized premium sessions — this session's usage is billed to your{' '}
51
- <Link
52
- href="https://huggingface.co/settings/billing"
53
- target="_blank"
54
- rel="noopener noreferrer"
55
- sx={{ color: 'inherit', textDecoration: 'underline' }}
56
- >
57
- Hugging Face account
58
- </Link>{' '}
59
- through Hugging Face Inference Providers.
60
- </Typography>
61
- <Box
62
- component="button"
63
- onClick={dismiss}
64
- aria-label="Dismiss billing notice"
65
- sx={{
66
- border: 'none',
67
- background: 'none',
68
- cursor: 'pointer',
69
- color: 'var(--muted-text)',
70
- fontSize: '0.95rem',
71
- lineHeight: 1,
72
- p: 0.5,
73
- '&:hover': { color: 'var(--text)' },
74
- }}
75
- >
76
-
77
- </Box>
78
- </Box>
79
- </Box>
80
- );
81
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/Chat/ChatErrorBanner.tsx CHANGED
@@ -1,5 +1,8 @@
1
  import { useEffect, useMemo, useState } from 'react';
2
- import { Alert, AlertTitle, Box, Button, Typography } from '@mui/material';
 
 
 
3
 
4
  interface ChatErrorBannerProps {
5
  error: string;
@@ -8,9 +11,14 @@ interface ChatErrorBannerProps {
8
  onDismiss: () => void;
9
  }
10
 
 
 
11
  export default function ChatErrorBanner({ error, sessionId, model, onDismiss }: ChatErrorBannerProps) {
12
  const [copied, setCopied] = useState(false);
13
  const [reportedAt, setReportedAt] = useState(() => new Date().toISOString());
 
 
 
14
 
15
  useEffect(() => {
16
  setReportedAt(new Date().toISOString());
@@ -37,6 +45,14 @@ export default function ChatErrorBanner({ error, sessionId, model, onDismiss }:
37
  }
38
  };
39
 
 
 
 
 
 
 
 
 
40
  return (
41
  <Box sx={{ maxWidth: 880, mx: 'auto', width: '100%', px: { xs: 0, sm: 1, md: 2 }, mb: 1 }}>
42
  <Alert
@@ -61,12 +77,58 @@ export default function ChatErrorBanner({ error, sessionId, model, onDismiss }:
61
  }
62
  >
63
  <AlertTitle sx={{ fontWeight: 700, fontSize: '0.86rem' }}>
64
- Message failed
65
  </AlertTitle>
66
  <Typography variant="body2" sx={{ fontSize: '0.8rem', lineHeight: 1.5 }}>
67
- The backend could not process the last message. Retry after a moment. If it keeps
68
- happening, raise an issue with the copied details.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  </Typography>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  <Typography
71
  variant="caption"
72
  component="pre"
 
1
  import { useEffect, useMemo, useState } from 'react';
2
+ import { Alert, AlertTitle, Box, Button, Link, Typography } from '@mui/material';
3
+ import { useAgentStore } from '@/store/agentStore';
4
+ import { apiFetch } from '@/utils/api';
5
+ import { inferenceCreditCta, isInferenceCreditError } from '@/utils/inferenceBilling';
6
 
7
  interface ChatErrorBannerProps {
8
  error: string;
 
11
  onDismiss: () => void;
12
  }
13
 
14
+ const DISCUSSIONS_URL = 'https://huggingface.co/spaces/smolagents/ml-intern/discussions';
15
+
16
  export default function ChatErrorBanner({ error, sessionId, model, onDismiss }: ChatErrorBannerProps) {
17
  const [copied, setCopied] = useState(false);
18
  const [reportedAt, setReportedAt] = useState(() => new Date().toISOString());
19
+ const userPlan = useAgentStore((s) => s.user?.plan);
20
+ const isCreditError = isInferenceCreditError(error);
21
+ const creditCta = isCreditError ? inferenceCreditCta(userPlan) : null;
22
 
23
  useEffect(() => {
24
  setReportedAt(new Date().toISOString());
 
45
  }
46
  };
47
 
48
+ const trackProClick = () => {
49
+ if (userPlan === 'pro') return;
50
+ void apiFetch(`/api/pro-click/${sessionId}`, {
51
+ method: 'POST',
52
+ body: JSON.stringify({ source: 'inference_credit_error', target: 'hf_pro' }),
53
+ }).catch(() => {});
54
+ };
55
+
56
  return (
57
  <Box sx={{ maxWidth: 880, mx: 'auto', width: '100%', px: { xs: 0, sm: 1, md: 2 }, mb: 1 }}>
58
  <Alert
 
77
  }
78
  >
79
  <AlertTitle sx={{ fontWeight: 700, fontSize: '0.86rem' }}>
80
+ {creditCta?.title ?? 'Message failed'}
81
  </AlertTitle>
82
  <Typography variant="body2" sx={{ fontSize: '0.8rem', lineHeight: 1.5 }}>
83
+ {creditCta ? (
84
+ creditCta.message
85
+ ) : (
86
+ <>
87
+ The backend could not process the last message. Retry after a moment. If it keeps
88
+ happening,{' '}
89
+ <Link
90
+ href={DISCUSSIONS_URL}
91
+ target="_blank"
92
+ rel="noopener noreferrer"
93
+ color="inherit"
94
+ underline="always"
95
+ >
96
+ open a discussion
97
+ </Link>{' '}
98
+ with the copied details.
99
+ </>
100
+ )}
101
  </Typography>
102
+ {creditCta && (
103
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1 }}>
104
+ <Button
105
+ component="a"
106
+ href={creditCta.primaryHref}
107
+ target="_blank"
108
+ rel="noopener noreferrer"
109
+ color="inherit"
110
+ size="small"
111
+ variant="outlined"
112
+ onClick={trackProClick}
113
+ sx={{ textTransform: 'none' }}
114
+ >
115
+ {creditCta.primaryLabel}
116
+ </Button>
117
+ {creditCta.secondaryHref && creditCta.secondaryLabel && (
118
+ <Button
119
+ component="a"
120
+ href={creditCta.secondaryHref}
121
+ target="_blank"
122
+ rel="noopener noreferrer"
123
+ color="inherit"
124
+ size="small"
125
+ sx={{ textTransform: 'none' }}
126
+ >
127
+ {creditCta.secondaryLabel}
128
+ </Button>
129
+ )}
130
+ </Box>
131
+ )}
132
  <Typography
133
  variant="caption"
134
  component="pre"
frontend/src/components/Chat/ChatInput.tsx CHANGED
@@ -20,28 +20,23 @@ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
20
  import StopIcon from '@mui/icons-material/Stop';
21
  import AddIcon from '@mui/icons-material/Add';
22
  import { apiFetch, apiUpload } from '@/utils/api';
23
- import type { PlanTier, UserQuota } from '@/hooks/useUserQuota';
24
  import JobsUpgradeDialog from '@/components/JobsUpgradeDialog';
25
  import { useAgentStore } from '@/store/agentStore';
26
  import { useSessionStore } from '@/store/sessionStore';
27
  import {
28
- CLAUDE_MODEL_PATH,
29
  CLAUDE_OPUS_48_MODEL_PATH,
30
  GPT_55_MODEL_PATH,
 
31
  isClaudePath,
32
- isPremiumPath,
33
- isProOnlyPath,
34
  } from '@/utils/model';
35
 
36
  // Model configuration
37
  interface ModelOption {
38
  id: string;
39
  name: string;
40
- description: string;
41
  modelPath: string;
42
  avatarUrl: string;
43
  recommended?: boolean;
44
- minimumPlan?: 'free' | 'pro';
45
  }
46
 
47
  const getHfAvatarUrl = (modelId: string) => {
@@ -50,66 +45,52 @@ const getHfAvatarUrl = (modelId: string) => {
50
  };
51
 
52
  const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
53
- {
54
- id: 'claude-sonnet-4-6',
55
- name: 'Claude Sonnet 4.6',
56
- description: 'Hugging Face',
57
- modelPath: CLAUDE_MODEL_PATH,
58
- avatarUrl: getHfAvatarUrl(CLAUDE_MODEL_PATH),
59
- recommended: true,
60
- },
61
  {
62
  id: 'claude-opus-4-8',
63
  name: 'Claude Opus 4.8',
64
- description: 'Hugging Face',
65
  modelPath: CLAUDE_OPUS_48_MODEL_PATH,
66
  avatarUrl: getHfAvatarUrl(CLAUDE_OPUS_48_MODEL_PATH),
67
- minimumPlan: 'pro',
68
  },
69
  {
70
  id: 'gpt-5.5',
71
  name: 'GPT-5.5',
72
- description: 'Hugging Face',
73
  modelPath: GPT_55_MODEL_PATH,
74
  avatarUrl: getHfAvatarUrl(GPT_55_MODEL_PATH),
75
- minimumPlan: 'pro',
76
  },
77
  {
78
  id: 'kimi-k2.6',
79
  name: 'Kimi K2.6',
80
- description: 'Hugging Face',
81
- modelPath: 'moonshotai/Kimi-K2.6',
82
- avatarUrl: getHfAvatarUrl('moonshotai/Kimi-K2.6'),
83
  },
84
  {
85
  id: 'minimax-m2.7',
86
  name: 'MiniMax M2.7',
87
- description: 'Hugging Face',
88
  modelPath: 'MiniMaxAI/MiniMax-M2.7',
89
  avatarUrl: getHfAvatarUrl('MiniMaxAI/MiniMax-M2.7'),
90
  },
91
  {
92
  id: 'glm-5.1',
93
  name: 'GLM 5.1',
94
- description: 'Hugging Face',
95
  modelPath: 'zai-org/GLM-5.1',
96
  avatarUrl: getHfAvatarUrl('zai-org/GLM-5.1'),
97
  },
98
  {
99
  id: 'deepseek-v4-pro',
100
  name: 'DeepSeek V4 Pro',
101
- description: 'Hugging Face',
102
  modelPath: 'deepseek-ai/DeepSeek-V4-Pro:deepinfra',
103
  avatarUrl: getHfAvatarUrl('deepseek-ai/DeepSeek-V4-Pro'),
104
  },
105
  ];
106
 
 
 
107
  const normalizeModelPath = (path: string | undefined) => (
108
  (path ?? '')
109
  .toLowerCase()
110
  .replace(/^huggingface\//, '')
111
  .replace(/claude-opus-4\.(\d)/g, 'claude-opus-4-$1')
112
- .replace(/claude-sonnet-4\.(\d)/g, 'claude-sonnet-4-$1')
113
  );
114
 
115
  const findModelByPath = (path: string, options: ModelOption[]): ModelOption | undefined => {
@@ -142,17 +123,14 @@ const modelOptionFromApi = (model: {
142
  label?: string;
143
  provider?: string;
144
  recommended?: boolean;
145
- minimum_plan?: string;
146
  }): ModelOption | null => {
147
  if (!model.id) return null;
148
  return {
149
  id: modelOptionId(model.id),
150
  name: model.label ?? model.id,
151
- description: 'Hugging Face',
152
  modelPath: model.id,
153
  avatarUrl: getHfAvatarUrl(model.id.replace(/^huggingface\//, '')),
154
  recommended: Boolean(model.recommended),
155
- minimumPlan: model.minimum_plan === 'pro' ? 'pro' : 'free',
156
  };
157
  };
158
 
@@ -178,8 +156,6 @@ interface ChatInputProps {
178
  isProcessing?: boolean;
179
  disabled?: boolean;
180
  placeholder?: string;
181
- quota: UserQuota | null;
182
- refreshQuota: () => Promise<void> | void;
183
  }
184
 
185
  interface DatasetUploadResponse {
@@ -202,13 +178,6 @@ const DATASET_UPLOAD_ACCEPT = '.csv,.json,.jsonl';
202
  const DATASET_UPLOAD_EXTENSIONS = new Set(['csv', 'json', 'jsonl']);
203
 
204
  const isClaudeModel = (m: ModelOption) => isClaudePath(m.modelPath);
205
- const isPremiumModel = (m: ModelOption) => isPremiumPath(m.modelPath);
206
- const isProOnlyModel = (m: ModelOption) => (
207
- m.minimumPlan === 'pro' || isProOnlyPath(m.modelPath)
208
- );
209
- const isModelAllowedForPlan = (m: ModelOption, plan: PlanTier) => (
210
- plan === 'pro' || !isProOnlyModel(m)
211
- );
212
 
213
  const formatBytes = (bytes: number) => {
214
  if (bytes < 1024) return `${bytes} B`;
@@ -220,7 +189,7 @@ const datasetRepoUrl = (repoId: string) => (
220
  `https://huggingface.co/datasets/${repoId.split('/').map(encodeURIComponent).join('/')}`
221
  );
222
 
223
- export default function ChatInput({ sessionId, initialModelPath, onSend, onStop, onDatasetUploaded, isProcessing = false, disabled = false, placeholder = 'Ask anything...', quota, refreshQuota }: ChatInputProps) {
224
  const [input, setInput] = useState('');
225
  const inputRef = useRef<HTMLTextAreaElement>(null);
226
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -228,7 +197,10 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
228
  const modelOptionsRef = useRef<ModelOption[]>(DEFAULT_MODEL_OPTIONS);
229
  const sessionIdRef = useRef<string | undefined>(sessionId);
230
  const [selectedModelId, setSelectedModelId] = useState<string>(
231
- () => findModelByPath(initialModelPath ?? '', DEFAULT_MODEL_OPTIONS)?.id ?? DEFAULT_MODEL_OPTIONS[0].id,
 
 
 
232
  );
233
  const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null);
234
  const jobsUpgradeRequired = useAgentStore((s) => s.jobsUpgradeRequired);
@@ -290,10 +262,7 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
290
  return () => { cancelled = true; };
291
  }, [sessionId, updateSessionModel]);
292
 
293
- const plan = quota?.plan ?? 'free';
294
- const visibleModelOptions = modelOptions.filter((model) => (
295
- isModelAllowedForPlan(model, plan)
296
- ));
297
  const selectedModel = (
298
  visibleModelOptions.find(m => m.id === selectedModelId)
299
  || modelOptions.find(m => m.id === selectedModelId)
@@ -309,16 +278,11 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
309
  }, [disabled, isProcessing]);
310
 
311
  const handleSend = useCallback(() => {
312
- const selectedOption = modelOptions.find((model) => model.id === selectedModelId);
313
- if (selectedOption && !isModelAllowedForPlan(selectedOption, plan)) {
314
- setModelSwitchError('Claude Opus 4.8 and GPT-5.5 daily sessions require HF Pro.');
315
- return;
316
- }
317
  if (input.trim() && !disabled && !isUploadingDataset) {
318
  onSend(input);
319
  setInput('');
320
  }
321
- }, [input, disabled, isUploadingDataset, onSend, modelOptions, selectedModelId, plan]);
322
 
323
  const handleDatasetUploadClick = useCallback(() => {
324
  fileInputRef.current?.click();
@@ -395,13 +359,6 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
395
  return () => window.clearTimeout(timeout);
396
  }, [datasetUploadSuccess]);
397
 
398
- // Refresh the quota display whenever the session changes (user might
399
- // have started another tab that spent quota).
400
- useEffect(() => {
401
- if (sessionId) refreshQuota();
402
- // eslint-disable-next-line react-hooks/exhaustive-deps
403
- }, [sessionId]);
404
-
405
  const handleKeyDown = useCallback(
406
  (e: KeyboardEvent<HTMLDivElement>) => {
407
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -414,7 +371,6 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
414
 
415
  const handleModelClick = (event: React.MouseEvent<HTMLElement>) => {
416
  setModelAnchorEl(event.currentTarget);
417
- void refreshQuota();
418
  };
419
 
420
  const handleModelClose = () => {
@@ -424,10 +380,6 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
424
  const handleSelectModel = async (model: ModelOption) => {
425
  handleModelClose();
426
  if (!sessionId) return;
427
- if (!isModelAllowedForPlan(model, plan)) {
428
- setModelSwitchError('Claude Opus 4.8 and GPT-5.5 daily sessions require HF Pro.');
429
- return;
430
- }
431
  try {
432
  const res = await apiFetch(`/api/session/${sessionId}/model`, {
433
  method: 'POST',
@@ -486,16 +438,6 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
486
  return () => document.removeEventListener('visibilitychange', onVisible);
487
  }, [awaitingTopUp, jobsUpgradeRequired, handleJobsRetry]);
488
 
489
- // Show the remaining subsidized premium-session allowance for today.
490
- const premiumChip = (() => {
491
- if (!quota) return null;
492
- const remaining = Math.max(0, quota.premiumRemaining);
493
- if (remaining === 0) {
494
- return quota.plan === 'pro' ? '0 left – using HF billing' : '0 left – enable billing';
495
- }
496
- return `${remaining} left today`;
497
- })();
498
-
499
  return (
500
  <Box
501
  sx={{
@@ -793,25 +735,8 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
793
  }}
794
  />
795
  )}
796
- {isPremiumModel(model) && premiumChip && (
797
- <Chip
798
- label={premiumChip}
799
- size="small"
800
- sx={{
801
- height: '18px',
802
- fontSize: '10px',
803
- bgcolor: 'rgba(255,255,255,0.08)',
804
- color: 'var(--muted-text)',
805
- fontWeight: 600,
806
- }}
807
- />
808
- )}
809
  </Box>
810
  }
811
- secondary={model.description}
812
- secondaryTypographyProps={{
813
- sx: { fontSize: '12px', color: 'var(--muted-text)' }
814
- }}
815
  />
816
  </MenuItem>
817
  ))}
 
20
  import StopIcon from '@mui/icons-material/Stop';
21
  import AddIcon from '@mui/icons-material/Add';
22
  import { apiFetch, apiUpload } from '@/utils/api';
 
23
  import JobsUpgradeDialog from '@/components/JobsUpgradeDialog';
24
  import { useAgentStore } from '@/store/agentStore';
25
  import { useSessionStore } from '@/store/sessionStore';
26
  import {
 
27
  CLAUDE_OPUS_48_MODEL_PATH,
28
  GPT_55_MODEL_PATH,
29
+ KIMI_K26_MODEL_PATH,
30
  isClaudePath,
 
 
31
  } from '@/utils/model';
32
 
33
  // Model configuration
34
  interface ModelOption {
35
  id: string;
36
  name: string;
 
37
  modelPath: string;
38
  avatarUrl: string;
39
  recommended?: boolean;
 
40
  }
41
 
42
  const getHfAvatarUrl = (modelId: string) => {
 
45
  };
46
 
47
  const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
 
 
 
 
 
 
 
 
48
  {
49
  id: 'claude-opus-4-8',
50
  name: 'Claude Opus 4.8',
 
51
  modelPath: CLAUDE_OPUS_48_MODEL_PATH,
52
  avatarUrl: getHfAvatarUrl(CLAUDE_OPUS_48_MODEL_PATH),
53
+ recommended: true,
54
  },
55
  {
56
  id: 'gpt-5.5',
57
  name: 'GPT-5.5',
 
58
  modelPath: GPT_55_MODEL_PATH,
59
  avatarUrl: getHfAvatarUrl(GPT_55_MODEL_PATH),
 
60
  },
61
  {
62
  id: 'kimi-k2.6',
63
  name: 'Kimi K2.6',
64
+ modelPath: KIMI_K26_MODEL_PATH,
65
+ avatarUrl: getHfAvatarUrl(KIMI_K26_MODEL_PATH),
 
66
  },
67
  {
68
  id: 'minimax-m2.7',
69
  name: 'MiniMax M2.7',
 
70
  modelPath: 'MiniMaxAI/MiniMax-M2.7',
71
  avatarUrl: getHfAvatarUrl('MiniMaxAI/MiniMax-M2.7'),
72
  },
73
  {
74
  id: 'glm-5.1',
75
  name: 'GLM 5.1',
 
76
  modelPath: 'zai-org/GLM-5.1',
77
  avatarUrl: getHfAvatarUrl('zai-org/GLM-5.1'),
78
  },
79
  {
80
  id: 'deepseek-v4-pro',
81
  name: 'DeepSeek V4 Pro',
 
82
  modelPath: 'deepseek-ai/DeepSeek-V4-Pro:deepinfra',
83
  avatarUrl: getHfAvatarUrl('deepseek-ai/DeepSeek-V4-Pro'),
84
  },
85
  ];
86
 
87
+ const DEFAULT_FREE_MODEL_OPTION_ID = 'kimi-k2.6';
88
+
89
  const normalizeModelPath = (path: string | undefined) => (
90
  (path ?? '')
91
  .toLowerCase()
92
  .replace(/^huggingface\//, '')
93
  .replace(/claude-opus-4\.(\d)/g, 'claude-opus-4-$1')
 
94
  );
95
 
96
  const findModelByPath = (path: string, options: ModelOption[]): ModelOption | undefined => {
 
123
  label?: string;
124
  provider?: string;
125
  recommended?: boolean;
 
126
  }): ModelOption | null => {
127
  if (!model.id) return null;
128
  return {
129
  id: modelOptionId(model.id),
130
  name: model.label ?? model.id,
 
131
  modelPath: model.id,
132
  avatarUrl: getHfAvatarUrl(model.id.replace(/^huggingface\//, '')),
133
  recommended: Boolean(model.recommended),
 
134
  };
135
  };
136
 
 
156
  isProcessing?: boolean;
157
  disabled?: boolean;
158
  placeholder?: string;
 
 
159
  }
160
 
161
  interface DatasetUploadResponse {
 
178
  const DATASET_UPLOAD_EXTENSIONS = new Set(['csv', 'json', 'jsonl']);
179
 
180
  const isClaudeModel = (m: ModelOption) => isClaudePath(m.modelPath);
 
 
 
 
 
 
 
181
 
182
  const formatBytes = (bytes: number) => {
183
  if (bytes < 1024) return `${bytes} B`;
 
189
  `https://huggingface.co/datasets/${repoId.split('/').map(encodeURIComponent).join('/')}`
190
  );
191
 
192
+ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop, onDatasetUploaded, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
193
  const [input, setInput] = useState('');
194
  const inputRef = useRef<HTMLTextAreaElement>(null);
195
  const fileInputRef = useRef<HTMLInputElement>(null);
 
197
  const modelOptionsRef = useRef<ModelOption[]>(DEFAULT_MODEL_OPTIONS);
198
  const sessionIdRef = useRef<string | undefined>(sessionId);
199
  const [selectedModelId, setSelectedModelId] = useState<string>(
200
+ () => (
201
+ findModelByPath(initialModelPath ?? '', DEFAULT_MODEL_OPTIONS)?.id
202
+ ?? DEFAULT_FREE_MODEL_OPTION_ID
203
+ ),
204
  );
205
  const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null);
206
  const jobsUpgradeRequired = useAgentStore((s) => s.jobsUpgradeRequired);
 
262
  return () => { cancelled = true; };
263
  }, [sessionId, updateSessionModel]);
264
 
265
+ const visibleModelOptions = modelOptions;
 
 
 
266
  const selectedModel = (
267
  visibleModelOptions.find(m => m.id === selectedModelId)
268
  || modelOptions.find(m => m.id === selectedModelId)
 
278
  }, [disabled, isProcessing]);
279
 
280
  const handleSend = useCallback(() => {
 
 
 
 
 
281
  if (input.trim() && !disabled && !isUploadingDataset) {
282
  onSend(input);
283
  setInput('');
284
  }
285
+ }, [input, disabled, isUploadingDataset, onSend]);
286
 
287
  const handleDatasetUploadClick = useCallback(() => {
288
  fileInputRef.current?.click();
 
359
  return () => window.clearTimeout(timeout);
360
  }, [datasetUploadSuccess]);
361
 
 
 
 
 
 
 
 
362
  const handleKeyDown = useCallback(
363
  (e: KeyboardEvent<HTMLDivElement>) => {
364
  if (e.key === 'Enter' && !e.shiftKey) {
 
371
 
372
  const handleModelClick = (event: React.MouseEvent<HTMLElement>) => {
373
  setModelAnchorEl(event.currentTarget);
 
374
  };
375
 
376
  const handleModelClose = () => {
 
380
  const handleSelectModel = async (model: ModelOption) => {
381
  handleModelClose();
382
  if (!sessionId) return;
 
 
 
 
383
  try {
384
  const res = await apiFetch(`/api/session/${sessionId}/model`, {
385
  method: 'POST',
 
438
  return () => document.removeEventListener('visibilitychange', onVisible);
439
  }, [awaitingTopUp, jobsUpgradeRequired, handleJobsRetry]);
440
 
 
 
 
 
 
 
 
 
 
 
441
  return (
442
  <Box
443
  sx={{
 
735
  }}
736
  />
737
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
738
  </Box>
739
  }
 
 
 
 
740
  />
741
  </MenuItem>
742
  ))}
frontend/src/components/Layout/AppLayout.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
  IconButton,
8
  Alert,
9
  AlertTitle,
 
10
  Snackbar,
11
  useMediaQuery,
12
  useTheme,
@@ -26,6 +27,7 @@ import CodePanel from '@/components/CodePanel/CodePanel';
26
  import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
27
  import YoloControl from '@/components/YoloControl';
28
  import { apiFetch } from '@/utils/api';
 
29
 
30
  const DRAWER_WIDTH = 260;
31
 
@@ -183,6 +185,18 @@ export default function AppLayout() {
183
  ? 'LLM Provider Unreachable'
184
  : 'LLM Error'
185
  : '';
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
  // -- Welcome screen: no sessions at all ---------------------------------
188
  if (!hasAnySessions) {
@@ -466,6 +480,36 @@ export default function AppLayout() {
466
  {llmHealthError.model} — {llmHealthError.error.slice(0, 150)}
467
  </Typography>
468
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  </Alert>
470
  </Snackbar>
471
  </Box>
 
7
  IconButton,
8
  Alert,
9
  AlertTitle,
10
+ Button,
11
  Snackbar,
12
  useMediaQuery,
13
  useTheme,
 
27
  import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
28
  import YoloControl from '@/components/YoloControl';
29
  import { apiFetch } from '@/utils/api';
30
+ import { inferenceCreditCta, isInferenceCreditError } from '@/utils/inferenceBilling';
31
 
32
  const DRAWER_WIDTH = 260;
33
 
 
185
  ? 'LLM Provider Unreachable'
186
  : 'LLM Error'
187
  : '';
188
+ const llmCreditCta =
189
+ llmHealthError && isInferenceCreditError(llmHealthError.error, llmHealthError.errorType)
190
+ ? inferenceCreditCta(user?.plan)
191
+ : null;
192
+
193
+ const trackHealthProClick = () => {
194
+ if (user?.plan === 'pro' || !activeSessionId) return;
195
+ void apiFetch(`/api/pro-click/${activeSessionId}`, {
196
+ method: 'POST',
197
+ body: JSON.stringify({ source: 'llm_health_credit_error', target: 'hf_pro' }),
198
+ }).catch(() => {});
199
+ };
200
 
201
  // -- Welcome screen: no sessions at all ---------------------------------
202
  if (!hasAnySessions) {
 
480
  {llmHealthError.model} — {llmHealthError.error.slice(0, 150)}
481
  </Typography>
482
  )}
483
+ {llmCreditCta && (
484
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1 }}>
485
+ <Button
486
+ component="a"
487
+ href={llmCreditCta.primaryHref}
488
+ target="_blank"
489
+ rel="noopener noreferrer"
490
+ color="inherit"
491
+ size="small"
492
+ variant="outlined"
493
+ onClick={trackHealthProClick}
494
+ sx={{ textTransform: 'none' }}
495
+ >
496
+ {llmCreditCta.primaryLabel}
497
+ </Button>
498
+ {llmCreditCta.secondaryHref && llmCreditCta.secondaryLabel && (
499
+ <Button
500
+ component="a"
501
+ href={llmCreditCta.secondaryHref}
502
+ target="_blank"
503
+ rel="noopener noreferrer"
504
+ color="inherit"
505
+ size="small"
506
+ sx={{ textTransform: 'none' }}
507
+ >
508
+ {llmCreditCta.secondaryLabel}
509
+ </Button>
510
+ )}
511
+ </Box>
512
+ )}
513
  </Alert>
514
  </Snackbar>
515
  </Box>
frontend/src/components/SessionChat.tsx CHANGED
@@ -12,10 +12,7 @@ import { useSessionStore } from '@/store/sessionStore';
12
  import MessageList from '@/components/Chat/MessageList';
13
  import ChatInput from '@/components/Chat/ChatInput';
14
  import ExpiredBanner from '@/components/Chat/ExpiredBanner';
15
- import BillingBanner from '@/components/Chat/BillingBanner';
16
  import ChatErrorBanner from '@/components/Chat/ChatErrorBanner';
17
- import { useUserQuota } from '@/hooks/useUserQuota';
18
- import { isPremiumPath } from '@/utils/model';
19
  import { apiFetch } from '@/utils/api';
20
  import { logger } from '@/utils/logger';
21
 
@@ -88,46 +85,6 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
88
  // SDK status is the ground truth — if it's streaming/submitted, agent is busy
89
  const sdkBusy = status === 'streaming' || status === 'submitted';
90
  const busy = isProcessing || sdkBusy;
91
- const { quota, refresh: refreshQuota } = useUserQuota({ enabled: isActive });
92
-
93
- // Whether this session's premium usage is being billed to the user's own HF
94
- // account (past the subsidized daily allowance). Re-read after each turn,
95
- // since the backend flips it at submit time. Only premium-model sessions can
96
- // ever be user-billed, so skip the fetch for free models.
97
- const [premiumBilled, setPremiumBilled] = useState<boolean | null>(null);
98
- const [premiumQuotaCounted, setPremiumQuotaCounted] = useState<boolean | null>(null);
99
- const onPremiumModel = isPremiumPath(sessionMeta?.model ?? undefined);
100
- useEffect(() => {
101
- if (!isActive || !onPremiumModel) {
102
- setPremiumBilled(null);
103
- setPremiumQuotaCounted(null);
104
- return;
105
- }
106
- if (busy) return;
107
- let cancelled = false;
108
- apiFetch(`/api/session/${sessionId}`)
109
- .then((r) => (r.ok ? r.json() : null))
110
- .then((d) => {
111
- if (!cancelled && d) {
112
- setPremiumBilled(Boolean(d.premium_user_billed));
113
- setPremiumQuotaCounted(Boolean(d.premium_quota_counted));
114
- }
115
- })
116
- .catch(() => {});
117
- return () => {
118
- cancelled = true;
119
- };
120
- }, [busy, isActive, onPremiumModel, sessionId]);
121
-
122
- const sessionPremiumBilled = premiumBilled ?? Boolean(sessionMeta?.premiumUserBilled);
123
- const sessionPremiumQuotaCounted =
124
- premiumQuotaCounted ?? Boolean(sessionMeta?.premiumQuotaCounted);
125
- const premiumBillingNotice =
126
- sessionPremiumBilled ||
127
- (isActive &&
128
- onPremiumModel &&
129
- quota?.premiumRemaining === 0 &&
130
- !sessionPremiumQuotaCounted);
131
 
132
  const handleSendMessage = useCallback(
133
  async (text: string) => {
@@ -175,7 +132,6 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
175
  <ExpiredBanner sessionId={sessionId} />
176
  ) : (
177
  <>
178
- {premiumBillingNotice && <BillingBanner />}
179
  {chatError && (
180
  <ChatErrorBanner
181
  error={chatError}
@@ -192,8 +148,6 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
192
  onDatasetUploaded={refreshMessages}
193
  isProcessing={busy}
194
  disabled={!isConnected || activityStatus.type === 'waiting-approval'}
195
- quota={quota}
196
- refreshQuota={refreshQuota}
197
  placeholder={
198
  activityStatus.type === 'waiting-approval'
199
  ? 'Approve or reject pending tools first...'
 
12
  import MessageList from '@/components/Chat/MessageList';
13
  import ChatInput from '@/components/Chat/ChatInput';
14
  import ExpiredBanner from '@/components/Chat/ExpiredBanner';
 
15
  import ChatErrorBanner from '@/components/Chat/ChatErrorBanner';
 
 
16
  import { apiFetch } from '@/utils/api';
17
  import { logger } from '@/utils/logger';
18
 
 
85
  // SDK status is the ground truth — if it's streaming/submitted, agent is busy
86
  const sdkBusy = status === 'streaming' || status === 'submitted';
87
  const busy = isProcessing || sdkBusy;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
  const handleSendMessage = useCallback(
90
  async (text: string) => {
 
132
  <ExpiredBanner sessionId={sessionId} />
133
  ) : (
134
  <>
 
135
  {chatError && (
136
  <ChatErrorBanner
137
  error={chatError}
 
148
  onDatasetUploaded={refreshMessages}
149
  isProcessing={busy}
150
  disabled={!isConnected || activityStatus.type === 'waiting-approval'}
 
 
151
  placeholder={
152
  activityStatus.type === 'waiting-approval'
153
  ? 'Approve or reject pending tools first...'
frontend/src/hooks/useAuth.ts CHANGED
@@ -48,6 +48,7 @@ export function useAuth() {
48
  username: data.username,
49
  name: data.name,
50
  picture: data.picture,
 
51
  });
52
  logger.log('Authenticated as', data.username);
53
  return;
@@ -59,7 +60,7 @@ export function useAuth() {
59
  const statusData = await statusRes.json();
60
  if (!statusData.auth_enabled) {
61
  // Dev mode — no OAuth configured
62
- if (!cancelled) setUser({ authenticated: true, username: 'dev' });
63
  return;
64
  }
65
 
@@ -67,7 +68,7 @@ export function useAuth() {
67
  if (!cancelled) setUser(null);
68
  } catch {
69
  // Backend unreachable — assume dev mode
70
- if (!cancelled) setUser({ authenticated: true, username: 'dev' });
71
  }
72
  }
73
 
 
48
  username: data.username,
49
  name: data.name,
50
  picture: data.picture,
51
+ plan: data.plan === 'pro' ? 'pro' : 'free',
52
  });
53
  logger.log('Authenticated as', data.username);
54
  return;
 
60
  const statusData = await statusRes.json();
61
  if (!statusData.auth_enabled) {
62
  // Dev mode — no OAuth configured
63
+ if (!cancelled) setUser({ authenticated: true, username: 'dev', plan: 'pro' });
64
  return;
65
  }
66
 
 
68
  if (!cancelled) setUser(null);
69
  } catch {
70
  // Backend unreachable — assume dev mode
71
+ if (!cancelled) setUser({ authenticated: true, username: 'dev', plan: 'pro' });
72
  }
73
  }
74
 
frontend/src/hooks/useUserQuota.ts DELETED
@@ -1,51 +0,0 @@
1
- /**
2
- * Reads the current user's premium-model daily quota + plan tier from the backend.
3
- *
4
- * Fetches once when the user becomes authenticated, and exposes a `refresh()`
5
- * that callers invoke after a successful session-create / model-switch so the
6
- * chip reflects the new count without a full page reload.
7
- */
8
- import { useCallback, useEffect, useState } from 'react';
9
- import { useAgentStore } from '@/store/agentStore';
10
- import { apiFetch } from '@/utils/api';
11
-
12
- export type PlanTier = 'free' | 'pro';
13
-
14
- export interface UserQuota {
15
- plan: PlanTier;
16
- premiumUsedToday: number;
17
- premiumDailyCap: number;
18
- premiumRemaining: number;
19
- }
20
-
21
- export function useUserQuota({ enabled = true }: { enabled?: boolean } = {}) {
22
- const user = useAgentStore((s) => s.user);
23
- const [quota, setQuota] = useState<UserQuota | null>(null);
24
- const [loading, setLoading] = useState(false);
25
-
26
- const refresh = useCallback(async () => {
27
- if (!enabled || !user?.authenticated) return;
28
- setLoading(true);
29
- try {
30
- const res = await apiFetch('/api/user/quota');
31
- if (!res.ok) return;
32
- const data = await res.json();
33
- setQuota({
34
- plan: (data.plan ?? 'free') as PlanTier,
35
- premiumUsedToday: data.premium_used_today ?? 0,
36
- premiumDailyCap: data.premium_daily_cap ?? 1,
37
- premiumRemaining: data.premium_remaining ?? 0,
38
- });
39
- } catch {
40
- /* backend unreachable — leave previous value */
41
- } finally {
42
- setLoading(false);
43
- }
44
- }, [enabled, user?.authenticated]);
45
-
46
- useEffect(() => {
47
- refresh();
48
- }, [refresh]);
49
-
50
- return { quota, loading, refresh };
51
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/store/agentStore.ts CHANGED
@@ -6,7 +6,7 @@
6
  * - Connection / processing flags
7
  * - Panel state (right panel — single-artifact pattern)
8
  * - Plan state
9
- * - User info / health and quota banners
10
  * - Edited scripts (for hf_jobs code editing)
11
  *
12
  * Per-session state:
 
6
  * - Connection / processing flags
7
  * - Panel state (right panel — single-artifact pattern)
8
  * - Plan state
9
+ * - User info / health banners
10
  * - Edited scripts (for hf_jobs code editing)
11
  *
12
  * Per-session state:
frontend/src/store/sessionStore.ts CHANGED
@@ -33,8 +33,6 @@ interface SessionStore {
33
  is_active?: boolean;
34
  is_processing?: boolean;
35
  model?: string | null;
36
- premium_user_billed?: boolean;
37
- premium_quota_counted?: boolean;
38
  pending_approval?: unknown[] | null;
39
  auto_approval?: {
40
  enabled?: boolean;
@@ -73,8 +71,6 @@ export const useSessionStore = create<SessionStore>()(
73
  autoApprovalCostCapUsd: null,
74
  autoApprovalEstimatedSpendUsd: 0,
75
  autoApprovalRemainingUsd: null,
76
- premiumUserBilled: false,
77
- premiumQuotaCounted: false,
78
  };
79
  set((state) => ({
80
  sessions: [...state.sessions, newSession],
@@ -128,8 +124,6 @@ export const useSessionStore = create<SessionStore>()(
128
  isActive: server.is_active ?? existing.isActive,
129
  isProcessing: Boolean(server.is_processing),
130
  model: server.model ?? existing.model ?? null,
131
- premiumUserBilled: Boolean(server.premium_user_billed),
132
- premiumQuotaCounted: Boolean(server.premium_quota_counted),
133
  needsAttention: Boolean(server.pending_approval?.length) || existing.needsAttention,
134
  expired: false,
135
  ...(auto
@@ -159,8 +153,6 @@ export const useSessionStore = create<SessionStore>()(
159
  autoApprovalCostCapUsd: server.auto_approval?.cost_cap_usd ?? null,
160
  autoApprovalEstimatedSpendUsd: server.auto_approval?.estimated_spend_usd ?? 0,
161
  autoApprovalRemainingUsd: server.auto_approval?.remaining_usd ?? null,
162
- premiumUserBilled: Boolean(server.premium_user_billed),
163
- premiumQuotaCounted: Boolean(server.premium_quota_counted),
164
  };
165
  merged.push(newSession);
166
  byId.set(id, newSession);
@@ -252,13 +244,11 @@ export const useSessionStore = create<SessionStore>()(
252
  {
253
  name: 'hf-agent-sessions',
254
  partialize: (state) => ({
255
- // Reset transient session flags so cold-load state is re-derived from
256
- // the live GET /sessions list and current daily quota window.
257
  sessions: state.sessions.map((s) => ({
258
  ...s,
259
  isProcessing: false,
260
- premiumUserBilled: false,
261
- premiumQuotaCounted: false,
262
  })),
263
  activeSessionId: state.activeSessionId,
264
  }),
 
33
  is_active?: boolean;
34
  is_processing?: boolean;
35
  model?: string | null;
 
 
36
  pending_approval?: unknown[] | null;
37
  auto_approval?: {
38
  enabled?: boolean;
 
71
  autoApprovalCostCapUsd: null,
72
  autoApprovalEstimatedSpendUsd: 0,
73
  autoApprovalRemainingUsd: null,
 
 
74
  };
75
  set((state) => ({
76
  sessions: [...state.sessions, newSession],
 
124
  isActive: server.is_active ?? existing.isActive,
125
  isProcessing: Boolean(server.is_processing),
126
  model: server.model ?? existing.model ?? null,
 
 
127
  needsAttention: Boolean(server.pending_approval?.length) || existing.needsAttention,
128
  expired: false,
129
  ...(auto
 
153
  autoApprovalCostCapUsd: server.auto_approval?.cost_cap_usd ?? null,
154
  autoApprovalEstimatedSpendUsd: server.auto_approval?.estimated_spend_usd ?? 0,
155
  autoApprovalRemainingUsd: server.auto_approval?.remaining_usd ?? null,
 
 
156
  };
157
  merged.push(newSession);
158
  byId.set(id, newSession);
 
244
  {
245
  name: 'hf-agent-sessions',
246
  partialize: (state) => ({
247
+ // Reset transient processing so cold-load state is re-derived from
248
+ // the live GET /sessions list.
249
  sessions: state.sessions.map((s) => ({
250
  ...s,
251
  isProcessing: false,
 
 
252
  })),
253
  activeSessionId: state.activeSessionId,
254
  }),
frontend/src/types/agent.ts CHANGED
@@ -33,8 +33,6 @@ export interface SessionMeta {
33
  autoApprovalCostCapUsd?: number | null;
34
  autoApprovalEstimatedSpendUsd?: number;
35
  autoApprovalRemainingUsd?: number | null;
36
- premiumUserBilled?: boolean;
37
- premiumQuotaCounted?: boolean;
38
  }
39
 
40
  export interface ToolApproval {
@@ -49,4 +47,5 @@ export interface User {
49
  username?: string;
50
  name?: string;
51
  picture?: string;
 
52
  }
 
33
  autoApprovalCostCapUsd?: number | null;
34
  autoApprovalEstimatedSpendUsd?: number;
35
  autoApprovalRemainingUsd?: number | null;
 
 
36
  }
37
 
38
  export interface ToolApproval {
 
47
  username?: string;
48
  name?: string;
49
  picture?: string;
50
+ plan?: 'free' | 'pro';
51
  }
frontend/src/utils/inferenceBilling.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const INFERENCE_PROVIDERS_PRICING_URL = 'https://huggingface.co/docs/inference-providers/pricing';
2
+ export const HF_PRO_SUBSCRIBE_URL = 'https://huggingface.co/subscribe/pro';
3
+
4
+ export type PlanTier = 'free' | 'pro';
5
+
6
+ export function isInferenceCreditError(error: string | undefined, errorType?: string): boolean {
7
+ if (errorType === 'credits') return true;
8
+ const value = (error ?? '').toLowerCase();
9
+ return (
10
+ value.includes('402') ||
11
+ (value.includes('credit') && (
12
+ value.includes('insufficient') ||
13
+ value.includes('exhausted') ||
14
+ value.includes('out of') ||
15
+ value.includes('billing')
16
+ ))
17
+ );
18
+ }
19
+
20
+ export function inferenceCreditCta(plan: PlanTier | undefined) {
21
+ if (plan === 'pro') {
22
+ return {
23
+ title: 'Inference credits exhausted',
24
+ message: 'Your HF account needs more Inference Providers credits before this model can continue.',
25
+ primaryLabel: 'View pricing',
26
+ primaryHref: INFERENCE_PROVIDERS_PRICING_URL,
27
+ secondaryLabel: null,
28
+ secondaryHref: null,
29
+ };
30
+ }
31
+
32
+ return {
33
+ title: 'Inference credits exhausted',
34
+ message: 'Upgrade to HF PRO for more monthly Inference Providers usage, or review pay-as-you-go pricing.',
35
+ primaryLabel: 'Upgrade to PRO',
36
+ primaryHref: HF_PRO_SUBSCRIBE_URL,
37
+ secondaryLabel: 'View pricing',
38
+ secondaryHref: INFERENCE_PROVIDERS_PRICING_URL,
39
+ };
40
+ }
frontend/src/utils/model.ts CHANGED
@@ -6,30 +6,10 @@
6
  * AVAILABLE_MODELS in backend/routes/agent.py.
7
  */
8
 
9
- export const CLAUDE_SONNET_46_MODEL_PATH = 'anthropic/claude-sonnet-4-6:fal-ai';
10
  export const CLAUDE_OPUS_48_MODEL_PATH = 'anthropic/claude-opus-4.8:fal-ai';
11
- export const CLAUDE_MODEL_PATH = CLAUDE_SONNET_46_MODEL_PATH;
12
  export const GPT_55_MODEL_PATH = 'openai/gpt-5.5:fal-ai';
13
-
14
- const PREMIUM_MODEL_PATHS = new Set([
15
- CLAUDE_SONNET_46_MODEL_PATH,
16
- CLAUDE_OPUS_48_MODEL_PATH,
17
- GPT_55_MODEL_PATH,
18
- ]);
19
-
20
- const PRO_ONLY_MODEL_PATHS = new Set([
21
- CLAUDE_OPUS_48_MODEL_PATH,
22
- GPT_55_MODEL_PATH,
23
- ]);
24
 
25
  export function isClaudePath(modelPath: string | undefined): boolean {
26
  return !!modelPath && modelPath.includes('anthropic');
27
  }
28
-
29
- export function isPremiumPath(modelPath: string | undefined): boolean {
30
- return !!modelPath && PREMIUM_MODEL_PATHS.has(modelPath);
31
- }
32
-
33
- export function isProOnlyPath(modelPath: string | undefined): boolean {
34
- return !!modelPath && PRO_ONLY_MODEL_PATHS.has(modelPath);
35
- }
 
6
  * AVAILABLE_MODELS in backend/routes/agent.py.
7
  */
8
 
 
9
  export const CLAUDE_OPUS_48_MODEL_PATH = 'anthropic/claude-opus-4.8:fal-ai';
 
10
  export const GPT_55_MODEL_PATH = 'openai/gpt-5.5:fal-ai';
11
+ export const KIMI_K26_MODEL_PATH = 'moonshotai/Kimi-K2.6';
 
 
 
 
 
 
 
 
 
 
12
 
13
  export function isClaudePath(modelPath: string | undefined): boolean {
14
  return !!modelPath && modelPath.includes('anthropic');
15
  }
 
 
 
 
 
 
 
 
tests/integration/test_live_thinking_models.py CHANGED
@@ -82,7 +82,6 @@ async def test_live_default_router_model_does_not_replay_reasoning_metadata():
82
  DEFAULT_MODEL_ID,
83
  os.environ["HF_TOKEN"],
84
  reasoning_effort="low",
85
- bill_to_user=True,
86
  )
87
 
88
  result = await _call_llm_streaming(
 
82
  DEFAULT_MODEL_ID,
83
  os.environ["HF_TOKEN"],
84
  reasoning_effort="low",
 
85
  )
86
 
87
  result = await _call_llm_streaming(
tests/unit/test_agent_model_gating.py CHANGED
@@ -1,4 +1,4 @@
1
- """Tests for premium model handling in backend/routes/agent.py."""
2
 
3
  import sys
4
  from pathlib import Path
@@ -14,66 +14,48 @@ if str(_BACKEND_DIR) not in sys.path:
14
  from routes import agent # noqa: E402
15
 
16
 
17
- @pytest.fixture(autouse=True)
18
- def _reset_quota_store():
19
- agent.user_quotas._reset_for_tests()
20
- yield
21
- agent.user_quotas._reset_for_tests()
22
-
23
-
24
- def _premium_session(model: str = agent.DEFAULT_PREMIUM_MODEL_ID):
25
- return SimpleNamespace(
26
- claude_counted=False,
27
- claude_counted_day=None,
28
- session=SimpleNamespace(
29
- config=SimpleNamespace(model_name=model),
30
- premium_user_billed=False,
31
- ),
32
- )
33
-
34
-
35
- def test_premium_model_predicate_uses_router_ids_only():
36
- assert agent._is_premium_model(agent.DEFAULT_PREMIUM_MODEL_ID)
37
- assert agent._is_premium_model(agent.DEFAULT_OPUS_MODEL_ID)
38
- assert agent._is_premium_model(agent.DEFAULT_GPT_MODEL_ID)
39
- assert not agent._is_pro_only_premium_model(agent.DEFAULT_PREMIUM_MODEL_ID)
40
- assert agent._is_pro_only_premium_model(agent.DEFAULT_OPUS_MODEL_ID)
41
- assert agent._is_pro_only_premium_model(agent.DEFAULT_GPT_MODEL_ID)
42
- assert agent._is_user_billed(agent.DEFAULT_PREMIUM_MODEL_ID)
43
- assert not agent._is_premium_model("moonshotai/Kimi-K2.6")
44
- assert not agent._is_premium_model("unsupported/model")
45
 
 
 
 
 
 
46
 
47
- def test_available_models_mark_opus_and_gpt_as_pro_only():
48
- models = {model["id"]: model for model in agent.AVAILABLE_MODELS}
49
 
50
- assert models[agent.DEFAULT_PREMIUM_MODEL_ID]["label"] == "Claude Sonnet 4.6"
51
- assert models[agent.DEFAULT_PREMIUM_MODEL_ID]["minimum_plan"] == "free"
52
- assert models[agent.DEFAULT_OPUS_MODEL_ID]["minimum_plan"] == "pro"
53
- assert models[agent.DEFAULT_GPT_MODEL_ID]["minimum_plan"] == "pro"
 
 
54
 
55
 
56
  @pytest.mark.asyncio
57
- async def test_default_session_uses_configured_default_model():
58
- model = await agent._model_override_for_new_session(None, None)
59
-
60
- assert model is None
 
 
 
 
 
61
 
62
 
63
  @pytest.mark.asyncio
64
- async def test_explicit_premium_session_allowed_for_authenticated_user():
65
  model = await agent._model_override_for_new_session(
66
- None,
67
- agent.DEFAULT_PREMIUM_MODEL_ID,
68
  )
69
 
70
- assert model == agent.DEFAULT_PREMIUM_MODEL_ID
71
 
72
 
73
  @pytest.mark.asyncio
74
- async def test_switching_to_premium_model_is_allowed_for_authenticated_user(
75
- monkeypatch,
76
- ):
77
  updated = []
78
 
79
  async def fake_check_session_access(session_id, user, request=None):
@@ -93,25 +75,21 @@ async def test_switching_to_premium_model_is_allowed_for_authenticated_user(
93
 
94
  response = await agent.set_session_model(
95
  "s1",
96
- {"model": agent.DEFAULT_PREMIUM_MODEL_ID},
97
  request=None,
98
  user={"user_id": "u1", "plan": "free"},
99
  )
100
 
101
- assert response == {"session_id": "s1", "model": agent.DEFAULT_PREMIUM_MODEL_ID}
102
- assert updated == [("s1", agent.DEFAULT_PREMIUM_MODEL_ID)]
103
 
104
 
105
  @pytest.mark.asyncio
106
- async def test_switching_to_pro_only_premium_model_is_allowed_for_pro_user(
107
- monkeypatch,
108
- ):
109
  updated = []
110
 
111
  async def fake_check_session_access(session_id, user, request=None):
112
- assert session_id == "s1"
113
- assert user["user_id"] == "u1"
114
- return SimpleNamespace(user_id="u1")
115
 
116
  async def fake_update_session_model(session_id, model_id):
117
  updated.append((session_id, model_id))
@@ -127,38 +105,13 @@ async def test_switching_to_pro_only_premium_model_is_allowed_for_pro_user(
127
  "s1",
128
  {"model": agent.DEFAULT_GPT_MODEL_ID},
129
  request=None,
130
- user={"user_id": "u1", "plan": "pro"},
131
  )
132
 
133
  assert response == {"session_id": "s1", "model": agent.DEFAULT_GPT_MODEL_ID}
134
  assert updated == [("s1", agent.DEFAULT_GPT_MODEL_ID)]
135
 
136
 
137
- @pytest.mark.asyncio
138
- async def test_switching_to_pro_only_premium_model_is_rejected_for_free_user(
139
- monkeypatch,
140
- ):
141
- async def fake_check_session_access(session_id, user, request=None):
142
- return SimpleNamespace(user_id=user["user_id"])
143
-
144
- async def fail_if_updated(session_id, model_id):
145
- raise AssertionError("free users should not switch to pro-only models")
146
-
147
- monkeypatch.setattr(agent, "_check_session_access", fake_check_session_access)
148
- monkeypatch.setattr(agent.session_manager, "update_session_model", fail_if_updated)
149
-
150
- with pytest.raises(HTTPException) as exc_info:
151
- await agent.set_session_model(
152
- "s1",
153
- {"model": agent.DEFAULT_GPT_MODEL_ID},
154
- request=None,
155
- user={"user_id": "u1", "plan": "free"},
156
- )
157
-
158
- assert exc_info.value.status_code == 403
159
- assert exc_info.value.detail["error"] == "model_requires_pro"
160
-
161
-
162
  @pytest.mark.asyncio
163
  async def test_switching_to_unknown_model_id_is_rejected(monkeypatch):
164
  async def fake_check_session_access(session_id, user, request=None):
@@ -179,232 +132,8 @@ async def test_switching_to_unknown_model_id_is_rejected(monkeypatch):
179
 
180
 
181
  @pytest.mark.asyncio
182
- async def test_premium_quota_charges_without_user_billing_inside_allowance(monkeypatch):
183
- persisted = []
184
-
185
- async def fake_persist_session_snapshot(agent_session):
186
- persisted.append(agent_session)
187
-
188
- monkeypatch.setattr(
189
- agent.session_manager,
190
- "persist_session_snapshot",
191
- fake_persist_session_snapshot,
192
- )
193
-
194
- agent_session = _premium_session()
195
-
196
- await agent._enforce_premium_model_quota(
197
- {"user_id": "u1", "plan": "free"},
198
- agent_session,
199
- )
200
-
201
- assert agent_session.claude_counted is True
202
- assert agent_session.claude_counted_day == agent.user_quotas.current_quota_day()
203
- assert agent_session.session.premium_user_billed is False
204
- assert persisted == [agent_session]
205
- assert await agent.user_quotas.get_claude_used_today("u1") == 1
206
-
207
-
208
- @pytest.mark.asyncio
209
- async def test_premium_quota_counts_same_session_once_per_day(monkeypatch):
210
- async def fake_persist_session_snapshot(_agent_session):
211
- return None
212
-
213
- monkeypatch.setattr(
214
- agent.session_manager,
215
- "persist_session_snapshot",
216
- fake_persist_session_snapshot,
217
- )
218
-
219
- agent_session = _premium_session()
220
-
221
- await agent._enforce_premium_model_quota(
222
- {"user_id": "u1", "plan": "free"},
223
- agent_session,
224
- )
225
- await agent._enforce_premium_model_quota(
226
- {"user_id": "u1", "plan": "free"},
227
- agent_session,
228
- )
229
-
230
- assert agent_session.claude_counted is True
231
- assert agent_session.claude_counted_day == agent.user_quotas.current_quota_day()
232
- assert await agent.user_quotas.get_claude_used_today("u1") == 1
233
-
234
-
235
- @pytest.mark.asyncio
236
- async def test_premium_quota_counts_stale_session_again_today(monkeypatch):
237
- async def fake_persist_session_snapshot(_agent_session):
238
- return None
239
-
240
- monkeypatch.setattr(
241
- agent.session_manager,
242
- "persist_session_snapshot",
243
- fake_persist_session_snapshot,
244
- )
245
-
246
- agent_session = _premium_session()
247
- agent_session.claude_counted = True
248
- agent_session.claude_counted_day = "2000-01-01"
249
- agent_session.session.premium_user_billed = True
250
-
251
- await agent._enforce_premium_model_quota(
252
- {"user_id": "u1", "plan": "free"},
253
- agent_session,
254
- )
255
-
256
- assert agent_session.claude_counted is True
257
- assert agent_session.claude_counted_day == agent.user_quotas.current_quota_day()
258
- assert agent_session.session.premium_user_billed is False
259
- assert await agent.user_quotas.get_claude_used_today("u1") == 1
260
-
261
-
262
- @pytest.mark.asyncio
263
- async def test_free_user_gets_two_subsidized_premium_sessions_then_user_billing(
264
- monkeypatch,
265
- ):
266
- async def fake_persist_session_snapshot(_agent_session):
267
- return None
268
-
269
- monkeypatch.setattr(
270
- agent.session_manager,
271
- "persist_session_snapshot",
272
- fake_persist_session_snapshot,
273
- )
274
-
275
- first = _premium_session()
276
- await agent._enforce_premium_model_quota({"user_id": "g1", "plan": "free"}, first)
277
- assert first.session.premium_user_billed is False
278
-
279
- second = _premium_session()
280
- await agent._enforce_premium_model_quota({"user_id": "g1", "plan": "free"}, second)
281
- assert second.session.premium_user_billed is False
282
-
283
- third = _premium_session()
284
- await agent._enforce_premium_model_quota({"user_id": "g1", "plan": "free"}, third)
285
- assert third.session.premium_user_billed is True
286
- assert third.claude_counted_day == agent.user_quotas.current_quota_day()
287
- assert await agent.user_quotas.get_claude_used_today("g1") == 2
288
-
289
-
290
- @pytest.mark.asyncio
291
- async def test_free_model_does_not_consume_premium_quota(monkeypatch):
292
- async def fail_if_persisted(_agent_session):
293
- raise AssertionError("free model should not consume premium quota")
294
-
295
- monkeypatch.setattr(
296
- agent.session_manager,
297
- "persist_session_snapshot",
298
- fail_if_persisted,
299
- )
300
-
301
- agent_session = _premium_session("moonshotai/Kimi-K2.6")
302
-
303
- await agent._enforce_premium_model_quota(
304
- {"user_id": "u1", "plan": "free"},
305
- agent_session,
306
- )
307
-
308
- assert agent_session.claude_counted is False
309
- assert agent_session.claude_counted_day is None
310
- assert await agent.user_quotas.get_claude_used_today("u1") == 0
311
-
312
-
313
- @pytest.mark.asyncio
314
- async def test_free_user_cannot_spend_quota_on_pro_only_premium_model(monkeypatch):
315
- async def fail_if_persisted(_agent_session):
316
- raise AssertionError("rejected model should not persist quota state")
317
-
318
- monkeypatch.setattr(
319
- agent.session_manager,
320
- "persist_session_snapshot",
321
- fail_if_persisted,
322
- )
323
-
324
- agent_session = _premium_session(agent.DEFAULT_OPUS_MODEL_ID)
325
-
326
- with pytest.raises(HTTPException) as exc_info:
327
- await agent._enforce_premium_model_quota(
328
- {"user_id": "u1", "plan": "free"},
329
- agent_session,
330
- )
331
-
332
- assert exc_info.value.status_code == 403
333
- assert exc_info.value.detail["error"] == "model_requires_pro"
334
- assert agent_session.claude_counted is False
335
- assert agent_session.claude_counted_day is None
336
- assert await agent.user_quotas.get_claude_used_today("u1") == 0
337
-
338
-
339
- @pytest.mark.asyncio
340
- async def test_downgraded_user_cannot_continue_counted_pro_only_session(monkeypatch):
341
- async def fail_if_persisted(_agent_session):
342
- raise AssertionError("already-counted rejected session should not persist")
343
-
344
- monkeypatch.setattr(
345
- agent.session_manager,
346
- "persist_session_snapshot",
347
- fail_if_persisted,
348
- )
349
-
350
- agent_session = _premium_session(agent.DEFAULT_OPUS_MODEL_ID)
351
- agent_session.claude_counted = True
352
-
353
- with pytest.raises(HTTPException) as exc_info:
354
- await agent._enforce_premium_model_quota(
355
- {"user_id": "u1", "plan": "free"},
356
- agent_session,
357
- )
358
-
359
- assert exc_info.value.status_code == 403
360
- assert exc_info.value.detail["error"] == "model_requires_pro"
361
- assert agent_session.claude_counted is True
362
- assert await agent.user_quotas.get_claude_used_today("u1") == 0
363
-
364
-
365
- @pytest.mark.asyncio
366
- async def test_pro_user_uses_pro_premium_quota(monkeypatch):
367
- async def fake_persist_session_snapshot(_agent_session):
368
- return None
369
-
370
- monkeypatch.setattr(
371
- agent.session_manager,
372
- "persist_session_snapshot",
373
- fake_persist_session_snapshot,
374
- )
375
-
376
- for index in range(2):
377
- agent_session = _premium_session()
378
- await agent._enforce_premium_model_quota(
379
- {"user_id": "pro-user", "plan": "pro"},
380
- agent_session,
381
- )
382
- assert agent_session.claude_counted is True
383
- assert agent_session.claude_counted_day == agent.user_quotas.current_quota_day()
384
- assert agent_session.session.premium_user_billed is False
385
- assert await agent.user_quotas.get_claude_used_today("pro-user") == index + 1
386
-
387
-
388
- @pytest.mark.asyncio
389
- async def test_pro_user_billable_overflow_also_bills_user(monkeypatch):
390
- async def fake_persist(_agent_session):
391
- return None
392
-
393
- monkeypatch.setattr(agent.session_manager, "persist_session_snapshot", fake_persist)
394
- monkeypatch.setattr(agent.user_quotas, "daily_cap_for", lambda plan: 1)
395
-
396
- await agent._enforce_premium_model_quota(
397
- {"user_id": "p1", "plan": "pro"}, _premium_session()
398
- )
399
- over = _premium_session()
400
- await agent._enforce_premium_model_quota({"user_id": "p1", "plan": "pro"}, over)
401
- assert over.session.premium_user_billed is True
402
-
403
-
404
- @pytest.mark.asyncio
405
- async def test_restore_summary_enforces_premium_quota_before_seed(monkeypatch):
406
  events = []
407
- agent_session = _premium_session()
408
 
409
  class Request:
410
  headers = {}
@@ -418,21 +147,14 @@ async def test_restore_summary_enforces_premium_quota_before_seed(monkeypatch):
418
  session_id, user, request, preload_sandbox=True
419
  ):
420
  events.append(("check", session_id, preload_sandbox))
421
- return agent_session
422
-
423
- async def fake_enforce_quota(user, session):
424
- assert user["user_id"] == "u1"
425
- assert session is agent_session
426
- session.session.premium_user_billed = True
427
- events.append(("quota", session.session.config.model_name))
428
 
429
  async def fake_seed(session_id, messages):
430
- events.append(("seed", session_id, agent_session.session.premium_user_billed))
431
  return len(messages)
432
 
433
  monkeypatch.setattr(agent.session_manager, "create_session", fake_create_session)
434
  monkeypatch.setattr(agent, "_check_session_access", fake_check_session_access)
435
- monkeypatch.setattr(agent, "_enforce_premium_model_quota", fake_enforce_quota)
436
  monkeypatch.setattr(agent.session_manager, "seed_from_summary", fake_seed)
437
 
438
  response = await agent.restore_session_summary(
@@ -442,73 +164,9 @@ async def test_restore_summary_enforces_premium_quota_before_seed(monkeypatch):
442
  )
443
 
444
  assert response.session_id == "s1"
 
445
  assert events == [
446
- ("create", None),
447
  ("check", "s1", False),
448
- ("quota", agent.DEFAULT_PREMIUM_MODEL_ID),
449
- ("seed", "s1", True),
450
- ]
451
-
452
-
453
- @pytest.mark.asyncio
454
- async def test_user_quota_response_uses_premium_fields_only(monkeypatch):
455
- async def fake_get_used_today(user_id):
456
- assert user_id == "u1"
457
- return 2
458
-
459
- monkeypatch.setattr(agent.user_quotas, "get_claude_used_today", fake_get_used_today)
460
- monkeypatch.setattr(agent.user_quotas, "daily_cap_for", lambda plan: 5)
461
-
462
- response = await agent.get_user_quota({"user_id": "u1", "plan": "pro"})
463
-
464
- assert response == {
465
- "plan": "pro",
466
- "premium_used_today": 2,
467
- "premium_daily_cap": 5,
468
- "premium_remaining": 3,
469
- }
470
-
471
-
472
- @pytest.mark.asyncio
473
- async def test_set_session_yolo_calls_manager_with_cap_presence(monkeypatch):
474
- async def fake_check_session_access(session_id, user, request=None):
475
- assert session_id == "s1"
476
- assert user["user_id"] == "u1"
477
- return object()
478
-
479
- calls = []
480
-
481
- async def fake_update_session_auto_approval(session_id, **kwargs):
482
- calls.append((session_id, kwargs))
483
- return {
484
- "enabled": kwargs["enabled"],
485
- "cost_cap_usd": 7.5,
486
- "estimated_spend_usd": 0.0,
487
- "remaining_usd": 7.5,
488
- }
489
-
490
- monkeypatch.setattr(agent, "_check_session_access", fake_check_session_access)
491
- monkeypatch.setattr(
492
- agent.session_manager,
493
- "update_session_auto_approval",
494
- fake_update_session_auto_approval,
495
- )
496
-
497
- response = await agent.set_session_yolo(
498
- "s1",
499
- agent.SessionYoloRequest(enabled=True, cost_cap_usd=7.5),
500
- {"user_id": "u1"},
501
- )
502
-
503
- assert response["enabled"] is True
504
- assert response["remaining_usd"] == 7.5
505
- assert calls == [
506
- (
507
- "s1",
508
- {
509
- "enabled": True,
510
- "cost_cap_usd": 7.5,
511
- "cap_provided": True,
512
- },
513
- )
514
  ]
 
1
+ """Tests for hosted model handling in backend/routes/agent.py."""
2
 
3
  import sys
4
  from pathlib import Path
 
14
  from routes import agent # noqa: E402
15
 
16
 
17
+ def test_available_models_exclude_sonnet_and_have_no_pro_gate():
18
+ models = {model["id"]: model for model in agent.AVAILABLE_MODELS}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
+ assert models[agent.DEFAULT_OPUS_MODEL_ID]["label"] == "Claude Opus 4.8"
21
+ assert models[agent.DEFAULT_OPUS_MODEL_ID]["recommended"] is True
22
+ assert "recommended" not in models[agent.DEFAULT_FREE_MODEL_ID]
23
+ assert all("minimum_plan" not in model for model in models.values())
24
+ assert all("tier" not in model for model in models.values())
25
 
 
 
26
 
27
+ def test_default_model_for_user_is_plan_aware():
28
+ assert agent._default_model_for_user({"plan": "pro"}) == agent.DEFAULT_OPUS_MODEL_ID
29
+ assert (
30
+ agent._default_model_for_user({"plan": "free"}) == agent.DEFAULT_FREE_MODEL_ID
31
+ )
32
+ assert agent._default_model_for_user({}) == agent.DEFAULT_FREE_MODEL_ID
33
 
34
 
35
  @pytest.mark.asyncio
36
+ async def test_empty_session_model_uses_plan_default():
37
+ assert (
38
+ await agent._model_override_for_new_session(None, {"plan": "pro"})
39
+ == agent.DEFAULT_OPUS_MODEL_ID
40
+ )
41
+ assert (
42
+ await agent._model_override_for_new_session(None, {"plan": "free"})
43
+ == agent.DEFAULT_FREE_MODEL_ID
44
+ )
45
 
46
 
47
  @pytest.mark.asyncio
48
+ async def test_explicit_session_model_is_honored():
49
  model = await agent._model_override_for_new_session(
50
+ agent.DEFAULT_GPT_MODEL_ID,
51
+ {"plan": "free"},
52
  )
53
 
54
+ assert model == agent.DEFAULT_GPT_MODEL_ID
55
 
56
 
57
  @pytest.mark.asyncio
58
+ async def test_switching_to_opus_is_allowed_for_free_user(monkeypatch):
 
 
59
  updated = []
60
 
61
  async def fake_check_session_access(session_id, user, request=None):
 
75
 
76
  response = await agent.set_session_model(
77
  "s1",
78
+ {"model": agent.DEFAULT_OPUS_MODEL_ID},
79
  request=None,
80
  user={"user_id": "u1", "plan": "free"},
81
  )
82
 
83
+ assert response == {"session_id": "s1", "model": agent.DEFAULT_OPUS_MODEL_ID}
84
+ assert updated == [("s1", agent.DEFAULT_OPUS_MODEL_ID)]
85
 
86
 
87
  @pytest.mark.asyncio
88
+ async def test_switching_to_gpt_is_allowed_for_free_user(monkeypatch):
 
 
89
  updated = []
90
 
91
  async def fake_check_session_access(session_id, user, request=None):
92
+ return SimpleNamespace(user_id=user["user_id"])
 
 
93
 
94
  async def fake_update_session_model(session_id, model_id):
95
  updated.append((session_id, model_id))
 
105
  "s1",
106
  {"model": agent.DEFAULT_GPT_MODEL_ID},
107
  request=None,
108
+ user={"user_id": "u1", "plan": "free"},
109
  )
110
 
111
  assert response == {"session_id": "s1", "model": agent.DEFAULT_GPT_MODEL_ID}
112
  assert updated == [("s1", agent.DEFAULT_GPT_MODEL_ID)]
113
 
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  @pytest.mark.asyncio
116
  async def test_switching_to_unknown_model_id_is_rejected(monkeypatch):
117
  async def fake_check_session_access(session_id, user, request=None):
 
132
 
133
 
134
  @pytest.mark.asyncio
135
+ async def test_restore_summary_uses_default_model_without_quota_gate(monkeypatch):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  events = []
 
137
 
138
  class Request:
139
  headers = {}
 
147
  session_id, user, request, preload_sandbox=True
148
  ):
149
  events.append(("check", session_id, preload_sandbox))
150
+ return SimpleNamespace(session=SimpleNamespace(config=SimpleNamespace()))
 
 
 
 
 
 
151
 
152
  async def fake_seed(session_id, messages):
153
+ events.append(("seed", session_id))
154
  return len(messages)
155
 
156
  monkeypatch.setattr(agent.session_manager, "create_session", fake_create_session)
157
  monkeypatch.setattr(agent, "_check_session_access", fake_check_session_access)
 
158
  monkeypatch.setattr(agent.session_manager, "seed_from_summary", fake_seed)
159
 
160
  response = await agent.restore_session_summary(
 
164
  )
165
 
166
  assert response.session_id == "s1"
167
+ assert response.model == agent.DEFAULT_FREE_MODEL_ID
168
  assert events == [
169
+ ("create", agent.DEFAULT_FREE_MODEL_ID),
170
  ("check", "s1", False),
171
+ ("seed", "s1"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  ]
tests/unit/test_cli_local_models.py CHANGED
@@ -1,7 +1,9 @@
1
  import pytest
2
 
 
3
  from agent.core import model_switcher
4
  from agent.core.local_models import is_local_model_id
 
5
 
6
 
7
  def test_local_model_helper_accepts_supported_prefixes():
@@ -33,11 +35,16 @@ def test_openai_compat_prefix_is_not_supported():
33
  def test_suggested_models_include_router_claude_models_and_no_native_ids():
34
  ids = {m["id"] for m in model_switcher.SUGGESTED_MODELS}
35
 
36
- assert "anthropic/claude-sonnet-4-6:fal-ai" in ids
37
  assert "anthropic/claude-opus-4.8:fal-ai" in ids
38
  assert all(model_id.count("/") >= 1 for model_id in ids)
39
 
40
 
 
 
 
 
 
 
41
  def test_model_switcher_accepts_router_model_ids():
42
  assert model_switcher.is_valid_model_id("openai/gpt-5.5:fal-ai")
43
  assert model_switcher.is_valid_model_id("openai/gpt-oss-120b")
 
1
  import pytest
2
 
3
+ from agent.config import load_config
4
  from agent.core import model_switcher
5
  from agent.core.local_models import is_local_model_id
6
+ from agent.main import CLI_CONFIG_PATH
7
 
8
 
9
  def test_local_model_helper_accepts_supported_prefixes():
 
35
  def test_suggested_models_include_router_claude_models_and_no_native_ids():
36
  ids = {m["id"] for m in model_switcher.SUGGESTED_MODELS}
37
 
 
38
  assert "anthropic/claude-opus-4.8:fal-ai" in ids
39
  assert all(model_id.count("/") >= 1 for model_id in ids)
40
 
41
 
42
+ def test_cli_default_model_is_opus():
43
+ config = load_config(CLI_CONFIG_PATH)
44
+
45
+ assert config.model_name == "anthropic/claude-opus-4.8:fal-ai"
46
+
47
+
48
  def test_model_switcher_accepts_router_model_ids():
49
  assert model_switcher.is_valid_model_id("openai/gpt-5.5:fal-ai")
50
  assert model_switcher.is_valid_model_id("openai/gpt-oss-120b")
tests/unit/test_llm_params.py CHANGED
@@ -9,22 +9,18 @@ from agent.core.llm_params import (
9
  from agent.core.model_ids import HF_ROUTER_BASE_URL
10
 
11
 
12
- def test_hf_router_params_for_default_premium_model(monkeypatch):
13
- monkeypatch.setenv("INFERENCE_TOKEN", "inference-token")
14
- monkeypatch.setenv("HF_BILL_TO", "smolagents")
15
-
16
  params = _resolve_llm_params(
17
- "anthropic/claude-sonnet-4-6:fal-ai",
18
  "session-token",
19
  reasoning_effort="high",
20
  strict=True,
21
  )
22
 
23
  assert params == {
24
- "model": "openai/anthropic/claude-sonnet-4-6:fal-ai",
25
  "api_base": HF_ROUTER_BASE_URL,
26
- "api_key": "inference-token",
27
- "extra_headers": {"X-HF-Bill-To": "smolagents"},
28
  "extra_body": {"reasoning_effort": "high"},
29
  }
30
 
@@ -52,57 +48,29 @@ def test_hf_router_drops_unsupported_effort_in_non_strict_mode(monkeypatch):
52
  assert "extra_body" not in params
53
 
54
 
55
- def test_user_billed_premium_uses_session_token_without_bill_to(monkeypatch):
56
- monkeypatch.setenv("INFERENCE_TOKEN", "inference-token")
57
- monkeypatch.setenv("HF_BILL_TO", "smolagents")
58
-
59
- params = _resolve_llm_params(
60
- "anthropic/claude-opus-4.8:fal-ai",
61
- "session-token",
62
- reasoning_effort="high",
63
- strict=True,
64
- bill_to_user=True,
65
- )
66
-
67
- assert params["model"] == "openai/anthropic/claude-opus-4.8:fal-ai"
68
- assert params["api_base"] == HF_ROUTER_BASE_URL
69
- assert params["api_key"] == "session-token"
70
- assert "extra_headers" not in params
71
- assert params["extra_body"] == {"reasoning_effort": "high"}
72
-
73
-
74
- def test_user_billed_premium_does_not_fall_back_to_cached_token(monkeypatch):
75
  import huggingface_hub
76
 
77
- monkeypatch.setenv("INFERENCE_TOKEN", "inference-token")
78
  monkeypatch.setenv("HF_TOKEN", "server-token")
79
  monkeypatch.setattr(huggingface_hub, "get_token", lambda: "cached-token")
80
 
81
  params = _resolve_llm_params(
82
  "anthropic/claude-opus-4.8:fal-ai",
83
  None,
84
- bill_to_user=True,
85
  )
86
 
87
- assert params["api_key"] is None
88
  assert "extra_headers" not in params
89
 
90
 
91
- def test_bill_to_user_ignored_for_free_models(monkeypatch):
92
- monkeypatch.setenv("INFERENCE_TOKEN", "inference-token")
93
- monkeypatch.setenv("HF_BILL_TO", "smolagents")
94
-
95
- params = _resolve_llm_params(
96
- "moonshotai/Kimi-K2.6", "session-token", bill_to_user=True
97
- )
98
-
99
- assert params["api_key"] == "inference-token"
100
- assert params["extra_headers"] == {"X-HF-Bill-To": "smolagents"}
101
 
 
 
102
 
103
- def test_huggingface_prefix_is_stripped_for_router_calls(monkeypatch):
104
- monkeypatch.setenv("INFERENCE_TOKEN", "inference-token")
105
 
 
106
  params = _resolve_llm_params("huggingface/openai/gpt-5.5:fal-ai")
107
 
108
  assert params["model"] == "openai/openai/gpt-5.5:fal-ai"
@@ -196,22 +164,13 @@ def test_empty_local_model_id_is_not_treated_as_hf_router():
196
  _resolve_llm_params("ollama/")
197
 
198
 
199
- def test_hf_router_token_prefers_inference_token(monkeypatch):
200
- monkeypatch.setenv("INFERENCE_TOKEN", " inference-token ")
201
- monkeypatch.setenv("HF_TOKEN", "hf-token")
202
-
203
- assert _resolve_hf_router_token("session-token") == "inference-token"
204
-
205
-
206
  def test_hf_router_token_prefers_session_over_hf_cache(monkeypatch):
207
- monkeypatch.delenv("INFERENCE_TOKEN", raising=False)
208
  monkeypatch.setenv("HF_TOKEN", "hf-token")
209
 
210
  assert _resolve_hf_router_token(" session-token ") == "session-token"
211
 
212
 
213
  def test_hf_router_token_uses_hf_token_env_via_huggingface_hub(monkeypatch):
214
- monkeypatch.delenv("INFERENCE_TOKEN", raising=False)
215
  monkeypatch.setenv("HF_TOKEN", " hf-token ")
216
 
217
  assert _resolve_hf_router_token(None) == "hf-token"
@@ -220,7 +179,6 @@ def test_hf_router_token_uses_hf_token_env_via_huggingface_hub(monkeypatch):
220
  def test_hf_router_token_uses_huggingface_hub_cache(monkeypatch):
221
  import huggingface_hub
222
 
223
- monkeypatch.delenv("INFERENCE_TOKEN", raising=False)
224
  monkeypatch.delenv("HF_TOKEN", raising=False)
225
  monkeypatch.setattr(huggingface_hub, "get_token", lambda: "cached-token")
226
 
@@ -233,21 +191,22 @@ def test_hf_router_token_swallows_huggingface_hub_errors(monkeypatch):
233
  def fail():
234
  raise RuntimeError("cache unavailable")
235
 
236
- monkeypatch.delenv("INFERENCE_TOKEN", raising=False)
237
  monkeypatch.delenv("HF_TOKEN", raising=False)
238
  monkeypatch.setattr(huggingface_hub, "get_token", fail)
239
 
240
  assert _resolve_hf_router_token(None) is None
241
 
242
 
243
- def test_hf_router_params_set_bill_to_only_for_inference_token(monkeypatch):
244
- monkeypatch.setenv("INFERENCE_TOKEN", "inference-token")
245
- monkeypatch.setenv("HF_BILL_TO", "test-org")
 
 
246
 
247
  params = _resolve_llm_params("moonshotai/Kimi-K2.6")
248
 
249
- assert params["api_key"] == "inference-token"
250
- assert params["extra_headers"] == {"X-HF-Bill-To": "test-org"}
251
 
252
 
253
  def test_hf_request_token_keeps_browser_user_precedence(monkeypatch):
 
9
  from agent.core.model_ids import HF_ROUTER_BASE_URL
10
 
11
 
12
+ def test_hf_router_params_for_default_model_uses_session_token():
 
 
 
13
  params = _resolve_llm_params(
14
+ "anthropic/claude-opus-4.8:fal-ai",
15
  "session-token",
16
  reasoning_effort="high",
17
  strict=True,
18
  )
19
 
20
  assert params == {
21
+ "model": "openai/anthropic/claude-opus-4.8:fal-ai",
22
  "api_base": HF_ROUTER_BASE_URL,
23
+ "api_key": "session-token",
 
24
  "extra_body": {"reasoning_effort": "high"},
25
  }
26
 
 
48
  assert "extra_body" not in params
49
 
50
 
51
+ def test_router_params_fall_back_to_hf_cache_when_session_token_missing(monkeypatch):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  import huggingface_hub
53
 
 
54
  monkeypatch.setenv("HF_TOKEN", "server-token")
55
  monkeypatch.setattr(huggingface_hub, "get_token", lambda: "cached-token")
56
 
57
  params = _resolve_llm_params(
58
  "anthropic/claude-opus-4.8:fal-ai",
59
  None,
 
60
  )
61
 
62
+ assert params["api_key"] == "cached-token"
63
  assert "extra_headers" not in params
64
 
65
 
66
+ def test_router_params_never_set_bill_to_headers():
67
+ params = _resolve_llm_params("moonshotai/Kimi-K2.6", "session-token")
 
 
 
 
 
 
 
 
68
 
69
+ assert params["api_key"] == "session-token"
70
+ assert "extra_headers" not in params
71
 
 
 
72
 
73
+ def test_huggingface_prefix_is_stripped_for_router_calls():
74
  params = _resolve_llm_params("huggingface/openai/gpt-5.5:fal-ai")
75
 
76
  assert params["model"] == "openai/openai/gpt-5.5:fal-ai"
 
164
  _resolve_llm_params("ollama/")
165
 
166
 
 
 
 
 
 
 
 
167
  def test_hf_router_token_prefers_session_over_hf_cache(monkeypatch):
 
168
  monkeypatch.setenv("HF_TOKEN", "hf-token")
169
 
170
  assert _resolve_hf_router_token(" session-token ") == "session-token"
171
 
172
 
173
  def test_hf_router_token_uses_hf_token_env_via_huggingface_hub(monkeypatch):
 
174
  monkeypatch.setenv("HF_TOKEN", " hf-token ")
175
 
176
  assert _resolve_hf_router_token(None) == "hf-token"
 
179
  def test_hf_router_token_uses_huggingface_hub_cache(monkeypatch):
180
  import huggingface_hub
181
 
 
182
  monkeypatch.delenv("HF_TOKEN", raising=False)
183
  monkeypatch.setattr(huggingface_hub, "get_token", lambda: "cached-token")
184
 
 
191
  def fail():
192
  raise RuntimeError("cache unavailable")
193
 
 
194
  monkeypatch.delenv("HF_TOKEN", raising=False)
195
  monkeypatch.setattr(huggingface_hub, "get_token", fail)
196
 
197
  assert _resolve_hf_router_token(None) is None
198
 
199
 
200
+ def test_hf_router_params_allow_missing_token_without_headers(monkeypatch):
201
+ import huggingface_hub
202
+
203
+ monkeypatch.delenv("HF_TOKEN", raising=False)
204
+ monkeypatch.setattr(huggingface_hub, "get_token", lambda: None)
205
 
206
  params = _resolve_llm_params("moonshotai/Kimi-K2.6")
207
 
208
+ assert params["api_key"] is None
209
+ assert "extra_headers" not in params
210
 
211
 
212
  def test_hf_request_token_keeps_browser_user_precedence(monkeypatch):
tests/unit/test_prompt_caching.py CHANGED
@@ -6,7 +6,7 @@ from agent.core.prompt_caching import with_prompt_cache_params, with_prompt_cach
6
 
7
  def _anthropic_fal_params() -> dict:
8
  return {
9
- "model": "openai/anthropic/claude-sonnet-4-6:fal-ai",
10
  "api_base": HF_ROUTER_BASE_URL,
11
  }
12
 
@@ -167,7 +167,7 @@ def test_prompt_caching_is_noop_for_non_router_fal_model():
167
  {"role": "user", "content": "current question"},
168
  ]
169
  llm_params = {
170
- "model": "openai/anthropic/claude-sonnet-4-6:fal-ai",
171
  "api_base": "http://localhost:8000/v1",
172
  }
173
 
 
6
 
7
  def _anthropic_fal_params() -> dict:
8
  return {
9
+ "model": "openai/anthropic/claude-opus-4.8:fal-ai",
10
  "api_base": HF_ROUTER_BASE_URL,
11
  }
12
 
 
167
  {"role": "user", "content": "current question"},
168
  ]
169
  llm_params = {
170
+ "model": "openai/anthropic/claude-opus-4.8:fal-ai",
171
  "api_base": "http://localhost:8000/v1",
172
  }
173
 
tests/unit/test_session_manager_persistence.py CHANGED
@@ -16,7 +16,7 @@ _BACKEND_DIR = Path(__file__).resolve().parent.parent.parent / "backend"
16
  if str(_BACKEND_DIR) not in sys.path:
17
  sys.path.insert(0, str(_BACKEND_DIR))
18
 
19
- from agent.core.model_ids import DEFAULT_MODEL_ID, KIMI_K26_MODEL_ID # noqa: E402
20
  from agent.core.session_persistence import NoopSessionStore # noqa: E402
21
  from session_manager import AgentSession, SessionManager # noqa: E402
22
 
@@ -128,32 +128,10 @@ def _runtime_agent_session(
128
  )
129
 
130
 
131
- def test_unknown_saved_model_defaults_to_claude():
132
- model, premium_user_billed, claude_counted = (
133
- SessionManager._model_from_saved_metadata(
134
- "unsupported/model",
135
- premium_user_billed=False,
136
- claude_counted=False,
137
- )
138
- )
139
-
140
- assert model == DEFAULT_MODEL_ID
141
- assert premium_user_billed is False
142
- assert claude_counted is False
143
-
144
-
145
- def test_unknown_saved_user_billed_model_defaults_to_free_model():
146
- model, premium_user_billed, claude_counted = (
147
- SessionManager._model_from_saved_metadata(
148
- "unsupported/model",
149
- premium_user_billed=True,
150
- claude_counted=True,
151
- )
152
- )
153
 
154
  assert model == KIMI_K26_MODEL_ID
155
- assert premium_user_billed is False
156
- assert claude_counted is False
157
 
158
 
159
  @pytest.mark.asyncio
@@ -755,9 +733,6 @@ async def test_list_sessions_dev_uses_store_dev_visibility():
755
  "user_id": "alice",
756
  "model": "m",
757
  "created_at": datetime.now(UTC),
758
- "premium_user_billed": True,
759
- "claude_counted": True,
760
- "claude_counted_day": datetime.now(UTC).date().isoformat(),
761
  "auto_approval_enabled": True,
762
  "auto_approval_cost_cap_usd": 5.0,
763
  "auto_approval_estimated_spend_usd": 2.0,
@@ -779,52 +754,9 @@ async def test_list_sessions_dev_uses_store_dev_visibility():
779
  assert store.seen_user_id == "dev"
780
  assert {session["session_id"] for session in sessions} == {"s1", "s2"}
781
  yolo = next(session for session in sessions if session["session_id"] == "s1")
782
- assert yolo["premium_user_billed"] is True
783
- assert yolo["premium_quota_counted"] is True
784
  assert yolo["auto_approval"] == {
785
  "enabled": True,
786
  "cost_cap_usd": 5.0,
787
  "estimated_spend_usd": 2.0,
788
  "remaining_usd": 3.0,
789
  }
790
-
791
-
792
- def test_get_session_info_marks_stale_premium_quota_as_unused_today():
793
- manager = _manager_with_store(NoopSessionStore())
794
- agent_session = _runtime_agent_session("s1", user_id="alice")
795
- agent_session.claude_counted = True
796
- agent_session.claude_counted_day = "2000-01-01"
797
- agent_session.session.premium_user_billed = True
798
- manager.sessions["s1"] = agent_session
799
-
800
- info = manager.get_session_info("s1")
801
-
802
- assert info is not None
803
- assert info["premium_user_billed"] is False
804
- assert info["premium_quota_counted"] is False
805
-
806
-
807
- @pytest.mark.asyncio
808
- async def test_list_sessions_marks_stale_premium_quota_as_unused_today():
809
- class ListStore(NoopSessionStore):
810
- enabled = True
811
-
812
- async def list_sessions(self, user_id: str, **_: Any) -> list[dict[str, Any]]:
813
- return [
814
- {
815
- "session_id": "s1",
816
- "user_id": user_id,
817
- "model": "m",
818
- "created_at": datetime.now(UTC),
819
- "premium_user_billed": True,
820
- "claude_counted": True,
821
- "claude_counted_day": "2000-01-01",
822
- }
823
- ]
824
-
825
- manager = _manager_with_store(ListStore())
826
-
827
- sessions = await manager.list_sessions(user_id="alice")
828
-
829
- assert sessions[0]["premium_user_billed"] is False
830
- assert sessions[0]["premium_quota_counted"] is False
 
16
  if str(_BACKEND_DIR) not in sys.path:
17
  sys.path.insert(0, str(_BACKEND_DIR))
18
 
19
+ from agent.core.model_ids import KIMI_K26_MODEL_ID # noqa: E402
20
  from agent.core.session_persistence import NoopSessionStore # noqa: E402
21
  from session_manager import AgentSession, SessionManager # noqa: E402
22
 
 
128
  )
129
 
130
 
131
+ def test_unknown_saved_model_defaults_to_kimi():
132
+ model = SessionManager._model_from_saved_metadata("unsupported/model")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
  assert model == KIMI_K26_MODEL_ID
 
 
135
 
136
 
137
  @pytest.mark.asyncio
 
733
  "user_id": "alice",
734
  "model": "m",
735
  "created_at": datetime.now(UTC),
 
 
 
736
  "auto_approval_enabled": True,
737
  "auto_approval_cost_cap_usd": 5.0,
738
  "auto_approval_estimated_spend_usd": 2.0,
 
754
  assert store.seen_user_id == "dev"
755
  assert {session["session_id"] for session in sessions} == {"s1", "s2"}
756
  yolo = next(session for session in sessions if session["session_id"] == "s1")
 
 
757
  assert yolo["auto_approval"] == {
758
  "enabled": True,
759
  "cost_cap_usd": 5.0,
760
  "estimated_spend_usd": 2.0,
761
  "remaining_usd": 3.0,
762
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/unit/test_session_persistence.py CHANGED
@@ -25,7 +25,6 @@ async def test_noop_store_keeps_local_cli_and_tests_db_free():
25
  assert await store.load_session("s1") is None
26
  assert await store.list_sessions("u1") == []
27
  assert await store.append_event("s1", "processing", {}) is None
28
- assert await store.try_increment_quota("u1", "2099-01-01", 1) is None
29
 
30
 
31
  def test_unsafe_message_payload_is_replaced_with_marker():
 
25
  assert await store.load_session("s1") is None
26
  assert await store.list_sessions("u1") == []
27
  assert await store.append_event("s1", "processing", {}) is None
 
28
 
29
 
30
  def test_unsafe_message_payload_is_replaced_with_marker():
tests/unit/test_user_quotas.py DELETED
@@ -1,141 +0,0 @@
1
- """Tests for backend/user_quotas.py — the in-memory premium quota store."""
2
-
3
- import asyncio
4
- import sys
5
- from pathlib import Path
6
-
7
- import pytest
8
-
9
- # The backend package isn't on sys.path by default; add it so we can import
10
- # the module under test without pulling in the whole FastAPI app.
11
- _BACKEND_DIR = Path(__file__).resolve().parent.parent.parent / "backend"
12
- if str(_BACKEND_DIR) not in sys.path:
13
- sys.path.insert(0, str(_BACKEND_DIR))
14
-
15
- import user_quotas # noqa: E402
16
- from agent.core.session_persistence import NoopSessionStore, _reset_store_for_tests # noqa: E402
17
-
18
-
19
- @pytest.fixture(autouse=True)
20
- def _reset_store():
21
- """Fresh in-memory store per test."""
22
- user_quotas._reset_for_tests()
23
- yield
24
- user_quotas._reset_for_tests()
25
-
26
-
27
- def test_daily_cap_for_known_plans():
28
- assert user_quotas.daily_cap_for("free") == user_quotas.CLAUDE_FREE_DAILY
29
- assert user_quotas.daily_cap_for("pro") == user_quotas.CLAUDE_PRO_DAILY
30
- assert user_quotas.daily_cap_for("org") == user_quotas.CLAUDE_FREE_DAILY
31
-
32
-
33
- def test_daily_cap_for_unknown_or_missing_defaults_to_free():
34
- assert user_quotas.daily_cap_for(None) == user_quotas.CLAUDE_FREE_DAILY
35
- assert user_quotas.daily_cap_for("") == user_quotas.CLAUDE_FREE_DAILY
36
- assert user_quotas.daily_cap_for("mystery") == user_quotas.CLAUDE_FREE_DAILY
37
-
38
-
39
- @pytest.mark.asyncio
40
- async def test_increment_and_read_back_same_day():
41
- assert await user_quotas.get_claude_used_today("u1") == 0
42
- assert await user_quotas.increment_claude("u1") == 1
43
- assert await user_quotas.increment_claude("u1") == 2
44
- assert await user_quotas.get_claude_used_today("u1") == 2
45
-
46
-
47
- @pytest.mark.asyncio
48
- async def test_independent_users_do_not_share_counts():
49
- await user_quotas.increment_claude("alice")
50
- await user_quotas.increment_claude("alice")
51
- await user_quotas.increment_claude("bob")
52
- assert await user_quotas.get_claude_used_today("alice") == 2
53
- assert await user_quotas.get_claude_used_today("bob") == 1
54
-
55
-
56
- @pytest.mark.asyncio
57
- async def test_stale_day_resets_before_next_read():
58
- await user_quotas.increment_claude("u1")
59
- # Simulate yesterday's entry still in the store.
60
- user_quotas._claude_counts["u1"] = ("2000-01-01", 99)
61
- assert await user_quotas.get_claude_used_today("u1") == 0
62
- # And a fresh increment starts from 0.
63
- assert await user_quotas.increment_claude("u1") == 1
64
-
65
-
66
- @pytest.mark.asyncio
67
- async def test_concurrent_increments_under_lock_do_not_lose_writes():
68
- """50 coroutines bumping the same user must land at exactly 50."""
69
- await asyncio.gather(*[user_quotas.increment_claude("race") for _ in range(50)])
70
- assert await user_quotas.get_claude_used_today("race") == 50
71
-
72
-
73
- @pytest.mark.asyncio
74
- async def test_try_increment_returns_none_at_cap():
75
- assert await user_quotas.try_increment_claude("freebie", 1) == 1
76
- assert await user_quotas.try_increment_claude("freebie", 1) is None
77
- assert await user_quotas.get_claude_used_today("freebie") == 1
78
-
79
-
80
- @pytest.mark.asyncio
81
- async def test_try_increment_delegates_cap_to_enabled_store():
82
- class StoreAtCap(NoopSessionStore):
83
- enabled = True
84
-
85
- async def try_increment_quota(self, user_id: str, day: str, cap: int):
86
- assert user_id == "mongo-user"
87
- assert cap == 1
88
- return None
89
-
90
- async def get_quota(self, user_id: str, day: str):
91
- return 1
92
-
93
- _reset_store_for_tests(StoreAtCap())
94
-
95
- assert await user_quotas.try_increment_claude("mongo-user", 1) is None
96
- assert await user_quotas.get_claude_used_today("mongo-user") == 1
97
- assert "mongo-user" not in user_quotas._claude_counts
98
-
99
-
100
- @pytest.mark.asyncio
101
- async def test_refund_decrements_and_drops_entry_at_zero():
102
- await user_quotas.increment_claude("u1")
103
- assert await user_quotas.get_claude_used_today("u1") == 1
104
- await user_quotas.refund_claude("u1")
105
- assert await user_quotas.get_claude_used_today("u1") == 0
106
- assert "u1" not in user_quotas._claude_counts
107
-
108
-
109
- @pytest.mark.asyncio
110
- async def test_refund_on_nonexistent_user_is_noop():
111
- await user_quotas.refund_claude("ghost") # should not raise
112
- assert await user_quotas.get_claude_used_today("ghost") == 0
113
-
114
-
115
- @pytest.mark.asyncio
116
- async def test_refund_on_stale_day_resets_rather_than_underflow():
117
- user_quotas._claude_counts["u1"] = ("2000-01-01", 5)
118
- await user_quotas.refund_claude("u1")
119
- # Stale entry dropped; today's count stays 0.
120
- assert await user_quotas.get_claude_used_today("u1") == 0
121
-
122
-
123
- @pytest.mark.asyncio
124
- async def test_free_user_cap_reached_at_two():
125
- cap = user_quotas.daily_cap_for("free")
126
- assert cap == 2
127
- assert await user_quotas.increment_claude("freebie") == 1
128
- used = await user_quotas.increment_claude("freebie")
129
- assert used == 2
130
- assert used >= cap
131
-
132
-
133
- @pytest.mark.asyncio
134
- async def test_pro_user_cap_reached_at_twenty():
135
- cap = user_quotas.daily_cap_for("pro")
136
- assert cap == 20
137
- for i in range(1, 21):
138
- assert await user_quotas.increment_claude("pro_user") == i
139
- # 21st would exceed — the gate in routes/agent.py enforces this; here
140
- # we just confirm the counter tracks past the cap so that check works.
141
- assert await user_quotas.increment_claude("pro_user") == 21