llm-pycascade 0.1.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.
@@ -0,0 +1,47 @@
1
+ """llm-pycascade — Resilient cascading LLM inference
2
+ with failover and circuit breaking."""
3
+
4
+ from llm_pycascade.cascade import run_cascade
5
+ from llm_pycascade.config import (
6
+ AppConfig,
7
+ CascadeConfig,
8
+ CascadeEntry,
9
+ DatabaseConfig,
10
+ FailureConfig,
11
+ ProviderConfig,
12
+ ProviderType,
13
+ load_config,
14
+ )
15
+ from llm_pycascade.db import init_db
16
+ from llm_pycascade.error import CascadeError, ProviderError
17
+ from llm_pycascade.models import (
18
+ ContentBlock,
19
+ Conversation,
20
+ LlmResponse,
21
+ Message,
22
+ MessageRole,
23
+ ToolDefinition,
24
+ )
25
+
26
+ __all__ = [
27
+ "run_cascade",
28
+ "AppConfig",
29
+ "CascadeConfig",
30
+ "CascadeEntry",
31
+ "DatabaseConfig",
32
+ "FailureConfig",
33
+ "ProviderConfig",
34
+ "ProviderType",
35
+ "load_config",
36
+ "init_db",
37
+ "CascadeError",
38
+ "ProviderError",
39
+ "ContentBlock",
40
+ "Conversation",
41
+ "LlmResponse",
42
+ "Message",
43
+ "MessageRole",
44
+ "ToolDefinition",
45
+ ]
46
+
47
+ __version__ = "0.1.0"
@@ -0,0 +1,287 @@
1
+ """Cascade engine — resilient multi-provider LLM inference with failover.
2
+
3
+ The cascade iterates through an ordered list of provider/model entries.
4
+ For each entry, it checks whether the combination is on cooldown, attempts
5
+ the request, and on failure computes an exponential-backoff cooldown
6
+ before moving to the next entry.
7
+
8
+ If every entry is exhausted, the failed conversation is persisted to disk
9
+ and a :class:`CascadeError` is raised.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import time
16
+ from datetime import datetime, timedelta, timezone
17
+ from typing import TYPE_CHECKING
18
+
19
+ from llm_pycascade.config import (
20
+ AppConfig,
21
+ CascadeConfig,
22
+ ProviderConfig,
23
+ ProviderType,
24
+ expand_tilde,
25
+ )
26
+ from llm_pycascade.db import is_on_cooldown, log_attempt, set_cooldown
27
+ from llm_pycascade.error import CascadeError, ProviderError
28
+ from llm_pycascade.persistence import save_failed_conversation
29
+ from llm_pycascade.providers.anthropic import AnthropicProvider
30
+ from llm_pycascade.providers.gemini import GeminiProvider
31
+ from llm_pycascade.providers.ollama import OllamaProvider
32
+ from llm_pycascade.providers.openai import OpenAIProvider
33
+
34
+ if TYPE_CHECKING:
35
+ import aiosqlite
36
+
37
+ from llm_pycascade.models import Conversation, LlmResponse
38
+ from llm_pycascade.providers.base import LlmProvider
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # ── constants ────────────────────────────────────────────────────────────
43
+
44
+ BASE_COOLDOWN_SECS: float = 30.0
45
+ MAX_COOLDOWN_SECS: float = 3600.0
46
+
47
+
48
+ # ── provider factory ────────────────────────────────────────────────────
49
+
50
+
51
+ def build_provider(
52
+ provider_name: str,
53
+ provider_config: ProviderConfig,
54
+ model: str,
55
+ ) -> LlmProvider:
56
+ """Instantiate the correct provider class based on the configuration.
57
+
58
+ Args:
59
+ provider_name: Logical name of the provider (for error messages).
60
+ provider_config: The provider's configuration block.
61
+ model: Model identifier to use.
62
+
63
+ Returns:
64
+ A fully-constructed :class:`LlmProvider` instance.
65
+
66
+ Raises:
67
+ ProviderError.missing_api_key: If no API key can be resolved.
68
+ ValueError: If the provider type is unknown.
69
+ """
70
+ from llm_pycascade.secrets import resolve_api_key
71
+
72
+ base_url = provider_config.base_url
73
+ api_key_env = provider_config.api_key_env or f"{provider_name.upper()}_API_KEY"
74
+ api_key_service = provider_config.api_key_service or provider_name
75
+
76
+ ptype = provider_config.type
77
+
78
+ if ptype == ProviderType.OPENAI:
79
+ api_key = resolve_api_key(api_key_service, api_key_env)
80
+ return OpenAIProvider(api_key=api_key, model=model, base_url=base_url)
81
+
82
+ if ptype == ProviderType.ANTHROPIC:
83
+ api_key = resolve_api_key(api_key_service, api_key_env)
84
+ return AnthropicProvider(api_key=api_key, model=model, base_url=base_url)
85
+
86
+ if ptype == ProviderType.GEMINI:
87
+ api_key = resolve_api_key(api_key_service, api_key_env)
88
+ return GeminiProvider(api_key=api_key, model=model, base_url=base_url)
89
+
90
+ if ptype == ProviderType.OLLAMA:
91
+ return OllamaProvider(model=model, base_url=base_url)
92
+
93
+ raise ValueError(f"Unknown provider type: {ptype}")
94
+
95
+
96
+ # ── cooldown computation ──────────────────────────────────────────────────
97
+
98
+
99
+ async def compute_cooldown(
100
+ entry_key: str,
101
+ conn: aiosqlite.Connection,
102
+ ) -> timedelta:
103
+ """Compute the cooldown duration based on recent failure history.
104
+
105
+ Uses exponential backoff:
106
+
107
+ * 1st failure → ``BASE_COOLDOWN_SECS``
108
+ * 2nd failure → ``2 × BASE_COOLDOWN_SECS``
109
+ * 3rd failure → ``4 × BASE_COOLDOWN_SECS``
110
+ * …capped at ``MAX_COOLDOWN_SECS``
111
+
112
+ Args:
113
+ entry_key: ``provider/model`` identifier.
114
+ conn: Open database connection for querying failure history.
115
+
116
+ Returns:
117
+ The computed cooldown duration.
118
+ """
119
+ # Count recent failures within the last hour for this entry
120
+ one_hour_ago = time.time() - 3600.0
121
+ cursor = await conn.execute(
122
+ "SELECT COUNT(*) FROM attempt_log "
123
+ "WHERE provider_model = ? AND http_status IS NOT NULL AND timestamp > ?",
124
+ (entry_key, datetime.fromtimestamp(one_hour_ago, tz=timezone.utc).isoformat()),
125
+ )
126
+ row = await cursor.fetchone()
127
+ failure_count = row[0] if row else 0
128
+
129
+ # Exponential backoff: 2^n * BASE_COOLDOWN_SECS, capped
130
+
131
+ cooldown_secs = min(
132
+ (2**failure_count) * BASE_COOLDOWN_SECS,
133
+ MAX_COOLDOWN_SECS,
134
+ )
135
+ return timedelta(seconds=cooldown_secs)
136
+
137
+
138
+ # ── main cascade function ──────────────────────────────────────────────
139
+
140
+
141
+ async def run_cascade(
142
+ cascade_name: str,
143
+ conversation: Conversation,
144
+ config: AppConfig,
145
+ conn: aiosqlite.Connection,
146
+ ) -> LlmResponse:
147
+ """Run the named cascade against *conversation*.
148
+
149
+ Iterates through the cascade's entries in order:
150
+
151
+ 1. Check if the entry is on cooldown → skip if so.
152
+ 2. Build the provider and send the request.
153
+ 3. On success: log the attempt and return the response.
154
+ 4. On failure: log the attempt, compute & set a cooldown, and continue
155
+ to the next entry.
156
+ 5. If all entries are exhausted: persist the failed conversation to disk
157
+ and raise :class:`CascadeError`.
158
+
159
+ Args:
160
+ cascade_name: Name of the cascade (must exist in ``config.cascades``).
161
+ conversation: The conversation to send.
162
+ config: The full application configuration.
163
+ conn: Open database connection for logging and cooldown queries.
164
+
165
+ Returns:
166
+ The first successful :class:`LlmResponse`.
167
+
168
+ Raises:
169
+ CascadeError: If every provider in the cascade fails.
170
+ KeyError: If the cascade name is not found in the configuration.
171
+ """
172
+ if cascade_name not in config.cascades:
173
+ raise KeyError(f"Cascade '{cascade_name}' not found in configuration")
174
+
175
+ cascade_config: CascadeConfig = config.cascades[cascade_name]
176
+ errors: list[tuple[str, ProviderError]] = []
177
+
178
+ for entry in cascade_config.entries:
179
+ provider_model_key = f"{entry.provider}/{entry.model}"
180
+
181
+ # 1. Check cooldown
182
+ if await is_on_cooldown(conn, provider_model_key):
183
+ logger.info(
184
+ "Skipping %s — on cooldown",
185
+ provider_model_key,
186
+ )
187
+ continue
188
+
189
+ # 2. Build provider
190
+ provider_cfg = config.providers.get(entry.provider)
191
+ if provider_cfg is None:
192
+ logger.warning(
193
+ "Provider '%s' not in config, skipping entry %s",
194
+ entry.provider,
195
+ provider_model_key,
196
+ )
197
+ continue
198
+
199
+ try:
200
+ provider = build_provider(entry.provider, provider_cfg, entry.model)
201
+ except (ProviderError, ValueError) as exc:
202
+ logger.error(
203
+ "Failed to build provider for %s: %s",
204
+ provider_model_key,
205
+ exc,
206
+ )
207
+ errors.append(
208
+ (
209
+ provider_model_key,
210
+ exc
211
+ if isinstance(exc, ProviderError)
212
+ else ProviderError.other(str(exc)),
213
+ )
214
+ )
215
+ continue
216
+
217
+ # 3. Send request
218
+ start = time.monotonic()
219
+ try:
220
+ response = await provider.complete(conversation)
221
+ except ProviderError as exc:
222
+ latency_ms = int((time.monotonic() - start) * 1000)
223
+
224
+ # 4. Log failure and set cooldown
225
+ logger.warning(
226
+ "Provider %s failed: %s",
227
+ provider_model_key,
228
+ exc,
229
+ )
230
+ await log_attempt(
231
+ conn,
232
+ cascade_name=cascade_name,
233
+ provider_model=provider_model_key,
234
+ http_status=exc.http_status,
235
+ latency_ms=latency_ms,
236
+ input_tokens=0,
237
+ output_tokens=0,
238
+ )
239
+
240
+ # Compute cooldown (respect Retry-After if available)
241
+ if exc.retry_after_seconds is not None:
242
+ cooldown_duration = timedelta(seconds=exc.retry_after_seconds)
243
+ else:
244
+ cooldown_duration = await compute_cooldown(provider_model_key, conn)
245
+
246
+ cooldown_until = time.time() + cooldown_duration.total_seconds()
247
+ await set_cooldown(conn, provider_model_key, cooldown_until)
248
+
249
+ logger.info(
250
+ "Cooldown set for %s until %s",
251
+ provider_model_key,
252
+ datetime.fromtimestamp(cooldown_until, tz=timezone.utc).isoformat(),
253
+ )
254
+
255
+ errors.append((provider_model_key, exc))
256
+ continue
257
+
258
+ latency_ms = int((time.monotonic() - start) * 1000)
259
+
260
+ # 5. Success — log and return
261
+ await log_attempt(
262
+ conn,
263
+ cascade_name=cascade_name,
264
+ provider_model=provider_model_key,
265
+ http_status=None,
266
+ latency_ms=latency_ms,
267
+ input_tokens=response.input_tokens,
268
+ output_tokens=response.output_tokens,
269
+ )
270
+ logger.info(
271
+ "Cascade '%s' succeeded via %s (%dms)",
272
+ cascade_name,
273
+ provider_model_key,
274
+ latency_ms,
275
+ )
276
+ return response
277
+
278
+ # 6. All entries exhausted — persist and raise
279
+ failure_dir = expand_tilde(config.failure_persistence.dir)
280
+ failed_path = save_failed_conversation(conversation, failure_dir, cascade_name)
281
+
282
+ error_summary = "; ".join(f"{k}: {v}" for k, v in errors)
283
+ raise CascadeError(
284
+ cascade_name=cascade_name,
285
+ message=f"All providers failed. {error_summary}",
286
+ failed_prompt_path=failed_path,
287
+ )
@@ -0,0 +1,193 @@
1
+ """Configuration types and TOML loading utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ # Python ≥ 3.11 has tomllib in the stdlib; fall back to tomli on older versions.
13
+ if os.sys.version_info >= (3, 11):
14
+ import tomllib # type: ignore[import-not-found]
15
+ else:
16
+ try:
17
+ import tomllib # type: ignore[import-not-found]
18
+ except ImportError:
19
+ import tomli as tomllib # type: ignore[import-not-found]
20
+
21
+
22
+ class ProviderType(str, Enum):
23
+ """Supported LLM provider types."""
24
+
25
+ OPENAI = "openai"
26
+ ANTHROPIC = "anthropic"
27
+ GEMINI = "gemini"
28
+ OLLAMA = "ollama"
29
+
30
+
31
+ class ProviderConfig(BaseModel):
32
+ """Configuration for a single LLM provider.
33
+
34
+ Attributes:
35
+ type: The provider type (openai, anthropic, gemini, ollama).
36
+ api_key_service: Service name to look up in the system keyring.
37
+ api_key_env: Environment variable name to read the API key from.
38
+ base_url: Override the default API base URL.
39
+ """
40
+
41
+ type: ProviderType
42
+ api_key_service: str | None = Field(default=None, exclude_none=True)
43
+ api_key_env: str | None = Field(default=None, exclude_none=True)
44
+ base_url: str | None = Field(default=None, exclude_none=True)
45
+
46
+
47
+ class CascadeEntry(BaseModel):
48
+ """A single entry in a cascade — one provider/model pair.
49
+
50
+ Attributes:
51
+ provider: Key name of the provider (must match a key in ``[providers]``).
52
+ model: The model identifier to use.
53
+ """
54
+
55
+ provider: str
56
+ model: str
57
+
58
+
59
+ class CascadeConfig(BaseModel):
60
+ """Ordered list of provider/model entries that form a cascade.
61
+
62
+ Attributes:
63
+ entries: The cascade entries, tried in order.
64
+ """
65
+
66
+ entries: list[CascadeEntry] = Field(default_factory=list)
67
+
68
+
69
+ class DatabaseConfig(BaseModel):
70
+ """Configuration for the SQLite database used for attempt logging and cooldowns.
71
+
72
+ Attributes:
73
+ path: Filesystem path to the SQLite database.
74
+ """
75
+
76
+ path: str = "~/.local/share/llm-pycascade/db.sqlite"
77
+
78
+
79
+ class FailureConfig(BaseModel):
80
+ """Configuration for persisting failed conversations.
81
+
82
+ Attributes:
83
+ dir: Directory where failed-prompt JSON files are saved.
84
+ """
85
+
86
+ dir: str = "~/.local/share/llm-pycascade/failed_prompts"
87
+
88
+
89
+ class AppConfig(BaseModel):
90
+ """Top-level application configuration loaded from TOML.
91
+
92
+ Attributes:
93
+ providers: Mapping of provider names to their configurations.
94
+ cascades: Mapping of cascade names to ordered entry lists.
95
+ database: Database configuration.
96
+ failure_persistence: Failed-prompt persistence configuration.
97
+ """
98
+
99
+ providers: dict[str, ProviderConfig] = Field(default_factory=dict)
100
+ cascades: dict[str, CascadeConfig] = Field(default_factory=dict)
101
+ database: DatabaseConfig = Field(default_factory=DatabaseConfig)
102
+ failure_persistence: FailureConfig = Field(default_factory=FailureConfig)
103
+
104
+
105
+ def expand_tilde(path: str) -> str:
106
+ """Expand a leading ``~`` to the user's home directory.
107
+
108
+ Args:
109
+ path: A filesystem path that may start with ``~``.
110
+
111
+ Returns:
112
+ The expanded absolute path.
113
+ """
114
+ return os.path.expanduser(path)
115
+
116
+
117
+ def default_config_path() -> Path:
118
+ """Return the default configuration file path.
119
+
120
+ Searches (in order):
121
+
122
+ 1. ``LLM_PYCASCADE_CONFIG`` environment variable
123
+ 2. ``~/.config/llm-pycascade/config.toml``
124
+ 3. ``~/.llm-pycascade.toml`` (legacy)
125
+
126
+ Returns:
127
+ Path to the first config file found, or the preferred default.
128
+ """
129
+ env_path = os.environ.get("LLM_PYCASCADE_CONFIG")
130
+ if env_path:
131
+ return Path(env_path)
132
+
133
+ xdg = Path.home() / ".config" / "llm-pycascade" / "config.toml"
134
+ if xdg.exists():
135
+ return xdg
136
+
137
+ legacy = Path.home() / ".llm-pycascade.toml"
138
+ if legacy.exists():
139
+ return legacy
140
+
141
+ return xdg
142
+
143
+
144
+ def load_config(path: str | Path | None = None) -> AppConfig:
145
+ """Load and parse the application configuration from a TOML file.
146
+
147
+ Args:
148
+ path: Explicit path to the configuration file. If ``None``, uses
149
+ :func:`default_config_path`.
150
+
151
+ Returns:
152
+ A fully-resolved :class:`AppConfig` instance.
153
+
154
+ Raises:
155
+ FileNotFoundError: If the config file does not exist.
156
+ tomllib.TOMLDecodeError: If the file contains invalid TOML.
157
+ """
158
+ path = default_config_path() if path is None else Path(path)
159
+
160
+ if not path.exists():
161
+ raise FileNotFoundError(f"Configuration file not found: {path}")
162
+
163
+ with open(path, "rb") as f:
164
+ raw: dict[str, Any] = tomllib.load(f)
165
+
166
+ # Parse the [providers] section
167
+ providers: dict[str, ProviderConfig] = {}
168
+ for name, pcfg in raw.get("providers", {}).items():
169
+ providers[name] = ProviderConfig(**pcfg)
170
+
171
+ # Parse the [cascades] section
172
+ cascades: dict[str, CascadeConfig] = {}
173
+ for cascade_name, ccfg in raw.get("cascades", {}).items():
174
+ entries_raw = ccfg.get("entries", [])
175
+ entries = [CascadeEntry(**e) for e in entries_raw]
176
+ cascades[cascade_name] = CascadeConfig(entries=entries)
177
+
178
+ # Parse optional [database] and [failure_persistence] sections
179
+ database = (
180
+ DatabaseConfig(**raw["database"]) if "database" in raw else DatabaseConfig()
181
+ )
182
+ failure = (
183
+ FailureConfig(**raw["failure_persistence"])
184
+ if "failure_persistence" in raw
185
+ else FailureConfig()
186
+ )
187
+
188
+ return AppConfig(
189
+ providers=providers,
190
+ cascades=cascades,
191
+ database=database,
192
+ failure_persistence=failure,
193
+ )
llm_pycascade/db.py ADDED
@@ -0,0 +1,123 @@
1
+ """Async SQLite database for attempt logging and cooldown tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import aiosqlite
8
+
9
+ _CREATE_ATTEMPT_LOG = """
10
+ CREATE TABLE IF NOT EXISTS attempt_log (
11
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
12
+ timestamp TEXT NOT NULL DEFAULT (datetime('now')),
13
+ cascade_name TEXT NOT NULL,
14
+ provider_model TEXT NOT NULL,
15
+ http_status INTEGER,
16
+ latency_ms INTEGER,
17
+ input_tokens INTEGER,
18
+ output_tokens INTEGER
19
+ );
20
+ """
21
+
22
+ _CREATE_COOLDOWN = """
23
+ CREATE TABLE IF NOT EXISTS cooldown (
24
+ provider_model TEXT PRIMARY KEY,
25
+ cooldown_until REAL NOT NULL
26
+ );
27
+ """
28
+
29
+
30
+ async def init_db(path: str) -> aiosqlite.Connection:
31
+ """Open (or create) the SQLite database and ensure tables exist.
32
+
33
+ Args:
34
+ path: Filesystem path to the SQLite database file.
35
+
36
+ Returns:
37
+ An open :class:`aiosqlite.Connection`.
38
+ """
39
+ conn = await aiosqlite.connect(path)
40
+ await conn.execute(_CREATE_ATTEMPT_LOG)
41
+ await conn.execute(_CREATE_COOLDOWN)
42
+ await conn.commit()
43
+ return conn
44
+
45
+
46
+ async def log_attempt(
47
+ conn: aiosqlite.Connection,
48
+ cascade_name: str,
49
+ provider_model: str,
50
+ http_status: int | None,
51
+ latency_ms: int,
52
+ input_tokens: int,
53
+ output_tokens: int,
54
+ ) -> None:
55
+ """Insert a row into the attempt log.
56
+
57
+ Args:
58
+ conn: Open database connection.
59
+ cascade_name: Name of the cascade that was running.
60
+ provider_model: ``provider/model`` identifier string.
61
+ http_status: HTTP status code (``None`` if not HTTP-related).
62
+ latency_ms: Round-trip latency in milliseconds.
63
+ input_tokens: Input tokens reported by the provider.
64
+ output_tokens: Output tokens reported by the provider.
65
+ """
66
+ await conn.execute(
67
+ "INSERT INTO attempt_log "
68
+ "(cascade_name, provider_model, http_status, latency_ms, "
69
+ "input_tokens, output_tokens) "
70
+ "VALUES (?, ?, ?, ?, ?, ?)",
71
+ (
72
+ cascade_name,
73
+ provider_model,
74
+ http_status,
75
+ latency_ms,
76
+ input_tokens,
77
+ output_tokens,
78
+ ),
79
+ )
80
+ await conn.commit()
81
+
82
+
83
+ async def is_on_cooldown(conn: aiosqlite.Connection, provider_model: str) -> bool:
84
+ """Check whether a provider/model combination is currently in cooldown.
85
+
86
+ Args:
87
+ conn: Open database connection.
88
+ provider_model: ``provider/model`` identifier string.
89
+
90
+ Returns:
91
+ ``True`` if the entry has an active cooldown.
92
+ """
93
+ cursor = await conn.execute(
94
+ "SELECT cooldown_until FROM cooldown WHERE provider_model = ?",
95
+ (provider_model,),
96
+ )
97
+ row = await cursor.fetchone()
98
+ if row is None:
99
+ return False
100
+ cooldown_until = row[0]
101
+ return time.time() < cooldown_until
102
+
103
+
104
+ async def set_cooldown(
105
+ conn: aiosqlite.Connection,
106
+ provider_model: str,
107
+ cooldown_until: float,
108
+ ) -> None:
109
+ """Set (or update) the cooldown for a provider/model combination.
110
+
111
+ Uses ``INSERT OR REPLACE`` so repeated cooldowns simply update the expiry.
112
+
113
+ Args:
114
+ conn: Open database connection.
115
+ provider_model: ``provider/model`` identifier string.
116
+ cooldown_until: Unix timestamp (seconds since epoch) when the cooldown expires.
117
+ """
118
+ await conn.execute(
119
+ "INSERT OR REPLACE INTO cooldown "
120
+ "(provider_model, cooldown_until) VALUES (?, ?)",
121
+ (provider_model, cooldown_until),
122
+ )
123
+ await conn.commit()