lewtun HF Staff OpenAI Codex commited on
Commit
092f909
·
unverified ·
1 Parent(s): 7b561e3

Fallback to free model for gated defaults (#208)

Browse files

* Fallback to free model for gated defaults

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

* Cover explicit gated model access for HF users

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

* Seed model picker from created session

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

---------

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

backend/models.py CHANGED
@@ -66,6 +66,7 @@ class SessionResponse(BaseModel):
66
 
67
  session_id: str
68
  ready: bool = True
 
69
 
70
 
71
  class PendingApprovalTool(BaseModel):
 
66
 
67
  session_id: str
68
  ready: bool = True
69
+ model: str | None = None
70
 
71
 
72
  class PendingApprovalTool(BaseModel):
backend/routes/agent.py CHANGED
@@ -44,6 +44,7 @@ router = APIRouter(prefix="/api", tags=["agent"])
44
  _background_teardown_tasks: set[asyncio.Task] = set()
45
 
46
  DEFAULT_CLAUDE_MODEL_ID = "bedrock/us.anthropic.claude-opus-4-6-v1"
 
47
  GATED_MODEL_IDS = {
48
  DEFAULT_CLAUDE_MODEL_ID,
49
  "openai/gpt-5.5",
@@ -113,6 +114,20 @@ def _is_gated_model(model_id: str) -> bool:
113
  return model_id in GATED_MODEL_IDS
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  async def _require_hf_for_gated_model(request: Request, model_id: str) -> None:
117
  """403 if a non-``huggingface``-org user tries to select a gated model.
118
 
@@ -123,17 +138,35 @@ async def _require_hf_for_gated_model(request: Request, model_id: str) -> None:
123
  if not _is_gated_model(model_id):
124
  return
125
  if not await require_huggingface_org_member(request):
126
- raise HTTPException(
127
- status_code=403,
128
- detail={
129
- "error": "premium_model_restricted",
130
- "message": (
131
- "Premium models are gated to HF staff. Pick a free model — "
132
- "Kimi K2.6, MiniMax M2.7, GLM 5.1, or DeepSeek V4 Pro — "
133
- "instead."
134
- ),
135
- },
136
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
 
139
  async def _enforce_gated_model_quota(
@@ -365,9 +398,9 @@ async def create_session(
365
  if model and model not in valid_ids:
366
  raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
367
 
368
- # Deployed paid models are gated to HF staff; free and local-dev models pass through.
369
- resolved_model = model or session_manager.config.model_name
370
- await _require_hf_for_gated_model(request, resolved_model)
371
 
372
  try:
373
  session_id = await session_manager.create_session(
@@ -380,7 +413,11 @@ async def create_session(
380
  except SessionCapacityError as e:
381
  raise HTTPException(status_code=503, detail=str(e))
382
 
383
- return SessionResponse(session_id=session_id, ready=True)
 
 
 
 
384
 
385
 
386
  @router.post("/session/restore-summary", response_model=SessionResponse)
@@ -406,8 +443,7 @@ async def restore_session_summary(
406
  if model and model not in valid_ids:
407
  raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
408
 
409
- resolved_model = model or session_manager.config.model_name
410
- await _require_hf_for_gated_model(request, resolved_model)
411
 
412
  try:
413
  session_id = await session_manager.create_session(
@@ -432,7 +468,11 @@ async def restore_session_summary(
432
  f"Seeded session {session_id} for {user.get('username', 'unknown')} "
433
  f"(summary of {summarized} messages)"
434
  )
435
- return SessionResponse(session_id=session_id, ready=True)
 
 
 
 
436
 
437
 
438
  @router.get("/session/{session_id}", response_model=SessionInfo)
 
44
  _background_teardown_tasks: set[asyncio.Task] = set()
45
 
46
  DEFAULT_CLAUDE_MODEL_ID = "bedrock/us.anthropic.claude-opus-4-6-v1"
47
+ DEFAULT_FREE_MODEL_ID = "moonshotai/Kimi-K2.6"
48
  GATED_MODEL_IDS = {
49
  DEFAULT_CLAUDE_MODEL_ID,
50
  "openai/gpt-5.5",
 
114
  return model_id in GATED_MODEL_IDS
115
 
116
 
117
+ def _premium_model_restricted_error() -> HTTPException:
118
+ return HTTPException(
119
+ status_code=403,
120
+ detail={
121
+ "error": "premium_model_restricted",
122
+ "message": (
123
+ "Premium models are gated to HF staff. Pick a free model — "
124
+ "Kimi K2.6, MiniMax M2.7, GLM 5.1, or DeepSeek V4 Pro — "
125
+ "instead."
126
+ ),
127
+ },
128
+ )
129
+
130
+
131
  async def _require_hf_for_gated_model(request: Request, model_id: str) -> None:
132
  """403 if a non-``huggingface``-org user tries to select a gated model.
133
 
 
138
  if not _is_gated_model(model_id):
139
  return
140
  if not await require_huggingface_org_member(request):
141
+ raise _premium_model_restricted_error()
142
+
143
+
144
+ async def _model_override_for_new_session(
145
+ request: Request,
146
+ requested_model: str | None,
147
+ ) -> str | None:
148
+ """Return the model override to use when creating a new session.
149
+
150
+ Explicit gated-model requests keep the hard membership gate. Implicit
151
+ default sessions are more forgiving: when the configured default is gated
152
+ and the user lacks access, start them on the first free model instead of
153
+ blocking session creation.
154
+ """
155
+ resolved_model = requested_model or session_manager.config.model_name
156
+ if not _is_gated_model(resolved_model):
157
+ return requested_model
158
+ if await require_huggingface_org_member(request):
159
+ return requested_model
160
+ if requested_model:
161
+ raise _premium_model_restricted_error()
162
+
163
+ logger.info(
164
+ "Default gated model %s is unavailable to this user; "
165
+ "creating session with free fallback %s",
166
+ resolved_model,
167
+ DEFAULT_FREE_MODEL_ID,
168
+ )
169
+ return DEFAULT_FREE_MODEL_ID
170
 
171
 
172
  async def _enforce_gated_model_quota(
 
398
  if model and model not in valid_ids:
399
  raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
400
 
401
+ # Explicit premium selections remain gated. If the implicit configured
402
+ # default is unavailable, start the session on a free model instead.
403
+ model = await _model_override_for_new_session(request, model)
404
 
405
  try:
406
  session_id = await session_manager.create_session(
 
413
  except SessionCapacityError as e:
414
  raise HTTPException(status_code=503, detail=str(e))
415
 
416
+ return SessionResponse(
417
+ session_id=session_id,
418
+ ready=True,
419
+ model=model or session_manager.config.model_name,
420
+ )
421
 
422
 
423
  @router.post("/session/restore-summary", response_model=SessionResponse)
 
443
  if model and model not in valid_ids:
444
  raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
445
 
446
+ model = await _model_override_for_new_session(request, model)
 
447
 
448
  try:
449
  session_id = await session_manager.create_session(
 
468
  f"Seeded session {session_id} for {user.get('username', 'unknown')} "
469
  f"(summary of {summarized} messages)"
470
  )
471
+ return SessionResponse(
472
+ session_id=session_id,
473
+ ready=True,
474
+ model=model or session_manager.config.model_name,
475
+ )
476
 
477
 
478
  @router.get("/session/{session_id}", response_model=SessionInfo)
frontend/src/components/Chat/ChatInput.tsx CHANGED
@@ -8,6 +8,7 @@ import { useUserQuota } from '@/hooks/useUserQuota';
8
  import ClaudeCapDialog from '@/components/ClaudeCapDialog';
9
  import JobsUpgradeDialog from '@/components/JobsUpgradeDialog';
10
  import { useAgentStore } from '@/store/agentStore';
 
11
  import {
12
  CLAUDE_MODEL_PATH,
13
  FIRST_FREE_MODEL_PATH,
@@ -79,11 +80,16 @@ const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
79
  ];
80
 
81
  const findModelByPath = (path: string, options: ModelOption[]): ModelOption | undefined => {
 
 
 
 
82
  return options.find(m => m.modelPath === path || path?.includes(m.id));
83
  };
84
 
85
  interface ChatInputProps {
86
  sessionId?: string;
 
87
  onSend: (text: string) => void;
88
  onStop?: () => void;
89
  isProcessing?: boolean;
@@ -95,13 +101,15 @@ const isClaudeModel = (m: ModelOption) => isClaudePath(m.modelPath);
95
  const isPremiumModel = (m: ModelOption) => isPremiumPath(m.modelPath);
96
  const firstFreeModel = (options: ModelOption[]) => options.find(m => !isPremiumModel(m)) ?? options[0];
97
 
98
- export default function ChatInput({ sessionId, onSend, onStop, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
99
  const [input, setInput] = useState('');
100
  const inputRef = useRef<HTMLTextAreaElement>(null);
101
  const [modelOptions, setModelOptions] = useState<ModelOption[]>(DEFAULT_MODEL_OPTIONS);
102
  const modelOptionsRef = useRef<ModelOption[]>(DEFAULT_MODEL_OPTIONS);
103
  const sessionIdRef = useRef<string | undefined>(sessionId);
104
- const [selectedModelId, setSelectedModelId] = useState<string>(DEFAULT_MODEL_OPTIONS[0].id);
 
 
105
  const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null);
106
  const { quota, refresh: refreshQuota } = useUserQuota();
107
  // The daily-cap dialog is triggered from two places: (a) a 429 returned
@@ -113,6 +121,7 @@ export default function ChatInput({ sessionId, onSend, onStop, isProcessing = fa
113
  const setClaudeQuotaExhausted = useAgentStore((s) => s.setClaudeQuotaExhausted);
114
  const jobsUpgradeRequired = useAgentStore((s) => s.jobsUpgradeRequired);
115
  const setJobsUpgradeRequired = useAgentStore((s) => s.setJobsUpgradeRequired);
 
116
  const [awaitingTopUp, setAwaitingTopUp] = useState(false);
117
  const lastSentRef = useRef<string>('');
118
 
@@ -163,11 +172,12 @@ export default function ChatInput({ sessionId, onSend, onStop, isProcessing = fa
163
  if (data?.model) {
164
  const model = findModelByPath(data.model, modelOptionsRef.current);
165
  if (model) setSelectedModelId(model.id);
 
166
  }
167
  })
168
  .catch(() => { /* ignore */ });
169
  return () => { cancelled = true; };
170
- }, [sessionId]);
171
 
172
  const selectedModel = modelOptions.find(m => m.id === selectedModelId) || modelOptions[0];
173
 
@@ -227,7 +237,10 @@ export default function ChatInput({ sessionId, onSend, onStop, isProcessing = fa
227
  method: 'POST',
228
  body: JSON.stringify({ model: model.modelPath }),
229
  });
230
- if (res.ok) setSelectedModelId(model.id);
 
 
 
231
  } catch { /* ignore */ }
232
  };
233
 
@@ -250,6 +263,7 @@ export default function ChatInput({ sessionId, onSend, onStop, isProcessing = fa
250
  });
251
  if (res.ok) {
252
  setSelectedModelId(free.id);
 
253
  const retryText = lastSentRef.current;
254
  if (retryText) {
255
  onSend(retryText);
@@ -258,7 +272,7 @@ export default function ChatInput({ sessionId, onSend, onStop, isProcessing = fa
258
  }
259
  }
260
  } catch { /* ignore */ }
261
- }, [sessionId, onSend, setClaudeQuotaExhausted, modelOptions]);
262
 
263
  const handlePremiumUpgradeClick = useCallback(async () => {
264
  if (!sessionId) return;
 
8
  import ClaudeCapDialog from '@/components/ClaudeCapDialog';
9
  import JobsUpgradeDialog from '@/components/JobsUpgradeDialog';
10
  import { useAgentStore } from '@/store/agentStore';
11
+ import { useSessionStore } from '@/store/sessionStore';
12
  import {
13
  CLAUDE_MODEL_PATH,
14
  FIRST_FREE_MODEL_PATH,
 
80
  ];
81
 
82
  const findModelByPath = (path: string, options: ModelOption[]): ModelOption | undefined => {
83
+ if (isClaudePath(path)) {
84
+ const claude = options.find(isClaudeModel);
85
+ if (claude) return claude;
86
+ }
87
  return options.find(m => m.modelPath === path || path?.includes(m.id));
88
  };
89
 
90
  interface ChatInputProps {
91
  sessionId?: string;
92
+ initialModelPath?: string | null;
93
  onSend: (text: string) => void;
94
  onStop?: () => void;
95
  isProcessing?: boolean;
 
101
  const isPremiumModel = (m: ModelOption) => isPremiumPath(m.modelPath);
102
  const firstFreeModel = (options: ModelOption[]) => options.find(m => !isPremiumModel(m)) ?? options[0];
103
 
104
+ export default function ChatInput({ sessionId, initialModelPath, onSend, onStop, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
105
  const [input, setInput] = useState('');
106
  const inputRef = useRef<HTMLTextAreaElement>(null);
107
  const [modelOptions, setModelOptions] = useState<ModelOption[]>(DEFAULT_MODEL_OPTIONS);
108
  const modelOptionsRef = useRef<ModelOption[]>(DEFAULT_MODEL_OPTIONS);
109
  const sessionIdRef = useRef<string | undefined>(sessionId);
110
+ const [selectedModelId, setSelectedModelId] = useState<string>(
111
+ () => findModelByPath(initialModelPath ?? '', DEFAULT_MODEL_OPTIONS)?.id ?? DEFAULT_MODEL_OPTIONS[0].id,
112
+ );
113
  const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null);
114
  const { quota, refresh: refreshQuota } = useUserQuota();
115
  // The daily-cap dialog is triggered from two places: (a) a 429 returned
 
121
  const setClaudeQuotaExhausted = useAgentStore((s) => s.setClaudeQuotaExhausted);
122
  const jobsUpgradeRequired = useAgentStore((s) => s.jobsUpgradeRequired);
123
  const setJobsUpgradeRequired = useAgentStore((s) => s.setJobsUpgradeRequired);
124
+ const updateSessionModel = useSessionStore((s) => s.updateSessionModel);
125
  const [awaitingTopUp, setAwaitingTopUp] = useState(false);
126
  const lastSentRef = useRef<string>('');
127
 
 
172
  if (data?.model) {
173
  const model = findModelByPath(data.model, modelOptionsRef.current);
174
  if (model) setSelectedModelId(model.id);
175
+ updateSessionModel(sessionId, data.model);
176
  }
177
  })
178
  .catch(() => { /* ignore */ });
179
  return () => { cancelled = true; };
180
+ }, [sessionId, updateSessionModel]);
181
 
182
  const selectedModel = modelOptions.find(m => m.id === selectedModelId) || modelOptions[0];
183
 
 
237
  method: 'POST',
238
  body: JSON.stringify({ model: model.modelPath }),
239
  });
240
+ if (res.ok) {
241
+ setSelectedModelId(model.id);
242
+ updateSessionModel(sessionId, model.modelPath);
243
+ }
244
  } catch { /* ignore */ }
