Skip to content

Commit b460629

Browse files
fix: support keyless Ollama and headless startup
1 parent dd103e4 commit b460629

4 files changed

Lines changed: 123 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,13 @@ python main.py
255255

256256
If the primary provider fails, Kyrozen automatically falls back through a chain (e.g., DeepSeek → OpenAI → Claude). Rate-limit errors (HTTP 429) trigger exponential backoff with jitter.
257257

258+
Ollama is keyless: set `KYROZEN_PROVIDER=ollama` and, if needed, point
259+
`KYROZEN_BASE_URL` at the local OpenAI-compatible endpoint. The Web server
260+
always initialises in headless mode. If a remote provider has no key, it
261+
starts in a documented degraded state without reading stdin; configure the
262+
provider before sending chat requests. The interactive CLI still prompts for
263+
providers that require credentials.
264+
258265
---
259266

260267
## 🛠 Tools Reference

‎main.py‎

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,31 +1007,52 @@ def _save_config_key(key: str) -> None:
10071007
console.print(f"[{_WARNING}]Warning: could not save API key to config file.[/{_WARNING}]")
10081008

10091009

1010-
def _prompt_and_init_deepseek() -> None:
1011-
"""Detect provider, prompt for API key if needed, initialise the LLM client."""
1010+
def _prompt_and_init_deepseek(*, interactive: bool = True) -> bool:
1011+
"""Detect the provider and initialise the LLM client.
1012+
1013+
Ollama is a local, keyless provider. Headless surfaces pass
1014+
``interactive=False`` so a missing remote credential produces a usable
1015+
degraded process instead of reading stdin or raising during ASGI startup.
1016+
Interactive CLI setup retains the credential prompt for providers that
1017+
require one. The return value reports whether a provider was initialised.
1018+
"""
10121019
global _provider_config, llm_provider, DEEPSEEK_MODEL, DEEPSEEK_MODEL_SIMPLE, DEEPSEEK_MODEL_COMPLEX, MODEL_NAME
10131020

10141021
_provider_config = detect_provider()
1022+
llm_provider = None
10151023

1016-
# If no API key is stored, prompt the user
1024+
# Ollama's local OpenAI-compatible endpoint deliberately has no credential.
10171025
if not _provider_config.api_key:
1018-
console.print(f"\n{_provider_config.provider.title()} API key not set.")
1019-
env_var = PROVIDER_ENV_VARS.get(_provider_config.provider, "")
1020-
hint = f" (set {env_var})" if env_var else ""
1021-
try:
1022-
key = console.input(
1023-
f"[bold yellow]Enter your {_provider_config.provider.title()} API key{hint}: [/bold yellow]"
1024-
).strip()
1025-
except (EOFError, KeyboardInterrupt):
1026-
console.print("\nCancelled.")
1027-
llm_provider = None
1028-
sys.exit(0)
1029-
if not key:
1030-
console.print("No API key entered – use /quit to exit.")
1031-
llm_provider = None
1032-
sys.exit(0)
1033-
_provider_config.api_key = key
1034-
save_provider_config_encrypted(_provider_config)
1026+
if _provider_config.provider == "ollama":
1027+
console.print("Ollama selected: no API key is required; using the configured local endpoint.")
1028+
elif not interactive:
1029+
env_var = PROVIDER_ENV_VARS.get(_provider_config.provider, "")
1030+
hint = f" Set {env_var} or KYROZEN_API_KEY before sending chat requests." if env_var else ""
1031+
console.print(
1032+
f"[yellow]Degraded startup: {_provider_config.provider.title()} API key is not configured."
1033+
f" Headless mode will not prompt for credentials.{hint}[/yellow]"
1034+
)
1035+
DEEPSEEK_MODEL_SIMPLE = _provider_config.model_simple
1036+
DEEPSEEK_MODEL_COMPLEX = _provider_config.model_complex
1037+
DEEPSEEK_MODEL = DEEPSEEK_MODEL_SIMPLE
1038+
MODEL_NAME = f"{_provider_config.provider} ({DEEPSEEK_MODEL_SIMPLE})"
1039+
return False
1040+
else:
1041+
console.print(f"\n{_provider_config.provider.title()} API key not set.")
1042+
env_var = PROVIDER_ENV_VARS.get(_provider_config.provider, "")
1043+
hint = f" (set {env_var})" if env_var else ""
1044+
try:
1045+
key = console.input(
1046+
f"[bold yellow]Enter your {_provider_config.provider.title()} API key{hint}: [/bold yellow]"
1047+
).strip()
1048+
except (EOFError, KeyboardInterrupt):
1049+
console.print("\nCancelled.")
1050+
sys.exit(0)
1051+
if not key:
1052+
console.print("No API key entered – use /quit to exit.")
1053+
sys.exit(0)
1054+
_provider_config.api_key = key
1055+
save_provider_config_encrypted(_provider_config)
10351056

