omni-ai-cli 1.1.0 → 1.2.0
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.
- package/README.md +0 -1
- package/docs/README.en.md +0 -1
- package/docs/README.es.md +0 -1
- package/docs/README.fr.md +0 -2
- package/omni_ai/chat.py +21 -3
- package/omni_ai/openrouter.py +84 -6
- package/omni_ai/ui.py +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -543,7 +543,6 @@ guardar nem rotacionar.
|
|
|
543
543
|
arquivos. Os agentes não editam o projeto ao mesmo tempo.
|
|
544
544
|
- A classificação simples/complexa é por palavras-chave e tamanho, não por um modelo.
|
|
545
545
|
- Trocar de modelo não renova a cota da conta.
|
|
546
|
-
- O OpenRouter não tem streaming nesta versão: a resposta aparece inteira no final.
|
|
547
546
|
- No Windows, o código usa os equivalentes nativos (grupo de processos e `taskkill`, `msvcrt`, junções de pasta), mas foi testado de verdade só no macOS.
|
|
548
547
|
|
|
549
548
|
---
|
package/docs/README.en.md
CHANGED
|
@@ -542,7 +542,6 @@ The workflow already requests `id-token: write` and `NPM_CONFIG_PROVENANCE`; the
|
|
|
542
542
|
access the files. The agents do not edit the project at the same time.
|
|
543
543
|
- The simple/complex classification is by keywords and length, not by a model.
|
|
544
544
|
- Switching models does not renew the account's quota.
|
|
545
|
-
- OpenRouter has no streaming in this version: the answer appears whole at the end.
|
|
546
545
|
- On Windows the code uses the native equivalents (process groups and `taskkill`, `msvcrt`, folder junctions), but it has only been tested for real on macOS.
|
|
547
546
|
|
|
548
547
|
---
|
package/docs/README.es.md
CHANGED
|
@@ -545,7 +545,6 @@ guardar ni rotar.
|
|
|
545
545
|
a los archivos. Los agentes no editan el proyecto al mismo tiempo.
|
|
546
546
|
- La clasificación simple/compleja se hace por palabras clave y tamaño, no con un modelo.
|
|
547
547
|
- Cambiar de modelo no renueva la cuota de la cuenta.
|
|
548
|
-
- OpenRouter no tiene streaming en esta versión: la respuesta aparece completa al final.
|
|
549
548
|
- En Windows el código usa los equivalentes nativos (grupos de procesos y `taskkill`, `msvcrt`, uniones de carpeta), pero solo se probó de verdad en macOS.
|
|
550
549
|
|
|
551
550
|
---
|
package/docs/README.fr.md
CHANGED
|
@@ -555,8 +555,6 @@ Le workflow demande déjà `id-token: write` et `NPM_CONFIG_PROVENANCE` ; il n'y
|
|
|
555
555
|
- La classification simple/complexe repose sur des mots-clés et la taille, pas sur un
|
|
556
556
|
modèle.
|
|
557
557
|
- Changer de modèle ne renouvelle pas le quota du compte.
|
|
558
|
-
- OpenRouter n'a pas de streaming dans cette version : la réponse apparaît en entier à
|
|
559
|
-
la fin.
|
|
560
558
|
- Sous Windows le code utilise les équivalents natifs (groupes de processus et `taskkill`, `msvcrt`, jonctions de dossier), mais il n'a été testé pour de vrai que sous macOS.
|
|
561
559
|
|
|
562
560
|
---
|
package/omni_ai/chat.py
CHANGED
|
@@ -154,6 +154,7 @@ T = {
|
|
|
154
154
|
/account [list|add|login|remove] [codex|agy|grok] NOME Contas (Claude por padrão; quantas quiser)
|
|
155
155
|
/key [add|remove] [{keys}] [NOME] Chaves de API (várias por ferramenta, usadas em ordem)
|
|
156
156
|
/status /reset Status das contas / zerar cooldowns
|
|
157
|
+
/doctor Diagnóstico de instalação, contas e chaves
|
|
157
158
|
/sync [--dry-run] Compartilhar MCPs e skills entre Claude, Codex e agy
|
|
158
159
|
/mode default|plan|edits|auto|yolo Modo de permissão (vale para Claude, Codex e agy)
|
|
159
160
|
/setup [install] Guia de primeiro acesso; install instala as ferramentas que faltam
|
|
@@ -177,6 +178,7 @@ T = {
|
|
|
177
178
|
/account [list|add|login|remove] [codex|agy|grok] NAME Accounts (Claude by default; as many as you want)
|
|
178
179
|
/key [add|remove] [{keys}] [NAME] API keys (several per tool, tried in order)
|
|
179
180
|
/status /reset Account status / clear cooldowns
|
|
181
|
+
/doctor Diagnose install, accounts and keys
|
|
180
182
|
/sync [--dry-run] Share MCPs and skills across Claude, Codex and agy
|
|
181
183
|
/mode default|plan|edits|auto|yolo Permission mode (applies to Claude, Codex and agy)
|
|
182
184
|
/setup [install] First-run guide; install installs the missing tools
|
|
@@ -200,6 +202,7 @@ T = {
|
|
|
200
202
|
/account [list|add|login|remove] [codex|agy|grok] NOMBRE Cuentas (Claude por defecto; las que quieras)
|
|
201
203
|
/key [add|remove] [{keys}] [NOMBRE] Claves de API (varias por herramienta, en orden)
|
|
202
204
|
/status /reset Estado de cuentas / limpiar cooldowns
|
|
205
|
+
/doctor Diagnóstico de instalación, cuentas y claves
|
|
203
206
|
/sync [--dry-run] Compartir MCPs y skills entre Claude, Codex y agy
|
|
204
207
|
/mode default|plan|edits|auto|yolo Modo de permisos (Claude, Codex y agy)
|
|
205
208
|
/setup [install] Guía de primer acceso; install instala las herramientas que faltan
|
|
@@ -223,6 +226,7 @@ T = {
|
|
|
223
226
|
/account [list|add|login|remove] [codex|agy|grok] NOM Comptes (Claude par défaut ; autant que voulu)
|
|
224
227
|
/key [add|remove] [{keys}] [NOM] Clés API (plusieurs par outil, essayées dans l'ordre)
|
|
225
228
|
/status /reset État des comptes / réinitialiser les cooldowns
|
|
229
|
+
/doctor Diagnostic installation, comptes et clés
|
|
226
230
|
/sync [--dry-run] Partager MCP et skills entre Claude, Codex et agy
|
|
227
231
|
/mode default|plan|edits|auto|yolo Mode de permissions (Claude, Codex et agy)
|
|
228
232
|
/setup [install] Guide de démarrage ; install installe les outils manquants
|
|
@@ -477,10 +481,24 @@ class Runner:
|
|
|
477
481
|
if job.get('dispatches'):
|
|
478
482
|
job['dispatches'][-1]['sent'] = event['data']
|
|
479
483
|
view.note('dados pessoais mascarados antes do envio · /sent para ver')
|
|
484
|
+
elif kind == 'smart.delta':
|
|
485
|
+
# Stream the answer line by line as the tokens arrive (OpenRouter).
|
|
486
|
+
job['stream_buf'] = job.get('stream_buf', '') + event.get('text', '')
|
|
487
|
+
job['streamed'] = True
|
|
488
|
+
while '\n' in job['stream_buf']:
|
|
489
|
+
line, job['stream_buf'] = job['stream_buf'].split('\n', 1)
|
|
490
|
+
view.stream_line(line)
|
|
480
491
|
elif kind == 'smart.answer':
|
|
481
492
|
job['model'] = event.get('model')
|
|
482
493
|
spend(job, event.get('usage') or {})
|
|
483
|
-
|
|
494
|
+
if job.get('streamed'):
|
|
495
|
+
if job.get('stream_buf'):
|
|
496
|
+
view.stream_line(job['stream_buf'])
|
|
497
|
+
job['stream_buf'] = ''
|
|
498
|
+
# the streamed lines were display-only; keep the full, unmasked answer for the session
|
|
499
|
+
job['answers'] = [event.get('content', '')]
|
|
500
|
+
else:
|
|
501
|
+
self.answer(job, event.get('content', ''))
|
|
484
502
|
view.note('modelo: ' + str(event.get('model', '?')))
|
|
485
503
|
elif kind in ('item.started', 'item.completed'):
|
|
486
504
|
item = event.get('item', {})
|
|
@@ -1016,7 +1034,7 @@ def compact(session):
|
|
|
1016
1034
|
|
|
1017
1035
|
|
|
1018
1036
|
TOOL_COMMANDS = {'/usage': ['--usage'], '/limits': ['--usage'], '/model': ['--model'], '/account': ['--account'], '/key': ['--key'],
|
|
1019
|
-
'/status': ['--status'], '/sync': ['--sync'], '/update': ['--update'], '/reset': ['--reset']}
|
|
1037
|
+
'/status': ['--status'], '/doctor': ['--doctor'], '/sync': ['--sync'], '/update': ['--update'], '/reset': ['--reset']}
|
|
1020
1038
|
|
|
1021
1039
|
|
|
1022
1040
|
def run_tool(args):
|
|
@@ -1027,7 +1045,7 @@ def run_tool(args):
|
|
|
1027
1045
|
pass
|
|
1028
1046
|
|
|
1029
1047
|
|
|
1030
|
-
BUILTIN = ['/usage', '/clear', '/compact', '/queue', '/versions', '/setup', '/terminal-setup', '/tool', '/model', '/account', '/key', '/status', '/reset', '/sync', '/update', '/mode', '/lang',
|
|
1048
|
+
BUILTIN = ['/usage', '/clear', '/compact', '/queue', '/versions', '/setup', '/terminal-setup', '/tool', '/model', '/account', '/key', '/status', '/doctor', '/reset', '/sync', '/update', '/mode', '/lang',
|
|
1031
1049
|
'/skills', '/history', '/sent', '/trace', '/sessions', '/resume', '/new', '/parallel', '/help', '/exit']
|
|
1032
1050
|
|
|
1033
1051
|
|
package/omni_ai/openrouter.py
CHANGED
|
@@ -105,6 +105,55 @@ class OpenRouterClient:
|
|
|
105
105
|
except urllib.error.URLError as e:
|
|
106
106
|
raise RuntimeError("Falha de rede ao acessar OpenRouter") from None
|
|
107
107
|
|
|
108
|
+
def _stream_request(self, data: dict):
|
|
109
|
+
"""POST /chat/completions with stream=True, yielding ('delta', text) per token and a final
|
|
110
|
+
('final', {content, model, usage}). Raises RuntimeError on any failure, like _request."""
|
|
111
|
+
if os.environ.get('SMART_AI_EVENTS') == '1':
|
|
112
|
+
print(json.dumps({'type': 'smart.request', 'data': data}, ensure_ascii=False), file=sys.stderr, flush=True)
|
|
113
|
+
headers = {
|
|
114
|
+
"Content-Type": "application/json",
|
|
115
|
+
"HTTP-Referer": "https://github.com/samuelrms/omni-ai-cli",
|
|
116
|
+
"X-Title": "Omni AI CLI",
|
|
117
|
+
"Accept": "text/event-stream",
|
|
118
|
+
}
|
|
119
|
+
if self.api_key:
|
|
120
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
121
|
+
body = json.dumps(dict(data, stream=True, stream_options={"include_usage": True})).encode()
|
|
122
|
+
req = urllib.request.Request(f"{OPENROUTER_BASE}/chat/completions", data=body, headers=headers, method="POST")
|
|
123
|
+
content, model, usage = [], None, {}
|
|
124
|
+
try:
|
|
125
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
126
|
+
for raw in resp:
|
|
127
|
+
line = raw.decode("utf-8", "replace").strip()
|
|
128
|
+
if not line.startswith("data:"):
|
|
129
|
+
continue
|
|
130
|
+
payload = line[5:].strip()
|
|
131
|
+
if payload == "[DONE]":
|
|
132
|
+
break
|
|
133
|
+
try:
|
|
134
|
+
chunk = json.loads(payload)
|
|
135
|
+
except ValueError:
|
|
136
|
+
continue
|
|
137
|
+
model = chunk.get("model") or model
|
|
138
|
+
usage = chunk.get("usage") or usage
|
|
139
|
+
for choice in chunk.get("choices", []):
|
|
140
|
+
piece = (choice.get("delta") or {}).get("content") or ""
|
|
141
|
+
if piece:
|
|
142
|
+
content.append(piece)
|
|
143
|
+
yield ("delta", piece)
|
|
144
|
+
except urllib.error.HTTPError as e:
|
|
145
|
+
try:
|
|
146
|
+
reason = json.loads(e.read().decode())["error"]["message"][:160]
|
|
147
|
+
except (ValueError, KeyError, TypeError, OSError):
|
|
148
|
+
reason = ""
|
|
149
|
+
e.close()
|
|
150
|
+
raise RuntimeError(f"OpenRouter HTTP {e.code}" + (f": {reason}" if reason else "")) from None
|
|
151
|
+
except (ValueError, TimeoutError):
|
|
152
|
+
raise RuntimeError("Resposta inválida ou timeout do OpenRouter") from None
|
|
153
|
+
except urllib.error.URLError:
|
|
154
|
+
raise RuntimeError("Falha de rede ao acessar OpenRouter") from None
|
|
155
|
+
yield ("final", {"content": "".join(content), "model": model, "usage": usage})
|
|
156
|
+
|
|
108
157
|
def list_free_models(self) -> list:
|
|
109
158
|
"""Lista todos os modelos gratuitos disponíveis."""
|
|
110
159
|
resp = self._request("/models")
|
|
@@ -122,6 +171,7 @@ class OpenRouterClient:
|
|
|
122
171
|
system_prompt: str = None,
|
|
123
172
|
temperature: float = 0.7,
|
|
124
173
|
max_tokens: int = 4096,
|
|
174
|
+
on_delta=None,
|
|
125
175
|
) -> dict:
|
|
126
176
|
"""
|
|
127
177
|
Envia um prompt e retorna a resposta.
|
|
@@ -185,10 +235,26 @@ class OpenRouterClient:
|
|
|
185
235
|
if not self.allow_paid:
|
|
186
236
|
data["provider"] = {"max_price": {"prompt": 0, "completion": 0}}
|
|
187
237
|
try:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
238
|
+
if on_delta is not None:
|
|
239
|
+
# Stream the tokens as they arrive; a chunk may still be a masked placeholder, so
|
|
240
|
+
# unmask each piece best-effort and unmask the whole answer again at the end.
|
|
241
|
+
final = None
|
|
242
|
+
for kind, payload in self._stream_request(data):
|
|
243
|
+
if kind == "delta":
|
|
244
|
+
piece = self.masker.unmask(payload, self._pii_map) if (self.masker and self._pii_map) else payload
|
|
245
|
+
on_delta(piece)
|
|
246
|
+
else:
|
|
247
|
+
final = payload
|
|
248
|
+
if final and final["content"].strip():
|
|
249
|
+
resp = {"choices": [{"message": {"content": final["content"]}}],
|
|
250
|
+
"model": final["model"], "usage": final["usage"]}
|
|
251
|
+
break
|
|
252
|
+
last_error = RuntimeError("OpenRouter não retornou uma resposta válida")
|
|
253
|
+
else:
|
|
254
|
+
resp = self._request("/chat/completions", data=data, method="POST")
|
|
255
|
+
if resp.get("choices"):
|
|
256
|
+
break
|
|
257
|
+
last_error = RuntimeError("OpenRouter não retornou uma resposta válida")
|
|
192
258
|
except RuntimeError as error:
|
|
193
259
|
last_error = error
|
|
194
260
|
# Bad key or no credit: another model won't help
|
|
@@ -310,19 +376,31 @@ def main():
|
|
|
310
376
|
parser.print_help()
|
|
311
377
|
sys.exit(1)
|
|
312
378
|
|
|
379
|
+
events = os.environ.get('SMART_AI_EVENTS') == '1'
|
|
380
|
+
# Stream tokens for the chat (events) and for a human at a terminal; keep --json buffered.
|
|
381
|
+
if events:
|
|
382
|
+
on_delta = lambda piece: print(json.dumps({'type': 'smart.delta', 'text': piece}, ensure_ascii=False), flush=True)
|
|
383
|
+
elif not args.json:
|
|
384
|
+
on_delta = lambda piece: (sys.stdout.write(piece), sys.stdout.flush())
|
|
385
|
+
else:
|
|
386
|
+
on_delta = None
|
|
387
|
+
|
|
313
388
|
try:
|
|
314
389
|
result = client.chat(
|
|
315
390
|
prompt=prompt,
|
|
316
391
|
system_prompt=args.system,
|
|
317
392
|
temperature=args.temperature,
|
|
393
|
+
on_delta=on_delta,
|
|
318
394
|
)
|
|
319
395
|
|
|
320
|
-
if
|
|
396
|
+
if events:
|
|
321
397
|
print(json.dumps({'type': 'smart.answer', **result}, ensure_ascii=False), flush=True)
|
|
322
398
|
elif args.json:
|
|
323
399
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
324
|
-
|
|
400
|
+
elif on_delta is None:
|
|
325
401
|
print(result["content"])
|
|
402
|
+
else:
|
|
403
|
+
print() # end the streamed line
|
|
326
404
|
|
|
327
405
|
except RuntimeError as e:
|
|
328
406
|
print(f"❌ Erro: {e}", file=sys.stderr)
|
package/omni_ai/ui.py
CHANGED
|
@@ -214,10 +214,20 @@ class View:
|
|
|
214
214
|
def __init__(self, prefix=''):
|
|
215
215
|
self.prefix = prefix
|
|
216
216
|
self.pending = None # last action, so its result can be attached under it
|
|
217
|
+
self._streamed = False # whether a streaming answer has started printing
|
|
217
218
|
|
|
218
219
|
def _p(self, text):
|
|
219
220
|
out('\n'.join(self.prefix + line for line in text.split('\n')))
|
|
220
221
|
|
|
222
|
+
def stream_line(self, line):
|
|
223
|
+
"""One completed line of a streaming answer, printed as it arrives (line-granular keeps the
|
|
224
|
+
chat's input box intact, unlike raw partial writes). The first line carries the answer mark."""
|
|
225
|
+
if not self._streamed:
|
|
226
|
+
self._p('\n' + gradient(MARK) + ' ' + line)
|
|
227
|
+
self._streamed = True
|
|
228
|
+
else:
|
|
229
|
+
self._p(' ' + line)
|
|
230
|
+
|
|
221
231
|
def provider(self, tool, detail):
|
|
222
232
|
self._p(dim(' ') + rgb(TOOL_RGB.get(tool, (6, 182, 212)), '● ' + tool) + (dim(' · ' + detail) if detail else ''))
|
|
223
233
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-ai-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "One chat for Claude Code, Codex, Antigravity and OpenRouter, with automatic routing, account fallback, API keys, permission modes and MCP/skill sync.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ai": "bin/ai.js",
|