mlx-code 0.0.11__tar.gz → 0.0.13__tar.gz

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 (35) hide show
  1. {mlx_code-0.0.11 → mlx_code-0.0.13}/PKG-INFO +6 -4
  2. {mlx_code-0.0.11 → mlx_code-0.0.13}/README.md +2 -2
  3. mlx_code-0.0.13/mlx_code/apis.py +574 -0
  4. mlx_code-0.0.13/mlx_code/gits.py +180 -0
  5. mlx_code-0.0.13/mlx_code/lsp_tool.py +599 -0
  6. mlx_code-0.0.13/mlx_code/main.py +943 -0
  7. mlx_code-0.0.13/mlx_code/mcb.py +183 -0
  8. {mlx_code-0.0.11 → mlx_code-0.0.13}/mlx_code/mcb_tool.py +22 -34
  9. mlx_code-0.0.13/mlx_code/repl.py +848 -0
  10. {mlx_code-0.0.11 → mlx_code-0.0.13}/mlx_code/stream_log.py +20 -20
  11. mlx_code-0.0.13/mlx_code/tools.py +412 -0
  12. mlx_code-0.0.13/mlx_code/util.py +30 -0
  13. {mlx_code-0.0.11 → mlx_code-0.0.13}/mlx_code/view_git.py +197 -324
  14. {mlx_code-0.0.11 → mlx_code-0.0.13}/mlx_code/view_log.py +160 -320
  15. {mlx_code-0.0.11 → mlx_code-0.0.13}/mlx_code.egg-info/PKG-INFO +6 -4
  16. {mlx_code-0.0.11 → mlx_code-0.0.13}/mlx_code.egg-info/requires.txt +3 -1
  17. {mlx_code-0.0.11 → mlx_code-0.0.13}/setup.py +8 -3
  18. mlx_code-0.0.13/tests/test.py +218 -0
  19. mlx_code-0.0.11/mlx_code/apis.py +0 -1133
  20. mlx_code-0.0.11/mlx_code/gits.py +0 -252
  21. mlx_code-0.0.11/mlx_code/lsp_tool.py +0 -882
  22. mlx_code-0.0.11/mlx_code/main.py +0 -1655
  23. mlx_code-0.0.11/mlx_code/mcb.py +0 -243
  24. mlx_code-0.0.11/mlx_code/repl.py +0 -784
  25. mlx_code-0.0.11/mlx_code/tools.py +0 -504
  26. mlx_code-0.0.11/mlx_code/util.py +0 -49
  27. mlx_code-0.0.11/tests/test.py +0 -75
  28. {mlx_code-0.0.11 → mlx_code-0.0.13}/LICENSE +0 -0
  29. {mlx_code-0.0.11 → mlx_code-0.0.13}/mlx_code/__init__.py +0 -0
  30. {mlx_code-0.0.11 → mlx_code-0.0.13}/mlx_code.egg-info/SOURCES.txt +0 -0
  31. {mlx_code-0.0.11 → mlx_code-0.0.13}/mlx_code.egg-info/dependency_links.txt +0 -0
  32. {mlx_code-0.0.11 → mlx_code-0.0.13}/mlx_code.egg-info/entry_points.txt +0 -0
  33. {mlx_code-0.0.11 → mlx_code-0.0.13}/mlx_code.egg-info/top_level.txt +0 -0
  34. {mlx_code-0.0.11 → mlx_code-0.0.13}/setup.cfg +0 -0
  35. {mlx_code-0.0.11 → mlx_code-0.0.13}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlx-code
3
- Version: 0.0.11
3
+ Version: 0.0.13
4
4
  Summary: Coding Agent for Mac
5
5
  Home-page: https://josefalbers.github.io/mlx-code/
6
6
  Author: J Joe
@@ -15,9 +15,11 @@ License-File: LICENSE
15
15
  Requires-Dist: mlx-lm>=0.31.3; platform_system == "Darwin"
16
16
  Requires-Dist: httpx
17
17
  Requires-Dist: pydantic
18
- Requires-Dist: GitPython
18
+ Requires-Dist: textual>=8.2.7
19
+ Requires-Dist: rich>=15.0.0
19
20
  Provides-Extra: all
20
21
  Requires-Dist: python-lsp-server[all]; extra == "all"
22
+ Requires-Dist: GitPython; extra == "all"
21
23
  Dynamic: author
22
24
  Dynamic: author-email
23
25
  Dynamic: description
@@ -31,11 +33,11 @@ Dynamic: requires-dist
31
33
  Dynamic: requires-python
32
34
  Dynamic: summary
33
35
 
34
- # [mlx-code](https://josefalbers.github.io/mlx-code)
36
+ # mlx-code
35
37
 
36
38
  A lightweight coding agent built on Apple's MLX framework.
