Skip to content

Commit 12248af

Browse files
committed
added no-llm option to install
1 parent 23752a2 commit 12248af

14 files changed

Lines changed: 239 additions & 12 deletions

File tree

api-service/api_service/assistant/runtime.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727

2828

2929
def provider_unavailable_message(config: ModelConfig) -> str:
30+
if config.provider_family == "none":
31+
return "Assistant is disabled because no LLM provider is configured."
3032
reason = config.availability_reason or f"Provider {config.provider} is not available"
3133
return f"Model provider '{config.provider}' is not configured: {reason}"
3234

api-service/api_service/routers/providers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def list_providers(context: AuthContext = Depends(get_context)) -> list[Provider
2020
provider_family=cfg.provider_family,
2121
model=cfg.model,
2222
role=cfg.role.value,
23+
is_default=provider_registry.is_default_provider(cfg.role, cfg.provider),
2324
native_tool_calling=cfg.capabilities.native_tool_calling,
2425
json_mode=cfg.capabilities.json_mode,
2526
vision=cfg.capabilities.vision,

api-service/api_service/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ class ProviderSummary(BaseModel):
172172
provider_family: str
173173
model: str
174174
role: str
175+
is_default: bool = False
175176
native_tool_calling: bool = False
176177
json_mode: bool = True
177178
vision: bool = False

backend_common/providers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ def _openai_compatible_availability_reason(base_url: str | None) -> str | None:
8585
return None
8686

8787

88+
def _no_llm_config(role: ProviderRole) -> ModelConfig:
89+
return ModelConfig(
90+
provider="no-llm",
91+
provider_family="none",
92+
model="no-llm",
93+
role=role,
94+
capabilities=ProviderCapabilities(native_tool_calling=False, json_mode=False, vision=False, streaming=False),
95+
available=False,
96+
availability_reason="LLM setup was skipped during install",
97+
)
98+
99+
88100
class ProviderRegistry:
89101
def __init__(self) -> None:
90102
default_openai_capabilities = ProviderCapabilities(
@@ -118,6 +130,7 @@ def __init__(self) -> None:
118130
available=chat_availability_reason is None,
119131
availability_reason=chat_availability_reason,
120132
),
133+
_no_llm_config(ProviderRole.chat),
121134
],
122135
ProviderRole.orchestration: [
123136
ModelConfig(
@@ -131,6 +144,7 @@ def __init__(self) -> None:
131144
available=orchestration_availability_reason is None,
132145
availability_reason=orchestration_availability_reason,
133146
),
147+
_no_llm_config(ProviderRole.orchestration),
134148
],
135149
ProviderRole.embedding: [],
136150
}
@@ -235,6 +249,14 @@ def list_models(self) -> list[ModelConfig]:
235249
"""Return all registered model configurations."""
236250
return [config for configs in self._configs.values() for config in configs]
237251

252+
def default_provider_for_role(self, role: ProviderRole) -> str | None:
253+
"""Return the configured default provider name for a model role."""
254+
return self._default_provider_by_role.get(role)
255+
256+
def is_default_provider(self, role: ProviderRole, provider: str) -> bool:
257+
"""Return whether a provider matches the configured default for a role."""
258+
return _normalize_provider_name(provider) == _normalize_provider_name(self.default_provider_for_role(role))
259+
238260
def get(self, role: ProviderRole, provider_override: str | None = None, model_override: str | None = None) -> ModelConfig:
239261
"""Resolve the active provider configuration for a role."""
240262
configs = self._configs[role]
@@ -315,6 +337,9 @@ def build_chat_model(self, role: ProviderRole, provider_override: str | None = N
315337
temperature=0,
316338
)
317339

340+
if config.provider_family == "none":
341+
raise ValueError(config.availability_reason or "Assistant is disabled because no LLM provider is configured")
342+
318343
raise ValueError(f"Unsupported provider family: {config.provider_family}")
319344

320345

client/src/components/Chat.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
ToolCallEntry,
1111
ReasoningEntry,
1212
} from '../types';
13-
import { appFetch, clearAssistantHistory, fetchAssistantHistory, parseError, reportClientError } from '../utils/api';
13+
import { appFetch, clearAssistantHistory, fetchAssistantHistory, fetchProviders, parseError, reportClientError } from '../utils/api';
1414

1515
export type { ChatMessage };
1616

