Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Set Sonnet as default premium model (#284)
Browse files* Set Sonnet as default premium model
Co-authored-by: OpenAI Codex <codex@openai.com>
* Address pro-only premium review feedback
Co-authored-by: OpenAI Codex <codex@openai.com>
---------
Co-authored-by: OpenAI Codex <codex@openai.com>
- AGENTS.md +1 -1
- README.md +5 -2
- agent/core/model_ids.py +14 -1
- agent/core/model_switcher.py +2 -0
- agent/main.py +1 -1
- backend/routes/agent.py +55 -8
- backend/user_quotas.py +5 -4
- configs/cli_agent_config.json +1 -1
- configs/frontend_agent_config.json +1 -1
- frontend/src/components/Chat/ChatInput.tsx +45 -6
- frontend/src/utils/model.ts +12 -1
- tests/unit/test_agent_model_gating.py +122 -1
- tests/unit/test_cli_local_models.py +2 -1
- tests/unit/test_llm_params.py +2 -2
AGENTS.md
CHANGED
|
@@ -13,7 +13,7 @@ Notes:
|
|
| 13 |
- Vite proxies `/api` and `/auth` to `http://localhost:7860`.
|
| 14 |
- If `127.0.0.1:7860` is already owned by another local process, binding the backend to `::1` lets the Vite proxy resolve `localhost` cleanly.
|
| 15 |
- Prefer `npm ci` over `npm install` for setup, since `npm install` may rewrite `frontend/package-lock.json` metadata depending on npm version.
|
| 16 |
-
- Production defaults to Claude
|
| 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 |
+
- Production defaults to Claude Sonnet 4.6 through the HF Router (`anthropic/claude-sonnet-4-6:fal-ai`). Non-local LLM calls use `https://router.huggingface.co/v1` with Hugging Face tokens. Subsidized premium calls use the Space/operator `INFERENCE_TOKEN` plus `X-HF-Bill-To` from `HF_BILL_TO` (default `smolagents`); Opus and GPT-5.5 daily sessions are Pro-only, and after the daily allowance, premium calls bill the user's own HF token. For local development, set `HF_TOKEN` and optionally `ML_INTERN_DEFAULT_MODEL_ID`.
|
| 17 |
|
| 18 |
## Development Checks
|
| 19 |
|
README.md
CHANGED
|
@@ -54,7 +54,7 @@ ml-intern "fine-tune llama on my dataset"
|
|
| 54 |
**Options:**
|
| 55 |
|
| 56 |
```bash
|
| 57 |
-
ml-intern --model anthropic/claude-
|
| 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,6 +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 |
#### Local models
|
| 72 |
|
| 73 |
Local model support uses OpenAI-compatible HTTP endpoints through LiteLLM. The
|
|
@@ -383,7 +386,7 @@ Edit `configs/cli_agent_config.json` for CLI defaults, or
|
|
| 383 |
|
| 384 |
```json
|
| 385 |
{
|
| 386 |
-
"model_name": "anthropic/claude-
|
| 387 |
"mcpServers": {
|
| 388 |
"your-server-name": {
|
| 389 |
"transport": "http",
|
|
|
|
| 54 |
**Options:**
|
| 55 |
|
| 56 |
```bash
|
| 57 |
+
ml-intern --model anthropic/claude-sonnet-4-6:fal-ai "your prompt"
|
| 58 |
ml-intern --model moonshotai/Kimi-K2.6 "your prompt"
|
| 59 |
ml-intern --model openai/gpt-5.5:fal-ai "your prompt"
|
| 60 |
ml-intern --model ollama/llama3.1:8b "your prompt"
|
|
|
|
| 68 |
(Claude, GPT, HF Router models like MiniMax, Kimi, GLM, DeepSeek, and local
|
| 69 |
model prefixes).
|
| 70 |
|
| 71 |
+
In the web app, subsidized daily sessions for Claude Opus 4.8 and GPT-5.5 are
|
| 72 |
+
available only to HF Pro users; Claude Sonnet 4.6 is the default premium model.
|
| 73 |
+
|
| 74 |
#### Local models
|
| 75 |
|
| 76 |
Local model support uses OpenAI-compatible HTTP endpoints through LiteLLM. The
|
|
|
|
| 386 |
|
| 387 |
```json
|
| 388 |
{
|
| 389 |
+
"model_name": "anthropic/claude-sonnet-4-6:fal-ai",
|
| 390 |
"mcpServers": {
|
| 391 |
"your-server-name": {
|
| 392 |
"transport": "http",
|
agent/core/model_ids.py
CHANGED
|
@@ -2,16 +2,24 @@
|
|
| 2 |
|
| 3 |
HF_ROUTER_BASE_URL = "https://router.huggingface.co/v1"
|
| 4 |
|
|
|
|
| 5 |
CLAUDE_OPUS_48_MODEL_ID = "anthropic/claude-opus-4.8:fal-ai"
|
|
|
|
| 6 |
GPT_55_MODEL_ID = "openai/gpt-5.5:fal-ai"
|
| 7 |
KIMI_K26_MODEL_ID = "moonshotai/Kimi-K2.6"
|
| 8 |
MINIMAX_M27_MODEL_ID = "MiniMaxAI/MiniMax-M2.7"
|
| 9 |
GLM_51_MODEL_ID = "zai-org/GLM-5.1"
|
| 10 |
DEEPSEEK_V4_PRO_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:deepinfra"
|
| 11 |
|
| 12 |
-
DEFAULT_MODEL_ID =
|
| 13 |
|
| 14 |
PREMIUM_MODEL_IDS = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
CLAUDE_OPUS_48_MODEL_ID,
|
| 16 |
GPT_55_MODEL_ID,
|
| 17 |
}
|
|
@@ -36,6 +44,11 @@ def is_premium_model_id(model_id: str | None) -> bool:
|
|
| 36 |
return bool(normalized and normalized in PREMIUM_MODEL_IDS)
|
| 37 |
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
def is_known_router_model_id(model_id: str | None) -> bool:
|
| 40 |
normalized = strip_huggingface_model_prefix(model_id)
|
| 41 |
return bool(normalized and normalized in KNOWN_ROUTER_MODEL_IDS)
|
|
|
|
| 2 |
|
| 3 |
HF_ROUTER_BASE_URL = "https://router.huggingface.co/v1"
|
| 4 |
|
| 5 |
+
# Keep these as verbatim HF Router ids; version punctuation differs by model.
|
| 6 |
CLAUDE_OPUS_48_MODEL_ID = "anthropic/claude-opus-4.8:fal-ai"
|
| 7 |
+
CLAUDE_SONNET_46_MODEL_ID = "anthropic/claude-sonnet-4-6:fal-ai"
|
| 8 |
GPT_55_MODEL_ID = "openai/gpt-5.5:fal-ai"
|
| 9 |
KIMI_K26_MODEL_ID = "moonshotai/Kimi-K2.6"
|
| 10 |
MINIMAX_M27_MODEL_ID = "MiniMaxAI/MiniMax-M2.7"
|
| 11 |
GLM_51_MODEL_ID = "zai-org/GLM-5.1"
|
| 12 |
DEEPSEEK_V4_PRO_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:deepinfra"
|
| 13 |
|
| 14 |
+
DEFAULT_MODEL_ID = CLAUDE_SONNET_46_MODEL_ID
|
| 15 |
|
| 16 |
PREMIUM_MODEL_IDS = {
|
| 17 |
+
CLAUDE_SONNET_46_MODEL_ID,
|
| 18 |
+
CLAUDE_OPUS_48_MODEL_ID,
|
| 19 |
+
GPT_55_MODEL_ID,
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
PRO_ONLY_PREMIUM_MODEL_IDS = {
|
| 23 |
CLAUDE_OPUS_48_MODEL_ID,
|
| 24 |
GPT_55_MODEL_ID,
|
| 25 |
}
|
|
|
|
| 44 |
return bool(normalized and normalized in PREMIUM_MODEL_IDS)
|
| 45 |
|
| 46 |
|
| 47 |
+
def is_pro_only_premium_model_id(model_id: str | None) -> bool:
|
| 48 |
+
normalized = strip_huggingface_model_prefix(model_id)
|
| 49 |
+
return bool(normalized and normalized in PRO_ONLY_PREMIUM_MODEL_IDS)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
def is_known_router_model_id(model_id: str | None) -> bool:
|
| 53 |
normalized = strip_huggingface_model_prefix(model_id)
|
| 54 |
return bool(normalized and normalized in KNOWN_ROUTER_MODEL_IDS)
|
agent/core/model_switcher.py
CHANGED
|
@@ -28,6 +28,7 @@ from agent.core.local_models import (
|
|
| 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,6 +40,7 @@ from agent.core.model_ids import (
|
|
| 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"},
|
|
|
|
| 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 |
# ":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"},
|
agent/main.py
CHANGED
|
@@ -85,7 +85,7 @@ def _validate_cli_model_override(model: str) -> str:
|
|
| 85 |
if not model_switcher.is_valid_model_id(model):
|
| 86 |
raise ValueError(
|
| 87 |
"Invalid model id. Use an HF Router id like "
|
| 88 |
-
"'anthropic/claude-
|
| 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-sonnet-4-6:fal-ai' or a supported local prefix."
|
| 89 |
)
|
| 90 |
return model.removeprefix("huggingface/")
|
| 91 |
|
backend/routes/agent.py
CHANGED
|
@@ -55,6 +55,7 @@ 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 |
DEEPSEEK_V4_PRO_MODEL_ID,
|
| 59 |
DEFAULT_MODEL_ID,
|
| 60 |
GLM_51_MODEL_ID,
|
|
@@ -62,6 +63,7 @@ from agent.core.model_ids import (
|
|
| 62 |
KIMI_K26_MODEL_ID,
|
| 63 |
MINIMAX_M27_MODEL_ID,
|
| 64 |
is_premium_model_id,
|
|
|
|
| 65 |
)
|
| 66 |
|
| 67 |
logger = logging.getLogger(__name__)
|
|
@@ -70,6 +72,7 @@ router = APIRouter(prefix="/api", tags=["agent"])
|
|
| 70 |
_background_teardown_tasks: set[asyncio.Task] = set()
|
| 71 |
|
| 72 |
DEFAULT_PREMIUM_MODEL_ID = DEFAULT_MODEL_ID
|
|
|
|
| 73 |
DEFAULT_GPT_MODEL_ID = GPT_55_MODEL_ID
|
| 74 |
DEFAULT_FREE_MODEL_ID = KIMI_K26_MODEL_ID
|
| 75 |
DATASET_UPLOAD_MULTIPART_SLACK_BYTES = 1024 * 1024
|
|
@@ -79,40 +82,53 @@ def _available_models() -> list[dict[str, Any]]:
|
|
| 79 |
models = [
|
| 80 |
{
|
| 81 |
"id": DEFAULT_PREMIUM_MODEL_ID,
|
| 82 |
-
"label": "Claude
|
| 83 |
"provider": "huggingface",
|
| 84 |
"tier": "pro",
|
| 85 |
"recommended": True,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
},
|
| 87 |
{
|
| 88 |
"id": DEFAULT_GPT_MODEL_ID,
|
| 89 |
"label": "GPT-5.5",
|
| 90 |
"provider": "huggingface",
|
| 91 |
"tier": "pro",
|
|
|
|
| 92 |
},
|
| 93 |
{
|
| 94 |
"id": DEFAULT_FREE_MODEL_ID,
|
| 95 |
"label": "Kimi K2.6",
|
| 96 |
"provider": "huggingface",
|
| 97 |
"tier": "free",
|
|
|
|
| 98 |
},
|
| 99 |
{
|
| 100 |
"id": MINIMAX_M27_MODEL_ID,
|
| 101 |
"label": "MiniMax M2.7",
|
| 102 |
"provider": "huggingface",
|
| 103 |
"tier": "free",
|
|
|
|
| 104 |
},
|
| 105 |
{
|
| 106 |
"id": GLM_51_MODEL_ID,
|
| 107 |
"label": "GLM 5.1",
|
| 108 |
"provider": "huggingface",
|
| 109 |
"tier": "free",
|
|
|
|
| 110 |
},
|
| 111 |
{
|
| 112 |
"id": DEEPSEEK_V4_PRO_MODEL_ID,
|
| 113 |
"label": "DeepSeek V4 Pro",
|
| 114 |
"provider": "huggingface",
|
| 115 |
"tier": "free",
|
|
|
|
| 116 |
},
|
| 117 |
]
|
| 118 |
return models
|
|
@@ -125,6 +141,32 @@ def _is_premium_model(model_id: str) -> bool:
|
|
| 125 |
return is_premium_model_id(model_id)
|
| 126 |
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
def _is_user_billed(model_id: str) -> bool:
|
| 129 |
return _is_premium_model(model_id)
|
| 130 |
|
|
@@ -168,17 +210,19 @@ async def _enforce_premium_model_quota(
|
|
| 168 |
``claude_counted`` flag on ``AgentSession`` guards against re-counting the
|
| 169 |
same session; the stored field name is kept for persistence compatibility.
|
| 170 |
|
| 171 |
-
Subsidizes the daily allowance (free = 2, pro = 20
|
| 172 |
-
through the HF Router.
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
| 176 |
"""
|
| 177 |
-
if agent_session.claude_counted:
|
| 178 |
-
return
|
| 179 |
model_name = agent_session.session.config.model_name
|
| 180 |
if not _is_premium_model(model_name):
|
| 181 |
return
|
|
|
|
|
|
|
|
|
|
| 182 |
user_id = user["user_id"]
|
| 183 |
plan = user.get("plan", "free")
|
| 184 |
cap = user_quotas.daily_cap_for(plan)
|
|
@@ -463,6 +507,7 @@ async def create_session(
|
|
| 463 |
valid_ids = {m["id"] for m in AVAILABLE_MODELS}
|
| 464 |
if model and model not in valid_ids:
|
| 465 |
raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
|
|
|
|
| 466 |
|
| 467 |
# Empty requests use the configured default, which may be premium.
|
| 468 |
model = await _model_override_for_new_session(request, model)
|
|
@@ -508,6 +553,7 @@ async def restore_session_summary(
|
|
| 508 |
valid_ids = {m["id"] for m in AVAILABLE_MODELS}
|
| 509 |
if model and model not in valid_ids:
|
| 510 |
raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
|
|
|
|
| 511 |
|
| 512 |
model = await _model_override_for_new_session(request, model)
|
| 513 |
|
|
@@ -579,6 +625,7 @@ async def set_session_model(
|
|
| 579 |
valid_ids = {m["id"] for m in AVAILABLE_MODELS}
|
| 580 |
if model_id not in valid_ids:
|
| 581 |
raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
|
|
|
|
| 582 |
if not agent_session:
|
| 583 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 584 |
await session_manager.update_session_model(session_id, model_id)
|
|
|
|
| 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,
|
|
|
|
| 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__)
|
|
|
|
| 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
|
| 78 |
DATASET_UPLOAD_MULTIPART_SLACK_BYTES = 1024 * 1024
|
|
|
|
| 82 |
models = [
|
| 83 |
{
|
| 84 |
"id": DEFAULT_PREMIUM_MODEL_ID,
|
| 85 |
+
"label": "Claude Sonnet 4.6",
|
| 86 |
"provider": "huggingface",
|
| 87 |
"tier": "pro",
|
| 88 |
"recommended": True,
|
| 89 |
+
"minimum_plan": "free",
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"id": DEFAULT_OPUS_MODEL_ID,
|
| 93 |
+
"label": "Claude Opus 4.8",
|
| 94 |
+
"provider": "huggingface",
|
| 95 |
+
"tier": "pro",
|
| 96 |
+
"minimum_plan": "pro",
|
| 97 |
},
|
| 98 |
{
|
| 99 |
"id": DEFAULT_GPT_MODEL_ID,
|
| 100 |
"label": "GPT-5.5",
|
| 101 |
"provider": "huggingface",
|
| 102 |
"tier": "pro",
|
| 103 |
+
"minimum_plan": "pro",
|
| 104 |
},
|
| 105 |
{
|
| 106 |
"id": DEFAULT_FREE_MODEL_ID,
|
| 107 |
"label": "Kimi K2.6",
|
| 108 |
"provider": "huggingface",
|
| 109 |
"tier": "free",
|
| 110 |
+
"minimum_plan": "free",
|
| 111 |
},
|
| 112 |
{
|
| 113 |
"id": MINIMAX_M27_MODEL_ID,
|
| 114 |
"label": "MiniMax M2.7",
|
| 115 |
"provider": "huggingface",
|
| 116 |
"tier": "free",
|
| 117 |
+
"minimum_plan": "free",
|
| 118 |
},
|
| 119 |
{
|
| 120 |
"id": GLM_51_MODEL_ID,
|
| 121 |
"label": "GLM 5.1",
|
| 122 |
"provider": "huggingface",
|
| 123 |
"tier": "free",
|
| 124 |
+
"minimum_plan": "free",
|
| 125 |
},
|
| 126 |
{
|
| 127 |
"id": DEEPSEEK_V4_PRO_MODEL_ID,
|
| 128 |
"label": "DeepSeek V4 Pro",
|
| 129 |
"provider": "huggingface",
|
| 130 |
"tier": "free",
|
| 131 |
+
"minimum_plan": "free",
|
| 132 |
},
|
| 133 |
]
|
| 134 |
return models
|
|
|
|
| 141 |
return is_premium_model_id(model_id)
|
| 142 |
|
| 143 |
|
| 144 |
+
def _is_pro_only_premium_model(model_id: str | None) -> bool:
|
| 145 |
+
return is_pro_only_premium_model_id(model_id)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _model_unavailable_for_plan(model_id: str | None, plan: str | None) -> bool:
|
| 149 |
+
return plan != "pro" and _is_pro_only_premium_model(model_id)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _reject_model_unavailable_for_plan(
|
| 153 |
+
model_id: str | None,
|
| 154 |
+
user: dict[str, Any],
|
| 155 |
+
) -> None:
|
| 156 |
+
plan = user.get("plan", "free")
|
| 157 |
+
if not _model_unavailable_for_plan(model_id, plan):
|
| 158 |
+
return
|
| 159 |
+
raise HTTPException(
|
| 160 |
+
status_code=403,
|
| 161 |
+
detail={
|
| 162 |
+
"error": "model_requires_pro",
|
| 163 |
+
"plan": plan,
|
| 164 |
+
"model": model_id,
|
| 165 |
+
"message": "Claude Opus 4.8 and GPT-5.5 daily sessions require HF Pro.",
|
| 166 |
+
},
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
def _is_user_billed(model_id: str) -> bool:
|
| 171 |
return _is_premium_model(model_id)
|
| 172 |
|
|
|
|
| 210 |
``claude_counted`` flag on ``AgentSession`` guards against re-counting the
|
| 211 |
same session; the stored field name is kept for persistence compatibility.
|
| 212 |
|
| 213 |
+
Subsidizes the daily allowance (free = 2 for default premium, pro = 20
|
| 214 |
+
across premium models), organization-billed through the HF Router. Opus and
|
| 215 |
+
GPT-5.5 are pro-only before quota is charged. Past the allowance, premium
|
| 216 |
+
router models flip the session to ``premium_user_billed`` so the call bills
|
| 217 |
+
the user's own HF token instead of blocking. No-ops when the model isn't
|
| 218 |
+
premium or when this session's billing has already been decided.
|
| 219 |
"""
|
|
|
|
|
|
|
| 220 |
model_name = agent_session.session.config.model_name
|
| 221 |
if not _is_premium_model(model_name):
|
| 222 |
return
|
| 223 |
+
_reject_model_unavailable_for_plan(model_name, user)
|
| 224 |
+
if agent_session.claude_counted:
|
| 225 |
+
return
|
| 226 |
user_id = user["user_id"]
|
| 227 |
plan = user.get("plan", "free")
|
| 228 |
cap = user_quotas.daily_cap_for(plan)
|
|
|
|
| 507 |
valid_ids = {m["id"] for m in AVAILABLE_MODELS}
|
| 508 |
if model and model not in valid_ids:
|
| 509 |
raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
|
| 510 |
+
_reject_model_unavailable_for_plan(model, user)
|
| 511 |
|
| 512 |
# Empty requests use the configured default, which may be premium.
|
| 513 |
model = await _model_override_for_new_session(request, model)
|
|
|
|
| 553 |
valid_ids = {m["id"] for m in AVAILABLE_MODELS}
|
| 554 |
if model and model not in valid_ids:
|
| 555 |
raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
|
| 556 |
+
_reject_model_unavailable_for_plan(model, user)
|
| 557 |
|
| 558 |
model = await _model_override_for_new_session(request, model)
|
| 559 |
|
|
|
|
| 625 |
valid_ids = {m["id"] for m in AVAILABLE_MODELS}
|
| 626 |
if model_id not in valid_ids:
|
| 627 |
raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
|
| 628 |
+
_reject_model_unavailable_for_plan(model_id, user)
|
| 629 |
if not agent_session:
|
| 630 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 631 |
await session_manager.update_session_model(session_id, model_id)
|
backend/user_quotas.py
CHANGED
|
@@ -8,12 +8,13 @@ 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 in a session, not raw messages. A user who
|
| 11 |
-
sends with
|
| 12 |
-
an already-counted session back to a premium model doesn't
|
| 13 |
-
(`AgentSession.claude_counted` guards that).
|
|
|
|
| 14 |
|
| 15 |
Cap tiers:
|
| 16 |
-
free user → CLAUDE_FREE_DAILY (2)
|
| 17 |
pro user → CLAUDE_PRO_DAILY (20)
|
| 18 |
"""
|
| 19 |
|
|
|
|
| 8 |
only covered Claude and the persisted session field uses that name.
|
| 9 |
|
| 10 |
Unit: first premium-model submit in a session, not raw messages. A user who
|
| 11 |
+
sends with an allowed premium model in a new session consumes one quota point;
|
| 12 |
+
switching an already-counted session back to a premium model doesn't
|
| 13 |
+
(`AgentSession.claude_counted` 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 |
|
configs/cli_agent_config.json
CHANGED
|
@@ -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-sonnet-4-6:fal-ai",
|
| 3 |
"save_sessions": true,
|
| 4 |
"session_dataset_repo": "smolagents/ml-intern-sessions",
|
| 5 |
"share_traces": true,
|
configs/frontend_agent_config.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"model_name": "${ML_INTERN_DEFAULT_MODEL_ID:-anthropic/claude-
|
| 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:-anthropic/claude-sonnet-4-6:fal-ai}",
|
| 3 |
"save_sessions": true,
|
| 4 |
"session_dataset_repo": "smolagents/ml-intern-sessions",
|
| 5 |
"share_traces": true,
|
frontend/src/components/Chat/ChatInput.tsx
CHANGED
|
@@ -20,15 +20,17 @@ 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 { 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 |
GPT_55_MODEL_PATH,
|
| 30 |
isClaudePath,
|
| 31 |
isPremiumPath,
|
|
|
|
| 32 |
} from '@/utils/model';
|
| 33 |
|
| 34 |
// Model configuration
|
|
@@ -39,6 +41,7 @@ interface ModelOption {
|
|
| 39 |
modelPath: string;
|
| 40 |
avatarUrl: string;
|
| 41 |
recommended?: boolean;
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
const getHfAvatarUrl = (modelId: string) => {
|
|
@@ -48,19 +51,28 @@ const getHfAvatarUrl = (modelId: string) => {
|
|
| 48 |
|
| 49 |
const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
|
| 50 |
{
|
| 51 |
-
id: 'claude-
|
| 52 |
-
name: 'Claude
|
| 53 |
description: 'Hugging Face',
|
| 54 |
modelPath: CLAUDE_MODEL_PATH,
|
| 55 |
avatarUrl: getHfAvatarUrl(CLAUDE_MODEL_PATH),
|
| 56 |
recommended: true,
|
| 57 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
{
|
| 59 |
id: 'gpt-5.5',
|
| 60 |
name: 'GPT-5.5',
|
| 61 |
description: 'Hugging Face',
|
| 62 |
modelPath: GPT_55_MODEL_PATH,
|
| 63 |
avatarUrl: getHfAvatarUrl(GPT_55_MODEL_PATH),
|
|
|
|
| 64 |
},
|
| 65 |
{
|
| 66 |
id: 'kimi-k2.6',
|
|
@@ -97,6 +109,7 @@ const normalizeModelPath = (path: string | undefined) => (
|
|
| 97 |
.toLowerCase()
|
| 98 |
.replace(/^huggingface\//, '')
|
| 99 |
.replace(/claude-opus-4\.(\d)/g, 'claude-opus-4-$1')
|
|
|
|
| 100 |
);
|
| 101 |
|
| 102 |
const findModelByPath = (path: string, options: ModelOption[]): ModelOption | undefined => {
|
|
@@ -129,6 +142,7 @@ const modelOptionFromApi = (model: {
|
|
| 129 |
label?: string;
|
| 130 |
provider?: string;
|
| 131 |
recommended?: boolean;
|
|
|
|
| 132 |
}): ModelOption | null => {
|
| 133 |
if (!model.id) return null;
|
| 134 |
return {
|
|
@@ -138,6 +152,7 @@ const modelOptionFromApi = (model: {
|
|
| 138 |
modelPath: model.id,
|
| 139 |
avatarUrl: getHfAvatarUrl(model.id.replace(/^huggingface\//, '')),
|
| 140 |
recommended: Boolean(model.recommended),
|
|
|
|
| 141 |
};
|
| 142 |
};
|
| 143 |
|
|
@@ -188,6 +203,12 @@ const DATASET_UPLOAD_EXTENSIONS = new Set(['csv', 'json', 'jsonl']);
|
|
| 188 |
|
| 189 |
const isClaudeModel = (m: ModelOption) => isClaudePath(m.modelPath);
|
| 190 |
const isPremiumModel = (m: ModelOption) => isPremiumPath(m.modelPath);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
const formatBytes = (bytes: number) => {
|
| 193 |
if (bytes < 1024) return `${bytes} B`;
|
|
@@ -269,7 +290,16 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
|
|
| 269 |
return () => { cancelled = true; };
|
| 270 |
}, [sessionId, updateSessionModel]);
|
| 271 |
|
| 272 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
// Auto-focus the textarea when the session becomes ready
|
| 275 |
useEffect(() => {
|
|
@@ -279,11 +309,16 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
|
|
| 279 |
}, [disabled, isProcessing]);
|
| 280 |
|
| 281 |
const handleSend = useCallback(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
if (input.trim() && !disabled && !isUploadingDataset) {
|
| 283 |
onSend(input);
|
| 284 |
setInput('');
|
| 285 |
}
|
| 286 |
-
}, [input, disabled, isUploadingDataset, onSend]);
|
| 287 |
|
| 288 |
const handleDatasetUploadClick = useCallback(() => {
|
| 289 |
fileInputRef.current?.click();
|
|
@@ -389,6 +424,10 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
|
|
| 389 |
const handleSelectModel = async (model: ModelOption) => {
|
| 390 |
handleModelClose();
|
| 391 |
if (!sessionId) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
try {
|
| 393 |
const res = await apiFetch(`/api/session/${sessionId}/model`, {
|
| 394 |
method: 'POST',
|
|
@@ -718,7 +757,7 @@ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop,
|
|
| 718 |
}
|
| 719 |
}}
|
| 720 |
>
|
| 721 |
-
{
|
| 722 |
<MenuItem
|
| 723 |
key={model.id}
|
| 724 |
onClick={() => handleSelectModel(model)}
|
|
|
|
| 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
|
|
|
|
| 41 |
modelPath: string;
|
| 42 |
avatarUrl: string;
|
| 43 |
recommended?: boolean;
|
| 44 |
+
minimumPlan?: 'free' | 'pro';
|
| 45 |
}
|
| 46 |
|
| 47 |
const getHfAvatarUrl = (modelId: string) => {
|
|
|
|
| 51 |
|
| 52 |
const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
|
| 53 |
{
|
| 54 |
+
id: 'claude-sonnet-4-6',
|
| 55 |
+
name: 'Claude Sonnet 4.6',
|
| 56 |
description: 'Hugging Face',
|
| 57 |
modelPath: CLAUDE_MODEL_PATH,
|
| 58 |
avatarUrl: getHfAvatarUrl(CLAUDE_MODEL_PATH),
|
| 59 |
recommended: true,
|
| 60 |
},
|
| 61 |
+
{
|
| 62 |
+
id: 'claude-opus-4-8',
|
| 63 |
+
name: 'Claude Opus 4.8',
|
| 64 |
+
description: 'Hugging Face',
|
| 65 |
+
modelPath: CLAUDE_OPUS_48_MODEL_PATH,
|
| 66 |
+
avatarUrl: getHfAvatarUrl(CLAUDE_OPUS_48_MODEL_PATH),
|
| 67 |
+
minimumPlan: 'pro',
|
| 68 |
+
},
|
| 69 |
{
|
| 70 |
id: 'gpt-5.5',
|
| 71 |
name: 'GPT-5.5',
|
| 72 |
description: 'Hugging Face',
|
| 73 |
modelPath: GPT_55_MODEL_PATH,
|
| 74 |
avatarUrl: getHfAvatarUrl(GPT_55_MODEL_PATH),
|
| 75 |
+
minimumPlan: 'pro',
|
| 76 |
},
|
| 77 |
{
|
| 78 |
id: 'kimi-k2.6',
|
|
|
|
| 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 |
label?: string;
|
| 143 |
provider?: string;
|
| 144 |
recommended?: boolean;
|
| 145 |
+
minimum_plan?: string;
|
| 146 |
}): ModelOption | null => {
|
| 147 |
if (!model.id) return null;
|
| 148 |
return {
|
|
|
|
| 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 |
|
|
|
|
| 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`;
|
|
|
|
| 290 |
return () => { cancelled = true; };
|
| 291 |
}, [sessionId, updateSessionModel]);
|
| 292 |
|
| 293 |
+
const plan = quota?.plan ?? 'free';
|
| 294 |
+
const visibleModelOptions = modelOptions.filter((model) => (
|
| 295 |
+
isModelAllowedForPlan(model, plan)
|
| 296 |
+
));
|
| 297 |
+
const selectedModel = (
|
| 298 |
+
visibleModelOptions.find(m => m.id === selectedModelId)
|
| 299 |
+
|| modelOptions.find(m => m.id === selectedModelId)
|
| 300 |
+
|| visibleModelOptions[0]
|
| 301 |
+
|| modelOptions[0]
|
| 302 |
+
);
|
| 303 |
|
| 304 |
// Auto-focus the textarea when the session becomes ready
|
| 305 |
useEffect(() => {
|
|
|
|
| 309 |
}, [disabled, isProcessing]);
|
| 310 |
|
| 311 |
const handleSend = useCallback(() => {
|
| 312 |
+
const selectedOption = modelOptions.find((model) => model.id === selectedModelId);
|
| 313 |
+
if (selectedOption && !isModelAllowedForPlan(selectedOption, plan)) {
|
| 314 |
+
setModelSwitchError('Claude Opus 4.8 and GPT-5.5 daily sessions require HF Pro.');
|
| 315 |
+
return;
|
| 316 |
+
}
|
| 317 |
if (input.trim() && !disabled && !isUploadingDataset) {
|
| 318 |
onSend(input);
|
| 319 |
setInput('');
|
| 320 |
}
|
| 321 |
+
}, [input, disabled, isUploadingDataset, onSend, modelOptions, selectedModelId, plan]);
|
| 322 |
|
| 323 |
const handleDatasetUploadClick = useCallback(() => {
|
| 324 |
fileInputRef.current?.click();
|
|
|
|
| 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',
|
|
|
|
| 757 |
}
|
| 758 |
}}
|
| 759 |
>
|
| 760 |
+
{visibleModelOptions.map((model) => (
|
| 761 |
<MenuItem
|
| 762 |
key={model.id}
|
| 763 |
onClick={() => handleSelectModel(model)}
|
frontend/src/utils/model.ts
CHANGED
|
@@ -6,11 +6,18 @@
|
|
| 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 CLAUDE_MODEL_PATH =
|
| 11 |
export const GPT_55_MODEL_PATH = 'openai/gpt-5.5:fal-ai';
|
| 12 |
|
| 13 |
const PREMIUM_MODEL_PATHS = new Set([
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
CLAUDE_OPUS_48_MODEL_PATH,
|
| 15 |
GPT_55_MODEL_PATH,
|
| 16 |
]);
|
|
@@ -22,3 +29,7 @@ export function isClaudePath(modelPath: string | undefined): boolean {
|
|
| 22 |
export function isPremiumPath(modelPath: string | undefined): boolean {
|
| 23 |
return !!modelPath && PREMIUM_MODEL_PATHS.has(modelPath);
|
| 24 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
]);
|
|
|
|
| 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 |
+
}
|
tests/unit/test_agent_model_gating.py
CHANGED
|
@@ -33,12 +33,25 @@ def _premium_session(model: str = agent.DEFAULT_PREMIUM_MODEL_ID):
|
|
| 33 |
|
| 34 |
def test_premium_model_predicate_uses_router_ids_only():
|
| 35 |
assert agent._is_premium_model(agent.DEFAULT_PREMIUM_MODEL_ID)
|
|
|
|
| 36 |
assert agent._is_premium_model(agent.DEFAULT_GPT_MODEL_ID)
|
|
|
|
|
|
|
|
|
|
| 37 |
assert agent._is_user_billed(agent.DEFAULT_PREMIUM_MODEL_ID)
|
| 38 |
assert not agent._is_premium_model("moonshotai/Kimi-K2.6")
|
| 39 |
assert not agent._is_premium_model("unsupported/model")
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
@pytest.mark.asyncio
|
| 43 |
async def test_default_session_uses_configured_default_model():
|
| 44 |
model = await agent._model_override_for_new_session(None, None)
|
|
@@ -79,15 +92,72 @@ async def test_switching_to_premium_model_is_allowed_for_authenticated_user(
|
|
| 79 |
|
| 80 |
response = await agent.set_session_model(
|
| 81 |
"s1",
|
| 82 |
-
{"model": agent.
|
| 83 |
request=None,
|
| 84 |
user={"user_id": "u1", "plan": "free"},
|
| 85 |
)
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
assert response == {"session_id": "s1", "model": agent.DEFAULT_GPT_MODEL_ID}
|
| 88 |
assert updated == [("s1", agent.DEFAULT_GPT_MODEL_ID)]
|
| 89 |
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
@pytest.mark.asyncio
|
| 92 |
async def test_switching_to_unknown_model_id_is_rejected(monkeypatch):
|
| 93 |
async def fake_check_session_access(session_id, user, request=None):
|
|
@@ -182,6 +252,57 @@ async def test_free_model_does_not_consume_premium_quota(monkeypatch):
|
|
| 182 |
assert await agent.user_quotas.get_claude_used_today("u1") == 0
|
| 183 |
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
@pytest.mark.asyncio
|
| 186 |
async def test_pro_user_uses_pro_premium_quota(monkeypatch):
|
| 187 |
async def fake_persist_session_snapshot(_agent_session):
|
|
|
|
| 33 |
|
| 34 |
def test_premium_model_predicate_uses_router_ids_only():
|
| 35 |
assert agent._is_premium_model(agent.DEFAULT_PREMIUM_MODEL_ID)
|
| 36 |
+
assert agent._is_premium_model(agent.DEFAULT_OPUS_MODEL_ID)
|
| 37 |
assert agent._is_premium_model(agent.DEFAULT_GPT_MODEL_ID)
|
| 38 |
+
assert not agent._is_pro_only_premium_model(agent.DEFAULT_PREMIUM_MODEL_ID)
|
| 39 |
+
assert agent._is_pro_only_premium_model(agent.DEFAULT_OPUS_MODEL_ID)
|
| 40 |
+
assert agent._is_pro_only_premium_model(agent.DEFAULT_GPT_MODEL_ID)
|
| 41 |
assert agent._is_user_billed(agent.DEFAULT_PREMIUM_MODEL_ID)
|
| 42 |
assert not agent._is_premium_model("moonshotai/Kimi-K2.6")
|
| 43 |
assert not agent._is_premium_model("unsupported/model")
|
| 44 |
|
| 45 |
|
| 46 |
+
def test_available_models_mark_opus_and_gpt_as_pro_only():
|
| 47 |
+
models = {model["id"]: model for model in agent.AVAILABLE_MODELS}
|
| 48 |
+
|
| 49 |
+
assert models[agent.DEFAULT_PREMIUM_MODEL_ID]["label"] == "Claude Sonnet 4.6"
|
| 50 |
+
assert models[agent.DEFAULT_PREMIUM_MODEL_ID]["minimum_plan"] == "free"
|
| 51 |
+
assert models[agent.DEFAULT_OPUS_MODEL_ID]["minimum_plan"] == "pro"
|
| 52 |
+
assert models[agent.DEFAULT_GPT_MODEL_ID]["minimum_plan"] == "pro"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
@pytest.mark.asyncio
|
| 56 |
async def test_default_session_uses_configured_default_model():
|
| 57 |
model = await agent._model_override_for_new_session(None, None)
|
|
|
|
| 92 |
|
| 93 |
response = await agent.set_session_model(
|
| 94 |
"s1",
|
| 95 |
+
{"model": agent.DEFAULT_PREMIUM_MODEL_ID},
|
| 96 |
request=None,
|
| 97 |
user={"user_id": "u1", "plan": "free"},
|
| 98 |
)
|
| 99 |
|
| 100 |
+
assert response == {"session_id": "s1", "model": agent.DEFAULT_PREMIUM_MODEL_ID}
|
| 101 |
+
assert updated == [("s1", agent.DEFAULT_PREMIUM_MODEL_ID)]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@pytest.mark.asyncio
|
| 105 |
+
async def test_switching_to_pro_only_premium_model_is_allowed_for_pro_user(
|
| 106 |
+
monkeypatch,
|
| 107 |
+
):
|
| 108 |
+
updated = []
|
| 109 |
+
|
| 110 |
+
async def fake_check_session_access(session_id, user, request=None):
|
| 111 |
+
assert session_id == "s1"
|
| 112 |
+
assert user["user_id"] == "u1"
|
| 113 |
+
return SimpleNamespace(user_id="u1")
|
| 114 |
+
|
| 115 |
+
async def fake_update_session_model(session_id, model_id):
|
| 116 |
+
updated.append((session_id, model_id))
|
| 117 |
+
|
| 118 |
+
monkeypatch.setattr(agent, "_check_session_access", fake_check_session_access)
|
| 119 |
+
monkeypatch.setattr(
|
| 120 |
+
agent.session_manager,
|
| 121 |
+
"update_session_model",
|
| 122 |
+
fake_update_session_model,
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
response = await agent.set_session_model(
|
| 126 |
+
"s1",
|
| 127 |
+
{"model": agent.DEFAULT_GPT_MODEL_ID},
|
| 128 |
+
request=None,
|
| 129 |
+
user={"user_id": "u1", "plan": "pro"},
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
assert response == {"session_id": "s1", "model": agent.DEFAULT_GPT_MODEL_ID}
|
| 133 |
assert updated == [("s1", agent.DEFAULT_GPT_MODEL_ID)]
|
| 134 |
|
| 135 |
|
| 136 |
+
@pytest.mark.asyncio
|
| 137 |
+
async def test_switching_to_pro_only_premium_model_is_rejected_for_free_user(
|
| 138 |
+
monkeypatch,
|
| 139 |
+
):
|
| 140 |
+
async def fake_check_session_access(session_id, user, request=None):
|
| 141 |
+
return SimpleNamespace(user_id=user["user_id"])
|
| 142 |
+
|
| 143 |
+
async def fail_if_updated(session_id, model_id):
|
| 144 |
+
raise AssertionError("free users should not switch to pro-only models")
|
| 145 |
+
|
| 146 |
+
monkeypatch.setattr(agent, "_check_session_access", fake_check_session_access)
|
| 147 |
+
monkeypatch.setattr(agent.session_manager, "update_session_model", fail_if_updated)
|
| 148 |
+
|
| 149 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 150 |
+
await agent.set_session_model(
|
| 151 |
+
"s1",
|
| 152 |
+
{"model": agent.DEFAULT_GPT_MODEL_ID},
|
| 153 |
+
request=None,
|
| 154 |
+
user={"user_id": "u1", "plan": "free"},
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
assert exc_info.value.status_code == 403
|
| 158 |
+
assert exc_info.value.detail["error"] == "model_requires_pro"
|
| 159 |
+
|
| 160 |
+
|
| 161 |
@pytest.mark.asyncio
|
| 162 |
async def test_switching_to_unknown_model_id_is_rejected(monkeypatch):
|
| 163 |
async def fake_check_session_access(session_id, user, request=None):
|
|
|
|
| 252 |
assert await agent.user_quotas.get_claude_used_today("u1") == 0
|
| 253 |
|
| 254 |
|
| 255 |
+
@pytest.mark.asyncio
|
| 256 |
+
async def test_free_user_cannot_spend_quota_on_pro_only_premium_model(monkeypatch):
|
| 257 |
+
async def fail_if_persisted(_agent_session):
|
| 258 |
+
raise AssertionError("rejected model should not persist quota state")
|
| 259 |
+
|
| 260 |
+
monkeypatch.setattr(
|
| 261 |
+
agent.session_manager,
|
| 262 |
+
"persist_session_snapshot",
|
| 263 |
+
fail_if_persisted,
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
agent_session = _premium_session(agent.DEFAULT_OPUS_MODEL_ID)
|
| 267 |
+
|
| 268 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 269 |
+
await agent._enforce_premium_model_quota(
|
| 270 |
+
{"user_id": "u1", "plan": "free"},
|
| 271 |
+
agent_session,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
assert exc_info.value.status_code == 403
|
| 275 |
+
assert exc_info.value.detail["error"] == "model_requires_pro"
|
| 276 |
+
assert agent_session.claude_counted is False
|
| 277 |
+
assert await agent.user_quotas.get_claude_used_today("u1") == 0
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@pytest.mark.asyncio
|
| 281 |
+
async def test_downgraded_user_cannot_continue_counted_pro_only_session(monkeypatch):
|
| 282 |
+
async def fail_if_persisted(_agent_session):
|
| 283 |
+
raise AssertionError("already-counted rejected session should not persist")
|
| 284 |
+
|
| 285 |
+
monkeypatch.setattr(
|
| 286 |
+
agent.session_manager,
|
| 287 |
+
"persist_session_snapshot",
|
| 288 |
+
fail_if_persisted,
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
agent_session = _premium_session(agent.DEFAULT_OPUS_MODEL_ID)
|
| 292 |
+
agent_session.claude_counted = True
|
| 293 |
+
|
| 294 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 295 |
+
await agent._enforce_premium_model_quota(
|
| 296 |
+
{"user_id": "u1", "plan": "free"},
|
| 297 |
+
agent_session,
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
assert exc_info.value.status_code == 403
|
| 301 |
+
assert exc_info.value.detail["error"] == "model_requires_pro"
|
| 302 |
+
assert agent_session.claude_counted is True
|
| 303 |
+
assert await agent.user_quotas.get_claude_used_today("u1") == 0
|
| 304 |
+
|
| 305 |
+
|
| 306 |
@pytest.mark.asyncio
|
| 307 |
async def test_pro_user_uses_pro_premium_quota(monkeypatch):
|
| 308 |
async def fake_persist_session_snapshot(_agent_session):
|
tests/unit/test_cli_local_models.py
CHANGED
|
@@ -30,9 +30,10 @@ def test_openai_compat_prefix_is_not_supported():
|
|
| 30 |
assert not model_switcher.is_valid_model_id("openai-compat/custom-model")
|
| 31 |
|
| 32 |
|
| 33 |
-
def
|
| 34 |
ids = {m["id"] for m in model_switcher.SUGGESTED_MODELS}
|
| 35 |
|
|
|
|
| 36 |
assert "anthropic/claude-opus-4.8:fal-ai" in ids
|
| 37 |
assert all(model_id.count("/") >= 1 for model_id in ids)
|
| 38 |
|
|
|
|
| 30 |
assert not model_switcher.is_valid_model_id("openai-compat/custom-model")
|
| 31 |
|
| 32 |
|
| 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 |
|
tests/unit/test_llm_params.py
CHANGED
|
@@ -14,14 +14,14 @@ def test_hf_router_params_for_default_premium_model(monkeypatch):
|
|
| 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": "inference-token",
|
| 27 |
"extra_headers": {"X-HF-Bill-To": "smolagents"},
|
|
|
|
| 14 |
monkeypatch.setenv("HF_BILL_TO", "smolagents")
|
| 15 |
|
| 16 |
params = _resolve_llm_params(
|
| 17 |
+
"anthropic/claude-sonnet-4-6:fal-ai",
|
| 18 |
"session-token",
|
| 19 |
reasoning_effort="high",
|
| 20 |
strict=True,
|
| 21 |
)
|
| 22 |
|
| 23 |
assert params == {
|
| 24 |
+
"model": "openai/anthropic/claude-sonnet-4-6:fal-ai",
|
| 25 |
"api_base": HF_ROUTER_BASE_URL,
|
| 26 |
"api_key": "inference-token",
|
| 27 |
"extra_headers": {"X-HF-Bill-To": "smolagents"},
|