@agxnte/reidx 2.0.2

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 (135) hide show
  1. package/README.md +724 -0
  2. package/bin/reidx.js +33 -0
  3. package/package.json +40 -0
  4. package/pyproject.toml +45 -0
  5. package/scripts/find-python.js +24 -0
  6. package/scripts/postinstall.js +35 -0
  7. package/src/reidx/__init__.py +3 -0
  8. package/src/reidx/__main__.py +9 -0
  9. package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
  10. package/src/reidx/app/__init__.py +3 -0
  11. package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
  12. package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
  13. package/src/reidx/app/commands.py +221 -0
  14. package/src/reidx/automation/__init__.py +3 -0
  15. package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
  17. package/src/reidx/automation/exec.py +35 -0
  18. package/src/reidx/config/__init__.py +10 -0
  19. package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
  20. package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
  21. package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
  22. package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
  23. package/src/reidx/config/loader.py +104 -0
  24. package/src/reidx/config/models.py +49 -0
  25. package/src/reidx/config/settings.py +127 -0
  26. package/src/reidx/deepreid/__init__.py +8 -0
  27. package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
  28. package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
  29. package/src/reidx/deepreid/pipeline.py +221 -0
  30. package/src/reidx/diagnostics/__init__.py +0 -0
  31. package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
  33. package/src/reidx/diagnostics/logger.py +56 -0
  34. package/src/reidx/goals/__init__.py +21 -0
  35. package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
  36. package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
  37. package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
  38. package/src/reidx/goals/models.py +101 -0
  39. package/src/reidx/goals/store.py +233 -0
  40. package/src/reidx/integrations/__init__.py +3 -0
  41. package/src/reidx/integrations/mcp.py +70 -0
  42. package/src/reidx/nyx/__init__.py +3 -0
  43. package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
  45. package/src/reidx/nyx/persona.py +35 -0
  46. package/src/reidx/policy/__init__.py +15 -0
  47. package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
  48. package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
  49. package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
  50. package/src/reidx/policy/engine.py +101 -0
  51. package/src/reidx/policy/models.py +35 -0
  52. package/src/reidx/provider/__init__.py +16 -0
  53. package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
  55. package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
  56. package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
  57. package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
  58. package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
  59. package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
  60. package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
  61. package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
  62. package/src/reidx/provider/_http.py +29 -0
  63. package/src/reidx/provider/anthropic.py +176 -0
  64. package/src/reidx/provider/base.py +50 -0
  65. package/src/reidx/provider/ollama.py +109 -0
  66. package/src/reidx/provider/openai.py +144 -0
  67. package/src/reidx/provider/registry.py +88 -0
  68. package/src/reidx/provider/store.py +141 -0
  69. package/src/reidx/provider/stub.py +60 -0
  70. package/src/reidx/provider_manager/__init__.py +40 -0
  71. package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
  72. package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
  73. package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
  74. package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
  75. package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
  76. package/src/reidx/provider_manager/catalog.py +330 -0
  77. package/src/reidx/provider_manager/database.py +234 -0
  78. package/src/reidx/provider_manager/keychain.py +102 -0
  79. package/src/reidx/provider_manager/palette.py +831 -0
  80. package/src/reidx/runtime/__init__.py +5 -0
  81. package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  82. package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
  83. package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
  84. package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
  85. package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
  86. package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
  87. package/src/reidx/runtime/agent.py +198 -0
  88. package/src/reidx/runtime/orchestrator.py +244 -0
  89. package/src/reidx/runtime/reasoning.py +77 -0
  90. package/src/reidx/runtime/state.py +32 -0
  91. package/src/reidx/runtime/subagent.py +174 -0
  92. package/src/reidx/session/__init__.py +4 -0
  93. package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
  94. package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
  95. package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
  96. package/src/reidx/session/models.py +43 -0
  97. package/src/reidx/session/store.py +101 -0
  98. package/src/reidx/tasks/__init__.py +4 -0
  99. package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
  100. package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
  101. package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
  102. package/src/reidx/tasks/models.py +44 -0
  103. package/src/reidx/tasks/store.py +86 -0
  104. package/src/reidx/tools/__init__.py +44 -0
  105. package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  106. package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
  107. package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  108. package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
  109. package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
  110. package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
  111. package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  112. package/src/reidx/tools/base.py +84 -0
  113. package/src/reidx/tools/file_tools.py +222 -0
  114. package/src/reidx/tools/registry.py +45 -0
  115. package/src/reidx/tools/shell_tool.py +75 -0
  116. package/src/reidx/tools/spawn_agent.py +185 -0
  117. package/src/reidx/tools/web_tools.py +216 -0
  118. package/src/reidx/ui/__init__.py +41 -0
  119. package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
  120. package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
  121. package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
  122. package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
  123. package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
  124. package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
  125. package/src/reidx/ui/app.py +1343 -0
  126. package/src/reidx/ui/commands.py +663 -0
  127. package/src/reidx/ui/render.py +517 -0
  128. package/src/reidx/ui/repl.py +14 -0
  129. package/src/reidx/ui/theme.py +126 -0
  130. package/src/reidx/workflows/__init__.py +4 -0
  131. package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
  132. package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
  133. package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
  134. package/src/reidx/workflows/models.py +19 -0
  135. package/src/reidx/workflows/store.py +56 -0
