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 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
@@ -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,2 @@
1
+ requests
2
+ python-dotenv
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+