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 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
- self.answer(job, event.get('content', ''))
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
 
@@ -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
- resp = self._request("/chat/completions", data=data, method="POST")
189
- if resp.get("choices"):
190
- break
191
- last_error = RuntimeError("OpenRouter não retornou uma resposta válida")
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 os.environ.get('SMART_AI_EVENTS') == '1':
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
- else:
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.1.0",
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",