pico-cli-ai 0.1.1__tar.gz
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.
- pico_cli_ai-0.1.1/.gitignore +25 -0
- pico_cli_ai-0.1.1/PKG-INFO +26 -0
- pico_cli_ai-0.1.1/README.md +5 -0
- pico_cli_ai-0.1.1/pyproject.toml +30 -0
- pico_cli_ai-0.1.1/src/pico_ai/__init__.py +1 -0
- pico_cli_ai-0.1.1/src/pico_ai/openrouter.py +262 -0
- pico_cli_ai-0.1.1/src/pico_ai/provider.py +16 -0
- pico_cli_ai-0.1.1/src/pico_ai/providers/__init__.py +86 -0
- pico_cli_ai-0.1.1/src/pico_ai/providers/_compat.py +244 -0
- pico_cli_ai-0.1.1/src/pico_ai/providers/anthropic.py +285 -0
- pico_cli_ai-0.1.1/src/pico_ai/providers/deepseek.py +78 -0
- pico_cli_ai-0.1.1/src/pico_ai/providers/gemini.py +309 -0
- pico_cli_ai-0.1.1/src/pico_ai/providers/ollama.py +239 -0
- pico_cli_ai-0.1.1/src/pico_ai/providers/openai.py +74 -0
- pico_cli_ai-0.1.1/src/pico_ai/providers/spec.py +45 -0
- pico_cli_ai-0.1.1/src/pico_ai/py.typed +1 -0
- pico_cli_ai-0.1.1/src/pico_ai/types.py +64 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
*.egg
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
.eggs/
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
|
|
16
|
+
# Test / coverage
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.coverage
|
|
19
|
+
htmlcov/
|
|
20
|
+
|
|
21
|
+
# IDE / OS
|
|
22
|
+
.idea/
|
|
23
|
+
.vscode/
|
|
24
|
+
.DS_Store
|
|
25
|
+
Thumbs.db
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pico-cli-ai
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: LLM abstraction and protocol normalisation.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Arya-Ojha/PicoCLI_Learn
|
|
6
|
+
Project-URL: Repository, https://github.com/Arya-Ojha/PicoCLI_Learn
|
|
7
|
+
Project-URL: Issues, https://github.com/Arya-Ojha/PicoCLI_Learn/issues
|
|
8
|
+
Author-email: Arya-Ojha <aryaojha195@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ai-agent,anthropic,llm,openai,openrouter
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: pydantic>=2
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# pico-cli-ai
|
|
23
|
+
|
|
24
|
+
LLM abstraction and protocol normalisation for `pico-cli`: one unified streaming "AI call" shape plus per-provider adapters (OpenRouter, OpenAI, Anthropic, Gemini, DeepSeek, local Ollama).
|
|
25
|
+
|
|
26
|
+
See the [pico-cli README](https://github.com/Arya-Ojha/PicoCLI_Learn#readme) for the full picture.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# pico-cli-ai
|
|
2
|
+
|
|
3
|
+
LLM abstraction and protocol normalisation for `pico-cli`: one unified streaming "AI call" shape plus per-provider adapters (OpenRouter, OpenAI, Anthropic, Gemini, DeepSeek, local Ollama).
|
|
4
|
+
|
|
5
|
+
See the [pico-cli README](https://github.com/Arya-Ojha/PicoCLI_Learn#readme) for the full picture.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pico-cli-ai"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "LLM abstraction and protocol normalisation."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Arya-Ojha", email = "aryaojha195@gmail.com" }]
|
|
8
|
+
keywords = ["llm", "ai-agent", "openrouter", "openai", "anthropic"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
]
|
|
17
|
+
requires-python = ">=3.12"
|
|
18
|
+
dependencies = ["pydantic>=2", "httpx>=0.27"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/Arya-Ojha/PicoCLI_Learn"
|
|
22
|
+
Repository = "https://github.com/Arya-Ojha/PicoCLI_Learn"
|
|
23
|
+
Issues = "https://github.com/Arya-Ojha/PicoCLI_Learn/issues"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/pico_ai"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pico_ai: LLM abstraction and protocol normalisation."""
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""OpenRouter provider: streams chat completions and normalizes to StreamEvents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .types import AICallRequest, StreamEvent, ToolCall, Usage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenRouterProvider:
|
|
15
|
+
"""Streams chat completions from OpenRouter and normalizes them."""
|
|
16
|
+
|
|
17
|
+
provider_id = "openrouter"
|
|
18
|
+
display_name = "OpenRouter"
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
api_key: str,
|
|
23
|
+
base_url: str = "https://openrouter.ai/api/v1",
|
|
24
|
+
client: httpx.AsyncClient | None = None,
|
|
25
|
+
timeout: httpx.Timeout | None = None,
|
|
26
|
+
first_token_timeout: float = 60.0,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._api_key = api_key
|
|
29
|
+
self._base_url = base_url
|
|
30
|
+
self._client = client
|
|
31
|
+
# Streaming LLM responses can take a while before the first token; the
|
|
32
|
+
# httpx default (5s) is far too short, so default to a generous read.
|
|
33
|
+
self._timeout = timeout or httpx.Timeout(300.0, connect=10.0)
|
|
34
|
+
# Abort if the model produces no first token within this many seconds;
|
|
35
|
+
# without it a stalled request looks like an infinite hang.
|
|
36
|
+
self._first_token_timeout = first_token_timeout
|
|
37
|
+
# model id -> whether the model supports tool calling (populated by
|
|
38
|
+
# list_models); unknown models are assumed to support tools.
|
|
39
|
+
self._tool_support: dict[str, bool] = {}
|
|
40
|
+
|
|
41
|
+
async def stream(self, request: AICallRequest) -> AsyncIterator[StreamEvent]:
|
|
42
|
+
payload = self._build_payload(request)
|
|
43
|
+
headers = {
|
|
44
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
}
|
|
47
|
+
client = self._client or httpx.AsyncClient(timeout=self._timeout)
|
|
48
|
+
pending: dict[int, dict] = {}
|
|
49
|
+
response_cm = client.stream(
|
|
50
|
+
"POST",
|
|
51
|
+
f"{self._base_url}/chat/completions",
|
|
52
|
+
json=payload,
|
|
53
|
+
headers=headers,
|
|
54
|
+
)
|
|
55
|
+
response: httpx.Response | None = None
|
|
56
|
+
try:
|
|
57
|
+
# Bound the wait for the response headers plus the first SSE line:
|
|
58
|
+
# a stalled upstream (e.g. a broken/free model) would otherwise
|
|
59
|
+
# hang for the full 300s read timeout looking like an infinite
|
|
60
|
+
# spin. Once tokens are flowing, the httpx read timeout governs
|
|
61
|
+
# inter-chunk gaps.
|
|
62
|
+
try:
|
|
63
|
+
async with asyncio.timeout(self._first_token_timeout):
|
|
64
|
+
response = await response_cm.__aenter__()
|
|
65
|
+
if response.status_code >= 400:
|
|
66
|
+
# Surface the API's error body: raise_for_status()
|
|
67
|
+
# alone hides the actual reason (e.g. unsupported
|
|
68
|
+
# tool calling for the chosen model).
|
|
69
|
+
body = (await response.aread()).decode(
|
|
70
|
+
errors="replace"
|
|
71
|
+
)
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
f"OpenRouter error {response.status_code} "
|
|
74
|
+
f"for model '{request.model}': "
|
|
75
|
+
f"{body[:500]}"
|
|
76
|
+
)
|
|
77
|
+
lines = response.aiter_lines()
|
|
78
|
+
first_line = await anext(lines, None)
|
|
79
|
+
except TimeoutError as exc:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
f"no response from model within "
|
|
82
|
+
f"{self._first_token_timeout:g}s "
|
|
83
|
+
f"(first-token timeout); try another model"
|
|
84
|
+
) from exc
|
|
85
|
+
if first_line is not None:
|
|
86
|
+
async for event in self._emit_lines(
|
|
87
|
+
self._prepend(first_line, lines), pending
|
|
88
|
+
):
|
|
89
|
+
yield event
|
|
90
|
+
finally:
|
|
91
|
+
if response is not None:
|
|
92
|
+
await response_cm.__aexit__(None, None, None)
|
|
93
|
+
if self._client is None:
|
|
94
|
+
await client.aclose()
|
|
95
|
+
|
|
96
|
+
async def _emit_lines(
|
|
97
|
+
self, lines: AsyncIterator[str], pending: dict[int, dict]
|
|
98
|
+
) -> AsyncIterator[StreamEvent]:
|
|
99
|
+
"""Parse SSE lines and yield normalized stream events."""
|
|
100
|
+
async for line in lines:
|
|
101
|
+
if not line.startswith("data:"):
|
|
102
|
+
continue
|
|
103
|
+
data = line[len("data:") :].strip()
|
|
104
|
+
if data == "[DONE]":
|
|
105
|
+
return
|
|
106
|
+
for event in self._parse_chunk(json.loads(data), pending):
|
|
107
|
+
yield event
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
async def _prepend(
|
|
111
|
+
first: str, rest: AsyncIterator[str]
|
|
112
|
+
) -> AsyncIterator[str]:
|
|
113
|
+
yield first
|
|
114
|
+
async for item in rest:
|
|
115
|
+
yield item
|
|
116
|
+
|
|
117
|
+
async def list_models(self) -> list[dict]:
|
|
118
|
+
"""Return the available models from OpenRouter.
|
|
119
|
+
|
|
120
|
+
Each entry is a dict with keys ``id``, ``name`` and ``is_free``.
|
|
121
|
+
"""
|
|
122
|
+
headers = {}
|
|
123
|
+
if self._api_key:
|
|
124
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
125
|
+
client = self._client or httpx.AsyncClient(timeout=self._timeout)
|
|
126
|
+
try:
|
|
127
|
+
response = await client.get(
|
|
128
|
+
f"{self._base_url}/models",
|
|
129
|
+
headers=headers,
|
|
130
|
+
)
|
|
131
|
+
response.raise_for_status()
|
|
132
|
+
data = response.json().get("data", [])
|
|
133
|
+
finally:
|
|
134
|
+
if self._client is None:
|
|
135
|
+
await client.aclose()
|
|
136
|
+
models: list[dict] = []
|
|
137
|
+
for entry in data:
|
|
138
|
+
pricing = entry.get("pricing") or {}
|
|
139
|
+
is_free = (
|
|
140
|
+
str(pricing.get("prompt", "1")).strip() in ("0", "0.0", "-1")
|
|
141
|
+
and str(pricing.get("completion", "1")).strip() in ("0", "0.0", "-1")
|
|
142
|
+
)
|
|
143
|
+
supported = entry.get("supported_parameters") or []
|
|
144
|
+
supports_tools = any(
|
|
145
|
+
p in supported for p in ("tools", "tool_choice")
|
|
146
|
+
)
|
|
147
|
+
model_id = entry.get("id", "")
|
|
148
|
+
# Remember tool support so stream() can omit tools for models
|
|
149
|
+
# that reject them (OpenRouter answers 400 otherwise).
|
|
150
|
+
self._tool_support[model_id] = supports_tools
|
|
151
|
+
models.append(
|
|
152
|
+
{
|
|
153
|
+
"id": model_id,
|
|
154
|
+
"name": entry.get("name") or model_id,
|
|
155
|
+
"is_free": is_free,
|
|
156
|
+
"supports_tools": supports_tools,
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
return models
|
|
160
|
+
|
|
161
|
+
def _build_payload(self, request: AICallRequest) -> dict:
|
|
162
|
+
messages: list[dict] = []
|
|
163
|
+
if request.system:
|
|
164
|
+
messages.append({"role": "system", "content": request.system})
|
|
165
|
+
for m in request.messages:
|
|
166
|
+
msg: dict = {"role": m.role, "content": m.content}
|
|
167
|
+
if m.tool_calls:
|
|
168
|
+
msg["tool_calls"] = [
|
|
169
|
+
{
|
|
170
|
+
"id": tc.id,
|
|
171
|
+
"type": "function",
|
|
172
|
+
"function": {
|
|
173
|
+
"name": tc.name,
|
|
174
|
+
"arguments": json.dumps(tc.arguments),
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
for tc in m.tool_calls
|
|
178
|
+
]
|
|
179
|
+
# An assistant turn that only made tool calls has no text;
|
|
180
|
+
# send null content rather than an empty string.
|
|
181
|
+
if not m.content:
|
|
182
|
+
msg["content"] = None
|
|
183
|
+
if m.tool_call_id is not None:
|
|
184
|
+
msg["tool_call_id"] = m.tool_call_id
|
|
185
|
+
if m.name is not None:
|
|
186
|
+
msg["name"] = m.name
|
|
187
|
+
messages.append(msg)
|
|
188
|
+
payload: dict = {
|
|
189
|
+
"model": request.model,
|
|
190
|
+
"messages": messages,
|
|
191
|
+
"stream": True,
|
|
192
|
+
"stream_options": {"include_usage": True},
|
|
193
|
+
}
|
|
194
|
+
if request.tools and self._tool_support.get(request.model, True):
|
|
195
|
+
payload["tools"] = [
|
|
196
|
+
{
|
|
197
|
+
"type": "function",
|
|
198
|
+
"function": {
|
|
199
|
+
"name": t.name,
|
|
200
|
+
"description": t.description,
|
|
201
|
+
"parameters": t.input_schema,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
for t in request.tools
|
|
205
|
+
]
|
|
206
|
+
return payload
|
|
207
|
+
|
|
208
|
+
def _parse_chunk(self, chunk: dict, pending: dict[int, dict]) -> list[StreamEvent]:
|
|
209
|
+
events: list[StreamEvent] = []
|
|
210
|
+
for choice in chunk.get("choices") or []:
|
|
211
|
+
delta = choice.get("delta") or {}
|
|
212
|
+
content = delta.get("content")
|
|
213
|
+
if content:
|
|
214
|
+
events.append(StreamEvent(kind="text", text=content))
|
|
215
|
+
reasoning = delta.get("reasoning") or delta.get("reasoning_content")
|
|
216
|
+
if reasoning:
|
|
217
|
+
events.append(StreamEvent(kind="thinking", thinking=reasoning))
|
|
218
|
+
for tc in delta.get("tool_calls") or []:
|
|
219
|
+
idx = tc.get("index", 0)
|
|
220
|
+
entry = pending.setdefault(idx, {"id": "", "name": "", "args": ""})
|
|
221
|
+
if tc.get("id"):
|
|
222
|
+
entry["id"] = tc["id"]
|
|
223
|
+
fn = tc.get("function") or {}
|
|
224
|
+
if fn.get("name"):
|
|
225
|
+
entry["name"] = fn["name"]
|
|
226
|
+
if fn.get("arguments"):
|
|
227
|
+
entry["args"] += fn["arguments"]
|
|
228
|
+
if choice.get("finish_reason") == "tool_calls":
|
|
229
|
+
for idx in sorted(pending):
|
|
230
|
+
entry = pending[idx]
|
|
231
|
+
events.append(
|
|
232
|
+
StreamEvent(
|
|
233
|
+
kind="tool_call",
|
|
234
|
+
tool_call=ToolCall(
|
|
235
|
+
id=entry["id"],
|
|
236
|
+
name=entry["name"],
|
|
237
|
+
arguments=self._parse_args(entry["args"]),
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
pending.clear()
|
|
242
|
+
usage = chunk.get("usage")
|
|
243
|
+
if usage:
|
|
244
|
+
events.append(
|
|
245
|
+
StreamEvent(
|
|
246
|
+
kind="usage",
|
|
247
|
+
usage=Usage(
|
|
248
|
+
input_tokens=usage.get("prompt_tokens", 0),
|
|
249
|
+
output_tokens=usage.get("completion_tokens", 0),
|
|
250
|
+
total_tokens=usage.get("total_tokens", 0),
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
return events
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _parse_args(raw: str) -> dict:
|
|
258
|
+
try:
|
|
259
|
+
parsed = json.loads(raw)
|
|
260
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
261
|
+
except json.JSONDecodeError:
|
|
262
|
+
return {}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The provider seam: a single async streaming interface every backend implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from .types import AICallRequest, StreamEvent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Provider(Protocol):
|
|
12
|
+
"""A backend that turns a request into a stream of normalized events."""
|
|
13
|
+
|
|
14
|
+
def stream(self, request: AICallRequest) -> AsyncIterator[StreamEvent]:
|
|
15
|
+
"""Yield normalized stream events for a request."""
|
|
16
|
+
...
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Provider registry: the host-owned list of available backends (ADR-0004).
|
|
2
|
+
|
|
3
|
+
Adding a provider means adding one module next to this one (converting that
|
|
4
|
+
provider's wire format to the app's default ``StreamEvent`` shape) plus one
|
|
5
|
+
line in ``PROVIDERS`` below. This is a hardcoded list, not a plugin API —
|
|
6
|
+
the host keeps sovereignty over which backends exist.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pico_ai.openrouter import OpenRouterProvider
|
|
14
|
+
|
|
15
|
+
from . import anthropic, deepseek, gemini, ollama
|
|
16
|
+
from . import openai as openai_provider
|
|
17
|
+
from .spec import FieldSpec, ProviderSpec
|
|
18
|
+
|
|
19
|
+
OPENROUTER_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _create_openrouter(config: dict[str, str]) -> OpenRouterProvider:
|
|
23
|
+
return OpenRouterProvider(
|
|
24
|
+
api_key=config.get("api_key", ""),
|
|
25
|
+
base_url=config.get("base_url", "") or OPENROUTER_DEFAULT_BASE_URL,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
OPENROUTER_SPEC = ProviderSpec(
|
|
30
|
+
id="openrouter",
|
|
31
|
+
display_name="OpenRouter",
|
|
32
|
+
description="OpenRouter gateway (many models, one key).",
|
|
33
|
+
fields=(
|
|
34
|
+
FieldSpec(
|
|
35
|
+
key="api_key",
|
|
36
|
+
label="API key",
|
|
37
|
+
secret=True,
|
|
38
|
+
required=True,
|
|
39
|
+
env_var="OPENROUTER_API_KEY",
|
|
40
|
+
placeholder="sk-or-v1-...",
|
|
41
|
+
),
|
|
42
|
+
FieldSpec(
|
|
43
|
+
key="base_url",
|
|
44
|
+
label="Base URL",
|
|
45
|
+
default=OPENROUTER_DEFAULT_BASE_URL,
|
|
46
|
+
placeholder=OPENROUTER_DEFAULT_BASE_URL,
|
|
47
|
+
),
|
|
48
|
+
FieldSpec(key="model", label="Default model", default="openrouter/free"),
|
|
49
|
+
),
|
|
50
|
+
default_model="openrouter/free",
|
|
51
|
+
create=_create_openrouter,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
PROVIDERS: dict[str, ProviderSpec] = {
|
|
55
|
+
OPENROUTER_SPEC.id: OPENROUTER_SPEC,
|
|
56
|
+
openai_provider.SPEC.id: openai_provider.SPEC,
|
|
57
|
+
anthropic.SPEC.id: anthropic.SPEC,
|
|
58
|
+
gemini.SPEC.id: gemini.SPEC,
|
|
59
|
+
deepseek.SPEC.id: deepseek.SPEC,
|
|
60
|
+
ollama.SPEC.id: ollama.SPEC,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def provider_ids() -> list[str]:
|
|
65
|
+
"""Return registered provider ids in picker order."""
|
|
66
|
+
return list(PROVIDERS)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_spec(provider_id: str) -> ProviderSpec:
|
|
70
|
+
"""Return the spec for ``provider_id`` (``KeyError`` when unknown)."""
|
|
71
|
+
return PROVIDERS[provider_id]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create_provider(provider_id: str, config: dict[str, str]) -> Any:
|
|
75
|
+
"""Build the adapter for ``provider_id`` from a config dict."""
|
|
76
|
+
return get_spec(provider_id).create(config)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"PROVIDERS",
|
|
81
|
+
"ProviderSpec",
|
|
82
|
+
"FieldSpec",
|
|
83
|
+
"provider_ids",
|
|
84
|
+
"get_spec",
|
|
85
|
+
"create_provider",
|
|
86
|
+
]
|