@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.
- package/README.md +724 -0
- package/bin/reidx.js +33 -0
- package/package.json +40 -0
- package/pyproject.toml +45 -0
- package/scripts/find-python.js +24 -0
- package/scripts/postinstall.js +35 -0
- package/src/reidx/__init__.py +3 -0
- package/src/reidx/__main__.py +9 -0
- package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__init__.py +3 -0
- package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/app/commands.py +221 -0
- package/src/reidx/automation/__init__.py +3 -0
- package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
- package/src/reidx/automation/exec.py +35 -0
- package/src/reidx/config/__init__.py +10 -0
- package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
- package/src/reidx/config/loader.py +104 -0
- package/src/reidx/config/models.py +49 -0
- package/src/reidx/config/settings.py +127 -0
- package/src/reidx/deepreid/__init__.py +8 -0
- package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/pipeline.py +221 -0
- package/src/reidx/diagnostics/__init__.py +0 -0
- package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/logger.py +56 -0
- package/src/reidx/goals/__init__.py +21 -0
- package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/goals/models.py +101 -0
- package/src/reidx/goals/store.py +233 -0
- package/src/reidx/integrations/__init__.py +3 -0
- package/src/reidx/integrations/mcp.py +70 -0
- package/src/reidx/nyx/__init__.py +3 -0
- package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
- package/src/reidx/nyx/persona.py +35 -0
- package/src/reidx/policy/__init__.py +15 -0
- package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/policy/engine.py +101 -0
- package/src/reidx/policy/models.py +35 -0
- package/src/reidx/provider/__init__.py +16 -0
- package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
- package/src/reidx/provider/_http.py +29 -0
- package/src/reidx/provider/anthropic.py +176 -0
- package/src/reidx/provider/base.py +50 -0
- package/src/reidx/provider/ollama.py +109 -0
- package/src/reidx/provider/openai.py +144 -0
- package/src/reidx/provider/registry.py +88 -0
- package/src/reidx/provider/store.py +141 -0
- package/src/reidx/provider/stub.py +60 -0
- package/src/reidx/provider_manager/__init__.py +40 -0
- package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/catalog.py +330 -0
- package/src/reidx/provider_manager/database.py +234 -0
- package/src/reidx/provider_manager/keychain.py +102 -0
- package/src/reidx/provider_manager/palette.py +831 -0
- package/src/reidx/runtime/__init__.py +5 -0
- package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/agent.py +198 -0
- package/src/reidx/runtime/orchestrator.py +244 -0
- package/src/reidx/runtime/reasoning.py +77 -0
- package/src/reidx/runtime/state.py +32 -0
- package/src/reidx/runtime/subagent.py +174 -0
- package/src/reidx/session/__init__.py +4 -0
- package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/session/models.py +43 -0
- package/src/reidx/session/store.py +101 -0
- package/src/reidx/tasks/__init__.py +4 -0
- package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/tasks/models.py +44 -0
- package/src/reidx/tasks/store.py +86 -0
- package/src/reidx/tools/__init__.py +44 -0
- package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/base.py +84 -0
- package/src/reidx/tools/file_tools.py +222 -0
- package/src/reidx/tools/registry.py +45 -0
- package/src/reidx/tools/shell_tool.py +75 -0
- package/src/reidx/tools/spawn_agent.py +185 -0
- package/src/reidx/tools/web_tools.py +216 -0
- package/src/reidx/ui/__init__.py +41 -0
- package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
- package/src/reidx/ui/app.py +1343 -0
- package/src/reidx/ui/commands.py +663 -0
- package/src/reidx/ui/render.py +517 -0
- package/src/reidx/ui/repl.py +14 -0
- package/src/reidx/ui/theme.py +126 -0
- package/src/reidx/workflows/__init__.py +4 -0
- package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/workflows/models.py +19 -0
- 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
|