omni-ai-cli 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +615 -2
- package/bin/ai.js +84 -0
- package/docs/README.en.md +615 -0
- package/docs/README.es.md +618 -0
- package/docs/README.fr.md +630 -0
- package/omni_ai/__init__.py +1 -0
- package/omni_ai/__main__.py +6 -0
- package/omni_ai/chat.py +1458 -0
- package/omni_ai/cli.py +991 -0
- package/omni_ai/config.py +531 -0
- package/omni_ai/deepseek.py +57 -0
- package/omni_ai/grok.py +111 -0
- package/omni_ai/onboarding.py +196 -0
- package/omni_ai/openrouter.py +335 -0
- package/omni_ai/osutils.py +86 -0
- package/omni_ai/pii.py +281 -0
- package/omni_ai/profiles.json +69 -0
- package/omni_ai/skills.py +124 -0
- package/omni_ai/sync.py +199 -0
- package/omni_ai/tools.py +75 -0
- package/omni_ai/ui.py +595 -0
- package/omni_ai/usage.py +305 -0
- package/omni_ai/vendor/prompt_toolkit/LICENSE +27 -0
- package/omni_ai/vendor/prompt_toolkit/__init__.py +52 -0
- package/omni_ai/vendor/prompt_toolkit/application/__init__.py +32 -0
- package/omni_ai/vendor/prompt_toolkit/application/application.py +1626 -0
- package/omni_ai/vendor/prompt_toolkit/application/current.py +195 -0
- package/omni_ai/vendor/prompt_toolkit/application/dummy.py +55 -0
- package/omni_ai/vendor/prompt_toolkit/application/run_in_terminal.py +114 -0
- package/omni_ai/vendor/prompt_toolkit/auto_suggest.py +177 -0
- package/omni_ai/vendor/prompt_toolkit/buffer.py +2029 -0
- package/omni_ai/vendor/prompt_toolkit/cache.py +127 -0
- package/omni_ai/vendor/prompt_toolkit/clipboard/__init__.py +17 -0
- package/omni_ai/vendor/prompt_toolkit/clipboard/base.py +109 -0
- package/omni_ai/vendor/prompt_toolkit/clipboard/in_memory.py +44 -0
- package/omni_ai/vendor/prompt_toolkit/clipboard/pyperclip.py +42 -0
- package/omni_ai/vendor/prompt_toolkit/completion/__init__.py +43 -0
- package/omni_ai/vendor/prompt_toolkit/completion/base.py +438 -0
- package/omni_ai/vendor/prompt_toolkit/completion/deduplicate.py +45 -0
- package/omni_ai/vendor/prompt_toolkit/completion/filesystem.py +118 -0
- package/omni_ai/vendor/prompt_toolkit/completion/fuzzy_completer.py +213 -0
- package/omni_ai/vendor/prompt_toolkit/completion/nested.py +109 -0
- package/omni_ai/vendor/prompt_toolkit/completion/word_completer.py +94 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/__init__.py +0 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/completers/__init__.py +5 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/completers/system.py +64 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/regular_languages/__init__.py +80 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/regular_languages/compiler.py +579 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/regular_languages/completion.py +100 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/regular_languages/lexer.py +94 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/regular_languages/regex_parser.py +279 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/regular_languages/validation.py +60 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/ssh/__init__.py +8 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/ssh/server.py +178 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/telnet/__init__.py +7 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/telnet/log.py +13 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/telnet/protocol.py +209 -0
- package/omni_ai/vendor/prompt_toolkit/contrib/telnet/server.py +428 -0
- package/omni_ai/vendor/prompt_toolkit/cursor_shapes.py +117 -0
- package/omni_ai/vendor/prompt_toolkit/data_structures.py +18 -0
- package/omni_ai/vendor/prompt_toolkit/document.py +1182 -0
- package/omni_ai/vendor/prompt_toolkit/enums.py +19 -0
- package/omni_ai/vendor/prompt_toolkit/eventloop/__init__.py +31 -0
- package/omni_ai/vendor/prompt_toolkit/eventloop/async_generator.py +125 -0
- package/omni_ai/vendor/prompt_toolkit/eventloop/inputhook.py +191 -0
- package/omni_ai/vendor/prompt_toolkit/eventloop/utils.py +101 -0
- package/omni_ai/vendor/prompt_toolkit/eventloop/win32.py +72 -0
- package/omni_ai/vendor/prompt_toolkit/filters/__init__.py +71 -0
- package/omni_ai/vendor/prompt_toolkit/filters/app.py +419 -0
- package/omni_ai/vendor/prompt_toolkit/filters/base.py +261 -0
- package/omni_ai/vendor/prompt_toolkit/filters/cli.py +65 -0
- package/omni_ai/vendor/prompt_toolkit/filters/utils.py +41 -0
- package/omni_ai/vendor/prompt_toolkit/formatted_text/__init__.py +59 -0
- package/omni_ai/vendor/prompt_toolkit/formatted_text/ansi.py +297 -0
- package/omni_ai/vendor/prompt_toolkit/formatted_text/base.py +179 -0
- package/omni_ai/vendor/prompt_toolkit/formatted_text/html.py +145 -0
- package/omni_ai/vendor/prompt_toolkit/formatted_text/pygments.py +32 -0
- package/omni_ai/vendor/prompt_toolkit/formatted_text/utils.py +103 -0
- package/omni_ai/vendor/prompt_toolkit/history.py +306 -0
- package/omni_ai/vendor/prompt_toolkit/input/__init__.py +14 -0
- package/omni_ai/vendor/prompt_toolkit/input/ansi_escape_sequences.py +344 -0
- package/omni_ai/vendor/prompt_toolkit/input/base.py +153 -0
- package/omni_ai/vendor/prompt_toolkit/input/defaults.py +79 -0
- package/omni_ai/vendor/prompt_toolkit/input/posix_pipe.py +118 -0
- package/omni_ai/vendor/prompt_toolkit/input/posix_utils.py +97 -0
- package/omni_ai/vendor/prompt_toolkit/input/typeahead.py +78 -0
- package/omni_ai/vendor/prompt_toolkit/input/vt100.py +309 -0
- package/omni_ai/vendor/prompt_toolkit/input/vt100_parser.py +250 -0
- package/omni_ai/vendor/prompt_toolkit/input/win32.py +749 -0
- package/omni_ai/vendor/prompt_toolkit/input/win32_pipe.py +156 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/__init__.py +22 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/__init__.py +0 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/auto_suggest.py +66 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/basic.py +257 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/completion.py +206 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/cpr.py +30 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/emacs.py +563 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/focus.py +26 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/mouse.py +348 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/named_commands.py +691 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/open_in_editor.py +52 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/page_navigation.py +85 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/scroll.py +190 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/search.py +96 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/bindings/vi.py +2233 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/defaults.py +63 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/digraphs.py +1378 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/emacs_state.py +36 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/key_bindings.py +670 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/key_processor.py +526 -0
- package/omni_ai/vendor/prompt_toolkit/key_binding/vi_state.py +107 -0
- package/omni_ai/vendor/prompt_toolkit/keys.py +222 -0
- package/omni_ai/vendor/prompt_toolkit/layout/__init__.py +147 -0
- package/omni_ai/vendor/prompt_toolkit/layout/containers.py +2738 -0
- package/omni_ai/vendor/prompt_toolkit/layout/controls.py +945 -0
- package/omni_ai/vendor/prompt_toolkit/layout/dimension.py +220 -0
- package/omni_ai/vendor/prompt_toolkit/layout/dummy.py +40 -0
- package/omni_ai/vendor/prompt_toolkit/layout/layout.py +412 -0
- package/omni_ai/vendor/prompt_toolkit/layout/margins.py +304 -0
- package/omni_ai/vendor/prompt_toolkit/layout/menus.py +748 -0
- package/omni_ai/vendor/prompt_toolkit/layout/mouse_handlers.py +56 -0
- package/omni_ai/vendor/prompt_toolkit/layout/processors.py +1010 -0
- package/omni_ai/vendor/prompt_toolkit/layout/screen.py +323 -0
- package/omni_ai/vendor/prompt_toolkit/layout/scrollable_pane.py +494 -0
- package/omni_ai/vendor/prompt_toolkit/layout/utils.py +80 -0
- package/omni_ai/vendor/prompt_toolkit/lexers/__init__.py +21 -0
- package/omni_ai/vendor/prompt_toolkit/lexers/base.py +85 -0
- package/omni_ai/vendor/prompt_toolkit/lexers/pygments.py +328 -0
- package/omni_ai/vendor/prompt_toolkit/log.py +13 -0
- package/omni_ai/vendor/prompt_toolkit/mouse_events.py +85 -0
- package/omni_ai/vendor/prompt_toolkit/output/__init__.py +15 -0
- package/omni_ai/vendor/prompt_toolkit/output/base.py +332 -0
- package/omni_ai/vendor/prompt_toolkit/output/color_depth.py +64 -0
- package/omni_ai/vendor/prompt_toolkit/output/conemu.py +65 -0
- package/omni_ai/vendor/prompt_toolkit/output/defaults.py +106 -0
- package/omni_ai/vendor/prompt_toolkit/output/flush_stdout.py +87 -0
- package/omni_ai/vendor/prompt_toolkit/output/plain_text.py +143 -0
- package/omni_ai/vendor/prompt_toolkit/output/vt100.py +748 -0
- package/omni_ai/vendor/prompt_toolkit/output/win32.py +683 -0
- package/omni_ai/vendor/prompt_toolkit/output/windows10.py +128 -0
- package/omni_ai/vendor/prompt_toolkit/patch_stdout.py +297 -0
- package/omni_ai/vendor/prompt_toolkit/py.typed +0 -0
- package/omni_ai/vendor/prompt_toolkit/renderer.py +814 -0
- package/omni_ai/vendor/prompt_toolkit/search.py +226 -0
- package/omni_ai/vendor/prompt_toolkit/selection.py +58 -0
- package/omni_ai/vendor/prompt_toolkit/shortcuts/__init__.py +46 -0
- package/omni_ai/vendor/prompt_toolkit/shortcuts/dialogs.py +330 -0
- package/omni_ai/vendor/prompt_toolkit/shortcuts/progress_bar/__init__.py +33 -0
- package/omni_ai/vendor/prompt_toolkit/shortcuts/progress_bar/base.py +449 -0
- package/omni_ai/vendor/prompt_toolkit/shortcuts/progress_bar/formatters.py +431 -0
- package/omni_ai/vendor/prompt_toolkit/shortcuts/prompt.py +1513 -0
- package/omni_ai/vendor/prompt_toolkit/shortcuts/utils.py +239 -0
- package/omni_ai/vendor/prompt_toolkit/styles/__init__.py +67 -0
- package/omni_ai/vendor/prompt_toolkit/styles/base.py +184 -0
- package/omni_ai/vendor/prompt_toolkit/styles/defaults.py +236 -0
- package/omni_ai/vendor/prompt_toolkit/styles/named_colors.py +162 -0
- package/omni_ai/vendor/prompt_toolkit/styles/pygments.py +70 -0
- package/omni_ai/vendor/prompt_toolkit/styles/style.py +401 -0
- package/omni_ai/vendor/prompt_toolkit/styles/style_transformation.py +374 -0
- package/omni_ai/vendor/prompt_toolkit/token.py +9 -0
- package/omni_ai/vendor/prompt_toolkit/utils.py +327 -0
- package/omni_ai/vendor/prompt_toolkit/validation.py +192 -0
- package/omni_ai/vendor/prompt_toolkit/widgets/__init__.py +63 -0
- package/omni_ai/vendor/prompt_toolkit/widgets/base.py +995 -0
- package/omni_ai/vendor/prompt_toolkit/widgets/dialogs.py +108 -0
- package/omni_ai/vendor/prompt_toolkit/widgets/menus.py +374 -0
- package/omni_ai/vendor/prompt_toolkit/widgets/toolbars.py +370 -0
- package/omni_ai/vendor/prompt_toolkit/win32_types.py +229 -0
- package/omni_ai/vendor/wcwidth/LICENSE +27 -0
- package/omni_ai/vendor/wcwidth/__init__.py +29 -0
- package/omni_ai/vendor/wcwidth/table_vs16.py +125 -0
- package/omni_ai/vendor/wcwidth/table_wide.py +1493 -0
- package/omni_ai/vendor/wcwidth/table_zero.py +4843 -0
- package/omni_ai/vendor/wcwidth/unicode_versions.py +38 -0
- package/omni_ai/vendor/wcwidth/wcwidth.py +345 -0
- package/package.json +40 -4
package/omni_ai/cli.py
ADDED
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Omni AI router: the `ai` command on macOS, Linux and Windows.
|
|
3
|
+
|
|
4
|
+
ai open the chat
|
|
5
|
+
ai "question" automatic routing
|
|
6
|
+
ai --claude|--codex|--cursor|... "question" (one flag per tool)
|
|
7
|
+
ai --mode plan|edits|auto|yolo "question"
|
|
8
|
+
ai --usage Uso e limites de todas as ferramentas e contas
|
|
9
|
+
ai --setup First-run guide: logins and API keys
|
|
10
|
+
ai --status | --reset | --log | --classify "prompt"
|
|
11
|
+
ai --model ... | --account ... | --key ... | --sync ...
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from . import config as config_tool
|
|
22
|
+
from . import usage
|
|
23
|
+
from .osutils import REPO, enable_ansi, module_cmd, module_env, resolve_bin
|
|
24
|
+
from .tools import CLIS, KEY_NAMES, TOOLS
|
|
25
|
+
|
|
26
|
+
STATE_FILE = Path(os.environ.get('SMART_AI_STATE', '~/.cache/smart-ai/state.json')).expanduser()
|
|
27
|
+
LOG_FILE = Path(os.environ.get('SMART_AI_LOG', '~/.cache/smart-ai/usage.log')).expanduser()
|
|
28
|
+
BINS = {name: os.environ.get(name.upper() + '_BIN', TOOLS[name].bin) for name in CLIS}
|
|
29
|
+
# --claude or -c, --codex or -x ...
|
|
30
|
+
FLAGS = {**{name: name for name in TOOLS}, **{tool.flag: name for name, tool in TOOLS.items()}}
|
|
31
|
+
TOOL_FLAGS = '|'.join('--' + name for name in TOOLS)
|
|
32
|
+
KEY_LIST = '|'.join(KEY_NAMES)
|
|
33
|
+
MODES = ('default', 'plan', 'edits', 'auto', 'yolo')
|
|
34
|
+
RATE_LIMITED = 2
|
|
35
|
+
NOT_READY = 3 # account without login: skip it, don't stop the fallback
|
|
36
|
+
LIMIT_TEXT = re.compile(r'rate.?limit|too many requests|usage limit|session limit|limit reached|quota|resets? (at|in)|out of .*messages', re.I)
|
|
37
|
+
|
|
38
|
+
COLOR = sys.stdout.isatty() and not os.environ.get('NO_COLOR')
|
|
39
|
+
RED, GREEN, YELLOW, BLUE, CYAN, MAGENTA, BOLD, DIM, NC = (
|
|
40
|
+
('\033[0;31m', '\033[0;32m', '\033[1;33m', '\033[0;34m', '\033[0;36m', '\033[0;35m', '\033[1m', '\033[2m', '\033[0m')
|
|
41
|
+
if COLOR else ('',) * 9)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def say(text=''):
|
|
45
|
+
print(text, flush=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ─── config and state ────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def config():
|
|
51
|
+
return config_tool.read_json(config_tool.profiles_path(), {})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def profile_conf(name):
|
|
55
|
+
return config().get('profiles', {}).get(name, {})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def state():
|
|
59
|
+
return config_tool.read_json(STATE_FILE, {})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def set_state(key, value):
|
|
63
|
+
data = state()
|
|
64
|
+
data[key] = value
|
|
65
|
+
config_tool.write_json(STATE_FILE, data)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def log_usage(tool, profile, status):
|
|
69
|
+
# Metadata only: the prompt never goes to the log
|
|
70
|
+
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
with open(LOG_FILE, 'a') as stream:
|
|
72
|
+
stream.write(f"[{time.strftime('%Y-%m-%dT%H:%M:%S%z')}] tool={tool} profile={profile} status={status}\n")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cooldown_left(profile):
|
|
76
|
+
last = state().get(f'{profile}_last_rate_limit')
|
|
77
|
+
if not last:
|
|
78
|
+
return 0
|
|
79
|
+
left = int(last) + int(profile_conf(profile).get('cooldown_minutes', 60)) * 60 - int(time.time())
|
|
80
|
+
if left <= 0:
|
|
81
|
+
set_state(f'{profile}_last_rate_limit', '')
|
|
82
|
+
return 0
|
|
83
|
+
return left
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def mark_rate_limited(profile):
|
|
87
|
+
set_state(f'{profile}_last_rate_limit', str(int(time.time())))
|
|
88
|
+
say(f"{YELLOW}⚠ Conta '{profile}' atingiu o rate limit. Cooldown ativado.{NC}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def mode():
|
|
92
|
+
return 'plan' if os.environ.get('SMART_AI_READ_ONLY') == '1' else os.environ.get('SMART_AI_MODE', 'default')
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def models():
|
|
96
|
+
return config_tool.model_values()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def emit(event):
|
|
100
|
+
if os.environ.get('SMART_AI_EVENTS') == '1':
|
|
101
|
+
say(json.dumps(event, ensure_ascii=False))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def emit_provider(provider, profile=''):
|
|
105
|
+
emit({'type': 'smart.provider', 'provider': provider, 'profile': profile})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run(argv, env=None, stdin=None):
|
|
109
|
+
"""Run a CLI, stream its output live, and return (exit code, captured text)."""
|
|
110
|
+
executable = resolve_bin(argv[0])
|
|
111
|
+
if not executable:
|
|
112
|
+
say(f'{RED}✗ {argv[0]} não encontrado.{NC}')
|
|
113
|
+
return 127, ''
|
|
114
|
+
process = subprocess.Popen([executable] + argv[1:], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
115
|
+
stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
|
|
116
|
+
text=True, encoding='utf-8', errors='replace')
|
|
117
|
+
if stdin is not None:
|
|
118
|
+
process.stdin.write(stdin)
|
|
119
|
+
process.stdin.close()
|
|
120
|
+
captured = []
|
|
121
|
+
for line in process.stdout:
|
|
122
|
+
sys.stdout.write(line)
|
|
123
|
+
sys.stdout.flush()
|
|
124
|
+
captured.append(line)
|
|
125
|
+
return process.wait(), ''.join(captured)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def installed(tool):
|
|
129
|
+
return resolve_bin(BINS[tool]) is not None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def header(label, note):
|
|
133
|
+
say(f'{label} {DIM}({note}){NC}')
|
|
134
|
+
say(f'{DIM}─────────────────────────────────────{NC}')
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ─── executors ───────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def run_claude(profile, prompt):
|
|
140
|
+
if not installed('claude'):
|
|
141
|
+
say(f'{RED}✗ Claude Code não instalado.{NC}')
|
|
142
|
+
say(f'{DIM} Execute o instalador: {REPO / "scripts" / ("install.ps1" if os.name == "nt" else "install.sh")}{NC}')
|
|
143
|
+
return 1
|
|
144
|
+
env = dict(os.environ)
|
|
145
|
+
env.pop('CLAUDE_CONFIG_DIR', None)
|
|
146
|
+
main_creds = Path('~/.claude/.credentials.json').expanduser()
|
|
147
|
+
backup = None
|
|
148
|
+
if profile == 'api-key' or profile.startswith('api-key:'):
|
|
149
|
+
wanted = profile.partition(':')[2]
|
|
150
|
+
env['ANTHROPIC_API_KEY'] = next((v for n, v, _ in config_tool.stored_keys('claude') if not wanted or n == wanted),
|
|
151
|
+
config_tool.stored_key('claude')[0])
|
|
152
|
+
else:
|
|
153
|
+
conf = profile_conf(profile)
|
|
154
|
+
config_dir = conf.get('config_dir')
|
|
155
|
+
if config_dir:
|
|
156
|
+
folder = Path(config_dir).expanduser()
|
|
157
|
+
if not folder.is_dir():
|
|
158
|
+
say(f"{RED}✗ Conta '{profile}' sem login.{NC}")
|
|
159
|
+
say(f'{DIM} Execute: ai --account login {profile}{NC}')
|
|
160
|
+
return NOT_READY
|
|
161
|
+
env['CLAUDE_CONFIG_DIR'] = str(folder)
|
|
162
|
+
elif conf.get('credentials_file'):
|
|
163
|
+
# Legacy profiles: swap the credentials file for this call only
|
|
164
|
+
creds = Path(conf['credentials_file']).expanduser()
|
|
165
|
+
if creds != main_creds:
|
|
166
|
+
if not creds.is_file():
|
|
167
|
+
say(f'{RED}✗ Arquivo de credenciais não encontrado: {creds}{NC}')
|
|
168
|
+
say(f'{DIM} Execute: ai --account add {profile}{NC}')
|
|
169
|
+
return NOT_READY
|
|
170
|
+
backup = main_creds.read_bytes() if main_creds.exists() else b''
|
|
171
|
+
main_creds.write_bytes(creds.read_bytes())
|
|
172
|
+
say(f'{DIM} → Credenciais trocadas para perfil: {profile}{NC}')
|
|
173
|
+
emit_provider('claude', profile)
|
|
174
|
+
header(f'{BLUE}🤖 Claude Code{NC}', f'perfil: {profile}')
|
|
175
|
+
argv = [BINS['claude'], '--model', models()['claude']]
|
|
176
|
+
argv += {'plan': ['--permission-mode', 'plan'], 'edits': ['--permission-mode', 'acceptEdits'],
|
|
177
|
+
'auto': ['--permission-mode', 'auto'], 'yolo': ['--permission-mode', 'bypassPermissions']}.get(mode(), [])
|
|
178
|
+
argv += ['-p', prompt, '--verbose']
|
|
179
|
+
if os.environ.get('SMART_AI_EVENTS') == '1':
|
|
180
|
+
argv += ['--output-format', 'stream-json']
|
|
181
|
+
try:
|
|
182
|
+
code, output = run(argv, env)
|
|
183
|
+
finally:
|
|
184
|
+
if backup is not None:
|
|
185
|
+
main_creds.write_bytes(backup)
|
|
186
|
+
if hit_limit(output, code):
|
|
187
|
+
remember_limit(f'claude:{profile}', output)
|
|
188
|
+
mark_rate_limited(profile)
|
|
189
|
+
log_usage('claude', profile, 'rate_limited')
|
|
190
|
+
return RATE_LIMITED
|
|
191
|
+
if code == 0:
|
|
192
|
+
remember_limit(f'claude:{profile}', '')
|
|
193
|
+
log_usage('claude', profile, 'success' if code == 0 else f'error:{code}')
|
|
194
|
+
return code
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
LIMIT_MESSAGE = re.compile(r"[^\n]*(?:hit your (?:usage|session) limit|quota reached|rate limit|try again at|resets? (?:at|in))[^\n]*", re.I)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
LIMIT_IN = re.compile(r'(?:resets?|try again)\s+in\s+((?:\d+\s*[dhms]\s*)+)', re.I)
|
|
201
|
+
LIMIT_AT = re.compile(r'(?:resets?(?:\s+at)?|try again at)\s+(\d{1,2})(?::(\d{2}))?\s*([ap]m)?\b', re.I)
|
|
202
|
+
LIMIT_FALLBACK = 24 * 3600 # no reset time in the message: stop showing it after a day
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def limit_until(text, at):
|
|
206
|
+
"""When the tool said the limit ends, as epoch seconds; None when the message gives no time."""
|
|
207
|
+
found = LIMIT_IN.search(text)
|
|
208
|
+
if found:
|
|
209
|
+
units = {'d': 86400, 'h': 3600, 'm': 60, 's': 1}
|
|
210
|
+
return at + sum(int(n) * units[u.lower()] for n, u in re.findall(r'(\d+)\s*([dhms])', found.group(1), re.I))
|
|
211
|
+
found = LIMIT_AT.search(text)
|
|
212
|
+
if not found:
|
|
213
|
+
return None
|
|
214
|
+
hour, minute, half = int(found.group(1)), int(found.group(2) or 0), (found.group(3) or '').lower()
|
|
215
|
+
if half == 'pm' and hour < 12:
|
|
216
|
+
hour += 12
|
|
217
|
+
elif half == 'am' and hour == 12:
|
|
218
|
+
hour = 0
|
|
219
|
+
if hour > 23 or minute > 59:
|
|
220
|
+
return None
|
|
221
|
+
seen = time.localtime(at)
|
|
222
|
+
until = time.mktime((seen.tm_year, seen.tm_mon, seen.tm_mday, hour, minute, 0, 0, 0, -1))
|
|
223
|
+
return int(until if until > at else until + 86400)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def remember_limit(key, output):
|
|
227
|
+
"""Keep the tool's own words about its limit (e.g. 'resets 7:40pm') for /usage."""
|
|
228
|
+
match = LIMIT_MESSAGE.search(re.sub(r'\\u2019', "'", output))
|
|
229
|
+
data = state()
|
|
230
|
+
limits = data.setdefault('limits', {})
|
|
231
|
+
if match:
|
|
232
|
+
text = match.group(0)
|
|
233
|
+
inner = re.search(r'"message"\s*:\s*"((?:[^"\\\\]|\\\\.)*)"', text) # JSON error: keep only its message
|
|
234
|
+
if inner:
|
|
235
|
+
text = inner.group(1)
|
|
236
|
+
text = ' '.join(text.split())[:220]
|
|
237
|
+
now = int(time.time())
|
|
238
|
+
limits[key] = {'text': text, 'at': now, 'until': limit_until(text, now)}
|
|
239
|
+
else:
|
|
240
|
+
limits.pop(key, None)
|
|
241
|
+
config_tool.write_json(STATE_FILE, data)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def active_limit(item, now=None):
|
|
245
|
+
"""The stored limit if it still holds; expired ones (reset time passed) count as gone."""
|
|
246
|
+
if not item:
|
|
247
|
+
return None
|
|
248
|
+
now = time.time() if now is None else now
|
|
249
|
+
until = item.get('until') or limit_until(item['text'], item['at']) or item['at'] + LIMIT_FALLBACK
|
|
250
|
+
return dict(item, until=until) if now < until else None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def hit_limit(output, code):
|
|
254
|
+
"""Only Claude's own verdict counts: the final error result, a rejected rate-limit event, or its
|
|
255
|
+
plain error lines. Tool output (files, PR text, command logs) is ignored, so a review that mentions
|
|
256
|
+
"rate limit" can't put an account in cooldown."""
|
|
257
|
+
verdicts = []
|
|
258
|
+
for line in output.splitlines():
|
|
259
|
+
try:
|
|
260
|
+
event = json.loads(line)
|
|
261
|
+
except ValueError:
|
|
262
|
+
verdicts.append(line) # Claude's stderr/plain error lines
|
|
263
|
+
continue
|
|
264
|
+
if not isinstance(event, dict):
|
|
265
|
+
continue
|
|
266
|
+
if event.get('type') == 'result' and event.get('is_error'):
|
|
267
|
+
verdicts.append(str(event.get('result', '')))
|
|
268
|
+
elif event.get('type') == 'rate_limit_event' and \
|
|
269
|
+
(event.get('rate_limit_info') or {}).get('status') in ('rejected', 'exceeded'):
|
|
270
|
+
return True
|
|
271
|
+
if os.environ.get('SMART_AI_EVENTS') != '1':
|
|
272
|
+
# Text mode: only the tail of a failed run is Claude talking about itself
|
|
273
|
+
verdicts = output.splitlines()[-5:] if code != 0 else []
|
|
274
|
+
return any(LIMIT_TEXT.search(v) for v in verdicts)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def over_ceiling(key, fetch, label):
|
|
278
|
+
"""Real % of an account before using it: warns past the alert line, and True when it is at or above
|
|
279
|
+
the router's ceiling (routing.usage_skip_percent), so the account is skipped instead of failing."""
|
|
280
|
+
reading = usage.read(key, fetch)
|
|
281
|
+
for line in usage.alerts(key, reading, label):
|
|
282
|
+
say(f'{YELLOW}⚠ {line}{NC}')
|
|
283
|
+
skip, _ = usage.thresholds()
|
|
284
|
+
top = usage.peak((reading or {}).get('windows'))
|
|
285
|
+
if reading and top >= skip:
|
|
286
|
+
say(f'{DIM} ⏭ {label}: {top:.0f}% usado, acima do teto de {skip:.0f}%; pulando{NC}')
|
|
287
|
+
return True
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def key_account(tool, name):
|
|
292
|
+
"""Limit/cooldown id of an API key: '<tool>:api-key' for the main one, '<tool>:api-key:<name>' for extras."""
|
|
293
|
+
base = f'{tool}:api-key' if tool else 'api-key'
|
|
294
|
+
return base if name in ('principal', 'env') else f'{base}:{name}'
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def run_with_key_retry(tool, argv, env_var, label):
|
|
298
|
+
"""Main login first, then each extra account of the tool (/account add TOOL NAME), then the saved API key."""
|
|
299
|
+
accounts = [('primary', {})] + [(name, config_tool.tool_env(tool, conf)) for name, conf in config_tool.tool_accounts(tool, True)]
|
|
300
|
+
code = 1
|
|
301
|
+
for name, env in accounts:
|
|
302
|
+
account = tool if name == 'primary' else f'{tool}:{name}'
|
|
303
|
+
if cooldown_left(account):
|
|
304
|
+
say(f'{DIM} ⏳ {label}: conta {name} em cooldown{NC}')
|
|
305
|
+
continue
|
|
306
|
+
if tool == 'codex' and over_ceiling(f'codex:{name}', lambda env=env: usage.codex_reading(env or None), f'{label} {name}'):
|
|
307
|
+
continue
|
|
308
|
+
if name != 'primary':
|
|
309
|
+
say(f'{YELLOW}🔄 {label}: tentando a conta {name}{NC}')
|
|
310
|
+
code, output = run(argv, dict(os.environ, **env) if env else None)
|
|
311
|
+
remember_limit(account, output if code != 0 else '')
|
|
312
|
+
if code == 0:
|
|
313
|
+
break
|
|
314
|
+
if LIMIT_TEXT.search(output[-3000:]):
|
|
315
|
+
mark_rate_limited(account)
|
|
316
|
+
for name, key, origin in config_tool.stored_keys(tool):
|
|
317
|
+
account = key_account(tool, name)
|
|
318
|
+
if code == 0 or origin == 'env' or cooldown_left(account):
|
|
319
|
+
continue
|
|
320
|
+
say(f'{YELLOW}🔑 {label}: tentando com a chave {name}{NC}')
|
|
321
|
+
code, output = run(argv, dict(os.environ, **{env_var: key}))
|
|
322
|
+
remember_limit(account, output if code != 0 else '')
|
|
323
|
+
if code != 0 and LIMIT_TEXT.search(output[-3000:]):
|
|
324
|
+
mark_rate_limited(account)
|
|
325
|
+
log_usage(tool, 'N/A', 'success' if code == 0 else 'error')
|
|
326
|
+
return code
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def run_codex(prompt):
|
|
330
|
+
emit_provider('codex')
|
|
331
|
+
header(f'{GREEN}📦 Codex CLI{NC}', 'economizando Claude')
|
|
332
|
+
values = models()
|
|
333
|
+
argv = [BINS['codex'], 'exec', '--skip-git-repo-check', '-m', values['codex'],
|
|
334
|
+
'-c', f'model_reasoning_effort="{values["codex_effort"]}"']
|
|
335
|
+
if os.environ.get('SMART_AI_EVENTS') == '1':
|
|
336
|
+
argv.append('--json')
|
|
337
|
+
argv += {'plan': ['--sandbox', 'read-only'], 'edits': ['--sandbox', 'workspace-write'],
|
|
338
|
+
'auto': ['--sandbox', 'workspace-write', '-c', 'approval_policy="never"'],
|
|
339
|
+
'yolo': ['--dangerously-bypass-approvals-and-sandbox']}.get(mode(), [])
|
|
340
|
+
return run_with_key_retry('codex', argv + ['--', prompt], 'CODEX_API_KEY', 'Codex')
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def run_agy(prompt):
|
|
344
|
+
emit_provider('agy')
|
|
345
|
+
header(f'{MAGENTA}🚀 Antigravity{NC}', 'economizando Claude')
|
|
346
|
+
argv = [BINS['agy']]
|
|
347
|
+
if os.environ.get('SMART_AI_EVENTS') == '1':
|
|
348
|
+
argv += ['--output-format', 'stream-json']
|
|
349
|
+
if models()['agy']:
|
|
350
|
+
argv += ['--model', models()['agy']]
|
|
351
|
+
argv += {'plan': ['--mode', 'plan'], 'edits': ['--mode', 'accept-edits'], 'auto': ['--mode', 'auto'],
|
|
352
|
+
'yolo': ['--dangerously-skip-permissions']}.get(mode(), [])
|
|
353
|
+
return run_with_key_retry('agy', argv + ['-p', prompt], 'GEMINI_API_KEY', 'Antigravity')
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def run_openrouter(prompt):
|
|
357
|
+
emit_provider('openrouter')
|
|
358
|
+
keys = config_tool.stored_keys('openrouter')
|
|
359
|
+
if not keys:
|
|
360
|
+
say(f'{RED}✗ OPENROUTER_API_KEY não configurada.{NC}')
|
|
361
|
+
say(f'{DIM} Execute: ai --key openrouter (ou /key openrouter no chat){NC}')
|
|
362
|
+
log_usage('openrouter', 'N/A', 'no_api_key')
|
|
363
|
+
return 1
|
|
364
|
+
values = models()
|
|
365
|
+
paid = values['openrouter_paid'] == 'on'
|
|
366
|
+
header(f'{YELLOW}🌐 OpenRouter{NC}', ('modelos pagos autorizados' if paid else 'modelo gratuito') + ' + PII masking')
|
|
367
|
+
argv = module_cmd('openrouter')
|
|
368
|
+
if values['openrouter']:
|
|
369
|
+
argv += ['--model', values['openrouter']]
|
|
370
|
+
code = 1
|
|
371
|
+
for i, (name, key, _) in enumerate(keys):
|
|
372
|
+
if i:
|
|
373
|
+
say(f'{YELLOW}🔑 OpenRouter: tentando com a chave {name}{NC}')
|
|
374
|
+
env = module_env(dict(os.environ, OPENROUTER_API_KEY=key, SMART_AI_OPENROUTER_PAID=values['openrouter_paid']))
|
|
375
|
+
code, _ = run(argv, env, stdin=prompt)
|
|
376
|
+
if code == 0:
|
|
377
|
+
break
|
|
378
|
+
log_usage('openrouter', 'N/A', 'success' if code == 0 else 'error')
|
|
379
|
+
return code
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def run_cursor(prompt):
|
|
383
|
+
emit_provider('cursor')
|
|
384
|
+
header(f'{DIM}▲ Cursor CLI{NC}', 'economizando Claude')
|
|
385
|
+
if not installed('cursor'):
|
|
386
|
+
say(f'{RED}✗ Cursor CLI não instalado.{NC}')
|
|
387
|
+
say(f'{DIM} Instale: curl https://cursor.com/install -fsS | bash e rode: cursor-agent login{NC}')
|
|
388
|
+
log_usage('cursor', 'N/A', 'not_installed')
|
|
389
|
+
return 1
|
|
390
|
+
argv = [BINS['cursor'], '-p', '--trust']
|
|
391
|
+
if os.environ.get('SMART_AI_EVENTS') == '1':
|
|
392
|
+
argv += ['--output-format', 'stream-json'] # same events as Claude Code's stream-json
|
|
393
|
+
if models()['cursor']:
|
|
394
|
+
argv += ['--model', models()['cursor']]
|
|
395
|
+
# Without --force the headless agent only proposes changes
|
|
396
|
+
argv += {'plan': ['--mode', 'plan'], 'edits': ['--force'], 'auto': ['--force'], 'yolo': ['--force']}.get(mode(), [])
|
|
397
|
+
# Subscription login first, then each saved CURSOR_API_KEY
|
|
398
|
+
return run_with_key_retry('cursor', argv + [prompt], 'CURSOR_API_KEY', 'Cursor')
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def run_deepseek(prompt):
|
|
402
|
+
emit_provider('deepseek')
|
|
403
|
+
keys = config_tool.stored_keys('deepseek')
|
|
404
|
+
if not keys:
|
|
405
|
+
say(f'{RED}✗ DEEPSEEK_API_KEY não configurada.{NC}')
|
|
406
|
+
say(f'{DIM} Execute: ai --key deepseek (ou /key deepseek no chat){NC}')
|
|
407
|
+
log_usage('deepseek', 'N/A', 'no_api_key')
|
|
408
|
+
return 1
|
|
409
|
+
header(f'{BLUE}◆ DeepSeek{NC}', 'API paga por uso + PII masking')
|
|
410
|
+
argv = module_cmd('deepseek') + (['--model', models()['deepseek']] if models()['deepseek'] else [])
|
|
411
|
+
code = 1
|
|
412
|
+
for i, (name, key, _) in enumerate(keys):
|
|
413
|
+
if i:
|
|
414
|
+
say(f'{YELLOW}🔑 DeepSeek: tentando com a chave {name}{NC}')
|
|
415
|
+
code, _ = run(argv, module_env(dict(os.environ, DEEPSEEK_API_KEY=key)), stdin=prompt)
|
|
416
|
+
if code == 0:
|
|
417
|
+
break
|
|
418
|
+
log_usage('deepseek', 'N/A', 'success' if code == 0 else 'error')
|
|
419
|
+
return code
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def run_grok(prompt):
|
|
423
|
+
emit_provider('grok')
|
|
424
|
+
model = models()['grok']
|
|
425
|
+
if installed('grok'):
|
|
426
|
+
# Grok Build: SuperGrok/X Premium+ login first, the saved xAI key as fallback
|
|
427
|
+
header(f'{BLUE}✦ Grok Build{NC}', 'economizando Claude')
|
|
428
|
+
argv = [BINS['grok'], '--no-auto-update', '--cwd', os.getcwd()]
|
|
429
|
+
if os.environ.get('SMART_AI_EVENTS') == '1':
|
|
430
|
+
argv += ['--output-format', 'streaming-messages-json'] # same events as Claude Code's stream-json
|
|
431
|
+
if model:
|
|
432
|
+
argv += ['-m', model]
|
|
433
|
+
# Headless 'plan' is only a compatibility alias; dontAsk keeps it to read-only tools
|
|
434
|
+
argv += {'plan': ['--permission-mode', 'dontAsk'], 'edits': ['--permission-mode', 'acceptEdits'],
|
|
435
|
+
'auto': ['--permission-mode', 'auto'], 'yolo': ['--permission-mode', 'bypassPermissions']}.get(mode(), [])
|
|
436
|
+
return run_with_key_retry('grok', argv + ['-p', prompt], 'XAI_API_KEY', 'Grok')
|
|
437
|
+
# No CLI: plain chat over the API, paid per use
|
|
438
|
+
keys = config_tool.stored_keys('grok')
|
|
439
|
+
if not keys:
|
|
440
|
+
say(f'{RED}✗ Grok Build não instalado e sem chave da xAI.{NC}')
|
|
441
|
+
say(f'{DIM} Instale: curl -fsSL https://x.ai/cli/install.sh | bash e rode: grok login{NC}')
|
|
442
|
+
say(f'{DIM} Ou use uma chave: ai --key grok (ou /key grok no chat){NC}')
|
|
443
|
+
log_usage('grok', 'N/A', 'no_api_key')
|
|
444
|
+
return 1
|
|
445
|
+
header(f'{BLUE}✦ Grok{NC}', 'API da xAI, pago por uso + PII masking')
|
|
446
|
+
argv = module_cmd('grok') + (['--model', model] if model else [])
|
|
447
|
+
code = 1
|
|
448
|
+
for i, (name, key, _) in enumerate(keys):
|
|
449
|
+
if i:
|
|
450
|
+
say(f'{YELLOW}🔑 Grok: tentando com a chave {name}{NC}')
|
|
451
|
+
code, _ = run(argv, module_env(dict(os.environ, XAI_API_KEY=key)), stdin=prompt)
|
|
452
|
+
if code == 0:
|
|
453
|
+
break
|
|
454
|
+
log_usage('grok', 'N/A', 'success' if code == 0 else 'error')
|
|
455
|
+
return code
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# ─── routing ─────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
# Groq retires models often; the second one covers the first going away
|
|
461
|
+
GROQ_CLASSIFIER_MODELS = [('qwen/qwen3.8-27b', 'none'), ('openai/gpt-oss-20b', 'low')]
|
|
462
|
+
GROK_CLASSIFIER_MODEL = 'grok-4-fast-non-reasoning'
|
|
463
|
+
# Cerebras: free tier and very fast; the second free classifier when Groq is down or out of quota
|
|
464
|
+
CEREBRAS_CLASSIFIER_MODELS = ['llama3.1-8b', 'gpt-oss-120b']
|
|
465
|
+
CLASSIFIER_MODELS = ['nvidia/nemotron-3.5-lightning:free', 'google/gemma-4-26b-a4b-it:free', 'openrouter/free']
|
|
466
|
+
CLASSIFIER_SYSTEM = (
|
|
467
|
+
'You are a router, not an assistant: never answer or carry out the prompt, only label it.\n'
|
|
468
|
+
'The CLI runs inside the user\'s project directory and can read files, run shell and use tools.\n'
|
|
469
|
+
'complex: anything that touches code, files, the repository, shell or GitHub, or that reasons, '
|
|
470
|
+
'plans, debugs, designs, compares trade-offs, or needs more than one step or any real work — '
|
|
471
|
+
'even when short and even when no file is named.\n'
|
|
472
|
+
'simple: ONLY a one-shot general-knowledge chat a plain model answers off the top of its head '
|
|
473
|
+
'with no project context — a definition, a fact, a translation, a quick rewrite of given text.\n'
|
|
474
|
+
'The two tiers cost very differently, so misrouting complex as simple is the expensive mistake: '
|
|
475
|
+
'when the prompt is borderline, mixed, or you are unsure, answer complex.\n'
|
|
476
|
+
'Examples:\n'
|
|
477
|
+
'what is a REST API -> simple\n'
|
|
478
|
+
'translate "good morning" to french -> simple\n'
|
|
479
|
+
'how do I reverse a list in python -> simple\n'
|
|
480
|
+
'why is my build failing -> complex\n'
|
|
481
|
+
'write a function to parse this log -> complex\n'
|
|
482
|
+
'explain how auth works in this app -> complex\n'
|
|
483
|
+
'add retries to the http client -> complex\n'
|
|
484
|
+
'Reply with exactly one word: complex or simple.')
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def ask_text(url, key, data, timeout=6):
|
|
488
|
+
"""One request to an OpenAI-compatible endpoint; the reply text, or None on any failure."""
|
|
489
|
+
import urllib.request
|
|
490
|
+
request = urllib.request.Request(url, data=json.dumps(data).encode(), method='POST', headers={
|
|
491
|
+
'Content-Type': 'application/json', 'Authorization': f'Bearer {key}', 'User-Agent': 'omni-ai-cli'})
|
|
492
|
+
try:
|
|
493
|
+
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
|
494
|
+
return json.loads(resp.read().decode())['choices'][0]['message']['content'] or ''
|
|
495
|
+
except (OSError, ValueError, KeyError, IndexError, TypeError):
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def ask_classifier(url, key, data, labels=('complex', 'simple')):
|
|
500
|
+
"""One labelling request; the first label found in the reply, None on any failure."""
|
|
501
|
+
answer = (ask_text(url, key, data) or '').lower()
|
|
502
|
+
return next((label for label in labels if label in answer), None)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def free_classifiers():
|
|
506
|
+
"""(provider, url, request extras) of the free labelling backends, in the order they are tried."""
|
|
507
|
+
groq = 'https://api.groq.com/openai/v1/chat/completions'
|
|
508
|
+
return [('groq', groq, {'model': model, 'reasoning_effort': effort}) for model, effort in GROQ_CLASSIFIER_MODELS] + [
|
|
509
|
+
('cerebras', 'https://api.cerebras.ai/v1/chat/completions', {'model': model}) for model in CEREBRAS_CLASSIFIER_MODELS] + [
|
|
510
|
+
# Free OpenRouter models fail now and then (rate limit, empty reply); the second try covers most of it
|
|
511
|
+
('openrouter', 'https://openrouter.ai/api/v1/chat/completions',
|
|
512
|
+
{'models': CLASSIFIER_MODELS, 'reasoning': {'enabled': False},
|
|
513
|
+
'provider': {'max_price': {'prompt': 0, 'completion': 0}}}),
|
|
514
|
+
] * 2
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def classify_llm(prompt):
|
|
518
|
+
"""Groq first (large free quota), then Cerebras, then free OpenRouter, then paid Grok; None when none answers."""
|
|
519
|
+
from .pii import PIIMasker
|
|
520
|
+
masked, _ = PIIMasker().mask(prompt)
|
|
521
|
+
messages = [{'role': 'system', 'content': CLASSIFIER_SYSTEM},
|
|
522
|
+
{'role': 'user', 'content': f'<prompt>\n{masked[:4000]}\n</prompt>\nLabel (complex or simple):'}]
|
|
523
|
+
backends = free_classifiers() + [
|
|
524
|
+
# Paid: only reached when every free service fails
|
|
525
|
+
('grok', 'https://api.x.ai/v1/chat/completions', {'model': models()['grok'] or GROK_CLASSIFIER_MODEL}),
|
|
526
|
+
]
|
|
527
|
+
for provider, url, extra in backends:
|
|
528
|
+
for _, key, _ in config_tool.stored_keys(provider):
|
|
529
|
+
# Room for a short reasoning pass on models that can't switch it off
|
|
530
|
+
label = ask_classifier(url, key, dict(extra, messages=messages, temperature=0, max_tokens=300))
|
|
531
|
+
if label:
|
|
532
|
+
CLASSIFIED_BY[0] = provider
|
|
533
|
+
return label
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
CLASSIFIED_BY = ['rules'] # who labelled the last prompt: a classifier service, or the keyword rules
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def classify(prompt):
|
|
541
|
+
prompt = os.environ.get('SMART_AI_CLASSIFY_PROMPT') or prompt
|
|
542
|
+
CLASSIFIED_BY[0] = 'rules'
|
|
543
|
+
return classify_llm(prompt) or classify_rules(prompt)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def classify_rules(prompt):
|
|
547
|
+
lower = prompt.lower()
|
|
548
|
+
routing = config().get('routing', {})
|
|
549
|
+
if any(k in lower for k in routing.get('complex_keywords', [])):
|
|
550
|
+
return 'complex'
|
|
551
|
+
# Anything that acts on the workspace needs an agent with file access; OpenRouter only chats
|
|
552
|
+
if re.search(r'\b(projeto|project|repo|reposit[oó]rio|codebase|c[oó]digo|code|arquivos?|files?|pastas?|folder|'
|
|
553
|
+
r'github|git|commit|push|pull request|pr\b|readme|about|branch|analis|analy[sz]|atuali[sz]|update|'
|
|
554
|
+
r'edit|alter|mud[ae]|ajust|adicion|add|remov|delet|instal|rod[ae]|run|execut|test)', lower):
|
|
555
|
+
return 'complex'
|
|
556
|
+
if re.search(r'\.(py|js|ts|go|rs|java|kt|dart|swift|rb|sh|yml|yaml|json|toml|sql)', prompt):
|
|
557
|
+
return 'complex'
|
|
558
|
+
if re.search(r'(/|\\|~/|\./)[a-zA-Z]', prompt):
|
|
559
|
+
return 'complex'
|
|
560
|
+
if any(k in lower for k in routing.get('simple_keywords', [])):
|
|
561
|
+
return 'simple'
|
|
562
|
+
# Only a genuinely short general question falls through to simple; anything longer, or that
|
|
563
|
+
# mentions code machinery, routes to Claude. Borderline stays complex (the cheap OpenRouter path
|
|
564
|
+
# is for day-to-day chat only).
|
|
565
|
+
if len(prompt) <= int(routing.get('max_simple_length', 120)) and \
|
|
566
|
+
not re.search(r'function|class|module|package|dependency|error|stack|trace|test|spec|'
|
|
567
|
+
r'debug|deploy|build|config|script|api|server|database|query', lower):
|
|
568
|
+
return 'simple'
|
|
569
|
+
return 'complex'
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def claude_with_fallback(prompt):
|
|
573
|
+
for profile in config_tool.enabled_profiles():
|
|
574
|
+
left = cooldown_left(profile)
|
|
575
|
+
if left:
|
|
576
|
+
say(f"{DIM} ⏳ Perfil '{profile}' em cooldown (restam: {left // 60}m{left % 60}s){NC}")
|
|
577
|
+
continue
|
|
578
|
+
conf = config_tool.profiles()['profiles'].get(profile, {})
|
|
579
|
+
if over_ceiling(f'claude:{profile}', lambda conf=conf: usage.claude_reading(conf), f'Claude {profile}'):
|
|
580
|
+
continue
|
|
581
|
+
result = run_claude(profile, prompt)
|
|
582
|
+
if result == 0:
|
|
583
|
+
return 0
|
|
584
|
+
if result not in (RATE_LIMITED, NOT_READY):
|
|
585
|
+
return result
|
|
586
|
+
say(f'{YELLOW}🔄 Tentando próximo perfil...{NC}')
|
|
587
|
+
for name, _, _ in config_tool.stored_keys('claude'):
|
|
588
|
+
account = key_account('', name)
|
|
589
|
+
if cooldown_left(account):
|
|
590
|
+
continue
|
|
591
|
+
say(f'{YELLOW}🔑 Claude: tentando com a chave {name}{NC}')
|
|
592
|
+
result = run_claude(account, prompt)
|
|
593
|
+
if result == 0:
|
|
594
|
+
return 0
|
|
595
|
+
if result != RATE_LIMITED:
|
|
596
|
+
return result
|
|
597
|
+
say(f'\n{RED}✗ Todas as contas Claude estão em rate limit!{NC}')
|
|
598
|
+
say(f'{YELLOW} Tentando resolver com ferramentas alternativas...{NC}\n')
|
|
599
|
+
for tool, runner in (('codex', run_codex), ('agy', run_agy)):
|
|
600
|
+
if installed(tool):
|
|
601
|
+
say(f'{YELLOW}↪ Forçando {tool} como último recurso...{NC}')
|
|
602
|
+
if runner(prompt) == 0:
|
|
603
|
+
return 0
|
|
604
|
+
say(f'\n{RED}✗ Nenhuma ferramenta disponível. Aguarde o cooldown.{NC}')
|
|
605
|
+
return 1
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def runners():
|
|
609
|
+
"""What runs each tool; every entry in tools.TOOLS needs one (the tests check)."""
|
|
610
|
+
return {'claude': claude_with_fallback, 'codex': run_codex, 'cursor': run_cursor, 'agy': run_agy,
|
|
611
|
+
'openrouter': run_openrouter, 'grok': run_grok, 'deepseek': run_deepseek}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def route(prompt, tool='auto'):
|
|
615
|
+
forced = runners()
|
|
616
|
+
if tool in forced:
|
|
617
|
+
return forced[tool](prompt)
|
|
618
|
+
label = classify(prompt)
|
|
619
|
+
# the chat shows this as one short line: why this tool was picked
|
|
620
|
+
emit({'type': 'smart.route', 'label': label, 'by': CLASSIFIED_BY[0]})
|
|
621
|
+
if label == 'complex':
|
|
622
|
+
say(f'{DIM}📊 Classificado como: {CYAN}query complexa{NC}{DIM} → usando Claude{NC}\n')
|
|
623
|
+
return forced['claude'](prompt)
|
|
624
|
+
say(f'{DIM}📊 Classificado como: {YELLOW}query simples{NC}{DIM} → roteando para alternativa{NC}\n')
|
|
625
|
+
routing = config().get('routing', {})
|
|
626
|
+
order = [routing.get('prefer_tool_for_simple', 'codex')] + routing.get('fallback_order', ['openrouter', 'codex', 'agy'])
|
|
627
|
+
# Grok, DeepSeek and Cursor cost money, so auto mode only uses them when listed in routing.fallback_order
|
|
628
|
+
for name in dict.fromkeys(t for t in order if t in forced and t != 'claude'):
|
|
629
|
+
if name == 'grok' and not (installed('grok') or config_tool.stored_key('grok')[0]):
|
|
630
|
+
continue
|
|
631
|
+
if name == 'deepseek' and not config_tool.stored_key('deepseek')[0]:
|
|
632
|
+
continue
|
|
633
|
+
# grok also answers over its API key (checked above); the other CLIs need to be installed
|
|
634
|
+
if name in CLIS and name != 'grok' and not installed(name):
|
|
635
|
+
continue
|
|
636
|
+
if forced[name](prompt) == 0:
|
|
637
|
+
return 0
|
|
638
|
+
say(f'\n{DIM}→ Usando Claude como fallback final{NC}')
|
|
639
|
+
return forced['claude'](prompt)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ─── management commands ─────────────────────────────────────────
|
|
643
|
+
|
|
644
|
+
def version(tool):
|
|
645
|
+
try:
|
|
646
|
+
out = subprocess.run([resolve_bin(BINS[tool]), '--version'], capture_output=True, text=True, timeout=15).stdout
|
|
647
|
+
return out.strip().splitlines()[0] if out.strip() else '?'
|
|
648
|
+
except (OSError, subprocess.TimeoutExpired, TypeError):
|
|
649
|
+
return '?'
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def show_status():
|
|
653
|
+
say(f'{BOLD}{CYAN} 🧠 Omni AI CLI{NC}\n')
|
|
654
|
+
say(f'{BOLD} Perfis Claude:{NC}\n')
|
|
655
|
+
for name, conf in config_tool.ordered(config_tool.profiles()):
|
|
656
|
+
left = cooldown_left(name)
|
|
657
|
+
if not conf.get('enabled', True):
|
|
658
|
+
icon, text = f'{RED}○{NC}', 'desabilitado'
|
|
659
|
+
elif left:
|
|
660
|
+
icon, text = f'{YELLOW}◐{NC}', f'cooldown (restam: {left // 60}m{left % 60}s)'
|
|
661
|
+
else:
|
|
662
|
+
icon, text = f'{GREEN}●{NC}', 'disponível'
|
|
663
|
+
say(f" {icon} {BOLD}{name}{NC}: {conf.get('name', '')}")
|
|
664
|
+
say(f' Status: {text}')
|
|
665
|
+
if conf.get('config_dir'):
|
|
666
|
+
say(f" Login: {GREEN}pasta isolada{NC} {DIM}{conf['config_dir']}{NC}")
|
|
667
|
+
elif conf.get('credentials_file'):
|
|
668
|
+
ok = Path(conf['credentials_file']).expanduser().is_file()
|
|
669
|
+
say(' Credenciais: ' + (f'{GREEN}✓ configurado{NC}' if ok else f'{RED}✗ arquivo não encontrado{NC}'))
|
|
670
|
+
say()
|
|
671
|
+
keys = [p for p in config_tool.KEYS if config_tool.stored_key(p)[0]]
|
|
672
|
+
say(f" Chaves de API: {', '.join(keys) if keys else 'nenhuma (ai --key)'}")
|
|
673
|
+
say(f' Modo padrão: {mode()}\n')
|
|
674
|
+
say(f'{BOLD} Ferramentas:{NC}\n')
|
|
675
|
+
for tool, label in ((name, TOOLS[name].label) for name in CLIS):
|
|
676
|
+
say(f' {GREEN}●{NC} {label}: {version(tool)}' if installed(tool) else f' {RED}○{NC} {label}: não instalado')
|
|
677
|
+
if LOG_FILE.is_file():
|
|
678
|
+
lines = LOG_FILE.read_text(errors='replace').splitlines()
|
|
679
|
+
counts = {t: sum(f'tool={t} ' in l for l in lines) for t in TOOLS}
|
|
680
|
+
say(f'\n{BOLD} 📊 Estatísticas de uso:{NC}')
|
|
681
|
+
say(f' Total de chamadas: {len(lines)}')
|
|
682
|
+
say(' ' + ' | '.join(f'{t}: {n}' for t, n in counts.items()))
|
|
683
|
+
say()
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def ago(seconds):
|
|
687
|
+
seconds = int(seconds)
|
|
688
|
+
return f'{seconds // 3600}h{seconds % 3600 // 60:02d}m' if seconds >= 3600 else f'{seconds // 60}m'
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def show_usage(watch=False):
|
|
692
|
+
"""One screen for every tool and account: who it is, % used of each window (when the tool says),
|
|
693
|
+
or the tool's own limit message. With watch, redraws every 30s until Ctrl-C."""
|
|
694
|
+
if not watch:
|
|
695
|
+
return render_usage()
|
|
696
|
+
try:
|
|
697
|
+
while True:
|
|
698
|
+
sys.stdout.write('\033[H\033[2J')
|
|
699
|
+
render_usage()
|
|
700
|
+
say(f"\n{DIM}atualiza a cada 30s · {time.strftime('%H:%M:%S')} · Ctrl-C sai{NC}")
|
|
701
|
+
time.sleep(30)
|
|
702
|
+
except KeyboardInterrupt:
|
|
703
|
+
say()
|
|
704
|
+
return 0
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def spend_today(today):
|
|
708
|
+
"""tool: (US$, tokens, requests) from the chat's per-request log, for one day."""
|
|
709
|
+
path = LOG_FILE.with_name('costs.jsonl')
|
|
710
|
+
totals = {}
|
|
711
|
+
try:
|
|
712
|
+
rows = path.read_text(errors='replace').splitlines()
|
|
713
|
+
except OSError:
|
|
714
|
+
return totals
|
|
715
|
+
for row in rows:
|
|
716
|
+
try:
|
|
717
|
+
item = json.loads(row)
|
|
718
|
+
except ValueError:
|
|
719
|
+
continue
|
|
720
|
+
if not str(item.get('at', '')).startswith(today):
|
|
721
|
+
continue
|
|
722
|
+
cost, tokens, count = totals.get(item.get('tool', '?'), (0.0, 0, 0))
|
|
723
|
+
totals[item.get('tool', '?')] = (cost + float(item.get('cost') or 0), tokens + int(item.get('tokens') or 0), count + 1)
|
|
724
|
+
return totals
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def render_usage():
|
|
728
|
+
limits = state().get('limits', {})
|
|
729
|
+
today = time.strftime('%Y-%m-%d')
|
|
730
|
+
lines = LOG_FILE.read_text(errors='replace').splitlines() if LOG_FILE.is_file() else []
|
|
731
|
+
skip, alert = usage.thresholds()
|
|
732
|
+
|
|
733
|
+
def calls(tool):
|
|
734
|
+
return sum(1 for l in lines if l.startswith('[' + today) and f'tool={tool} ' in l)
|
|
735
|
+
|
|
736
|
+
def limit_line(key):
|
|
737
|
+
item = active_limit(limits.get(key))
|
|
738
|
+
if not item:
|
|
739
|
+
return ''
|
|
740
|
+
back = f" · volta em {ago(item['until'] - time.time())}" if limit_until(item['text'], item['at']) else ''
|
|
741
|
+
return f"{YELLOW}limitado{NC} {DIM}· {item['text']} (visto há {ago(time.time() - item['at'])}{back}){NC}"
|
|
742
|
+
|
|
743
|
+
def who(info):
|
|
744
|
+
return f" {DIM}· {info['email']}{' · ' + info['plan'] if info.get('plan') else ''}{NC}" if info and info.get('email') else ''
|
|
745
|
+
|
|
746
|
+
def windows(items, pad=15):
|
|
747
|
+
for window in items:
|
|
748
|
+
used = window['used']
|
|
749
|
+
color = RED if used >= max(alert + 10, 90) else YELLOW if used >= alert - 10 else GREEN
|
|
750
|
+
reset = ''
|
|
751
|
+
if window.get('resets'):
|
|
752
|
+
reset = f" {DIM}reseta em {usage.resets_in(window['resets'])} · {usage.clock(window['resets'])}{NC}"
|
|
753
|
+
name = usage.window_label(window['minutes'], window.get('label'))
|
|
754
|
+
filled = usage.bar(used, 24).count('█')
|
|
755
|
+
say(f"{' ' * pad}{DIM}{name:<7}{NC} {color}{'█' * filled}{NC}{DIM}{'░' * (24 - filled)}{NC} {BOLD}{color}{used:>3.0f}%{NC}{reset}")
|
|
756
|
+
|
|
757
|
+
def keys_of(tool, prefix):
|
|
758
|
+
for name, value, origin in config_tool.stored_keys(tool):
|
|
759
|
+
account = key_account(prefix, name)
|
|
760
|
+
text = limit_line(account if prefix else f'claude:{account}')
|
|
761
|
+
left = cooldown_left(account)
|
|
762
|
+
status = (text or f'{YELLOW}limitada{NC}') + f' {DIM}· volta em {left // 60}m{NC}' if left else text or f'{GREEN}configurada{NC}'
|
|
763
|
+
say(f" {'chave ' + name:<12} {status} {DIM}· {value[:7]}…{value[-4:]}{' · variável de ambiente' if origin == 'env' else ''}{NC}")
|
|
764
|
+
|
|
765
|
+
def state_of(live, fallback_key, cooldown=0):
|
|
766
|
+
if live and live.get('windows'):
|
|
767
|
+
top = usage.peak(live['windows'])
|
|
768
|
+
if live.get('blocked') or top >= 100:
|
|
769
|
+
return f'{RED}no limite{NC}'
|
|
770
|
+
note = f" {DIM}(última leitura há {ago(time.time() - live['at'])}; a Anthropic pediu uma pausa nas consultas){NC}" \
|
|
771
|
+
if live.get('stale') else ''
|
|
772
|
+
if top >= skip:
|
|
773
|
+
return f'{YELLOW}acima do teto de {skip:.0f}% · o Omni pula{NC}' + note
|
|
774
|
+
return f'{GREEN}disponível{NC}' + note
|
|
775
|
+
text = limit_line(fallback_key)
|
|
776
|
+
if not text and not cooldown and fallback_key.startswith('claude:') and usage.load_cache().get('backoff', {}).get(fallback_key, 0) > time.time():
|
|
777
|
+
text = f"{GREEN}disponível{NC} {DIM}· uso indisponível agora: a Anthropic pediu uma pausa nas consultas{NC}"
|
|
778
|
+
if cooldown:
|
|
779
|
+
return (text or f'{YELLOW}limitado{NC}') + f' {DIM}· o Omni tenta de novo em {cooldown // 60}m{NC}'
|
|
780
|
+
return text or f'{GREEN}sem limite registrado{NC}'
|
|
781
|
+
|
|
782
|
+
profiles = [(n, c) for n, c in config_tool.ordered(config_tool.profiles()) if c.get('enabled', True)]
|
|
783
|
+
codex = [('primary', None)] + [(n, config_tool.tool_env('codex', c)) for n, c in config_tool.tool_accounts('codex', True)] \
|
|
784
|
+
if installed('codex') else []
|
|
785
|
+
or_keys = config_tool.stored_keys('openrouter')
|
|
786
|
+
if sys.stdout.isatty():
|
|
787
|
+
sys.stdout.write(f'{DIM}consultando contas…{NC}\r')
|
|
788
|
+
sys.stdout.flush()
|
|
789
|
+
data = usage.snapshot(profiles, codex, or_keys)
|
|
790
|
+
if sys.stdout.isatty():
|
|
791
|
+
sys.stdout.write('\033[K')
|
|
792
|
+
say(f'{BOLD}Uso e limites{NC} {DIM}% usado de cada janela · teto do roteador {skip:.0f}% · alerta em {alert:.0f}%{NC}\n')
|
|
793
|
+
|
|
794
|
+
say(f'{BOLD}Claude{NC} {DIM}{calls("claude")} chamadas hoje{NC}')
|
|
795
|
+
for name, conf in config_tool.ordered(config_tool.profiles()):
|
|
796
|
+
if not conf.get('enabled', True):
|
|
797
|
+
say(f' {name:<12} {DIM}desativada{NC}')
|
|
798
|
+
continue
|
|
799
|
+
info = data.get(('claude-who', name))
|
|
800
|
+
if not info and config_tool.logged_in(conf) is False:
|
|
801
|
+
say(f' {name:<12} {RED}sem login{NC} {DIM}· /account login {name}{NC}')
|
|
802
|
+
continue
|
|
803
|
+
live = data.get(('claude', name))
|
|
804
|
+
say(f' {name:<12} {state_of(live, f"claude:{name}", cooldown_left(name))}{who(info)}')
|
|
805
|
+
windows((live or {}).get('windows', []))
|
|
806
|
+
keys_of('claude', '')
|
|
807
|
+
say(f' {DIM}+ nova conta: /account add NOME · outra chave: /key add claude [NOME]{NC}')
|
|
808
|
+
for tool, label in ((name, TOOLS[name].label) for name in CLIS if TOOLS[name].in_usage):
|
|
809
|
+
say(f'\n{BOLD}{label}{NC} {DIM}{calls(tool)} chamadas hoje{NC}')
|
|
810
|
+
if not installed(tool):
|
|
811
|
+
say(f' {RED}não instalado{NC}')
|
|
812
|
+
keys_of(tool, tool)
|
|
813
|
+
say(f" {DIM}+ usar com chave de API: /key {'add ' if config_tool.stored_keys(tool) else ''}{tool}{NC}")
|
|
814
|
+
continue
|
|
815
|
+
for name, conf in [('primary', None)] + (config_tool.tool_accounts(tool, True) if tool in config_tool.TOOL_HOMES else []):
|
|
816
|
+
account = tool if conf is None else f'{tool}:{name}'
|
|
817
|
+
live = data.get(('codex', name)) if tool == 'codex' else None
|
|
818
|
+
say(f" {'assinatura' if conf is None else name:<12} {state_of(live, account, cooldown_left(account))}{who(live)}")
|
|
819
|
+
windows((live or {}).get('windows', []))
|
|
820
|
+
keys_of(tool, tool)
|
|
821
|
+
key_hint = f'outra chave: /key add {tool} [NOME]' if config_tool.stored_keys(tool) else f'chave de reserva: /key {tool}'
|
|
822
|
+
say(f' {DIM}+ nova conta: /account add {tool} NOME · {key_hint}{NC}')
|
|
823
|
+
say(f'\n{BOLD}OpenRouter{NC} {DIM}{calls("openrouter")} chamadas hoje{NC}')
|
|
824
|
+
if not or_keys:
|
|
825
|
+
say(f' {DIM}sem chave · /key openrouter{NC}')
|
|
826
|
+
for name, value, _ in or_keys:
|
|
827
|
+
credits = data.get(('openrouter', name))
|
|
828
|
+
label = f"chave {name}"
|
|
829
|
+
if credits and credits['limit']:
|
|
830
|
+
say(f" {label:<12} {GREEN}configurada{NC} {DIM}· US$ {credits['used']:.2f} de US$ {float(credits['limit']):.2f}{NC}")
|
|
831
|
+
windows([{'minutes': 0, 'used': credits['used'] / float(credits['limit']) * 100}])
|
|
832
|
+
elif credits:
|
|
833
|
+
say(f" {label:<12} {GREEN}configurada{NC} {DIM}· US$ {credits['used']:.2f} usados · sem teto{NC}")
|
|
834
|
+
else:
|
|
835
|
+
say(f" {label:<12} {GREEN}configurada{NC}")
|
|
836
|
+
say(f" {DIM}+ outra chave: /key add openrouter [NOME]{NC}")
|
|
837
|
+
spent = spend_today(today)
|
|
838
|
+
if spent:
|
|
839
|
+
say(f'\n{BOLD}Gasto de hoje{NC} {DIM}pedidos feitos pelo chat; custo só quando a ferramenta informa{NC}')
|
|
840
|
+
for tool, (cost, tokens, count) in sorted(spent.items(), key=lambda item: -item[1][0]):
|
|
841
|
+
money = f'US$ {cost:.4f}' if cost else 'sem custo informado'
|
|
842
|
+
say(f' {tool:<12} {money:<22} {DIM}{tokens:,} tokens · {count} pedido(s){NC}'.replace(',', '.'))
|
|
843
|
+
total = sum(cost for cost, _, _ in spent.values())
|
|
844
|
+
if total:
|
|
845
|
+
say(f' {"total":<12} {BOLD}US$ {total:.4f}{NC}')
|
|
846
|
+
say(f'\n{DIM}Teto e alerta: routing.usage_skip_percent e routing.usage_alert_percent no profiles.json · /usage watch{NC}')
|
|
847
|
+
return 0
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def reset_cooldowns():
|
|
851
|
+
data = state()
|
|
852
|
+
for key in list(data):
|
|
853
|
+
if key.endswith('_last_rate_limit'):
|
|
854
|
+
data[key] = ''
|
|
855
|
+
config_tool.write_json(STATE_FILE, data)
|
|
856
|
+
say(f'{GREEN}✓ Cooldowns resetados para todos os perfis.{NC}')
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def show_log():
|
|
860
|
+
if not LOG_FILE.is_file():
|
|
861
|
+
say(f'{DIM}Nenhum log encontrado ainda.{NC}')
|
|
862
|
+
return
|
|
863
|
+
say(f'{BOLD}Últimas 20 entradas do log:{NC}\n')
|
|
864
|
+
say('\n'.join(LOG_FILE.read_text(errors='replace').splitlines()[-20:]))
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def update():
|
|
868
|
+
"""Pull the latest version into the install folder; settings live in ~/.config and are kept."""
|
|
869
|
+
if (REPO / '.git').is_dir() and resolve_bin('git'):
|
|
870
|
+
code = subprocess.call(['git', '-C', str(REPO), 'pull', '--ff-only'])
|
|
871
|
+
say(f'{GREEN}✓ Omni AI atualizado.{NC}' if code == 0 else f'{RED}✗ Não consegui atualizar; veja a mensagem acima.{NC}')
|
|
872
|
+
return code
|
|
873
|
+
if os.environ.get('SMART_AI_INSTALLER') == 'npm':
|
|
874
|
+
command = 'npm install -g omni-ai-cli@latest'
|
|
875
|
+
say(f'Atualizando com: {command}')
|
|
876
|
+
npm = resolve_bin('npm')
|
|
877
|
+
return subprocess.call([npm, 'install', '-g', 'omni-ai-cli@latest']) if npm else 1
|
|
878
|
+
if os.name == 'nt':
|
|
879
|
+
command = 'irm https://raw.githubusercontent.com/samuelrms/omni-ai-cli/main/get.ps1 | iex'
|
|
880
|
+
else:
|
|
881
|
+
command = 'curl -fsSL https://raw.githubusercontent.com/samuelrms/omni-ai-cli/main/get.sh | bash'
|
|
882
|
+
say(f'Instalação sem git. Para atualizar, rode:\n {command}')
|
|
883
|
+
return 1
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
USAGE = f'''{BOLD}Uso:{NC}
|
|
887
|
+
ai Abrir o chat (/help dentro dele)
|
|
888
|
+
ai "pergunta" Roteamento automático
|
|
889
|
+
ai {TOOL_FLAGS} "pergunta"
|
|
890
|
+
ai --mode plan|edits|auto|yolo "pergunta"
|
|
891
|
+
ai --model [chave valor] Modelos
|
|
892
|
+
ai --account [list|add|login|sync|remove] NOME
|
|
893
|
+
ai --key [{KEY_LIST}|remove X]
|
|
894
|
+
ai --sync [--dry-run] MCPs e skills entre Claude, Codex e agy
|
|
895
|
+
ai --usage Uso e limites de todas as ferramentas e contas
|
|
896
|
+
ai --setup Guia de primeiro acesso: logins e chaves
|
|
897
|
+
ai --install Instala as ferramentas que faltam (pergunta antes)
|
|
898
|
+
ai --status | --reset | --log | --classify "prompt"
|
|
899
|
+
ai --update Atualizar para a versão mais nova
|
|
900
|
+
'''
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def main(argv):
|
|
904
|
+
enable_ansi()
|
|
905
|
+
if not argv and sys.stdin.isatty():
|
|
906
|
+
from . import chat
|
|
907
|
+
return chat.main([])
|
|
908
|
+
head = argv[0] if argv else ''
|
|
909
|
+
delegated = {'--model': ('config_tool', ['model']), '-m': ('config_tool', ['model']),
|
|
910
|
+
'--account': ('config_tool', ['account']), '--key': ('config_tool', ['key']),
|
|
911
|
+
'--keys': ('config_tool', ['key']), '--setup-openrouter': ('config_tool', ['key', 'set', 'openrouter']),
|
|
912
|
+
'--setup-secondary': ('config_tool', ['account', 'add', 'secondary'])}
|
|
913
|
+
if head in ('--chat', '--interactive', '-i', '--resume', '--sessions'):
|
|
914
|
+
from . import chat
|
|
915
|
+
return chat.main(argv[1:] if head in ('--chat', '--interactive', '-i') else argv)
|
|
916
|
+
if head in delegated:
|
|
917
|
+
_, base = delegated[head]
|
|
918
|
+
try:
|
|
919
|
+
config_tool.main(base + argv[1:])
|
|
920
|
+
except SystemExit as error:
|
|
921
|
+
if error.code not in (None, 0):
|
|
922
|
+
print(error.code, file=sys.stderr)
|
|
923
|
+
return 1
|
|
924
|
+
return 0
|
|
925
|
+
if head in ('--update', '--upgrade'):
|
|
926
|
+
return update()
|
|
927
|
+
if head == '--sync':
|
|
928
|
+
from . import sync
|
|
929
|
+
return sync.main(argv[1:])
|
|
930
|
+
tool, words, i = 'auto', [], 0
|
|
931
|
+
while i < len(argv):
|
|
932
|
+
arg = argv[i]
|
|
933
|
+
if arg == '--':
|
|
934
|
+
words += argv[i + 1:]
|
|
935
|
+
break
|
|
936
|
+
if arg.lstrip('-') in FLAGS and arg.startswith('-'):
|
|
937
|
+
tool = FLAGS[arg.lstrip('-')]
|
|
938
|
+
elif arg == '--mode':
|
|
939
|
+
value = argv[i + 1] if i + 1 < len(argv) else ''
|
|
940
|
+
if value not in MODES:
|
|
941
|
+
print('Modo inválido: use default, plan, edits, auto ou yolo', file=sys.stderr)
|
|
942
|
+
return 1
|
|
943
|
+
os.environ['SMART_AI_MODE'] = value
|
|
944
|
+
i += 1
|
|
945
|
+
elif arg in ('--status', '-s'):
|
|
946
|
+
show_status()
|
|
947
|
+
return 0
|
|
948
|
+
elif arg in ('--setup', '--install'):
|
|
949
|
+
from . import onboarding
|
|
950
|
+
onboarding.main(install_missing=arg == '--install' or 'install' in argv[i + 1:])
|
|
951
|
+
return 0
|
|
952
|
+
elif arg in ('--usage', '--limits'):
|
|
953
|
+
return show_usage(watch='watch' in argv[i + 1:] or '--watch' in argv[i + 1:])
|
|
954
|
+
elif arg in ('--reset', '-r'):
|
|
955
|
+
reset_cooldowns()
|
|
956
|
+
return 0
|
|
957
|
+
elif arg in ('--log', '-l'):
|
|
958
|
+
show_log()
|
|
959
|
+
return 0
|
|
960
|
+
elif arg == '--classify':
|
|
961
|
+
prompt = ' '.join(argv[i + 1:])
|
|
962
|
+
result = classify(prompt)
|
|
963
|
+
say(f'Prompt: {DIM}"{prompt}"{NC}')
|
|
964
|
+
say('Classificação: ' + (f'{GREEN}SIMPLES{NC} → alternativas' if result == 'simple' else f'{CYAN}COMPLEXA{NC} → Claude'))
|
|
965
|
+
return 0
|
|
966
|
+
elif arg in ('--help', '-h'):
|
|
967
|
+
say(USAGE)
|
|
968
|
+
return 0
|
|
969
|
+
else:
|
|
970
|
+
words.append(arg)
|
|
971
|
+
i += 1
|
|
972
|
+
prompt = ' '.join(words).strip()
|
|
973
|
+
if not prompt:
|
|
974
|
+
say(USAGE)
|
|
975
|
+
return 1
|
|
976
|
+
return route(prompt, tool)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def entry():
|
|
980
|
+
try:
|
|
981
|
+
return main(sys.argv[1:])
|
|
982
|
+
except KeyboardInterrupt:
|
|
983
|
+
return 130
|
|
984
|
+
except BrokenPipeError:
|
|
985
|
+
# Output piped into something that stopped reading (e.g. `ai --usage | head`)
|
|
986
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
987
|
+
return 0
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
if __name__ == '__main__':
|
|
991
|
+
sys.exit(entry())
|