245
  };
246
 
 
263
  });
264
  if (res.ok) {
265
  setSelectedModelId(free.id);
266
+ updateSessionModel(sessionId, free.modelPath);
267
  const retryText = lastSentRef.current;
268
  if (retryText) {
269
  onSend(retryText);
 
272
  }
273
  }
274
  } catch { /* ignore */ }
275
+ }, [sessionId, onSend, setClaudeQuotaExhausted, modelOptions, updateSessionModel]);
276
 
277
  const handlePremiumUpgradeClick = useCallback(async () => {
278
  if (!sessionId) return;
frontend/src/components/Chat/ExpiredBanner.tsx CHANGED
@@ -18,7 +18,7 @@ interface Props {
18
  }
19
 
20
  export default function ExpiredBanner({ sessionId }: Props) {
21
- const { renameSession, deleteSession } = useSessionStore();
22
  const [busy, setBusy] = useState<'catch-up' | 'start-over' | null>(null);
23
  const [error, setError] = useState<string | null>(null);
24
 
@@ -50,12 +50,13 @@ export default function ExpiredBanner({ sessionId }: Props) {
50
 
51
  useAgentStore.getState().clearSessionState(sessionId);
52
  renameSession(sessionId, newId);
 
53
  } catch (e) {
54
  logger.warn('Catch-up failed:', e);
55
  setError("Couldn't catch up — try starting over.");
56
  setBusy(null);
57
  }
58
- }, [sessionId, renameSession]);
59
 
60
  const handleStartOver = useCallback(() => {
61
  setBusy('start-over');
 
18
  }
19
 
20
  export default function ExpiredBanner({ sessionId }: Props) {
21
+ const { renameSession, deleteSession, updateSessionModel } = useSessionStore();
22
  const [busy, setBusy] = useState<'catch-up' | 'start-over' | null>(null);
23
  const [error, setError] = useState<string | null>(null);
24
 
 
50
 
51
  useAgentStore.getState().clearSessionState(sessionId);
52
  renameSession(sessionId, newId);
53
+ if (data.model) updateSessionModel(newId, data.model);
54
  } catch (e) {
55
  logger.warn('Catch-up failed:', e);
56
  setError("Couldn't catch up — try starting over.");
57
  setBusy(null);
58
  }
59
+ }, [sessionId, renameSession, updateSessionModel]);
60
 
