slimx 0.5.0__py3-none-any.whl
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.
- slimx/__init__.py +99 -0
- slimx/errors.py +7 -0
- slimx/high/__init__.py +2 -0
- slimx/high/api.py +148 -0
- slimx/low/__init__.py +32 -0
- slimx/low/client.py +137 -0
- slimx/low/providers/__init__.py +6 -0
- slimx/low/types.py +24 -0
- slimx/messages.py +89 -0
- slimx/providers/__init__.py +13 -0
- slimx/providers/_defaults.py +100 -0
- slimx/providers/anthropic.py +54 -0
- slimx/providers/anthropic_async.py +58 -0
- slimx/providers/base.py +58 -0
- slimx/providers/google.py +422 -0
- slimx/providers/google_async.py +119 -0
- slimx/providers/ollama.py +96 -0
- slimx/providers/ollama_async.py +100 -0
- slimx/providers/openai.py +100 -0
- slimx/providers/openai_async.py +104 -0
- slimx/providers/plugins.py +17 -0
- slimx/providers/registry.py +36 -0
- slimx/schema.py +75 -0
- slimx/tooling.py +34 -0
- slimx/types.py +172 -0
- slimx/utils/__init__.py +0 -0
- slimx/utils/ndjson.py +28 -0
- slimx/utils/retry.py +16 -0
- slimx/utils/sse.py +17 -0
- slimx/utils/sse_async.py +17 -0
- slimx-0.5.0.dist-info/METADATA +179 -0
- slimx-0.5.0.dist-info/RECORD +34 -0
- slimx-0.5.0.dist-info/WHEEL +4 -0
- slimx-0.5.0.dist-info/licenses/LICENSE +21 -0
slimx/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""SlimX — a slim, intuitive, lightweight library for calling LLMs.
|
|
2
|
+
|
|
3
|
+
This top-level module keeps imports *lazy* to:
|
|
4
|
+
- speed up imports
|
|
5
|
+
- avoid provider bootstrapping side-effects at import time
|
|
6
|
+
- prevent circular imports across `high`, `low`, and `providers`
|
|
7
|
+
|
|
8
|
+
You can still write:
|
|
9
|
+
|
|
10
|
+
from slimx import llm, tool, Message
|
|
11
|
+
|
|
12
|
+
The symbols resolve on first access.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from importlib import import_module
|
|
18
|
+
from typing import Any, TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
__version__ = "0.5.0"
|
|
21
|
+
|
|
22
|
+
_LAZY: dict[str, tuple[str, str]] = {
|
|
23
|
+
# High-level
|
|
24
|
+
"llm": ("slimx.high.api", "llm"),
|
|
25
|
+
"allm": ("slimx.high.api", "allm"),
|
|
26
|
+
"Model": ("slimx.high.api", "Model"),
|
|
27
|
+
"AsyncModel": ("slimx.high.api", "AsyncModel"),
|
|
28
|
+
|
|
29
|
+
# Tooling
|
|
30
|
+
"tool": ("slimx.tooling", "tool"),
|
|
31
|
+
"ToolSpec": ("slimx.tooling", "ToolSpec"),
|
|
32
|
+
|
|
33
|
+
# Messages & core types
|
|
34
|
+
"Message": ("slimx.messages", "Message"),
|
|
35
|
+
"Result": ("slimx.types", "Result"),
|
|
36
|
+
"StreamEvent": ("slimx.types", "StreamEvent"),
|
|
37
|
+
"Usage": ("slimx.types", "Usage"),
|
|
38
|
+
"ToolCall": ("slimx.types", "ToolCall"),
|
|
39
|
+
|
|
40
|
+
# Low-level
|
|
41
|
+
"Client": ("slimx.low.client", "Client"),
|
|
42
|
+
"ChatRequest": ("slimx.low.types", "ChatRequest"),
|
|
43
|
+
|
|
44
|
+
# Providers
|
|
45
|
+
"get_provider": ("slimx.providers.registry", "get_provider"),
|
|
46
|
+
"list_providers": ("slimx.providers.registry", "list_providers"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
# High-level
|
|
51
|
+
"llm",
|
|
52
|
+
"allm",
|
|
53
|
+
"Model",
|
|
54
|
+
"AsyncModel",
|
|
55
|
+
|
|
56
|
+
# Tooling
|
|
57
|
+
"tool",
|
|
58
|
+
"ToolSpec",
|
|
59
|
+
|
|
60
|
+
# Messages & core types
|
|
61
|
+
"Message",
|
|
62
|
+
"Result",
|
|
63
|
+
"StreamEvent",
|
|
64
|
+
"Usage",
|
|
65
|
+
"ToolCall",
|
|
66
|
+
|
|
67
|
+
# Low-level
|
|
68
|
+
"Client",
|
|
69
|
+
"ChatRequest",
|
|
70
|
+
|
|
71
|
+
# Providers
|
|
72
|
+
"get_provider",
|
|
73
|
+
"list_providers",
|
|
74
|
+
|
|
75
|
+
"__version__",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if TYPE_CHECKING:
|
|
80
|
+
# These imports are for type checkers only; runtime is lazy.
|
|
81
|
+
from slimx.high.api import AsyncModel, Model, allm, llm
|
|
82
|
+
from slimx.low.client import Client
|
|
83
|
+
from slimx.low.types import ChatRequest
|
|
84
|
+
from slimx.messages import Message
|
|
85
|
+
from slimx.providers.registry import get_provider, list_providers
|
|
86
|
+
from slimx.tooling import ToolSpec, tool
|
|
87
|
+
from slimx.types import Result, StreamEvent, ToolCall, Usage
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def __getattr__(name: str) -> Any:
|
|
91
|
+
if name in _LAZY:
|
|
92
|
+
module_name, attr = _LAZY[name]
|
|
93
|
+
mod = import_module(module_name)
|
|
94
|
+
return getattr(mod, attr)
|
|
95
|
+
raise AttributeError(f"module 'slimx' has no attribute {name!r}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def __dir__() -> list[str]:
|
|
99
|
+
return sorted(list(globals().keys()) + list(_LAZY.keys()))
|
slimx/errors.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class SlimXError(Exception): ...
|
|
2
|
+
class ProviderError(SlimXError): ...
|
|
3
|
+
class ProviderAuthError(ProviderError): ...
|
|
4
|
+
class ProviderRateLimitError(ProviderError): ...
|
|
5
|
+
class ProviderTimeoutError(ProviderError): ...
|
|
6
|
+
class ToolExecutionError(SlimXError): ...
|
|
7
|
+
class SchemaError(SlimXError): ...
|
slimx/high/__init__.py
ADDED
slimx/high/api.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from typing import Any, Dict, Iterable, Optional, Sequence
|
|
2
|
+
|
|
3
|
+
from ..messages import Message
|
|
4
|
+
from ..types import Result, StreamEvent
|
|
5
|
+
from ..schema import parse_json, schema_for, coerce_dataclass
|
|
6
|
+
from ..tooling import ToolSpec
|
|
7
|
+
from ..providers import get_provider
|
|
8
|
+
from ..low import Client, ChatRequest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_model(model: str):
|
|
12
|
+
if ":" in model:
|
|
13
|
+
p, m = model.split(":", 1)
|
|
14
|
+
return p.strip(), m.strip()
|
|
15
|
+
return "openai", model.strip()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Model:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
model: str,
|
|
22
|
+
*,
|
|
23
|
+
temperature: Optional[float] = None,
|
|
24
|
+
max_tokens: Optional[int] = None,
|
|
25
|
+
tools: Optional[Sequence[ToolSpec]] = None,
|
|
26
|
+
tool_runtime: str = "none",
|
|
27
|
+
timeout: Optional[float] = None,
|
|
28
|
+
retries: int = 2,
|
|
29
|
+
provider_kwargs: Optional[Dict[str, Any]] = None,
|
|
30
|
+
):
|
|
31
|
+
provider_name, model_name = _parse_model(model)
|
|
32
|
+
provider = get_provider(provider_name, async_mode=False, **(provider_kwargs or {}))
|
|
33
|
+
self._client = Client(provider, timeout=timeout, retries=retries)
|
|
34
|
+
self._model = model_name
|
|
35
|
+
self._temperature = temperature
|
|
36
|
+
self._max_tokens = max_tokens
|
|
37
|
+
self._tools = list(tools or [])
|
|
38
|
+
self._tool_runtime = tool_runtime
|
|
39
|
+
|
|
40
|
+
def __call__(self, prompt: str, **overrides: Any) -> Result:
|
|
41
|
+
req = ChatRequest(
|
|
42
|
+
model=self._model,
|
|
43
|
+
messages=[Message.user(prompt)],
|
|
44
|
+
temperature=overrides.get("temperature", self._temperature),
|
|
45
|
+
max_tokens=overrides.get("max_tokens", self._max_tokens),
|
|
46
|
+
)
|
|
47
|
+
return self._client.chat(req, tools=self._tools, tool_runtime=self._tool_runtime)
|
|
48
|
+
|
|
49
|
+
def stream(self, prompt: str, **overrides: Any) -> Iterable[StreamEvent]:
|
|
50
|
+
req = ChatRequest(
|
|
51
|
+
model=self._model,
|
|
52
|
+
messages=[Message.user(prompt)],
|
|
53
|
+
temperature=overrides.get("temperature", self._temperature),
|
|
54
|
+
max_tokens=overrides.get("max_tokens", self._max_tokens),
|
|
55
|
+
)
|
|
56
|
+
return self._client.stream(req, tools=self._tools)
|
|
57
|
+
|
|
58
|
+
def json(self, prompt: str, *, schema: Any, **overrides: Any) -> Result:
|
|
59
|
+
if isinstance(schema, dict):
|
|
60
|
+
schema_dict = schema
|
|
61
|
+
schema_type = None
|
|
62
|
+
else:
|
|
63
|
+
schema_type = schema
|
|
64
|
+
schema_dict = schema_for(schema)
|
|
65
|
+
|
|
66
|
+
sys = "Return ONLY valid JSON (no markdown). Match this JSON Schema exactly: " + str(schema_dict)
|
|
67
|
+
req = ChatRequest(
|
|
68
|
+
model=self._model,
|
|
69
|
+
messages=[Message.system(sys), Message.user(prompt)],
|
|
70
|
+
temperature=overrides.get("temperature", self._temperature),
|
|
71
|
+
max_tokens=overrides.get("max_tokens", self._max_tokens),
|
|
72
|
+
response_format="json_object",
|
|
73
|
+
)
|
|
74
|
+
res = self._client.chat(req, tools=self._tools, tool_runtime=self._tool_runtime)
|
|
75
|
+
obj = parse_json(res.text)
|
|
76
|
+
res.data = coerce_dataclass(schema_type, obj) if schema_type else obj
|
|
77
|
+
return res
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AsyncModel:
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
model: str,
|
|
84
|
+
*,
|
|
85
|
+
temperature: Optional[float] = None,
|
|
86
|
+
max_tokens: Optional[int] = None,
|
|
87
|
+
tools: Optional[Sequence[ToolSpec]] = None,
|
|
88
|
+
tool_runtime: str = "none",
|
|
89
|
+
timeout: Optional[float] = None,
|
|
90
|
+
retries: int = 2,
|
|
91
|
+
provider_kwargs: Optional[Dict[str, Any]] = None,
|
|
92
|
+
):
|
|
93
|
+
provider_name, model_name = _parse_model(model)
|
|
94
|
+
provider = get_provider(provider_name, async_mode=True, **(provider_kwargs or {}))
|
|
95
|
+
self._client = Client(provider, timeout=timeout, retries=retries)
|
|
96
|
+
self._model = model_name
|
|
97
|
+
self._temperature = temperature
|
|
98
|
+
self._max_tokens = max_tokens
|
|
99
|
+
self._tools = list(tools or [])
|
|
100
|
+
self._tool_runtime = tool_runtime
|
|
101
|
+
|
|
102
|
+
async def __call__(self, prompt: str, **overrides: Any) -> Result:
|
|
103
|
+
req = ChatRequest(
|
|
104
|
+
model=self._model,
|
|
105
|
+
messages=[Message.user(prompt)],
|
|
106
|
+
temperature=overrides.get("temperature", self._temperature),
|
|
107
|
+
max_tokens=overrides.get("max_tokens", self._max_tokens),
|
|
108
|
+
)
|
|
109
|
+
return await self._client.achat(req, tools=self._tools, tool_runtime=self._tool_runtime)
|
|
110
|
+
|
|
111
|
+
async def astream(self, prompt: str, **overrides: Any):
|
|
112
|
+
req = ChatRequest(
|
|
113
|
+
model=self._model,
|
|
114
|
+
messages=[Message.user(prompt)],
|
|
115
|
+
temperature=overrides.get("temperature", self._temperature),
|
|
116
|
+
max_tokens=overrides.get("max_tokens", self._max_tokens),
|
|
117
|
+
)
|
|
118
|
+
async for ev in self._client.astream(req, tools=self._tools):
|
|
119
|
+
yield ev
|
|
120
|
+
|
|
121
|
+
async def json(self, prompt: str, *, schema: Any, **overrides: Any) -> Result:
|
|
122
|
+
if isinstance(schema, dict):
|
|
123
|
+
schema_dict = schema
|
|
124
|
+
schema_type = None
|
|
125
|
+
else:
|
|
126
|
+
schema_type = schema
|
|
127
|
+
schema_dict = schema_for(schema)
|
|
128
|
+
|
|
129
|
+
sys = "Return ONLY valid JSON (no markdown). Match this JSON Schema exactly: " + str(schema_dict)
|
|
130
|
+
req = ChatRequest(
|
|
131
|
+
model=self._model,
|
|
132
|
+
messages=[Message.system(sys), Message.user(prompt)],
|
|
133
|
+
temperature=overrides.get("temperature", self._temperature),
|
|
134
|
+
max_tokens=overrides.get("max_tokens", self._max_tokens),
|
|
135
|
+
response_format="json_object",
|
|
136
|
+
)
|
|
137
|
+
res = await self._client.achat(req, tools=self._tools, tool_runtime=self._tool_runtime)
|
|
138
|
+
obj = parse_json(res.text)
|
|
139
|
+
res.data = coerce_dataclass(schema_type, obj) if schema_type else obj
|
|
140
|
+
return res
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def llm(model: str, **kwargs: Any) -> Model:
|
|
144
|
+
return Model(model, **kwargs)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def allm(model: str, **kwargs: Any) -> AsyncModel:
|
|
148
|
+
return AsyncModel(model, **kwargs)
|
slimx/low/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Low-level SlimX API.
|
|
2
|
+
|
|
3
|
+
This module is intentionally lazy to avoid circular imports.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from typing import Any, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
_LAZY: dict[str, tuple[str, str]] = {
|
|
12
|
+
"Client": ("slimx.low.client", "Client"),
|
|
13
|
+
"ChatRequest": ("slimx.low.types", "ChatRequest"),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
__all__ = ["Client", "ChatRequest"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .client import Client
|
|
21
|
+
from .types import ChatRequest
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def __getattr__(name: str) -> Any:
|
|
25
|
+
if name in _LAZY:
|
|
26
|
+
module_name, attr = _LAZY[name]
|
|
27
|
+
return getattr(import_module(module_name), attr)
|
|
28
|
+
raise AttributeError(name)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __dir__() -> list[str]:
|
|
32
|
+
return sorted(list(globals().keys()) + list(_LAZY.keys()))
|
slimx/low/client.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from typing import Iterable, Optional, Sequence
|
|
4
|
+
from ..messages import Message
|
|
5
|
+
from ..types import Result, StreamEvent
|
|
6
|
+
from ..tooling import ToolSpec, execute_tool
|
|
7
|
+
from ..utils.retry import retry
|
|
8
|
+
from ..providers.base import Provider
|
|
9
|
+
from .types import ChatRequest
|
|
10
|
+
|
|
11
|
+
class Client:
|
|
12
|
+
def __init__(self, provider: Provider, *, timeout: Optional[float]=None, retries: int=2):
|
|
13
|
+
self.provider = provider
|
|
14
|
+
self.timeout = timeout
|
|
15
|
+
self.retries = retries
|
|
16
|
+
self.provider_name = getattr(provider, "name", "provider")
|
|
17
|
+
|
|
18
|
+
def chat(self, req: ChatRequest, *, tools: Sequence[ToolSpec]=(), tool_runtime: str="none", max_steps: int=6) -> Result:
|
|
19
|
+
tool_map = {t.name: t for t in tools}
|
|
20
|
+
started = time.perf_counter()
|
|
21
|
+
|
|
22
|
+
res = retry(lambda: self.provider.chat(req, tools=tools, timeout=self.timeout), retries=self.retries)
|
|
23
|
+
|
|
24
|
+
if tool_runtime != "auto" or not res.tool_calls or not tool_map:
|
|
25
|
+
self._attach_trace(res, req=req, started=started, steps=0)
|
|
26
|
+
return res
|
|
27
|
+
|
|
28
|
+
# Auto tool loop (best-effort cross-provider)
|
|
29
|
+
messages = list(req.messages)
|
|
30
|
+
steps = 0
|
|
31
|
+
while res.tool_calls and steps < max_steps:
|
|
32
|
+
steps += 1
|
|
33
|
+
messages.append(Message.assistant("", tool_calls=[_tool_call_to_provider_dict(tc) for tc in res.tool_calls]))
|
|
34
|
+
for tc in res.tool_calls:
|
|
35
|
+
spec = tool_map.get(tc.name)
|
|
36
|
+
if not spec:
|
|
37
|
+
continue
|
|
38
|
+
out = execute_tool(spec, tc.arguments)
|
|
39
|
+
messages.append(Message.tool(content=json.dumps(out), tool_call_id=tc.id or tc.name))
|
|
40
|
+
|
|
41
|
+
req = ChatRequest(
|
|
42
|
+
model=req.model,
|
|
43
|
+
messages=messages,
|
|
44
|
+
temperature=req.temperature,
|
|
45
|
+
max_tokens=req.max_tokens,
|
|
46
|
+
response_format=req.response_format,
|
|
47
|
+
extra=req.extra,
|
|
48
|
+
)
|
|
49
|
+
res = retry(lambda: self.provider.chat(req, tools=tools, timeout=self.timeout), retries=self.retries)
|
|
50
|
+
if not res.tool_calls:
|
|
51
|
+
break
|
|
52
|
+
self._attach_trace(res, req=req, started=started, steps=steps)
|
|
53
|
+
return res
|
|
54
|
+
|
|
55
|
+
def stream(self, req: ChatRequest, *, tools: Sequence[ToolSpec]=()) -> Iterable[StreamEvent]:
|
|
56
|
+
return self.provider.stream(req, tools=tools, timeout=self.timeout)
|
|
57
|
+
|
|
58
|
+
async def achat(self, req: ChatRequest, *, tools: Sequence[ToolSpec]=(), tool_runtime: str="none", max_steps: int=6) -> Result:
|
|
59
|
+
started = time.perf_counter()
|
|
60
|
+
# async retry
|
|
61
|
+
last = None
|
|
62
|
+
for i in range(self.retries + 1):
|
|
63
|
+
try:
|
|
64
|
+
res = await self.provider.achat(req, tools=tools, timeout=self.timeout)
|
|
65
|
+
break
|
|
66
|
+
except Exception as e:
|
|
67
|
+
last = e
|
|
68
|
+
if i >= self.retries:
|
|
69
|
+
raise
|
|
70
|
+
else:
|
|
71
|
+
raise last # type: ignore[misc]
|
|
72
|
+
|
|
73
|
+
tool_map = {t.name: t for t in tools}
|
|
74
|
+
if tool_runtime != "auto" or not res.tool_calls or not tool_map:
|
|
75
|
+
self._attach_trace(res, req=req, started=started, steps=0)
|
|
76
|
+
return res
|
|
77
|
+
|
|
78
|
+
messages = list(req.messages)
|
|
79
|
+
steps = 0
|
|
80
|
+
while res.tool_calls and steps < max_steps:
|
|
81
|
+
steps += 1
|
|
82
|
+
messages.append(Message.assistant("", tool_calls=[_tool_call_to_provider_dict(tc) for tc in res.tool_calls]))
|
|
83
|
+
for tc in res.tool_calls:
|
|
84
|
+
spec = tool_map.get(tc.name)
|
|
85
|
+
if not spec:
|
|
86
|
+
continue
|
|
87
|
+
out = execute_tool(spec, tc.arguments)
|
|
88
|
+
messages.append(Message.tool(content=json.dumps(out), tool_call_id=tc.id or tc.name))
|
|
89
|
+
|
|
90
|
+
req = ChatRequest(
|
|
91
|
+
model=req.model,
|
|
92
|
+
messages=messages,
|
|
93
|
+
temperature=req.temperature,
|
|
94
|
+
max_tokens=req.max_tokens,
|
|
95
|
+
response_format=req.response_format,
|
|
96
|
+
extra=req.extra,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
last = None
|
|
100
|
+
for i in range(self.retries + 1):
|
|
101
|
+
try:
|
|
102
|
+
res = await self.provider.achat(req, tools=tools, timeout=self.timeout)
|
|
103
|
+
break
|
|
104
|
+
except Exception as e:
|
|
105
|
+
last = e
|
|
106
|
+
if i >= self.retries:
|
|
107
|
+
raise
|
|
108
|
+
else:
|
|
109
|
+
raise last # type: ignore[misc]
|
|
110
|
+
|
|
111
|
+
if not res.tool_calls:
|
|
112
|
+
break
|
|
113
|
+
self._attach_trace(res, req=req, started=started, steps=steps)
|
|
114
|
+
return res
|
|
115
|
+
|
|
116
|
+
async def astream(self, req: ChatRequest, *, tools: Sequence[ToolSpec]=()):
|
|
117
|
+
async for ev in self.provider.astream(req, tools=tools, timeout=self.timeout):
|
|
118
|
+
yield ev
|
|
119
|
+
|
|
120
|
+
def _attach_trace(self, res: Result, *, req: ChatRequest, started: float, steps: int) -> None:
|
|
121
|
+
res.trace.update({
|
|
122
|
+
"provider": self.provider_name,
|
|
123
|
+
"model": req.model,
|
|
124
|
+
"elapsed_ms": int((time.perf_counter() - started) * 1000),
|
|
125
|
+
"retries": self.retries,
|
|
126
|
+
"tool_steps": steps,
|
|
127
|
+
"tool_call_count": len(res.tool_calls or []),
|
|
128
|
+
"timeout": self.timeout,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _tool_call_to_provider_dict(tc) -> dict:
|
|
133
|
+
return {
|
|
134
|
+
"id": tc.id or tc.name,
|
|
135
|
+
"type": "function",
|
|
136
|
+
"function": {"name": tc.name, "arguments": tc.arguments_json},
|
|
137
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Backwards-compatible import path for older code:
|
|
2
|
+
from ...providers.openai import OpenAIProvider
|
|
3
|
+
from ...providers.anthropic import AnthropicProvider
|
|
4
|
+
from ...providers.ollama import OllamaProvider
|
|
5
|
+
|
|
6
|
+
__all__ = ["OpenAIProvider", "AnthropicProvider", "OllamaProvider"]
|
slimx/low/types.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
from ..messages import Message
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ChatRequest:
|
|
7
|
+
model: str
|
|
8
|
+
messages: List[Message]
|
|
9
|
+
temperature: Optional[float] = None
|
|
10
|
+
max_tokens: Optional[int] = None
|
|
11
|
+
response_format: Optional[str] = None
|
|
12
|
+
extra: Optional[Dict[str, Any]] = None
|
|
13
|
+
|
|
14
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
15
|
+
d: Dict[str, Any] = {"model": self.model, "messages": [m.to_dict() for m in self.messages]}
|
|
16
|
+
if self.temperature is not None:
|
|
17
|
+
d["temperature"] = self.temperature
|
|
18
|
+
if self.max_tokens is not None:
|
|
19
|
+
d["max_tokens"] = self.max_tokens
|
|
20
|
+
if self.response_format:
|
|
21
|
+
d["response_format"] = self.response_format
|
|
22
|
+
if self.extra:
|
|
23
|
+
d.update(self.extra)
|
|
24
|
+
return d
|
slimx/messages.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# slimx/messages.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Message:
|
|
10
|
+
"""
|
|
11
|
+
SlimX canonical message.
|
|
12
|
+
|
|
13
|
+
- `role`: system | user | assistant | tool
|
|
14
|
+
- `content`: message content (text)
|
|
15
|
+
- `name`: optional participant name
|
|
16
|
+
- `tool_call_id`: provider tool call identifier (OpenAI/Anthropic)
|
|
17
|
+
- `tool_name`: required by some providers for tool result messages (e.g., Ollama)
|
|
18
|
+
- `metadata`: extension point for provider-specific or app-specific fields
|
|
19
|
+
"""
|
|
20
|
+
role: str
|
|
21
|
+
content: str
|
|
22
|
+
name: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
# Tool message fields
|
|
25
|
+
tool_call_id: Optional[str] = None
|
|
26
|
+
tool_name: Optional[str] = None
|
|
27
|
+
tool_calls: List[Dict[str, Any]] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def system(content: str, *, name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> "Message":
|
|
33
|
+
return Message("system", content, name=name, metadata=metadata or {})
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def user(content: str, *, name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> "Message":
|
|
37
|
+
return Message("user", content, name=name, metadata=metadata or {})
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def assistant(
|
|
41
|
+
content: str,
|
|
42
|
+
*,
|
|
43
|
+
name: Optional[str] = None,
|
|
44
|
+
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
|
45
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
46
|
+
) -> "Message":
|
|
47
|
+
return Message("assistant", content, name=name, tool_calls=tool_calls or [], metadata=metadata or {})
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def tool(
|
|
51
|
+
content: str,
|
|
52
|
+
*,
|
|
53
|
+
tool_call_id: Optional[str] = None,
|
|
54
|
+
tool_name: Optional[str] = None,
|
|
55
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
56
|
+
) -> "Message":
|
|
57
|
+
# tool_call_id: OpenAI/Anthropic
|
|
58
|
+
# tool_name: Ollama and some adapters
|
|
59
|
+
return Message(
|
|
60
|
+
"tool",
|
|
61
|
+
content,
|
|
62
|
+
tool_call_id=tool_call_id,
|
|
63
|
+
tool_name=tool_name,
|
|
64
|
+
metadata=metadata or {},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
68
|
+
"""
|
|
69
|
+
Best-effort provider-agnostic serialization.
|
|
70
|
+
Providers may ignore unknown keys; adapters/providers can override if needed.
|
|
71
|
+
"""
|
|
72
|
+
d: Dict[str, Any] = {"role": self.role, "content": self.content}
|
|
73
|
+
|
|
74
|
+
if self.name:
|
|
75
|
+
d["name"] = self.name
|
|
76
|
+
|
|
77
|
+
# Tool-related fields (only set if present)
|
|
78
|
+
if self.tool_call_id:
|
|
79
|
+
d["tool_call_id"] = self.tool_call_id
|
|
80
|
+
if self.tool_name:
|
|
81
|
+
d["tool_name"] = self.tool_name
|
|
82
|
+
if self.tool_calls:
|
|
83
|
+
d["tool_calls"] = self.tool_calls
|
|
84
|
+
|
|
85
|
+
# Optional extra fields
|
|
86
|
+
if self.metadata:
|
|
87
|
+
d["metadata"] = self.metadata
|
|
88
|
+
|
|
89
|
+
return d
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Providers package.
|
|
2
|
+
|
|
3
|
+
Provider implementations live under `slimx.providers.*`.
|
|
4
|
+
|
|
5
|
+
Important: we do *not* import/register built-in providers at import time.
|
|
6
|
+
Defaults are registered lazily in `slimx.providers.registry` when you call
|
|
7
|
+
`list_providers()` or `get_provider()`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .base import ProviderCapabilities
|
|
11
|
+
from .registry import get_provider, list_providers, load_plugins, register
|
|
12
|
+
|
|
13
|
+
__all__ = ["register", "get_provider", "load_plugins", "list_providers", "ProviderCapabilities"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Built-in provider factories.
|
|
2
|
+
|
|
3
|
+
We keep provider imports inside factories so importing SlimX doesn't pull in
|
|
4
|
+
provider code unless you actually select/use a provider.
|
|
5
|
+
|
|
6
|
+
Factories accept keyword overrides where possible:
|
|
7
|
+
- OpenAI: api_key, base_url
|
|
8
|
+
- Anthropic: api_key, base_url, version
|
|
9
|
+
- Ollama: base_url
|
|
10
|
+
- Google: api_key, base_url
|
|
11
|
+
|
|
12
|
+
Async selection is controlled via `async_mode=True`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any, Callable, Dict
|
|
19
|
+
|
|
20
|
+
from ..errors import ProviderAuthError
|
|
21
|
+
from .base import Provider
|
|
22
|
+
|
|
23
|
+
ProviderFactory = Callable[..., Provider]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def openai_factory(*, async_mode: bool = False, **kwargs: Any) -> Provider:
|
|
27
|
+
api_key = kwargs.pop("api_key", None) or os.environ.get("OPENAI_API_KEY")
|
|
28
|
+
base_url = kwargs.pop("base_url", None) or os.environ.get(
|
|
29
|
+
"OPENAI_BASE_URL", "https://api.openai.com/v1"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if async_mode:
|
|
33
|
+
from .openai_async import OpenAIAsyncProvider as P
|
|
34
|
+
else:
|
|
35
|
+
from .openai import OpenAIProvider as P
|
|
36
|
+
|
|
37
|
+
if not api_key:
|
|
38
|
+
raise ProviderAuthError("OPENAI_API_KEY is not set")
|
|
39
|
+
|
|
40
|
+
return P(api_key=api_key, base_url=base_url)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def anthropic_factory(*, async_mode: bool = False, **kwargs: Any) -> Provider:
|
|
44
|
+
api_key = kwargs.pop("api_key", None) or os.environ.get("ANTHROPIC_API_KEY")
|
|
45
|
+
base_url = kwargs.pop("base_url", None) or os.environ.get(
|
|
46
|
+
"ANTHROPIC_BASE_URL", "https://api.anthropic.com"
|
|
47
|
+
)
|
|
48
|
+
version = kwargs.pop("version", None) or os.environ.get("ANTHROPIC_VERSION", "2023-06-01")
|
|
49
|
+
|
|
50
|
+
if async_mode:
|
|
51
|
+
from .anthropic_async import AnthropicAsyncProvider as P
|
|
52
|
+
else:
|
|
53
|
+
from .anthropic import AnthropicProvider as P
|
|
54
|
+
|
|
55
|
+
if not api_key:
|
|
56
|
+
raise ProviderAuthError("ANTHROPIC_API_KEY is not set")
|
|
57
|
+
|
|
58
|
+
return P(api_key=api_key, base_url=base_url, version=version)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ollama_factory(*, async_mode: bool = False, **kwargs: Any) -> Provider:
|
|
62
|
+
base_url = kwargs.pop("base_url", None) or os.environ.get(
|
|
63
|
+
"OLLAMA_BASE_URL", "http://localhost:11434"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if async_mode:
|
|
67
|
+
from .ollama_async import OllamaAsyncProvider as P
|
|
68
|
+
else:
|
|
69
|
+
from .ollama import OllamaProvider as P
|
|
70
|
+
|
|
71
|
+
return P(base_url=base_url)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def google_factory(*, async_mode: bool = False, **kwargs: Any) -> Provider:
|
|
75
|
+
api_key = (
|
|
76
|
+
kwargs.pop("api_key", None)
|
|
77
|
+
or os.environ.get("GOOGLE_API_KEY")
|
|
78
|
+
or os.environ.get("GEMINI_API_KEY")
|
|
79
|
+
)
|
|
80
|
+
base_url = kwargs.pop("base_url", None) or os.environ.get(
|
|
81
|
+
"GOOGLE_BASE_URL", "https://generativelanguage.googleapis.com/v1beta"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if async_mode:
|
|
85
|
+
from .google_async import GoogleAsyncProvider as P
|
|
86
|
+
else:
|
|
87
|
+
from .google import GoogleProvider as P
|
|
88
|
+
|
|
89
|
+
if not api_key:
|
|
90
|
+
raise ProviderAuthError("GOOGLE_API_KEY or GEMINI_API_KEY is not set")
|
|
91
|
+
|
|
92
|
+
return P(api_key=api_key, base_url=base_url)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
DEFAULT_FACTORIES: Dict[str, ProviderFactory] = {
|
|
96
|
+
"openai": openai_factory,
|
|
97
|
+
"anthropic": anthropic_factory,
|
|
98
|
+
"ollama": ollama_factory,
|
|
99
|
+
"google": google_factory,
|
|
100
|
+
}
|