kivi-cli 2.0.5__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.
- kivi_cli-2.0.5.dist-info/METADATA +17 -0
- kivi_cli-2.0.5.dist-info/RECORD +48 -0
- kivi_cli-2.0.5.dist-info/WHEEL +5 -0
- kivi_cli-2.0.5.dist-info/entry_points.txt +2 -0
- kivi_cli-2.0.5.dist-info/top_level.txt +1 -0
- kiviai/__init__.py +0 -0
- kiviai/backend.py +1380 -0
- kiviai/index.html +6207 -0
- kiviai/services/__init__.py +0 -0
- kiviai/services/git_service.py +219 -0
- kiviai/services/monitor.py +97 -0
- kiviai/services/providers/__init__.py +0 -0
- kiviai/services/providers/base.py +131 -0
- kiviai/services/providers/claude.py +125 -0
- kiviai/services/providers/copilot.py +251 -0
- kiviai/services/providers/gemini.py +120 -0
- kiviai/services/providers/inhouse.py +130 -0
- kiviai/services/providers/kivi.py +108 -0
- kiviai/services/providers/model_costs.py +150 -0
- kiviai/services/providers/openai_agent.py +113 -0
- kiviai/services/providers/opencode.py +157 -0
- kiviai/services/providers/orchestrator_provider.py +163 -0
- kiviai/services/providers/registry.py +95 -0
- kiviai/services/providers/vllm.py +319 -0
- kiviai/services/scheduler.py +244 -0
- kiviai/services/tools/__init__.py +1 -0
- kiviai/services/tools/copilot_tools/__init__.py +92 -0
- kiviai/services/tools/copilot_tools/agent_orchestration/__init__.py +17 -0
- kiviai/services/tools/copilot_tools/agent_orchestration/tools.py +296 -0
- kiviai/services/tools/copilot_tools/bash_execution/__init__.py +20 -0
- kiviai/services/tools/copilot_tools/bash_execution/sessions.py +335 -0
- kiviai/services/tools/copilot_tools/code_search/__init__.py +10 -0
- kiviai/services/tools/copilot_tools/code_search/tools.py +137 -0
- kiviai/services/tools/copilot_tools/file_operations/__init__.py +12 -0
- kiviai/services/tools/copilot_tools/file_operations/tools.py +197 -0
- kiviai/services/tools/copilot_tools/markdown/__init__.py +41 -0
- kiviai/services/tools/copilot_tools/markdown/custom_markdownify.py +774 -0
- kiviai/services/tools/copilot_tools/markdown/mrkdwn_analysis.py +1155 -0
- kiviai/services/tools/copilot_tools/markdown/tools.py +357 -0
- kiviai/services/tools/copilot_tools/session_workflow/__init__.py +17 -0
- kiviai/services/tools/copilot_tools/session_workflow/tools.py +199 -0
- kiviai/services/tools/copilot_tools/web/__init__.py +37 -0
- kiviai/services/tools/copilot_tools/web/_fetcher.py +39 -0
- kiviai/services/tools/copilot_tools/web/_store.py +122 -0
- kiviai/services/tools/copilot_tools/web/fetch.py +20 -0
- kiviai/services/tools/copilot_tools/web/search.py +116 -0
- kiviai/services/tools/copilot_tools/web/store_tools.py +92 -0
- kiviai/services/tools/tool_manager.py +239 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""GitHub Copilot provider — uses the official copilot-sdk Python package.
|
|
2
|
+
|
|
3
|
+
Supports streaming, tool use, sessions, and multiple models
|
|
4
|
+
(gpt-5.4, gpt-5-mini, claude-sonnet-4.6, claude-haiku-4.5, claude-opus-4.6).
|
|
5
|
+
|
|
6
|
+
Requires: pip install github-copilot-sdk
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
from typing import AsyncIterator
|
|
14
|
+
|
|
15
|
+
from .base import BaseProvider, ProviderConfig, ProviderEvent, EventType
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("cc-ui.copilot")
|
|
18
|
+
|
|
19
|
+
# Default model when none specified
|
|
20
|
+
DEFAULT_MODEL = "gpt-5.4"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CopilotProvider(BaseProvider):
|
|
24
|
+
name = "copilot"
|
|
25
|
+
display_name = "GitHub Copilot"
|
|
26
|
+
description = "GitHub Copilot SDK with full agentic capabilities. Streaming, tool use, sessions, and multi-model support (GPT-5.4, GPT-5 mini, Claude Sonnet/Haiku/Opus). Runs the bundled Copilot CLI under the hood."
|
|
27
|
+
supports_streaming = True
|
|
28
|
+
supports_tools = True
|
|
29
|
+
supports_sessions = True
|
|
30
|
+
supports_agents = True
|
|
31
|
+
available_models = [
|
|
32
|
+
"gpt-5.4", "gpt-5-mini", "gpt-4.1", "gpt-4.1-mini",
|
|
33
|
+
"claude-sonnet-4.6", "claude-haiku-4.5", "claude-opus-4.6",
|
|
34
|
+
"o3-mini", "o4-mini",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self._stop = False
|
|
39
|
+
self._client = None
|
|
40
|
+
self._session = None
|
|
41
|
+
|
|
42
|
+
async def run(self, prompt: str, config: ProviderConfig, history=None) -> AsyncIterator[ProviderEvent]:
|
|
43
|
+
# Lazy import — only fail when actually used
|
|
44
|
+
try:
|
|
45
|
+
# Ensure the SDK path is importable
|
|
46
|
+
sdk_path = None
|
|
47
|
+
try:
|
|
48
|
+
import copilot as _cp
|
|
49
|
+
except ImportError:
|
|
50
|
+
import glob
|
|
51
|
+
paths = glob.glob("/home/ntlpt24/.local/lib/python*/site-packages")
|
|
52
|
+
for p in paths:
|
|
53
|
+
if p not in sys.path:
|
|
54
|
+
sys.path.insert(0, p)
|
|
55
|
+
sdk_path = p
|
|
56
|
+
|
|
57
|
+
from copilot import CopilotClient
|
|
58
|
+
from copilot.session import PermissionHandler
|
|
59
|
+
from copilot.generated.session_events import SessionEventType
|
|
60
|
+
except ImportError as e:
|
|
61
|
+
yield ProviderEvent(
|
|
62
|
+
type=EventType.ERROR,
|
|
63
|
+
content=f"Copilot SDK not installed: {e}\nInstall with: pip install github-copilot-sdk",
|
|
64
|
+
)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
self._stop = False
|
|
68
|
+
model = config.model or config.extra.get("copilot_model") or DEFAULT_MODEL
|
|
69
|
+
session_id = config.session_id
|
|
70
|
+
text_buf = ""
|
|
71
|
+
total_cost = 0.0
|
|
72
|
+
usage = {}
|
|
73
|
+
resolved_session_id = None
|
|
74
|
+
total_input_tokens = 0
|
|
75
|
+
total_output_tokens = 0
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
self._client = CopilotClient()
|
|
79
|
+
await self._client.start()
|
|
80
|
+
|
|
81
|
+
# Create or resume session
|
|
82
|
+
session_kwargs = dict(
|
|
83
|
+
on_permission_request=PermissionHandler.approve_all,
|
|
84
|
+
model=model,
|
|
85
|
+
streaming=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if session_id:
|
|
89
|
+
self._session = await self._client.resume_session(
|
|
90
|
+
session_id,
|
|
91
|
+
on_permission_request=PermissionHandler.approve_all,
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
self._session = await self._client.create_session(**session_kwargs)
|
|
95
|
+
|
|
96
|
+
resolved_session_id = getattr(self._session, 'session_id', None) or session_id
|
|
97
|
+
|
|
98
|
+
# Collect events via callback
|
|
99
|
+
done_event = asyncio.Event()
|
|
100
|
+
events_queue: asyncio.Queue = asyncio.Queue()
|
|
101
|
+
|
|
102
|
+
def on_event(event):
|
|
103
|
+
events_queue.put_nowait(event)
|
|
104
|
+
etype = getattr(event, 'type', None)
|
|
105
|
+
if etype and etype.value in ('session.idle', 'session.error', 'session.shutdown'):
|
|
106
|
+
done_event.set()
|
|
107
|
+
|
|
108
|
+
self._session.on(on_event)
|
|
109
|
+
await self._session.send(prompt)
|
|
110
|
+
|
|
111
|
+
# Process events until done
|
|
112
|
+
while not done_event.is_set() or not events_queue.empty():
|
|
113
|
+
if self._stop:
|
|
114
|
+
yield ProviderEvent(type=EventType.TEXT, content="⏹ *stopped*")
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
event = await asyncio.wait_for(events_queue.get(), timeout=1.0)
|
|
119
|
+
except asyncio.TimeoutError:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
etype = getattr(event, 'type', None)
|
|
123
|
+
if not etype:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
ev_type = etype.value
|
|
127
|
+
data = getattr(event, 'data', None)
|
|
128
|
+
|
|
129
|
+
if ev_type == 'assistant.message_delta' and data:
|
|
130
|
+
delta = getattr(data, 'delta_content', '') or ''
|
|
131
|
+
if delta:
|
|
132
|
+
text_buf += delta
|
|
133
|
+
yield ProviderEvent(type=EventType.TEXT, content=delta)
|
|
134
|
+
|
|
135
|
+
elif ev_type == 'assistant.reasoning_delta' and data:
|
|
136
|
+
delta = getattr(data, 'delta_content', '') or ''
|
|
137
|
+
if delta:
|
|
138
|
+
yield ProviderEvent(type=EventType.THINKING, content=delta)
|
|
139
|
+
|
|
140
|
+
elif ev_type == 'tool.execution_start' and data:
|
|
141
|
+
tool_name = getattr(data, 'tool_name', '') or 'tool'
|
|
142
|
+
args = getattr(data, 'input', '') or ''
|
|
143
|
+
yield ProviderEvent(
|
|
144
|
+
type=EventType.TOOL_START,
|
|
145
|
+
metadata={"title": f"⚙ {tool_name}", "args": str(args)[:500]},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
elif ev_type == 'tool.execution_complete' and data:
|
|
149
|
+
output = getattr(data, 'output', '') or ''
|
|
150
|
+
is_error = getattr(data, 'is_error', False)
|
|
151
|
+
yield ProviderEvent(
|
|
152
|
+
type=EventType.TOOL_RESULT,
|
|
153
|
+
content=str(output)[:600],
|
|
154
|
+
metadata={"is_error": bool(is_error)},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
elif ev_type == 'assistant.usage' and data:
|
|
158
|
+
u = getattr(data, 'usage', None)
|
|
159
|
+
if u:
|
|
160
|
+
it = getattr(u, 'input_tokens', 0) or 0
|
|
161
|
+
ot = getattr(u, 'output_tokens', 0) or 0
|
|
162
|
+
total_input_tokens += it
|
|
163
|
+
total_output_tokens += ot
|
|
164
|
+
usage["input_tokens"] = total_input_tokens
|
|
165
|
+
usage["output_tokens"] = total_output_tokens
|
|
166
|
+
|
|
167
|
+
elif ev_type == 'subagent.started' and data:
|
|
168
|
+
agent_id = getattr(data, 'session_id', '') or ''
|
|
169
|
+
label = getattr(data, 'label', '') or f'Sub-agent {agent_id[:8]}'
|
|
170
|
+
yield ProviderEvent(
|
|
171
|
+
type=EventType.AGENT_GROUP,
|
|
172
|
+
metadata={"agent_id": agent_id, "agent_label": label, "status": "running", "children": []},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
elif ev_type == 'subagent.completed' and data:
|
|
176
|
+
agent_id = getattr(data, 'session_id', '') or ''
|
|
177
|
+
yield ProviderEvent(
|
|
178
|
+
type=EventType.AGENT_GROUP,
|
|
179
|
+
metadata={"agent_id": agent_id, "status": "done"},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
elif ev_type == 'session.error' and data:
|
|
183
|
+
msg = getattr(data, 'message', '') or str(data)
|
|
184
|
+
yield ProviderEvent(type=EventType.ERROR, content=f"Copilot error: {msg}")
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
elif ev_type == 'session.idle':
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
log.exception("Copilot provider error: %s", e)
|
|
192
|
+
yield ProviderEvent(type=EventType.ERROR, content=str(e))
|
|
193
|
+
finally:
|
|
194
|
+
# Cleanup
|
|
195
|
+
try:
|
|
196
|
+
if self._session:
|
|
197
|
+
await self._session.disconnect()
|
|
198
|
+
if self._client:
|
|
199
|
+
await self._client.stop()
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
self._session = None
|
|
203
|
+
self._client = None
|
|
204
|
+
|
|
205
|
+
# Emit cost/session info with calculated cost
|
|
206
|
+
from .model_costs import estimate_cost
|
|
207
|
+
if total_input_tokens or total_output_tokens:
|
|
208
|
+
total_cost = estimate_cost(
|
|
209
|
+
model,
|
|
210
|
+
input_tokens=total_input_tokens,
|
|
211
|
+
output_tokens=total_output_tokens,
|
|
212
|
+
)
|
|
213
|
+
if resolved_session_id or usage:
|
|
214
|
+
yield ProviderEvent(
|
|
215
|
+
type=EventType.COST,
|
|
216
|
+
metadata={
|
|
217
|
+
"session_id": resolved_session_id,
|
|
218
|
+
"total_cost_usd": total_cost,
|
|
219
|
+
"usage": usage,
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
224
|
+
|
|
225
|
+
async def stop(self):
|
|
226
|
+
self._stop = True
|
|
227
|
+
if self._session:
|
|
228
|
+
try:
|
|
229
|
+
await self._session.disconnect()
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
async def health_check(self):
|
|
234
|
+
try:
|
|
235
|
+
sdk_ok = False
|
|
236
|
+
try:
|
|
237
|
+
import copilot
|
|
238
|
+
sdk_ok = True
|
|
239
|
+
except ImportError:
|
|
240
|
+
import sys, glob
|
|
241
|
+
for p in glob.glob("/home/ntlpt24/.local/lib/python*/site-packages"):
|
|
242
|
+
if p not in sys.path:
|
|
243
|
+
sys.path.insert(0, p)
|
|
244
|
+
from copilot import CopilotClient
|
|
245
|
+
sdk_ok = True
|
|
246
|
+
|
|
247
|
+
if sdk_ok:
|
|
248
|
+
return {"status": "ok", "provider": self.name, "sdk": "github-copilot-sdk"}
|
|
249
|
+
except Exception as e:
|
|
250
|
+
return {"status": "unavailable", "provider": self.name, "error": str(e)}
|
|
251
|
+
return {"status": "unavailable", "provider": self.name}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Google Gemini provider — uses google-genai SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import AsyncIterator
|
|
8
|
+
|
|
9
|
+
from .base import BaseProvider, ProviderConfig, ProviderEvent, EventType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GeminiProvider(BaseProvider):
|
|
13
|
+
name = "gemini"
|
|
14
|
+
display_name = "Google Gemini"
|
|
15
|
+
description = "Google's multimodal AI. Fast streaming text generation via the Gemini API. Best for quick questions, summaries, and brainstorming. Requires GEMINI_API_KEY."
|
|
16
|
+
supports_streaming = True
|
|
17
|
+
supports_tools = False
|
|
18
|
+
supports_sessions = False
|
|
19
|
+
available_models = [
|
|
20
|
+
"gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash",
|
|
21
|
+
"gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self._stop = False
|
|
26
|
+
|
|
27
|
+
async def run(self, prompt: str, config: ProviderConfig, history=None) -> AsyncIterator[ProviderEvent]:
|
|
28
|
+
self._stop = False
|
|
29
|
+
|
|
30
|
+
api_key = config.api_key or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY", "")
|
|
31
|
+
model = config.model or "gemini-2.5-flash"
|
|
32
|
+
|
|
33
|
+
if not api_key:
|
|
34
|
+
yield ProviderEvent(type=EventType.ERROR, content="GEMINI_API_KEY not set")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from google import genai
|
|
39
|
+
from google.genai import types
|
|
40
|
+
|
|
41
|
+
client = genai.Client(api_key=api_key)
|
|
42
|
+
|
|
43
|
+
contents = []
|
|
44
|
+
if history:
|
|
45
|
+
for h in history:
|
|
46
|
+
if h.get("role") in ("user", "assistant") and not h.get("metadata"):
|
|
47
|
+
role = "user" if h["role"] == "user" else "model"
|
|
48
|
+
contents.append(types.Content(role=role, parts=[types.Part(text=h["content"])]))
|
|
49
|
+
contents.append(types.Content(role="user", parts=[types.Part(text=prompt)]))
|
|
50
|
+
|
|
51
|
+
response = await asyncio.to_thread(
|
|
52
|
+
lambda: client.models.generate_content_stream(
|
|
53
|
+
model=model,
|
|
54
|
+
contents=contents,
|
|
55
|
+
config=types.GenerateContentConfig(
|
|
56
|
+
temperature=0.7,
|
|
57
|
+
max_output_tokens=4096,
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
text_buf = ""
|
|
63
|
+
total_input_tokens = 0
|
|
64
|
+
total_output_tokens = 0
|
|
65
|
+
for chunk in response:
|
|
66
|
+
if self._stop:
|
|
67
|
+
yield ProviderEvent(type=EventType.TEXT, content="⏹ *stopped*")
|
|
68
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
if chunk.text:
|
|
72
|
+
text_buf += chunk.text
|
|
73
|
+
yield ProviderEvent(type=EventType.TEXT, content=chunk.text)
|
|
74
|
+
|
|
75
|
+
# Capture usage metadata (available in the final chunk or via usage_metadata)
|
|
76
|
+
if hasattr(chunk, 'usage_metadata') and chunk.usage_metadata:
|
|
77
|
+
um = chunk.usage_metadata
|
|
78
|
+
total_input_tokens += getattr(um, 'prompt_token_count', 0) or 0
|
|
79
|
+
total_output_tokens += getattr(um, 'candidates_token_count', 0) or 0
|
|
80
|
+
|
|
81
|
+
except ImportError:
|
|
82
|
+
yield ProviderEvent(
|
|
83
|
+
type=EventType.ERROR,
|
|
84
|
+
content="google-genai package required: pip install google-genai",
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
except Exception as e:
|
|
88
|
+
yield ProviderEvent(type=EventType.ERROR, content=str(e))
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
# Emit cost event with calculated cost from usage
|
|
92
|
+
from .model_costs import estimate_cost
|
|
93
|
+
usage = {
|
|
94
|
+
"input_tokens": total_input_tokens,
|
|
95
|
+
"output_tokens": total_output_tokens,
|
|
96
|
+
}
|
|
97
|
+
total_cost = estimate_cost(
|
|
98
|
+
model,
|
|
99
|
+
input_tokens=total_input_tokens,
|
|
100
|
+
output_tokens=total_output_tokens,
|
|
101
|
+
)
|
|
102
|
+
yield ProviderEvent(
|
|
103
|
+
type=EventType.COST,
|
|
104
|
+
metadata={
|
|
105
|
+
"total_cost_usd": total_cost,
|
|
106
|
+
"usage": usage,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
111
|
+
|
|
112
|
+
async def stop(self):
|
|
113
|
+
self._stop = True
|
|
114
|
+
|
|
115
|
+
async def health_check(self):
|
|
116
|
+
api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY", "")
|
|
117
|
+
return {
|
|
118
|
+
"status": "ok" if api_key else "no_api_key",
|
|
119
|
+
"provider": self.name,
|
|
120
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""In-house AI framework provider — wraps lib/ai.py AIAgent."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import AsyncIterator
|
|
9
|
+
|
|
10
|
+
from .base import BaseProvider, ProviderConfig, ProviderEvent, EventType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InhouseProvider(BaseProvider):
|
|
14
|
+
name = "inhouse"
|
|
15
|
+
display_name = "AI Framework (In-house)"
|
|
16
|
+
description = "Custom AI framework from lab/src/ai. Unified interface for multiple backends (OpenAI, Anthropic, local). Supports tool calling, structured output, and batch processing."
|
|
17
|
+
supports_streaming = True
|
|
18
|
+
supports_tools = True
|
|
19
|
+
supports_sessions = False
|
|
20
|
+
available_models = ["auto"]
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self._stop = False
|
|
24
|
+
|
|
25
|
+
async def run(self, prompt: str, config: ProviderConfig, history=None) -> AsyncIterator[ProviderEvent]:
|
|
26
|
+
self._stop = False
|
|
27
|
+
|
|
28
|
+
base_url = config.base_url or config.extra.get("base_url") or os.environ.get("OPENAI_BASE_URL", "http://localhost:9999/v1")
|
|
29
|
+
api_key = config.api_key or config.extra.get("api_key") or "dummy"
|
|
30
|
+
model = config.model or config.extra.get("model") or ""
|
|
31
|
+
mode = config.extra.get("ai_mode") or "instruct_general"
|
|
32
|
+
use_tools = config.extra.get("use_tools", False)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# Import from our lib
|
|
36
|
+
here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
37
|
+
lib_path = os.path.join(here, "lib")
|
|
38
|
+
if lib_path not in sys.path:
|
|
39
|
+
sys.path.insert(0, lib_path)
|
|
40
|
+
|
|
41
|
+
from ai import AIAgent, AIConfig, Chat, Text, ToolCall, ToolResult, StepResult, AgentResult, DoneEvent
|
|
42
|
+
from ai import read_file, write_file, edit_file, bash_run, glob_files, grep_files
|
|
43
|
+
|
|
44
|
+
ai_config = AIConfig(base_url=base_url, api_key=api_key)
|
|
45
|
+
tools = None
|
|
46
|
+
if use_tools:
|
|
47
|
+
tools = [read_file, write_file, edit_file, bash_run, glob_files, grep_files]
|
|
48
|
+
agent = AIAgent(config=ai_config, tools=tools)
|
|
49
|
+
|
|
50
|
+
# Build chat with history
|
|
51
|
+
messages = []
|
|
52
|
+
if history:
|
|
53
|
+
for h in history:
|
|
54
|
+
if h.get("role") in ("user", "assistant") and not h.get("metadata"):
|
|
55
|
+
messages.append({"role": h["role"], "content": h["content"]})
|
|
56
|
+
messages.append({"role": "user", "content": prompt})
|
|
57
|
+
|
|
58
|
+
chat = Chat.__new__(Chat)
|
|
59
|
+
chat._messages = messages
|
|
60
|
+
|
|
61
|
+
# Run in thread to avoid blocking
|
|
62
|
+
def _run_sync():
|
|
63
|
+
events = []
|
|
64
|
+
if use_tools:
|
|
65
|
+
for event in agent.forward(chat, model=model, mode=mode):
|
|
66
|
+
events.append(event)
|
|
67
|
+
else:
|
|
68
|
+
for event in agent.step(chat, model=model, mode=mode):
|
|
69
|
+
events.append(event)
|
|
70
|
+
return events
|
|
71
|
+
|
|
72
|
+
events = await asyncio.to_thread(_run_sync)
|
|
73
|
+
|
|
74
|
+
for event in events:
|
|
75
|
+
if self._stop:
|
|
76
|
+
yield ProviderEvent(type=EventType.TEXT, content="⏹ *stopped*")
|
|
77
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
if isinstance(event, Text):
|
|
81
|
+
yield ProviderEvent(type=EventType.TEXT, content=event.content)
|
|
82
|
+
elif isinstance(event, ToolCall):
|
|
83
|
+
yield ProviderEvent(
|
|
84
|
+
type=EventType.TOOL_START,
|
|
85
|
+
metadata={"title": f"⚙ {event.name}", "args": event.arguments},
|
|
86
|
+
)
|
|
87
|
+
elif isinstance(event, ToolResult):
|
|
88
|
+
yield ProviderEvent(
|
|
89
|
+
type=EventType.TOOL_RESULT,
|
|
90
|
+
content=event.result[:600],
|
|
91
|
+
metadata={"is_error": False},
|
|
92
|
+
)
|
|
93
|
+
elif isinstance(event, StepResult):
|
|
94
|
+
from .model_costs import estimate_cost
|
|
95
|
+
it = event.input_tokens or 0
|
|
96
|
+
ot = event.output_tokens or 0
|
|
97
|
+
step_cost = estimate_cost(
|
|
98
|
+
model or "auto",
|
|
99
|
+
input_tokens=it,
|
|
100
|
+
output_tokens=ot,
|
|
101
|
+
)
|
|
102
|
+
yield ProviderEvent(
|
|
103
|
+
type=EventType.COST,
|
|
104
|
+
metadata={
|
|
105
|
+
"total_cost_usd": step_cost,
|
|
106
|
+
"usage": {
|
|
107
|
+
"input_tokens": it,
|
|
108
|
+
"output_tokens": ot,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
yield ProviderEvent(type=EventType.ERROR, content=str(e))
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
118
|
+
|
|
119
|
+
async def stop(self):
|
|
120
|
+
self._stop = True
|
|
121
|
+
|
|
122
|
+
async def health_check(self):
|
|
123
|
+
try:
|
|
124
|
+
here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
125
|
+
lib_path = os.path.join(here, "lib")
|
|
126
|
+
if os.path.exists(os.path.join(lib_path, "ai.py")):
|
|
127
|
+
return {"status": "ok", "provider": self.name}
|
|
128
|
+
return {"status": "missing_lib", "provider": self.name}
|
|
129
|
+
except Exception:
|
|
130
|
+
return {"status": "error", "provider": self.name}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Kivi provider — local model via OpenAI-compatible API (same as vLLM but separate config)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import AsyncIterator
|
|
6
|
+
|
|
7
|
+
from .base import BaseProvider, ProviderConfig, ProviderEvent, EventType
|
|
8
|
+
|
|
9
|
+
DEFAULT_KIVI_URL = "http://localhost:9999"
|
|
10
|
+
DEFAULT_KIVI_MODEL = "mock-model-v1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KiviProvider(BaseProvider):
|
|
14
|
+
name = "kivi"
|
|
15
|
+
display_name = "Kivi (Local)"
|
|
16
|
+
description = "Lightweight local inference via OpenAI-compatible API. Similar to vLLM but for smaller models. Supports streaming and tool-use formatted responses."
|
|
17
|
+
supports_streaming = True
|
|
18
|
+
supports_tools = True
|
|
19
|
+
supports_sessions = False
|
|
20
|
+
available_models = ["auto"]
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self._stop = False
|
|
24
|
+
|
|
25
|
+
async def run(self, prompt: str, config: ProviderConfig, history=None) -> AsyncIterator[ProviderEvent]:
|
|
26
|
+
self._stop = False
|
|
27
|
+
base_url = config.base_url or config.extra.get("kivi_url") or DEFAULT_KIVI_URL
|
|
28
|
+
api_key = config.api_key or config.extra.get("kivi_key") or "dummy"
|
|
29
|
+
model = config.model or config.extra.get("kivi_model") or DEFAULT_KIVI_MODEL
|
|
30
|
+
|
|
31
|
+
if not base_url.endswith("/v1"):
|
|
32
|
+
base_url = base_url.rstrip("/") + "/v1"
|
|
33
|
+
|
|
34
|
+
messages = []
|
|
35
|
+
if history:
|
|
36
|
+
for h in history:
|
|
37
|
+
if h.get("role") in ("user", "assistant") and not h.get("metadata"):
|
|
38
|
+
messages.append({"role": h["role"], "content": h["content"]})
|
|
39
|
+
messages.append({"role": "user", "content": prompt})
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from openai import AsyncOpenAI
|
|
43
|
+
|
|
44
|
+
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
|
|
45
|
+
|
|
46
|
+
tools = None
|
|
47
|
+
if config.extra.get("tools"):
|
|
48
|
+
tools = config.extra["tools"]
|
|
49
|
+
|
|
50
|
+
kwargs = {
|
|
51
|
+
"model": model,
|
|
52
|
+
"messages": messages,
|
|
53
|
+
"stream": True,
|
|
54
|
+
"max_tokens": 4096,
|
|
55
|
+
}
|
|
56
|
+
if tools:
|
|
57
|
+
kwargs["tools"] = tools
|
|
58
|
+
|
|
59
|
+
stream = await client.chat.completions.create(**kwargs)
|
|
60
|
+
|
|
61
|
+
async for chunk in stream:
|
|
62
|
+
if self._stop:
|
|
63
|
+
yield ProviderEvent(type=EventType.TEXT, content="⏹ *stopped*")
|
|
64
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
delta = chunk.choices[0].delta if chunk.choices else None
|
|
68
|
+
if delta:
|
|
69
|
+
if delta.content:
|
|
70
|
+
yield ProviderEvent(type=EventType.TEXT, content=delta.content)
|
|
71
|
+
if delta.tool_calls:
|
|
72
|
+
for tc in delta.tool_calls:
|
|
73
|
+
if tc.function and tc.function.name:
|
|
74
|
+
yield ProviderEvent(
|
|
75
|
+
type=EventType.TOOL_START,
|
|
76
|
+
metadata={"title": f"⚙ {tc.function.name}", "args": tc.function.arguments or ""},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if chunk.choices and chunk.choices[0].finish_reason:
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
# Emit cost event (self-hosted = $0)
|
|
83
|
+
yield ProviderEvent(
|
|
84
|
+
type=EventType.COST,
|
|
85
|
+
metadata={"total_cost_usd": 0.0, "usage": {}},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
except ImportError:
|
|
89
|
+
yield ProviderEvent(type=EventType.ERROR, content="openai package required: pip install openai")
|
|
90
|
+
return
|
|
91
|
+
except Exception as e:
|
|
92
|
+
yield ProviderEvent(type=EventType.ERROR, content=str(e))
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
96
|
+
|
|
97
|
+
async def stop(self):
|
|
98
|
+
self._stop = True
|
|
99
|
+
|
|
100
|
+
async def health_check(self):
|
|
101
|
+
base_url = DEFAULT_KIVI_URL
|
|
102
|
+
try:
|
|
103
|
+
import httpx
|
|
104
|
+
async with httpx.AsyncClient(timeout=3) as client:
|
|
105
|
+
r = await client.get(f"{base_url}/health")
|
|
106
|
+
return {"status": "ok" if r.status_code == 200 else "error", "provider": self.name}
|
|
107
|
+
except Exception:
|
|
108
|
+
return {"status": "unavailable", "provider": self.name}
|