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.
@@ -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
+ ]