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.
Files changed (48) hide show
  1. kivi_cli-2.0.5.dist-info/METADATA +17 -0
  2. kivi_cli-2.0.5.dist-info/RECORD +48 -0
  3. kivi_cli-2.0.5.dist-info/WHEEL +5 -0
  4. kivi_cli-2.0.5.dist-info/entry_points.txt +2 -0
  5. kivi_cli-2.0.5.dist-info/top_level.txt +1 -0
  6. kiviai/__init__.py +0 -0
  7. kiviai/backend.py +1380 -0
  8. kiviai/index.html +6207 -0
  9. kiviai/services/__init__.py +0 -0
  10. kiviai/services/git_service.py +219 -0
  11. kiviai/services/monitor.py +97 -0
  12. kiviai/services/providers/__init__.py +0 -0
  13. kiviai/services/providers/base.py +131 -0
  14. kiviai/services/providers/claude.py +125 -0
  15. kiviai/services/providers/copilot.py +251 -0
  16. kiviai/services/providers/gemini.py +120 -0
  17. kiviai/services/providers/inhouse.py +130 -0
  18. kiviai/services/providers/kivi.py +108 -0
  19. kiviai/services/providers/model_costs.py +150 -0
  20. kiviai/services/providers/openai_agent.py +113 -0
  21. kiviai/services/providers/opencode.py +157 -0
  22. kiviai/services/providers/orchestrator_provider.py +163 -0
  23. kiviai/services/providers/registry.py +95 -0
  24. kiviai/services/providers/vllm.py +319 -0
  25. kiviai/services/scheduler.py +244 -0
  26. kiviai/services/tools/__init__.py +1 -0
  27. kiviai/services/tools/copilot_tools/__init__.py +92 -0
  28. kiviai/services/tools/copilot_tools/agent_orchestration/__init__.py +17 -0
  29. kiviai/services/tools/copilot_tools/agent_orchestration/tools.py +296 -0
  30. kiviai/services/tools/copilot_tools/bash_execution/__init__.py +20 -0
  31. kiviai/services/tools/copilot_tools/bash_execution/sessions.py +335 -0
  32. kiviai/services/tools/copilot_tools/code_search/__init__.py +10 -0
  33. kiviai/services/tools/copilot_tools/code_search/tools.py +137 -0
  34. kiviai/services/tools/copilot_tools/file_operations/__init__.py +12 -0
  35. kiviai/services/tools/copilot_tools/file_operations/tools.py +197 -0
  36. kiviai/services/tools/copilot_tools/markdown/__init__.py +41 -0
  37. kiviai/services/tools/copilot_tools/markdown/custom_markdownify.py +774 -0
  38. kiviai/services/tools/copilot_tools/markdown/mrkdwn_analysis.py +1155 -0
  39. kiviai/services/tools/copilot_tools/markdown/tools.py +357 -0
  40. kiviai/services/tools/copilot_tools/session_workflow/__init__.py +17 -0
  41. kiviai/services/tools/copilot_tools/session_workflow/tools.py +199 -0
  42. kiviai/services/tools/copilot_tools/web/__init__.py +37 -0
  43. kiviai/services/tools/copilot_tools/web/_fetcher.py +39 -0
  44. kiviai/services/tools/copilot_tools/web/_store.py +122 -0
  45. kiviai/services/tools/copilot_tools/web/fetch.py +20 -0
  46. kiviai/services/tools/copilot_tools/web/search.py +116 -0
  47. kiviai/services/tools/copilot_tools/web/store_tools.py +92 -0
  48. 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}