Spaces:
Running on CPU Upgrade
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>
- AGENTS.md +1 -1
- README.md +5 -4
- agent/context_manager/manager.py +4 -5
- agent/core/agent_loop.py +3 -5
- agent/core/effort_probe.py +0 -1
- agent/core/hf_tokens.py +3 -11
- agent/core/llm_params.py +4 -23
- agent/core/model_ids.py +3 -23
- agent/core/model_switcher.py +0 -2
- agent/core/session.py +0 -5
- agent/core/session_persistence.py +1 -61
- agent/main.py +1 -1
- agent/tools/research_tool.py +0 -1
- backend/dependencies.py +4 -4
- backend/main.py +2 -2
- backend/models.py +0 -2
- backend/routes/agent.py +26 -180
- backend/session_manager.py +6 -62
- backend/user_quotas.py +0 -135
- configs/cli_agent_config.json +1 -1
- configs/frontend_agent_config.json +1 -1
- frontend/src/components/Chat/BillingBanner.tsx +0 -81
- frontend/src/components/Chat/ChatErrorBanner.tsx +66 -4
- frontend/src/components/Chat/ChatInput.tsx +13 -88
- frontend/src/components/Layout/AppLayout.tsx +44 -0
- frontend/src/components/SessionChat.tsx +0 -46
- frontend/src/hooks/useAuth.ts +3 -2
- frontend/src/hooks/useUserQuota.ts +0 -51
- frontend/src/store/agentStore.ts +1 -1
- frontend/src/store/sessionStore.ts +2 -12
- frontend/src/types/agent.ts +1 -2
- frontend/src/utils/inferenceBilling.ts +40 -0
- frontend/src/utils/model.ts +1 -21
- tests/integration/test_live_thinking_models.py +0 -1
- tests/unit/test_agent_model_gating.py +40 -382
- tests/unit/test_cli_local_models.py +8 -1
- tests/unit/test_llm_params.py +18 -59
- tests/unit/test_prompt_caching.py +2 -2
- tests/unit/test_session_manager_persistence.py +3 -71
- tests/unit/test_session_persistence.py +0 -1
- tests/unit/test_user_quotas.py +0 -141
|
@@ -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 |
-
-
|
| 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 |
|
|
@@ -54,7 +54,7 @@ ml-intern "fine-tune llama on my dataset"
|
|
| 54 |
**Options:**
|
| 55 |
|
| 56 |
```bash
|
| 57 |
-
ml-intern --model anthropic/claude-
|
| 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 |
-
|
| 72 |
-
|
|
|
|
| 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-
|
| 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",
|
|
@@ -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 |
-
|
| 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
|
| 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
|
| 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 |
-
#
|
| 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
|
|
@@ -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
|
| 566 |
-
"
|
| 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
|
| 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(
|
|
@@ -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
|
|
@@ -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.
|
| 45 |
-
2.
|
| 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(
|
| 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:
|
|
@@ -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.
|
| 127 |
-
|
| 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 |
-
|
| 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_token — the 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:
|
|
@@ -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 =
|
| 15 |
|
| 16 |
-
|
| 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
|
|
|
|
| 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)
|
|
@@ -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"},
|
|
@@ -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
|
|
@@ -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
|
| 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:
|
|
@@ -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-
|
| 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 |
|
|
@@ -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)
|
|
@@ -35,7 +35,7 @@ DEV_USER: dict[str, Any] = {
|
|
| 35 |
"user_id": "dev",
|
| 36 |
"username": "dev",
|
| 37 |
"authenticated": True,
|
| 38 |
-
"plan": "pro", # Dev
|
| 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
|
| 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
|
| 155 |
-
|
| 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)
|
|
@@ -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
|
| 14 |
-
# modules see local
|
| 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
|
|
@@ -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):
|
|
@@ -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 |
-
"
|
| 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
|
| 141 |
-
return
|
| 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
|
| 153 |
-
model_id
|
| 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
|
| 171 |
-
return
|
| 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
|
| 181 |
-
|
| 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 |
-
|
| 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).
|
| 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 |
-
|
| 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
|
| 520 |
-
model = await _model_override_for_new_session(
|
| 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
|
| 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
|
| 550 |
-
|
| 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 |
-
|
| 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(
|
| 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 |
-
|
| 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
|
| 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.
|
| 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 |
-
|
| 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 |
-
|
| 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 = [
|
|
@@ -6,14 +6,13 @@ import logging
|
|
| 6 |
import os
|
| 7 |
import uuid
|
| 8 |
from dataclasses import dataclass, field
|
| 9 |
-
from datetime import
|
| 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
|
| 241 |
|
| 242 |
-
fallback_model = KIMI_K26_MODEL_ID
|
| 243 |
logger.warning(
|
| 244 |
"Saved session model %r failed validation; using %r",
|
| 245 |
model,
|
| 246 |
fallback_model,
|
| 247 |
)
|
| 248 |
-
|
| 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
|
| 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 |
)
|
|
@@ -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())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"model_name": "anthropic/claude-
|
| 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,
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"model_name": "${ML_INTERN_DEFAULT_MODEL_ID:-
|
| 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,
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
@@ -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 |
-
|
| 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 |
-
|
| 81 |
-
|
| 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...'
|
| 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 |
-
() =>
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 |
))}
|
|
@@ -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>
|
|
@@ -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...'
|
|
@@ -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 |
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -6,7 +6,7 @@
|
|
| 6 |
* - Connection / processing flags
|
| 7 |
* - Panel state (right panel — single-artifact pattern)
|
| 8 |
* - Plan state
|
| 9 |
-
* - User info / health
|
| 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:
|
|
@@ -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
|
| 256 |
-
// the live GET /sessions list
|
| 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 |
}),
|
|
@@ -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 |
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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(
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Tests for
|
| 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 |
-
|
| 18 |
-
|
| 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 |
-
|
| 51 |
-
assert
|
| 52 |
-
assert
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
@pytest.mark.asyncio
|
| 57 |
-
async def
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
@pytest.mark.asyncio
|
| 64 |
-
async def
|
| 65 |
model = await agent._model_override_for_new_session(
|
| 66 |
-
|
| 67 |
-
|
| 68 |
)
|
| 69 |
|
| 70 |
-
assert model == agent.
|
| 71 |
|
| 72 |
|
| 73 |
@pytest.mark.asyncio
|
| 74 |
-
async def
|
| 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.
|
| 97 |
request=None,
|
| 98 |
user={"user_id": "u1", "plan": "free"},
|
| 99 |
)
|
| 100 |
|
| 101 |
-
assert response == {"session_id": "s1", "model": agent.
|
| 102 |
-
assert updated == [("s1", agent.
|
| 103 |
|
| 104 |
|
| 105 |
@pytest.mark.asyncio
|
| 106 |
-
async def
|
| 107 |
-
monkeypatch,
|
| 108 |
-
):
|
| 109 |
updated = []
|
| 110 |
|
| 111 |
async def fake_check_session_access(session_id, user, request=None):
|
| 112 |
-
|
| 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": "
|
| 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
|
| 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
|
| 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
|
| 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",
|
| 447 |
("check", "s1", False),
|
| 448 |
-
("
|
| 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 |
]
|
|
@@ -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")
|
|
@@ -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
|
| 13 |
-
monkeypatch.setenv("INFERENCE_TOKEN", "inference-token")
|
| 14 |
-
monkeypatch.setenv("HF_BILL_TO", "smolagents")
|
| 15 |
-
|
| 16 |
params = _resolve_llm_params(
|
| 17 |
-
"anthropic/claude-
|
| 18 |
"session-token",
|
| 19 |
reasoning_effort="high",
|
| 20 |
strict=True,
|
| 21 |
)
|
| 22 |
|
| 23 |
assert params == {
|
| 24 |
-
"model": "openai/anthropic/claude-
|
| 25 |
"api_base": HF_ROUTER_BASE_URL,
|
| 26 |
-
"api_key": "
|
| 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
|
| 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"]
|
| 88 |
assert "extra_headers" not in params
|
| 89 |
|
| 90 |
|
| 91 |
-
def
|
| 92 |
-
|
| 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
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
| 246 |
|
| 247 |
params = _resolve_llm_params("moonshotai/Kimi-K2.6")
|
| 248 |
|
| 249 |
-
assert params["api_key"]
|
| 250 |
-
assert
|
| 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):
|
|
@@ -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-
|
| 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-
|
| 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 |
|
|
@@ -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
|
| 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
|
| 132 |
-
model
|
| 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 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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():
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|