k-cli-for-devs 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. k_cli/__init__.py +77 -0
  2. k_cli/agents/__init__.py +0 -0
  3. k_cli/agents/adversarial_swarm.py +338 -0
  4. k_cli/agents/agent_core.py +255 -0
  5. k_cli/agents/background_daemon.py +141 -0
  6. k_cli/agents/orchestrator.py +376 -0
  7. k_cli/agents/persona.py +649 -0
  8. k_cli/agents/scaffold_engine.py +121 -0
  9. k_cli/agents/strands_agent.py +832 -0
  10. k_cli/agents/subagents.py +1496 -0
  11. k_cli/cli.py +3297 -0
  12. k_cli/core/__init__.py +0 -0
  13. k_cli/core/airgap.py +95 -0
  14. k_cli/core/credentials.py +548 -0
  15. k_cli/core/intent_sensor.py +177 -0
  16. k_cli/core/llm_driver.py +1028 -0
  17. k_cli/core/model_manager.py +1109 -0
  18. k_cli/core/models_hub.py +913 -0
  19. k_cli/core/prompting.py +41 -0
  20. k_cli/core/sdk.py +322 -0
  21. k_cli/core/session.py +826 -0
  22. k_cli/core/smart_router.py +230 -0
  23. k_cli/core/storage_manager.py +176 -0
  24. k_cli/core/viewport_engine.py +117 -0
  25. k_cli/demo/demo_runner.py +579 -0
  26. k_cli/git/__init__.py +0 -0
  27. k_cli/git/ai_bisect.py +208 -0
  28. k_cli/git/conflict_resolver.py +1039 -0
  29. k_cli/git/git_guard.py +417 -0
  30. k_cli/git/patcher.py +1175 -0
  31. k_cli/git/repo_map.py +1780 -0
  32. k_cli/git/smart_git.py +928 -0
  33. k_cli/git/verifier.py +969 -0
  34. k_cli/github/__init__.py +0 -0
  35. k_cli/github/dedup_engine.py +787 -0
  36. k_cli/github/github_client.py +1702 -0
  37. k_cli/github/github_engine.py +641 -0
  38. k_cli/github/local_hub.py +209 -0
  39. k_cli/github/pr_watcher.py +129 -0
  40. k_cli/github/trending.py +205 -0
  41. k_cli/tools/__init__.py +0 -0
  42. k_cli/tools/audit.py +79 -0
  43. k_cli/tools/chaos_immunity.py +377 -0
  44. k_cli/tools/codebase_qa.py +106 -0
  45. k_cli/tools/command_runner.py +256 -0
  46. k_cli/tools/diagram_generator.py +547 -0
  47. k_cli/tools/doc_retriever.py +1332 -0
  48. k_cli/tools/feature.py +105 -0
  49. k_cli/tools/ghost_daemon.py +122 -0
  50. k_cli/tools/incident_triage.py +1365 -0
  51. k_cli/tools/mcp_client.py +1846 -0
  52. k_cli/tools/repo_gardener.py +142 -0
  53. k_cli/tools/rules.py +109 -0
  54. k_cli/tools/security.py +52 -0
  55. k_cli/tools/security_healer.py +999 -0
  56. k_cli/tools/synapse_graph.py +155 -0
  57. k_cli/tui/__init__.py +0 -0
  58. k_cli/tui/diff_viewer.py +223 -0
  59. k_cli/tui/tui.py +1145 -0
  60. k_cli/tui/tui_animations.py +648 -0
  61. k_cli/tui/tui_app.py +2788 -0
  62. k_cli/ui/__init__.py +10 -0
  63. k_cli/ui/simple_repl.py +315 -0
  64. k_cli/web/__init__.py +7 -0
  65. k_cli/web/server.py +624 -0
  66. k_cli/web/static/app.js +830 -0
  67. k_cli/web/static/index.html +495 -0
  68. k_cli/web/static/monitor.html +189 -0
  69. k_cli/web/static/style.css +838 -0
  70. k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
  71. k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
  72. k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
  73. k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
  74. k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
  75. k_cli_for_devs-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1028 @@
