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.
- k_cli/__init__.py +77 -0
- k_cli/agents/__init__.py +0 -0
- k_cli/agents/adversarial_swarm.py +338 -0
- k_cli/agents/agent_core.py +255 -0
- k_cli/agents/background_daemon.py +141 -0
- k_cli/agents/orchestrator.py +376 -0
- k_cli/agents/persona.py +649 -0
- k_cli/agents/scaffold_engine.py +121 -0
- k_cli/agents/strands_agent.py +832 -0
- k_cli/agents/subagents.py +1496 -0
- k_cli/cli.py +3297 -0
- k_cli/core/__init__.py +0 -0
- k_cli/core/airgap.py +95 -0
- k_cli/core/credentials.py +548 -0
- k_cli/core/intent_sensor.py +177 -0
- k_cli/core/llm_driver.py +1028 -0
- k_cli/core/model_manager.py +1109 -0
- k_cli/core/models_hub.py +913 -0
- k_cli/core/prompting.py +41 -0
- k_cli/core/sdk.py +322 -0
- k_cli/core/session.py +826 -0
- k_cli/core/smart_router.py +230 -0
- k_cli/core/storage_manager.py +176 -0
- k_cli/core/viewport_engine.py +117 -0
- k_cli/demo/demo_runner.py +579 -0
- k_cli/git/__init__.py +0 -0
- k_cli/git/ai_bisect.py +208 -0
- k_cli/git/conflict_resolver.py +1039 -0
- k_cli/git/git_guard.py +417 -0
- k_cli/git/patcher.py +1175 -0
- k_cli/git/repo_map.py +1780 -0
- k_cli/git/smart_git.py +928 -0
- k_cli/git/verifier.py +969 -0
- k_cli/github/__init__.py +0 -0
- k_cli/github/dedup_engine.py +787 -0
- k_cli/github/github_client.py +1702 -0
- k_cli/github/github_engine.py +641 -0
- k_cli/github/local_hub.py +209 -0
- k_cli/github/pr_watcher.py +129 -0
- k_cli/github/trending.py +205 -0
- k_cli/tools/__init__.py +0 -0
- k_cli/tools/audit.py +79 -0
- k_cli/tools/chaos_immunity.py +377 -0
- k_cli/tools/codebase_qa.py +106 -0
- k_cli/tools/command_runner.py +256 -0
- k_cli/tools/diagram_generator.py +547 -0
- k_cli/tools/doc_retriever.py +1332 -0
- k_cli/tools/feature.py +105 -0
- k_cli/tools/ghost_daemon.py +122 -0
- k_cli/tools/incident_triage.py +1365 -0
- k_cli/tools/mcp_client.py +1846 -0
- k_cli/tools/repo_gardener.py +142 -0
- k_cli/tools/rules.py +109 -0
- k_cli/tools/security.py +52 -0
- k_cli/tools/security_healer.py +999 -0
- k_cli/tools/synapse_graph.py +155 -0
- k_cli/tui/__init__.py +0 -0
- k_cli/tui/diff_viewer.py +223 -0
- k_cli/tui/tui.py +1145 -0
- k_cli/tui/tui_animations.py +648 -0
- k_cli/tui/tui_app.py +2788 -0
- k_cli/ui/__init__.py +10 -0
- k_cli/ui/simple_repl.py +315 -0
- k_cli/web/__init__.py +7 -0
- k_cli/web/server.py +624 -0
- k_cli/web/static/app.js +830 -0
- k_cli/web/static/index.html +495 -0
- k_cli/web/static/monitor.html +189 -0
- k_cli/web/static/style.css +838 -0
- k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
- k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
- k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
- k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
- k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
- k_cli_for_devs-1.0.0.dist-info/top_level.txt +1 -0
k_cli/core/llm_driver.py
ADDED
|
@@ -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
|
+
|