llm-proxy-cli 0.5.0__tar.gz → 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/PKG-INFO +1 -1
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/llm_proxy_cli.egg-info/PKG-INFO +1 -1
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/llm_proxy_cli.py +67 -44
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/pyproject.toml +1 -1
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/tests/test_query_ai.py +3 -3
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/LICENSE +0 -0
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/README.md +0 -0
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/llm_proxy_cli.egg-info/SOURCES.txt +0 -0
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/llm_proxy_cli.egg-info/dependency_links.txt +0 -0
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/llm_proxy_cli.egg-info/entry_points.txt +0 -0
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/llm_proxy_cli.egg-info/requires.txt +0 -0
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/llm_proxy_cli.egg-info/top_level.txt +0 -0
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/setup.cfg +0 -0
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/tests/test_circuit.py +0 -0
- {llm_proxy_cli-0.5.0 → llm_proxy_cli-0.5.1}/tests/test_discovery.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: llm-proxy-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: A lightweight CLI tool for delegating LLM tasks to expert models across multiple providers.
|
|
5
5
|
Author-email: Kerem Barbaros Karnabat <kbarbaros@hotmail.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: llm-proxy-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: A lightweight CLI tool for delegating LLM tasks to expert models across multiple providers.
|
|
5
5
|
Author-email: Kerem Barbaros Karnabat <kbarbaros@hotmail.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -9,7 +9,7 @@ import logging
|
|
|
9
9
|
from openai import OpenAI
|
|
10
10
|
from filelock import FileLock, Timeout
|
|
11
11
|
|
|
12
|
-
__version__ = "0.5.
|
|
12
|
+
__version__ = "0.5.1"
|
|
13
13
|
|
|
14
14
|
# Optional import for anthropic
|
|
15
15
|
try:
|
|
@@ -48,11 +48,9 @@ def get_api_key(provider):
|
|
|
48
48
|
with open(keys_file, 'r', encoding='utf-8') as f:
|
|
49
49
|
keys = json.load(f)
|
|
50
50
|
env_name = f"{provider.upper()}_API_KEY"
|
|
51
|
-
if keys.get(env_name):
|
|
52
|
-
return keys[env_name]
|
|
53
51
|
except Exception:
|
|
54
52
|
pass
|
|
55
|
-
return os.environ.get(f"{provider.upper()}_API_KEY")
|
|
53
|
+
return os.environ.get(f"{provider.upper()}_API_KEY") or (keys.get(env_name) if 'keys' in locals() else None)
|
|
56
54
|
|
|
57
55
|
|
|
58
56
|
PROVIDERS = {
|
|
@@ -99,7 +97,11 @@ def _is_chat_candidate(model_id):
|
|
|
99
97
|
def _score_model_smart(model_id):
|
|
100
98
|
"""Higher score = bigger / newer / more capable. Used by auto-smart."""
|
|
101
99
|
name = model_id.lower()
|
|
102
|
-
#
|
|
100
|
+
# Handle MoE like 8x7b -> 56b
|
|
101
|
+
def _moe(m):
|
|
102
|
+
return str(float(m.group(1)) * float(m.group(2))) + 'b'
|
|
103
|
+
name = re.sub(r'(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)\s*b', _moe, name)
|
|
104
|
+
# Strip dates and huge context
|
|
103
105
|
name = re.sub(r'20\d{2}[-]?\d{2}[-]?\d{2}', '', name)
|
|
104
106
|
name = re.sub(r'\d{4,}', '', name)
|
|
105
107
|
score = 0.0
|
|
@@ -108,35 +110,36 @@ def _score_model_smart(model_id):
|
|
|
108
110
|
elif "sonnet" in name: score += 40
|
|
109
111
|
elif "haiku" in name: score += 30
|
|
110
112
|
|
|
111
|
-
#
|
|
113
|
+
# Version extraction (handle 3-5 as 3.5 for claude/llama)
|
|
114
|
+
name_wo_size = re.sub(r'\d+(?:\.\d+)?\s*b(?!\w)', '', name)
|
|
115
|
+
ver_str = name_wo_size.replace('_', '-').replace('-r', '-')
|
|
116
|
+
ver_matches = re.findall(r'(\d+(?:[-.]\d+)*)', ver_str)
|
|
117
|
+
best_ver = 0.0
|
|
118
|
+
for v in ver_matches:
|
|
119
|
+
parts = v.replace('-', '.').split('.')
|
|
120
|
+
val = float(f"{parts[0]}.{parts[1]}") if len(parts) >= 2 else (float(parts[0]) if parts[0] else 0.0)
|
|
121
|
+
best_ver = max(best_ver, val)
|
|
122
|
+
score += best_ver * 5
|
|
123
|
+
|
|
124
|
+
# Parameter size
|
|
112
125
|
size_matches = re.findall(r'(\d+(?:\.\d+)?)\s*b(?!\w)', name)
|
|
113
126
|
if size_matches:
|
|
114
127
|
score += max(float(s) for s in size_matches) * 10
|
|
115
|
-
name_wo_size = re.sub(r'\d+(?:\.\d+)?\s*b(?!\w)', '', name)
|
|
116
|
-
else:
|
|
117
|
-
name_wo_size = name
|
|
118
|
-
|
|
119
|
-
# Any remaining digits are treated as generation/version numbers,
|
|
120
|
-
# e.g. "llama-4", "gemini-2.5", "v3.3" -> higher wins.
|
|
121
|
-
version_matches = re.findall(r'(\d+(?:\.\d+)?)', name_wo_size)
|
|
122
|
-
if version_matches:
|
|
123
|
-
score += max(float(v) for v in version_matches) * 5
|
|
124
128
|
|
|
125
129
|
for kw, bonus in (("instruct", 3), ("versatile", 3), ("reasoning", 4), ("chat", 1)):
|
|
126
|
-
if kw in name:
|
|
127
|
-
score += bonus
|
|
130
|
+
if kw in name: score += bonus
|
|
128
131
|
for kw, penalty in (("mini", -2), ("lite", -2), ("tiny", -3), ("preview", -1), ("deprecated", -100)):
|
|
129
|
-
if kw in name:
|
|
130
|
-
score += penalty
|
|
132
|
+
if kw in name: score += penalty
|
|
131
133
|
|
|
132
134
|
return score
|
|
133
135
|
|
|
134
136
|
|
|
135
137
|
def _score_model_fast(model_id):
|
|
136
|
-
"""Higher score = smaller / snappier chat model. Used by auto-fast.
|
|
137
|
-
NON_CHAT_KEYWORDS models are filtered out before this ever runs, so
|
|
138
|
-
"smallest wins" can't land on a non-chat model anymore."""
|
|
138
|
+
"""Higher score = smaller / snappier chat model. Used by auto-fast."""
|
|
139
139
|
name = model_id.lower()
|
|
140
|
+
def _moe(m):
|
|
141
|
+
return str(float(m.group(1)) * float(m.group(2))) + 'b'
|
|
142
|
+
name = re.sub(r'(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)\s*b', _moe, name)
|
|
140
143
|
name = re.sub(r'20\d{2}[-]?\d{2}[-]?\d{2}', '', name)
|
|
141
144
|
name = re.sub(r'\d{4,}', '', name)
|
|
142
145
|
score = 0.0
|
|
@@ -144,23 +147,24 @@ def _score_model_fast(model_id):
|
|
|
144
147
|
if "haiku" in name: score += 20
|
|
145
148
|
elif "sonnet" in name: score += 10
|
|
146
149
|
|
|
150
|
+
name_wo_size = re.sub(r'\d+(?:\.\d+)?\s*b(?!\w)', '', name)
|
|
151
|
+
ver_str = name_wo_size.replace('_', '-').replace('-r', '-')
|
|
152
|
+
ver_matches = re.findall(r'(\d+(?:[-.]\d+)*)', ver_str)
|
|
153
|
+
best_ver = 0.0
|
|
154
|
+
for v in ver_matches:
|
|
155
|
+
parts = v.replace('-', '.').split('.')
|
|
156
|
+
val = float(f"{parts[0]}.{parts[1]}") if len(parts) >= 2 else (float(parts[0]) if parts[0] else 0.0)
|
|
157
|
+
best_ver = max(best_ver, val)
|
|
158
|
+
score += best_ver * 5
|
|
159
|
+
|
|
147
160
|
size_matches = re.findall(r'(\d+(?:\.\d+)?)\s*b(?!\w)', name)
|
|
148
161
|
if size_matches:
|
|
149
|
-
score -= max(float(s) for s in size_matches) * 10
|
|
150
|
-
name_wo_size = re.sub(r'\d+(?:\.\d+)?\s*b(?!\w)', '', name)
|
|
151
|
-
else:
|
|
152
|
-
name_wo_size = name
|
|
153
|
-
|
|
154
|
-
version_matches = re.findall(r'(\d+(?:\.\d+)?)', name_wo_size)
|
|
155
|
-
if version_matches:
|
|
156
|
-
score += max(float(v) for v in version_matches) * 5 # still prefer the newer generation
|
|
162
|
+
score -= max(float(s) for s in size_matches) * 10
|
|
157
163
|
|
|
158
164
|
for kw, bonus in (("instant", 4), ("flash", 4), ("turbo", 4), ("mini", 3), ("lite", 3), ("small", 2), ("instruct", 1)):
|
|
159
|
-
if kw in name:
|
|
160
|
-
score += bonus
|
|
165
|
+
if kw in name: score += bonus
|
|
161
166
|
for kw, penalty in (("preview", -1), ("deprecated", -100)):
|
|
162
|
-
if kw in name:
|
|
163
|
-
score += penalty
|
|
167
|
+
if kw in name: score += penalty
|
|
164
168
|
|
|
165
169
|
return score
|
|
166
170
|
|
|
@@ -370,17 +374,19 @@ def parse_model(model_string, force_refresh_auto=False):
|
|
|
370
374
|
if resolved:
|
|
371
375
|
return provider, resolved
|
|
372
376
|
logger.error(f"Auto-discovery unavailable for '{provider}' ({mode}) and no static fallback was given.")
|
|
373
|
-
return provider,
|
|
377
|
+
return provider, None
|
|
374
378
|
|
|
375
379
|
return provider, model
|
|
376
380
|
|
|
377
381
|
|
|
378
|
-
def query_ai(models_list, prompt, cb: CircuitBreaker, max_retries=2, base_timeout=30, force_refresh_auto=False):
|
|
382
|
+
def query_ai(models_list, prompt, cb: CircuitBreaker, max_retries=2, base_timeout=30, force_refresh_auto=False, stream_out=False):
|
|
379
383
|
if isinstance(models_list, str):
|
|
380
384
|
models_list = [m.strip() for m in models_list.split(',')]
|
|
381
385
|
|
|
382
386
|
for current_model_str in models_list:
|
|
383
387
|
provider, current_model = parse_model(current_model_str, force_refresh_auto=force_refresh_auto)
|
|
388
|
+
if not current_model:
|
|
389
|
+
continue
|
|
384
390
|
# Circuit breaker tracks the *resolved* model, not the literal "auto" alias,
|
|
385
391
|
# since "auto" can point at a different real model over time.
|
|
386
392
|
resolved_key = f"{provider}:{current_model}"
|
|
@@ -410,6 +416,10 @@ def query_ai(models_list, prompt, cb: CircuitBreaker, max_retries=2, base_timeou
|
|
|
410
416
|
messages=[{"role": "user", "content": prompt}], temperature=0.7
|
|
411
417
|
) as stream:
|
|
412
418
|
for text in stream.text_stream:
|
|
419
|
+
if stream_out:
|
|
420
|
+
import sys
|
|
421
|
+
sys.stdout.write(text)
|
|
422
|
+
sys.stdout.flush()
|
|
413
423
|
full_content += text
|
|
414
424
|
else:
|
|
415
425
|
client = OpenAI(
|
|
@@ -423,9 +433,19 @@ def query_ai(models_list, prompt, cb: CircuitBreaker, max_retries=2, base_timeou
|
|
|
423
433
|
for chunk in completion:
|
|
424
434
|
if not chunk.choices: continue
|
|
425
435
|
reasoning = getattr(chunk.choices[0].delta, "reasoning_content", None)
|
|
426
|
-
if reasoning:
|
|
436
|
+
if reasoning:
|
|
437
|
+
full_reasoning += reasoning
|
|
438
|
+
if stream_out:
|
|
439
|
+
import sys
|
|
440
|
+
sys.stderr.write(reasoning)
|
|
441
|
+
sys.stderr.flush()
|
|
427
442
|
content = chunk.choices[0].delta.content
|
|
428
|
-
if content:
|
|
443
|
+
if content:
|
|
444
|
+
if stream_out:
|
|
445
|
+
import sys
|
|
446
|
+
sys.stdout.write(content)
|
|
447
|
+
sys.stdout.flush()
|
|
448
|
+
full_content += content
|
|
429
449
|
|
|
430
450
|
cb.record_success(resolved_key)
|
|
431
451
|
output = ""
|
|
@@ -436,10 +456,12 @@ def query_ai(models_list, prompt, cb: CircuitBreaker, max_retries=2, base_timeou
|
|
|
436
456
|
except Exception as e:
|
|
437
457
|
error_msg = str(e).lower()
|
|
438
458
|
logger.error(f"Attempt {attempt+1} failed for {resolved_key}: {str(e)}")
|
|
439
|
-
cb.record_failure(resolved_key)
|
|
440
|
-
|
|
441
459
|
status_code = getattr(e, "status_code", None)
|
|
442
|
-
if status_code in (
|
|
460
|
+
if status_code in (404, 401, 403) or "404" in error_msg or "not found" in error_msg or "auth" in error_msg:
|
|
461
|
+
break
|
|
462
|
+
if status_code != 429:
|
|
463
|
+
cb.record_failure(resolved_key)
|
|
464
|
+
if status_code == 429:
|
|
443
465
|
break
|
|
444
466
|
|
|
445
467
|
if attempt == max_retries - 1: break
|
|
@@ -453,7 +475,7 @@ def main():
|
|
|
453
475
|
sys.stdout.reconfigure(encoding='utf-8')
|
|
454
476
|
|
|
455
477
|
parser = argparse.ArgumentParser(description="Smart Router: A fault-tolerant CLI tool for LLM delegation.")
|
|
456
|
-
parser.add_argument("-v", "--version", action="version", version=f"
|
|
478
|
+
parser.add_argument("-v", "--version", action="version", version=f"LLM Proxy CLI v{__version__}")
|
|
457
479
|
parser.add_argument("-m", "--models", required=True, help="Comma-separated list of provider:model fallbacks (e.g. nvidia:nemotron,groq:llama3, or groq:auto-smart / groq:auto-fast).")
|
|
458
480
|
parser.add_argument("-p", "--prompt", help="The prompt text to send to the model.")
|
|
459
481
|
parser.add_argument("-f", "--file", help="Path to a text file containing the prompt.")
|
|
@@ -484,8 +506,9 @@ def main():
|
|
|
484
506
|
|
|
485
507
|
cb = CircuitBreaker(args.project, args.max_failures, args.cooldown)
|
|
486
508
|
try:
|
|
487
|
-
response = query_ai(args.models, prompt_text, cb, force_refresh_auto=args.refresh_models)
|
|
488
|
-
|
|
509
|
+
response = query_ai(args.models, prompt_text, cb, force_refresh_auto=args.refresh_models, stream_out=True)
|
|
510
|
+
if not sys.stdout.isatty():
|
|
511
|
+
print(response)
|
|
489
512
|
except Exception as e:
|
|
490
513
|
logger.error(str(e))
|
|
491
514
|
sys.exit(1)
|
|
@@ -117,7 +117,7 @@ def test_query_ai_stops_retrying_immediately_on_404(monkeypatch, mock_cb):
|
|
|
117
117
|
assert result == "ok"
|
|
118
118
|
# A 404 must break the retry loop after a single attempt, not consume all max_retries=3.
|
|
119
119
|
assert not_found_client.chat.completions.create.call_count == 1
|
|
120
|
-
assert mock_cb.record_failure.call_count ==
|
|
120
|
+
assert mock_cb.record_failure.call_count == 0
|
|
121
121
|
|
|
122
122
|
|
|
123
123
|
def test_query_ai_skips_models_in_cooldown(monkeypatch, mock_cb):
|
|
@@ -159,10 +159,10 @@ def test_query_ai_exits_when_every_model_fails(monkeypatch, mock_cb):
|
|
|
159
159
|
failing_client.chat.completions.create.side_effect = Exception("timeout")
|
|
160
160
|
factory = openai_factory({GROQ_URL: failing_client})
|
|
161
161
|
with patch.object(router, "OpenAI", side_effect=factory):
|
|
162
|
-
with pytest.raises(
|
|
162
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
163
163
|
router.query_ai("groq:llama-3.3-70b-versatile", "hi", mock_cb, max_retries=2)
|
|
164
164
|
|
|
165
|
-
assert exc_info.value
|
|
165
|
+
assert "All fallback models failed" in str(exc_info.value)
|
|
166
166
|
assert failing_client.chat.completions.create.call_count == 2
|
|
167
167
|
|
|
168
168
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|