llmshim 0.1.2__py3-none-win_amd64.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.
llmshim/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ llmshim — Multi-provider LLM gateway for Python.
3
+
4
+ Usage:
5
+ import llmshim
6
+
7
+ llmshim.configure(anthropic="sk-ant-...", openai="sk-...")
8
+ resp = llmshim.chat("claude-sonnet-4-6", "Hello!")
9
+ print(resp["message"]["content"])
10
+
11
+ The proxy server starts automatically on first use and stops on exit.
12
+ """
13
+
14
+ from llmshim._client import chat, stream, models, health, configure
15
+
16
+ __all__ = ["chat", "stream", "models", "health", "configure"]
17
+ __version__ = "0.1.2"
llmshim/_client.py ADDED
@@ -0,0 +1,246 @@
1
+ """
2
+ llmshim module-level API.
3
+
4
+ import llmshim
5
+ resp = llmshim.chat("claude-sonnet-4-6", "Hello!")
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any, Generator, Optional
14
+
15
+ import httpx
16
+
17
+ from llmshim._server import ensure_server
18
+
19
+ _base_url: Optional[str] = None
20
+ _http: Optional[httpx.Client] = None
21
+ _timeout: float = 120.0
22
+
23
+
24
+ def _get_base_url() -> str:
25
+ global _base_url
26
+ if _base_url is None:
27
+ _base_url = ensure_server()
28
+ return _base_url
29
+
30
+
31
+ def _get_http() -> httpx.Client:
32
+ global _http
33
+ if _http is None:
34
+ _http = httpx.Client(timeout=_timeout)
35
+ return _http
36
+
37
+
38
+ def configure(
39
+ *,
40
+ openai: Optional[str] = None,
41
+ anthropic: Optional[str] = None,
42
+ gemini: Optional[str] = None,
43
+ xai: Optional[str] = None,
44
+ ) -> None:
45
+ """Configure API keys. Writes to ~/.llmshim/config.toml.
46
+
47
+ Keys are persistent — configure once, use everywhere.
48
+ Only provided keys are updated; others are left unchanged.
49
+
50
+ Usage:
51
+ import llmshim
52
+ llmshim.configure(anthropic="sk-ant-...", openai="sk-...")
53
+ """
54
+ config_dir = Path.home() / ".llmshim"
55
+ config_path = config_dir / "config.toml"
56
+
57
+ # Read existing config
58
+ existing: dict[str, Any] = {}
59
+ if config_path.exists():
60
+ try:
61
+ import tomllib # Python 3.11+
62
+ except ImportError:
63
+ tomllib = None # type: ignore
64
+ if tomllib:
65
+ try:
66
+ with open(config_path, "rb") as f:
67
+ existing = tomllib.load(f)
68
+ except Exception:
69
+ pass
70
+
71
+ keys = existing.get("keys", {})
72
+ if openai is not None:
73
+ keys["openai"] = openai
74
+ if anthropic is not None:
75
+ keys["anthropic"] = anthropic
76
+ if gemini is not None:
77
+ keys["gemini"] = gemini
78
+ if xai is not None:
79
+ keys["xai"] = xai
80
+
81
+ # Write back
82
+ config_dir.mkdir(parents=True, exist_ok=True)
83
+ lines = ["[keys]"]
84
+ for key, val in keys.items():
85
+ if val:
86
+ lines.append(f'{key} = "{val}"')
87
+ if "proxy" in existing:
88
+ lines.append("")
89
+ lines.append("[proxy]")
90
+ proxy = existing["proxy"]
91
+ if "host" in proxy:
92
+ lines.append(f'host = "{proxy["host"]}"')
93
+ if "port" in proxy:
94
+ lines.append(f"port = {proxy['port']}")
95
+
96
+ config_path.write_text("\n".join(lines) + "\n")
97
+
98
+ # Also set as env vars for the current process (so server picks them up)
99
+ if openai:
100
+ os.environ["OPENAI_API_KEY"] = openai
101
+ if anthropic:
102
+ os.environ["ANTHROPIC_API_KEY"] = anthropic
103
+ if gemini:
104
+ os.environ["GEMINI_API_KEY"] = gemini
105
+ if xai:
106
+ os.environ["XAI_API_KEY"] = xai
107
+
108
+ # If server is already running, it won't pick up new keys until restart.
109
+ # Force restart on next call.
110
+ global _base_url
111
+ from llmshim._server import _stop_server
112
+
113
+ _stop_server()
114
+ _base_url = None
115
+
116
+
117
+ def chat(
118
+ model: str,
119
+ messages: str | list[dict[str, Any]],
120
+ *,
121
+ max_tokens: Optional[int] = None,
122
+ temperature: Optional[float] = None,
123
+ reasoning_effort: Optional[str] = None,
124
+ provider_config: Optional[dict[str, Any]] = None,
125
+ fallback: Optional[list[str]] = None,
126
+ ) -> dict[str, Any]:
127
+ """Send a chat completion request.
128
+
129
+ Args:
130
+ model: Model ID (e.g., "anthropic/claude-sonnet-4-6" or "claude-sonnet-4-6")
131
+ messages: A string (single user message) or list of message dicts
132
+ max_tokens: Maximum output tokens
133
+ temperature: Sampling temperature
134
+ reasoning_effort: "low", "medium", or "high"
135
+ provider_config: Raw provider-specific JSON
136
+ fallback: Ordered list of fallback model IDs
137
+
138
+ Returns:
139
+ Response dict with keys: id, model, provider, message, usage, latency_ms
140
+
141
+ Usage:
142
+ resp = llmshim.chat("claude-sonnet-4-6", "What is Rust?")
143
+ print(resp["message"]["content"])
144
+ """
145
+ if isinstance(messages, str):
146
+ msgs = [{"role": "user", "content": messages}]
147
+ else:
148
+ msgs = messages
149
+
150
+ body: dict[str, Any] = {"model": model, "messages": msgs}
151
+
152
+ config: dict[str, Any] = {}
153
+ if max_tokens is not None:
154
+ config["max_tokens"] = max_tokens
155
+ if temperature is not None:
156
+ config["temperature"] = temperature
157
+ if reasoning_effort is not None:
158
+ config["reasoning_effort"] = reasoning_effort
159
+ if config:
160
+ body["config"] = config
161
+
162
+ if provider_config is not None:
163
+ body["provider_config"] = provider_config
164
+ if fallback is not None:
165
+ body["fallback"] = fallback
166
+
167
+ resp = _get_http().post(f"{_get_base_url()}/v1/chat", json=body)
168
+ resp.raise_for_status()
169
+ return resp.json()
170
+
171
+
172
+ def stream(
173
+ model: str,
174
+ messages: str | list[dict[str, Any]],
175
+ *,
176
+ max_tokens: Optional[int] = None,
177
+ temperature: Optional[float] = None,
178
+ reasoning_effort: Optional[str] = None,
179
+ provider_config: Optional[dict[str, Any]] = None,
180
+ ) -> Generator[dict[str, Any], None, None]:
181
+ """Stream a chat completion. Yields typed event dicts.
182
+
183
+ Event types: content, reasoning, tool_call, usage, done, error
184
+
185
+ Usage:
186
+ for event in llmshim.stream("claude-sonnet-4-6", "Write a poem"):
187
+ if event["type"] == "content":
188
+ print(event["text"], end="")
189
+ """
190
+ if isinstance(messages, str):
191
+ msgs = [{"role": "user", "content": messages}]
192
+ else:
193
+ msgs = messages
194
+
195
+ body: dict[str, Any] = {"model": model, "messages": msgs}
196
+
197
+ config: dict[str, Any] = {}
198
+ if max_tokens is not None:
199
+ config["max_tokens"] = max_tokens
200
+ if temperature is not None:
201
+ config["temperature"] = temperature
202
+ if reasoning_effort is not None:
203
+ config["reasoning_effort"] = reasoning_effort
204
+ if config:
205
+ body["config"] = config
206
+
207
+ if provider_config is not None:
208
+ body["provider_config"] = provider_config
209
+
210
+ with httpx.stream(
211
+ "POST",
212
+ f"{_get_base_url()}/v1/chat/stream",
213
+ json=body,
214
+ timeout=_timeout,
215
+ ) as resp:
216
+ resp.raise_for_status()
217
+ current_event = ""
218
+ for line in resp.iter_lines():
219
+ if line.startswith("event: "):
220
+ current_event = line[7:]
221
+ elif line.startswith("data: "):
222
+ data = json.loads(line[6:])
223
+ data["type"] = current_event or data.get("type", "")
224
+ yield data
225
+
226
+
227
+ def models() -> list[dict[str, str]]:
228
+ """List available models.
229
+
230
+ Returns:
231
+ List of dicts with keys: id, provider, name
232
+ """
233
+ resp = _get_http().get(f"{_get_base_url()}/v1/models")
234
+ resp.raise_for_status()
235
+ return resp.json()["models"]
236
+
237
+
238
+ def health() -> dict[str, Any]:
239
+ """Health check.
240
+
241
+ Returns:
242
+ Dict with keys: status, providers
243
+ """
244
+ resp = _get_http().get(f"{_get_base_url()}/health")
245
+ resp.raise_for_status()
246
+ return resp.json()
llmshim/_server.py ADDED
@@ -0,0 +1,172 @@
1
+ """
2
+ Auto-managed llmshim proxy server.
3
+
4
+ Starts the bundled Rust binary on a random port, waits for it to be ready,
5
+ and stops it on process exit. The server is shared across all LlmShim
6
+ instances in the same process.
7
+ """
8
+
9
+ import atexit
10
+ import os
11
+ import platform
12
+ import signal
13
+ import socket
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ _server_process = None
20
+ _server_port = None
21
+
22
+
23
+ def _find_binary() -> str:
24
+ """Find the llmshim binary. Checks in order:
25
+ 1. Bundled in the package (clients/python/llmshim/bin/)
26
+ 2. On PATH (e.g., installed via cargo install)
27
+ 3. In the repo's target/release/ directory
28
+ """
29
+ bin_name = "llmshim.exe" if platform.system() == "Windows" else "llmshim"
30
+ pkg_dir = Path(__file__).parent
31
+
32
+ # 1. Python scripts directory (where maturin/pip installs binaries)
33
+ import sysconfig
34
+ scripts_dir = sysconfig.get_path("scripts")
35
+ if scripts_dir:
36
+ candidate = Path(scripts_dir) / bin_name
37
+ if candidate.exists() and os.access(str(candidate), os.X_OK):
38
+ return str(candidate)
39
+
40
+ # 2. Bundled in package (dev installs, manual copy)
41
+ bundled = pkg_dir / "bin" / bin_name
42
+ if bundled.exists() and os.access(str(bundled), os.X_OK):
43
+ return str(bundled)
44
+
45
+ # 3. On PATH (cargo install)
46
+ import shutil
47
+ on_path = shutil.which("llmshim")
48
+ if on_path:
49
+ return on_path
50
+
51
+ # 4. Repo target directory (development)
52
+ for root in [pkg_dir.parent.parent.parent, pkg_dir.parent.parent]:
53
+ for build_dir in ["target/release", "target/debug"]:
54
+ candidate = root / build_dir / bin_name
55
+ if candidate.exists() and os.access(str(candidate), os.X_OK):
56
+ return str(candidate)
57
+
58
+ raise FileNotFoundError(
59
+ "llmshim binary not found. Install with:\n"
60
+ " pip install llmshim (includes the binary)\n"
61
+ " cargo install llmshim (from crates.io)\n"
62
+ " cargo build --release --features proxy (from source)"
63
+ )
64
+
65
+
66
+ def _find_free_port() -> int:
67
+ """Find a free port on localhost."""
68
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
69
+ s.bind(("127.0.0.1", 0))
70
+ return s.getsockname()[1]
71
+
72
+
73
+ def _wait_for_server(port: int, timeout: float = 10.0) -> bool:
74
+ """Wait until the server is accepting connections."""
75
+ start = time.time()
76
+ while time.time() - start < timeout:
77
+ try:
78
+ with socket.create_connection(("127.0.0.1", port), timeout=0.5):
79
+ return True
80
+ except (ConnectionRefusedError, OSError):
81
+ time.sleep(0.1)
82
+ return False
83
+
84
+
85
+ def _stop_server():
86
+ """Stop the server process."""
87
+ global _server_process, _server_port
88
+ if _server_process is not None:
89
+ try:
90
+ _server_process.terminate()
91
+ _server_process.wait(timeout=5)
92
+ except Exception:
93
+ try:
94
+ _server_process.kill()
95
+ except Exception:
96
+ pass
97
+ _server_process = None
98
+ _server_port = None
99
+
100
+
101
+ def ensure_server() -> str:
102
+ """Ensure the proxy server is running. Returns the base URL.
103
+
104
+ Starts the server automatically if not already running.
105
+ The server is stopped automatically on process exit.
106
+ """
107
+ global _server_process, _server_port
108
+
109
+ # Already running?
110
+ if _server_process is not None and _server_process.poll() is None:
111
+ return f"http://127.0.0.1:{_server_port}"
112
+
113
+ # Find binary
114
+ binary = _find_binary()
115
+
116
+ # Pick a free port
117
+ port = _find_free_port()
118
+
119
+ # Start the server
120
+ env = os.environ.copy()
121
+ env["LLMSHIM_HOST"] = "127.0.0.1"
122
+ env["LLMSHIM_PORT"] = str(port)
123
+
124
+ _server_process = subprocess.Popen(
125
+ [binary, "proxy"],
126
+ env=env,
127
+ stdout=subprocess.DEVNULL,
128
+ stderr=subprocess.PIPE,
129
+ )
130
+
131
+ # Register cleanup
132
+ atexit.register(_stop_server)
133
+
134
+ # Also handle SIGTERM
135
+ try:
136
+ original_handler = signal.getsignal(signal.SIGTERM)
137
+ def _handle_sigterm(signum, frame):
138
+ _stop_server()
139
+ if callable(original_handler) and original_handler not in (signal.SIG_DFL, signal.SIG_IGN):
140
+ original_handler(signum, frame)
141
+ sys.exit(0)
142
+ signal.signal(signal.SIGTERM, _handle_sigterm)
143
+ except (ValueError, OSError):
144
+ pass # Can't set signal handler in some contexts (e.g., threads)
145
+
146
+ # Wait for server to be ready
147
+ if not _wait_for_server(port):
148
+ stderr_output = ""
149
+ try:
150
+ stderr_output = _server_process.stderr.read().decode() if _server_process.stderr else ""
151
+ except Exception:
152
+ pass
153
+ _stop_server()
154
+
155
+ # Detect common errors and give helpful messages
156
+ if "No API keys found" in stderr_output:
157
+ raise RuntimeError(
158
+ "No API keys configured. Set them with:\n\n"
159
+ " import llmshim\n"
160
+ " llmshim.configure(anthropic='sk-ant-...', openai='sk-...')\n\n"
161
+ "Or from the command line:\n"
162
+ " llmshim configure"
163
+ )
164
+
165
+ raise RuntimeError(
166
+ f"llmshim proxy failed to start on port {port}.\n"
167
+ f"Binary: {binary}\n"
168
+ f"stderr: {stderr_output}"
169
+ )
170
+
171
+ _server_port = port
172
+ return f"http://127.0.0.1:{port}"
Binary file
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: llmshim
3
+ Version: 0.1.2
4
+ Classifier: Development Status :: 4 - Beta
5
+ Classifier: Intended Audience :: Developers
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Rust
9
+ Classifier: Topic :: Software Development :: Libraries
10
+ Requires-Dist: httpx>=0.24
11
+ Summary: Multi-provider LLM gateway — one interface, every provider
12
+ Home-Page: https://github.com/sanjay920/llmshim
13
+ License: MIT
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Project-URL: Homepage, https://github.com/sanjay920/llmshim
17
+ Project-URL: Repository, https://github.com/sanjay920/llmshim
18
+
19
+ # llmshim
20
+
21
+ One interface, every LLM provider. The proxy server starts automatically — no setup needed.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install llmshim
27
+ ```
28
+
29
+ ## Configure
30
+
31
+ ```python
32
+ import llmshim
33
+
34
+ # Set API keys (writes to ~/.llmshim/config.toml — only needed once)
35
+ llmshim.configure(
36
+ anthropic="sk-ant-...",
37
+ openai="sk-...",
38
+ gemini="AIza...",
39
+ xai="xai-...",
40
+ )
41
+ ```
42
+
43
+ Or from the CLI: `llmshim configure`
44
+
45
+ ## Chat
46
+
47
+ ```python
48
+ import llmshim
49
+
50
+ resp = llmshim.chat("claude-sonnet-4-6", "What is Rust?")
51
+ print(resp["message"]["content"])
52
+ ```
53
+
54
+ With options:
55
+
56
+ ```python
57
+ resp = llmshim.chat(
58
+ "openai/gpt-5.4",
59
+ "Explain quicksort",
60
+ max_tokens=500,
61
+ temperature=0.7,
62
+ )
63
+ ```
64
+
65
+ With message history:
66
+
67
+ ```python
68
+ resp = llmshim.chat("claude-sonnet-4-6", [
69
+ {"role": "system", "content": "You are a pirate."},
70
+ {"role": "user", "content": "Hello!"},
71
+ ], max_tokens=500)
72
+ ```
73
+
74
+ ## Streaming
75
+
76
+ ```python
77
+ for event in llmshim.stream("claude-sonnet-4-6", "Write a poem"):
78
+ if event["type"] == "content":
79
+ print(event["text"], end="", flush=True)
80
+ elif event["type"] == "reasoning":
81
+ pass # thinking tokens
82
+ elif event["type"] == "usage":
83
+ print(f"\n[↑{event['input_tokens']} ↓{event['output_tokens']}]")
84
+ ```
85
+
86
+ ## Multi-Model Conversations
87
+
88
+ Switch models mid-conversation. History carries over.
89
+
90
+ ```python
91
+ messages = [{"role": "user", "content": "What is a closure?"}]
92
+
93
+ r1 = llmshim.chat("claude-sonnet-4-6", messages, max_tokens=500)
94
+ print(f"Claude: {r1['message']['content']}")
95
+
96
+ messages.append({"role": "assistant", "content": r1["message"]["content"]})
97
+ messages.append({"role": "user", "content": "Now explain differently."})
98
+
99
+ r2 = llmshim.chat("gpt-5.4", messages, max_tokens=500)
100
+ print(f"GPT: {r2['message']['content']}")
101
+ ```
102
+
103
+ ## Reasoning / Thinking
104
+
105
+ ```python
106
+ resp = llmshim.chat(
107
+ "claude-sonnet-4-6",
108
+ "Solve: x^2 - 5x + 6 = 0",
109
+ max_tokens=4000,
110
+ reasoning_effort="high",
111
+ )
112
+ print(resp["reasoning"]) # thinking content
113
+ print(resp["message"]["content"]) # answer
114
+ ```
115
+
116
+ ## Fallback Chains
117
+
118
+ ```python
119
+ resp = llmshim.chat(
120
+ "anthropic/claude-sonnet-4-6",
121
+ "Hello",
122
+ max_tokens=100,
123
+ fallback=["openai/gpt-5.4", "gemini/gemini-3-flash-preview"],
124
+ )
125
+ ```
126
+
127
+ ## Other
128
+
129
+ ```python
130
+ llmshim.models() # list available models
131
+ llmshim.health() # {"status": "ok", "providers": [...]}
132
+ ```
133
+
134
+ ## How It Works
135
+
136
+ On first call, the package:
137
+ 1. Finds the `llmshim` binary (bundled, on PATH, or in repo)
138
+ 2. Starts the proxy on a random localhost port
139
+ 3. Routes your request through it
140
+ 4. Server stops automatically when Python exits
141
+
142
+ No Docker, no background services, no manual server management.
143
+
144
+ ## Supported Models
145
+
146
+ | Provider | Models |
147
+ |----------|--------|
148
+ | OpenAI | `gpt-5.4` |
149
+ | Anthropic | `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-haiku-4-5-20251001` |
150
+ | Gemini | `gemini-3.1-pro-preview`, `gemini-3-flash-preview`, `gemini-3.1-flash-lite-preview` |
151
+ | xAI | `grok-4-1-fast-reasoning`, `grok-4-1-fast-non-reasoning` |
152
+
@@ -0,0 +1,8 @@
1
+ llmshim/__init__.py,sha256=IkkeWimsM5SKVQHTjYt7vzTEe_NKjGAuV4tmopq6BQA,485
2
+ llmshim/_client.py,sha256=2PMEBY-iIBaFwB4hp2Gi-Rb6uTdPjbYm8I9CgGV_1bc,7162
3
+ llmshim/_server.py,sha256=RNbopsB83BhYst9Yfbiu_b2m9iJOHWF6nxZkhUX7AeY,5500
4
+ llmshim-0.1.2.data/scripts/llmshim.exe,sha256=_bL6XAePCHXVPISFeKgoZg5yQijZ-wdJBGgRV86AHfg,5669376
5
+ llmshim-0.1.2.dist-info/METADATA,sha256=UNcWHAVX_SuWuXuLyFSbXLguJrNZlPBM-e-Q5BkUSaw,3802
6
+ llmshim-0.1.2.dist-info/WHEEL,sha256=uJOc2U-Q1x95AlblQcqMRb3iR4QnPtdI7X2ycPN99rM,94
7
+ llmshim-0.1.2.dist-info/sboms/llmshim.cyclonedx.json,sha256=jd2OJlouFEf0x-NhDGHqC3jDc8lhQxKRBoQYsjAe_Pc,186159
8
+ llmshim-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: maturin (1.12.6)
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64