61
  const handleStartOver = useCallback(() => {
62
  setBusy('start-over');
frontend/src/components/SessionChat.tsx CHANGED
@@ -24,7 +24,8 @@ interface SessionChatProps {
24
  export default function SessionChat({ sessionId, isActive, onSessionDead }: SessionChatProps) {
25
  const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore();
26
  const { updateSessionTitle, sessions } = useSessionStore();
27
- const isExpired = sessions.find((s) => s.id === sessionId)?.expired === true;
 
28
 
29
  const { messages, sendMessage, stop, status, undoLastTurn, editAndRegenerate, approveTools } = useAgentChat({
30
  sessionId,
@@ -112,6 +113,7 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
112
  ) : (
113
  <ChatInput
114
  sessionId={sessionId}
 
115
  onSend={handleSendMessage}
116
  onStop={handleStop}
117
  isProcessing={busy}
 
24
  export default function SessionChat({ sessionId, isActive, onSessionDead }: SessionChatProps) {
25
  const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore();
26
  const { updateSessionTitle, sessions } = useSessionStore();
27
+ const sessionMeta = sessions.find((s) => s.id === sessionId);
28
+ const isExpired = sessionMeta?.expired === true;
29
 
30
  const { messages, sendMessage, stop, status, undoLastTurn, editAndRegenerate, approveTools } = useAgentChat({
31
  sessionId,
 
113
  ) : (
114
  <ChatInput
115
  sessionId={sessionId}
116
+ initialModelPath={sessionMeta?.model}
117
  onSend={handleSendMessage}
118
  onStop={handleStop}
119
  isProcessing={busy}
frontend/src/components/SessionSidebar/SessionSidebar.tsx CHANGED
@@ -63,7 +63,7 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
63
  return;
64
  }
65
  const data = await response.json();
66
- createSession(data.session_id);
67
  setPlan([]);
68
  clearPanel();
69
  onClose?.();
@@ -107,7 +107,7 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
107
  const response = await apiFetch('/api/session', { method: 'POST' });
108
  if (response.ok) {
109
  const data = await response.json();
110
- createSession(data.session_id);
111
  setPlan([]);
112
  clearPanel();
113
  }
 
63
  return;
64
  }
65
  const data = await response.json();
66
+ createSession(data.session_id, data.model);
67
  setPlan([]);
68
  clearPanel();
69
  onClose?.();
 
107
  const response = await apiFetch('/api/session', { method: 'POST' });
108
  if (response.ok) {
109
  const data = await response.json();
110
+ createSession(data.session_id, data.model);
111
  setPlan([]);
112
  clearPanel();
113
  }
frontend/src/components/WelcomeScreen/WelcomeScreen.tsx CHANGED
@@ -209,7 +209,7 @@ export default function WelcomeScreen() {
209
  return;
210
  }
211
  const data = await response.json();
212
- createSession(data.session_id);
213
  setPlan([]);
214
  clearPanel();
215
  } catch {
 
209
  return;
210
  }
211
  const data = await response.json();
212
+ createSession(data.session_id, data.model);
213
  setPlan([]);
214
  clearPanel();
215
  } catch {
frontend/src/store/sessionStore.ts CHANGED
@@ -9,11 +9,12 @@ interface SessionStore {
9
  activeSessionId: string | null;
10
 
11
  // Actions
12
- createSession: (id: string) => void;
13
  deleteSession: (id: string) => void;
14
  switchSession: (id: string) => void;
15
  setSessionActive: (id: string, isActive: boolean) => void;
16
  updateSessionTitle: (id: string, title: string) => void;
 
17
  setNeedsAttention: (id: string, needs: boolean) => void;
18
  /** Mark a session as expired (backend no longer has it). The UI shows a
19
  * recovery banner and disables input. */
@@ -26,6 +27,7 @@ interface SessionStore {
26
  title?: string | null;
27
  created_at: string;
28
  is_active?: boolean;
 
29
  pending_approval?: unknown[] | null;
30
  auto_approval?: {
31
  enabled?: boolean;
@@ -52,13 +54,14 @@ export const useSessionStore = create<SessionStore>()(
52
  sessions: [],
53
  activeSessionId: null,
54
 
55
- createSession: (id: string) => {
56
  const newSession: SessionMeta = {
57
  id,
58
  title: `Chat ${get().sessions.length + 1}`,
59
  createdAt: new Date().toISOString(),
60
  isActive: true,
61
  needsAttention: false,
 
62
  autoApprovalEnabled: false,
63
  autoApprovalCostCapUsd: null,
64
  autoApprovalEstimatedSpendUsd: 0,
@@ -114,6 +117,7 @@ export const useSessionStore = create<SessionStore>()(
114
  ...existing,
115
  title: server.title || existing.title,
116
  isActive: server.is_active ?? existing.isActive,
 
117
  needsAttention: Boolean(server.pending_approval?.length) || existing.needsAttention,
118
  expired: false,
119
  ...(auto
@@ -136,6 +140,7 @@ export const useSessionStore = create<SessionStore>()(
136
  createdAt: server.created_at || new Date().toISOString(),
137
  isActive: server.is_active ?? true,
138
  needsAttention: Boolean(server.pending_approval?.length),
 
139
  expired: false,
140
  autoApprovalEnabled: Boolean(server.auto_approval?.enabled),
141
  autoApprovalCostCapUsd: server.auto_approval?.cost_cap_usd ?? null,
@@ -205,6 +210,14 @@ export const useSessionStore = create<SessionStore>()(
205
  }));
206
  },
207
 
 
 
 
 
 
 
 
 
208
  setNeedsAttention: (id: string, needs: boolean) => {
209
  set((state) => ({
210
  sessions: state.sessions.map((s) =>
 
9
  activeSessionId: string | null;
10
 
11
  // Actions
12
+ createSession: (id: string, model?: string | null) => void;
13
  deleteSession: (id: string) => void;
14
  switchSession: (id: string) => void;
15
  setSessionActive: (id: string, isActive: boolean) => void;
16
  updateSessionTitle: (id: string, title: string) => void;
17
+ updateSessionModel: (id: string, model: string | null) => void;
18
  setNeedsAttention: (id: string, needs: boolean) => void;
19
  /** Mark a session as expired (backend no longer has it). The UI shows a
20
  * recovery banner and disables input. */
 
27
  title?: string | null;
28
  created_at: string;
29
  is_active?: boolean;
30
+ model?: string | null;
31
  pending_approval?: unknown[] | null;
32
  auto_approval?: {
33
  enabled?: boolean;
 
54
  sessions: [],
55
  activeSessionId: null,
56
 
57
+ createSession: (id: string, model?: string | null) => {
58
  const newSession: SessionMeta = {
59
  id,
60
  title: `Chat ${get().sessions.length + 1}`,
61
  createdAt: new Date().toISOString(),
62
  isActive: true,
63
  needsAttention: false,
64
+ model: model ?? null,
65
  autoApprovalEnabled: false,
66
  autoApprovalCostCapUsd: null,
67
  autoApprovalEstimatedSpendUsd: 0,
 
117
  ...existing,
118
  title: server.title || existing.title,
119
  isActive: server.is_active ?? existing.isActive,
120
+ model: server.model ?? existing.model ?? null,
121
  needsAttention: Boolean(server.pending_approval?.length) || existing.needsAttention,
122
  expired: false,
123
  ...(auto
 
140
  createdAt: server.created_at || new Date().toISOString(),
141
  isActive: server.is_active ?? true,
142
  needsAttention: Boolean(server.pending_approval?.length),
143
+ model: server.model ?? null,
144
  expired: false,
145
  autoApprovalEnabled: Boolean(server.auto_approval?.enabled),
146
  autoApprovalCostCapUsd: server.auto_approval?.cost_cap_usd ?? null,
 
210
  }));
211
  },
212
 
213
+ updateSessionModel: (id: string, model: string | null) => {
214
+ set((state) => ({
215
+ sessions: state.sessions.map((s) =>
216
+ s.id === id ? { ...s, model } : s
217
+ ),
218
+ }));
219
+ },
220
+
221
  setNeedsAttention: (id: string, needs: boolean) => {
222
  set((state) => ({
223
  sessions: state.sessions.map((s) =>
frontend/src/types/agent.ts CHANGED
@@ -16,6 +16,7 @@ export interface SessionMeta {
16
  createdAt: string;
17
  isActive: boolean;
18
  needsAttention: boolean;
 
19
  /** True when the backend no longer recognizes this session id (e.g.
20
  * after a backend restart). The UI shows a recovery banner and
21
  * disables input until the user chooses to restore-with-summary or
 
16
  createdAt: string;
17
  isActive: boolean;
18
  needsAttention: boolean;
19
+ model?: string | null;
20
  /** True when the backend no longer recognizes this session id (e.g.
21
  * after a backend restart). The UI shows a recovery banner and
22
  * disables input until the user chooses to restore-with-summary or
tests/unit/test_agent_model_gating.py CHANGED
@@ -33,7 +33,11 @@ async def test_gated_model_gate_rejects_gpt55_for_non_hf_user(monkeypatch):
33
  async def fake_require_hf_org_member(_request):
34
  return False
35
 
36
- monkeypatch.setattr(agent, "require_huggingface_org_member", fake_require_hf_org_member)
 
 
 
 
37
 
38
  with pytest.raises(HTTPException) as exc_info:
39
  await agent._require_hf_for_gated_model(None, "openai/gpt-5.5")
@@ -42,6 +46,83 @@ async def test_gated_model_gate_rejects_gpt55_for_non_hf_user(monkeypatch):
42
  assert exc_info.value.detail["error"] == "premium_model_restricted"
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  @pytest.mark.asyncio
46
  async def test_ungated_models_skip_hf_membership_check(monkeypatch):
47
  async def fail_if_called(_request):
 
33
  async def fake_require_hf_org_member(_request):
34
  return False
35
 
36
+ monkeypatch.setattr(
37
+ agent,
38
+ "require_huggingface_org_member",
39
+ fake_require_hf_org_member,
40
+ )
41
 
42
  with pytest.raises(HTTPException) as exc_info:
43
  await agent._require_hf_for_gated_model(None, "openai/gpt-5.5")
 
46
  assert exc_info.value.detail["error"] == "premium_model_restricted"
47
 
48
 
49
+ @pytest.mark.asyncio
50
+ async def test_default_gated_session_falls_back_to_free_model_for_non_hf_user(
51
+ monkeypatch,
52
+ ):
53
+ async def fake_require_hf_org_member(_request):
54
+ return False
55
+
56
+ monkeypatch.setattr(
57
+ agent,
58
+ "require_huggingface_org_member",
59
+ fake_require_hf_org_member,
60
+ )
61
+ monkeypatch.setattr(
62
+ agent.session_manager.config,
63
+ "model_name",
64
+ agent.DEFAULT_CLAUDE_MODEL_ID,
65
+ )
66
+
67
+ model = await agent._model_override_for_new_session(None, None)
68
+
69
+ assert model == agent.DEFAULT_FREE_MODEL_ID
70
+
71
+
72
+ @pytest.mark.asyncio
73
+ async def test_default_gated_session_stays_default_for_hf_user(monkeypatch):
74
+ async def fake_require_hf_org_member(_request):
75
+ return True
76
+
77
+ monkeypatch.setattr(
78
+ agent,
79
+ "require_huggingface_org_member",
80
+ fake_require_hf_org_member,
81
+ )
82
+ monkeypatch.setattr(
83
+ agent.session_manager.config,
84
+ "model_name",
85
+ agent.DEFAULT_CLAUDE_MODEL_ID,
86
+ )
87
+
88
+ model = await agent._model_override_for_new_session(None, None)
89
+
90
+ assert model is None
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_explicit_gated_session_allowed_for_hf_user(monkeypatch):
95
+ async def fake_require_hf_org_member(_request):
96
+ return True
97
+
98
+ monkeypatch.setattr(
99
+ agent,
100
+ "require_huggingface_org_member",
101
+ fake_require_hf_org_member,
102
+ )
103
+
104
+ model = await agent._model_override_for_new_session(
105
+ None,
106
+ agent.DEFAULT_CLAUDE_MODEL_ID,
107
+ )
108
+
109
+ assert model == agent.DEFAULT_CLAUDE_MODEL_ID
110
+
111
+
112
+ @pytest.mark.asyncio
113
+ async def test_explicit_gated_session_request_still_rejects_non_hf_user(monkeypatch):
114
+ async def fake_require_hf_org_member(_request):
115
+ return False
116
+
117
+ monkeypatch.setattr(agent, "require_huggingface_org_member", fake_require_hf_org_member)
118
+
119
+ with pytest.raises(HTTPException) as exc_info:
120
+ await agent._model_override_for_new_session(None, agent.DEFAULT_CLAUDE_MODEL_ID)
121
+
122
+ assert exc_info.value.status_code == 403
123
+ assert exc_info.value.detail["error"] == "premium_model_restricted"
124
+
125
+
126
  @pytest.mark.asyncio
127
  async def test_ungated_models_skip_hf_membership_check(monkeypatch):
128
  async def fail_if_called(_request):