10361057
# Set provider-specific env var for subprocesses / SDK auto-detection
10371058
env_var = PROVIDER_ENV_VARS.get(_provider_config.provider, "")
@@ -1052,6 +1073,7 @@ def _prompt_and_init_deepseek() -> None:
10521073
if issues:
10531074
for issue in issues:
10541075
console.print(f"[{_WARNING}]Config: {issue}[/{_WARNING}]")
1076+
return True
10551077

10561078

10571079
DEEPSEEK_MODEL: str = DEEPSEEK_MODEL_SIMPLE

‎server.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1200,7 +1200,7 @@ async def startup():
12001200
"""Initialize the agent on server start."""
12011201
print("[Server] Initializing OpenKyrozen...")
12021202
_agent._set_workspace_root(os.getcwd())
1203-
_agent._prompt_and_init_deepseek()
1203+
_agent._prompt_and_init_deepseek(interactive=False)
12041204
if _agent.llm_provider is None:
12051205
print("[Server] WARNING: No LLM provider configured. Set API key env vars.")
12061206
else:

‎tests/test_server.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,86 @@
11
import os
2+
import asyncio
3+
import threading
24
import unittest
35
from unittest.mock import patch
46

57
import server
68
from fastapi import HTTPException
79
from fastapi.testclient import TestClient
10+
from providers import ProviderConfig
811

912

1013
class ServerBoundaryTests(unittest.TestCase):
14+
def test_ollama_initialization_never_prompts_for_a_key(self):
15+
original_config = server._agent._provider_config
16+
original_provider = server._agent.llm_provider
17+
config = ProviderConfig(provider="ollama", base_url="http://127.0.0.1:11434/v1")
18+
try:
19+
with patch.object(server._agent, "detect_provider", return_value=config):
20+
with patch.object(server._agent, "get_fallback_provider", return_value=object()) as create:
21+
with patch.object(server._agent.console, "input", side_effect=AssertionError("prompted")):
22+
self.assertTrue(server._agent._prompt_and_init_deepseek(interactive=False))
23+
create.assert_called_once_with(config)
24+
self.assertEqual(server._agent._provider_config.provider, "ollama")
25+
finally:
26+
server._agent._provider_config = original_config
27+
server._agent.llm_provider = original_provider
28+
29+
def test_headless_missing_remote_key_enters_deterministic_degraded_state(self):
30+
original_config = server._agent._provider_config
31+
original_provider = server._agent.llm_provider
32+
config = ProviderConfig(provider="deepseek", api_key="")
33+
try:
34+
with patch.object(server._agent, "detect_provider", return_value=config):
35+
with patch.object(server._agent.console, "input", side_effect=AssertionError("prompted")):
36+
self.assertFalse(server._agent._prompt_and_init_deepseek(interactive=False))
37+
self.assertIsNone(server._agent.llm_provider)
38+
self.assertEqual(server._agent._provider_config.provider, "deepseek")
39+
finally:
40+
server._agent._provider_config = original_config
41+
server._agent.llm_provider = original_provider
42+
43+
def test_interactive_missing_remote_key_still_accepts_a_key(self):
44+
original_config = server._agent._provider_config
45+
original_provider = server._agent.llm_provider
46+
config = ProviderConfig(provider="deepseek", api_key="")
47+
try:
48+
with patch.object(server._agent, "detect_provider", return_value=config):
49+
with patch.object(server._agent.console, "input", return_value="sk-interactive"):
50+
with patch.object(server._agent, "save_provider_config_encrypted"):
51+
with patch.object(server._agent, "get_fallback_provider", return_value=object()):
52+
self.assertTrue(server._agent._prompt_and_init_deepseek(interactive=True))
53+
self.assertEqual(server._agent._provider_config.api_key, "sk-interactive")
54+
finally:
55+
server._agent._provider_config = original_config
56+
server._agent.llm_provider = original_provider
57+
58+
def test_server_startup_initializes_headlessly(self):
59+
with patch.object(server._agent, "_prompt_and_init_deepseek") as init_provider:
60+
with patch.object(server._agent, "_set_workspace_root"):
61+
with patch.object(server._agent, "_load_project_files_into_memory"):
62+
with patch.object(server, "_load_plugins"):
63+
with patch.object(server, "_trigger_hook"):
64+
with patch.object(server._task_worker, "recover", return_value=[]):
65+
with patch.object(server._scheduler, "list_jobs", return_value=[
66+
{"payload": {"type": "task_worker"}},
67+
]):
68+
with patch.object(server._scheduler, "start"):
69+
errors = []
70+
71+
def run_startup():
72+
try:
73+
asyncio.run(server.startup())
74+
except Exception as exc: # pragma: no cover - assertion below reports it
75+
errors.append(exc)
76+
77+
thread = threading.Thread(target=run_startup)
78+
thread.start()
79+
thread.join(timeout=5)
80+
self.assertFalse(thread.is_alive())
81+
self.assertEqual(errors, [])
82+
init_provider.assert_called_once_with(interactive=False)
83+
1184
def test_server_profiles_keep_workspace_tools_and_gate_reset(self):
1285
workspace = server._allowed_server_tools("mcp")
1386
self.assertIn("run_cmd", workspace)

0 commit comments

Comments
 (0)