@@ -249,6 +249,7 @@ export function Chat({ externalMessages = [], style, hideHeader = false, current
249249
const [isLoading, setIsLoading] = useState(false);
250250
const [loadingMessage, setLoadingMessage] = useState<string>(STATUS_MESSAGES[1]);
251251
const [isClearing, setIsClearing] = useState(false);
252+
const [assistantDisabledMessage, setAssistantDisabledMessage] = useState<string | null>(null);
252253
const scrollRef = useRef<HTMLDivElement>(null);
253254
const abortRef = useRef<AbortController | null>(null);
254255
const historyRequestVersionRef = useRef(0);
@@ -283,6 +284,33 @@ export function Chat({ externalMessages = [], style, hideHeader = false, current
283284
};
284285
}, [workspaceId, caseId, scope]);
285286

287+
useEffect(() => {
288+
let cancelled = false;
289+
void fetchProviders()
290+
.then((providers) => {
291+
if (cancelled) return;
292+
const chatProviders = providers.filter((provider) => provider.role === 'chat');
293+
const defaultChatProvider = chatProviders.find((provider) => provider.is_default);
294+
if (defaultChatProvider?.provider === 'no-llm' || defaultChatProvider?.provider_family === 'none') {
295+
setAssistantDisabledMessage('Assistant is disabled because LLM setup was skipped. You can still upload, view, and process cases.');
296+
return;
297+
}
298+
if (!defaultChatProvider && chatProviders.length > 0 && chatProviders.every((provider) => !provider.available)) {
299+
setAssistantDisabledMessage('Assistant is disabled because no LLM provider is configured. You can still upload, view, and process cases.');
300+
return;
301+
}
302+
setAssistantDisabledMessage(null);
303+
})
304+
.catch((error) => {
305+
if (cancelled) return;
306+
console.error('Failed to load provider configuration:', error);
307+
setAssistantDisabledMessage(null);
308+
});
309+
return () => {
310+
cancelled = true;
311+
};
312+
}, []);
313+
286314
useEffect(() => {
287315
if (isLoading) {
288316
setLoadingMessage(prev => getRandomStatusMessage(prev));
@@ -338,7 +366,7 @@ export function Chat({ externalMessages = [], style, hideHeader = false, current
338366
}, [clearRequestToken, handleClear]);
339367

340368
const handleSend = async () => {
341-
if (!input.trim() || isLoading || isClearing) return;
369+
if (!input.trim() || isLoading || isClearing || assistantDisabledMessage) return;
342370

343371
const userContent = buildUserContent(input, currentLocation, getMriSnapshots);
344372
if (userContent.error) {
@@ -598,6 +626,11 @@ export function Chat({ externalMessages = [], style, hideHeader = false, current
598626
<span className="chat-spinner" aria-hidden="true" />
599627
</div>
600628
)}
629+
{assistantDisabledMessage && (
630+
<div className="chat-message info">
631+
{assistantDisabledMessage}
632+
</div>
633+
)}
601634
</div>
602635

603636
<div className="chat-input-container">
@@ -607,7 +640,7 @@ export function Chat({ externalMessages = [], style, hideHeader = false, current
607640
value={input}
608641
onChange={e => setInput(e.target.value)}
609642
onKeyDown={e => { if (e.key === 'Enter') void handleSend(); }}
610-
disabled={isClearing}
643+
disabled={isClearing || Boolean(assistantDisabledMessage)}
611644
/>
612645
{isLoading ? (
613646
<button
@@ -621,7 +654,7 @@ export function Chat({ externalMessages = [], style, hideHeader = false, current
621654
<button
622655
className="nc-btn nc-btn-active px-4"
623656
onClick={() => void handleSend()}
624-
disabled={!input.trim() || isClearing}
657+
disabled={!input.trim() || isClearing || Boolean(assistantDisabledMessage)}
625658
>
626659
Send
627660
</button>

client/src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,20 @@ export interface AssistantHistoryResponse {
389389
messages: ChatMessage[];
390390
}
391391

392+
export interface ProviderSummary {
393+
provider: string;
394+
provider_family: string;
395+
model: string;
396+
role: string;
397+
is_default: boolean;
398+
native_tool_calling: boolean;
399+
json_mode: boolean;
400+
vision: boolean;
401+
streaming: boolean;
402+
available: boolean;
403+
availability_reason?: string | null;
404+
}
405+
392406
export interface ArtifactListItem {
393407
id?: string;
394408
case_id?: string | null;

client/src/utils/api/assistant.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AssistantHistoryResponse, AssistantScope } from '../../types';
1+
import type { AssistantHistoryResponse, AssistantScope, ProviderSummary } from '../../types';
22

33
import { appJson, appOk } from './core';
44

@@ -30,3 +30,7 @@ export async function clearAssistantHistory(
3030
method: 'DELETE',
3131
});
3232
}
33+
34+
export async function fetchProviders(): Promise<ProviderSummary[]> {
35+
return appJson<ProviderSummary[]>('/providers', 'Failed to fetch provider configuration');
36+
}

scripts/check_python_runtime.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env python3
2+
"""Check NeuroCade Python runtime dependencies with a local startup sentinel."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import hashlib
8+
import importlib
9+
import sys
10+
from pathlib import Path
11+
12+
13+
RUNTIME_MODULES = (
14+
"fastapi",
15+
"uvicorn",
16+
"celery",
17+
"redis",
18+
"sqlalchemy",
19+
"psycopg",
20+
"pydantic_settings",
21+
"langgraph",
22+
"langchain_openai",
23+
"neurocade_runtime_tools",
24+
)
25+
26+
27+
def dependency_key(pyproject: Path) -> str:
28+
digest = hashlib.sha256(pyproject.read_bytes()).hexdigest()
29+
version = f"{sys.version_info.major}.{sys.version_info.minor}"
30+
return f"python-deps-v1|{sys.executable}|{version}|{digest}"
31+
32+
33+
def sentinel_current(sentinel: Path, key: str) -> bool:
34+
try:
35+
return sentinel.read_text(encoding="utf-8").strip() == key
36+
except OSError:
37+
return False
38+
39+
40+
def write_sentinel(sentinel: Path, key: str) -> None:
41+
sentinel.parent.mkdir(parents=True, exist_ok=True)
42+
temp = sentinel.with_name(f"{sentinel.name}.{id(key)}.tmp")
43+
temp.write_text(f"{key}\n", encoding="utf-8")
44+
temp.replace(sentinel)
45+
46+
47+
def check_imports() -> None:
48+
for module_name in RUNTIME_MODULES:
49+
importlib.import_module(module_name)
50+
51+
52+
def main() -> int:
53+
parser = argparse.ArgumentParser(description="Check NeuroCade Python runtime dependencies.")
54+
parser.add_argument("--pyproject", required=True, type=Path)
55+
parser.add_argument("--sentinel", required=True, type=Path)
56+
args = parser.parse_args()
57+
58+
key = dependency_key(args.pyproject)
59+
if sentinel_current(args.sentinel, key):
60+
print("Python runtime dependencies already verified.")
61+
return 0
62+
63+
check_imports()
64+
write_sentinel(args.sentinel, key)
65+
print("Python runtime dependencies already installed.")
66+
return 0
67+
68+
69+
if __name__ == "__main__":
70+
raise SystemExit(main())

scripts/install.sh

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Options:
3636
--prerelease Clone the latest prerelease tag for fresh one-line installs.
3737
--dev Clone the repository default branch for fresh one-line installs.
3838
--mode local|internal|demo Deployment profile. If omitted, prompts interactively.
39-
--llm-provider NAME openai-compatible, anthropic, google, or ollama.
39+
--llm-provider NAME openai-compatible, anthropic, google, ollama, or no-llm.
4040
--no-start Write configuration but do not start the Apptainer stack.
4141
--no-prereqs Do not install missing prerequisites such as uv, Node.js, Lima, or Apptainer.
4242
--desktop Prepare the local Electron desktop launcher.
@@ -604,7 +604,7 @@ prompt_desktop_shortcut() {
604604
echo "Skipping Desktop shortcut prompt under WSL."
605605
return
606606
fi
607-
if confirm "Create a Desktop shortcut icon? Pros: one-click launch from the Desktop, uses the NeuroCade icon, and starts the local backend automatically. Cons: adds an icon/link to your Desktop folder and depends on the Electron launcher files staying in this install location." "y"; then
607+
if confirm "Create a Desktop shortcut icon?" "y"; then
608608
create_desktop_shortcut "$root"
609609
fi
610610
}
@@ -787,7 +787,8 @@ main() {
787787
"openai-compatible|Custom OpenAI-compatible API base URL." \
788788
"anthropic|Anthropic Claude API." \
789789
"google|Google Gemini API." \
790-
"ollama|Local Ollama server."
790+
"ollama|Local Ollama server." \
791+
"no-llm|Skip LLM setup; assistant features will be disabled."
791792
)"
792793
fi
793794
fi

scripts/install/common.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,9 @@ normalize_provider() {
195195
ollama|Ollama)
196196
printf 'ollama\n'
197197
;;
198+
no-llm|none|"No LLM"|"No LLM setup")
199+
printf 'no-llm\n'
200+
;;
198201
*)
199202
echo "Invalid LLM provider: $1" >&2
200203
exit 2

0 commit comments

Comments
 (0)