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/models_hub.py
ADDED
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
"""
|
|
2
|
+
models_hub.py - Universal AI Model Hub & Multi-Provider Model Registry for K-CLI
|
|
3
|
+
Project Bankai Engine v1.0.0
|
|
4
|
+
|
|
5
|
+
Supports dynamic discovery, configuration, pulling, benchmarking, and cascading for:
|
|
6
|
+
1. Local Providers:
|
|
7
|
+
- Ollama (Qwen2.5-Coder, DeepSeek-R1, Llama-3.3, StarCoder2, CodeLlama, Phi-4, Mistral)
|
|
8
|
+
- llama.cpp HTTP server (GGUF weights)
|
|
9
|
+
- Native llama-cpp-python GGUF in-process
|
|
10
|
+
- vLLM / SGLang / LocalAI / LM Studio / Jan (OpenAI-compatible local endpoints)
|
|
11
|
+
2. Cloud Model Providers:
|
|
12
|
+
- Google Gemini (Gemini 2.0 Flash, Gemini 2.0 Pro, Gemini 1.5 Pro, Thinking models)
|
|
13
|
+
- Anthropic (Claude 3.7 Sonnet, Claude 3.5 Sonnet, Claude 3.5 Haiku)
|
|
14
|
+
- OpenAI (GPT-4o, GPT-4o-mini, o1, o3-mini)
|
|
15
|
+
- DeepSeek (DeepSeek V3, DeepSeek R1)
|
|
16
|
+
- Groq (Ultra-fast Llama-3.3-70B, Qwen-2.5-Coder-32B @ 300+ tok/s)
|
|
17
|
+
- Mistral AI (Codestral, Mistral Large)
|
|
18
|
+
- OpenRouter (Unified access to 100+ AI models)
|
|
19
|
+
- Together AI, Cerebras, Fireworks, Cohere
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import time
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.request
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from enum import Enum
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("k_cli.models_hub")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ModelProvider(str, Enum):
|
|
42
|
+
OLLAMA = "ollama"
|
|
43
|
+
LLAMACPP = "llamacpp"
|
|
44
|
+
NATIVE = "native"
|
|
45
|
+
VLLM = "vllm"
|
|
46
|
+
LMSTUDIO = "lmstudio"
|
|
47
|
+
LOCALAI = "localai"
|
|
48
|
+
OPENAI = "openai"
|
|
49
|
+
ANTHROPIC = "anthropic"
|
|
50
|
+
GEMINI = "gemini"
|
|
51
|
+
DEEPSEEK = "deepseek"
|
|
52
|
+
GROQ = "groq"
|
|
53
|
+
MISTRAL = "mistral"
|
|
54
|
+
OPENROUTER = "openrouter"
|
|
55
|
+
TOGETHER = "together"
|
|
56
|
+
MOCK = "mock"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ModelSpec:
|
|
61
|
+
"""Specification and metadata for an AI model."""
|
|
62
|
+
id: str
|
|
63
|
+
name: str
|
|
64
|
+
provider: ModelProvider
|
|
65
|
+
context_window: int = 32768
|
|
66
|
+
max_output_tokens: int = 4096
|
|
67
|
+
is_local: bool = False
|
|
68
|
+
description: str = ""
|
|
69
|
+
prompt_price_per_m: float = 0.0
|
|
70
|
+
completion_price_per_m: float = 0.0
|
|
71
|
+
supports_tools: bool = True
|
|
72
|
+
supports_vision: bool = False
|
|
73
|
+
supports_thinking: bool = False
|
|
74
|
+
is_installed: bool = False
|
|
75
|
+
base_url: Optional[str] = None
|
|
76
|
+
env_var_key: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"id": self.id,
|
|
81
|
+
"name": self.name,
|
|
82
|
+
"provider": self.provider.value,
|
|
83
|
+
"context_window": self.context_window,
|
|
84
|
+
"max_output_tokens": self.max_output_tokens,
|
|
85
|
+
"is_local": self.is_local,
|
|
86
|
+
"description": self.description,
|
|
87
|
+
"prompt_price_per_m": self.prompt_price_per_m,
|
|
88
|
+
"completion_price_per_m": self.completion_price_per_m,
|
|
89
|
+
"supports_tools": self.supports_tools,
|
|
90
|
+
"supports_vision": self.supports_vision,
|
|
91
|
+
"supports_thinking": self.supports_thinking,
|
|
92
|
+
"is_installed": self.is_installed,
|
|
93
|
+
"base_url": self.base_url,
|
|
94
|
+
"env_var_key": self.env_var_key,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class ModelBenchmarkResult:
|
|
100
|
+
"""Telemetry results from model execution benchmark."""
|
|
101
|
+
model_id: str
|
|
102
|
+
provider: str
|
|
103
|
+
success: bool
|
|
104
|
+
tokens_generated: int = 0
|
|
105
|
+
duration_seconds: float = 0.0
|
|
106
|
+
tokens_per_second: float = 0.0
|
|
107
|
+
time_to_first_token: float = 0.0
|
|
108
|
+
ram_rss_mb: float = 0.0
|
|
109
|
+
error_message: Optional[str] = None
|
|
110
|
+
sample_output: str = ""
|
|
111
|
+
|
|
112
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
113
|
+
return {
|
|
114
|
+
"model_id": self.model_id,
|
|
115
|
+
"provider": self.provider,
|
|
116
|
+
"success": self.success,
|
|
117
|
+
"tokens_generated": self.tokens_generated,
|
|
118
|
+
"duration_seconds": round(self.duration_seconds, 4),
|
|
119
|
+
"tokens_per_second": round(self.tokens_per_second, 2),
|
|
120
|
+
"time_to_first_token": round(self.time_to_first_token, 4),
|
|
121
|
+
"ram_rss_mb": round(self.ram_rss_mb, 2),
|
|
122
|
+
"error_message": self.error_message,
|
|
123
|
+
"sample_output": self.sample_output,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Curated catalog of industry-leading local and cloud models
|
|
128
|
+
MODEL_CATALOG_REGISTRY: Dict[str, ModelSpec] = {
|
|
129
|
+
# --- Local Models (Ollama / GGUF) ---
|
|
130
|
+
"qwen2.5-coder:1.5b": ModelSpec(
|
|
131
|
+
id="qwen2.5-coder:1.5b",
|
|
132
|
+
name="Qwen 2.5 Coder 1.5B (Ultra-Light)",
|
|
133
|
+
provider=ModelProvider.OLLAMA,
|
|
134
|
+
context_window=32768,
|
|
135
|
+
is_local=True,
|
|
136
|
+
description="Fastest lightweight local coding model",
|
|
137
|
+
),
|
|
138
|
+
"qwen2.5-coder:7b": ModelSpec(
|
|
139
|
+
id="qwen2.5-coder:7b",
|
|
140
|
+
name="Qwen 2.5 Coder 7B",
|
|
141
|
+
provider=ModelProvider.OLLAMA,
|
|
142
|
+
context_window=32768,
|
|
143
|
+
is_local=True,
|
|
144
|
+
description="Strong local coding and multi-file reasoning model",
|
|
145
|
+
),
|
|
146
|
+
"qwen2.5-coder:14b": ModelSpec(
|
|
147
|
+
id="qwen2.5-coder:14b",
|
|
148
|
+
name="Qwen 2.5 Coder 14B",
|
|
149
|
+
provider=ModelProvider.OLLAMA,
|
|
150
|
+
context_window=32768,
|
|
151
|
+
is_local=True,
|
|
152
|
+
description="High-accuracy local code generation and refactoring",
|
|
153
|
+
),
|
|
154
|
+
"deepseek-r1:7b": ModelSpec(
|
|
155
|
+
id="deepseek-r1:7b",
|
|
156
|
+
name="DeepSeek R1 Distill Qwen 7B",
|
|
157
|
+
provider=ModelProvider.OLLAMA,
|
|
158
|
+
context_window=32768,
|
|
159
|
+
is_local=True,
|
|
160
|
+
supports_thinking=True,
|
|
161
|
+
description="Local reasoning model with deep chain-of-thought",
|
|
162
|
+
),
|
|
163
|
+
"deepseek-r1:14b": ModelSpec(
|
|
164
|
+
id="deepseek-r1:14b",
|
|
165
|
+
name="DeepSeek R1 Distill Qwen 14B",
|
|
166
|
+
provider=ModelProvider.OLLAMA,
|
|
167
|
+
context_window=32768,
|
|
168
|
+
is_local=True,
|
|
169
|
+
supports_thinking=True,
|
|
170
|
+
description="Advanced local reasoning and architectural planning",
|
|
171
|
+
),
|
|
172
|
+
"llama3.3:70b": ModelSpec(
|
|
173
|
+
id="llama3.3:70b",
|
|
174
|
+
name="Meta Llama 3.3 70B",
|
|
175
|
+
provider=ModelProvider.OLLAMA,
|
|
176
|
+
context_window=128000,
|
|
177
|
+
is_local=True,
|
|
178
|
+
description="State-of-the-art open weights flagship model",
|
|
179
|
+
),
|
|
180
|
+
"codellama:7b": ModelSpec(
|
|
181
|
+
id="codellama:7b",
|
|
182
|
+
name="CodeLlama 7B",
|
|
183
|
+
provider=ModelProvider.OLLAMA,
|
|
184
|
+
context_window=16384,
|
|
185
|
+
is_local=True,
|
|
186
|
+
description="Meta's classic code-specialized model",
|
|
187
|
+
),
|
|
188
|
+
"phi4:14b": ModelSpec(
|
|
189
|
+
id="phi4:14b",
|
|
190
|
+
name="Microsoft Phi-4 14B",
|
|
191
|
+
provider=ModelProvider.OLLAMA,
|
|
192
|
+
context_window=16384,
|
|
193
|
+
is_local=True,
|
|
194
|
+
description="High-reasoning compact model from Microsoft Research",
|
|
195
|
+
),
|
|
196
|
+
|
|
197
|
+
# --- Google Gemini ---
|
|
198
|
+
"gemini-2.0-flash": ModelSpec(
|
|
199
|
+
id="gemini-2.0-flash",
|
|
200
|
+
name="Google Gemini 2.0 Flash",
|
|
201
|
+
provider=ModelProvider.GEMINI,
|
|
202
|
+
context_window=1048576,
|
|
203
|
+
max_output_tokens=8192,
|
|
204
|
+
prompt_price_per_m=0.10,
|
|
205
|
+
completion_price_per_m=0.40,
|
|
206
|
+
supports_tools=True,
|
|
207
|
+
supports_vision=True,
|
|
208
|
+
supports_thinking=True,
|
|
209
|
+
env_var_key="GEMINI_API_KEY",
|
|
210
|
+
description="Next-generation ultra-fast multimodal model with 1M context",
|
|
211
|
+
),
|
|
212
|
+
"gemini-1.5-pro": ModelSpec(
|
|
213
|
+
id="gemini-1.5-pro",
|
|
214
|
+
name="Google Gemini 1.5 Pro",
|
|
215
|
+
provider=ModelProvider.GEMINI,
|
|
216
|
+
context_window=2097152,
|
|
217
|
+
max_output_tokens=8192,
|
|
218
|
+
prompt_price_per_m=1.25,
|
|
219
|
+
completion_price_per_m=5.00,
|
|
220
|
+
supports_tools=True,
|
|
221
|
+
supports_vision=True,
|
|
222
|
+
env_var_key="GEMINI_API_KEY",
|
|
223
|
+
description="2M token context window for full-codebase reasoning",
|
|
224
|
+
),
|
|
225
|
+
|
|
226
|
+
# --- Anthropic Claude ---
|
|
227
|
+
"claude-3-7-sonnet": ModelSpec(
|
|
228
|
+
id="claude-3-7-sonnet",
|
|
229
|
+
name="Claude 3.7 Sonnet (Hybrid Thinking)",
|
|
230
|
+
provider=ModelProvider.ANTHROPIC,
|
|
231
|
+
context_window=200000,
|
|
232
|
+
max_output_tokens=8192,
|
|
233
|
+
prompt_price_per_m=3.00,
|
|
234
|
+
completion_price_per_m=15.00,
|
|
235
|
+
supports_tools=True,
|
|
236
|
+
supports_vision=True,
|
|
237
|
+
supports_thinking=True,
|
|
238
|
+
env_var_key="ANTHROPIC_API_KEY",
|
|
239
|
+
description="Anthropic's flagship coding and hybrid reasoning model",
|
|
240
|
+
),
|
|
241
|
+
"claude-3-5-sonnet": ModelSpec(
|
|
242
|
+
id="claude-3-5-sonnet",
|
|
243
|
+
name="Claude 3.5 Sonnet",
|
|
244
|
+
provider=ModelProvider.ANTHROPIC,
|
|
245
|
+
context_window=200000,
|
|
246
|
+
max_output_tokens=8192,
|
|
247
|
+
prompt_price_per_m=3.00,
|
|
248
|
+
completion_price_per_m=15.00,
|
|
249
|
+
supports_tools=True,
|
|
250
|
+
supports_vision=True,
|
|
251
|
+
env_var_key="ANTHROPIC_API_KEY",
|
|
252
|
+
description="Industry-standard benchmark leader in code generation",
|
|
253
|
+
),
|
|
254
|
+
"claude-3-5-haiku": ModelSpec(
|
|
255
|
+
id="claude-3-5-haiku",
|
|
256
|
+
name="Claude 3.5 Haiku",
|
|
257
|
+
provider=ModelProvider.ANTHROPIC,
|
|
258
|
+
context_window=200000,
|
|
259
|
+
max_output_tokens=4096,
|
|
260
|
+
prompt_price_per_m=0.80,
|
|
261
|
+
completion_price_per_m=4.00,
|
|
262
|
+
supports_tools=True,
|
|
263
|
+
env_var_key="ANTHROPIC_API_KEY",
|
|
264
|
+
description="High-speed, cost-effective coding model",
|
|
265
|
+
),
|
|
266
|
+
|
|
267
|
+
# --- OpenAI ---
|
|
268
|
+
"gpt-4o": ModelSpec(
|
|
269
|
+
id="gpt-4o",
|
|
270
|
+
name="OpenAI GPT-4o",
|
|
271
|
+
provider=ModelProvider.OPENAI,
|
|
272
|
+
context_window=128000,
|
|
273
|
+
max_output_tokens=4096,
|
|
274
|
+
prompt_price_per_m=2.50,
|
|
275
|
+
completion_price_per_m=10.00,
|
|
276
|
+
supports_tools=True,
|
|
277
|
+
supports_vision=True,
|
|
278
|
+
env_var_key="OPENAI_API_KEY",
|
|
279
|
+
description="Omni flagship model from OpenAI",
|
|
280
|
+
),
|
|
281
|
+
"gpt-4o-mini": ModelSpec(
|
|
282
|
+
id="gpt-4o-mini",
|
|
283
|
+
name="OpenAI GPT-4o Mini",
|
|
284
|
+
provider=ModelProvider.OPENAI,
|
|
285
|
+
context_window=128000,
|
|
286
|
+
max_output_tokens=4096,
|
|
287
|
+
prompt_price_per_m=0.15,
|
|
288
|
+
completion_price_per_m=0.60,
|
|
289
|
+
supports_tools=True,
|
|
290
|
+
env_var_key="OPENAI_API_KEY",
|
|
291
|
+
description="Affordable fast model for everyday coding tasks",
|
|
292
|
+
),
|
|
293
|
+
"o3-mini": ModelSpec(
|
|
294
|
+
id="o3-mini",
|
|
295
|
+
name="OpenAI o3-mini",
|
|
296
|
+
provider=ModelProvider.OPENAI,
|
|
297
|
+
context_window=200000,
|
|
298
|
+
max_output_tokens=8192,
|
|
299
|
+
prompt_price_per_m=1.10,
|
|
300
|
+
completion_price_per_m=4.40,
|
|
301
|
+
supports_tools=True,
|
|
302
|
+
supports_thinking=True,
|
|
303
|
+
env_var_key="OPENAI_API_KEY",
|
|
304
|
+
description="Advanced STEM & coding reasoning model with reasoning tiers",
|
|
305
|
+
),
|
|
306
|
+
|
|
307
|
+
# --- DeepSeek ---
|
|
308
|
+
"deepseek-chat": ModelSpec(
|
|
309
|
+
id="deepseek-chat",
|
|
310
|
+
name="DeepSeek V3",
|
|
311
|
+
provider=ModelProvider.DEEPSEEK,
|
|
312
|
+
context_window=64000,
|
|
313
|
+
max_output_tokens=8192,
|
|
314
|
+
prompt_price_per_m=0.27,
|
|
315
|
+
completion_price_per_m=1.10,
|
|
316
|
+
supports_tools=True,
|
|
317
|
+
base_url="https://api.deepseek.com/v1",
|
|
318
|
+
env_var_key="DEEPSEEK_API_KEY",
|
|
319
|
+
description="High-performance MoE coding & general model",
|
|
320
|
+
),
|
|
321
|
+
"deepseek-reasoner": ModelSpec(
|
|
322
|
+
id="deepseek-reasoner",
|
|
323
|
+
name="DeepSeek R1 Reasoning",
|
|
324
|
+
provider=ModelProvider.DEEPSEEK,
|
|
325
|
+
context_window=64000,
|
|
326
|
+
max_output_tokens=8192,
|
|
327
|
+
prompt_price_per_m=0.55,
|
|
328
|
+
completion_price_per_m=2.19,
|
|
329
|
+
supports_thinking=True,
|
|
330
|
+
base_url="https://api.deepseek.com/v1",
|
|
331
|
+
env_var_key="DEEPSEEK_API_KEY",
|
|
332
|
+
description="DeepSeek R1 full reasoning with verified think trace",
|
|
333
|
+
),
|
|
334
|
+
|
|
335
|
+
# --- Groq (Ultra-Fast) ---
|
|
336
|
+
"llama-3.3-70b-versatile": ModelSpec(
|
|
337
|
+
id="llama-3.3-70b-versatile",
|
|
338
|
+
name="Groq Llama 3.3 70B (300+ tok/s)",
|
|
339
|
+
provider=ModelProvider.GROQ,
|
|
340
|
+
context_window=128000,
|
|
341
|
+
max_output_tokens=8192,
|
|
342
|
+
prompt_price_per_m=0.59,
|
|
343
|
+
completion_price_per_m=0.79,
|
|
344
|
+
supports_tools=True,
|
|
345
|
+
base_url="https://api.groq.com/openai/v1",
|
|
346
|
+
env_var_key="GROQ_API_KEY",
|
|
347
|
+
description="Ultra-low-latency Llama 3.3 powered by Groq LPUs",
|
|
348
|
+
),
|
|
349
|
+
"qwen-2.5-coder-32b": ModelSpec(
|
|
350
|
+
id="qwen-2.5-coder-32b",
|
|
351
|
+
name="Groq Qwen 2.5 Coder 32B",
|
|
352
|
+
provider=ModelProvider.GROQ,
|
|
353
|
+
context_window=128000,
|
|
354
|
+
max_output_tokens=8192,
|
|
355
|
+
supports_tools=True,
|
|
356
|
+
base_url="https://api.groq.com/openai/v1",
|
|
357
|
+
env_var_key="GROQ_API_KEY",
|
|
358
|
+
description="Ultra-fast code generation on Groq LPU hardware",
|
|
359
|
+
),
|
|
360
|
+
|
|
361
|
+
# --- Mistral AI ---
|
|
362
|
+
"codestral-latest": ModelSpec(
|
|
363
|
+
id="codestral-latest",
|
|
364
|
+
name="Mistral Codestral",
|
|
365
|
+
provider=ModelProvider.MISTRAL,
|
|
366
|
+
context_window=32768,
|
|
367
|
+
max_output_tokens=8192,
|
|
368
|
+
prompt_price_per_m=0.30,
|
|
369
|
+
completion_price_per_m=0.90,
|
|
370
|
+
supports_tools=True,
|
|
371
|
+
base_url="https://api.mistral.ai/v1",
|
|
372
|
+
env_var_key="MISTRAL_API_KEY",
|
|
373
|
+
description="Mistral's purpose-built code generation & fill-in-middle model",
|
|
374
|
+
),
|
|
375
|
+
|
|
376
|
+
# --- OpenRouter ---
|
|
377
|
+
"openrouter/auto": ModelSpec(
|
|
378
|
+
id="openrouter/auto",
|
|
379
|
+
name="OpenRouter Smart Auto-Router",
|
|
380
|
+
provider=ModelProvider.OPENROUTER,
|
|
381
|
+
context_window=128000,
|
|
382
|
+
supports_tools=True,
|
|
383
|
+
base_url="https://openrouter.ai/api/v1",
|
|
384
|
+
env_var_key="OPENROUTER_API_KEY",
|
|
385
|
+
description="Universal router picking optimal model per prompt",
|
|
386
|
+
),
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class ModelHub:
|
|
391
|
+
"""
|
|
392
|
+
Universal Model Hub & Provider Manager for K-CLI.
|
|
393
|
+
Discovers local models, tests connectivity, manages API keys,
|
|
394
|
+
and constructs multi-tier fallback cascades.
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
def __init__(
|
|
398
|
+
self,
|
|
399
|
+
ollama_url: str = "http://localhost:11434",
|
|
400
|
+
llamacpp_url: str = "http://localhost:8080",
|
|
401
|
+
config_file: Optional[str] = None,
|
|
402
|
+
):
|
|
403
|
+
self.ollama_url = ollama_url.rstrip("/")
|
|
404
|
+
self.llamacpp_url = llamacpp_url.rstrip("/")
|
|
405
|
+
self.config_file = config_file or str(Path.home() / ".kcli" / "models.json")
|
|
406
|
+
self.registry: Dict[str, ModelSpec] = dict(MODEL_CATALOG_REGISTRY)
|
|
407
|
+
self.custom_models: Dict[str, ModelSpec] = {}
|
|
408
|
+
self._load_custom_config()
|
|
409
|
+
|
|
410
|
+
def _load_custom_config(self) -> None:
|
|
411
|
+
"""Loads user-registered custom models and local endpoints from JSON."""
|
|
412
|
+
cfg_path = Path(self.config_file)
|
|
413
|
+
if cfg_path.exists():
|
|
414
|
+
try:
|
|
415
|
+
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
416
|
+
for item in data.get("models", []):
|
|
417
|
+
spec = ModelSpec(
|
|
418
|
+
id=item["id"],
|
|
419
|
+
name=item.get("name", item["id"]),
|
|
420
|
+
provider=ModelProvider(item.get("provider", "openai-compatible")),
|
|
421
|
+
context_window=item.get("context_window", 32768),
|
|
422
|
+
is_local=item.get("is_local", False),
|
|
423
|
+
base_url=item.get("base_url"),
|
|
424
|
+
env_var_key=item.get("env_var_key"),
|
|
425
|
+
)
|
|
426
|
+
self.custom_models[spec.id] = spec
|
|
427
|
+
self.registry[spec.id] = spec
|
|
428
|
+
except Exception as exc:
|
|
429
|
+
logger.warning(f"Failed loading custom model config: {exc}")
|
|
430
|
+
|
|
431
|
+
def save_custom_config(self) -> bool:
|
|
432
|
+
"""Persists custom model definitions to disk."""
|
|
433
|
+
cfg_path = Path(self.config_file)
|
|
434
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
try:
|
|
436
|
+
data = {"models": [m.to_dict() for m in self.custom_models.values()]}
|
|
437
|
+
cfg_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
438
|
+
return True
|
|
439
|
+
except Exception as exc:
|
|
440
|
+
logger.error(f"Failed saving custom models: {exc}")
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
def register_model(self, spec: ModelSpec) -> None:
|
|
444
|
+
"""Registers a custom or self-hosted model in the registry."""
|
|
445
|
+
self.custom_models[spec.id] = spec
|
|
446
|
+
self.registry[spec.id] = spec
|
|
447
|
+
self.save_custom_config()
|
|
448
|
+
|
|
449
|
+
def resolve_model(self, identifier: str) -> Optional[ModelSpec]:
|
|
450
|
+
"""
|
|
451
|
+
Parses model identifier (e.g. `gemini-2.0-flash`, `ollama/qwen2.5-coder:7b`,
|
|
452
|
+
`deepseek/deepseek-r1`, `groq/llama-3.3-70b-versatile`) and resolves ModelSpec.
|
|
453
|
+
"""
|
|
454
|
+
if not identifier:
|
|
455
|
+
return self.registry.get("qwen2.5-coder:1.5b")
|
|
456
|
+
|
|
457
|
+
clean_id = identifier.strip().lower()
|
|
458
|
+
|
|
459
|
+
# 1. Exact match in registry
|
|
460
|
+
if clean_id in self.registry:
|
|
461
|
+
return self.registry[clean_id]
|
|
462
|
+
|
|
463
|
+
# 2. Check with provider prefix stripped
|
|
464
|
+
if "/" in clean_id:
|
|
465
|
+
provider_prefix, model_name = clean_id.split("/", 1)
|
|
466
|
+
if model_name in self.registry:
|
|
467
|
+
return self.registry[model_name]
|
|
468
|
+
|
|
469
|
+
# Dynamic provider instantiation
|
|
470
|
+
prov_enum = ModelProvider.OPENAI
|
|
471
|
+
for p in ModelProvider:
|
|
472
|
+
if p.value == provider_prefix:
|
|
473
|
+
prov_enum = p
|
|
474
|
+
break
|
|
475
|
+
|
|
476
|
+
return ModelSpec(
|
|
477
|
+
id=clean_id,
|
|
478
|
+
name=model_name,
|
|
479
|
+
provider=prov_enum,
|
|
480
|
+
is_local=(prov_enum in (ModelProvider.OLLAMA, ModelProvider.LLAMACPP, ModelProvider.NATIVE)),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# 3. Fuzzy matching against catalog
|
|
484
|
+
for spec_id, spec in self.registry.items():
|
|
485
|
+
if clean_id in spec_id.lower() or clean_id in spec.name.lower():
|
|
486
|
+
return spec
|
|
487
|
+
|
|
488
|
+
# 4. Fallback: treat as Ollama local model
|
|
489
|
+
return ModelSpec(
|
|
490
|
+
id=identifier,
|
|
491
|
+
name=identifier,
|
|
492
|
+
provider=ModelProvider.OLLAMA,
|
|
493
|
+
is_local=True,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
def discover_local_ollama_models(self) -> List[Dict[str, Any]]:
|
|
497
|
+
"""Queries local Ollama daemon dynamically for ALL installed models and metadata."""
|
|
498
|
+
try:
|
|
499
|
+
req = urllib.request.Request(
|
|
500
|
+
f"{self.ollama_url}/api/tags",
|
|
501
|
+
headers={"User-Agent": "K-CLI/1.0.0 (AGY Edition)"},
|
|
502
|
+
)
|
|
503
|
+
with urllib.request.urlopen(req, timeout=3.0) as resp:
|
|
504
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
505
|
+
discovered: List[Dict[str, Any]] = []
|
|
506
|
+
for m in data.get("models", []):
|
|
507
|
+
name = m.get("name", "")
|
|
508
|
+
if name:
|
|
509
|
+
size_gb = round(m.get("size", 0) / (1024**3), 2)
|
|
510
|
+
details = m.get("details", {})
|
|
511
|
+
quant = details.get("quantization_level", "")
|
|
512
|
+
param_size = details.get("parameter_size", "")
|
|
513
|
+
family = details.get("family", "")
|
|
514
|
+
|
|
515
|
+
spec = ModelSpec(
|
|
516
|
+
id=name,
|
|
517
|
+
name=f"{name} ({param_size} {quant})".strip(),
|
|
518
|
+
provider=ModelProvider.OLLAMA,
|
|
519
|
+
context_window=32768,
|
|
520
|
+
is_local=True,
|
|
521
|
+
is_installed=True,
|
|
522
|
+
description=f"Local Ollama model: {family} {param_size} {quant} ({size_gb} GB)",
|
|
523
|
+
)
|
|
524
|
+
self.registry[name] = spec
|
|
525
|
+
discovered.append({
|
|
526
|
+
"name": name,
|
|
527
|
+
"size_gb": size_gb,
|
|
528
|
+
"quant": quant,
|
|
529
|
+
"param_size": param_size,
|
|
530
|
+
"family": family,
|
|
531
|
+
"spec": spec,
|
|
532
|
+
})
|
|
533
|
+
return discovered
|
|
534
|
+
except Exception:
|
|
535
|
+
return []
|
|
536
|
+
|
|
537
|
+
def discover_all_live_models(self) -> List[ModelSpec]:
|
|
538
|
+
"""
|
|
539
|
+
Dynamically queries all active local daemons (Ollama, LM Studio, vLLM)
|
|
540
|
+
and cloud provider endpoints to discover every available model in real time.
|
|
541
|
+
"""
|
|
542
|
+
# 1. Local Ollama
|
|
543
|
+
self.discover_local_ollama_models()
|
|
544
|
+
|
|
545
|
+
# 2. Local LM Studio / vLLM / OpenAI Compatible endpoints
|
|
546
|
+
for local_url in ("http://localhost:1234/v1", "http://localhost:8000/v1", "http://localhost:8080/v1"):
|
|
547
|
+
try:
|
|
548
|
+
req = urllib.request.Request(f"{local_url}/models", headers={"User-Agent": "K-CLI"})
|
|
549
|
+
with urllib.request.urlopen(req, timeout=1.5) as resp:
|
|
550
|
+
if resp.status == 200:
|
|
551
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
552
|
+
for item in data.get("data", []):
|
|
553
|
+
m_id = item.get("id")
|
|
554
|
+
if m_id:
|
|
555
|
+
spec = ModelSpec(
|
|
556
|
+
id=f"local/{m_id}",
|
|
557
|
+
name=f"Local ({local_url}): {m_id}",
|
|
558
|
+
provider=ModelProvider.OPENAI_COMPATIBLE,
|
|
559
|
+
is_local=True,
|
|
560
|
+
base_url=local_url,
|
|
561
|
+
description=f"Local self-hosted model running on {local_url}",
|
|
562
|
+
)
|
|
563
|
+
self.registry[spec.id] = spec
|
|
564
|
+
except Exception:
|
|
565
|
+
pass
|
|
566
|
+
|
|
567
|
+
# 3. Groq Dynamic Models
|
|
568
|
+
groq_key = os.environ.get("GROQ_API_KEY")
|
|
569
|
+
if groq_key:
|
|
570
|
+
try:
|
|
571
|
+
req = urllib.request.Request(
|
|
572
|
+
"https://api.groq.com/openai/v1/models",
|
|
573
|
+
headers={"Authorization": f"Bearer {groq_key}", "User-Agent": "K-CLI"},
|
|
574
|
+
)
|
|
575
|
+
with urllib.request.urlopen(req, timeout=2.5) as resp:
|
|
576
|
+
if resp.status == 200:
|
|
577
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
578
|
+
for item in data.get("data", []):
|
|
579
|
+
m_id = item.get("id")
|
|
580
|
+
if m_id and ("llama" in m_id or "qwen" in m_id or "deepseek" in m_id or "mixtral" in m_id):
|
|
581
|
+
spec = ModelSpec(
|
|
582
|
+
id=f"groq/{m_id}",
|
|
583
|
+
name=f"Groq Fast: {m_id}",
|
|
584
|
+
provider=ModelProvider.GROQ,
|
|
585
|
+
base_url="https://api.groq.com/openai/v1",
|
|
586
|
+
env_var_key="GROQ_API_KEY",
|
|
587
|
+
description=f"Groq ultra-fast LPU inference: {m_id}",
|
|
588
|
+
)
|
|
589
|
+
self.registry[spec.id] = spec
|
|
590
|
+
except Exception:
|
|
591
|
+
pass
|
|
592
|
+
|
|
593
|
+
# 4. Google Gemini Dynamic Models
|
|
594
|
+
gemini_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
|
595
|
+
if gemini_key:
|
|
596
|
+
try:
|
|
597
|
+
req = urllib.request.Request(
|
|
598
|
+
f"https://generativelanguage.googleapis.com/v1beta/models?key={gemini_key}",
|
|
599
|
+
headers={"User-Agent": "K-CLI"},
|
|
600
|
+
)
|
|
601
|
+
with urllib.request.urlopen(req, timeout=3.0) as resp:
|
|
602
|
+
if resp.status == 200:
|
|
603
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
604
|
+
for item in data.get("models", []):
|
|
605
|
+
raw_name = item.get("name", "").replace("models/", "")
|
|
606
|
+
if "gemini" in raw_name and "deprecated" not in raw_name:
|
|
607
|
+
spec = ModelSpec(
|
|
608
|
+
id=raw_name,
|
|
609
|
+
name=f"Google Gemini: {raw_name}",
|
|
610
|
+
provider=ModelProvider.GEMINI,
|
|
611
|
+
env_var_key="GEMINI_API_KEY",
|
|
612
|
+
description=item.get("description", f"Google Gemini model {raw_name}")[:60],
|
|
613
|
+
)
|
|
614
|
+
self.registry[spec.id] = spec
|
|
615
|
+
except Exception:
|
|
616
|
+
pass
|
|
617
|
+
|
|
618
|
+
# 5. DeepSeek Dynamic Models
|
|
619
|
+
deepseek_key = os.environ.get("DEEPSEEK_API_KEY")
|
|
620
|
+
if deepseek_key:
|
|
621
|
+
try:
|
|
622
|
+
req = urllib.request.Request(
|
|
623
|
+
"https://api.deepseek.com/models",
|
|
624
|
+
headers={"Authorization": f"Bearer {deepseek_key}", "User-Agent": "K-CLI"},
|
|
625
|
+
)
|
|
626
|
+
with urllib.request.urlopen(req, timeout=2.5) as resp:
|
|
627
|
+
if resp.status == 200:
|
|
628
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
629
|
+
for item in data.get("data", []):
|
|
630
|
+
m_id = item.get("id")
|
|
631
|
+
if m_id:
|
|
632
|
+
spec = ModelSpec(
|
|
633
|
+
id=f"deepseek/{m_id}",
|
|
634
|
+
name=f"DeepSeek: {m_id}",
|
|
635
|
+
provider=ModelProvider.DEEPSEEK,
|
|
636
|
+
base_url="https://api.deepseek.com",
|
|
637
|
+
env_var_key="DEEPSEEK_API_KEY",
|
|
638
|
+
description=f"DeepSeek Reasoning & Coding model {m_id}",
|
|
639
|
+
)
|
|
640
|
+
self.registry[spec.id] = spec
|
|
641
|
+
except Exception:
|
|
642
|
+
pass
|
|
643
|
+
|
|
644
|
+
# 6. OpenAI Dynamic Models
|
|
645
|
+
openai_key = os.environ.get("OPENAI_API_KEY")
|
|
646
|
+
if openai_key:
|
|
647
|
+
try:
|
|
648
|
+
req = urllib.request.Request(
|
|
649
|
+
"https://api.openai.com/v1/models",
|
|
650
|
+
headers={"Authorization": f"Bearer {openai_key}", "User-Agent": "K-CLI"},
|
|
651
|
+
)
|
|
652
|
+
with urllib.request.urlopen(req, timeout=2.5) as resp:
|
|
653
|
+
if resp.status == 200:
|
|
654
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
655
|
+
for item in data.get("data", []):
|
|
656
|
+
m_id = item.get("id", "")
|
|
657
|
+
if m_id.startswith("gpt-4") or m_id.startswith("o1") or m_id.startswith("o3") or m_id.startswith("chatgpt"):
|
|
658
|
+
spec = ModelSpec(
|
|
659
|
+
id=f"openai/{m_id}",
|
|
660
|
+
name=f"OpenAI: {m_id}",
|
|
661
|
+
provider=ModelProvider.OPENAI,
|
|
662
|
+
base_url="https://api.openai.com/v1",
|
|
663
|
+
env_var_key="OPENAI_API_KEY",
|
|
664
|
+
description=f"OpenAI model {m_id}",
|
|
665
|
+
)
|
|
666
|
+
self.registry[spec.id] = spec
|
|
667
|
+
except Exception:
|
|
668
|
+
pass
|
|
669
|
+
|
|
670
|
+
# 7. Anthropic Dynamic Models
|
|
671
|
+
anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
672
|
+
if anthropic_key:
|
|
673
|
+
try:
|
|
674
|
+
req = urllib.request.Request(
|
|
675
|
+
"https://api.anthropic.com/v1/models",
|
|
676
|
+
headers={
|
|
677
|
+
"x-api-key": anthropic_key,
|
|
678
|
+
"anthropic-version": "2023-06-01",
|
|
679
|
+
"User-Agent": "K-CLI",
|
|
680
|
+
},
|
|
681
|
+
)
|
|
682
|
+
with urllib.request.urlopen(req, timeout=2.5) as resp:
|
|
683
|
+
if resp.status == 200:
|
|
684
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
685
|
+
for item in data.get("data", []):
|
|
686
|
+
m_id = item.get("id", "")
|
|
687
|
+
if m_id:
|
|
688
|
+
spec = ModelSpec(
|
|
689
|
+
id=f"anthropic/{m_id}",
|
|
690
|
+
name=f"Anthropic: {m_id}",
|
|
691
|
+
provider=ModelProvider.ANTHROPIC,
|
|
692
|
+
env_var_key="ANTHROPIC_API_KEY",
|
|
693
|
+
description=item.get("display_name", f"Anthropic model {m_id}"),
|
|
694
|
+
)
|
|
695
|
+
self.registry[spec.id] = spec
|
|
696
|
+
except Exception:
|
|
697
|
+
for m_id in ("claude-3-7-sonnet-20250219", "claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022"):
|
|
698
|
+
spec = ModelSpec(
|
|
699
|
+
id=f"anthropic/{m_id}",
|
|
700
|
+
name=f"Anthropic: {m_id}",
|
|
701
|
+
provider=ModelProvider.ANTHROPIC,
|
|
702
|
+
env_var_key="ANTHROPIC_API_KEY",
|
|
703
|
+
description=f"Anthropic state-of-the-art model {m_id}",
|
|
704
|
+
)
|
|
705
|
+
self.registry[spec.id] = spec
|
|
706
|
+
|
|
707
|
+
# 8. OpenRouter Dynamic Models
|
|
708
|
+
openrouter_key = os.environ.get("OPENROUTER_API_KEY")
|
|
709
|
+
if openrouter_key:
|
|
710
|
+
try:
|
|
711
|
+
req = urllib.request.Request(
|
|
712
|
+
"https://openrouter.ai/api/v1/models",
|
|
713
|
+
headers={"Authorization": f"Bearer {openrouter_key}", "User-Agent": "K-CLI"},
|
|
714
|
+
)
|
|
715
|
+
with urllib.request.urlopen(req, timeout=3.0) as resp:
|
|
716
|
+
if resp.status == 200:
|
|
717
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
718
|
+
for item in data.get("data", [])[:20]:
|
|
719
|
+
m_id = item.get("id", "")
|
|
720
|
+
if m_id:
|
|
721
|
+
spec = ModelSpec(
|
|
722
|
+
id=f"openrouter/{m_id}",
|
|
723
|
+
name=f"OpenRouter: {m_id}",
|
|
724
|
+
provider=ModelProvider.OPENROUTER,
|
|
725
|
+
base_url="https://openrouter.ai/api/v1",
|
|
726
|
+
env_var_key="OPENROUTER_API_KEY",
|
|
727
|
+
description=item.get("description", f"OpenRouter model {m_id}")[:60],
|
|
728
|
+
)
|
|
729
|
+
self.registry[spec.id] = spec
|
|
730
|
+
except Exception:
|
|
731
|
+
pass
|
|
732
|
+
|
|
733
|
+
# 9. AWS Bedrock Models
|
|
734
|
+
if os.environ.get("AWS_ACCESS_KEY_ID") or os.environ.get("BEDROCK_MODEL_ID"):
|
|
735
|
+
for m_id, label in [
|
|
736
|
+
("anthropic.claude-3-5-sonnet-20241022-v2:0", "Bedrock Claude 3.5 Sonnet v2"),
|
|
737
|
+
("amazon.nova-pro-v1:0", "Bedrock Amazon Nova Pro"),
|
|
738
|
+
]:
|
|
739
|
+
spec = ModelSpec(
|
|
740
|
+
id=m_id,
|
|
741
|
+
name=f"AWS Bedrock: {label}",
|
|
742
|
+
provider=ModelProvider.BEDROCK,
|
|
743
|
+
description=f"AWS Bedrock Foundation Model {m_id}",
|
|
744
|
+
)
|
|
745
|
+
self.registry[spec.id] = spec
|
|
746
|
+
|
|
747
|
+
return list(self.registry.values())
|
|
748
|
+
|
|
749
|
+
def get_verified_active_models(self) -> List[ModelSpec]:
|
|
750
|
+
"""Returns only models whose provider is actively configured and reachable."""
|
|
751
|
+
self.discover_all_live_models()
|
|
752
|
+
active = []
|
|
753
|
+
for spec in self.registry.values():
|
|
754
|
+
if spec.is_local:
|
|
755
|
+
# If local, check if installed in ollama or custom local server
|
|
756
|
+
if spec.is_installed or spec.base_url:
|
|
757
|
+
active.append(spec)
|
|
758
|
+
else:
|
|
759
|
+
if self.is_provider_configured(spec.provider):
|
|
760
|
+
active.append(spec)
|
|
761
|
+
return active
|
|
762
|
+
|
|
763
|
+
def is_provider_configured(self, provider: ModelProvider) -> bool:
|
|
764
|
+
"""Checks if a provider has active API credentials or local service available."""
|
|
765
|
+
if provider == ModelProvider.OLLAMA:
|
|
766
|
+
try:
|
|
767
|
+
req = urllib.request.Request(f"{self.ollama_url}/api/tags", headers={"User-Agent": "K-CLI/1.0.0"})
|
|
768
|
+
with urllib.request.urlopen(req, timeout=1.0) as resp:
|
|
769
|
+
return resp.status == 200
|
|
770
|
+
except Exception:
|
|
771
|
+
return False
|
|
772
|
+
elif provider == ModelProvider.LLAMACPP:
|
|
773
|
+
try:
|
|
774
|
+
req = urllib.request.Request(f"{self.llamacpp_url}/v1/models", headers={"User-Agent": "K-CLI/1.0.0"})
|
|
775
|
+
with urllib.request.urlopen(req, timeout=1.0) as resp:
|
|
776
|
+
return resp.status == 200
|
|
777
|
+
except Exception:
|
|
778
|
+
return False
|
|
779
|
+
elif provider == ModelProvider.GEMINI:
|
|
780
|
+
return bool(os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"))
|
|
781
|
+
elif provider == ModelProvider.ANTHROPIC:
|
|
782
|
+
return bool(os.environ.get("ANTHROPIC_API_KEY"))
|
|
783
|
+
elif provider == ModelProvider.OPENAI:
|
|
784
|
+
return bool(os.environ.get("OPENAI_API_KEY"))
|
|
785
|
+
elif provider == ModelProvider.DEEPSEEK:
|
|
786
|
+
return bool(os.environ.get("DEEPSEEK_API_KEY"))
|
|
787
|
+
elif provider == ModelProvider.GROQ:
|
|
788
|
+
return bool(os.environ.get("GROQ_API_KEY"))
|
|
789
|
+
elif provider == ModelProvider.MISTRAL:
|
|
790
|
+
return bool(os.environ.get("MISTRAL_API_KEY"))
|
|
791
|
+
elif provider == ModelProvider.OPENROUTER:
|
|
792
|
+
return bool(os.environ.get("OPENROUTER_API_KEY"))
|
|
793
|
+
elif provider == ModelProvider.MOCK:
|
|
794
|
+
return True
|
|
795
|
+
return False
|
|
796
|
+
|
|
797
|
+
def list_models(
|
|
798
|
+
self,
|
|
799
|
+
provider: Optional[Union[ModelProvider, str]] = None,
|
|
800
|
+
local_only: bool = False,
|
|
801
|
+
) -> List[ModelSpec]:
|
|
802
|
+
"""Returns list of models matching optional provider or local filters."""
|
|
803
|
+
ollama_models = self.discover_local_ollama_models()
|
|
804
|
+
ollama_installed = {m["name"] for m in ollama_models}
|
|
805
|
+
results: List[ModelSpec] = []
|
|
806
|
+
|
|
807
|
+
prov_val = provider.value if isinstance(provider, ModelProvider) else (provider.lower() if provider else None)
|
|
808
|
+
|
|
809
|
+
for spec in self.registry.values():
|
|
810
|
+
if local_only and not spec.is_local:
|
|
811
|
+
continue
|
|
812
|
+
if prov_val and spec.provider.value != prov_val:
|
|
813
|
+
continue
|
|
814
|
+
|
|
815
|
+
# Update installed flag for Ollama models
|
|
816
|
+
if spec.provider == ModelProvider.OLLAMA:
|
|
817
|
+
spec.is_installed = (spec.id in ollama_installed or f"{spec.id}:latest" in ollama_installed)
|
|
818
|
+
|
|
819
|
+
results.append(spec)
|
|
820
|
+
|
|
821
|
+
return results
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def pull_model(
|
|
825
|
+
self,
|
|
826
|
+
model_name: str,
|
|
827
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
828
|
+
) -> bool:
|
|
829
|
+
"""Pulls a local model via Ollama daemon API."""
|
|
830
|
+
try:
|
|
831
|
+
payload = json.dumps({"name": model_name, "stream": True}).encode("utf-8")
|
|
832
|
+
req = urllib.request.Request(
|
|
833
|
+
f"{self.ollama_url}/api/pull",
|
|
834
|
+
data=payload,
|
|
835
|
+
headers={"Content-Type": "application/json", "User-Agent": "K-CLI/1.0.0"},
|
|
836
|
+
method="POST",
|
|
837
|
+
)
|
|
838
|
+
with urllib.request.urlopen(req, timeout=300.0) as resp:
|
|
839
|
+
for line in resp:
|
|
840
|
+
if not line:
|
|
841
|
+
continue
|
|
842
|
+
try:
|
|
843
|
+
chunk = json.loads(line.decode("utf-8"))
|
|
844
|
+
status = chunk.get("status", "")
|
|
845
|
+
completed = chunk.get("completed", 0)
|
|
846
|
+
total = chunk.get("total", 0)
|
|
847
|
+
if total > 0 and stream_callback:
|
|
848
|
+
pct = (completed / total) * 100.0
|
|
849
|
+
stream_callback(f"{status}: {pct:.1f}%\n")
|
|
850
|
+
elif stream_callback and status:
|
|
851
|
+
stream_callback(f"{status}\n")
|
|
852
|
+
except Exception:
|
|
853
|
+
pass
|
|
854
|
+
return True
|
|
855
|
+
except Exception as exc:
|
|
856
|
+
logger.error(f"Failed pulling model {model_name}: {exc}")
|
|
857
|
+
return False
|
|
858
|
+
|
|
859
|
+
def benchmark_model(
|
|
860
|
+
self,
|
|
861
|
+
model_name: str,
|
|
862
|
+
prompt: str = "Write a Python function to compute fibonacci numbers iteratively.",
|
|
863
|
+
driver: Optional[Any] = None,
|
|
864
|
+
) -> ModelBenchmarkResult:
|
|
865
|
+
"""
|
|
866
|
+
Executes benchmark test on model to calculate Time-to-First-Token,
|
|
867
|
+
throughput tokens/second, memory RSS footprint, and output correctness.
|
|
868
|
+
"""
|
|
869
|
+
from k_cli.core.llm_driver import LLMDriver
|
|
870
|
+
|
|
871
|
+
start_time = time.time()
|
|
872
|
+
first_token_time: Optional[float] = None
|
|
873
|
+
token_count = 0
|
|
874
|
+
collected_chunks: List[str] = []
|
|
875
|
+
|
|
876
|
+
def benchmark_stream(chunk: str) -> None:
|
|
877
|
+
nonlocal first_token_time, token_count
|
|
878
|
+
if first_token_time is None:
|
|
879
|
+
first_token_time = time.time()
|
|
880
|
+
token_count += 1
|
|
881
|
+
collected_chunks.append(chunk)
|
|
882
|
+
|
|
883
|
+
spec = self.resolve_model(model_name)
|
|
884
|
+
active_driver = driver or LLMDriver(model_name=spec.id if spec else model_name, mock_mode=False)
|
|
885
|
+
|
|
886
|
+
try:
|
|
887
|
+
res = active_driver.generate(prompt=prompt, stream_callback=benchmark_stream)
|
|
888
|
+
duration = time.time() - start_time
|
|
889
|
+
ttft = (first_token_time - start_time) if first_token_time else duration
|
|
890
|
+
tok_per_sec = (token_count / duration) if duration > 0 else 0.0
|
|
891
|
+
|
|
892
|
+
import psutil
|
|
893
|
+
ram_mb = psutil.Process().memory_info().rss / (1024 * 1024)
|
|
894
|
+
|
|
895
|
+
return ModelBenchmarkResult(
|
|
896
|
+
model_id=spec.id if spec else model_name,
|
|
897
|
+
provider=spec.provider.value if spec else "unknown",
|
|
898
|
+
success=True,
|
|
899
|
+
tokens_generated=token_count or len(res.split()),
|
|
900
|
+
duration_seconds=duration,
|
|
901
|
+
tokens_per_second=tok_per_sec or (len(res.split()) / max(duration, 0.001)),
|
|
902
|
+
time_to_first_token=ttft,
|
|
903
|
+
ram_rss_mb=ram_mb,
|
|
904
|
+
sample_output=res[:200],
|
|
905
|
+
)
|
|
906
|
+
except Exception as exc:
|
|
907
|
+
return ModelBenchmarkResult(
|
|
908
|
+
model_id=spec.id if spec else model_name,
|
|
909
|
+
provider=spec.provider.value if spec else "unknown",
|
|
910
|
+
success=False,
|
|
911
|
+
duration_seconds=time.time() - start_time,
|
|
912
|
+
error_message=str(exc),
|
|
913
|
+
)
|