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

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 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 Opus 4.8 through the HF Router (`anthropic/claude-opus-4.8: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`); after the daily allowance, premium calls bill the user's own HF token. For local development, set `HF_TOKEN` and optionally `ML_INTERN_DEFAULT_MODEL_ID`.
17
 
18
  ## Development Checks
19
 
 
13
  - Vite proxies `/api` and `/auth` to `http://localhost:7860`.
14
  - If `127.0.0.1:7860` is already owned by another local process, binding the backend to `::1` lets the Vite proxy resolve `localhost` cleanly.
15
  - Prefer `npm ci` over `npm install` for setup, since `npm install` may rewrite `frontend/package-lock.json` metadata depending on npm version.
16
+ - 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-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,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-opus-4.8:fal-ai",
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 = CLAUDE_OPUS_48_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-opus-4.8:fal-ai' or a supported local prefix."
89
  )
90
  return model.removeprefix("huggingface/")
91
 
 
85
  if not model_switcher.is_valid_model_id(model):
86
  raise ValueError(
87
  "Invalid model id. Use an HF Router id like "
88
+ "'anthropic/claude-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 Opus 4.8",
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), organization-billed
172
- through the HF Router. Past the allowance, premium router models flip the
173
- session to ``premium_user_billed`` so the call bills the user's own HF
174
- token instead of blocking. No-ops when the model isn't premium or when this
175
- session's billing has already been decided.
 
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 a premium model in a new session consumes one quota point; switching
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-opus-4.8:fal-ai",
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-opus-4.8:fal-ai}",
3
  "save_sessions": true,
4
  "session_dataset_repo": "smolagents/ml-intern-sessions",
5
  "share_traces": true,
 
1
  {
2
+ "model_name": "${ML_INTERN_DEFAULT_MODEL_ID:-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-opus-4-8',
52
- name: 'Claude Opus 4.8',
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 selectedModel = modelOptions.find(m => m.id === selectedModelId) || modelOptions[0];
 
 
 
 
 
 
 
 
 
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
- {modelOptions.map((model) => (
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 = CLAUDE_OPUS_48_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.DEFAULT_GPT_MODEL_ID},
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 test_suggested_models_include_router_opus48_and_no_native_ids():
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-opus-4.8:fal-ai",
18
  "session-token",
19
  reasoning_effort="high",
20
  strict=True,
21
  )
22
 
23
  assert params == {
24
- "model": "openai/anthropic/claude-opus-4.8:fal-ai",
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"},