@agxnte/reidx 2.0.5 → 2.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agxnte/reidx",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
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.5"
3
+ version = "2.0.6"
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.5"
3
+ __version__ = "2.0.6"
@@ -1,42 +1,75 @@
1
- """Shared HTTP helper for provider clients — stdlib urllib, no extra deps."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import urllib.error
6
- import urllib.request
7
-
8
- TIMEOUT_SECONDS = 120
9
-
10
-
11
- def post_json(url: str, payload: dict, headers: dict[str, str], timeout: int = TIMEOUT_SECONDS) -> dict:
12
- """POST a JSON payload, return the parsed JSON response.
13
-
14
- Raises RuntimeError on HTTP or network errors — providers surface those
15
- to the agent loop, which turns them into tool-result errors rather than
16
- crashing the turn.
17
- """
18
- body = json.dumps(payload).encode("utf-8")
19
- hdrs = {"content-type": "application/json", **headers}
20
- req = urllib.request.Request(url, data=body, headers=hdrs, method="POST")
21
- try:
22
- with urllib.request.urlopen(req, timeout=timeout) as resp:
23
- raw = resp.read().decode("utf-8")
24
- except urllib.error.HTTPError as exc:
25
- err_body = exc.read().decode("utf-8", errors="replace")[:500]
26
- raise RuntimeError(f"HTTP {exc.code}: {err_body}") from exc
27
- except urllib.error.URLError as exc:
28
- raise RuntimeError(f"connection error: {exc}") from exc
29
- return json.loads(raw)
30
-
31
-
32
- def get_json(url: str, headers: dict[str, str], timeout: int = TIMEOUT_SECONDS) -> dict:
33
- req = urllib.request.Request(url, headers=headers, method="GET")
34
- try:
35
- with urllib.request.urlopen(req, timeout=timeout) as resp:
36
- raw = resp.read().decode("utf-8")
37
- except urllib.error.HTTPError as exc:
38
- err_body = exc.read().decode("utf-8", errors="replace")[:500]
39
- raise RuntimeError(f"HTTP {exc.code}: {err_body}") from exc
40
- except urllib.error.URLError as exc:
41
- raise RuntimeError(f"connection error: {exc}") from exc
42
- return json.loads(raw)
1
+ """Shared HTTP helper for provider clients — stdlib urllib, no extra deps."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import ssl
7
+ import urllib.error
8
+ import urllib.request
9
+
10
+ TIMEOUT_SECONDS = 120
11
+
12
+ _SSL_CONTEXT: ssl.SSLContext | None = None
13
+
14
+
15
+ def _build_ssl_context() -> ssl.SSLContext:
16
+ insecure = os.environ.get("REIDX_INSECURE", "").strip().lower() in ("1", "true", "yes", "on")
17
+ if insecure:
18
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
19
+ ctx.check_hostname = False
20
+ ctx.verify_mode = ssl.CERT_NONE
21
+ return ctx
22
+
23
+ try:
24
+ import certifi
25
+ return ssl.create_default_context(cafile=certifi.where())
26
+ except ImportError:
27
+ pass
28
+
29
+ ctx = ssl.create_default_context()
30
+ try:
31
+ ctx.load_default_certs(ssl.Purpose.SERVER_AUTH)
32
+ except ssl.SSLError:
33
+ pass
34
+ return ctx
35
+
36
+
37
+ def _ssl_ctx() -> ssl.SSLContext:
38
+ global _SSL_CONTEXT
39
+ if _SSL_CONTEXT is None:
40
+ _SSL_CONTEXT = _build_ssl_context()
41
+ return _SSL_CONTEXT
42
+
43
+
44
+ def post_json(url: str, payload: dict, headers: dict[str, str], timeout: int = TIMEOUT_SECONDS) -> dict:
45
+ """POST a JSON payload, return the parsed JSON response.
46
+
47
+ Raises RuntimeError on HTTP or network errors — providers surface those
48
+ to the agent loop, which turns them into tool-result errors rather than
49
+ crashing the turn.
50
+ """
51
+ body = json.dumps(payload).encode("utf-8")
52
+ hdrs = {"content-type": "application/json", **headers}
53
+ req = urllib.request.Request(url, data=body, headers=hdrs, method="POST")
54
+ try:
55
+ with urllib.request.urlopen(req, timeout=timeout, context=_ssl_ctx()) as resp:
56
+ raw = resp.read().decode("utf-8")
57
+ except urllib.error.HTTPError as exc:
58
+ err_body = exc.read().decode("utf-8", errors="replace")[:500]
59
+ raise RuntimeError(f"HTTP {exc.code}: {err_body}") from exc
60
+ except urllib.error.URLError as exc:
61
+ raise RuntimeError(f"connection error: {exc}") from exc
62
+ return json.loads(raw)
63
+
64
+
65
+ def get_json(url: str, headers: dict[str, str], timeout: int = TIMEOUT_SECONDS) -> dict:
66
+ req = urllib.request.Request(url, headers=headers, method="GET")
67
+ try:
68
+ with urllib.request.urlopen(req, timeout=timeout, context=_ssl_ctx()) as resp:
69
+ raw = resp.read().decode("utf-8")
70
+ except urllib.error.HTTPError as exc:
71
+ err_body = exc.read().decode("utf-8", errors="replace")[:500]
72
+ raise RuntimeError(f"HTTP {exc.code}: {err_body}") from exc
73
+ except urllib.error.URLError as exc:
74
+ raise RuntimeError(f"connection error: {exc}") from exc
75
+ return json.loads(raw)
@@ -0,0 +1,229 @@
1
+ """Model normalization, validation, and discovery utilities."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ from reidx.diagnostics.logger import get_logger
9
+
10
+ log = get_logger("reidx.provider.models")
11
+
12
+ KNOWN_PROVIDER_PREFIXES = {
13
+ "openrouter",
14
+ "anthropic",
15
+ "openai",
16
+ "google",
17
+ "cohere",
18
+ "mistral",
19
+ "meta",
20
+ "meta-llama",
21
+ "microsoft",
22
+ "nvidia",
23
+ "deepseek",
24
+ "qwen",
25
+ "yi",
26
+ "zai",
27
+ "xai",
28
+ "perplexity",
29
+ "together",
30
+ "fireworks",
31
+ "groq",
32
+ "cerebras",
33
+ "deepinfra",
34
+ "sambanova",
35
+ "novita",
36
+ "featherless",
37
+ "chutes",
38
+ "aimlapi",
39
+ "hyperbolic",
40
+ "moonshot",
41
+ "volcengine",
42
+ "hunyuan",
43
+ "upstage",
44
+ "predibase",
45
+ "gravity",
46
+ "infermatic",
47
+ "baseten",
48
+ "anyscale",
49
+ "lambda",
50
+ "kluster",
51
+ "monsterapi",
52
+ "cloudflare-ai",
53
+ "replicate",
54
+ "watsonx",
55
+ "aleph-alpha",
56
+ "ai21",
57
+ "databricks",
58
+ "siliconflow",
59
+ "nebius",
60
+ "lepton",
61
+ "voyage-ai",
62
+ "jan",
63
+ "llamacpp",
64
+ }
65
+
66
+ VALID_VARIANTS = {":free", ":thinking", ":nitro", ":extended", ":online", ":alpha", ":beta"}
67
+ VALID_VARIANTS_LOWER = {v.lower() for v in VALID_VARIANTS}
68
+
69
+ UI_DISPLAY_PATTERN = re.compile(r"\s*\[via\s+([^\]]+)\]", re.IGNORECASE)
70
+ PROVIDER_DASH_PATTERN = re.compile(r"^([A-Za-z0-9\-]+)\s+-\s+(.+)$")
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class NormalizedModel:
75
+ provider: str
76
+ model_id: str
77
+ full_id: str
78
+ variant: Optional[str]
79
+ base_model: str
80
+ is_valid: bool
81
+
82
+
83
+ def normalize_model_id(raw: str, provider_name: Optional[str] = None) -> NormalizedModel:
84
+ original = raw.strip()
85
+
86
+ # Remove UI display text like "[via openrouter]"
87
+ cleaned = UI_DISPLAY_PATTERN.sub("", original).strip()
88
+
89
+ # Remove "ProviderName - " prefix
90
+ dash_match = PROVIDER_DASH_PATTERN.match(cleaned)
91
+ if dash_match:
92
+ cleaned = dash_match.group(2).strip()
93
+
94
+ parts = cleaned.split("/")
95
+
96
+ if not parts or not parts[0]:
97
+ return NormalizedModel(
98
+ provider=provider_name.lower() if provider_name else "",
99
+ model_id=cleaned,
100
+ full_id=cleaned,
101
+ variant=None,
102
+ base_model=cleaned,
103
+ is_valid=False,
104
+ )
105
+
106
+ first_part_lower = parts[0].lower()
107
+ provider = ""
108
+ model_parts = parts
109
+
110
+ if provider_name and provider_name.lower() in KNOWN_PROVIDER_PREFIXES:
111
+ provider = provider_name.lower()
112
+ if first_part_lower == provider:
113
+ model_parts = parts[1:]
114
+ elif first_part_lower in KNOWN_PROVIDER_PREFIXES:
115
+ provider = first_part_lower
116
+ model_parts = parts[1:]
117
+
118
+ if not model_parts:
119
+ return NormalizedModel(
120
+ provider=provider,
121
+ model_id="",
122
+ full_id=cleaned,
123
+ variant=None,
124
+ base_model="",
125
+ is_valid=False,
126
+ )
127
+
128
+ model_id = "/".join(model_parts)
129
+
130
+ variant = None
131
+ base_model = model_id
132
+ for v in VALID_VARIANTS:
133
+ if model_id.lower().endswith(v.lower()):
134
+ variant = v[1:]
135
+ base_model = model_id[: -len(v)]
136
+ break
137
+
138
+ if provider:
139
+ full_id = f"{provider}/{model_id}"
140
+ else:
141
+ full_id = model_id
142
+
143
+ is_valid = bool(provider and base_model and "/" in base_model)
144
+
145
+ return NormalizedModel(
146
+ provider=provider,
147
+ model_id=model_id,
148
+ full_id=full_id,
149
+ variant=variant,
150
+ base_model=base_model,
151
+ is_valid=is_valid,
152
+ )
153
+
154
+
155
+ def denormalize_model_id(normalized: NormalizedModel, include_provider: bool = True) -> str:
156
+ if include_provider and normalized.provider:
157
+ return normalized.full_id
158
+ return normalized.model_id
159
+
160
+
161
+ async def fetch_provider_models(provider) -> list[str]:
162
+ import asyncio
163
+ try:
164
+ loop = asyncio.get_event_loop()
165
+ models = await loop.run_in_executor(None, provider.fetch_models)
166
+ return sorted(models) if models else []
167
+ except Exception as exc:
168
+ log.warning("failed to fetch models from provider: %s", exc)
169
+ return []
170
+
171
+
172
+ def validate_model_against_provider(
173
+ normalized: NormalizedModel,
174
+ available_models: list[str],
175
+ ) -> tuple[bool, str]:
176
+ if not normalized.is_valid:
177
+ return False, f"Invalid model format: {normalized.full_id}"
178
+
179
+ if not available_models:
180
+ return True, "No model list available from provider (skipping validation)"
181
+
182
+ if normalized.full_id in available_models:
183
+ return True, "Model found in provider catalog"
184
+
185
+ if normalized.model_id in available_models:
186
+ return True, "Model found in provider catalog (without provider prefix)"
187
+
188
+ if normalized.base_model in available_models:
189
+ return True, f"Base model found (variant '{normalized.variant}' may not be listed separately)"
190
+
191
+ base_with_provider = f"{normalized.provider}/{normalized.base_model}"
192
+ if base_with_provider in available_models:
193
+ return True, f"Base model found with provider prefix (variant '{normalized.variant}' may not be listed separately)"
194
+
195
+ for avail in available_models:
196
+ if normalized.base_model in avail or avail in normalized.base_model:
197
+ return True, f"Similar model found: {avail}"
198
+
199
+ return False, f"Model '{normalized.full_id}' not found in provider catalog ({len(available_models)} models available)"
200
+
201
+
202
+ class ModelCache:
203
+ def __init__(self) -> None:
204
+ self._cache: dict[str, tuple[list[str], float]] = {}
205
+ self._ttl = 3600
206
+
207
+ def get(self, provider_name: str) -> Optional[list[str]]:
208
+ import time
209
+ if provider_name in self._cache:
210
+ models, timestamp = self._cache[provider_name]
211
+ if time.time() - timestamp < self._ttl:
212
+ return models
213
+ else:
214
+ del self._cache[provider_name]
215
+ return None
216
+
217
+ def set(self, provider_name: str, models: list[str]) -> None:
218
+ import time
219
+ self._cache[provider_name] = (models, time.time())
220
+
221
+ def invalidate(self, provider_name: str) -> None:
222
+ self._cache.pop(provider_name, None)
223
+
224
+
225
+ _model_cache = ModelCache()
226
+
227
+
228
+ def get_model_cache() -> ModelCache:
229
+ 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
@@ -127,7 +132,10 @@ class ProviderStore:
127
132
  return True
128
133
 
129
134
 
130
- def validate_provider(record: ProviderRecord) -> tuple[bool, str]:
135
+ def validate_provider(
136
+ record: ProviderRecord,
137
+ skip_verify: bool = False,
138
+ ) -> tuple[bool, str]:
131
139
  try:
132
140
  provider = build_provider(record)
133
141
  except (ValueError, TypeError) as exc:
@@ -137,15 +145,32 @@ def validate_provider(record: ProviderRecord) -> tuple[bool, str]:
137
145
  return False, "API key required for this provider kind"
138
146
  if not record.api_key:
139
147
  return True, "no key required"
148
+ if skip_verify:
149
+ return True, "verification skipped"
140
150
  try:
141
151
  models = provider.fetch_models()
152
+ except RuntimeError as exc:
153
+ msg = str(exc)
154
+ if msg.startswith("HTTP 401") or msg.startswith("HTTP 403"):
155
+ return False, f"authentication failed ({msg})"
156
+ if msg.startswith("HTTP "):
157
+ return False, f"provider error: {msg}"
158
+ return False, f"{msg} (use --skip-verify to save anyway)"
142
159
  except Exception as exc:
143
- return False, f"could not connect to provider: {exc}"
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
+
144
171
  if models:
145
172
  return True, f"ok ({len(models)} models available)"
146
- if record.kind == "ollama":
147
- return True, "connected"
148
- return False, "key rejected or no models returned"
173
+ return True, "connected (no models endpoint or empty list)"
149
174
 
150
175
 
151
176
  def load_into(registry: ProviderRegistry, storage_root: Path) -> list[str]:
@@ -162,6 +187,7 @@ def load_into(registry: ProviderRegistry, storage_root: Path) -> list[str]:
162
187
 
163
188
  def load_from_database(registry: ProviderRegistry, storage_root: Path) -> list[str]:
164
189
  from reidx.provider_manager.database import ProviderDatabase
190
+ from reidx.provider.models import denormalize_model_id, normalize_model_id
165
191
 
166
192
  added: list[str] = []
167
193
  db = ProviderDatabase(storage_root)
@@ -188,10 +214,22 @@ def load_from_database(registry: ProviderRegistry, storage_root: Path) -> list[s
188
214
  except Exception:
189
215
  models = []
190
216
  if models:
191
- provider.default_model = models[0]
192
- 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
193
232
  db.save_provider(sp)
194
- log.info("auto-fetched model for %s: %s", sp.name, models[0])
195
233
  registry.register(sp.name, provider)
196
234
  added.append(sp.name)
197
235
  return added
@@ -9,6 +9,14 @@ 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
+ ModelCache,
14
+ NormalizedModel,
15
+ denormalize_model_id,
16
+ fetch_provider_models,
17
+ normalize_model_id,
18
+ validate_model_against_provider,
19
+ )
12
20
  from reidx.provider.store import ProviderRecord, build_provider, validate_provider
13
21
  from reidx.provider_manager.catalog import (
14
22
  ProviderDefinition,
@@ -267,6 +275,12 @@ class ProviderPalette:
267
275
  self._handle_input_enter()
268
276
  self._invalidate()
269
277
  return
278
+ if self.screen == self.MESSAGE:
279
+ self.screen = self._message_next
280
+ self.selected_index = 0
281
+ self._scroll_offset = 0
282
+ self._invalidate()
283
+ return
270
284
  items = self._build_items()
271
285
  if not items:
272
286
  return
@@ -610,8 +624,14 @@ class ProviderPalette:
610
624
  )
611
625
  ok, msg = validate_provider(record)
612
626
  if not ok:
613
- self._show_message(f"Key validation failed: {msg}", self.WIZARD)
614
- self.wizard_step_idx = len(WIZARD_STEPS) - 1
627
+ self._confirm_text = f"Key validation failed: {msg}\nSave anyway?"
628
+ self._pending_action = lambda: self._commit_wizard_provider(
629
+ name, kind, base_url, model, auth, api_key, forced_msg=msg,
630
+ )
631
+ self.screen = self.CONFIRM
632
+ self.selected_index = 0
633
+ self._scroll_offset = 0
634
+ self._invalidate()
615
635
  return
616
636
  if not model:
617
637
  try:
@@ -620,8 +640,22 @@ class ProviderPalette:
620
640
  except Exception:
621
641
  models = []
622
642
  if models:
623
- model = models[0]
643
+ normalized = normalize_model_id(models[0], provider_name=name)
644
+ if normalized.is_valid:
645
+ model = denormalize_model_id(normalized)
646
+
647
+ self._commit_wizard_provider(name, kind, base_url, model, auth, api_key)
624
648
 
649
+ def _commit_wizard_provider(
650
+ self,
651
+ name: str,
652
+ kind: str,
653
+ base_url: str,
654
+ model: str,
655
+ auth: str,
656
+ api_key: str,
657
+ forced_msg: str = "",
658
+ ) -> str:
625
659
  existing = self.db.get_provider(name)
626
660
  if existing:
627
661
  existing.kind = kind
@@ -660,7 +694,9 @@ class ProviderPalette:
660
694
  self.wizard_data = {}
661
695
  self.wizard_step_idx = 0
662
696
  self.input_is_password = False
663
- self._close(f"Saved provider '{name}' ({kind})")
697
+ note = " (unverified)" if forced_msg else ""
698
+ self._close(f"Saved provider '{name}' ({kind}){note}")
699
+ return f"Saved provider '{name}'"
664
700
 
665
701
  def _do_add_key(self, label: str, api_key: str) -> None:
666
702
  if not self.current_provider:
@@ -673,13 +709,25 @@ class ProviderPalette:
673
709
  auth_method=sp.auth_method,
674
710
  )
675
711
  ok, msg = validate_provider(record)
676
- if not ok:
677
- self._show_message(f"Key validation failed: {msg}", self.MANAGE)
712
+ if ok:
713
+ self._commit_key(label, api_key, msg)
678
714
  return
715
+ self._confirm_text = f"Key validation failed: {msg}\nSave anyway?"
716
+ self._pending_action = lambda: self._commit_key(label, api_key, "saved (unverified)")
717
+ self.screen = self.CONFIRM
718
+ self.selected_index = 0
719
+ self._scroll_offset = 0
720
+ self._invalidate()
721
+
722
+ def _commit_key(self, label: str, api_key: str, msg: str) -> str:
723
+ if not self.current_provider:
724
+ return "Error: no provider"
725
+ name = self.current_provider.name
679
726
  self.db.add_key(name, label, api_key)
680
727
  self.current_provider = self.db.get_provider(name)
681
728
  self._register_current()
682
729
  self._show_message(f"Added key '{label}' ({msg})", self.MANAGE)
730
+ return f"Added key '{label}'"
683
731
 
684
732
  def _do_delete_key(self, key_id: str, label: str) -> str:
685
733
  if not self.current_provider:
@@ -718,9 +766,11 @@ class ProviderPalette:
718
766
  except Exception:
719
767
  models = []
720
768
  if models:
721
- provider.default_model = models[0]
722
- sp.default_model = models[0]
723
- self.db.save_provider(sp)
769
+ normalized = normalize_model_id(models[0], provider_name=sp.name)
770
+ if normalized.is_valid:
771
+ provider.default_model = denormalize_model_id(normalized)
772
+ sp.default_model = denormalize_model_id(normalized)
773
+ self.db.save_provider(sp)
724
774
  self.orchestrator.providers.register(sp.name, provider)
725
775
  except (ValueError, TypeError):
726
776
  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,
@@ -486,6 +487,7 @@ def _providers_store(orchestrator: Orchestrator) -> ProviderStore:
486
487
  def _save_to_database(orchestrator: Orchestrator, record: ProviderRecord) -> None:
487
488
  from reidx.provider_manager import keychain
488
489
  from reidx.provider_manager.database import ProviderDatabase, StoredKey, StoredProvider
490
+ from reidx.provider.models import denormalize_model_id, normalize_model_id
489
491
 
490
492
  root = orchestrator.config.storage_root or storage_root()
491
493
  db = ProviderDatabase(root)
@@ -504,7 +506,11 @@ def _save_to_database(orchestrator: Orchestrator, record: ProviderRecord) -> Non
504
506
  existing.base_url = record.base_url
505
507
  existing.auth_method = record.auth_method
506
508
  if record.default_model:
507
- existing.default_model = record.default_model
509
+ normalized = normalize_model_id(record.default_model, provider_name=record.name)
510
+ if normalized.is_valid:
511
+ existing.default_model = denormalize_model_id(normalized)
512
+ else:
513
+ existing.default_model = record.default_model
508
514
  db.save_provider(existing)
509
515
  else:
510
516
  sp = StoredProvider(
@@ -519,6 +525,12 @@ def _save_to_database(orchestrator: Orchestrator, record: ProviderRecord) -> Non
519
525
  )
520
526
  sp.keys.append(k)
521
527
  sp.active_key_id = k.id
528
+ if record.default_model:
529
+ normalized = normalize_model_id(record.default_model, provider_name=record.name)
530
+ if normalized.is_valid:
531
+ sp.default_model = denormalize_model_id(normalized)
532
+ else:
533
+ sp.default_model = record.default_model
522
534
  db.save_provider(sp)
523
535
 
524
536
 
@@ -536,10 +548,17 @@ def _handle_providers(orchestrator: Orchestrator) -> None:
536
548
 
537
549
 
538
550
  def _handle_connect(orchestrator: Orchestrator, arg: str) -> None:
539
- parts = arg.split()
551
+ skip_verify = False
552
+ tokens = arg.split()
553
+ parts: list[str] = []
554
+ for tok in tokens:
555
+ if tok in ("--skip-verify", "--skip", "--no-verify"):
556
+ skip_verify = True
557
+ else:
558
+ parts.append(tok)
540
559
  if len(parts) < 3:
541
560
  render.print_error(
542
- "usage: /connect <name> <kind> <base_url> [api_key] [model] "
561
+ "usage: /connect <name> <kind> <base_url> [api_key] [model] [--skip-verify] "
543
562
  f"(kind: {'|'.join(SUPPORTED_KINDS)})"
544
563
  )
545
564
  return
@@ -554,7 +573,7 @@ def _handle_connect(orchestrator: Orchestrator, arg: str) -> None:
554
573
  model = parts[4] if len(parts) > 4 else ""
555
574
  record = ProviderRecord(name=name, kind=kind, base_url=base_url, api_key=api_key, default_model=model)
556
575
  if api_key:
557
- ok, msg = validate_provider(record)
576
+ ok, msg = validate_provider(record, skip_verify=skip_verify)
558
577
  if not ok:
559
578
  render.print_error(f"key validation failed: {msg}")
560
579
  return
@@ -564,21 +583,24 @@ def _handle_connect(orchestrator: Orchestrator, arg: str) -> None:
564
583
  except ValueError as exc:
565
584
  render.print_error(f"failed to build provider: {exc}")
566
585
  return
567
- if not model and api_key:
586
+ if not model and api_key and not skip_verify:
568
587
  try:
569
588
  models = provider.fetch_models()
570
589
  except Exception:
571
590
  models = []
572
591
  if models:
573
- provider.default_model = models[0]
574
- record.default_model = models[0]
575
- render.print_info(f"auto-detected model: {models[0]}")
592
+ normalized = normalize_model_id(models[0], provider_name=name)
593
+ if normalized.is_valid:
594
+ model = denormalize_model_id(normalized)
595
+ provider.default_model = model
596
+ record.default_model = model
597
+ render.print_info(f"auto-detected model: {model}")
576
598
  _providers_store(orchestrator).save(record)
577
599
  _save_to_database(orchestrator, record)
578
600
  if orchestrator.providers is not None:
579
601
  orchestrator.providers.register(name, provider)
580
- render.print_info(f"connected provider '{name}' ({kind}) {base_url or '(default)'}")
581
- render.print_info(f"switch with: /use {name}")
602
+ render.print_info(f"connected provider '{name}' ({kind})  {base_url or '(default)'}")
603
+ render.print_info(f"switch with: /use {name}")
582
604
 
583
605
 
584
606
  def _handle_disconnect(orchestrator: Orchestrator, arg: str) -> None: