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