kivi-cli 2.0.6__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.6.dist-info/METADATA +17 -0
- kivi_cli-2.0.6.dist-info/RECORD +47 -0
- kivi_cli-2.0.6.dist-info/WHEEL +5 -0
- kivi_cli-2.0.6.dist-info/entry_points.txt +2 -0
- kivi_cli-2.0.6.dist-info/top_level.txt +1 -0
- kiviai/__init__.py +0 -0
- kiviai/backend.py +1372 -0
- kiviai/index.html +6172 -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/inhouse.py +130 -0
- kiviai/services/providers/kivi.py +108 -0
- kiviai/services/providers/model_costs.py +137 -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 +89 -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,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}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized per-token pricing for all supported models.
|
|
3
|
+
|
|
4
|
+
All costs are in USD per million tokens.
|
|
5
|
+
Input = cost to process the prompt/context
|
|
6
|
+
Output = cost to generate the response
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from model_costs import COSTS, estimate_cost
|
|
10
|
+
cost = estimate_cost("anthropic/claude-haiku-4.5-20241022", input_tokens=1000, output_tokens=500)
|
|
11
|
+
# => 0.0015 ($0.001 input + $0.0025 output)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# ── Pricing: $ per 1,000,000 tokens ──────────────────────────────
|
|
15
|
+
|
|
16
|
+
COSTS: dict[str, tuple[float, float]] = {
|
|
17
|
+
# ── Anthropic ────────────────────────────────────────────────
|
|
18
|
+
# Model (input, output)
|
|
19
|
+
"claude-haiku-4.5": (1.00, 5.00), # Claude 4.5 Haiku / 3.5 Haiku
|
|
20
|
+
"claude-haiku-4-5": (1.00, 5.00),
|
|
21
|
+
"claude-haiku-3.5": (1.00, 5.00),
|
|
22
|
+
"claude-sonnet-4": (3.00, 15.00), # Claude 4 Sonnet
|
|
23
|
+
"claude-sonnet-4-20250514": (3.00, 15.00),
|
|
24
|
+
"claude-sonnet-4-20250514:20250514": (3.00, 15.00),
|
|
25
|
+
"claude-sonnet-4-20250514-thinking": (3.00, 15.00),
|
|
26
|
+
"claude-sonnet-4-6": (3.00, 15.00),
|
|
27
|
+
"claude-sonnet-4-20250514-6": (3.00, 15.00),
|
|
28
|
+
"claude-sonnet-4.5": (3.00, 15.00),
|
|
29
|
+
"claude-sonnet-4-5": (3.00, 15.00),
|
|
30
|
+
"claude-sonnet-4-20250514-5": (3.00, 15.00),
|
|
31
|
+
"claude-sonnet-4-20250514-6": (3.00, 15.00),
|
|
32
|
+
"claude-opus-4": (15.00, 75.00), # Claude 4 Opus
|
|
33
|
+
"claude-opus-4-20250514": (15.00, 75.00),
|
|
34
|
+
"claude-opus-4-6": (15.00, 75.00),
|
|
35
|
+
"claude-opus-4-20250514-6": (15.00, 75.00),
|
|
36
|
+
"sonnet": (3.00, 15.00), # Claude short names
|
|
37
|
+
"opus": (15.00, 75.00),
|
|
38
|
+
"haiku": (1.00, 5.00),
|
|
39
|
+
|
|
40
|
+
# ── OpenAI ──────────────────────────────────────────────────
|
|
41
|
+
# Model (input, output)
|
|
42
|
+
"gpt-5.4": (1.25, 10.50), # GPT-5.4 (latest flagship)
|
|
43
|
+
"gpt-5.4-mini": (0.75, 4.50), # GPT-5.4 Mini
|
|
44
|
+
"gpt-5-mini": (0.25, 2.00), # GPT-5 Mini (budget)
|
|
45
|
+
"gpt-4.1": (2.00, 8.00), # GPT-4.1
|
|
46
|
+
"gpt-4.1-mini": (0.40, 1.60), # GPT-4.1 Mini
|
|
47
|
+
"gpt-4.1-nano": (0.10, 0.40), # GPT-4.1 Nano (cheapest GPT)
|
|
48
|
+
"gpt-4o": (2.50, 10.00), # GPT-4o
|
|
49
|
+
"gpt-4o-mini": (0.15, 0.60), # GPT-4o Mini
|
|
50
|
+
"o3": (10.50, 42.00), # O3 (reasoning)
|
|
51
|
+
"o3-mini": (1.10, 4.40), # O3 Mini
|
|
52
|
+
"o4-mini": (1.10, 4.40), # O4 Mini
|
|
53
|
+
|
|
54
|
+
# ── OpenCode model aliases (full paths) ─────────────────────
|
|
55
|
+
"anthropic/claude-sonnet-4-20250514": (3.00, 15.00),
|
|
56
|
+
"anthropic/claude-opus-4-20250514": (15.00, 75.00),
|
|
57
|
+
"anthropic/claude-haiku-4.5-20241022": (1.00, 5.00),
|
|
58
|
+
"openai/gpt-4.1": (2.00, 8.00),
|
|
59
|
+
"openai/gpt-4.1-mini": (0.40, 1.60),
|
|
60
|
+
"openai/o3": (10.50, 42.00),
|
|
61
|
+
"openai/o4-mini": (1.10, 4.40),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def estimate_cost(
|
|
66
|
+
model: str,
|
|
67
|
+
input_tokens: int = 0,
|
|
68
|
+
output_tokens: int = 0,
|
|
69
|
+
cache_read_tokens: int = 0,
|
|
70
|
+
cache_write_tokens: int = 0,
|
|
71
|
+
reasoning_tokens: int = 0,
|
|
72
|
+
) -> float:
|
|
73
|
+
"""
|
|
74
|
+
Estimate cost in USD for a given model and token usage.
|
|
75
|
+
|
|
76
|
+
Cache pricing (Anthropic):
|
|
77
|
+
- cache_write (creation): 1.25x normal input rate
|
|
78
|
+
- cache_read: 0.1x normal input rate
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
model: Model identifier string (any known variant)
|
|
82
|
+
input_tokens: Prompt/context tokens consumed
|
|
83
|
+
output_tokens: Generated/completion tokens produced
|
|
84
|
+
cache_read_tokens: Tokens served from cache (discounted)
|
|
85
|
+
cache_write_tokens: Tokens written to cache (premium)
|
|
86
|
+
reasoning_tokens: Tokens spent on internal reasoning (GPT-o series)
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Estimated cost in USD (0.0 for unknown/local models)
|
|
90
|
+
"""
|
|
91
|
+
model_lower = model.lower().strip()
|
|
92
|
+
|
|
93
|
+
# Try exact match first
|
|
94
|
+
rates = COSTS.get(model_lower)
|
|
95
|
+
|
|
96
|
+
# Fuzzy match: try to find a key that's contained in the model name
|
|
97
|
+
if rates is None:
|
|
98
|
+
for key, val in COSTS.items():
|
|
99
|
+
if key in model_lower or model_lower in key:
|
|
100
|
+
rates = val
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
if rates is None:
|
|
104
|
+
# Unknown / local model — cost is $0
|
|
105
|
+
return 0.0
|
|
106
|
+
|
|
107
|
+
input_rate, output_rate = rates # per 1M tokens
|
|
108
|
+
|
|
109
|
+
cost = 0.0
|
|
110
|
+
cost += (input_tokens / 1_000_000) * input_rate
|
|
111
|
+
cost += (output_tokens / 1_000_000) * output_rate
|
|
112
|
+
|
|
113
|
+
# Cache adjustments (Anthropic-style)
|
|
114
|
+
if cache_read_tokens:
|
|
115
|
+
cost += (cache_read_tokens / 1_000_000) * (input_rate * 0.1)
|
|
116
|
+
if cache_write_tokens:
|
|
117
|
+
cost += (cache_write_tokens / 1_000_000) * (input_rate * 1.25)
|
|
118
|
+
|
|
119
|
+
# Reasoning tokens billed as output tokens (OpenAI o-series pattern)
|
|
120
|
+
if reasoning_tokens:
|
|
121
|
+
cost += (reasoning_tokens / 1_000_000) * output_rate
|
|
122
|
+
|
|
123
|
+
return round(cost, 8)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_model_rates(model: str) -> tuple[float, float] | None:
|
|
127
|
+
"""
|
|
128
|
+
Look up per-million-token rates for a model.
|
|
129
|
+
Returns (input_rate, output_rate) or None if unknown.
|
|
130
|
+
"""
|
|
131
|
+
model_lower = model.lower().strip()
|
|
132
|
+
rates = COSTS.get(model_lower)
|
|
133
|
+
if rates is None:
|
|
134
|
+
for key, val in COSTS.items():
|
|
135
|
+
if key in model_lower or model_lower in key:
|
|
136
|
+
return val
|
|
137
|
+
return rates
|