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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llm-proxy-cli
3
- Version: 0.5.0
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.0
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.0"
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
- # Strip dates like 2024-08-06 or context sizes like 32768
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
- # Parameter size, e.g. "8b", "70b", "405b" -> biggest wins, weighted heavily.
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 # smaller size = higher score
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, model # will fail fast in query_ai (model not found / no key)
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: full_reasoning += 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: full_content += 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 (429, 404, 401, 403) or "404" in error_msg or "not found" in error_msg or "auth" in error_msg:
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"Smart Router v{__version__}")
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
- print(response)
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)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "llm-proxy-cli"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  authors = [
9
9
  { name="Kerem Barbaros Karnabat", email="kbarbaros@hotmail.com" }
10
10
  ]
@@ -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 == 1
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(SystemExit) as exc_info:
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.code == 1
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