@agxnte/reidx 2.0.2 → 2.0.4

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.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/reidx/__init__.py +1 -1
  5. package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
  6. package/src/reidx/app/commands.py +6 -6
  7. package/src/reidx/config/__init__.py +3 -0
  8. package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
  9. package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
  10. package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
  11. package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
  12. package/src/reidx/config/__pycache__/storage.cpython-312.pyc +0 -0
  13. package/src/reidx/config/loader.py +2 -1
  14. package/src/reidx/config/models.py +1 -0
  15. package/src/reidx/config/settings.py +12 -10
  16. package/src/reidx/config/storage.py +21 -0
  17. package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
  18. package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
  19. package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
  20. package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
  21. package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
  22. package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
  23. package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
  24. package/src/reidx/provider/_http.py +13 -0
  25. package/src/reidx/provider/anthropic.py +26 -29
  26. package/src/reidx/provider/base.py +3 -0
  27. package/src/reidx/provider/ollama.py +14 -1
  28. package/src/reidx/provider/openai.py +40 -3
  29. package/src/reidx/provider/registry.py +1 -0
  30. package/src/reidx/provider/store.py +60 -4
  31. package/src/reidx/provider_manager/catalog.py +11 -8
  32. package/src/reidx/provider_manager/keychain.py +20 -3
  33. package/src/reidx/provider_manager/palette.py +42 -5
  34. package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
  35. package/src/reidx/ui/app.py +4 -2
  36. package/src/reidx/ui/commands.py +65 -3
package/README.md CHANGED
@@ -65,7 +65,7 @@ reid doctor
65
65
  Expected output:
66
66
 
