pico-cli-ai 0.1.1__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.
pico_ai/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """pico_ai: LLM abstraction and protocol normalisation."""
pico_ai/openrouter.py ADDED
@@ -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 {}
pico_ai/provider.py ADDED
@@ -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
+ ]
@@ -0,0 +1,244 @@
1
+ """Shared base for OpenAI chat-completions-compatible providers.
2
+
3
+ Covers any backend speaking ``POST {base}/chat/completions`` with SSE
4
+ ``data:`` chunks shaped like OpenAI's (``choices[].delta``,
5
+ ``finish_reason``, ``usage``). Subclasses only set endpoint defaults and
6
+ identity — the wire conversion lives here, once.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ from collections.abc import AsyncIterator
14
+
15
+ import httpx
16
+
17
+ from ..types import AICallRequest, StreamEvent, ToolCall, Usage
18
+
19
+
20
+ class OpenAICompatProvider:
21
+ """Streams chat completions and normalizes them to ``StreamEvent``."""
22
+
23
+ provider_id: str = "openai-compat"
24
+ display_name: str = "OpenAI-compatible"
25
+ completions_path: str = "/chat/completions"
26
+ models_path: str = "/models"
27
+
28
+ def __init__(
29
+ self,
30
+ api_key: str = "",
31
+ base_url: str = "https://api.openai.com/v1",
32
+ *,
33
+ client: httpx.AsyncClient | None = None,
34
+ timeout: httpx.Timeout | None = None,
35
+ first_token_timeout: float = 60.0,
36
+ extra_headers: dict[str, str] | None = None,
37
+ ) -> None:
38
+ self._api_key = api_key
39
+ self._base_url = base_url.rstrip("/")
40
+ self._client = client
41
+ self._timeout = timeout or httpx.Timeout(300.0, connect=10.0)
42
+ self._first_token_timeout = first_token_timeout
43
+ self._extra_headers = extra_headers or {}
44
+
45
+ # -- streaming ----------------------------------------------------------
46
+
47
+ async def stream(self, request: AICallRequest) -> AsyncIterator[StreamEvent]:
48
+ payload = self._build_payload(request)
49
+ headers = {
50
+ "Content-Type": "application/json",
51
+ **self._extra_headers,
52
+ }
53
+ if self._api_key:
54
+ headers["Authorization"] = f"Bearer {self._api_key}"
55
+ client = self._client or httpx.AsyncClient(timeout=self._timeout)
56
+ pending: dict[int, dict] = {}
57
+ response_cm = client.stream(
58
+ "POST",
59
+ f"{self._base_url}{self.completions_path}",
60
+ json=payload,
61
+ headers=headers,
62
+ )
63
+ response: httpx.Response | None = None
64
+ try:
65
+ try:
66
+ async with asyncio.timeout(self._first_token_timeout):
67
+ response = await response_cm.__aenter__()
68
+ if response.status_code >= 400:
69
+ body = (await response.aread()).decode(errors="replace")
70
+ raise RuntimeError(
71
+ f"{self.display_name} error {response.status_code} "
72
+ f"for model '{request.model}': "
73
+ f"{body[:500]}"
74
+ )
75
+ lines = response.aiter_lines()
76
+ first_line = await anext(lines, None)
77
+ except TimeoutError as exc:
78
+ raise RuntimeError(
79
+ f"no response from model within "
80
+ f"{self._first_token_timeout:g}s "
81
+ f"(first-token timeout); try another model"
82
+ ) from exc
83
+ if first_line is not None:
84
+ async for event in self._emit_lines(
85
+ self._prepend(first_line, lines), pending
86
+ ):
87
+ yield event
88
+ finally:
89
+ if response is not None:
90
+ await response_cm.__aexit__(None, None, None)
91
+ if self._client is None:
92
+ await client.aclose()
93
+
94
+ async def _emit_lines(
95
+ self, lines: AsyncIterator[str], pending: dict[int, dict]
96
+ ) -> AsyncIterator[StreamEvent]:
97
+ """Parse SSE lines and yield normalized stream events."""
98
+ async for line in lines:
99
+ if not line.startswith("data:"):
100
+ continue
101
+ data = line[len("data:") :].strip()
102
+ if data == "[DONE]":
103
+ return
104
+ for event in self._parse_chunk(json.loads(data), pending):
105
+ yield event
106
+
107
+ @staticmethod
108
+ async def _prepend(
109
+ first: str, rest: AsyncIterator[str]
110
+ ) -> AsyncIterator[str]:
111
+ yield first
112
+ async for item in rest:
113
+ yield item
114
+
115
+ # -- models -------------------------------------------------------------
116
+
117
+ async def list_models(self) -> list[dict]:
118
+ """Return available models (OpenAI ``/models`` shape)."""
119
+ headers = dict(self._extra_headers)
120
+ if self._api_key:
121
+ headers["Authorization"] = f"Bearer {self._api_key}"
122
+ client = self._client or httpx.AsyncClient(timeout=self._timeout)
123
+ try:
124
+ response = await client.get(
125
+ f"{self._base_url}{self.models_path}",
126
+ headers=headers,
127
+ )
128
+ response.raise_for_status()
129
+ data = response.json().get("data", [])
130
+ finally:
131
+ if self._client is None:
132
+ await client.aclose()
133
+ return [
134
+ {
135
+ "id": entry.get("id", ""),
136
+ "name": entry.get("id", ""),
137
+ "is_free": False,
138
+ "supports_tools": True,
139
+ }
140
+ for entry in data
141
+ ]
142
+
143
+ # -- wire conversion ----------------------------------------------------
144
+
145
+ def _build_payload(self, request: AICallRequest) -> dict:
146
+ messages: list[dict] = []
147
+ if request.system:
148
+ messages.append({"role": "system", "content": request.system})
149
+ for m in request.messages:
150
+ msg: dict = {"role": m.role, "content": m.content}
151
+ if m.tool_calls:
152
+ msg["tool_calls"] = [
153
+ {
154
+ "id": tc.id,
155
+ "type": "function",
156
+ "function": {
157
+ "name": tc.name,
158
+ "arguments": json.dumps(tc.arguments),
159
+ },
160
+ }
161
+ for tc in m.tool_calls
162
+ ]
163
+ if not m.content:
164
+ msg["content"] = None
165
+ if m.tool_call_id is not None:
166
+ msg["tool_call_id"] = m.tool_call_id
167
+ if m.name is not None:
168
+ msg["name"] = m.name
169
+ messages.append(msg)
170
+ payload: dict = {
171
+ "model": request.model,
172
+ "messages": messages,
173
+ "stream": True,
174
+ "stream_options": {"include_usage": True},
175
+ }
176
+ if request.tools:
177
+ payload["tools"] = [
178
+ {
179
+ "type": "function",
180
+ "function": {
181
+ "name": t.name,
182
+ "description": t.description,
183
+ "parameters": t.input_schema,
184
+ },
185
+ }
186
+ for t in request.tools
187
+ ]
188
+ return payload
189
+
190
+ def _parse_chunk(self, chunk: dict, pending: dict[int, dict]) -> list[StreamEvent]:
191
+ events: list[StreamEvent] = []
192
+ for choice in chunk.get("choices") or []:
193
+ delta = choice.get("delta") or {}
194
+ content = delta.get("content")
195
+ if content:
196
+ events.append(StreamEvent(kind="text", text=content))
197
+ reasoning = delta.get("reasoning") or delta.get("reasoning_content")
198
+ if reasoning:
199
+ events.append(StreamEvent(kind="thinking", thinking=reasoning))
200
+ for tc in delta.get("tool_calls") or []:
201
+ idx = tc.get("index", 0)
202
+ entry = pending.setdefault(idx, {"id": "", "name": "", "args": ""})
203
+ if tc.get("id"):
204
+ entry["id"] = tc["id"]
205
+ fn = tc.get("function") or {}
206
+ if fn.get("name"):
207
+ entry["name"] = fn["name"]
208
+ if fn.get("arguments"):
209
+ entry["args"] += fn["arguments"]
210
+ if choice.get("finish_reason") == "tool_calls":
211
+ for idx in sorted(pending):
212
+ entry = pending[idx]
213
+ events.append(
214
+ StreamEvent(
215
+ kind="tool_call",
216
+ tool_call=ToolCall(
217
+ id=entry["id"],
218
+ name=entry["name"],
219
+ arguments=self._parse_args(entry["args"]),
220
+ ),
221
+ )
222
+ )
223
+ pending.clear()
224
+ usage = chunk.get("usage")
225
+ if usage:
226
+ events.append(
227
+ StreamEvent(
228
+ kind="usage",
229
+ usage=Usage(
230
+ input_tokens=usage.get("prompt_tokens", 0),
231
+ output_tokens=usage.get("completion_tokens", 0),
232
+ total_tokens=usage.get("total_tokens", 0),
233
+ ),
234
+ )
235
+ )
236
+ return events
237
+
238
+ @staticmethod
239
+ def _parse_args(raw: str) -> dict:
240
+ try:
241
+ parsed = json.loads(raw)
242
+ return parsed if isinstance(parsed, dict) else {}
243
+ except json.JSONDecodeError:
244
+ return {}