37
39
 
38
- https://github.com/user-attachments/assets/0569d101-8d0a-4e67-9e82-fce84a5ef3f0
40
+ [![demo](https://raw.githubusercontent.com/JosefAlbers/mlx-code/main/assets/mlx-code.gif)](https://youtu.be/0lkY7YQCyCo)
39
41
 
40
42
  ---
41
43
 
@@ -1,8 +1,8 @@
1
- # [mlx-code](https://josefalbers.github.io/mlx-code)
1
+ # mlx-code
2
2
 
3
3
  A lightweight coding agent built on Apple's MLX framework.
4
4
 
5
- https://github.com/user-attachments/assets/0569d101-8d0a-4e67-9e82-fce84a5ef3f0
5
+ [![demo](https://raw.githubusercontent.com/JosefAlbers/mlx-code/main/assets/mlx-code.gif)](https://youtu.be/0lkY7YQCyCo)
6
6
 
7
7
  ---
8
8
 
@@ -0,0 +1,574 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import json
4
+ import os
5
+ import time
6
+ import uuid
7
+ import logging
8
+ import httpx
9
+ from typing import Any, Literal
10
+ from .tools import Tool
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class EventStream:
14
+
15
+ def __init__(self) -> None:
16
+ self._queue: asyncio.Queue[dict | None] = asyncio.Queue()
17
+ self._result: dict | None = None
18
+ self._task: asyncio.Task | None = None
19
+
20
+ def _attach(self, task: asyncio.Task) -> None:
21
+ self._task = task
22
+
23
+ def push(self, event: dict) -> None:
24
+ self._queue.put_nowait(event)
25
+
26
+ def finish(self, result: dict) -> None:
27
+ self._result = result
28
+ self._queue.put_nowait(None)
29
+
30
+ async def result(self) -> dict:
31
+ if self._result is None:
32
+ async for _ in self:
33
+ pass
34
+ assert self._result is not None
35
+ return self._result
36
+
37
+ def __aiter__(self) -> 'EventStream':
38
+ return self
39
+
40
+ async def __anext__(self) -> dict:
41
+ item = await self._queue.get()
42
+ if item is None:
43
+ raise StopAsyncIteration
44
+ return item
45
+ _REASONING_BUDGET: dict[str, int] = {'minimal': 512, 'low': 1024, 'medium': 8192, 'high': 16000, 'xhigh': 32000}
46
+
47
+ class ClaudeChat:
48
+
49
+ def __init__(self, *, model=None, api_key=None, base_url=None, max_tokens=8192, temperature=None, reasoning: Literal['off', 'minimal', 'low', 'medium', 'high', 'xhigh']='off', tool_choice=None):
50
+ self.model = model or 'claude-haiku-4-5'
51
+ self.api_key = api_key or os.environ.get('ANTHROPIC_API_KEY')
52
+ self.base_url = (base_url or 'https://api.anthropic.com').rstrip('/')
53
+ self.max_tokens = max_tokens
54
+ self.temperature = temperature
55
+ self.reasoning = reasoning
56
+ self.tool_choice = tool_choice
57
+ if not self.api_key:
58
+ logger.debug('No api key')
59
+
60
+ def _fmt_content(self, content) -> str | list:
61
+ if isinstance(content, str):
62
+ return content
63
+ out = []
64
+ for b in content:
65
+ t = b['type']
66
+ if t == 'text':
67
+ blk = {'type': 'text', 'text': b['text']}
68
+ if b.get('cache_control'):
69
+ blk['cache_control'] = {'type': b['cache_control']}
70
+ out.append(blk)
71
+ elif t == 'image':
72
+ blk = {'type': 'image', 'source': {'type': 'base64', 'media_type': b['mime_type'], 'data': b['data']}}
73
+ if b.get('cache_control'):
74
+ blk['cache_control'] = {'type': b['cache_control']}
75
+ out.append(blk)
76
+ elif t == 'thinking':
77
+ if b['redacted']:
78
+ out.append({'type': 'redacted_thinking', 'data': b['thinking']})
79
+ else:
80
+ out.append({'type': 'thinking', 'thinking': b['thinking'], 'signature': b.get('signature') or ''})
81
+ elif t == 'toolCall':
82
+ out.append({'type': 'tool_use', 'id': b['id'], 'name': b['name'], 'input': b['arguments']})
83
+ return out
84
+
85
+ def _build_messages(self, messages: list[dict]) -> list[dict]:
86
+ out = []
87
+ i = 0
88
+ while i < len(messages):
89
+ m = messages[i]
90
+ if m['role'] == 'user':
91
+ out.append({'role': 'user', 'content': self._fmt_content(m['content'])})
92
+ i += 1
93
+ elif m['role'] == 'assistant':
94
+ out.append({'role': 'assistant', 'content': self._fmt_content(m['content'])})
95
+ i += 1
96
+ elif m['role'] == 'toolResult':
97
+ batch = []
98
+ while i < len(messages) and messages[i]['role'] == 'toolResult':
99
+ tr = messages[i]
100
+ batch.append({'type': 'tool_result', 'tool_use_id': tr['tool_call_id'], 'content': self._fmt_content(tr['content']), 'is_error': tr['is_error']})
101
+ i += 1
102
+ out.append({'role': 'user', 'content': batch})
103
+ else:
104
+ i += 1
105
+ return out
106
+
107
+ async def stream(self, messages: list[dict], system: str, tools: list[Tool]) -> EventStream:
108
+ es = EventStream()
109
+ payload: dict[str, Any] = {'model': self.model, 'max_tokens': self.max_tokens, 'messages': self._build_messages(messages), 'stream': True}
110
+ if system:
111
+ payload['system'] = [{'type': 'text', 'text': system}]
112
+ if self.temperature:
113
+ payload['temperature'] = self.temperature
114
+ if self.reasoning != 'off':
115
+ payload['thinking'] = {'type': 'enabled', 'budget_tokens': _REASONING_BUDGET[self.reasoning]}
116
+ if tools:
117
+ payload['tools'] = [t.schema() for t in tools]
118
+ tc = self.tool_choice
119
+ if tc is not None:
120
+ payload['tool_choice'] = {'type': 'tool', 'name': tc['name']} if isinstance(tc, dict) else {'type': 'any'} if tc == 'required' else {'type': 'none'} if tc == 'none' else {'type': 'auto'}
121
+ headers = {'x-api-key': str(self.api_key), 'anthropic-version': '2023-06-01', 'content-type': 'application/json'}
122
+ logger.debug(json.dumps(payload, indent=2))
123
+
124
+ async def _run() -> None:
125
+ msg = {'role': 'assistant', 'content': [], 'stop_reason': 'stop', 'usage': {'input': 0, 'output': 0, 'cache_read': 0, 'cache_write': 0}, 'error_message': None, 'timestamp': int(time.time() * 1000)}
126
+ try:
127
+ async with httpx.AsyncClient(timeout=120.0) as client:
128
+ async with client.stream('POST', f'{self.base_url}/v1/messages', json=payload, headers=headers) as resp:
129
+ if resp.status_code >= 400:
130
+ raise RuntimeError(f'HTTP {resp.status_code}: {(await resp.aread()).decode()}')
131
+ es.push({'type': 'start', 'payload': {'partial': msg}})
132
+ _tool_buf: dict[int, str] = {}
133
+ _idx: dict[int, int] = {}
134
+ async for line in resp.aiter_lines():
135
+ if not line.startswith('data: '):
136
+ continue
137
+ data = line[6:]
138
+ if data == '[DONE]':
139
+ break
140
+ chunk = json.loads(data)
141
+ et = chunk.get('type')
142
+ if et == 'message_start':
143
+ u = chunk.get('message', {}).get('usage', {})
144
+ msg['usage']['input'] = u.get('input_tokens', 0)
145
+ msg['usage']['cache_read'] = u.get('cache_read_input_tokens', 0)
146
+ msg['usage']['cache_write'] = u.get('cache_creation_input_tokens', 0)
147
+ elif et == 'content_block_start':
148
+ idx = chunk.get('index', 0)
149
+ blk = chunk.get('content_block', {})
150
+ bt = blk.get('type')
151
+ if bt == 'text':
152
+ _idx[idx] = len(msg['content'])
153
+ msg['content'].append({'type': 'text', 'text': ''})
154
+ elif bt == 'thinking':
155
+ _idx[idx] = len(msg['content'])
156
+ msg['content'].append({'type': 'thinking', 'thinking': '', 'signature': None, 'redacted': False})
157
+ elif bt == 'redacted_thinking':
158
+ _idx[idx] = len(msg['content'])
159
+ msg['content'].append({'type': 'thinking', 'thinking': '', 'signature': None, 'redacted': True})
160
+ elif bt == 'tool_use':
161
+ _idx[idx] = len(msg['content'])
162
+ msg['content'].append({'type': 'toolCall', 'id': blk['id'], 'name': blk['name'], 'arguments': {}})
163
+ _tool_buf[idx] = ''
164
+ elif et == 'content_block_delta':
165
+ idx = chunk.get('index', 0)
166
+ delta = chunk.get('delta', {})
167
+ dt = delta.get('type')
168
+ pos = _idx.get(idx)
169
+ if pos is None:
170
+ continue
171
+ b = msg['content'][pos]
172
+ if dt == 'text_delta':
173
+ d = delta.get('text', '')
174
+ b['text'] += d
175
+ es.push({'type': 'text_delta', 'payload': {'delta': d, 'partial': msg}})
176
+ elif dt == 'thinking_delta':
177
+ d = delta.get('thinking', '')
178
+ b['thinking'] += d
179
+ es.push({'type': 'thinking_delta', 'payload': {'delta': d, 'partial': msg}})
180
+ elif dt == 'signature_delta':
181
+ b['signature'] = (b.get('signature') or '') + delta.get('signature', '')
182
+ elif dt == 'input_json_delta':
183
+ _tool_buf[idx] = _tool_buf.get(idx, '') + delta.get('partial_json', '')
184
+ elif et == 'content_block_stop':
185
+ idx = chunk.get('index', 0)
186
+ pos = _idx.get(idx)
187
+ if pos is not None and pos < len(msg['content']):
188
+ b = msg['content'][pos]
189
+ if b['type'] == 'toolCall':
190
+ raw = _tool_buf.pop(idx, '')
191
+ if raw:
192
+ try:
193
+ b['arguments'] = json.loads(raw)
194
+ except json.JSONDecodeError:
195
+ pass
196
+ es.push({'type': 'toolcall_end', 'payload': {'tool_call': b, 'partial': msg}})
197
+ elif et == 'message_delta':
198
+ reason = chunk.get('delta', {}).get('stop_reason')
199
+ if reason == 'tool_use':
200
+ msg['stop_reason'] = 'tool_use'
201
+ elif reason == 'max_tokens':
202
+ msg['stop_reason'] = 'length'
203
+ elif reason:
204
+ msg['stop_reason'] = 'stop'
205
+ u = chunk.get('usage', {})
206
+ if u:
207
+ msg['usage']['output'] = u.get('output_tokens', msg['usage']['output'])
208
+ elif et == 'message_stop':
209
+ es.push({'type': 'done', 'payload': {'reason': msg['stop_reason'], 'message': msg}})
210
+ except Exception as exc:
211
+ msg['stop_reason'] = 'error'
212
+ msg['error_message'] = str(exc)
213
+ es.push({'type': 'error', 'payload': {'error': msg}})
214
+ es.finish(msg)
215
+ es._attach(asyncio.create_task(_run()))
216
+ return es
217
+
218
+ class DefaultChat:
219
+
220
+ def __init__(self, *, model=None, api_key=None, base_url=None, max_tokens=8192, temperature=None, tool_choice=None):
221
+ self.model = model or 'noapi'
222
+ self.api_key = api_key or 'noapi'
223
+ self.base_url = (base_url or 'http://127.0.0.1:8000').rstrip('/')
224
+ self.max_tokens = max_tokens
225
+ self.temperature = temperature
226
+ self.tool_choice = tool_choice
227
+ if not api_key:
228
+ logger.debug('No api key')
229
+
230
+ def _build_messages(self, messages: list[dict], system: str) -> list[dict]:
231
+ out = []
232
+ if system:
233
+ out.append({'role': 'system', 'content': system})
234
+ for m in messages:
235
+ if m['role'] == 'user':
236
+ content = m['content']
237
+ if isinstance(content, str):
238
+ out.append({'role': 'user', 'content': content})
239
+ else:
240
+ out.append({'role': 'user', 'content': [{'type': 'text', 'text': b['text']} if b['type'] == 'text' else {'type': 'image_url', 'image_url': {'url': f'data:{b['mime_type']};base64,{b['data']}'}} for b in content]})
241
+ elif m['role'] == 'assistant':
242
+ texts = [b for b in m['content'] if b['type'] == 'text']
243
+ calls = [b for b in m['content'] if b['type'] == 'toolCall']
244
+ thinking = [b for b in m['content'] if b['type'] == 'thinking']
245
+ mo: dict[str, Any] = {'role': 'assistant'}
246
+ if texts:
247
+ mo['content'] = ''.join((b['text'] for b in texts))
248
+ if thinking:
249
+ mo['reasoning_content'] = ''.join((b['thinking'] for b in thinking))
250
+ if calls:
251
+ mo['tool_calls'] = [{'id': c['id'], 'type': 'function', 'function': {'name': c['name'], 'arguments': json.dumps(c['arguments'])}} for c in calls]
252
+ out.append(mo)
253
+ elif m['role'] == 'toolResult':
254
+ c = m['content']
255
+ out.append({'role': 'tool', 'tool_call_id': m['tool_call_id'], 'content': c[0]['text'] if len(c) == 1 and c[0]['type'] == 'text' else [{'type': 'text', 'text': b['text']} if b['type'] == 'text' else {'type': 'image_url', 'image_url': {'url': f'data:{b['mime_type']};base64,{b['data']}'}} for b in c]})
256
+ return out
257
+
258
+ async def stream(self, messages: list[dict], system: str, tools: list[Tool]) -> EventStream:
259
+ es = EventStream()
260
+ payload: dict[str, Any] = {'model': self.model, 'max_tokens': self.max_tokens, 'messages': self._build_messages(messages, system), 'stream': True}
261
+ if self.temperature:
262
+ payload['temperature'] = self.temperature
263
+ if tools:
264
+ payload['tools'] = [{'type': 'function', 'function': {'name': t.name, 'description': t.description, 'parameters': {'type': 'object', 'properties': t.parameters.model_json_schema().get('properties', {}), 'required': t.parameters.model_json_schema().get('required', [])}}} for t in tools]
265
+ tc = self.tool_choice
266
+ if tc is not None:
267
+ payload['tool_choice'] = {'type': 'function', 'function': {'name': tc['name']}} if isinstance(tc, dict) else tc
268
+ headers = {'Authorization': f'Bearer {self.api_key}', 'Content-Type': 'application/json'}
269
+ logger.debug(json.dumps(payload, indent=2))
270
+
271
+ async def _run() -> None:
272
+ msg = {'role': 'assistant', 'content': [], 'stop_reason': 'stop', 'usage': {'input': 0, 'output': 0, 'cache_read': 0, 'cache_write': 0}, 'error_message': None, 'timestamp': int(time.time() * 1000)}
273
+ try:
274
+ async with httpx.AsyncClient(timeout=None) as client:
275
+ async with client.stream('POST', f'{self.base_url}/v1/chat/completions', json=payload, headers=headers) as resp:
276
+ if resp.status_code >= 400:
277
+ raise RuntimeError(f'HTTP {resp.status_code}: {(await resp.aread()).decode()}')
278
+ es.push({'type': 'start', 'payload': {'partial': msg}})
279
+ _tc: dict[int, dict] = {}
280
+ _text_buf = _think_buf = ''
281
+ finish = None
282
+ async for line in resp.aiter_lines():
283
+ if not line.startswith('data: '):
284
+ continue
285
+ data = line[6:].strip()
286
+ if data == '[DONE]' or not data:
287
+ continue
288
+ chunk = json.loads(data)
289
+ choice = (chunk.get('choices') or [{}])[0]
290
+ delta = choice.get('delta', {})
291
+ finish = choice.get('finish_reason') or finish
292
+ if (r := (delta.get('reasoning_content') or delta.get('thinking'))):
293
+ _think_buf += r
294
+ es.push({'type': 'thinking_delta', 'payload': {'delta': r, 'partial': msg}})
295
+ if (d := delta.get('content')):
296
+ _text_buf += d
297
+ es.push({'type': 'text_delta', 'payload': {'delta': d, 'partial': msg}})
298
+ for tcd in delta.get('tool_calls') or []:
299
+ idx = tcd.get('index', 0)
300
+ fn = tcd.get('function', {})
301
+ if idx not in _tc:
302
+ _tc[idx] = {'id': tcd.get('id', str(uuid.uuid4())), 'name': fn.get('name', ''), 'args_buf': fn.get('arguments', '')}
303
+ else:
304
+ if 'name' in fn:
305
+ _tc[idx]['name'] += fn['name']
306
+ if 'arguments' in fn:
307
+ _tc[idx]['args_buf'] += fn['arguments']
308
+ if (u := chunk.get('usage')):
309
+ msg['usage']['input'] = u.get('prompt_tokens', msg['usage']['input'])
310
+ msg['usage']['output'] = u.get('completion_tokens', msg['usage']['output'])
311
+ if _think_buf:
312
+ msg['content'].append({'type': 'thinking', 'thinking': _think_buf, 'signature': None, 'redacted': False})
313
+ if _text_buf:
314
+ msg['content'].append({'type': 'text', 'text': _text_buf})
315
+ for _, s in sorted(_tc.items()):
316
+ try:
317
+ args = json.loads(s['args_buf']) if s['args_buf'] else {}
318
+ except json.JSONDecodeError:
319
+ args = {}
320
+ call = {'type': 'toolCall', 'id': s['id'], 'name': s['name'], 'arguments': args}
321
+ msg['content'].append(call)
322
+ es.push({'type': 'toolcall_end', 'payload': {'tool_call': call, 'partial': msg}})
323
+ msg['stop_reason'] = 'tool_use' if finish == 'tool_calls' else 'length' if finish in ('length', 'max_tokens') else 'stop'
324
+ es.push({'type': 'done', 'payload': {'reason': msg['stop_reason'], 'message': msg}})
325
+ except Exception as exc:
326
+ msg['stop_reason'] = 'error'
327
+ msg['error_message'] = str(exc)
328
+ es.push({'type': 'error', 'payload': {'error': msg}})
329
+ es.finish(msg)
330
+ es._attach(asyncio.create_task(_run()))
331
+ return es
332
+
333
+ class GeminiChat:
334
+
335
+ def __init__(self, *, model=None, api_key=None, base_url=None, max_tokens=8192, temperature=None, thinking=False, thinking_budget=8192, tool_choice=None):
336
+ self.model = model or 'gemini-3.1-flash-lite-preview'
337
+ self.api_key = api_key or os.environ.get('GEMINI_API_KEY')
338
+ self.base_url = (base_url or 'https://generativelanguage.googleapis.com').rstrip('/')
339
+ self.max_tokens = max_tokens
340
+ self.temperature = temperature
341
+ self.thinking = thinking
342
+ self.thinking_budget = thinking_budget
343
+ self.tool_choice = tool_choice
344
+ if not self.api_key:
345
+ logger.debug('No api key')
346
+
347
+ def _build_contents(self, messages: list[dict]) -> list[dict]:
348
+ out = []
349
+ pending: list[dict] = []
350
+
351
+ def flush():
352
+ if pending:
353
+ out.append({'role': 'user', 'parts': list(pending)})
354
+ pending.clear()
355
+ for m in messages:
356
+ if m['role'] == 'user':
357
+ flush()
358
+ content = m['content']
359
+ if isinstance(content, str):
360
+ out.append({'role': 'user', 'parts': [{'text': content}]})
361
+ else:
362
+ out.append({'role': 'user', 'parts': [{'text': b['text']} if b['type'] == 'text' else {'inlineData': {'mimeType': b['mime_type'], 'data': b['data']}} for b in content]})
363
+ elif m['role'] == 'assistant':
364
+ flush()
365
+ parts = []
366
+ for b in m['content']:
367
+ if b['type'] == 'thinking':
368
+ parts.append({'thought': b['thinking']})
369
+ elif b['type'] == 'text':
370
+ parts.append({'text': b['text']})
371
+ elif b['type'] == 'toolCall':
372
+ fc: dict = {'name': b['name'], 'args': b['arguments']}
373
+ if b['id'] != b['name']:
374
+ fc['id'] = b['id']
375
+ parts.append({'functionCall': fc})
376
+ if parts:
377
+ out.append({'role': 'model', 'parts': parts})
378
+ elif m['role'] == 'toolResult':
379
+ c = m['content']
380
+ try:
381
+ body = json.loads(c[0]['text']) if c else {}
382
+ except (json.JSONDecodeError, KeyError):
383
+ body = {'result': c[0]['text'] if c else ''}
384
+ fr: dict = {'name': m['tool_name'], 'response': body}
385
+ if m['tool_call_id'] != m['tool_name']:
386
+ fr['id'] = m['tool_call_id']
387
+ pending.append({'functionResponse': fr})
388
+ flush()
389
+ return out
390
+
391
+ async def stream(self, messages: list[dict], system: str, tools: list[Tool]) -> EventStream:
392
+ es = EventStream()
393
+ payload: dict[str, Any] = {'contents': self._build_contents(messages), 'generationConfig': {'maxOutputTokens': self.max_tokens}}
394
+ if system:
395
+ payload['systemInstruction'] = {'parts': [{'text': system}]}
396
+ if self.temperature:
397
+ payload['generationConfig']['temperature'] = self.temperature
398
+ if self.thinking:
399
+ payload['generationConfig']['thinkingConfig'] = {'includeThoughts': True, 'thinkingBudget': self.thinking_budget}
400
+ if tools:
401
+ payload['tools'] = [{'functionDeclarations': [{'name': t.name, 'description': t.description, 'parameters': {'type': 'object', 'properties': t.parameters.model_json_schema().get('properties', {}), 'required': t.parameters.model_json_schema().get('required', [])}} for t in tools]}]
402
+ tc = self.tool_choice
403
+ if tc is not None:
404
+ payload['toolConfig'] = {'functionCallingConfig': {'mode': 'ANY', 'allowedFunctionNames': [tc['name']]} if isinstance(tc, dict) else {'mode': 'ANY'} if tc == 'required' else {'mode': 'NONE'} if tc == 'none' else {'mode': 'AUTO'}}
405
+ url = f'{self.base_url}/v1beta/models/{self.model}:streamGenerateContent?alt=sse&key={self.api_key}'
406
+ logger.debug(json.dumps(payload, indent=2))
407
+
408
+ async def _run() -> None:
409
+ msg = {'role': 'assistant', 'content': [], 'stop_reason': 'stop', 'usage': {'input': 0, 'output': 0, 'cache_read': 0, 'cache_write': 0}, 'error_message': None, 'timestamp': int(time.time() * 1000)}
410
+ try:
411
+ async with httpx.AsyncClient(timeout=120.0) as client:
412
+ async with client.stream('POST', url, json=payload, headers={'Content-Type': 'application/json'}) as resp:
413
+ if resp.status_code >= 400:
414
+ raise RuntimeError(f'HTTP {resp.status_code}: {(await resp.aread()).decode()}')
415
+ es.push({'type': 'start', 'payload': {'partial': msg}})
416
+ _text = _think = ''
417
+ _calls: dict[str, dict] = {}
418
+ finish = None
419
+ async for line in resp.aiter_lines():
420
+ if not line.startswith('data: '):
421
+ continue
422
+ data = line[6:].strip()
423
+ if not data:
424
+ continue
425
+ chunk = json.loads(data)
426
+ cand = (chunk.get('candidates') or [{}])[0]
427
+ finish = cand.get('finishReason') or finish
428
+ for part in cand.get('content', {}).get('parts') or []:
429
+ if 'thought' in part:
430
+ _think += part['thought']
431
+ es.push({'type': 'thinking_delta', 'payload': {'delta': part['thought'], 'partial': msg}})
432
+ elif 'text' in part:
433
+ _text += part['text']
434
+ es.push({'type': 'text_delta', 'payload': {'delta': part['text'], 'partial': msg}})
435
+ elif 'functionCall' in part:
436
+ fc = part['functionCall']
437
+ cid = fc.get('id') or fc['name']
438
+ if cid not in _calls:
439
+ _calls[cid] = {'name': fc['name'], 'args': fc.get('args', {})}
440
+ else:
441
+ _calls[cid]['args'].update(fc.get('args', {}))
442
+ if (u := chunk.get('usageMetadata')):
443
+ msg['usage']['input'] = u.get('promptTokenCount', msg['usage']['input'])
444
+ msg['usage']['output'] = u.get('candidatesTokenCount', msg['usage']['output'])
445
+ if _think:
446
+ msg['content'].append({'type': 'thinking', 'thinking': _think, 'signature': None, 'redacted': False})
447
+ if _text:
448
+ msg['content'].append({'type': 'text', 'text': _text})
449
+ for cid, s in _calls.items():
450
+ call = {'type': 'toolCall', 'id': cid, 'name': s['name'], 'arguments': s['args']}
451
+ msg['content'].append(call)
452
+ es.push({'type': 'toolcall_end', 'payload': {'tool_call': call, 'partial': msg}})
453
+ msg['stop_reason'] = 'length' if finish == 'MAX_TOKENS' else 'tool_use' if _calls else 'stop'
454
+ es.push({'type': 'done', 'payload': {'reason': msg['stop_reason'], 'message': msg}})
455
+ es.finish(msg)
456
+ return
457
+ except Exception as exc:
458
+ msg['stop_reason'] = 'error'
459
+ msg['error_message'] = str(exc)
460
+ es.push({'type': 'error', 'payload': {'error': msg}})
461
+ es.finish(msg)
462
+ es._attach(asyncio.create_task(_run()))
463
+ return es
464
+
465
+ class CodexChat:
466
+
467
+ def __init__(self, *, model=None, api_key=None, base_url=None, max_tokens=8192, temperature=None, tool_choice=None):
468
+ self.model = model or 'gpt-5.4-mini'
469
+ self.api_key = api_key or os.environ.get('OPENAI_API_KEY')
470
+ self.base_url = (base_url or 'https://api.openai.com').rstrip('/')
471
+ self.max_tokens = max_tokens
472
+ self.temperature = temperature
473
+ self.tool_choice = tool_choice
474
+ if not self.api_key:
475
+ logger.debug('No api key')
476
+
477
+ def _build_input(self, messages: list[dict], system: str) -> list[dict]:
478
+ out = []
479
+ if system:
480
+ out.append({'type': 'message', 'role': 'developer', 'content': system})
481
+ for m in messages:
482
+ if m['role'] == 'user':
483
+ c = m['content']
484
+ out.append({'type': 'message', 'role': 'user', 'content': c if isinstance(c, str) else [{'type': 'input_text', 'text': b['text']} if b['type'] == 'text' else {'type': 'input_image', 'image_url': f'data:{b['mime_type']};base64,{b['data']}'} for b in c]})
485
+ elif m['role'] == 'assistant':
486
+ for b in m['content']:
487
+ if b['type'] == 'text':
488
+ out.append({'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': b['text']}]})
489
+ elif b['type'] == 'toolCall':
490
+ out.append({'type': 'function_call', 'call_id': b['id'], 'name': b['name'], 'arguments': json.dumps(b['arguments'])})
491
+ elif m['role'] == 'toolResult':
492
+ out.append({'type': 'function_call_output', 'call_id': m['tool_call_id'], 'output': m['content'][0]['text'] if m['content'] else ''})
493
+ return out
494
+
495
+ async def stream(self, messages: list[dict], system: str, tools: list[Tool]) -> EventStream:
496
+ es = EventStream()
497
+ payload: dict[str, Any] = {'model': self.model, 'max_output_tokens': self.max_tokens, 'input': self._build_input(messages, system), 'stream': True}
498
+ if self.temperature:
499
+ payload['temperature'] = self.temperature
500
+ if tools:
501
+ payload['tools'] = [{'type': 'function', 'name': t.name, 'description': t.description, 'parameters': {'type': 'object', 'properties': t.parameters.model_json_schema().get('properties', {}), 'required': t.parameters.model_json_schema().get('required', [])}} for t in tools]
502
+ tc = self.tool_choice
503
+ if tc is not None:
504
+ payload['tool_choice'] = {'type': 'function', 'name': tc['name']} if isinstance(tc, dict) else tc
505
+ headers = {'Authorization': f'Bearer {self.api_key}', 'Content-Type': 'application/json'}
506
+ logger.debug(json.dumps(payload, indent=2))
507
+
508
+ async def _run() -> None:
509
+ msg = {'role': 'assistant', 'content': [], 'stop_reason': 'stop', 'usage': {'input': 0, 'output': 0, 'cache_read': 0, 'cache_write': 0}, 'error_message': None, 'timestamp': int(time.time() * 1000)}
510
+ try:
511
+ async with httpx.AsyncClient(timeout=120.0) as client:
512
+ async with client.stream('POST', f'{self.base_url}/v1/responses', json=payload, headers=headers) as resp:
513
+ if resp.status_code >= 400:
514
+ raise RuntimeError(f'HTTP {resp.status_code}: {(await resp.aread()).decode()}')
515
+ es.push({'type': 'start', 'payload': {'partial': msg}})
516
+ _text = ''
517
+ _calls: dict[str, dict] = {}
518
+ finish = None
519
+ async for line in resp.aiter_lines():
520
+ if not line.startswith('data: '):
521
+ continue
522
+ data = line[6:].strip()
523
+ if not data:
524
+ continue
525
+ chunk = json.loads(data)
526
+ et = chunk.get('type', '')
527
+ if et == 'response.output_text.delta':
528
+ d = chunk.get('delta', '')
529
+ _text += d
530
+ es.push({'type': 'text_delta', 'payload': {'delta': d, 'partial': msg}})
531
+ elif et == 'response.output_item.added':
532
+ item = chunk.get('item', {})
533
+ if item.get('type') == 'function_call':
534
+ iid = item['id']
535
+ _calls[iid] = {'name': item.get('name', ''), 'call_id': item.get('call_id', iid), 'args_buf': ''}
536
+ elif et == 'response.function_call_arguments.delta':
537
+ iid = chunk.get('item_id', '')
538
+ if iid in _calls:
539
+ _calls[iid]['args_buf'] += chunk.get('delta', '')
540
+ elif et == 'response.completed':
541
+ finish = chunk.get('response', {}).get('status')
542
+ if (u := chunk.get('response', {}).get('usage')):
543
+ msg['usage']['input'] = u.get('input_tokens', msg['usage']['input'])
544
+ msg['usage']['output'] = u.get('output_tokens', msg['usage']['output'])
545
+ if _text:
546
+ msg['content'].append({'type': 'text', 'text': _text})
547
+ for s in _calls.values():
548
+ try:
549
+ args = json.loads(s['args_buf']) if s['args_buf'] else {}
550
+ except json.JSONDecodeError:
551
+ args = {}
552
+ call = {'type': 'toolCall', 'id': s['call_id'], 'name': s['name'], 'arguments': args}
553
+ msg['content'].append(call)
554
+ es.push({'type': 'toolcall_end', 'payload': {'tool_call': call, 'partial': msg}})
555
+ msg['stop_reason'] = 'tool_use' if _calls else 'length' if finish == 'incomplete' else 'stop'
556
+ es.push({'type': 'done', 'payload': {'reason': msg['stop_reason'], 'message': msg}})
557
+ except Exception as exc:
558
+ msg['stop_reason'] = 'error'
559
+ msg['error_message'] = str(exc)
560
+ es.push({'type': 'error', 'payload': {'error': msg}})
561
+ es.finish(msg)
562
+ es._attach(asyncio.create_task(_run()))
563
+ return es
564
+
565
+ def resolve_api(api, *, model, api_key, base_url):
566
+ if not isinstance(api, str):
567
+ return api
568
+ if api == 'claude':
569
+ return ClaudeChat(model=model, api_key=api_key, base_url=base_url)
570
+ if api == 'gemini':
571
+ return GeminiChat(model=model, api_key=api_key, base_url=base_url)
572
+ if api == 'codex':
573
+ return CodexChat(model=model, api_key=api_key, base_url=base_url)
574
+ return DefaultChat(model=model, api_key=api_key, base_url=base_url)