67
67
  ```
68
- reid 2.0.2
68
+ reid 2.0.3
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.2",
3
+ "version": "2.0.4",
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.1"
3
+ version = "2.0.4"
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.2"
3
+ __version__ = "2.0.4"
@@ -16,9 +16,11 @@ from reidx import __version__
16
16
  from reidx.automation.exec import exec_run
17
17
  from reidx.config.loader import ConfigLoader
18
18
  from reidx.config.models import Config
19
+ from reidx.config.storage import storage_root
19
20
  from reidx.deepreid import format_markdown, run_deepreid, save_deepreid_result
20
21
  from reidx.diagnostics.logger import get_logger
21
22
  from reidx.provider.registry import default_registry
23
+ from reidx.provider.store import load_from_database
22
24
  from reidx.provider.store import load_into as load_stored_providers
23
25
  from reidx.runtime.orchestrator import Orchestrator
24
26
  from reidx.tools import default_registry as tools_registry
@@ -97,11 +99,9 @@ def _main(
97
99
  def build_orchestrator(config: Config | None = None) -> Orchestrator:
98
100
  cfg = config or ConfigLoader().load()
99
101
  providers = default_registry(cfg)
100
- load_stored_providers(providers, cfg.storage_root or (Path.home() / ".reidx"))
101
- # `/connect` may have persisted a provider that isn't yet `cfg.default_provider`.
102
- # We never auto-promote — stub stays default unless the user did `/use` last
103
- # session (that's a session setting, not a config change). Fall back to stub
104
- # if the configured default isn't registered.
102
+ root = cfg.storage_root or storage_root()
103
+ load_stored_providers(providers, root)
104
+ load_from_database(providers, root)
105
105
  default_name = cfg.default_provider if providers.has(cfg.default_provider) else "stub"
106
106
  provider = providers.get(default_name)
107
107
  return Orchestrator(cfg, provider, tools_registry(), providers=providers)
@@ -207,7 +207,7 @@ def doctor() -> None:
207
207
  console.print(f"storage {cfg.storage_root}")
208
208
  console.print(f"provider {cfg.default_provider}")
209
209
  console.print(f"mode {cfg.policy.default_mode.value}")
210
- console.print(f"providers {orch.provider.name} (active), {orch.tools.definitions().__len__()} tools")
210
+ console.print(f"providers {orch.provider.name} (active), {len(orch.tools.definitions())} tools")
211
211
  import os
212
212
 
213
213
  has_key = bool(os.environ.get("ANTHROPIC_API_KEY", "").strip())
@@ -1,10 +1,13 @@
1
1
  from reidx.config.loader import ConfigLoader
2
2
  from reidx.config.models import Config, PolicyConfig, ProviderConfig, default_config
3
+ from reidx.config.storage import app_data_dir, storage_root
3
4
 
4
5
  __all__ = [
5
6
  "Config",
6
7
  "ConfigLoader",
7
8
  "PolicyConfig",
8
9
  "ProviderConfig",
10
+ "app_data_dir",
9
11
  "default_config",
12
+ "storage_root",
10
13
  ]
@@ -11,11 +11,12 @@ from pathlib import Path
11
11
 
12
12
  from reidx.config.models import Config, default_config
13
13
  from reidx.config.settings import apply_settings_env, read_reidx_block
14
+ from reidx.config.storage import storage_root
14
15
  from reidx.diagnostics.logger import get_logger
15
16
 
16
17
  log = get_logger("reidx.config")
17
18
 
18
- GLOBAL_DIR = Path.home() / ".reidx"
19
+ GLOBAL_DIR = storage_root()
19
20
  PROJECT_DIR = Path(".reidx")
20
21
  CONFIG_FILENAME = "config.json"
21
22
 
@@ -19,6 +19,7 @@ class ProviderConfig(BaseModel):
19
19
  base_url: str | None = None
20
20
  api_key: SecretStr | None = None
21
21
  default_model: str = ""
22
+ auth_method: str = "bearer"
22
23
 
23
24
 
24
25
  class PolicyConfig(BaseModel):
@@ -10,13 +10,14 @@ ignored harmlessly.
10
10
  Path resolution (first hit wins):
11
11
  1. $REIDCHAT_SETTINGS (explicit override)
12
12
  2. ./settings.json (project-local, when it exists)
13
- 3. E:/leech/Reidchat.json (legacy default — the shared Reidchat file)
14
-
15
- Project-local wins over the shared Reidchat file so a project can bake in a
16
- different backend, permission mode, or provider set without editing the
17
- global file. The env block is authoritative for the process (overrides any
18
- ambient env) this is how ReidX picks up the Reidchat proxy credentials
19
- even when the shell's ANTHROPIC_* vars point somewhere else.
13
+ 3. ~/.reidx/settings.json (global default)
14
+ 4. ~/Reidchat.json (legacy fallback)
15
+
16
+ Project-local wins over the global file so a project can bake in a different
17
+ backend, permission mode, or provider set without editing the global file.
18
+ The env block is authoritative for the process (overrides any ambient env)
19
+ — this is how ReidX picks up proxy credentials even when the shell's
20
+ ANTHROPIC_* vars point somewhere else.
20
21
  """
21
22
  from __future__ import annotations
22
23
 
@@ -24,13 +25,14 @@ import json
24
25
  import os
25
26
  from pathlib import Path
26
27
 
28
+ from reidx.config.storage import app_data_dir
27
29
  from reidx.diagnostics.logger import get_logger
28
30
 
29
31
  log = get_logger("reidx.config.settings")
30
32
 
31
- LEGACY_SETTINGS_PATH = Path("E:/leech/Reidchat.json")
32
- GLOBAL_SETTINGS_PATH = Path.home() / ".reidx" / "settings.json"
33
+ GLOBAL_SETTINGS_PATH = app_data_dir() / "settings.json"
33
34
  PROJECT_SETTINGS_FILENAME = "settings.json"
35
+ LEGACY_SETTINGS_PATH = Path.home() / "Reidchat.json"
34
36
 
35
37
 
36
38
  def _walk_upward_for_project_settings(start: Path) -> Path | None:
@@ -61,7 +63,7 @@ def settings_path() -> Path:
61
63
  1. $REIDCHAT_SETTINGS (explicit override)
62
64
  2. project settings.json (walk upward from CWD)
63
65
  3. ~/.reidx/settings.json (global default)
64
- 4. E:/leech/Reidchat.json (legacy shared file)
66
+ 4. ~/Reidchat.json (legacy fallback)
65
67
 
66
68
  Returns the last fallback even if it doesn't exist, so `doctor` can
67
69
  report it as "missing" rather than crashing on a None.
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def app_data_dir() -> Path:
9
+ if sys.platform == "win32":
10
+ base = os.environ.get("APPDATA")
11
+ if base:
12
+ return Path(base) / "Reid"
13
+ return Path.home() / ".reidx"
14
+
15
+
16
+ def storage_root() -> Path:
17
+ return app_data_dir()
18
+
19
+
20
+ def settings_file() -> Path:
21
+ return app_data_dir() / "settings.json"
@@ -27,3 +27,16 @@ def post_json(url: str, payload: dict, headers: dict[str, str], timeout: int = T
27
27
  except urllib.error.URLError as exc:
28
28
  raise RuntimeError(f"connection error: {exc}") from exc
29
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)
@@ -11,13 +11,11 @@ Env vars consumed:
11
11
  """
12
12
  from __future__ import annotations
13
13
 
14
- import json
15
14
  import os
16
- import urllib.error
17
- import urllib.request
18
15
  from typing import Any
19
16
 
20
17
  from reidx.diagnostics.logger import get_logger
18
+ from reidx.provider._http import get_json, post_json
21
19
  from reidx.provider.base import BaseProvider, Message, ProviderResponse, ToolCall, Usage
22
20
 
23
21
  log = get_logger("reidx.provider.anthropic")
@@ -25,7 +23,6 @@ log = get_logger("reidx.provider.anthropic")
25
23
  DEFAULT_BASE_URL = "https://api.anthropic.com"
26
24
  API_VERSION = "2023-06-01"
27
25
  MAX_TOKENS = 4096
28
- TIMEOUT_SECONDS = 120
29
26
 
30
27
 
31
28
  class AnthropicProvider(BaseProvider):
@@ -53,6 +50,12 @@ class AnthropicProvider(BaseProvider):
53
50
  model = os.environ.get("ANTHROPIC_MODEL", "").strip()
54
51
  return cls(api_key=key, base_url=base, default_model=model)
55
52
 
53
+ def _headers(self) -> dict[str, str]:
54
+ return {
55
+ "x-api-key": self.api_key,
56
+ "anthropic-version": API_VERSION,
57
+ }
58
+
56
59
  def _to_anthropic_messages(self, messages: list[Message]) -> tuple[str | None, list[dict]]:
57
60
  """Convert ReidX Messages to Anthropic format. Returns (system, messages)."""
58
61
  system: str | None = None
@@ -62,7 +65,6 @@ class AnthropicProvider(BaseProvider):
62
65
  system = m.content
63
66
  continue
64
67
  if m.role == "tool":
65
- # Tool results -> Anthropic tool_result content block.
66
68
  out.append({
67
69
  "role": "user",
68
70
  "content": [{
@@ -73,7 +75,6 @@ class AnthropicProvider(BaseProvider):
73
75
  })
74
76
  continue
75
77
  if m.role == "assistant" and m.tool_calls:
76
- # Assistant turn with tool calls -> content blocks.
77
78
  blocks: list[dict] = []
78
79
  if m.content:
79
80
  blocks.append({"type": "text", "text": m.content})
@@ -86,7 +87,6 @@ class AnthropicProvider(BaseProvider):
86
87
  })
87
88
  out.append({"role": "assistant", "content": blocks})
88
89
  continue
89
- # Plain user or assistant text.
90
90
  out.append({"role": m.role, "content": m.content})
91
91
  return system, out
92
92
 
@@ -150,27 +150,24 @@ class AnthropicProvider(BaseProvider):
150
150
  if anthropic_tools:
151
151
  payload["tools"] = anthropic_tools
152
152
 
153
- body = json.dumps(payload).encode("utf-8")
154
153
  url = f"{self.base_url}/v1/messages"
155
- req = urllib.request.Request(
156
- url,
157
- data=body,
158
- headers={
159
- "x-api-key": self.api_key,
160
- "anthropic-version": API_VERSION,
161
- "content-type": "application/json",
162
- },
163
- method="POST",
164
- )
165
154
  try:
166
- with urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS) as resp:
167
- raw = resp.read().decode("utf-8")
168
- except urllib.error.HTTPError as exc:
169
- err_body = exc.read().decode("utf-8", errors="replace")[:500]
170
- log.error("Anthropic API error %s: %s", exc.code, err_body)
171
- raise RuntimeError(f"Anthropic API error {exc.code}: {err_body}") from exc
172
- except urllib.error.URLError as exc:
173
- log.error("Anthropic connection error: %s", exc)
174
- raise RuntimeError(f"connection error: {exc}") from exc
175
-
176
- return self._parse_response(json.loads(raw), model)
155
+ body = post_json(url, payload, self._headers())
156
+ except RuntimeError:
157
+ log.exception("Anthropic API request failed")
158
+ raise
159
+ return self._parse_response(body, model)
160
+
161
+ def fetch_models(self) -> list[str]:
162
+ url = f"{self.base_url}/v1/models"
163
+ try:
164
+ body = get_json(url, self._headers())
165
+ except RuntimeError:
166
+ log.debug("failed to fetch models from %s", url)
167
+ return []
168
+ models: list[str] = []
169
+ for item in body.get("data", []):
170
+ mid = item.get("id", "")
171
+ if mid:
172
+ models.append(mid)
173
+ return sorted(models)
@@ -48,3 +48,6 @@ class BaseProvider(ABC):
48
48
  model: str | None = None,
49
49
  ) -> ProviderResponse:
50
50
  """Run one model turn. Returns text and/or tool calls."""
51
+
52
+ def fetch_models(self) -> list[str]:
53
+ return []
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  from typing import Any
10
10
 
11
11
  from reidx.diagnostics.logger import get_logger
12
- from reidx.provider._http import post_json
12
+ from reidx.provider._http import get_json, post_json
13
13
  from reidx.provider.base import BaseProvider, Message, ProviderResponse, ToolCall, Usage
14
14
 
15
15
  log = get_logger("reidx.provider.ollama")
@@ -107,3 +107,16 @@ class OllamaProvider(BaseProvider):
107
107
  ),
108
108
  stop_reason=body.get("done_reason", "stop"),
109
109
  )
110
+
111
+ def fetch_models(self) -> list[str]:
112
+ try:
113
+ body = get_json(f"{self.base_url}/api/tags", {})
114
+ except RuntimeError:
115
+ log.debug("failed to fetch models from ollama")
116
+ return []
117
+ models: list[str] = []
118
+ for item in body.get("models", []):
119
+ name = item.get("name", "")
120
+ if name:
121
+ models.append(name)
122
+ return sorted(models)
@@ -14,7 +14,7 @@ import os
14
14
  from typing import Any
15
15
 
16
16
  from reidx.diagnostics.logger import get_logger
17
- from reidx.provider._http import post_json
17
+ from reidx.provider._http import get_json, post_json
18
18
  from reidx.provider.base import BaseProvider, Message, ProviderResponse, ToolCall, Usage
19
19
 
20
20
  log = get_logger("reidx.provider.openai")
@@ -125,19 +125,56 @@ class OpenAIProvider(BaseProvider):
125
125
  raise
126
126
  return self._parse(body, model)
127
127
 
128
+ def fetch_models(self) -> list[str]:
129
+ url = f"{self.base_url}/v1/models"
130
+ try:
131
+ body = get_json(url, self._headers())
132
+ except RuntimeError:
133
+ log.debug("failed to fetch models from %s", url)
134
+ return []
135
+ models: list[str] = []
136
+ for item in body.get("data", []):
137
+ mid = item.get("id", "")
138
+ if mid:
139
+ models.append(mid)
140
+ return sorted(models)
141
+
128
142
 
129
143
  class OpenAICompatibleProvider(OpenAIProvider):
130
144
  """OpenAI-compatible endpoints (llama.cpp server, LM Studio, vLLM, etc.).
131
145
 
132
146
  Same wire format as OpenAI; only default base_url differs and the API key
133
- is optional. Kept as a subclass so `/providers` can show the kind
134
- separately in the list.
147
+ is optional. Auth method is configurable so providers that use x-api-key
148
+ instead of Bearer are handled correctly. Kept as a subclass so `/providers`
149
+ can show the kind separately in the list.
135
150
  """
136
151
 
137
152
  name = "openai-compatible"
138
153
  DEFAULT_BASE_URL = "http://localhost:8080"
139
154
  DEFAULT_MODEL = "local"
140
155
 
156
+ def __init__(
157
+ self,
158
+ api_key: str,
159
+ base_url: str = "",
160
+ default_model: str = "",
161
+ auth_method: str = "bearer",
162
+ ) -> None:
163
+ self.auth_method = auth_method
164
+ super().__init__(api_key=api_key, base_url=base_url, default_model=default_model)
165
+
166
+ def _headers(self) -> dict[str, str]:
167
+ h = {}
168
+ if not self.api_key:
169
+ return h
170
+ if self.auth_method == "x-api-key":
171
+ h["x-api-key"] = self.api_key
172
+ elif self.auth_method == "none":
173
+ pass
174
+ else:
175
+ h["authorization"] = f"Bearer {self.api_key}"
176
+ return h
177
+
141
178
 
142
179
  def _json_dump(obj) -> str: # type: ignore[no-untyped-def]
143
180
  import json
@@ -79,6 +79,7 @@ def default_registry(config: Config) -> ProviderRegistry:
79
79
  base_url=pc.base_url or "",
80
80
  api_key=pc.api_key.get_secret_value() if pc.api_key else "",
81
81
  default_model=pc.default_model,
82
+ auth_method=pc.auth_method,
82
83
  ))
83
84
  except (ValueError, TypeError):
84
85
  log.warning("provider '%s' (kind=%s) configured but could not be built; skipping", name, kind)
@@ -39,6 +39,7 @@ class ProviderRecord:
39
39
  base_url: str = ""
40
40
  api_key: str = ""
41
41
  default_model: str = ""
42
+ auth_method: str = "bearer"
42
43
 
43
44
 
44
45
  def build_provider(record: ProviderRecord) -> BaseProvider:
@@ -60,6 +61,7 @@ def build_provider(record: ProviderRecord) -> BaseProvider:
60
61
  api_key=record.api_key,
61
62
  base_url=record.base_url,
62
63
  default_model=record.default_model,
64
+ auth_method=record.auth_method,
63
65
  )
64
66
  if kind == "ollama":
65
67
  return OllamaProvider(
@@ -125,11 +127,28 @@ class ProviderStore:
125
127
  return True
126
128
 
127
129
 
130
+ def validate_provider(record: ProviderRecord) -> tuple[bool, str]:
131
+ try:
132
+ provider = build_provider(record)
133
+ except (ValueError, TypeError) as exc:
134
+ return False, str(exc)
135
+ _keyless_kinds = ("ollama", "openai-compatible")
136
+ if not record.api_key and record.kind not in _keyless_kinds:
137
+ return False, "API key required for this provider kind"
138
+ if not record.api_key:
139
+ return True, "no key required"
140
+ try:
141
+ models = provider.fetch_models()
142
+ except Exception:
143
+ return False, "could not connect to provider"
144
+ if models:
145
+ 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"
149
+
150
+
128
151
  def load_into(registry: ProviderRegistry, storage_root: Path) -> list[str]:
129
- """Register every persisted provider into `registry`. Returns the list of
130
- names successfully registered. Skips (with a warning) any record whose
131
- kind is unknown or fails to construct — the app must not crash on a
132
- single bad entry."""
133
152
  added: list[str] = []
134
153
  store = ProviderStore(storage_root)
135
154
  for record in store.list():
@@ -139,3 +158,40 @@ def load_into(registry: ProviderRegistry, storage_root: Path) -> list[str]:
139
158
  except (ValueError, TypeError):
140
159
  log.exception("skipping provider %s (kind=%s): failed to build", record.name, record.kind)
141
160
  return added
161
+
162
+
163
+ def load_from_database(registry: ProviderRegistry, storage_root: Path) -> list[str]:
164
+ from reidx.provider_manager.database import ProviderDatabase
165
+
166
+ added: list[str] = []
167
+ db = ProviderDatabase(storage_root)
168
+ for sp in db.list_providers():
169
+ if registry.has(sp.name):
170
+ continue
171
+ api_key = sp.decrypted_api_key()
172
+ record = ProviderRecord(
173
+ name=sp.name,
174
+ kind=sp.kind,
175
+ base_url=sp.base_url,
176
+ api_key=api_key,
177
+ default_model=sp.default_model,
178
+ auth_method=sp.auth_method,
179
+ )
180
+ try:
181
+ provider = build_provider(record)
182
+ except (ValueError, TypeError):
183
+ log.warning("skipping provider %s (kind=%s): failed to build", sp.name, sp.kind)
184
+ continue
185
+ if not record.default_model:
186
+ try:
187
+ models = provider.fetch_models()
188
+ except Exception:
189
+ models = []
190
+ if models:
191
+ provider.default_model = models[0]
192
+ sp.default_model = models[0]
193
+ db.save_provider(sp)
194
+ log.info("auto-fetched model for %s: %s", sp.name, models[0])
195
+ registry.register(sp.name, provider)
196
+ added.append(sp.name)
197
+ return added
@@ -113,8 +113,8 @@ _BUILTIN: list[ProviderDefinition] = [
113
113
  default_model="auto", aliases=[], auth_method="none", icon="🚀",
114
114
  ),
115
115
  ProviderDefinition(
116
- id="azure-openai", name="Azure OpenAI", description="OpenAI models via Azure cloud",
117
- kind="openai", base_url="", default_model="gpt-4o-mini",
116
+ id="azure-openai", name="Azure OpenAI", description="OpenAI models via Azure cloud (set base URL to your endpoint)",
117
+ kind="openai", base_url="https://api.openai.com", default_model="gpt-4o-mini",
118
118
  aliases=["azure"], icon="☁️",
119
119
  ),
120
120
  ProviderDefinition(
@@ -178,8 +178,9 @@ _BUILTIN: list[ProviderDefinition] = [
178
178
  default_model="jamba-1.5-large", aliases=[], icon="🃏",
179
179
  ),
180
180
  ProviderDefinition(
181
- id="databricks", name="Databricks", description="Serving endpoints for fine-tuned models",
182
- kind="openai-compatible", base_url="", default_model="databricks-dbrx-instruct",
181
+ id="databricks", name="Databricks", description="Serving endpoints for fine-tuned models (set base URL to your workspace)",
182
+ kind="openai-compatible", base_url="https://dbc-xxxxxxxx.cloud.databricks.com/serving-endpoints",
183
+ default_model="databricks-dbrx-instruct",
183
184
  aliases=[], icon="🧱",
184
185
  ),
185
186
  ProviderDefinition(
@@ -205,7 +206,7 @@ _BUILTIN: list[ProviderDefinition] = [
205
206
  ProviderDefinition(
206
207
  id="lambda", name="Lambda Labs", description="GPU cloud for AI training and inference",
207
208
  kind="openai-compatible", base_url="https://api.lambdalabs.com/v1",
208
- default_model="", aliases=["lambda-labs"], icon="λ",
209
+ default_model="meta-llama/Meta-Llama-3.1-70B-Instruct", aliases=["lambda-labs"], icon="λ",
209
210
  ),
210
211
  ProviderDefinition(
211
212
  id="kluster", name="Kluster AI", description="Distributed GPU network for inference",
@@ -234,7 +235,8 @@ _BUILTIN: list[ProviderDefinition] = [
234
235
  ),
235
236
  ProviderDefinition(
236
237
  id="cloudflare-ai", name="Cloudflare Workers AI", description="Serverless AI inference at the edge",
237
- kind="openai-compatible", base_url="", default_model="@cf/meta/llama-3.1-70b-instruct",
238
+ kind="openai-compatible", base_url="https://api.cloudflare.com/client/v4",
239
+ default_model="@cf/meta/llama-3.1-70b-instruct",
238
240
  aliases=["cf-ai"], icon="orange",
239
241
  ),
240
242
  ProviderDefinition(
@@ -245,7 +247,7 @@ _BUILTIN: list[ProviderDefinition] = [
245
247
  ProviderDefinition(
246
248
  id="baseten", name="Baseten", description="Model deployment and serving platform",
247
249
  kind="openai-compatible", base_url="https://inference.baseten.co/v1",
248
- default_model="", aliases=[], icon="🏗️",
250
+ default_model="meta-llama/Meta-Llama-3.1-70B-Instruct", aliases=[], icon="🏗️",
249
251
  ),
250
252
  ProviderDefinition(
251
253
  id="anyscale", name="Anyscale", description="Ray-based scalable model serving",
@@ -264,7 +266,8 @@ _BUILTIN: list[ProviderDefinition] = [
264
266
  ),
265
267
  ProviderDefinition(
266
268
  id="watsonx", name="IBM Watsonx", description="Enterprise AI platform with Granite models",
267
- kind="openai-compatible", base_url="", default_model="ibm/granite-3-8b-instruct",
269
+ kind="openai-compatible", base_url="https://us-south.ml.cloud.ibm.com",
270
+ default_model="ibm/granite-3-8b-instruct",
268
271
  aliases=["ibm"], icon="🔷",
269
272
  ),
270
273
  ProviderDefinition(
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import base64
4
+ import getpass
5
+ import hashlib
4
6
  import os
7
+ import socket
5
8
  import sys
6
9
  from pathlib import Path
7
10
 
@@ -70,6 +73,21 @@ if _IS_WINDOWS:
70
73
  ctypes.windll.kernel32.LocalFree(blob_out.pbData)
71
74
 
72
75
 
76
+ def _machine_key() -> bytes:
77
+ parts = [
78
+ getpass.getuser(),
79
+ socket.gethostname(),
80
+ str(Path.home()),
81
+ sys.platform,
82
+ ]
83
+ raw = "|".join(parts).encode("utf-8")
84
+ return hashlib.pbkdf2_hmac("sha256", raw, b"reidx-keychain-v1", 100000, dklen=32)
85
+
86
+
87
+ def _xor_cipher(data: bytes, key: bytes) -> bytes:
88
+ return bytes(data[i] ^ key[i % len(key)] for i in range(len(data)))
89
+
90
+
73
91
  def encrypt(plaintext: str) -> str:
74
92
  if not plaintext:
75
93
  return ""
@@ -77,8 +95,7 @@ def encrypt(plaintext: str) -> str:
77
95
  if _IS_WINDOWS:
78
96
  encrypted = _dpapi_encrypt(raw)
79
97
  else:
80
- log.warning("DPAPI not available on this platform; storing key without OS encryption")
81
- encrypted = raw
98
+ encrypted = _xor_cipher(raw, _machine_key())
82
99
  return base64.b64encode(encrypted).decode("ascii")
83
100
 
84
101
 
@@ -89,7 +106,7 @@ def decrypt(ciphertext: str) -> str:
89
106
  if _IS_WINDOWS:
90
107
  decrypted = _dpapi_decrypt(raw)
91
108
  else:
92
- decrypted = raw
109
+ decrypted = _xor_cipher(raw, _machine_key())
93
110
  return decrypted.decode("utf-8")
94
111
 
95
112
 
@@ -9,7 +9,7 @@ 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.store import ProviderRecord, build_provider
12
+ from reidx.provider.store import ProviderRecord, build_provider, validate_provider
13
13
  from reidx.provider_manager.catalog import (
14
14
  ProviderDefinition,
15
15
  all_providers,
@@ -602,6 +602,25 @@ class ProviderPalette:
602
602
  auth = d.get("auth_method", "bearer")
603
603
  api_key = d.get("api_key", "").strip()
604
604
 
605
+ if api_key:
606
+ record = ProviderRecord(
607
+ name=name, kind=kind, base_url=base_url,
608
+ api_key=api_key, default_model=model,
609
+ )
610
+ ok, msg = validate_provider(record)
611
+ if not ok:
612
+ self._show_message(f"Key validation failed: {msg}", self.WIZARD)
613
+ self.wizard_step_idx = len(WIZARD_STEPS) - 1
614
+ return
615
+ if not model:
616
+ try:
617
+ provider = build_provider(record)
618
+ models = provider.fetch_models()
619
+ except Exception:
620
+ models = []
621
+ if models:
622
+ model = models[0]
623
+
605
624
  existing = self.db.get_provider(name)
606
625
  if existing:
607
626
  existing.kind = kind
@@ -609,13 +628,12 @@ class ProviderPalette:
609
628
  existing.default_model = model
610
629
  existing.auth_method = auth
611
630
  if api_key:
631
+ from reidx.provider_manager import keychain
612
632
  existing.keys.append(StoredKey(
613
633
  id=uuid.uuid4().hex[:12],
614
634
  label="Default",
615
- encrypted_key="",
635
+ encrypted_key=keychain.encrypt(api_key),
616
636
  ))
617
- from reidx.provider_manager import keychain
618
- existing.keys[-1].encrypted_key = keychain.encrypt(api_key)
619
637
  if existing.active_key_id is None:
620
638
  existing.active_key_id = existing.keys[-1].id
621
639
  self.db.save_provider(existing)
@@ -647,10 +665,19 @@ class ProviderPalette:
647
665
  if not self.current_provider:
648
666
  return
649
667
  name = self.current_provider.name
668
+ sp = self.current_provider
669
+ record = ProviderRecord(
670
+ name=name, kind=sp.kind, base_url=sp.base_url,
671
+ api_key=api_key, default_model=sp.default_model,
672
+ )
673
+ ok, msg = validate_provider(record)
674
+ if not ok:
675
+ self._show_message(f"Key validation failed: {msg}", self.MANAGE)
676
+ return
650
677
  self.db.add_key(name, label, api_key)
651
678
  self.current_provider = self.db.get_provider(name)
652
679
  self._register_current()
653
- self._show_message(f"Added key '{label}'", self.MANAGE)
680
+ self._show_message(f"Added key '{label}' ({msg})", self.MANAGE)
654
681
 
655
682
  def _do_delete_key(self, key_id: str, label: str) -> str:
656
683
  if not self.current_provider:
@@ -680,8 +707,18 @@ class ProviderPalette:
680
707
  record = ProviderRecord(
681
708
  name=sp.name, kind=sp.kind, base_url=sp.base_url,
682
709
  api_key=api_key, default_model=sp.default_model,
710
+ auth_method=sp.auth_method,
683
711
  )
684
712
  provider = build_provider(record)
713
+ if not sp.default_model:
714
+ try:
715
+ models = provider.fetch_models()
716
+ except Exception:
717
+ models = []
718
+ if models:
719
+ provider.default_model = models[0]
720
+ sp.default_model = models[0]
721
+ self.db.save_provider(sp)
685
722
  self.orchestrator.providers.register(sp.name, provider)
686
723
  except (ValueError, TypeError):
687
724
  log.exception("failed to register provider %s", sp.name)
@@ -458,8 +458,10 @@ class ChatApp:
458
458
  if self._palette is not None:
459
459
  return
460
460
  from pathlib import Path
461
- storage_root = self.orchestrator.config.storage_root or (Path.home() / ".reidx")
462
- db = ProviderDatabase(Path(storage_root))
461
+
462
+ from reidx.config.storage import storage_root as get_storage_root
463
+ root = self.orchestrator.config.storage_root or get_storage_root()
464
+ db = ProviderDatabase(Path(root))
463
465
  self._palette = ProviderPalette(
464
466
  db=db,
465
467
  orchestrator=self.orchestrator,
@@ -10,15 +10,22 @@ from, so they can't drift out of sync.
10
10
  """
11
11
  from __future__ import annotations
12
12
 
13
- from pathlib import Path
13
+ import uuid
14
14
 
15
15
  from rich.console import Group
16
16
  from rich.panel import Panel
17
17
  from rich.text import Text
18
18
 
19
+ from reidx.config.storage import storage_root
19
20
  from reidx.goals.models import Goal, GoalNodeKind, GoalStatus
20
21
  from reidx.policy.models import PermissionMode
21
- from reidx.provider.store import SUPPORTED_KINDS, ProviderRecord, ProviderStore, build_provider
22
+ from reidx.provider.store import (
23
+ SUPPORTED_KINDS,
24
+ ProviderRecord,
25
+ ProviderStore,
26
+ build_provider,
27
+ validate_provider,
28
+ )
22
29
  from reidx.runtime.orchestrator import Orchestrator
23
30
  from reidx.ui import render
24
31
  from reidx.ui.theme import APP_NAME, BOX, PRIMARY
@@ -472,10 +479,49 @@ _BUILTIN_PROVIDER_NAMES = ("stub", "anthropic")
472
479
 
473
480
 
474
481
  def _providers_store(orchestrator: Orchestrator) -> ProviderStore:
475
- root = orchestrator.config.storage_root or (Path.home() / ".reidx")
482
+ root = orchestrator.config.storage_root or storage_root()
476
483
  return ProviderStore(root)
477
484
 
478
485
 
486
+ def _save_to_database(orchestrator: Orchestrator, record: ProviderRecord) -> None:
487
+ from reidx.provider_manager import keychain
488
+ from reidx.provider_manager.database import ProviderDatabase, StoredKey, StoredProvider
489
+
490
+ root = orchestrator.config.storage_root or storage_root()
491
+ db = ProviderDatabase(root)
492
+ existing = db.get_provider(record.name)
493
+ if existing:
494
+ if record.api_key:
495
+ k = StoredKey(
496
+ id=uuid.uuid4().hex[:12],
497
+ label="Default",
498
+ encrypted_key=keychain.encrypt(record.api_key),
499
+ )
500
+ existing.keys.append(k)
501
+ if existing.active_key_id is None:
502
+ existing.active_key_id = k.id
503
+ existing.kind = record.kind
504
+ existing.base_url = record.base_url
505
+ existing.auth_method = record.auth_method
506
+ if record.default_model:
507
+ existing.default_model = record.default_model
508
+ db.save_provider(existing)
509
+ else:
510
+ sp = StoredProvider(
511
+ name=record.name, kind=record.kind, base_url=record.base_url,
512
+ default_model=record.default_model, auth_method=record.auth_method,
513
+ )
514
+ if record.api_key:
515
+ k = StoredKey(
516
+ id=uuid.uuid4().hex[:12],
517
+ label="Default",
518
+ encrypted_key=keychain.encrypt(record.api_key),
519
+ )
520
+ sp.keys.append(k)
521
+ sp.active_key_id = k.id
522
+ db.save_provider(sp)
523
+
524
+
479
525
  def _handle_providers(orchestrator: Orchestrator) -> None:
480
526
  store = _providers_store(orchestrator)
481
527
  persisted = store.list()
@@ -507,12 +553,28 @@ def _handle_connect(orchestrator: Orchestrator, arg: str) -> None:
507
553
  api_key = parts[3] if len(parts) > 3 else ""
508
554
  model = parts[4] if len(parts) > 4 else ""
509
555
  record = ProviderRecord(name=name, kind=kind, base_url=base_url, api_key=api_key, default_model=model)
556
+ if api_key:
557
+ ok, msg = validate_provider(record)
558
+ if not ok:
559
+ render.print_error(f"key validation failed: {msg}")
560
+ return
561
+ render.print_info(f"key validated: {msg}")
510
562
  try:
511
563
  provider = build_provider(record)
512
564
  except ValueError as exc:
513
565
  render.print_error(f"failed to build provider: {exc}")
514
566
  return
567
+ if not model and api_key:
568
+ try:
569
+ models = provider.fetch_models()
570
+ except Exception:
571
+ models = []
572
+ if models:
573
+ provider.default_model = models[0]
574
+ record.default_model = models[0]
575
+ render.print_info(f"auto-detected model: {models[0]}")
515
576
  _providers_store(orchestrator).save(record)
577
+ _save_to_database(orchestrator, record)
516
578
  if orchestrator.providers is not None:
517
579
  orchestrator.providers.register(name, provider)
518
580
  render.print_info(f"connected provider '{name}' ({kind}) → {base_url or '(default)'}")