ftouter 0.1.0__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.
- ftouter-0.1.0/PKG-INFO +151 -0
- ftouter-0.1.0/README.md +140 -0
- ftouter-0.1.0/ftouter/__init__.py +13 -0
- ftouter-0.1.0/ftouter/chat_models.py +94 -0
- ftouter-0.1.0/ftouter/general_models.py +84 -0
- ftouter-0.1.0/ftouter/providers.py +7 -0
- ftouter-0.1.0/ftouter/reasoning_models.py +88 -0
- ftouter-0.1.0/ftouter/router.py +39 -0
- ftouter-0.1.0/ftouter/tool_calling_models.py +90 -0
- ftouter-0.1.0/ftouter/vision_models.py +92 -0
- ftouter-0.1.0/ftouter.egg-info/PKG-INFO +151 -0
- ftouter-0.1.0/ftouter.egg-info/SOURCES.txt +15 -0
- ftouter-0.1.0/ftouter.egg-info/dependency_links.txt +1 -0
- ftouter-0.1.0/ftouter.egg-info/requires.txt +2 -0
- ftouter-0.1.0/ftouter.egg-info/top_level.txt +1 -0
- ftouter-0.1.0/pyproject.toml +19 -0
- ftouter-0.1.0/setup.cfg +4 -0
ftouter-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ftouter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A resilient runtime layer for free/cheap-tier LLM APIs
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests
|
|
10
|
+
Requires-Dist: python-dotenv
|
|
11
|
+
|
|
12
|
+
# ftouter
|
|
13
|
+
|
|
14
|
+
A resilient runtime layer for free and cheap-tier LLM APIs.
|
|
15
|
+
|
|
16
|
+
Free-tier LLM providers are unreliable in ways that break production apps silently: models get deprecated overnight, quotas zero out without warning, and providers add card requirements with no notice. `ftouter` sits between your app and multiple providers, automatically falling back to the next available model when one fails — so a single dead endpoint doesn't take down your whole app.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- **Automatic fallback** across multiple providers and models
|
|
21
|
+
- **Cooldown handling** — a model that fails gets temporarily skipped instead of retried every call
|
|
22
|
+
- **Clear failure reasons** — distinguishes rate limits, deprecated models, bad keys, and server errors instead of generic exceptions
|
|
23
|
+
- **Zero config to start** — just add your API keys and call `.complete()`
|
|
24
|
+
- Five ready-made routers for different use cases: `chat_models`, `general_models`, `reasoning_models`, `tool_call_models`, `vision_models`
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install ftouter
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
1. Create a `.env` file in your project root with the API keys for the providers you want to use:
|
|
35
|
+
|
|
36
|
+
```env
|
|
37
|
+
GROQ_API_KEY=your_key_here
|
|
38
|
+
OPENROUTER_API_KEY=your_key_here
|
|
39
|
+
MISTRAL_API_KEY=your_key_here
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
You don't need all three — `ftouter` skips any provider whose key isn't set and falls through to the next one.
|
|
43
|
+
|
|
44
|
+
2. Use it in your code:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from ftouter import chat_models
|
|
48
|
+
from dotenv import load_dotenv
|
|
49
|
+
|
|
50
|
+
load_dotenv()
|
|
51
|
+
|
|
52
|
+
result = chat_models.complete([
|
|
53
|
+
{"role": "user", "content": "Say hi in 5 words"}
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
print(result["choices"][0]["message"]["content"])
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Available routers
|
|
60
|
+
|
|
61
|
+
| Router | Use case |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `chat_models.complete()` | General-purpose conversational completions |
|
|
64
|
+
| `general_models.complete()` | General-purpose tasks, pooled across all other routers |
|
|
65
|
+
| `reasoning_models.complete()` | Tasks that benefit from reasoning-focused models |
|
|
66
|
+
| `tool_call_models.complete()` | Completions that use function/tool calling |
|
|
67
|
+
| `vision_models.complete()` | Completions that include image input |
|
|
68
|
+
|
|
69
|
+
Each router tries a prioritized list of models across providers and automatically moves to the next one on failure.
|
|
70
|
+
|
|
71
|
+
## Vision
|
|
72
|
+
|
|
73
|
+
`vision_models.complete()` takes the same message format as the others — an image is just another block inside `content`, either an `https://` URL or a base64 `data:` URI:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import base64
|
|
77
|
+
from ftouter import vision_models
|
|
78
|
+
from dotenv import load_dotenv
|
|
79
|
+
|
|
80
|
+
load_dotenv()
|
|
81
|
+
|
|
82
|
+
with open("example.jpg", "rb") as image_file:
|
|
83
|
+
image_data = base64.b64encode(image_file.read()).decode("utf-8")
|
|
84
|
+
|
|
85
|
+
result = vision_models.complete([
|
|
86
|
+
{
|
|
87
|
+
"role": "user",
|
|
88
|
+
"content": [
|
|
89
|
+
{"type": "text", "text": "what's in this image?"},
|
|
90
|
+
{
|
|
91
|
+
"type": "image_url",
|
|
92
|
+
"image_url": {
|
|
93
|
+
"url": f"data:image/jpeg;base64,{image_data}"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
])
|
|
99
|
+
print(result["choices"][0]["message"]["content"])
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Vision draws from Groq, OpenRouter, and Mistral like the other routers, plus two vision-specific providers — add their keys to `.env` only if you want them in the fallback chain, `ftouter` skips them otherwise like any other missing key:
|
|
103
|
+
|
|
104
|
+
```env
|
|
105
|
+
GEMINI_API_KEY=your_key_here
|
|
106
|
+
MOONDREAM_API_KEY=your_key_here
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Note: base64 image support is confirmed for Groq and Gemini. OpenRouter should accept it (same spec, not independently verified here). Moondream's exact behavior with base64 input is unconfirmed — test it directly if you're relying on that provider.
|
|
110
|
+
|
|
111
|
+
## How fallback works
|
|
112
|
+
|
|
113
|
+
When you call `.complete()`, `ftouter`:
|
|
114
|
+
|
|
115
|
+
1. Tries the first available (not-on-cooldown) model in its list
|
|
116
|
+
2. If it fails, records *why* (rate limit, deprecated model, bad key, server error, etc.) and puts that model on a cooldown timer
|
|
117
|
+
3. Moves to the next model and repeats
|
|
118
|
+
4. Returns the first successful response
|
|
119
|
+
5. Raises `RuntimeError` only if every model in the list is exhausted or on cooldown
|
|
120
|
+
|
|
121
|
+
This means a single provider having a bad day doesn't crash your app — it just quietly routes around it.
|
|
122
|
+
|
|
123
|
+
## Getting free API keys
|
|
124
|
+
|
|
125
|
+
- **Groq** — [console.groq.com](https://console.groq.com)
|
|
126
|
+
- **OpenRouter** — [openrouter.ai](https://openrouter.ai)
|
|
127
|
+
- **Mistral** — [console.mistral.ai](https://console.mistral.ai)
|
|
128
|
+
- **Gemini** (vision only) — [aistudio.google.com](https://aistudio.google.com)
|
|
129
|
+
- **Moondream** (vision only) — [moondream.ai](https://moondream.ai)
|
|
130
|
+
|
|
131
|
+
## Handling errors
|
|
132
|
+
|
|
133
|
+
If every provider fails, `.complete()` raises a `RuntimeError`:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
try:
|
|
137
|
+
result = chat_models.complete([{"role": "user", "content": "Hello"}])
|
|
138
|
+
print(result["choices"][0]["message"]["content"])
|
|
139
|
+
except RuntimeError as e:
|
|
140
|
+
print(f"All providers failed: {e}")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Contributing
|
|
144
|
+
|
|
145
|
+
Issues and pull requests are welcome. If you'd like to add support for another provider, open an issue first so the model list and provider config stay consistent.
|
|
146
|
+
|
|
147
|
+
https://github.com/Irfan-gitt
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT
|
ftouter-0.1.0/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# ftouter
|
|
2
|
+
|
|
3
|
+
A resilient runtime layer for free and cheap-tier LLM APIs.
|
|
4
|
+
|
|
5
|
+
Free-tier LLM providers are unreliable in ways that break production apps silently: models get deprecated overnight, quotas zero out without warning, and providers add card requirements with no notice. `ftouter` sits between your app and multiple providers, automatically falling back to the next available model when one fails — so a single dead endpoint doesn't take down your whole app.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Automatic fallback** across multiple providers and models
|
|
10
|
+
- **Cooldown handling** — a model that fails gets temporarily skipped instead of retried every call
|
|
11
|
+
- **Clear failure reasons** — distinguishes rate limits, deprecated models, bad keys, and server errors instead of generic exceptions
|
|
12
|
+
- **Zero config to start** — just add your API keys and call `.complete()`
|
|
13
|
+
- Five ready-made routers for different use cases: `chat_models`, `general_models`, `reasoning_models`, `tool_call_models`, `vision_models`
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install ftouter
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
1. Create a `.env` file in your project root with the API keys for the providers you want to use:
|
|
24
|
+
|
|
25
|
+
```env
|
|
26
|
+
GROQ_API_KEY=your_key_here
|
|
27
|
+
OPENROUTER_API_KEY=your_key_here
|
|
28
|
+
MISTRAL_API_KEY=your_key_here
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
You don't need all three — `ftouter` skips any provider whose key isn't set and falls through to the next one.
|
|
32
|
+
|
|
33
|
+
2. Use it in your code:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from ftouter import chat_models
|
|
37
|
+
from dotenv import load_dotenv
|
|
38
|
+
|
|
39
|
+
load_dotenv()
|
|
40
|
+
|
|
41
|
+
result = chat_models.complete([
|
|
42
|
+
{"role": "user", "content": "Say hi in 5 words"}
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
print(result["choices"][0]["message"]["content"])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Available routers
|
|
49
|
+
|
|
50
|
+
| Router | Use case |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `chat_models.complete()` | General-purpose conversational completions |
|
|
53
|
+
| `general_models.complete()` | General-purpose tasks, pooled across all other routers |
|
|
54
|
+
| `reasoning_models.complete()` | Tasks that benefit from reasoning-focused models |
|
|
55
|
+
| `tool_call_models.complete()` | Completions that use function/tool calling |
|
|
56
|
+
| `vision_models.complete()` | Completions that include image input |
|
|
57
|
+
|
|
58
|
+
Each router tries a prioritized list of models across providers and automatically moves to the next one on failure.
|
|
59
|
+
|
|
60
|
+
## Vision
|
|
61
|
+
|
|
62
|
+
`vision_models.complete()` takes the same message format as the others — an image is just another block inside `content`, either an `https://` URL or a base64 `data:` URI:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import base64
|
|
66
|
+
from ftouter import vision_models
|
|
67
|
+
from dotenv import load_dotenv
|
|
68
|
+
|
|
69
|
+
load_dotenv()
|
|
70
|
+
|
|
71
|
+
with open("example.jpg", "rb") as image_file:
|
|
72
|
+
image_data = base64.b64encode(image_file.read()).decode("utf-8")
|
|
73
|
+
|
|
74
|
+
result = vision_models.complete([
|
|
75
|
+
{
|
|
76
|
+
"role": "user",
|
|
77
|
+
"content": [
|
|
78
|
+
{"type": "text", "text": "what's in this image?"},
|
|
79
|
+
{
|
|
80
|
+
"type": "image_url",
|
|
81
|
+
"image_url": {
|
|
82
|
+
"url": f"data:image/jpeg;base64,{image_data}"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
])
|
|
88
|
+
print(result["choices"][0]["message"]["content"])
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Vision draws from Groq, OpenRouter, and Mistral like the other routers, plus two vision-specific providers — add their keys to `.env` only if you want them in the fallback chain, `ftouter` skips them otherwise like any other missing key:
|
|
92
|
+
|
|
93
|
+
```env
|
|
94
|
+
GEMINI_API_KEY=your_key_here
|
|
95
|
+
MOONDREAM_API_KEY=your_key_here
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Note: base64 image support is confirmed for Groq and Gemini. OpenRouter should accept it (same spec, not independently verified here). Moondream's exact behavior with base64 input is unconfirmed — test it directly if you're relying on that provider.
|
|
99
|
+
|
|
100
|
+
## How fallback works
|
|
101
|
+
|
|
102
|
+
When you call `.complete()`, `ftouter`:
|
|
103
|
+
|
|
104
|
+
1. Tries the first available (not-on-cooldown) model in its list
|
|
105
|
+
2. If it fails, records *why* (rate limit, deprecated model, bad key, server error, etc.) and puts that model on a cooldown timer
|
|
106
|
+
3. Moves to the next model and repeats
|
|
107
|
+
4. Returns the first successful response
|
|
108
|
+
5. Raises `RuntimeError` only if every model in the list is exhausted or on cooldown
|
|
109
|
+
|
|
110
|
+
This means a single provider having a bad day doesn't crash your app — it just quietly routes around it.
|
|
111
|
+
|
|
112
|
+
## Getting free API keys
|
|
113
|
+
|
|
114
|
+
- **Groq** — [console.groq.com](https://console.groq.com)
|
|
115
|
+
- **OpenRouter** — [openrouter.ai](https://openrouter.ai)
|
|
116
|
+
- **Mistral** — [console.mistral.ai](https://console.mistral.ai)
|
|
117
|
+
- **Gemini** (vision only) — [aistudio.google.com](https://aistudio.google.com)
|
|
118
|
+
- **Moondream** (vision only) — [moondream.ai](https://moondream.ai)
|
|
119
|
+
|
|
120
|
+
## Handling errors
|
|
121
|
+
|
|
122
|
+
If every provider fails, `.complete()` raises a `RuntimeError`:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
try:
|
|
126
|
+
result = chat_models.complete([{"role": "user", "content": "Hello"}])
|
|
127
|
+
print(result["choices"][0]["message"]["content"])
|
|
128
|
+
except RuntimeError as e:
|
|
129
|
+
print(f"All providers failed: {e}")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Contributing
|
|
133
|
+
|
|
134
|
+
Issues and pull requests are welcome. If you'd like to add support for another provider, open an issue first so the model list and provider config stay consistent.
|
|
135
|
+
|
|
136
|
+
https://github.com/Irfan-gitt
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .providers import PROVIDER_INFO
|
|
2
|
+
from . import chat_models, reasoning_models, tool_calling_models, general_models, vision_models
|
|
3
|
+
from .router import check_available_models
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"PROVIDER_INFO",
|
|
7
|
+
"chat_models",
|
|
8
|
+
"reasoning_models",
|
|
9
|
+
"tool_calling_models",
|
|
10
|
+
"general_models",
|
|
11
|
+
"vision_models",
|
|
12
|
+
"check_available_models",
|
|
13
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from .providers import PROVIDER_INFO
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
MODELS = [
|
|
9
|
+
("groq", "qwen/qwen3.8-27b"),
|
|
10
|
+
("groq", "openai/gpt-oss-120b"),
|
|
11
|
+
("groq", "openai/gpt-oss-20b"),
|
|
12
|
+
("groq", "allam-2-7b"),
|
|
13
|
+
("openrouter", "google/gemma-4-26b-a4b-it:free"),
|
|
14
|
+
("openrouter", "google/gemma-4-31b-it:free"),
|
|
15
|
+
("openrouter", "nvidia/nemotron-3-super-120b-a12b:free"),
|
|
16
|
+
("openrouter", "nvidia/nemotron-3.5-lightning:free"),
|
|
17
|
+
("openrouter", "liquid/lfm-2.5-2.6b:free"),
|
|
18
|
+
("mistral", "magistral-medium-latest"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
COOLDOWN_SECONDS = 30 * 60
|
|
22
|
+
_cooldown = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _reason(status_code, message=""):
|
|
26
|
+
text = (message or "").lower()
|
|
27
|
+
if any(k in text for k in ("decommission", "deprecated", "no longer supported", "retired")):
|
|
28
|
+
return f"model deprecated by provider — {message}"
|
|
29
|
+
if status_code in (401, 403):
|
|
30
|
+
return "wrong or invalid API key"
|
|
31
|
+
if status_code == 404:
|
|
32
|
+
return "model not found — likely deprecated or renamed"
|
|
33
|
+
if status_code == 429:
|
|
34
|
+
return "rate limited — free quota exhausted"
|
|
35
|
+
if status_code == 400:
|
|
36
|
+
return f"bad request — {message}" if message else "bad request — check parameters or unsupported feature"
|
|
37
|
+
if status_code and status_code >= 500:
|
|
38
|
+
return "provider server error — temporary, try again later"
|
|
39
|
+
if message:
|
|
40
|
+
return message
|
|
41
|
+
return f"request failed (status {status_code})"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def complete(messages, **kwargs):
|
|
45
|
+
now = time.time()
|
|
46
|
+
for provider, model_id in MODELS:
|
|
47
|
+
if _cooldown.get((provider, model_id), 0) > now:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
info = PROVIDER_INFO[provider]
|
|
51
|
+
key = os.getenv(info["env_key"])
|
|
52
|
+
if not key:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
resp = requests.post(
|
|
57
|
+
f"{info['base_url']}/chat/completions",
|
|
58
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
59
|
+
json={"model": model_id, "messages": messages, **kwargs},
|
|
60
|
+
timeout=30,
|
|
61
|
+
)
|
|
62
|
+
except requests.exceptions.Timeout:
|
|
63
|
+
print(f"{provider}/{model_id} failed: request timed out")
|
|
64
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
65
|
+
continue
|
|
66
|
+
except requests.exceptions.ConnectionError:
|
|
67
|
+
print(
|
|
68
|
+
f"{provider}/{model_id} failed: could not reach provider (connection error)")
|
|
69
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
70
|
+
continue
|
|
71
|
+
except requests.exceptions.RequestException as e:
|
|
72
|
+
print(f"{provider}/{model_id} failed: request error — {e}")
|
|
73
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
data = resp.json()
|
|
78
|
+
except ValueError:
|
|
79
|
+
print(
|
|
80
|
+
f"{provider}/{model_id} failed: response wasn't valid JSON (status {resp.status_code})")
|
|
81
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if resp.status_code == 200 and "choices" in data:
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
err = data.get("error")
|
|
88
|
+
message = err.get("message") if isinstance(err, dict) else err
|
|
89
|
+
reason = _reason(resp.status_code, message)
|
|
90
|
+
print(f"{provider}/{model_id} failed: {reason}")
|
|
91
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
92
|
+
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
"all chat models are currently unavailable or on cooldown")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from .providers import PROVIDER_INFO
|
|
6
|
+
from . import chat_models, reasoning_models, tool_calling_models
|
|
7
|
+
|
|
8
|
+
# every model across all three categories, deduped, order preserved
|
|
9
|
+
MODELS = list(dict.fromkeys(chat_models.MODELS +
|
|
10
|
+
reasoning_models.MODELS + tool_calling_models.MODELS))
|
|
11
|
+
|
|
12
|
+
COOLDOWN_SECONDS = 30 * 60
|
|
13
|
+
_cooldown = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _reason(status_code, message=""):
|
|
17
|
+
text = (message or "").lower()
|
|
18
|
+
if any(k in text for k in ("decommission", "deprecated", "no longer supported", "retired")):
|
|
19
|
+
return f"model deprecated by provider — {message}"
|
|
20
|
+
if status_code in (401, 403):
|
|
21
|
+
return "wrong or invalid API key"
|
|
22
|
+
if status_code == 404:
|
|
23
|
+
return "model not found — likely deprecated or renamed"
|
|
24
|
+
if status_code == 429:
|
|
25
|
+
return "rate limited — free quota exhausted"
|
|
26
|
+
if status_code == 400:
|
|
27
|
+
return f"bad request — {message}" if message else "bad request — check parameters or unsupported feature"
|
|
28
|
+
if status_code and status_code >= 500:
|
|
29
|
+
return "provider server error — temporary, try again later"
|
|
30
|
+
if message:
|
|
31
|
+
return message
|
|
32
|
+
return f"request failed (status {status_code})"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def complete(messages, **kwargs):
|
|
36
|
+
now = time.time()
|
|
37
|
+
for provider, model_id in MODELS:
|
|
38
|
+
if _cooldown.get((provider, model_id), 0) > now:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
info = PROVIDER_INFO[provider]
|
|
42
|
+
key = os.getenv(info["env_key"])
|
|
43
|
+
if not key:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
resp = requests.post(
|
|
48
|
+
f"{info['base_url']}/chat/completions",
|
|
49
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
50
|
+
json={"model": model_id, "messages": messages, **kwargs},
|
|
51
|
+
timeout=30,
|
|
52
|
+
)
|
|
53
|
+
except requests.exceptions.Timeout:
|
|
54
|
+
print(f"{provider}/{model_id} failed: request timed out")
|
|
55
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
56
|
+
continue
|
|
57
|
+
except requests.exceptions.ConnectionError:
|
|
58
|
+
print(
|
|
59
|
+
f"{provider}/{model_id} failed: could not reach provider (connection error)")
|
|
60
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
61
|
+
continue
|
|
62
|
+
except requests.exceptions.RequestException as e:
|
|
63
|
+
print(f"{provider}/{model_id} failed: request error — {e}")
|
|
64
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
data = resp.json()
|
|
69
|
+
except ValueError:
|
|
70
|
+
print(
|
|
71
|
+
f"{provider}/{model_id} failed: response wasn't valid JSON (status {resp.status_code})")
|
|
72
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if resp.status_code == 200 and "choices" in data:
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
err = data.get("error")
|
|
79
|
+
message = err.get("message") if isinstance(err, dict) else err
|
|
80
|
+
reason = _reason(resp.status_code, message)
|
|
81
|
+
print(f"{provider}/{model_id} failed: {reason}")
|
|
82
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
83
|
+
|
|
84
|
+
raise RuntimeError("all models are currently unavailable or on cooldown")
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
PROVIDER_INFO = {
|
|
2
|
+
"groq": {"base_url": "https://api.groq.com/openai/v1", "env_key": "GROQ_API_KEY"},
|
|
3
|
+
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "env_key": "OPENROUTER_API_KEY"},
|
|
4
|
+
"mistral": {"base_url": "https://api.mistral.ai/v1", "env_key": "MISTRAL_API_KEY"},
|
|
5
|
+
"gemini": {"base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "env_key": "GEMINI_API_KEY"},
|
|
6
|
+
"moondream": {"base_url": "https://api.moondream.ai/v1", "env_key": "MOONDREAM_API_KEY"},
|
|
7
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from .providers import PROVIDER_INFO
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
MODELS = [
|
|
9
|
+
("groq", "qwen/qwen3.8-27b"),
|
|
10
|
+
("openrouter", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free"),
|
|
11
|
+
("openrouter", "nvidia/nemotron-3-ultra-550b-a55b:free"),
|
|
12
|
+
("mistral", "magistral-medium-latest"),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
COOLDOWN_SECONDS = 30 * 60
|
|
16
|
+
_cooldown = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _reason(status_code, message=""):
|
|
20
|
+
text = (message or "").lower()
|
|
21
|
+
if any(k in text for k in ("decommission", "deprecated", "no longer supported", "retired")):
|
|
22
|
+
return f"model deprecated by provider — {message}"
|
|
23
|
+
if status_code in (401, 403):
|
|
24
|
+
return "wrong or invalid API key"
|
|
25
|
+
if status_code == 404:
|
|
26
|
+
return "model not found — likely deprecated or renamed"
|
|
27
|
+
if status_code == 429:
|
|
28
|
+
return "rate limited — free quota exhausted"
|
|
29
|
+
if status_code == 400:
|
|
30
|
+
return f"bad request — {message}" if message else "bad request — check parameters or unsupported feature"
|
|
31
|
+
if status_code and status_code >= 500:
|
|
32
|
+
return "provider server error — temporary, try again later"
|
|
33
|
+
if message:
|
|
34
|
+
return message
|
|
35
|
+
return f"request failed (status {status_code})"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def complete(messages, **kwargs):
|
|
39
|
+
now = time.time()
|
|
40
|
+
for provider, model_id in MODELS:
|
|
41
|
+
if _cooldown.get((provider, model_id), 0) > now:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
info = PROVIDER_INFO[provider]
|
|
45
|
+
key = os.getenv(info["env_key"])
|
|
46
|
+
if not key:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
resp = requests.post(
|
|
51
|
+
f"{info['base_url']}/chat/completions",
|
|
52
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
53
|
+
json={"model": model_id, "messages": messages, **kwargs},
|
|
54
|
+
timeout=30,
|
|
55
|
+
)
|
|
56
|
+
except requests.exceptions.Timeout:
|
|
57
|
+
print(f"{provider}/{model_id} failed: request timed out")
|
|
58
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
59
|
+
continue
|
|
60
|
+
except requests.exceptions.ConnectionError:
|
|
61
|
+
print(
|
|
62
|
+
f"{provider}/{model_id} failed: could not reach provider (connection error)")
|
|
63
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
64
|
+
continue
|
|
65
|
+
except requests.exceptions.RequestException as e:
|
|
66
|
+
print(f"{provider}/{model_id} failed: request error — {e}")
|
|
67
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
data = resp.json()
|
|
72
|
+
except ValueError:
|
|
73
|
+
print(
|
|
74
|
+
f"{provider}/{model_id} failed: response wasn't valid JSON (status {resp.status_code})")
|
|
75
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if resp.status_code == 200 and "choices" in data:
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
err = data.get("error")
|
|
82
|
+
message = err.get("message") if isinstance(err, dict) else err
|
|
83
|
+
reason = _reason(resp.status_code, message)
|
|
84
|
+
print(f"{provider}/{model_id} failed: {reason}")
|
|
85
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
86
|
+
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
"all reasoning models are currently unavailable or on cooldown")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import requests
|
|
3
|
+
|
|
4
|
+
from .providers import PROVIDER_INFO
|
|
5
|
+
from . import chat_models, reasoning_models, tool_calling_models, vision_models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _configured_by_provider():
|
|
9
|
+
configured = {}
|
|
10
|
+
for module in (chat_models, reasoning_models, tool_calling_models, vision_models):
|
|
11
|
+
for provider, model_id in module.MODELS:
|
|
12
|
+
configured.setdefault(provider, set()).add(model_id)
|
|
13
|
+
return configured
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_available_models():
|
|
17
|
+
configured = _configured_by_provider()
|
|
18
|
+
results = {}
|
|
19
|
+
for provider, model_ids in configured.items():
|
|
20
|
+
info = PROVIDER_INFO[provider]
|
|
21
|
+
key = os.getenv(info["env_key"])
|
|
22
|
+
if not key:
|
|
23
|
+
results[provider] = {"status": "no_key"}
|
|
24
|
+
continue
|
|
25
|
+
try:
|
|
26
|
+
resp = requests.get(
|
|
27
|
+
f"{info['base_url']}/models",
|
|
28
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
29
|
+
timeout=10,
|
|
30
|
+
)
|
|
31
|
+
resp.raise_for_status()
|
|
32
|
+
live_ids = {m["id"] for m in resp.json()["data"]}
|
|
33
|
+
results[provider] = {
|
|
34
|
+
"status": "ok",
|
|
35
|
+
"missing": sorted(model_ids - live_ids),
|
|
36
|
+
}
|
|
37
|
+
except Exception as e:
|
|
38
|
+
results[provider] = {"status": "error", "error": str(e)}
|
|
39
|
+
return results
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from .providers import PROVIDER_INFO
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Only confirmed tool-calling models go here. cohere/north-mini-code and the
|
|
9
|
+
# poolside/laguna models are plausible but unverified — add them once tested.
|
|
10
|
+
MODELS = [
|
|
11
|
+
("groq", "qwen/qwen3.8-27b"),
|
|
12
|
+
("groq", "openai/gpt-oss-120b"),
|
|
13
|
+
("groq", "openai/gpt-oss-20b"),
|
|
14
|
+
("openrouter", "openrouter/free"),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
COOLDOWN_SECONDS = 30 * 60
|
|
18
|
+
_cooldown = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _reason(status_code, message=""):
|
|
22
|
+
text = (message or "").lower()
|
|
23
|
+
if any(k in text for k in ("decommission", "deprecated", "no longer supported", "retired")):
|
|
24
|
+
return f"model deprecated by provider — {message}"
|
|
25
|
+
if status_code in (401, 403):
|
|
26
|
+
return "wrong or invalid API key"
|
|
27
|
+
if status_code == 404:
|
|
28
|
+
return "model not found — likely deprecated or renamed"
|
|
29
|
+
if status_code == 429:
|
|
30
|
+
return "rate limited — free quota exhausted"
|
|
31
|
+
if status_code == 400:
|
|
32
|
+
return f"bad request — {message}" if message else "bad request — check parameters or unsupported feature"
|
|
33
|
+
if status_code and status_code >= 500:
|
|
34
|
+
return "provider server error — temporary, try again later"
|
|
35
|
+
if message:
|
|
36
|
+
return message
|
|
37
|
+
return f"request failed (status {status_code})"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def complete(messages, **kwargs):
|
|
41
|
+
now = time.time()
|
|
42
|
+
for provider, model_id in MODELS:
|
|
43
|
+
if _cooldown.get((provider, model_id), 0) > now:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
info = PROVIDER_INFO[provider]
|
|
47
|
+
key = os.getenv(info["env_key"])
|
|
48
|
+
if not key:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
resp = requests.post(
|
|
53
|
+
f"{info['base_url']}/chat/completions",
|
|
54
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
55
|
+
json={"model": model_id, "messages": messages, **kwargs},
|
|
56
|
+
timeout=30,
|
|
57
|
+
)
|
|
58
|
+
except requests.exceptions.Timeout:
|
|
59
|
+
print(f"{provider}/{model_id} failed: request timed out")
|
|
60
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
61
|
+
continue
|
|
62
|
+
except requests.exceptions.ConnectionError:
|
|
63
|
+
print(
|
|
64
|
+
f"{provider}/{model_id} failed: could not reach provider (connection error)")
|
|
65
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
66
|
+
continue
|
|
67
|
+
except requests.exceptions.RequestException as e:
|
|
68
|
+
print(f"{provider}/{model_id} failed: request error — {e}")
|
|
69
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
data = resp.json()
|
|
74
|
+
except ValueError:
|
|
75
|
+
print(
|
|
76
|
+
f"{provider}/{model_id} failed: response wasn't valid JSON (status {resp.status_code})")
|
|
77
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
if resp.status_code == 200 and "choices" in data:
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
err = data.get("error")
|
|
84
|
+
message = err.get("message") if isinstance(err, dict) else err
|
|
85
|
+
reason = _reason(resp.status_code, message)
|
|
86
|
+
print(f"{provider}/{model_id} failed: {reason}")
|
|
87
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
88
|
+
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
"all tool_call models are currently unavailable or on cooldown")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from .providers import PROVIDER_INFO
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Moondream model id below is unconfirmed — verify the exact string in their
|
|
9
|
+
# dashboard/docs before relying on it, everything else here is verified.
|
|
10
|
+
MODELS = [
|
|
11
|
+
("groq", "qwen/qwen3.8-27b"),
|
|
12
|
+
("gemini", "gemini-2.5-flash"),
|
|
13
|
+
("openrouter", "google/gemma-4-26b-a4b-it:free"),
|
|
14
|
+
("openrouter", "google/gemma-4-31b-it:free"),
|
|
15
|
+
("openrouter", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free"),
|
|
16
|
+
("moondream", "moondream-3-preview"),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
COOLDOWN_SECONDS = 30 * 60
|
|
20
|
+
_cooldown = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _reason(status_code, message=""):
|
|
24
|
+
text = (message or "").lower()
|
|
25
|
+
if any(k in text for k in ("decommission", "deprecated", "no longer supported", "retired")):
|
|
26
|
+
return f"model deprecated by provider — {message}"
|
|
27
|
+
if status_code in (401, 403):
|
|
28
|
+
return "wrong or invalid API key"
|
|
29
|
+
if status_code == 404:
|
|
30
|
+
return "model not found — likely deprecated or renamed"
|
|
31
|
+
if status_code == 429:
|
|
32
|
+
return "rate limited — free quota exhausted"
|
|
33
|
+
if status_code == 400:
|
|
34
|
+
return f"bad request — {message}" if message else "bad request — check parameters or unsupported feature"
|
|
35
|
+
if status_code and status_code >= 500:
|
|
36
|
+
return "provider server error — temporary, try again later"
|
|
37
|
+
if message:
|
|
38
|
+
return message
|
|
39
|
+
return f"request failed (status {status_code})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def complete(messages, **kwargs):
|
|
43
|
+
now = time.time()
|
|
44
|
+
for provider, model_id in MODELS:
|
|
45
|
+
if _cooldown.get((provider, model_id), 0) > now:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
info = PROVIDER_INFO[provider]
|
|
49
|
+
key = os.getenv(info["env_key"])
|
|
50
|
+
if not key:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
resp = requests.post(
|
|
55
|
+
f"{info['base_url']}/chat/completions",
|
|
56
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
57
|
+
json={"model": model_id, "messages": messages, **kwargs},
|
|
58
|
+
timeout=30,
|
|
59
|
+
)
|
|
60
|
+
except requests.exceptions.Timeout:
|
|
61
|
+
print(f"{provider}/{model_id} failed: request timed out")
|
|
62
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
63
|
+
continue
|
|
64
|
+
except requests.exceptions.ConnectionError:
|
|
65
|
+
print(
|
|
66
|
+
f"{provider}/{model_id} failed: could not reach provider (connection error)")
|
|
67
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
68
|
+
continue
|
|
69
|
+
except requests.exceptions.RequestException as e:
|
|
70
|
+
print(f"{provider}/{model_id} failed: request error — {e}")
|
|
71
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
data = resp.json()
|
|
76
|
+
except ValueError:
|
|
77
|
+
print(
|
|
78
|
+
f"{provider}/{model_id} failed: response wasn't valid JSON (status {resp.status_code})")
|
|
79
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if resp.status_code == 200 and "choices" in data:
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
err = data.get("error")
|
|
86
|
+
message = err.get("message") if isinstance(err, dict) else err
|
|
87
|
+
reason = _reason(resp.status_code, message)
|
|
88
|
+
print(f"{provider}/{model_id} failed: {reason}")
|
|
89
|
+
_cooldown[(provider, model_id)] = now + COOLDOWN_SECONDS
|
|
90
|
+
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
"all vision models are currently unavailable or on cooldown")
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ftouter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A resilient runtime layer for free/cheap-tier LLM APIs
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests
|
|
10
|
+
Requires-Dist: python-dotenv
|
|
11
|
+
|
|
12
|
+
# ftouter
|
|
13
|
+
|
|
14
|
+
A resilient runtime layer for free and cheap-tier LLM APIs.
|
|
15
|
+
|
|
16
|
+
Free-tier LLM providers are unreliable in ways that break production apps silently: models get deprecated overnight, quotas zero out without warning, and providers add card requirements with no notice. `ftouter` sits between your app and multiple providers, automatically falling back to the next available model when one fails — so a single dead endpoint doesn't take down your whole app.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- **Automatic fallback** across multiple providers and models
|
|
21
|
+
- **Cooldown handling** — a model that fails gets temporarily skipped instead of retried every call
|
|
22
|
+
- **Clear failure reasons** — distinguishes rate limits, deprecated models, bad keys, and server errors instead of generic exceptions
|
|
23
|
+
- **Zero config to start** — just add your API keys and call `.complete()`
|
|
24
|
+
- Five ready-made routers for different use cases: `chat_models`, `general_models`, `reasoning_models`, `tool_call_models`, `vision_models`
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install ftouter
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
1. Create a `.env` file in your project root with the API keys for the providers you want to use:
|
|
35
|
+
|
|
36
|
+
```env
|
|
37
|
+
GROQ_API_KEY=your_key_here
|
|
38
|
+
OPENROUTER_API_KEY=your_key_here
|
|
39
|
+
MISTRAL_API_KEY=your_key_here
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
You don't need all three — `ftouter` skips any provider whose key isn't set and falls through to the next one.
|
|
43
|
+
|
|
44
|
+
2. Use it in your code:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from ftouter import chat_models
|
|
48
|
+
from dotenv import load_dotenv
|
|
49
|
+
|
|
50
|
+
load_dotenv()
|
|
51
|
+
|
|
52
|
+
result = chat_models.complete([
|
|
53
|
+
{"role": "user", "content": "Say hi in 5 words"}
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
print(result["choices"][0]["message"]["content"])
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Available routers
|
|
60
|
+
|
|
61
|
+
| Router | Use case |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `chat_models.complete()` | General-purpose conversational completions |
|
|
64
|
+
| `general_models.complete()` | General-purpose tasks, pooled across all other routers |
|
|
65
|
+
| `reasoning_models.complete()` | Tasks that benefit from reasoning-focused models |
|
|
66
|
+
| `tool_call_models.complete()` | Completions that use function/tool calling |
|
|
67
|
+
| `vision_models.complete()` | Completions that include image input |
|
|
68
|
+
|
|
69
|
+
Each router tries a prioritized list of models across providers and automatically moves to the next one on failure.
|
|
70
|
+
|
|
71
|
+
## Vision
|
|
72
|
+
|
|
73
|
+
`vision_models.complete()` takes the same message format as the others — an image is just another block inside `content`, either an `https://` URL or a base64 `data:` URI:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import base64
|
|
77
|
+
from ftouter import vision_models
|
|
78
|
+
from dotenv import load_dotenv
|
|
79
|
+
|
|
80
|
+
load_dotenv()
|
|
81
|
+
|
|
82
|
+
with open("example.jpg", "rb") as image_file:
|
|
83
|
+
image_data = base64.b64encode(image_file.read()).decode("utf-8")
|
|
84
|
+
|
|
85
|
+
result = vision_models.complete([
|
|
86
|
+
{
|
|
87
|
+
"role": "user",
|
|
88
|
+
"content": [
|
|
89
|
+
{"type": "text", "text": "what's in this image?"},
|
|
90
|
+
{
|
|
91
|
+
"type": "image_url",
|
|
92
|
+
"image_url": {
|
|
93
|
+
"url": f"data:image/jpeg;base64,{image_data}"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
])
|
|
99
|
+
print(result["choices"][0]["message"]["content"])
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Vision draws from Groq, OpenRouter, and Mistral like the other routers, plus two vision-specific providers — add their keys to `.env` only if you want them in the fallback chain, `ftouter` skips them otherwise like any other missing key:
|
|
103
|
+
|
|
104
|
+
```env
|
|
105
|
+
GEMINI_API_KEY=your_key_here
|
|
106
|
+
MOONDREAM_API_KEY=your_key_here
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Note: base64 image support is confirmed for Groq and Gemini. OpenRouter should accept it (same spec, not independently verified here). Moondream's exact behavior with base64 input is unconfirmed — test it directly if you're relying on that provider.
|
|
110
|
+
|
|
111
|
+
## How fallback works
|
|
112
|
+
|
|
113
|
+
When you call `.complete()`, `ftouter`:
|
|
114
|
+
|
|
115
|
+
1. Tries the first available (not-on-cooldown) model in its list
|
|
116
|
+
2. If it fails, records *why* (rate limit, deprecated model, bad key, server error, etc.) and puts that model on a cooldown timer
|
|
117
|
+
3. Moves to the next model and repeats
|
|
118
|
+
4. Returns the first successful response
|
|
119
|
+
5. Raises `RuntimeError` only if every model in the list is exhausted or on cooldown
|
|
120
|
+
|
|
121
|
+
This means a single provider having a bad day doesn't crash your app — it just quietly routes around it.
|
|
122
|
+
|
|
123
|
+
## Getting free API keys
|
|
124
|
+
|
|
125
|
+
- **Groq** — [console.groq.com](https://console.groq.com)
|
|
126
|
+
- **OpenRouter** — [openrouter.ai](https://openrouter.ai)
|
|
127
|
+
- **Mistral** — [console.mistral.ai](https://console.mistral.ai)
|
|
128
|
+
- **Gemini** (vision only) — [aistudio.google.com](https://aistudio.google.com)
|
|
129
|
+
- **Moondream** (vision only) — [moondream.ai](https://moondream.ai)
|
|
130
|
+
|
|
131
|
+
## Handling errors
|
|
132
|
+
|
|
133
|
+
If every provider fails, `.complete()` raises a `RuntimeError`:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
try:
|
|
137
|
+
result = chat_models.complete([{"role": "user", "content": "Hello"}])
|
|
138
|
+
print(result["choices"][0]["message"]["content"])
|
|
139
|
+
except RuntimeError as e:
|
|
140
|
+
print(f"All providers failed: {e}")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Contributing
|
|
144
|
+
|
|
145
|
+
Issues and pull requests are welcome. If you'd like to add support for another provider, open an issue first so the model list and provider config stay consistent.
|
|
146
|
+
|
|
147
|
+
https://github.com/Irfan-gitt
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
ftouter/__init__.py
|
|
4
|
+
ftouter/chat_models.py
|
|
5
|
+
ftouter/general_models.py
|
|
6
|
+
ftouter/providers.py
|
|
7
|
+
ftouter/reasoning_models.py
|
|
8
|
+
ftouter/router.py
|
|
9
|
+
ftouter/tool_calling_models.py
|
|
10
|
+
ftouter/vision_models.py
|
|
11
|
+
ftouter.egg-info/PKG-INFO
|
|
12
|
+
ftouter.egg-info/SOURCES.txt
|
|
13
|
+
ftouter.egg-info/dependency_links.txt
|
|
14
|
+
ftouter.egg-info/requires.txt
|
|
15
|
+
ftouter.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ftouter
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ftouter"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A resilient runtime layer for free/cheap-tier LLM APIs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "Your Name", email = "you@example.com"}]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"requests",
|
|
15
|
+
"python-dotenv",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
include = ["ftouter*"]
|
ftouter-0.1.0/setup.cfg
ADDED