mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-10 11:47:43 +02:00
[aidan] fix/model-fallback: reconcile free trial and provider states
This commit is contained in:
@@ -810,6 +810,16 @@ async def list_models():
|
||||
if entries:
|
||||
result[cp_name] = entries
|
||||
|
||||
# Free lane: nothing of the user's own is connected, so surface the funded Haiku as the free-trial face. The picker shows "Claude Haiku" and the session/default reconcile to it, instead of the picker going empty and the model staying stuck on a dead last-used id (active = it runs; spent = the send is gated by the out-of-runs UI).
|
||||
if not result:
|
||||
haiku_entry = next((m for m in anthropic_models if m.get("value") == "haiku"), None)
|
||||
if haiku_entry:
|
||||
haiku_rows = p_serialize([haiku_entry])
|
||||
for hr in haiku_rows:
|
||||
hr["is_free"] = True
|
||||
hr["billing_kind"] = "free"
|
||||
result["Anthropic"] = haiku_rows
|
||||
|
||||
return {"models": result, "notes": notes}
|
||||
|
||||
|
||||
|
||||
@@ -217,6 +217,9 @@ def p_antigravity_connected() -> bool:
|
||||
|
||||
def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
|
||||
"""Short model name → id string for ClaudeAgentOptions."""
|
||||
# Free trial funds only Haiku via the cloud proxy; force it so a session left on a gpt-*/sub model can't escape to a lane the trial can't fund (which snags as a 401/404).
|
||||
if getattr(settings, "connection_mode", "own_key") == "free-trial":
|
||||
short_name = "haiku"
|
||||
entry = find_builtin_model(short_name)
|
||||
if entry is None:
|
||||
return short_name
|
||||
|
||||
@@ -357,12 +357,16 @@ async def ensure_running():
|
||||
|
||||
async def p_ensure_running_impl():
|
||||
"""Start 9Router if not already running."""
|
||||
global p_process
|
||||
global p_process, p_is_running_last_ok
|
||||
p_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
|
||||
|
||||
if is_running():
|
||||
# In dev mode, kill stale standalone servers (from previous builds) so we can start `next dev` which always uses latest source code
|
||||
if not p_is_packaged:
|
||||
# But never kill the instance WE already started: a second ensure call (another sub-app's lifespan races settings') would pkill our fresh next-server, leaving a dead window the boot key-sync fails into, so the cp-openai node never registers and gpt-5.* own-key dies.
|
||||
if p_process is not None and p_process.poll() is None:
|
||||
logger.info("9Router already running (ours) on port %d", NINE_ROUTER_PORT)
|
||||
return
|
||||
import subprocess as p_sp
|
||||
try:
|
||||
result = p_sp.run(
|
||||
@@ -372,6 +376,8 @@ async def p_ensure_running_impl():
|
||||
if result.stdout.strip():
|
||||
logger.info("Dev mode: killing stale standalone 9Router to use next dev instead")
|
||||
p_sp.run(["pkill", "-f", "next-server"], timeout=5)
|
||||
# The port is about to go dead; drop the positive-cache so the start-loop below actually re-probes instead of trusting the killed server's stale "ready".
|
||||
p_is_running_last_ok = 0.0
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
|
||||
|
||||
@@ -132,8 +132,10 @@ async def clear_free_trial(settings_obj) -> None:
|
||||
(so the UI knows it's spent) and never touches a real paid mode."""
|
||||
if getattr(settings_obj, "connection_mode", "own_key") == "free-trial":
|
||||
settings_obj.connection_mode = "own_key"
|
||||
# arm() pinned default_model to "haiku" for the free run; once the wheel is handed back, don't let that forced pick linger (it'd silently default a real subscription user to Haiku). "sonnet" is the fresh default; the frontend's DefaultModelGuard reconciles it to a reachable model if the connected provider isn't Anthropic.
|
||||
if getattr(settings_obj, "default_model", None) == "haiku":
|
||||
# Keep Haiku as the face of the free lane while the user has no model of their own (a spent trial still shows "Claude Haiku", with the send gated by the out-of-runs UI); only fall back to "sonnet" once a real key/sub connects so we never pin a paying user to Haiku.
|
||||
if getattr(settings_obj, "default_model", None) == "haiku" and (
|
||||
has_own_model(settings_obj) or await p_has_connected_subscription()
|
||||
):
|
||||
settings_obj.default_model = "sonnet"
|
||||
settings_obj.free_trial_token = None
|
||||
await save_settings_async(settings_obj)
|
||||
|
||||
Generated
+18
-1
@@ -84,6 +84,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1964,6 +1965,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -2007,6 +2009,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
||||
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -2242,6 +2245,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz",
|
||||
"integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"@mui/core-downloads-tracker": "^7.3.10",
|
||||
@@ -3374,6 +3378,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3770,6 +3775,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3809,6 +3815,7 @@
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -4137,6 +4144,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -8184,6 +8192,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -8240,6 +8249,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8458,6 +8468,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -8470,6 +8481,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -8516,6 +8528,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -8654,7 +8667,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -8966,6 +8980,7 @@
|
||||
"integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.1.5",
|
||||
@@ -10069,6 +10084,7 @@
|
||||
"integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.8",
|
||||
@@ -10117,6 +10133,7 @@
|
||||
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@discoveryjs/json-ext": "^0.5.0",
|
||||
"@webpack-cli/configtest": "^2.1.1",
|
||||
|
||||
+30
-20
@@ -324,8 +324,10 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
// Until 9Router answers, /models omits subscription models, so the saved default can look "no longer available" when it's really just not loaded yet. Reconciling then would clobber a real sub user's default down to a fallback (and persist it). Only reconcile against the complete list.
|
||||
const nineRouterUp = useAppSelector((s) => s.subscriptions.status?.running === true);
|
||||
const sessions = useAppSelector((s) => s.agents.sessions);
|
||||
const connectionMode = useAppSelector((s) => s.settings.data.connection_mode);
|
||||
const freeTrialRemaining = useAppSelector((s) => s.settings.data.free_trial_remaining);
|
||||
|
||||
const [warning, setWarning] = useState<{ from: string; to: string; provider: string } | null>(null);
|
||||
const [sessionSwitch, setSessionSwitch] = useState<{ toFreeTrial: boolean; runs: number | null; toLabel: string } | null>(null);
|
||||
const pendingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -340,52 +342,60 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
const fallback = pickFallbackModel(byProvider);
|
||||
if (!fallback || fallback.value === settings.default_model) return;
|
||||
|
||||
const fromLabel = flat.find((m) => m.value === settings.default_model)?.label ?? settings.default_model;
|
||||
// Persist the fallback so the stored default is never a dead model, and surface the same blue banner the per-session reconcile uses (no separate yellow notice, it just doubled up).
|
||||
pendingRef.current = true;
|
||||
dispatch(updateSettingsPatch({ default_model: fallback.value }))
|
||||
.finally(() => {
|
||||
pendingRef.current = false;
|
||||
});
|
||||
setWarning({ from: fromLabel, to: fallback.label, provider: fallback.provider });
|
||||
}, [settingsLoaded, modelsLoaded, nineRouterUp, byProvider, settings, dispatch]);
|
||||
setSessionSwitch({ toFreeTrial: connectionMode === 'free-trial', runs: freeTrialRemaining ?? null, toLabel: fallback.label });
|
||||
}, [settingsLoaded, modelsLoaded, nineRouterUp, connectionMode, freeTrialRemaining, byProvider, settings, dispatch]);
|
||||
|
||||
// Same staleness, per session: a session pinned to a now-gone model (e.g. gpt-5.5-api
|
||||
// after the OpenAI key is disconnected) makes the picker show an out-of-range value and
|
||||
// the next send snags, since the send carries the session's model. The guard above only
|
||||
// fixes the global default, so reconcile open sessions to the (valid) default or fallback.
|
||||
// Same staleness per session: a session pinned to a now-gone model (e.g. gpt-5.4-api after its key is disconnected) snags on the next send since the send carries that model, so reconcile open sessions to the valid default/fallback and warn once.
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded || !modelsLoaded || !nineRouterUp) return;
|
||||
if (!settingsLoaded || !modelsLoaded) return;
|
||||
// free-trial/pro model lists don't wait on 9Router sub enumeration, so don't gate them on nineRouterUp (often false on the free lane) or a stranded session never recovers.
|
||||
if (!nineRouterUp && connectionMode !== 'free-trial' && connectionMode !== 'openswarm-pro') return;
|
||||
if (Object.keys(byProvider).length === 0) return;
|
||||
const valid = new Set(Object.values(byProvider).flat().map((m) => m.value));
|
||||
const flat = Object.values(byProvider).flat();
|
||||
const valid = new Set(flat.map((m) => m.value));
|
||||
if (valid.size === 0) return;
|
||||
const fallback = pickFallbackModel(byProvider);
|
||||
if (!fallback) return;
|
||||
const target = valid.has(settings.default_model) ? settings.default_model : fallback.value;
|
||||
let switched = false;
|
||||
for (const sess of Object.values(sessions)) {
|
||||
if (sess.model && !valid.has(sess.model)) {
|
||||
switched = true;
|
||||
dispatch(updateSessionModel({ sessionId: sess.id, model: target }));
|
||||
}
|
||||
}
|
||||
}, [settingsLoaded, modelsLoaded, nineRouterUp, byProvider, sessions, settings, dispatch]);
|
||||
if (switched) {
|
||||
const toLabel = flat.find((m) => m.value === target)?.label ?? target;
|
||||
setSessionSwitch({ toFreeTrial: connectionMode === 'free-trial', runs: freeTrialRemaining ?? null, toLabel });
|
||||
}
|
||||
}, [settingsLoaded, modelsLoaded, nineRouterUp, connectionMode, freeTrialRemaining, byProvider, sessions, settings, dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Snackbar
|
||||
open={!!warning}
|
||||
autoHideDuration={8000}
|
||||
onClose={() => setWarning(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
open={!!sessionSwitch}
|
||||
autoHideDuration={9000}
|
||||
onClose={() => setSessionSwitch(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity="warning"
|
||||
severity="info"
|
||||
variant="filled"
|
||||
onClose={() => setWarning(null)}
|
||||
onClose={() => setSessionSwitch(null)}
|
||||
sx={{ fontSize: '0.8rem' }}
|
||||
>
|
||||
{warning && (
|
||||
<>Default model <b>{warning.from}</b> is no longer available, switched to <b>{warning.to}</b> ({warning.provider}).</>
|
||||
)}
|
||||
{sessionSwitch && (sessionSwitch.toFreeTrial ? (
|
||||
<>Your model isn't connected, you're on the free trial now{sessionSwitch.runs != null ? <> ({sessionSwitch.runs} runs left)</> : null}.</>
|
||||
) : (
|
||||
<>Your model isn't available anymore, switched to <b>{sessionSwitch.toLabel}</b>.</>
|
||||
))}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
|
||||
@@ -36,6 +36,7 @@ import Dashboard from '@/app/pages/Dashboard/Dashboard';
|
||||
import DashboardHost from '@/app/components/Layout/DashboardHost';
|
||||
import { useLastDashboardId } from '@/shared/hooks/useLastDashboardId';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { hasModelConnected as selectHasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
|
||||
import { shallowEqual } from 'react-redux';
|
||||
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
|
||||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
@@ -133,10 +134,9 @@ const AppShell: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// /agents/models intersects BUILTIN_MODELS with API keys + 9Router state; non-empty means at least one usable model.
|
||||
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
|
||||
const modelsLoaded = useAppSelector((s) => s.models.loaded);
|
||||
const hasModelConnected = Object.keys(modelsByProvider).length > 0;
|
||||
// "Connected" = the user's OWN model (key/sub/pro/custom), NOT a non-empty /models list: the free-trial Haiku is always in that list now, so a byProvider-length check would falsely read as connected and hide the out-of-runs banner.
|
||||
const hasModelConnected = useAppSelector(selectHasModelConnected);
|
||||
// During an active free trial the user CAN run things, so a red "no model connected" warning is misleading and discouraging (it sits right above the working starter chips). The trial flips connection_mode back to own_key the moment it's spent, so this banner returns then, landing the connect-a-model nudge after the win, not before it.
|
||||
const freeTrialActive = useAppSelector((s) => {
|
||||
const d = s.settings.data as any;
|
||||
|
||||
@@ -8,6 +8,7 @@ import TextField from '@mui/material/TextField';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import Fade from '@mui/material/Fade';
|
||||
import SwapHorizRoundedIcon from '@mui/icons-material/SwapHorizRounded';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
@@ -336,6 +337,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
// Workflow build chat only: brief "this model now runs the workflow" notice when the user switches models, so the run-model change isn't silent.
|
||||
const [workflowModelNotice, setWorkflowModelNotice] = useState<string | null>(null);
|
||||
const workflowModelNoticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [freeTrialModelNotice, setFreeTrialModelNotice] = useState<{ kind: 'connect' | 'spent'; label: string } | null>(null);
|
||||
const freeTrialModelNoticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const freeTrialRemaining = useAppSelector((s) => s.settings.data.free_trial_remaining);
|
||||
|
||||
// Read live in the stable handleSend/dispatchMessage closures without busting their memo (ChatInput leans on handleSend identity holding across renders).
|
||||
const runContextRef = useRef(runContext);
|
||||
@@ -844,16 +848,33 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
}, [id, isDraft, dispatch]);
|
||||
|
||||
const handleModelChange = useCallback((newModel: string) => {
|
||||
// On the trial only Haiku is funded; picking anything else needs a connected provider, and once runs are spent nothing local works, so warn and keep the funded model instead of snagging.
|
||||
if (connectionMode === 'free-trial') {
|
||||
const kind: 'connect' | 'spent' | null =
|
||||
(freeTrialRemaining ?? 0) <= 0 ? 'spent' : (newModel !== 'haiku' ? 'connect' : null);
|
||||
if (kind) {
|
||||
setFreeTrialModelNotice({ kind, label: resolveModelLabel(newModel) });
|
||||
if (freeTrialModelNoticeTimer.current) clearTimeout(freeTrialModelNoticeTimer.current);
|
||||
freeTrialModelNoticeTimer.current = setTimeout(() => setFreeTrialModelNotice(null), 6000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (workflowEditId && newModel !== model) {
|
||||
setWorkflowModelNotice(resolveModelLabel(newModel));
|
||||
if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current);
|
||||
workflowModelNoticeTimer.current = setTimeout(() => setWorkflowModelNotice(null), 5000);
|
||||
}
|
||||
// Picked a usable model: drop any stale notice now (fades out in ~220ms) instead of letting it sit out its timer.
|
||||
setFreeTrialModelNotice(null);
|
||||
if (freeTrialModelNoticeTimer.current) clearTimeout(freeTrialModelNoticeTimer.current);
|
||||
setModel(newModel);
|
||||
if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel }));
|
||||
}, [id, isDraft, dispatch, workflowEditId, model, resolveModelLabel]);
|
||||
}, [id, isDraft, dispatch, workflowEditId, model, resolveModelLabel, connectionMode, freeTrialRemaining]);
|
||||
|
||||
useEffect(() => () => { if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current); }, []);
|
||||
useEffect(() => () => {
|
||||
if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current);
|
||||
if (freeTrialModelNoticeTimer.current) clearTimeout(freeTrialModelNoticeTimer.current);
|
||||
}, []);
|
||||
|
||||
const handleThinkingLevelChange = useCallback((level: 'off' | 'low' | 'medium' | 'high' | 'auto') => {
|
||||
if (!id) return;
|
||||
@@ -2219,6 +2240,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
) : (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<WorkflowModelNotice c={c} label={workflowModelNotice} />
|
||||
<FreeTrialModelNotice c={c} notice={freeTrialModelNotice} />
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSend={handleSend}
|
||||
@@ -2275,4 +2297,31 @@ function WorkflowModelNotice({ c, label }: { c: ReturnType<typeof useClaudeToken
|
||||
);
|
||||
}
|
||||
|
||||
function FreeTrialModelNotice({ c, notice }: { c: ReturnType<typeof useClaudeTokens>; notice: { kind: 'connect' | 'spent'; label: string } | null }) {
|
||||
const last = React.useRef<{ kind: 'connect' | 'spent'; label: string } | null>(null);
|
||||
if (notice) last.current = notice;
|
||||
const display = last.current;
|
||||
if (!display) return null;
|
||||
return (
|
||||
<Fade in={!!notice} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box sx={{
|
||||
position: 'absolute', left: 8, right: 8, bottom: 'calc(100% + 8px)',
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
|
||||
boxShadow: c.shadow.md, borderRadius: '12px',
|
||||
px: 1.75, py: 1, zIndex: 6,
|
||||
}}>
|
||||
<InfoOutlinedIcon sx={{ fontSize: 17, color: c.accent.primary, flexShrink: 0 }} />
|
||||
<Box sx={{ fontSize: '0.83rem', color: c.text.primary, lineHeight: 1.4 }}>
|
||||
{display.kind === 'spent' ? (
|
||||
<>You're out of free runs, connect a model in Settings to use <b>{display.label}</b>.</>
|
||||
) : (
|
||||
<>Connect a provider in Settings to use <b>{display.label}</b>.</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentChat;
|
||||
|
||||
@@ -179,6 +179,7 @@ export function useModelPicker(
|
||||
});
|
||||
if (cancelled) return;
|
||||
const data = await res.json();
|
||||
if (!data.ok && data.error) console.warn('[model-probe]', model, data.error);
|
||||
setProbeResult({ value: model, ok: !!data.ok, error: data.error, latency_ms: data.latency_ms });
|
||||
} catch {}
|
||||
}, 350);
|
||||
|
||||
@@ -43,6 +43,21 @@ interface Props {
|
||||
pendingPayloadEstimate: number;
|
||||
}
|
||||
|
||||
// Probe errors come back as raw upstream JSON ("Error code: 400 - {'error': {...}}"); never show that to users. Map to a short actionable line (the raw is console-logged in useModelPicker for devs).
|
||||
function friendlyProbeError(raw?: string): string {
|
||||
const r = (raw || '').toLowerCase();
|
||||
if (r.includes('no credentials') || r.includes('not connected') || r.includes('bad_request')) {
|
||||
return "This model isn't connected, add its provider in Settings.";
|
||||
}
|
||||
if (r.includes('401') || r.includes('unauthorized') || r.includes('invalid api key') || r.includes('invalid_api_key')) {
|
||||
return "This model's key looks invalid, check it in Settings.";
|
||||
}
|
||||
if (r.includes('402') || r.includes('quota') || r.includes('credit') || r.includes('billing')) {
|
||||
return "This model is out of credits, check billing.";
|
||||
}
|
||||
return "This model isn't available right now.";
|
||||
}
|
||||
|
||||
export const ModelPickerMenu: React.FC<Props> = (props) => {
|
||||
const {
|
||||
c, menuPaperProps, modelAnchor, setModelAnchor, model, onModelChange, onProviderChange,
|
||||
@@ -84,7 +99,7 @@ export const ModelPickerMenu: React.FC<Props> = (props) => {
|
||||
/>
|
||||
|
||||
{probeResult && probeResult.value === model && !probeResult.ok && (
|
||||
<Tooltip title={probeResult.error || 'health check failed'} placement="bottom-start" enterDelay={400}>
|
||||
<Tooltip title={friendlyProbeError(probeResult.error)} placement="bottom-start" enterDelay={400}>
|
||||
<Box
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
@@ -108,7 +123,7 @@ export const ModelPickerMenu: React.FC<Props> = (props) => {
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: 0.85,
|
||||
}}>
|
||||
· {probeResult.error || 'this model failed its health check'}
|
||||
· {friendlyProbeError(probeResult.error)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { openSettingsModal } from '@/shared/state/settingsSlice';
|
||||
import { fetchSubscriptionStatus } from '@/shared/state/subscriptionsSlice';
|
||||
import { shallowEqual } from 'react-redux';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { hasModelConnected as selectHasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { SKILL_COLOR } from '@/app/components/editor/richEditorUtils';
|
||||
import PlanPickerModal from '@/app/components/overlays/PlanPickerModal';
|
||||
@@ -977,7 +978,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
frameworkOverhead: s.framework_overhead_tokens,
|
||||
activeMcpCount: s.active_mcps?.length ?? 0,
|
||||
messagesCount: s.messages?.length ?? 0,
|
||||
hasModel: Object.keys(state.models.byProvider || {}).length > 0,
|
||||
hasModel: selectHasModelConnected(state),
|
||||
} as OverflowContext;
|
||||
}, shallowEqual);
|
||||
const activeSessionId = useAppSelector((state) => state.agents.activeSessionId);
|
||||
|
||||
@@ -196,8 +196,11 @@ const Settings: React.FC = () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
const payload = loaded ? buildSubmit() : null;
|
||||
if (payload) {
|
||||
dispatch(updateSettingsPatch(payload.patch));
|
||||
dispatch(fetchModels());
|
||||
// Refetch only AFTER the patch lands, or it races the save and reads the pre-change list (stale Haiku until you reopen Settings). Not awaited, so the modal still closes instantly.
|
||||
dispatch(updateSettingsPatch(payload.patch))
|
||||
.unwrap()
|
||||
.then(() => dispatch(fetchModels()))
|
||||
.catch(() => {});
|
||||
baselineRef.current = form;
|
||||
}
|
||||
dispatch(closeSettingsModal());
|
||||
|
||||
Reference in New Issue
Block a user