@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 +1 -1
- package/pyproject.toml +1 -1
- package/src/reidx/__init__.py +1 -1
- package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/provider/_http.py +75 -42
- package/src/reidx/provider/models.py +229 -0
- package/src/reidx/provider/store.py +46 -8
- package/src/reidx/provider_manager/palette.py +59 -9
- package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/ui/commands.py +32 -10
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/reidx/__init__.py
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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(
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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.
|
|
614
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
677
|
-
self.
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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)
|
|
Binary file
|
package/src/reidx/ui/commands.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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})
|
|
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:
|