@@ -0,0 +1,176 @@
1
+ """Anthropic Messages API provider.
2
+
3
+ Speaks the Anthropic Messages API (POST /v1/messages). Compatible with Anthropic
4
+ directly and with Anthropic-compatible proxies (e.g. Reidchat). Uses stdlib
5
+ urllib for zero extra dependencies.
6
+
7
+ Env vars consumed:
8
+ ANTHROPIC_API_KEY - auth key
9
+ ANTHROPIC_BASE_URL - API base (defaults to https://api.anthropic.com)
10
+ ANTHROPIC_MODEL - default model name
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import urllib.error
17
+ import urllib.request
18
+ from typing import Any
19
+
20
+ from reidx.diagnostics.logger import get_logger
21
+ from reidx.provider.base import BaseProvider, Message, ProviderResponse, ToolCall, Usage
22
+
23
+ log = get_logger("reidx.provider.anthropic")
24
+
25
+ DEFAULT_BASE_URL = "https://api.anthropic.com"
26
+ API_VERSION = "2023-06-01"
27
+ MAX_TOKENS = 4096
28
+ TIMEOUT_SECONDS = 120
29
+
30
+
31
+ class AnthropicProvider(BaseProvider):
32
+ """Real provider that calls the Anthropic Messages API over HTTP."""
33
+
34
+ name = "anthropic"
35
+
36
+ def __init__(
37
+ self,
38
+ api_key: str,
39
+ base_url: str = DEFAULT_BASE_URL,
40
+ default_model: str = "",
41
+ ) -> None:
42
+ self.api_key = api_key
43
+ self.base_url = base_url.rstrip("/")
44
+ self.default_model = default_model
45
+
46
+ @classmethod
47
+ def from_env(cls) -> AnthropicProvider | None:
48
+ """Build from ANTHROPIC_* env vars. Returns None if no API key is set."""
49
+ key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
50
+ if not key:
51
+ return None
52
+ base = os.environ.get("ANTHROPIC_BASE_URL", DEFAULT_BASE_URL).strip()
53
+ model = os.environ.get("ANTHROPIC_MODEL", "").strip()
54
+ return cls(api_key=key, base_url=base, default_model=model)
55
+
56
+ def _to_anthropic_messages(self, messages: list[Message]) -> tuple[str | None, list[dict]]:
57
+ """Convert ReidX Messages to Anthropic format. Returns (system, messages)."""
58
+ system: str | None = None
59
+ out: list[dict] = []
60
+ for m in messages:
61
+ if m.role == "system":
62
+ system = m.content
63
+ continue
64
+ if m.role == "tool":
65
+ # Tool results -> Anthropic tool_result content block.
66
+ out.append({
67
+ "role": "user",
68
+ "content": [{
69
+ "type": "tool_result",
70
+ "tool_use_id": m.tool_call_id or "",
71
+ "content": m.content,
72
+ }],
73
+ })
74
+ continue
75
+ if m.role == "assistant" and m.tool_calls:
76
+ # Assistant turn with tool calls -> content blocks.
77
+ blocks: list[dict] = []
78
+ if m.content:
79
+ blocks.append({"type": "text", "text": m.content})
80
+ for tc in m.tool_calls:
81
+ blocks.append({
82
+ "type": "tool_use",
83
+ "id": tc.id,
84
+ "name": tc.name,
85
+ "input": tc.arguments,
86
+ })
87
+ out.append({"role": "assistant", "content": blocks})
88
+ continue
89
+ # Plain user or assistant text.
90
+ out.append({"role": m.role, "content": m.content})
91
+ return system, out
92
+
93
+ def _to_anthropic_tools(self, tools: list[dict] | None) -> list[dict]:
94
+ """Convert OpenAI-style tool schemas to Anthropic tool format."""
95
+ if not tools:
96
+ return []
97
+ result: list[dict] = []
98
+ for t in tools:
99
+ fn = t.get("function", t)
100
+ result.append({
101
+ "name": fn.get("name", ""),
102
+ "description": fn.get("description", ""),
103
+ "input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
104
+ })
105
+ return result
106
+
107
+ def _parse_response(self, body: dict, model: str) -> ProviderResponse:
108
+ """Parse Anthropic response into ProviderResponse."""
109
+ content_blocks = body.get("content", [])
110
+ text_parts: list[str] = []
111
+ tool_calls: list[ToolCall] = []
112
+ for block in content_blocks:
113
+ btype = block.get("type", "")
114
+ if btype == "text":
115
+ text_parts.append(block.get("text", ""))
116
+ elif btype == "tool_use":
117
+ tool_calls.append(ToolCall(
118
+ id=block.get("id", ""),
119
+ name=block.get("name", ""),
120
+ arguments=block.get("input", {}),
121
+ ))
122
+ usage_raw = body.get("usage", {})
123
+ return ProviderResponse(
124
+ text="\n".join(text_parts),
125
+ tool_calls=tool_calls,
126
+ usage=Usage(
127
+ prompt_tokens=usage_raw.get("input_tokens", 0),
128
+ completion_tokens=usage_raw.get("output_tokens", 0),
129
+ ),
130
+ stop_reason=body.get("stop_reason", "stop"),
131
+ )
132
+
133
+ def chat(
134
+ self,
135
+ messages: list[Message],
136
+ tools: list[dict[str, Any]] | None = None,
137
+ model: str | None = None,
138
+ ) -> ProviderResponse:
139
+ model = model or self.default_model or "claude-sonnet-4-20250514"
140
+ system, anthropic_msgs = self._to_anthropic_messages(messages)
141
+ anthropic_tools = self._to_anthropic_tools(tools)
142
+
143
+ payload: dict[str, Any] = {
144
+ "model": model,
145
+ "max_tokens": MAX_TOKENS,
146
+ "messages": anthropic_msgs,
147
+ }
148
+ if system:
149
+ payload["system"] = system
150
+ if anthropic_tools:
151
+ payload["tools"] = anthropic_tools
152
+
153
+ body = json.dumps(payload).encode("utf-8")
154
+ 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
+ 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)
@@ -0,0 +1,50 @@
1
+ """Provider layer: abstract model access from runtime.
2
+
3
+ Defines the message/response contract and BaseProvider. Real providers
4
+ (OpenAI/Anthropic/local) plug in here. A StubProvider is included so the runtime
5
+ loop is exercisable end-to-end without API keys.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ class ToolCall(BaseModel):
16
+ id: str
17
+ name: str
18
+ arguments: dict[str, Any] = Field(default_factory=dict)
19
+
20
+
21
+ class Message(BaseModel):
22
+ role: str # "system" | "user" | "assistant" | "tool"
23
+ content: str = ""
24
+ tool_calls: list[ToolCall] = Field(default_factory=list)
25
+ tool_call_id: str | None = None # set when role == "tool" (result)
26
+
27
+
28
+ class Usage(BaseModel):
29
+ prompt_tokens: int = 0
30
+ completion_tokens: int = 0
31
+
32
+
33
+ class ProviderResponse(BaseModel):
34
+ text: str = ""
35
+ tool_calls: list[ToolCall] = Field(default_factory=list)
36
+ usage: Usage = Field(default_factory=Usage)
37
+ stop_reason: str = "stop"
38
+
39
+
40
+ class BaseProvider(ABC):
41
+ name: str = "base"
42
+
43
+ @abstractmethod
44
+ def chat(
45
+ self,
46
+ messages: list[Message],
47
+ tools: list[dict[str, Any]] | None = None,
48
+ model: str | None = None,
49
+ ) -> ProviderResponse:
50
+ """Run one model turn. Returns text and/or tool calls."""
@@ -0,0 +1,109 @@
1
+ """Ollama native chat provider (POST /api/chat).
2
+
3
+ Ollama also exposes an OpenAI-compatible endpoint at /v1, but the native
4
+ endpoint is what `ollama serve` recommends and has cleaner tool-call payloads
5
+ in current versions. No API key required.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from reidx.diagnostics.logger import get_logger
12
+ from reidx.provider._http import post_json
13
+ from reidx.provider.base import BaseProvider, Message, ProviderResponse, ToolCall, Usage
14
+
15
+ log = get_logger("reidx.provider.ollama")
16
+
17
+
18
+ class OllamaProvider(BaseProvider):
19
+ name = "ollama"
20
+ DEFAULT_BASE_URL = "http://localhost:11434"
21
+ DEFAULT_MODEL = "llama3.2"
22
+
23
+ def __init__(self, base_url: str = "", default_model: str = "", api_key: str = "") -> None:
24
+ self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
25
+ self.default_model = default_model or self.DEFAULT_MODEL
26
+ self.api_key = api_key # unused; kept for uniform ProviderRecord shape
27
+
28
+ def _to_ollama_tools(self, tools: list[dict] | None) -> list[dict]:
29
+ if not tools:
30
+ return []
31
+ out: list[dict] = []
32
+ for t in tools:
33
+ fn = t.get("function", t)
34
+ out.append({
35
+ "type": "function",
36
+ "function": {
37
+ "name": fn.get("name", ""),
38
+ "description": fn.get("description", ""),
39
+ "parameters": fn.get("parameters", {"type": "object", "properties": {}}),
40
+ },
41
+ })
42
+ return out
43
+
44
+ def _to_ollama_messages(self, messages: list[Message]) -> list[dict]:
45
+ out: list[dict] = []
46
+ for m in messages:
47
+ if m.role == "tool":
48
+ out.append({"role": "tool", "content": m.content})
49
+ continue
50
+ if m.role == "assistant" and m.tool_calls:
51
+ out.append({
52
+ "role": "assistant",
53
+ "content": m.content or "",
54
+ "tool_calls": [
55
+ {"function": {"name": tc.name, "arguments": tc.arguments}}
56
+ for tc in m.tool_calls
57
+ ],
58
+ })
59
+ continue
60
+ out.append({"role": m.role, "content": m.content})
61
+ return out
62
+
63
+ def chat(
64
+ self,
65
+ messages: list[Message],
66
+ tools: list[dict[str, Any]] | None = None,
67
+ model: str | None = None,
68
+ ) -> ProviderResponse:
69
+ model = model or self.default_model
70
+ payload: dict[str, Any] = {
71
+ "model": model,
72
+ "messages": self._to_ollama_messages(messages),
73
+ "stream": False,
74
+ }
75
+ ol_tools = self._to_ollama_tools(tools)
76
+ if ol_tools:
77
+ payload["tools"] = ol_tools
78
+ try:
79
+ body = post_json(f"{self.base_url}/api/chat", payload, {})
80
+ except RuntimeError:
81
+ log.exception("Ollama request failed")
82
+ raise
83
+
84
+ msg = body.get("message", {}) or {}
85
+ text = msg.get("content") or ""
86
+ tool_calls: list[ToolCall] = []
87
+ for i, tc in enumerate(msg.get("tool_calls") or []):
88
+ fn = tc.get("function", {})
89
+ args = fn.get("arguments") or {}
90
+ if isinstance(args, str):
91
+ try:
92
+ import json as _json
93
+ args = _json.loads(args)
94
+ except ValueError:
95
+ args = {"_raw": args}
96
+ tool_calls.append(ToolCall(
97
+ id=tc.get("id") or f"ollama-{i}",
98
+ name=fn.get("name", ""),
99
+ arguments=args or {},
100
+ ))
101
+ return ProviderResponse(
102
+ text=text,
103
+ tool_calls=tool_calls,
104
+ usage=Usage(
105
+ prompt_tokens=body.get("prompt_eval_count", 0),
106
+ completion_tokens=body.get("eval_count", 0),
107
+ ),
108
+ stop_reason=body.get("done_reason", "stop"),
109
+ )
@@ -0,0 +1,144 @@
1
+ """OpenAI Chat Completions provider.
2
+
3
+ Speaks the OpenAI Chat Completions API (POST /v1/chat/completions). The same
4
+ wire format is spoken by llama.cpp's server (`/v1/chat/completions`) and by
5
+ LM Studio's local server — see `OpenAICompatibleProvider` below for the
6
+ local-endpoint variant that skips the auth header when no key is set.
7
+
8
+ Tool schemas ReidX produces (`ToolBase.schema()`) are already OpenAI-style,
9
+ so tool passing is a straight forward.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import Any
15
+
16
+ from reidx.diagnostics.logger import get_logger
17
+ from reidx.provider._http import post_json
18
+ from reidx.provider.base import BaseProvider, Message, ProviderResponse, ToolCall, Usage
19
+
20
+ log = get_logger("reidx.provider.openai")
21
+
22
+
23
+ class OpenAIProvider(BaseProvider):
24
+ name = "openai"
25
+ DEFAULT_BASE_URL = "https://api.openai.com"
26
+ DEFAULT_MODEL = "gpt-4o-mini"
27
+
28
+ def __init__(
29
+ self,
30
+ api_key: str,
31
+ base_url: str = "",
32
+ default_model: str = "",
33
+ ) -> None:
34
+ self.api_key = api_key
35
+ self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
36
+ self.default_model = default_model or self.DEFAULT_MODEL
37
+
38
+ @classmethod
39
+ def from_env(cls) -> OpenAIProvider | None:
40
+ """Build from OPENAI_* env vars. Returns None if no API key is set, so
41
+ it can be auto-registered under `openai` alongside anthropic."""
42
+ key = os.environ.get("OPENAI_API_KEY", "").strip()
43
+ if not key:
44
+ return None
45
+ base = os.environ.get("OPENAI_BASE_URL", "").strip()
46
+ model = os.environ.get("OPENAI_MODEL", "").strip()
47
+ return cls(api_key=key, base_url=base, default_model=model)
48
+
49
+ def _to_openai_messages(self, messages: list[Message]) -> list[dict]:
50
+ out: list[dict] = []
51
+ for m in messages:
52
+ if m.role == "tool":
53
+ out.append({
54
+ "role": "tool",
55
+ "tool_call_id": m.tool_call_id or "",
56
+ "content": m.content,
57
+ })
58
+ continue
59
+ if m.role == "assistant" and m.tool_calls:
60
+ out.append({
61
+ "role": "assistant",
62
+ "content": m.content or None,
63
+ "tool_calls": [
64
+ {
65
+ "id": tc.id,
66
+ "type": "function",
67
+ "function": {"name": tc.name, "arguments": _json_dump(tc.arguments)},
68
+ }
69
+ for tc in m.tool_calls
70
+ ],
71
+ })
72
+ continue
73
+ out.append({"role": m.role, "content": m.content})
74
+ return out
75
+
76
+ def _headers(self) -> dict[str, str]:
77
+ h = {}
78
+ if self.api_key:
79
+ h["authorization"] = f"Bearer {self.api_key}"
80
+ return h
81
+
82
+ def _parse(self, body: dict, model: str) -> ProviderResponse:
83
+ choices = body.get("choices") or [{}]
84
+ msg = (choices[0] or {}).get("message", {})
85
+ text = msg.get("content") or ""
86
+ tool_calls: list[ToolCall] = []
87
+ for tc in msg.get("tool_calls") or []:
88
+ fn = tc.get("function", {})
89
+ args_raw = fn.get("arguments") or "{}"
90
+ try:
91
+ import json as _json
92
+ args = _json.loads(args_raw) if isinstance(args_raw, str) else args_raw
93
+ except ValueError:
94
+ args = {"_raw": args_raw}
95
+ tool_calls.append(ToolCall(id=tc.get("id", ""), name=fn.get("name", ""), arguments=args or {}))
96
+ usage_raw = body.get("usage", {})
97
+ return ProviderResponse(
98
+ text=text,
99
+ tool_calls=tool_calls,
100
+ usage=Usage(
101
+ prompt_tokens=usage_raw.get("prompt_tokens", 0),
102
+ completion_tokens=usage_raw.get("completion_tokens", 0),
103
+ ),
104
+ stop_reason=(choices[0] or {}).get("finish_reason", "stop"),
105
+ )
106
+
107
+ def chat(
108
+ self,
109
+ messages: list[Message],
110
+ tools: list[dict[str, Any]] | None = None,
111
+ model: str | None = None,
112
+ ) -> ProviderResponse:
113
+ model = model or self.default_model
114
+ payload: dict[str, Any] = {
115
+ "model": model,
116
+ "messages": self._to_openai_messages(messages),
117
+ }
118
+ if tools:
119
+ payload["tools"] = tools
120
+ url = f"{self.base_url}/v1/chat/completions"
121
+ try:
122
+ body = post_json(url, payload, self._headers())
123
+ except RuntimeError:
124
+ log.exception("OpenAI-compatible request failed")
125
+ raise
126
+ return self._parse(body, model)
127
+
128
+
129
+ class OpenAICompatibleProvider(OpenAIProvider):
130
+ """OpenAI-compatible endpoints (llama.cpp server, LM Studio, vLLM, etc.).
131
+
132
+ 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.
135
+ """
136
+
137
+ name = "openai-compatible"
138
+ DEFAULT_BASE_URL = "http://localhost:8080"
139
+ DEFAULT_MODEL = "local"
140
+
141
+
142
+ def _json_dump(obj) -> str: # type: ignore[no-untyped-def]
143
+ import json
144
+ return json.dumps(obj, ensure_ascii=False)
@@ -0,0 +1,88 @@
1
+ """Provider registry: config-driven registration of providers by name.
2
+
3
+ The built-in `stub` provider is always registered and remains the default.
4
+ Real providers (Anthropic/OpenAI/OpenAI-compatible/Ollama) are added by
5
+ `/connect` (see reidx.provider.store) or by env-var auto-registration for
6
+ Anthropic, but never auto-promoted to default — the user picks with `/use`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from reidx.config.models import Config
11
+ from reidx.diagnostics.logger import get_logger
12
+ from reidx.provider.anthropic import AnthropicProvider
13
+ from reidx.provider.base import BaseProvider
14
+ from reidx.provider.stub import StubProvider
15
+
16
+ log = get_logger("reidx.provider")
17
+
18
+
19
+ class ProviderRegistry:
20
+ def __init__(self) -> None:
21
+ self._providers: dict[str, BaseProvider] = {}
22
+
23
+ def register(self, name: str, provider: BaseProvider) -> None:
24
+ self._providers[name] = provider
25
+ log.debug("registered provider: %s", name)
26
+
27
+ def unregister(self, name: str) -> bool:
28
+ if name in self._providers:
29
+ del self._providers[name]
30
+ return True
31
+ return False
32
+
33
+ def get(self, name: str) -> BaseProvider:
34
+ if name not in self._providers:
35
+ raise KeyError(f"provider '{name}' not registered")
36
+ return self._providers[name]
37
+
38
+ def has(self, name: str) -> bool:
39
+ return name in self._providers
40
+
41
+ def names(self) -> list[str]:
42
+ return list(self._providers)
43
+
44
+
45
+ def default_registry(config: Config) -> ProviderRegistry:
46
+ """Build the default registry. Stub is always registered and stays the
47
+ default; Anthropic and OpenAI auto-register under their own names when the
48
+ matching env vars are set (available via `/use`), but never override the
49
+ default. Every other configured provider is built from its `kind` so a
50
+ subagent can request it by name. Providers persisted by `/connect` are
51
+ layered on top by `reidx.provider.store.load_into`.
52
+ """
53
+ # Lazy import: store.py imports ProviderRegistry from this module, so a
54
+ # top-level import here would be circular.
55
+ from reidx.provider.store import ProviderRecord, build_provider
56
+
57
+ reg = ProviderRegistry()
58
+ reg.register("stub", StubProvider())
59
+
60
+ anthropic = AnthropicProvider.from_env()
61
+ if anthropic is not None:
62
+ reg.register("anthropic", anthropic)
63
+ log.debug("auto-registered anthropic provider from env vars")
64
+
65
+ from reidx.provider.openai import OpenAIProvider # noqa: PLC0415
66
+ openai = OpenAIProvider.from_env()
67
+ if openai is not None:
68
+ reg.register("openai", openai)
69
+ log.debug("auto-registered openai provider from env vars")
70
+
71
+ for name, pc in config.providers.items():
72
+ if name == "stub" or reg.has(name):
73
+ continue
74
+ kind = (pc.kind or name).strip()
75
+ try:
76
+ provider = build_provider(ProviderRecord(
77
+ name=name,
78
+ kind=kind,
79
+ base_url=pc.base_url or "",
80
+ api_key=pc.api_key.get_secret_value() if pc.api_key else "",
81
+ default_model=pc.default_model,
82
+ ))
83
+ except (ValueError, TypeError):
84
+ log.warning("provider '%s' (kind=%s) configured but could not be built; skipping", name, kind)
85
+ continue
86
+ reg.register(name, provider)
87
+ log.debug("registered configured provider: %s (kind=%s)", name, kind)
88
+ return reg