1
+ """
2
+ llm_driver.py - Universal Plug-and-Play LLM Inference Driver for K-CLI
3
+
4
+ Supported Inference Backends:
5
+ 1. Local Bankai-7B / 14B GGUF via Ollama (http://localhost:11434) and llama.cpp HTTP server (http://localhost:8080)
6
+ 2. Native in-process llama-cpp-python GGUF inference
7
+ 3. Google Gemini (Gemini 3.7 Flash, Gemini 1.5 Pro, thinking budgets)
8
+ 4. Anthropic Claude (Claude 3.7 Sonnet, Claude 3.5 Haiku, streaming)
9
+ 5. OpenAI / DeepSeek / OpenRouter compatible REST API endpoints
10
+ 6. Multi-tier auto-fallback routing hierarchy with full streaming token preservation
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ import urllib.error
18
+ import urllib.request
19
+ from enum import Enum
20
+ from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union
21
+
22
+ try:
23
+ from k_cli.git.verifier import CodeExtractor, VerificationResult, Verifier
24
+ except ModuleNotFoundError:
25
+ try:
26
+ from verifier import CodeExtractor, VerificationResult, Verifier
27
+ except ModuleNotFoundError:
28
+ pass
29
+
30
+ try:
31
+ from k_cli.core.model_manager import ModelManager
32
+ except (ModuleNotFoundError, ImportError):
33
+ try:
34
+ from model_manager import ModelManager
35
+ except (ModuleNotFoundError, ImportError):
36
+ ModelManager = None
37
+
38
+
39
+ class ProviderType(str, Enum):
40
+ AUTO = "auto"
41
+ OLLAMA = "ollama"
42
+ LLAMACPP = "llamacpp"
43
+ NATIVE = "native"
44
+ GEMINI = "gemini"
45
+ ANTHROPIC = "anthropic"
46
+ OPENAI = "openai"
47
+ OPENAI_COMPATIBLE = "openai-compatible"
48
+ DEEPSEEK = "deepseek"
49
+ OPENROUTER = "openrouter"
50
+ MOCK = "mock"
51
+
52
+
53
+ class _CallbackException(Exception):
54
+ """Wrapper to safely bubble user exceptions raised inside stream_callback."""
55
+
56
+ def __init__(self, original_exception: Exception):
57
+ self.original_exception = original_exception
58
+ super().__init__(str(original_exception))
59
+
60
+
61
+ def _invoke_callback(cb: Optional[Callable[[str], None]], token: str) -> None:
62
+ """Invokes stream callback safely, wrapping user exceptions to prevent fallback swallowing."""
63
+ if cb is not None and token:
64
+ try:
65
+ cb(token)
66
+ except Exception as exc:
67
+ raise _CallbackException(exc) from exc
68
+
69
+
70
+ _CUSTOM_ADAPTERS: Dict[str, Callable[..., str]] = {}
71
+
72
+
73
+ def register_adapter(name: str, adapter_fn: Callable[..., str]) -> None:
74
+ """Register a custom adapter for external or proprietary model providers (e.g. GenBlaze SDK)."""
75
+ _CUSTOM_ADAPTERS[name.lower().strip()] = adapter_fn
76
+
77
+
78
+ class LLMDriver:
79
+ """
80
+ Universal LLM Driver supporting multi-provider backends:
81
+ - Ollama (Bankai-7B/14B, Qwen2.5-Coder, DeepSeek, etc.)
82
+ - llama.cpp HTTP server
83
+ - Native llama-cpp-python GGUF
84
+ - Google Gemini (Gemini 3.7 Flash, Gemini 1.5 Pro with thinking budgets)
85
+ - Anthropic Claude (Claude 3.7 Sonnet, Claude 3.5 Haiku with streaming)
86
+ - OpenAI / DeepSeek / OpenRouter compatible REST APIs
87
+ - Deterministic Mock Engine
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ model_name: str = "qwen2.5-coder:1.5b",
93
+ ollama_url: str = "http://localhost:11434",
94
+ timeout: float = 60.0,
95
+ mock_mode: bool = False,
96
+ provider: Optional[Union[ProviderType, str]] = None,
97
+ llamacpp_url: str = "http://localhost:8080",
98
+ gemini_api_key: Optional[str] = None,
99
+ gemini_base_url: Optional[str] = None,
100
+ anthropic_api_key: Optional[str] = None,
101
+ anthropic_base_url: Optional[str] = None,
102
+ openai_api_key: Optional[str] = None,
103
+ openai_base_url: Optional[str] = None,
104
+ deepseek_api_key: Optional[str] = None,
105
+ deepseek_base_url: Optional[str] = None,
106
+ openrouter_api_key: Optional[str] = None,
107
+ openrouter_base_url: Optional[str] = None,
108
+ thinking_budget: Optional[int] = None,
109
+ **kwargs: Any,
110
+ ):
111
+ try:
112
+ from k_cli.core.credentials import CredentialsManager
113
+ CredentialsManager.load_all_credentials()
114
+ except Exception:
115
+ pass
116
+
117
+ # Model Name: check env override
118
+ self.model_name = os.getenv("KCLI_MODEL", os.getenv("LLM_MODEL", model_name))
119
+
120
+ # Provider: check env override
121
+ raw_provider = provider or os.getenv("KCLI_PROVIDER", os.getenv("LLM_PROVIDER", "auto"))
122
+ self.provider = raw_provider.value if isinstance(raw_provider, ProviderType) else str(raw_provider).lower()
123
+ self.provider = {
124
+ "openai_compatible": "openai-compatible",
125
+ "compatible": "openai-compatible",
126
+ "openai-compatible": "openai-compatible",
127
+ }.get(self.provider, self.provider)
128
+
129
+ # Ollama config
130
+ raw_ollama_url = os.getenv("OLLAMA_HOST", os.getenv("OLLAMA_BASE_URL", ollama_url)).rstrip("/")
131
+ if not raw_ollama_url.startswith("http"):
132
+ raw_ollama_url = f"http://{raw_ollama_url}"
133
+ self.ollama_url = raw_ollama_url
134
+
135
+ # llama.cpp server config
136
+ raw_llamacpp_url = os.getenv(
137
+ "LLAMACPP_HOST",
138
+ os.getenv("LLAMACPP_BASE_URL", os.getenv("LLAMA_CPP_URL", llamacpp_url)),
139
+ ).rstrip("/")
140
+ if not raw_llamacpp_url.startswith("http"):
141
+ raw_llamacpp_url = f"http://{raw_llamacpp_url}"
142
+ self.llamacpp_url = raw_llamacpp_url
143
+
144
+ # Cloud API Keys and Base URLs
145
+ self.gemini_api_key = gemini_api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
146
+ self.gemini_base_url = (
147
+ gemini_base_url or os.getenv("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com")
148
+ ).rstrip("/")
149
+
150
+ self.anthropic_api_key = anthropic_api_key or os.getenv("ANTHROPIC_API_KEY")
151
+ self.anthropic_base_url = (
152
+ anthropic_base_url or os.getenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
153
+ ).rstrip("/")
154
+
155
+ self.openai_api_key = (
156
+ openai_api_key or os.getenv("KCLI_API_KEY") or os.getenv("OPENAI_API_KEY") or kwargs.get("api_key")
157
+ )
158
+ self.openai_base_url = (
159
+ openai_base_url
160
+ or os.getenv("KCLI_BASE_URL")
161
+ or os.getenv("OPENAI_BASE_URL", kwargs.get("base_url", "https://api.openai.com/v1"))
162
+ ).rstrip("/")
163
+
164
+ self.deepseek_api_key = deepseek_api_key or os.getenv("DEEPSEEK_API_KEY")
165
+ self.deepseek_base_url = (
166
+ deepseek_base_url or os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
167
+ ).rstrip("/")
168
+
169
+ self.openrouter_api_key = openrouter_api_key or os.getenv("OPENROUTER_API_KEY")
170
+ self.openrouter_base_url = (
171
+ openrouter_base_url or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
172
+ ).rstrip("/")
173
+
174
+ # Thinking Budget (Gemini / Claude)
175
+ tb_env = os.getenv("GEMINI_THINKING_BUDGET") or os.getenv("ANTHROPIC_THINKING_BUDGET") or os.getenv("THINKING_BUDGET")
176
+ if thinking_budget is not None:
177
+ self.thinking_budget = thinking_budget
178
+ elif tb_env is not None:
179
+ try:
180
+ self.thinking_budget = int(tb_env)
181
+ except ValueError:
182
+ self.thinking_budget = None
183
+ else:
184
+ self.thinking_budget = None
185
+
186
+ self.timeout = float(os.getenv("KCLI_LLM_TIMEOUT", timeout))
187
+ self.mock_mode = mock_mode
188
+ self._native_llm = None
189
+ self._last_used_provider: Optional[str] = None
190
+
191
+ def is_ollama_available(self) -> bool:
192
+ """Checks if Ollama server is running locally and target model is present."""
193
+ if self.mock_mode:
194
+ return True
195
+ try:
196
+ req = urllib.request.Request(f"{self.ollama_url}/api/tags", method="GET")
197
+ with urllib.request.urlopen(req, timeout=2.0) as resp:
198
+ if resp.status != 200:
199
+ return False
200
+ data = json.loads(resp.read().decode("utf-8"))
201
+ models = [m.get("name", "") for m in data.get("models", []) if isinstance(m, dict) and m.get("name")]
202
+ target = self.model_name.lower().strip()
203
+ for m in models:
204
+ if not m:
205
+ continue
206
+ m_low = m.lower().strip()
207
+ if target == m_low or m_low.startswith(target) or (m_low.split(":")[0] and target.startswith(m_low.split(":")[0])):
208
+ return True
209
+ # Check tag normalization (e.g. bankai-7b vs bankai:7b)
210
+ if target.replace("-", ":") == m_low.replace("-", ":"):
211
+ return True
212
+ return False
213
+ except Exception:
214
+ return False
215
+
216
+ def is_llamacpp_available(self) -> bool:
217
+ """Checks if llama.cpp HTTP server is running."""
218
+ if self.mock_mode:
219
+ return True
220
+ for endpoint in ["/health", "/v1/models", "/props"]:
221
+ try:
222
+ req = urllib.request.Request(f"{self.llamacpp_url}{endpoint}", method="GET")
223
+ with urllib.request.urlopen(req, timeout=2.0) as resp:
224
+ if resp.status in (200, 204):
225
+ return True
226
+ except Exception:
227
+ continue
228
+ return False
229
+
230
+ def is_gemini_available(self) -> bool:
231
+ """Checks if Gemini API key is configured."""
232
+ return bool(self.gemini_api_key)
233
+
234
+ def is_anthropic_available(self) -> bool:
235
+ """Checks if Anthropic API key is configured."""
236
+ return bool(self.anthropic_api_key)
237
+
238
+ def is_openai_available(self) -> bool:
239
+ """Checks if OpenAI API key is configured."""
240
+ return bool(self.openai_api_key)
241
+
242
+ def is_deepseek_available(self) -> bool:
243
+ """Checks if DeepSeek API key is configured."""
244
+ return bool(self.deepseek_api_key)
245
+
246
+ def is_openrouter_available(self) -> bool:
247
+ """Checks if OpenRouter API key is configured."""
248
+ return bool(self.openrouter_api_key)
249
+
250
+ def detect_primary_provider(self) -> str:
251
+ """Determines the primary provider to attempt based on configuration, model name, and keys."""
252
+ if self.mock_mode or self.provider == "mock":
253
+ return "mock"
254
+ if self.provider and self.provider != "auto":
255
+ return self.provider
256
+
257
+ model_lower = self.model_name.lower()
258
+
259
+ # Cloud model prefixes
260
+ if model_lower.startswith("gemini"):
261
+ return "gemini" if self.is_gemini_available() else "auto"
262
+ if model_lower.startswith("claude"):
263
+ return "anthropic" if self.is_anthropic_available() else "auto"
264
+ if model_lower.startswith("deepseek") and self.is_deepseek_available():
265
+ return "deepseek"
266
+ if (
267
+ model_lower.startswith("gpt-") or model_lower.startswith("o1") or model_lower.startswith("o3")
268
+ ) and self.is_openai_available():
269
+ return "openai"
270
+ if (model_lower.startswith("openrouter/") or "/" in model_lower) and self.is_openrouter_available():
271
+ return "openrouter"
272
+
273
+ # Local models (bankai, qwen, llama, mistral, deepseek-coder, etc.)
274
+ if self.is_ollama_available():
275
+ return "ollama"
276
+ if self.is_llamacpp_available():
277
+ return "llamacpp"
278
+ if self.get_native_llama() is not None:
279
+ return "native"
280
+
281
+ # Fallback to available cloud provider if API keys exist
282
+ if self.is_gemini_available():
283
+ return "gemini"
284
+ if self.is_openai_available():
285
+ return "openai"
286
+ if self.is_anthropic_available():
287
+ return "anthropic"
288
+ if self.is_deepseek_available():
289
+ return "deepseek"
290
+ if self.is_openrouter_available():
291
+ return "openrouter"
292
+
293
+ return "ollama"
294
+
295
+ def get_model_manager(self) -> Optional[Any]:
296
+ """Returns initialized ModelManager instance if available."""
297
+ if ModelManager is not None:
298
+ return ModelManager(ollama_url=self.ollama_url, mock_mode=self.mock_mode)
299
+ return None
300
+
301
+ def ensure_model_ready(self, auto_pull: bool = False) -> Dict[str, Any]:
302
+ """
303
+ Validates whether target model is ready in Ollama or local GGUF cache.
304
+ If auto_pull is True and model is missing, attempts automated pull.
305
+ """
306
+ mm = self.get_model_manager()
307
+ if mm is None:
308
+ return {"ready": self.is_ollama_available() or self.mock_mode, "ollama": self.is_ollama_available()}
309
+
310
+ has_ollama = mm.has_ollama_model(self.model_name)
311
+ local_gguf = mm.find_local_gguf(self.model_name)
312
+
313
+ if has_ollama or (local_gguf and local_gguf.exists()):
314
+ return {
315
+ "ready": True,
316
+ "ollama": has_ollama,
317
+ "local_gguf": str(local_gguf) if local_gguf else None,
318
+ "pulled": False,
319
+ }
320
+
321
+ if auto_pull:
322
+ res = mm.pull_model(model_identifier=self.model_name)
323
+ return {
324
+ "ready": res.success,
325
+ "ollama": res.ollama_created,
326
+ "local_gguf": str(res.gguf_path) if res.gguf_path else None,
327
+ "pulled": True,
328
+ "pull_result": res.to_dict(),
329
+ }
330
+
331
+ return {
332
+ "ready": False,
333
+ "ollama": False,
334
+ "local_gguf": None,
335
+ "pulled": False,
336
+ }
337
+
338
+ def get_native_llama(self):
339
+ """Lazy loads llama-cpp-python GGUF model if llama-cpp-python is installed."""
340
+ if self._native_llm is not None:
341
+ return self._native_llm
342
+
343
+ try:
344
+ from llama_cpp import Llama
345
+
346
+ model_path = None
347
+ if ModelManager is not None:
348
+ mm = ModelManager(mock_mode=self.mock_mode)
349
+ local_path = mm.find_local_gguf(self.model_name)
350
+ if local_path and local_path.exists():
351
+ model_path = str(local_path)
352
+
353
+ if not model_path:
354
+ return None
355
+
356
+ self._native_llm = Llama(
357
+ model_path=str(model_path),
358
+ n_ctx=2048,
359
+ n_threads=4,
360
+ verbose=False,
361
+ )
362
+ return self._native_llm
363
+ except Exception:
364
+ return None
365
+
366
+ def generate(
367
+ self,
368
+ prompt: str,
369
+ system_prompt: Optional[str] = None,
370
+ temperature: float = 0.2,
371
+ stream_callback: Optional[Callable[[str], None]] = None,
372
+ **kwargs: Any,
373
+ ) -> str:
374
+ """
375
+ Universal generation entry point.
376
+ Dispatches to active provider with graceful auto-fallback hierarchy:
377
+ Local (Ollama -> llama.cpp -> Native GGUF) -> Cloud (Gemini -> Anthropic -> OpenAI / DeepSeek / OpenRouter) -> Mock.
378
+ Supports full streaming token callbacks and robust error handling.
379
+ """
380
+ # Inject custom developer instructions & workspace rules if present
381
+ try:
382
+ from k_cli.tools.rules import load_project_rules
383
+ rules_ctx = load_project_rules(".")
384
+ if rules_ctx:
385
+ if system_prompt:
386
+ system_prompt = f"{rules_ctx}\n\n{system_prompt}"
387
+ else:
388
+ system_prompt = rules_ctx
389
+ except Exception:
390
+ pass
391
+
392
+ if self.mock_mode:
393
+ self._last_used_provider = "mock"
394
+ return self._mock_generate(prompt, system_prompt, stream_callback=stream_callback)
395
+
396
+ primary = self.detect_primary_provider()
397
+
398
+ def make_runner(prov: str) -> Callable[[], str]:
399
+ if prov in _CUSTOM_ADAPTERS:
400
+ adapter_fn = _CUSTOM_ADAPTERS[prov]
401
+ return lambda: adapter_fn(prompt, system_prompt, temperature, stream_callback)
402
+ elif prov == "ollama":
403
+ return lambda: self._generate_ollama(prompt, system_prompt, temperature, stream_callback)
404
+ elif prov == "llamacpp":
405
+ return lambda: self._generate_llamacpp(prompt, system_prompt, temperature, stream_callback)
406
+ elif prov == "native":
407
+ native_llm = self.get_native_llama()
408
+ if native_llm is None:
409
+ raise RuntimeError("Native llama-cpp-python model not available")
410
+ return lambda: self._generate_native(native_llm, prompt, system_prompt, temperature, stream_callback)
411
+ elif prov == "gemini":
412
+ return lambda: self._generate_gemini(prompt, system_prompt, temperature, stream_callback)
413
+ elif prov == "anthropic":
414
+ return lambda: self._generate_anthropic(prompt, system_prompt, temperature, stream_callback)
415
+ elif prov in ("openai", "openai-compatible"):
416
+ return lambda: self._generate_openai(prompt, system_prompt, temperature, stream_callback)
417
+ elif prov == "deepseek":
418
+ return lambda: self._generate_deepseek(prompt, system_prompt, temperature, stream_callback)
419
+ elif prov == "openrouter":
420
+ return lambda: self._generate_openrouter(prompt, system_prompt, temperature, stream_callback)
421
+ else:
422
+ return lambda: self._mock_generate(prompt, system_prompt, stream_callback=stream_callback)
423
+
424
+ # Try primary provider directly first to avoid probing overhead
425
+ primary_runner = make_runner(primary)
426
+ try:
427
+ self._last_used_provider = primary
428
+ res = primary_runner()
429
+ if res is not None:
430
+ return res
431
+ except _CallbackException as cb_exc:
432
+ raise cb_exc.original_exception
433
+ except Exception:
434
+ pass
435
+
436
+ # Primary failed: build fallback candidate list
437
+ candidates: List[Tuple[str, Callable[[], str]]] = []
438
+ fallback_order = ["gemini", "anthropic", "openai", "deepseek", "openrouter", "ollama", "llamacpp", "native"]
439
+ for fb in fallback_order:
440
+ if fb != primary:
441
+ if fb == "gemini" and self.is_gemini_available():
442
+ candidates.append((fb, make_runner(fb)))
443
+ elif fb == "anthropic" and self.is_anthropic_available():
444
+ candidates.append((fb, make_runner(fb)))
445
+ elif fb == "openai" and self.is_openai_available():
446
+ candidates.append((fb, make_runner(fb)))
447
+ elif fb == "deepseek" and self.is_deepseek_available():
448
+ candidates.append((fb, make_runner(fb)))
449
+ elif fb == "openrouter" and self.is_openrouter_available():
450
+ candidates.append((fb, make_runner(fb)))
451
+ elif fb == "ollama" and self.is_ollama_available():
452
+ candidates.append((fb, make_runner(fb)))
453
+ elif fb == "llamacpp" and self.is_llamacpp_available():
454
+ candidates.append((fb, make_runner(fb)))
455
+ elif fb == "native" and self.get_native_llama() is not None:
456
+ candidates.append((fb, make_runner(fb)))
457
+
458
+ # Always add deterministic mock fallback
459
+ candidates.append(("mock", lambda: self._mock_generate(prompt, system_prompt, stream_callback=stream_callback)))
460
+
461
+ # Execute candidate hierarchy with auto-fallback
462
+ for prov_name, runner in candidates:
463
+ try:
464
+ self._last_used_provider = prov_name
465
+ res = runner()
466
+ if res is not None:
467
+ return res
468
+ except _CallbackException as cb_exc:
469
+ raise cb_exc.original_exception
470
+ except Exception:
471
+ continue
472
+
473
+ self._last_used_provider = "mock"
474
+ return self._mock_generate(prompt, system_prompt, stream_callback=stream_callback)
475
+
476
+ def _generate_ollama(
477
+ self,
478
+ prompt: str,
479
+ system_prompt: Optional[str] = None,
480
+ temperature: float = 0.2,
481
+ stream_callback: Optional[Callable[[str], None]] = None,
482
+ ) -> str:
483
+ """Sends request to local Ollama server with streaming token support."""
484
+ endpoint = f"{self.ollama_url}/api/generate"
485
+ payload = {
486
+ "model": self.model_name,
487
+ "prompt": prompt,
488
+ "system": system_prompt or "",
489
+ "stream": bool(stream_callback),
490
+ "options": {
491
+ "temperature": temperature,
492
+ },
493
+ }
494
+
495
+ data = json.dumps(payload).encode("utf-8")
496
+ req = urllib.request.Request(
497
+ endpoint,
498
+ data=data,
499
+ headers={"Content-Type": "application/json"},
500
+ method="POST",
501
+ )
502
+
503
+ try:
504
+ if stream_callback:
505
+ full_text: List[str] = []
506
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
507
+ for line in resp:
508
+ if line:
509
+ chunk = json.loads(line.decode("utf-8"))
510
+ token = chunk.get("response", "")
511
+ if token:
512
+ full_text.append(token)
513
+ _invoke_callback(stream_callback, token)
514
+ if chunk.get("done", False):
515
+ break
516
+ return "".join(full_text)
517
+ else:
518
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
519
+ res_json = json.loads(resp.read().decode("utf-8"))
520
+ return res_json.get("response", "")
521
+ except _CallbackException:
522
+ raise
523
+ except Exception:
524
+ # Fallback to mock directly if standalone call
525
+ return self._mock_generate(prompt, system_prompt, stream_callback=stream_callback)
526
+
527
+ def _generate_llamacpp(
528
+ self,
529
+ prompt: str,
530
+ system_prompt: Optional[str] = None,
531
+ temperature: float = 0.2,
532
+ stream_callback: Optional[Callable[[str], None]] = None,
533
+ ) -> str:
534
+ """Generates text via llama.cpp HTTP server (/v1/chat/completions or /completion)."""
535
+ messages = []
536
+ if system_prompt:
537
+ messages.append({"role": "system", "content": system_prompt})
538
+ messages.append({"role": "user", "content": prompt})
539
+
540
+ endpoint = f"{self.llamacpp_url}/v1/chat/completions"
541
+ payload = {
542
+ "model": self.model_name,
543
+ "messages": messages,
544
+ "temperature": temperature,
545
+ "stream": bool(stream_callback),
546
+ }
547
+
548
+ data = json.dumps(payload).encode("utf-8")
549
+ req = urllib.request.Request(
550
+ endpoint,
551
+ data=data,
552
+ headers={"Content-Type": "application/json"},
553
+ method="POST",
554
+ )
555
+
556
+ try:
557
+ if stream_callback:
558
+ full_text: List[str] = []
559
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
560
+ for line in resp:
561
+ line_str = line.decode("utf-8").strip()
562
+ if not line_str.startswith("data:"):
563
+ continue
564
+ data_payload = line_str[5:].strip()
565
+ if data_payload == "[DONE]":
566
+ break
567
+ if not data_payload:
568
+ continue
569
+ chunk = json.loads(data_payload)
570
+ choices = chunk.get("choices", [])
571
+ if choices:
572
+ delta = choices[0].get("delta", {})
573
+ token = delta.get("content") or ""
574
+ if token:
575
+ full_text.append(token)
576
+ _invoke_callback(stream_callback, token)
577
+ return "".join(full_text)
578
+ else:
579
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
580
+ res_json = json.loads(resp.read().decode("utf-8"))
581
+ choices = res_json.get("choices", [])
582
+ if choices:
583
+ return choices[0].get("message", {}).get("content", "")
584
+ return ""
585
+ except urllib.error.HTTPError as http_err:
586
+ if http_err.code == 404:
587
+ # Fallback to legacy /completion endpoint
588
+ legacy_endpoint = f"{self.llamacpp_url}/completion"
589
+ legacy_payload = {
590
+ "prompt": f"<|im_start|>system\n{system_prompt or ''}<|im_end|>\n<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n",
591
+ "temperature": temperature,
592
+ "stream": bool(stream_callback),
593
+ }
594
+ legacy_req = urllib.request.Request(
595
+ legacy_endpoint,
596
+ data=json.dumps(legacy_payload).encode("utf-8"),
597
+ headers={"Content-Type": "application/json"},
598
+ method="POST",
599
+ )
600
+ if stream_callback:
601
+ full_text = []
602
+ with urllib.request.urlopen(legacy_req, timeout=self.timeout) as resp:
603
+ for line in resp:
604
+ line_str = line.decode("utf-8").strip()
605
+ if line_str.startswith("data:"):
606
+ line_str = line_str[5:].strip()
607
+ if not line_str:
608
+ continue
609
+ chunk = json.loads(line_str)
610
+ token = chunk.get("content", "")
611
+ if token:
612
+ full_text.append(token)
613
+ _invoke_callback(stream_callback, token)
614
+ if chunk.get("stop", False):
615
+ break
616
+ return "".join(full_text)
617
+ else:
618
+ with urllib.request.urlopen(legacy_req, timeout=self.timeout) as resp:
619
+ res_json = json.loads(resp.read().decode("utf-8"))
620
+ return res_json.get("content", "")
621
+ raise
622
+
623
+ def _generate_gemini(
624
+ self,
625
+ prompt: str,
626
+ system_prompt: Optional[str] = None,
627
+ temperature: float = 0.2,
628
+ stream_callback: Optional[Callable[[str], None]] = None,
629
+ ) -> str:
630
+ """Generates text via Google Gemini REST API with native streaming and thinking budgets."""
631
+ api_key = self.gemini_api_key
632
+ if not api_key:
633
+ raise ValueError("GEMINI_API_KEY or GOOGLE_API_KEY is not set")
634
+
635
+ model = self.model_name
636
+ gemini_model_map = {
637
+ "gemini-3.8-flash": "gemini-2.5-flash",
638
+ "gemini-3.7-flash": "gemini-2.5-flash",
639
+ "gemini-3.5-flash": "gemini-2.5-flash",
640
+ "gemini-3-flash": "gemini-2.5-flash",
641
+ "gemini-flash": "gemini-2.5-flash",
642
+ "gemini-pro": "gemini-2.5-pro",
643
+ "gemini-2.5-flash": "gemini-2.5-flash",
644
+ "gemini-2.5-pro": "gemini-2.5-pro",
645
+ "gemini-2.0-flash": "gemini-2.5-flash",
646
+ "gemini-1.5-flash": "gemini-2.5-flash",
647
+ "gemini-1.5-pro": "gemini-2.5-pro",
648
+ }
649
+ model = gemini_model_map.get(model, model)
650
+ if not (model.startswith("gemini-1.5") or model.startswith("gemini-2.0") or model.startswith("gemini-2.5")):
651
+ model = "gemini-2.5-flash"
652
+
653
+ contents = [
654
+ {
655
+ "role": "user",
656
+ "parts": [{"text": prompt}],
657
+ }
658
+ ]
659
+
660
+ generation_config: Dict[str, Any] = {
661
+ "temperature": temperature,
662
+ }
663
+
664
+ # Thinking Budget config: default to 0 for instant responses and credit savings
665
+ thinking_budget = self.thinking_budget
666
+ if thinking_budget is None and "thinking" in model.lower():
667
+ thinking_budget = 2048
668
+ elif thinking_budget is None:
669
+ thinking_budget = 0
670
+
671
+ generation_config["thinkingConfig"] = {
672
+ "thinkingBudget": thinking_budget,
673
+ }
674
+
675
+ payload: Dict[str, Any] = {
676
+ "contents": contents,
677
+ "generationConfig": generation_config,
678
+ }
679
+
680
+ if system_prompt:
681
+ payload["systemInstruction"] = {
682
+ "parts": [{"text": system_prompt}],
683
+ }
684
+
685
+ data = json.dumps(payload).encode("utf-8")
686
+ headers = {"Content-Type": "application/json"}
687
+
688
+ try:
689
+ endpoint = f"{self.gemini_base_url}/v1beta/models/{model}:streamGenerateContent?alt=sse&key={api_key}"
690
+ req = urllib.request.Request(endpoint, data=data, headers=headers, method="POST")
691
+ full_text: List[str] = []
692
+
693
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
694
+ for line in resp:
695
+ line_str = line.decode("utf-8").strip()
696
+ if line_str in ("data: [DONE]", "[DONE]"):
697
+ break
698
+ if not line_str.startswith("data:"):
699
+ continue
700
+ data_payload = line_str[5:].strip()
701
+ if not data_payload:
702
+ continue
703
+ try:
704
+ chunk = json.loads(data_payload)
705
+ candidates = chunk.get("candidates", [])
706
+ if candidates:
707
+ cand = candidates[0]
708
+ parts = cand.get("content", {}).get("parts", [])
709
+ for part in parts:
710
+ token = part.get("text", "")
711
+ if token:
712
+ full_text.append(token)
713
+ if stream_callback:
714
+ _invoke_callback(stream_callback, token)
715
+ if cand.get("finishReason"):
716
+ break
717
+ except Exception:
718
+ pass
719
+ return "".join(full_text)
720
+ except urllib.error.HTTPError as http_err:
721
+ if http_err.code in (400, 404) and model != "gemini-2.5-flash":
722
+ # Fallback to standard reliable gemini-2.5-flash
723
+ fallback_driver = LLMDriver(model_name="gemini-2.5-flash")
724
+ return fallback_driver._generate_gemini(prompt, system_prompt, temperature, stream_callback)
725
+ raise
726
+
727
+ def _generate_anthropic(
728
+ self,
729
+ prompt: str,
730
+ system_prompt: Optional[str] = None,
731
+ temperature: float = 0.2,
732
+ stream_callback: Optional[Callable[[str], None]] = None,
733
+ ) -> str:
734
+ """Generates text via Anthropic Claude REST API with streaming and thinking support."""
735
+ api_key = self.anthropic_api_key
736
+ if not api_key:
737
+ raise ValueError("ANTHROPIC_API_KEY is not set")
738
+
739
+ model = self.model_name
740
+ if not model.startswith("claude"):
741
+ model = "claude-3-7-sonnet-20250219"
742
+
743
+ endpoint = f"{self.anthropic_base_url}/v1/messages"
744
+ headers = {
745
+ "x-api-key": api_key,
746
+ "anthropic-version": "2023-06-01",
747
+ "content-type": "application/json",
748
+ }
749
+
750
+ payload: Dict[str, Any] = {
751
+ "model": model,
752
+ "max_tokens": 8192,
753
+ "messages": [{"role": "user", "content": prompt}],
754
+ "temperature": temperature,
755
+ "stream": bool(stream_callback),
756
+ }
757
+
758
+ if system_prompt:
759
+ payload["system"] = system_prompt
760
+
761
+ thinking_budget = self.thinking_budget
762
+ if thinking_budget is None and "thinking" in model.lower():
763
+ thinking_budget = 2048
764
+
765
+ if thinking_budget is not None and thinking_budget > 0:
766
+ payload["thinking"] = {
767
+ "type": "enabled",
768
+ "budget_tokens": thinking_budget,
769
+ }
770
+ payload["temperature"] = 1.0
771
+
772
+ data = json.dumps(payload).encode("utf-8")
773
+ req = urllib.request.Request(endpoint, data=data, headers=headers, method="POST")
774
+
775
+ if stream_callback:
776
+ full_text: List[str] = []
777
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
778
+ for line in resp:
779
+ line_str = line.decode("utf-8").strip()
780
+ if not line_str.startswith("data:"):
781
+ continue
782
+ data_payload = line_str[5:].strip()
783
+ if not data_payload or data_payload == "[DONE]":
784
+ continue
785
+ event_obj = json.loads(data_payload)
786
+ evt_type = event_obj.get("type", "")
787
+ if evt_type == "content_block_delta":
788
+ delta = event_obj.get("delta", {})
789
+ if delta.get("type") == "text_delta":
790
+ token = delta.get("text", "")
791
+ if token:
792
+ full_text.append(token)
793
+ _invoke_callback(stream_callback, token)
794
+ return "".join(full_text)
795
+ else:
796
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
797
+ res_json = json.loads(resp.read().decode("utf-8"))
798
+ content = res_json.get("content", [])
799
+ return "".join(block.get("text", "") for block in content if block.get("type") == "text")
800
+
801
+ def _generate_openai_compatible(
802
+ self,
803
+ base_url: str,
804
+ api_key: str,
805
+ prompt: str,
806
+ system_prompt: Optional[str] = None,
807
+ temperature: float = 0.2,
808
+ stream_callback: Optional[Callable[[str], None]] = None,
809
+ extra_headers: Optional[Dict[str, str]] = None,
810
+ default_model: Optional[str] = None,
811
+ ) -> str:
812
+ """Generic handler for OpenAI-compatible chat completion APIs (OpenAI, DeepSeek, OpenRouter, etc.)."""
813
+ model = self.model_name
814
+ if default_model and (not model or model == "qwen2.5-coder:1.5b"):
815
+ model = default_model
816
+
817
+ endpoint = f"{base_url.rstrip('/')}/chat/completions"
818
+ headers = {
819
+ "Content-Type": "application/json",
820
+ "Authorization": f"Bearer {api_key}",
821
+ }
822
+ if extra_headers:
823
+ headers.update(extra_headers)
824
+
825
+ messages = []
826
+ if system_prompt:
827
+ messages.append({"role": "system", "content": system_prompt})
828
+ messages.append({"role": "user", "content": prompt})
829
+
830
+ payload = {
831
+ "model": model,
832
+ "messages": messages,
833
+ "temperature": temperature,
834
+ "stream": bool(stream_callback),
835
+ }
836
+
837
+ data = json.dumps(payload).encode("utf-8")
838
+ req = urllib.request.Request(endpoint, data=data, headers=headers, method="POST")
839
+
840
+ if stream_callback:
841
+ full_text: List[str] = []
842
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
843
+ for line in resp:
844
+ line_str = line.decode("utf-8").strip()
845
+ if not line_str.startswith("data:"):
846
+ continue
847
+ data_payload = line_str[5:].strip()
848
+ if data_payload == "[DONE]":
849
+ break
850
+ if not data_payload:
851
+ continue
852
+ chunk = json.loads(data_payload)
853
+ choices = chunk.get("choices", [])
854
+ if choices:
855
+ delta = choices[0].get("delta", {})
856
+ token = delta.get("content") or ""
857
+ if token:
858
+ full_text.append(token)
859
+ _invoke_callback(stream_callback, token)
860
+ return "".join(full_text)
861
+ else:
862
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
863
+ res_json = json.loads(resp.read().decode("utf-8"))
864
+ choices = res_json.get("choices", [])
865
+ if choices:
866
+ return choices[0].get("message", {}).get("content", "")
867
+ return ""
868
+
869
+ def _generate_openai(
870
+ self,
871
+ prompt: str,
872
+ system_prompt: Optional[str] = None,
873
+ temperature: float = 0.2,
874
+ stream_callback: Optional[Callable[[str], None]] = None,
875
+ ) -> str:
876
+ """Generates text via OpenAI API."""
877
+ api_key = self.openai_api_key
878
+ if not api_key:
879
+ raise ValueError("OPENAI_API_KEY is not set")
880
+ return self._generate_openai_compatible(
881
+ base_url=self.openai_base_url,
882
+ api_key=api_key,
883
+ prompt=prompt,
884
+ system_prompt=system_prompt,
885
+ temperature=temperature,
886
+ stream_callback=stream_callback,
887
+ default_model="gpt-4o",
888
+ )
889
+
890
+ def _generate_deepseek(
891
+ self,
892
+ prompt: str,
893
+ system_prompt: Optional[str] = None,
894
+ temperature: float = 0.2,
895
+ stream_callback: Optional[Callable[[str], None]] = None,
896
+ ) -> str:
897
+ """Generates text via DeepSeek API."""
898
+ api_key = self.deepseek_api_key
899
+ if not api_key:
900
+ raise ValueError("DEEPSEEK_API_KEY is not set")
901
+ return self._generate_openai_compatible(
902
+ base_url=self.deepseek_base_url,
903
+ api_key=api_key,
904
+ prompt=prompt,
905
+ system_prompt=system_prompt,
906
+ temperature=temperature,
907
+ stream_callback=stream_callback,
908
+ default_model="deepseek-chat",
909
+ )
910
+
911
+ def _generate_openrouter(
912
+ self,
913
+ prompt: str,
914
+ system_prompt: Optional[str] = None,
915
+ temperature: float = 0.2,
916
+ stream_callback: Optional[Callable[[str], None]] = None,
917
+ ) -> str:
918
+ """Generates text via OpenRouter API."""
919
+ api_key = self.openrouter_api_key
920
+ if not api_key:
921
+ raise ValueError("OPENROUTER_API_KEY is not set")
922
+ extra_headers = {
923
+ "HTTP-Referer": "https://github.com/k-cli",
924
+ "X-Title": "K-CLI Engine",
925
+ }
926
+ return self._generate_openai_compatible(
927
+ base_url=self.openrouter_base_url,
928
+ api_key=api_key,
929
+ prompt=prompt,
930
+ system_prompt=system_prompt,
931
+ temperature=temperature,
932
+ stream_callback=stream_callback,
933
+ extra_headers=extra_headers,
934
+ default_model="anthropic/claude-3.7-sonnet",
935
+ )
936
+
937
+ def _generate_native(
938
+ self,
939
+ llm: Any,
940
+ prompt: str,
941
+ system_prompt: Optional[str] = None,
942
+ temperature: float = 0.2,
943
+ stream_callback: Optional[Callable[[str], None]] = None,
944
+ ) -> str:
945
+ """Generates text via in-process llama-cpp-python."""
946
+ formatted_prompt = f"<|im_start|>system\n{system_prompt or ''}<|im_end|>\n<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n"
947
+ if stream_callback:
948
+ full_text: List[str] = []
949
+ for chunk in llm(formatted_prompt, max_tokens=1024, temperature=temperature, stream=True):
950
+ token = chunk["choices"][0]["text"]
951
+ full_text.append(token)
952
+ _invoke_callback(stream_callback, token)
953
+ return "".join(full_text)
954
+ else:
955
+ out = llm(formatted_prompt, max_tokens=1024, temperature=temperature)
956
+ return out["choices"][0]["text"]
957
+
958
+ def _mock_generate(
959
+ self,
960
+ prompt: str,
961
+ system_prompt: Optional[str] = None,
962
+ stream_callback: Optional[Callable[[str], None]] = None,
963
+ ) -> str:
964
+ """Deterministic mock generator for offline mode and test suites."""
965
+ prompt_lower = prompt.lower()
966
+ sys_lower = (system_prompt or "").lower()
967
+
968
+ if "[researcher]" in sys_lower or "phase (researcher)" in sys_lower or sys_lower.startswith("you are [researcher]"):
969
+ text = "- Task: Python code implementation\n- Module: sys, psutil, time\n- Return type: clean Python script\n- Resource optimization: high efficiency"
970
+ elif "[critic]" in sys_lower or "phase (critic)" in sys_lower or sys_lower.startswith("you are [critic]"):
971
+ text = "VALIDATED: Code structure is sound, handles missing psutil gracefully, memory usage < 10MB."
972
+ elif "[debugger]" in sys_lower or "phase (debugger)" in sys_lower or sys_lower.startswith("you are [debugger]"):
973
+ text = (
974
+ "```python\n"
975
+ "import psutil\n"
976
+ "import time\n"
977
+ "\n"
978
+ "def get_ram_usage_mb() -> float:\n"
979
+ " process = psutil.Process()\n"
980
+ " return process.memory_info().rss / (1024 * 1024)\n"
981
+ "\n"
982
+ "if __name__ == '__main__':\n"
983
+ " print(f'Current RAM Usage: {get_ram_usage_mb():.2f} MB')\n"
984
+ "```"
985
+ )
986
+ elif "[architect]" in sys_lower or "phase (architect)" in sys_lower or sys_lower.startswith("you are [architect]") or (not any(p in sys_lower for p in ("[coder]", "[researcher]", "[critic]", "[debugger]")) and "architect" in sys_lower):
987
+ text = (
988
+ "<think>\n"
989
+ "1. Define RAM checking function using psutil.\n"
990
+ "2. Create main loop to monitor RSS memory.\n"
991
+ "3. Ensure zero external bloat and low memory consumption.\n"
992
+ "</think>\n"
993
+ '{"architecture": "RAM Monitoring Script", "language": "python"}'
994
+ )
995
+ else:
996
+ if "ram" in prompt_lower or "memory" in prompt_lower:
997
+ text = (
998
+ "```python\n"
999
+ "import psutil\n"
1000
+ "import time\n"
1001
+ "\n"
1002
+ "def get_ram_usage_mb() -> float:\n"
1003
+ " process = psutil.Process()\n"
1004
+ " return process.memory_info().rss / (1024 * 1024)\n"
1005
+ "\n"
1006
+ "if __name__ == '__main__':\n"
1007
+ " print(f'Current RAM Usage: {get_ram_usage_mb():.2f} MB')\n"
1008
+ "```"
1009
+ )
1010
+ else:
1011
+ text = (
1012
+ "```python\n"
1013
+ "def solution():\n"
1014
+ " return 'K-CLI Ground-Truth Execution Verified'\n"
1015
+ "\n"
1016
+ "if __name__ == '__main__':\n"
1017
+ " print(solution())\n"
1018
+ "```"
1019
+ )
1020
+
1021
+ if stream_callback:
1022
+ chunks = re.split(r"(\s+)", text)
1023
+ for chunk in chunks:
1024
+ if chunk:
1025
+ stream_callback(chunk)
1026
+
1027
+ return text
1028
+