@agxnte/reidx 2.0.6 → 2.0.8

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
@@ -65,7 +65,7 @@ reid doctor
65
65
  Expected output:
66
66
 
67
67
  ```
68
- reid 2.0.3
68
+ reid 2.0.8
69
69
  settings <path> (found|missing)
70
70
  python <path> (3.13.x)
71
71
  workspace <cwd>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agxnte/reidx",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "Terminal-native personal intelligence and coding CLI (agent-first runtime)",
5
5
  "bin": {
6
6
  "reid": "bin/reidx.js"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "reidx"
3
- version = "2.0.6"
3
+ version = "2.0.8"
4
4
  description = "Terminal-native personal intelligence and coding CLI (agent-first runtime)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """ReidX: terminal-native agent-first CLI runtime."""
2
2
 
3
- __version__ = "2.0.6"
3
+ __version__ = "2.0.8"
@@ -0,0 +1,228 @@
1
+ """Model normalization, validation, and discovery utilities."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+
7
+ from reidx.diagnostics.logger import get_logger
8
+
9
+ log = get_logger("reidx.provider.models")
10
+
11
+ KNOWN_PROVIDER_PREFIXES = {
12
+ "openrouter",
13
+ "anthropic",
14
+ "openai",
15
+ "google",
16
+ "cohere",
17
+ "mistral",
18
+ "meta",
19
+ "meta-llama",
20
+ "microsoft",
21
+ "nvidia",
22
+ "deepseek",
23
+ "qwen",
24
+ "yi",
25
+ "zai",
26
+ "xai",
27
+ "perplexity",
28
+ "together",
29
+ "fireworks",
30
+ "groq",
31
+ "cerebras",
32
+ "deepinfra",
33
+ "sambanova",
34
+ "novita",
35
+ "featherless",
36
+ "chutes",
37
+ "aimlapi",
38
+ "hyperbolic",
39
+ "moonshot",
40
+ "volcengine",
41
+ "hunyuan",
42
+ "upstage",
43
+ "predibase",
44
+ "gravity",
45
+ "infermatic",
46
+ "baseten",
47
+ "anyscale",
48
+ "lambda",
49
+ "kluster",
50
+ "monsterapi",
51
+ "cloudflare-ai",
52
+ "replicate",
53
+ "watsonx",
54
+ "aleph-alpha",
55
+ "ai21",
56
+ "databricks",
57
+ "siliconflow",
58
+ "nebius",
59
+ "lepton",
60
+ "voyage-ai",
61
+ "jan",
62
+ "llamacpp",
63
+ }
64
+
65
+ VALID_VARIANTS = {":free", ":thinking", ":nitro", ":extended", ":online", ":alpha", ":beta"}
66
+ VALID_VARIANTS_LOWER = {v.lower() for v in VALID_VARIANTS}
67
+
68
+ UI_DISPLAY_PATTERN = re.compile(r"\s*\[via\s+([^\]]+)\]", re.IGNORECASE)
69
+ PROVIDER_DASH_PATTERN = re.compile(r"^([A-Za-z0-9\-]+)\s+-\s+(.+)$")
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class NormalizedModel:
74
+ provider: str
75
+ model_id: str
76
+ full_id: str
77
+ variant: str | None
78
+ base_model: str
79
+ is_valid: bool
80
+
81
+
82
+ def normalize_model_id(raw: str, provider_name: str | None = None) -> NormalizedModel:
83
+ original = raw.strip()
84
+
85
+ # Remove UI display text like "[via openrouter]"
86
+ cleaned = UI_DISPLAY_PATTERN.sub("", original).strip()
87
+
88
+ # Remove "ProviderName - " prefix
89
+ dash_match = PROVIDER_DASH_PATTERN.match(cleaned)
90
+ if dash_match:
91
+ cleaned = dash_match.group(2).strip()
92
+
93
+ parts = cleaned.split("/")
94
+
95
+ if not parts or not parts[0]:
96
+ return NormalizedModel(
97
+ provider=provider_name.lower() if provider_name else "",
98
+ model_id=cleaned,
99
+ full_id=cleaned,
100
+ variant=None,
101
+ base_model=cleaned,
102
+ is_valid=False,
103
+ )
104
+
105
+ first_part_lower = parts[0].lower()
106
+ provider = ""
107
+ model_parts = parts
108
+
109
+ if provider_name and provider_name.lower() in KNOWN_PROVIDER_PREFIXES:
110
+ provider = provider_name.lower()
111
+ if first_part_lower == provider:
112
+ model_parts = parts[1:]
113
+ elif first_part_lower in KNOWN_PROVIDER_PREFIXES:
114
+ provider = first_part_lower
115
+ model_parts = parts[1:]
116
+
117
+ if not model_parts:
118
+ return NormalizedModel(
119
+ provider=provider,
120
+ model_id="",
121
+ full_id=cleaned,
122
+ variant=None,
123
+ base_model="",
124
+ is_valid=False,
125
+ )
126
+
127
+ model_id = "/".join(model_parts)
128
+
129
+ variant = None
130
+ base_model = model_id
131
+ for v in VALID_VARIANTS:
132
+ if model_id.lower().endswith(v.lower()):
133
+ variant = v[1:]
134
+ base_model = model_id[: -len(v)]
135
+ break
136
+
137
+ if provider:
138
+ full_id = f"{provider}/{model_id}"
139
+ else:
140
+ full_id = model_id
141
+
142
+ is_valid = bool(provider and base_model and "/" in base_model)
143
+
144
+ return NormalizedModel(
145
+ provider=provider,
146
+ model_id=model_id,
147
+ full_id=full_id,
148
+ variant=variant,
149
+ base_model=base_model,
150
+ is_valid=is_valid,
151
+ )
152
+
153
+
154
+ def denormalize_model_id(normalized: NormalizedModel, include_provider: bool = True) -> str:
155
+ if include_provider and normalized.provider:
156
+ return normalized.full_id
157
+ return normalized.model_id
158
+
159
+
160
+ async def fetch_provider_models(provider) -> list[str]:
161
+ import asyncio
162
+ try:
163
+ loop = asyncio.get_event_loop()
164
+ models = await loop.run_in_executor(None, provider.fetch_models)
165
+ return sorted(models) if models else []
166
+ except Exception as exc:
167
+ log.warning("failed to fetch models from provider: %s", exc)
168
+ return []
169
+
170
+
171
+ def validate_model_against_provider(
172
+ normalized: NormalizedModel,
173
+ available_models: list[str],
174
+ ) -> tuple[bool, str]:
175
+ if not normalized.is_valid:
176
+ return False, f"Invalid model format: {normalized.full_id}"
177
+
178
+ if not available_models:
179
+ return True, "No model list available from provider (skipping validation)"
180
+
181
+ if normalized.full_id in available_models:
182
+ return True, "Model found in provider catalog"
183
+
184
+ if normalized.model_id in available_models:
185
+ return True, "Model found in provider catalog (without provider prefix)"
186
+
187
+ if normalized.base_model in available_models:
188
+ return True, f"Base model found (variant '{normalized.variant}' may not be listed separately)"
189
+
190
+ base_with_provider = f"{normalized.provider}/{normalized.base_model}"
191
+ if base_with_provider in available_models:
192
+ return True, f"Base model found with provider prefix (variant '{normalized.variant}' may not be listed separately)"
193
+
194
+ for avail in available_models:
195
+ if normalized.base_model in avail or avail in normalized.base_model:
196
+ return True, f"Similar model found: {avail}"
197
+
198
+ return False, f"Model '{normalized.full_id}' not found in provider catalog ({len(available_models)} models available)"
199
+
200
+
201
+ class ModelCache:
202
+ def __init__(self) -> None:
203
+ self._cache: dict[str, tuple[list[str], float]] = {}
204
+ self._ttl = 3600
205
+
206
+ def get(self, provider_name: str) -> list[str] | None:
207
+ import time
208
+ if provider_name in self._cache:
209
+ models, timestamp = self._cache[provider_name]
210
+ if time.time() - timestamp < self._ttl:
211
+ return models
212
+ else:
213
+ del self._cache[provider_name]
214
+ return None
215
+
216
+ def set(self, provider_name: str, models: list[str]) -> None:
217
+ import time
218
+ self._cache[provider_name] = (models, time.time())
219
+
220
+ def invalidate(self, provider_name: str) -> None:
221
+ self._cache.pop(provider_name, None)
222
+
223
+
224
+ _model_cache = ModelCache()
225
+
226
+
227
+ def get_model_cache() -> ModelCache:
228
+ return _model_cache
@@ -23,6 +23,11 @@ from pathlib import Path
23
23
  from reidx.diagnostics.logger import get_logger
24
24
  from reidx.provider.anthropic import AnthropicProvider
25
25
  from reidx.provider.base import BaseProvider
26
+ from reidx.provider.models import (
27
+ denormalize_model_id,
28
+ normalize_model_id,
29
+ validate_model_against_provider,
30
+ )
26
31
  from reidx.provider.ollama import OllamaProvider
27
32
  from reidx.provider.openai import OpenAICompatibleProvider, OpenAIProvider
28
33
  from reidx.provider.registry import ProviderRegistry
@@ -153,6 +158,16 @@ def validate_provider(
153
158
  return False, f"{msg} (use --skip-verify to save anyway)"
154
159
  except Exception as exc:
155
160
  return False, f"unexpected error: {exc} (use --skip-verify to save anyway)"
161
+
162
+ if models and record.default_model:
163
+ normalized = normalize_model_id(record.default_model, provider_name=record.name)
164
+ if normalized.is_valid:
165
+ is_valid, msg = validate_model_against_provider(normalized, models)
166
+ if not is_valid:
167
+ return False, f"model validation failed: {msg}"
168
+ # Update the record with the normalized model ID
169
+ record.default_model = denormalize_model_id(normalized)
170
+
156
171
  if models:
157
172
  return True, f"ok ({len(models)} models available)"
158
173
  return True, "connected (no models endpoint or empty list)"
@@ -171,6 +186,7 @@ def load_into(registry: ProviderRegistry, storage_root: Path) -> list[str]:
171
186
 
172
187
 
173
188
  def load_from_database(registry: ProviderRegistry, storage_root: Path) -> list[str]:
189
+ from reidx.provider.models import denormalize_model_id, normalize_model_id
174
190
  from reidx.provider_manager.database import ProviderDatabase
175
191
 
176
192
  added: list[str] = []
@@ -198,10 +214,22 @@ def load_from_database(registry: ProviderRegistry, storage_root: Path) -> list[s
198
214
  except Exception:
199
215
  models = []
200
216
  if models:
201
- provider.default_model = models[0]
202
- sp.default_model = models[0]
217
+ normalized = normalize_model_id(models[0], provider_name=sp.name)
218
+ if normalized.is_valid:
219
+ model = denormalize_model_id(normalized)
220
+ provider.default_model = model
221
+ sp.default_model = model
222
+ db.save_provider(sp)
223
+ log.info("auto-fetched model for %s: %s", sp.name, model)
224
+ elif record.default_model:
225
+ # Normalize the existing model
226
+ normalized = normalize_model_id(record.default_model, provider_name=sp.name)
227
+ if normalized.is_valid:
228
+ model = denormalize_model_id(normalized)
229
+ record.default_model = model
230
+ provider.default_model = model
231
+ sp.default_model = model
203
232
  db.save_provider(sp)
204
- log.info("auto-fetched model for %s: %s", sp.name, models[0])
205
233
  registry.register(sp.name, provider)
206
234
  added.append(sp.name)
207
235
  return added
@@ -9,6 +9,10 @@ from typing import Any
9
9
  from prompt_toolkit.buffer import Buffer
10
10
 
11
11
  from reidx.diagnostics.logger import get_logger
12
+ from reidx.provider.models import (
13
+ denormalize_model_id,
14
+ normalize_model_id,
15
+ )
12
16
  from reidx.provider.store import ProviderRecord, build_provider, validate_provider
13
17
  from reidx.provider_manager.catalog import (
14
18
  ProviderDefinition,
@@ -267,6 +271,12 @@ class ProviderPalette:
267
271
  self._handle_input_enter()
268
272
  self._invalidate()
269
273
  return
274
+ if self.screen == self.MESSAGE:
275
+ self.screen = self._message_next
276
+ self.selected_index = 0
277
+ self._scroll_offset = 0
278
+ self._invalidate()
279
+ return
270
280
  items = self._build_items()
271
281
  if not items:
272
282
  return
@@ -626,7 +636,9 @@ class ProviderPalette:
626
636
  except Exception:
627
637
  models = []
628
638
  if models:
629
- model = models[0]
639
+ normalized = normalize_model_id(models[0], provider_name=name)
640
+ if normalized.is_valid:
641
+ model = denormalize_model_id(normalized)
630
642
 
631
643
  self._commit_wizard_provider(name, kind, base_url, model, auth, api_key)
632
644
 
@@ -750,9 +762,11 @@ class ProviderPalette:
750
762
  except Exception:
751
763
  models = []
752
764
  if models:
753
- provider.default_model = models[0]
754
- sp.default_model = models[0]
755
- self.db.save_provider(sp)
765
+ normalized = normalize_model_id(models[0], provider_name=sp.name)
766
+ if normalized.is_valid:
767
+ provider.default_model = denormalize_model_id(normalized)
768
+ sp.default_model = denormalize_model_id(normalized)
769
+ self.db.save_provider(sp)
756
770
  self.orchestrator.providers.register(sp.name, provider)
757
771
  except (ValueError, TypeError):
758
772
  log.exception("failed to register provider %s", sp.name)
@@ -19,6 +19,7 @@ from rich.text import Text
19
19
  from reidx.config.storage import storage_root
20
20
  from reidx.goals.models import Goal, GoalNodeKind, GoalStatus
21
21
  from reidx.policy.models import PermissionMode
22
+ from reidx.provider.models import denormalize_model_id, normalize_model_id
22
23
  from reidx.provider.store import (
23
24
  SUPPORTED_KINDS,
24
25
  ProviderRecord,
@@ -80,6 +81,7 @@ SLASH_COMMANDS: list[tuple[str, str, str, str]] = [
80
81
  ("/connect", "", "open the interactive provider connection palette", "Providers"),
81
82
  ("/disconnect", "<name>", "remove a saved provider", "Providers"),
82
83
  ("/use", "<name>", "switch this session to a registered provider", "Providers"),
84
+ ("/models", "[provider]", "list available models from providers (optionally filter by provider)", "Providers"),
83
85
  ("/help", "", "show this help", "Meta"),
84
86
  ("/clear", "", "clear the screen", "Meta"),
85
87
  ("/exit", "", f"quit {APP_NAME}", "Meta"),
@@ -484,6 +486,7 @@ def _providers_store(orchestrator: Orchestrator) -> ProviderStore:
484
486
 
485
487
 
486
488
  def _save_to_database(orchestrator: Orchestrator, record: ProviderRecord) -> None:
489
+ from reidx.provider.models import denormalize_model_id, normalize_model_id
487
490
  from reidx.provider_manager import keychain
488
491
  from reidx.provider_manager.database import ProviderDatabase, StoredKey, StoredProvider
489
492
 
@@ -504,7 +507,11 @@ def _save_to_database(orchestrator: Orchestrator, record: ProviderRecord) -> Non
504
507
  existing.base_url = record.base_url
505
508
  existing.auth_method = record.auth_method
506
509
  if record.default_model:
507
- existing.default_model = record.default_model
510
+ normalized = normalize_model_id(record.default_model, provider_name=record.name)
511
+ if normalized.is_valid:
512
+ existing.default_model = denormalize_model_id(normalized)
513
+ else:
514
+ existing.default_model = record.default_model
508
515
  db.save_provider(existing)
509
516
  else:
510
517
  sp = StoredProvider(
@@ -519,6 +526,12 @@ def _save_to_database(orchestrator: Orchestrator, record: ProviderRecord) -> Non
519
526
  )
520
527
  sp.keys.append(k)
521
528
  sp.active_key_id = k.id
529
+ if record.default_model:
530
+ normalized = normalize_model_id(record.default_model, provider_name=record.name)
531
+ if normalized.is_valid:
532
+ sp.default_model = denormalize_model_id(normalized)
533
+ else:
534
+ sp.default_model = record.default_model
522
535
  db.save_provider(sp)
523
536
 
524
537
 
@@ -577,15 +590,18 @@ def _handle_connect(orchestrator: Orchestrator, arg: str) -> None:
577
590
  except Exception:
578
591
  models = []
579
592
  if models:
580
- provider.default_model = models[0]
581
- record.default_model = models[0]
582
- render.print_info(f"auto-detected model: {models[0]}")
593
+ normalized = normalize_model_id(models[0], provider_name=name)
594
+ if normalized.is_valid:
595
+ model = denormalize_model_id(normalized)
596
+ provider.default_model = model
597
+ record.default_model = model
598
+ render.print_info(f"auto-detected model: {model}")
583
599
  _providers_store(orchestrator).save(record)
584
600
  _save_to_database(orchestrator, record)
585
601
  if orchestrator.providers is not None:
586
602
  orchestrator.providers.register(name, provider)
587
- render.print_info(f"connected provider '{name}' ({kind}) {base_url or '(default)'}")
588
- render.print_info(f"switch with: /use {name}")
603
+ render.print_info(f"connected provider '{name}' ({kind})  {base_url or '(default)'}")
604
+ render.print_info(f"switch with: /use {name}")
589
605
 
590
606
 
591
607
  def _handle_disconnect(orchestrator: Orchestrator, arg: str) -> None:
@@ -609,6 +625,42 @@ def _handle_disconnect(orchestrator: Orchestrator, arg: str) -> None:
609
625
  render.print_error(f"no saved provider named '{name}'")
610
626
 
611
627
 
628
+ def _handle_models(orchestrator: Orchestrator, arg: str) -> None:
629
+ """Handle /models command - list available models from providers."""
630
+ provider_filter = arg.strip() or None
631
+
632
+ if orchestrator.providers is None:
633
+ render.print_error("no provider registry available")
634
+ return
635
+
636
+ provider_names = orchestrator.providers.names()
637
+ if not provider_names:
638
+ render.print_info("no providers registered")
639
+ return
640
+
641
+ if provider_filter:
642
+ if provider_filter not in provider_names:
643
+ render.print_error(f"provider '{provider_filter}' not registered (see /providers)")
644
+ return
645
+ provider_names = [provider_filter]
646
+
647
+ for name in provider_names:
648
+ provider = orchestrator.providers.get(name)
649
+ try:
650
+ models = provider.fetch_models()
651
+ except Exception as exc:
652
+ render.print_error(f" {name}: failed to fetch models - {exc}")
653
+ continue
654
+
655
+ if not models:
656
+ render.print_info(f" {name}: (no models returned)")
657
+ continue
658
+
659
+ render.print_info(f" {name} ({len(models)} models):")
660
+ for model in models:
661
+ render.print_info(f" {model}")
662
+
663
+
612
664
  def _handle_use(orchestrator: Orchestrator, arg: str) -> None:
613
665
  name = arg.strip()
614
666
  if not name:
@@ -723,10 +775,12 @@ def handle(orchestrator: Orchestrator, line: str) -> str:
723
775
  _handle_disconnect(orchestrator, arg)
724
776
  elif cmd == "use":
725
777
  _handle_use(orchestrator, arg)
778
+ elif cmd == "models":
779
+ _handle_models(orchestrator, arg)
726
780
  elif cmd == "clear":
727
781
  render.console.clear()
728
782
  elif cmd in ("exit", "quit", "q"):
729
783
  return "exit"
730
784
  else:
731
785
  render.print_error(f"unknown command: /{cmd} (try /help)")
732
- return "continue"
786
+ return "continue"