Wilker commited on
Commit
6de4e4d
·
1 Parent(s): 8f1edf5

feat: Fase 5 — Provider padrão + fallback automático por rate limit

Browse files

- Default provider: openrouter (era thc)
- ProviderRateLimitError uniforme em base.py + 4 providers (429 e RESOURCE_EXHAUSTED)
- chat_completion_with_fallback: ordem configurável via ~/.thcrc, dedupe, thc fora da cadeia
- Integração em agent.py (run_agent) e chat.py (single-shot + REPL), com aviso ao usuário na troca
- Fix: NameError em messages_to_send no bloco single-shot (não existia nesse escopo)
- Guard: run_agent com fallback_order + config=None carrega config padrão via load_config()
- 18 testes novos (test_provider_fallback.py + test_providers_rate_limit.py)
- PROJECT_SPEC.md atualizado (Fase 3, 4, 5 concluídas; renumeração das fases seguintes)

116 testes, 1 skip esperado (test_alucinacao_fallback_llama_8b), 0 failures.

PROJECT_SPEC.md CHANGED
@@ -4,7 +4,7 @@ INTEIRO antes de iniciar qualquer tarefa, e revisitar a seção de Guardrails
4
  e Lições Aprendidas durante a execução. Se algo aqui divergir do código
5
  real, pare e reporte — não assuma, não invente.
6
 
7
- Última atualização: 2026-07-23 — pós Fase 2.3 (Plan Mode)
8
 
9
  ---
10
 
@@ -72,11 +72,19 @@ test_robustez_fase1b_2_1.py, test_agente_real_pexpect.py, test_tokens.py
72
  - [x] Fase 2.3 — Plan Mode (/plan, generate_plan(), on_round_complete,
73
  plan_mode_step_confirm em ~/.thcrc) — 50 testes OK
74
  - [ ] Fase 2.2 — Streaming (rich.live.Live) — ADIADA DE PROPÓSITO para o final
75
- - [ ] Fase 3 — Skills plug-and-play (~/.thc/skills/<nome>/SKILL.md)
76
- - [ ] Fase 4 — MCP (MCPTool, config em ~/.thcrc)
77
- - [ ] Fase 5 — Sub-agentes/orquestração (AgentTool, TaskCreateTool)
78
- - [ ] Fase 6 — Integrações de sistema (browser, LSP, git worktree)
79
- - [ ] Fase 7 — Empacotamento (pip install thc-cli, testes, CI)
 
 
 
 
 
 
 
 
80
 
81
  ## 5. Lições aprendidas (atualizado pelo Claude a cada rodada)
82
 
@@ -84,6 +92,11 @@ test_robustez_fase1b_2_1.py, test_agente_real_pexpect.py, test_tokens.py
84
  free — sempre checar limites reais do provider antes de assumir bug de código.
85
  - Fase 2.3: nenhum deslize registrado nesta rodada. Diff, testes e módulo
86
  novo (plan.py) vieram exatamente como especificado.
 
 
 
 
 
87
 
88
  ## 6. Ethos compartilhado (injetado no system prompt de todo provider)
89
 
 
4
  e Lições Aprendidas durante a execução. Se algo aqui divergir do código
5
  real, pare e reporte — não assuma, não invente.
6
 
7
+ Última atualização: 2026-07-23 — pós Fase 5 (fallback automático de providers)
8
 
9
  ---
10
 
 
72
  - [x] Fase 2.3 — Plan Mode (/plan, generate_plan(), on_round_complete,
73
  plan_mode_step_confirm em ~/.thcrc) — 50 testes OK
74
  - [ ] Fase 2.2 — Streaming (rich.live.Live) — ADIADA DE PROPÓSITO para o final
75
+ - [x] Fase 3 — Memória de longo prazo (MemoryStore, memory_write/read tools,
76
+ pinned entries, injetadas automaticamente no system prompt)
77
+ commit: f7583f1
78
+ - [x] Fase 4 — Skills plug-and-play (SkillStore, /skills REPL, --skill CLI,
79
+ tools_allowed, system_prompt_extra) — 18 testes
80
+ commit: 8f1edf5
81
+ - [x] Fase 5 — Provider padrão + fallback automático por rate limit
82
+ (default openrouter, ProviderRateLimitError, chat_completion_with_fallback,
83
+ ordem configurável em ~/.thcrc, thc fora da cadeia automática)
84
+ - [ ] Fase 6 — MCP (MCPTool, config em ~/.thcrc)
85
+ - [ ] Fase 7 — Sub-agentes/orquestração (AgentTool, TaskCreateTool)
86
+ - [ ] Fase 8 — Integrações de sistema (browser, LSP, git worktree)
87
+ - [ ] Fase 9 — Empacotamento (pip install thc-cli, testes, CI)
88
 
89
  ## 5. Lições aprendidas (atualizado pelo Claude a cada rodada)
90
 
 
92
  free — sempre checar limites reais do provider antes de assumir bug de código.
93
  - Fase 2.3: nenhum deslize registrado nesta rodada. Diff, testes e módulo
94
  novo (plan.py) vieram exatamente como especificado.
95
+ - Fase 5: diagnóstico do teste `test_alucinacao_fallback_llama_8b` foi só
96
+ parcialmente confirmado — as 3 execuções de verificação reproduziram apenas
97
+ rate limit (skip), não o cenário original de resposta malformada em bloco de
98
+ código. O parser de fallback ainda não foi validado contra esse formato
99
+ específico.
100
 
101
  ## 6. Ethos compartilhado (injetado no system prompt de todo provider)
102
 
tests/test_provider_fallback.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import unittest
3
+ import unittest.mock
4
+
5
+ from thc_cli.core.config import load_config, DEFAULT_PROVIDER_FALLBACK_ORDER
6
+ from thc_cli.core.provider_fallback import chat_completion_with_fallback, ProviderRateLimitError
7
+ from thc_cli.core.providers import PROVIDER_CLASSES
8
+
9
+
10
+ class MockProvider:
11
+ def __init__(self, name, fail_with_rate_limit=False, fail_with_other=False):
12
+ self.name = name
13
+ self._fail_with_rate_limit = fail_with_rate_limit
14
+ self._fail_with_other = fail_with_other
15
+ self.calls = []
16
+
17
+ def chat_completion(self, messages, model, max_tokens=8192, temperature=0.7):
18
+ self.calls.append({
19
+ "messages": messages,
20
+ "model": model,
21
+ "max_tokens": max_tokens,
22
+ "temperature": temperature,
23
+ })
24
+ if self._fail_with_rate_limit:
25
+ raise ProviderRateLimitError(self.name, status_code=429, message="Rate limited")
26
+ if self._fail_with_other:
27
+ raise ValueError("Some other error")
28
+ return {
29
+ "choices": [{"message": {"role": "assistant", "content": f"response from {self.name}"}}],
30
+ "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
31
+ }
32
+
33
+
34
+ def _make_mocks(failures=None):
35
+ failures = failures or {}
36
+ mocks = {}
37
+ for name, cls in PROVIDER_CLASSES.items():
38
+ mock = MockProvider(
39
+ name=name,
40
+ fail_with_rate_limit=failures.get(name, False),
41
+ )
42
+ mocks[name] = mock
43
+ return mocks
44
+
45
+
46
+ class TestFallback(unittest.TestCase):
47
+ def test_fallback_usa_proximo_provider_em_rate_limit(self):
48
+ mocks = _make_mocks({"openrouter": True})
49
+ config = {"provider_fallback_order": ["openrouter", "mistral"]}
50
+ with unittest.mock.patch("thc_cli.core.provider_fallback.get_provider", side_effect=lambda n, _: mocks[n]):
51
+ result, used = chat_completion_with_fallback(
52
+ messages=[{"role": "user", "content": "oi"}],
53
+ model="mock-model",
54
+ max_tokens=16,
55
+ temperature=0.7,
56
+ config=config,
57
+ fallback_order=["openrouter", "mistral"],
58
+ provider_name="openrouter",
59
+ )
60
+ self.assertEqual(used, "mistral")
61
+ self.assertIn("mistral", result["choices"][0]["message"]["content"])
62
+ self.assertEqual(len(mocks["mistral"].calls), 1)
63
+
64
+ def test_fallback_pula_provider_ja_tentado(self):
65
+ mocks = _make_mocks({"openrouter": True, "mistral": True})
66
+ config = {"provider_fallback_order": ["openrouter", "mistral"]}
67
+ with unittest.mock.patch("thc_cli.core.provider_fallback.get_provider", side_effect=lambda n, _: mocks[n]):
68
+ with self.assertRaises(ProviderRateLimitError):
69
+ chat_completion_with_fallback(
70
+ messages=[{"role": "user", "content": "oi"}],
71
+ model="mock-model",
72
+ max_tokens=16,
73
+ temperature=0.7,
74
+ config=config,
75
+ fallback_order=["openrouter", "mistral"],
76
+ provider_name="openrouter",
77
+ )
78
+ self.assertEqual(len(mocks["openrouter"].calls), 1)
79
+ self.assertEqual(len(mocks["mistral"].calls), 1)
80
+ self.assertEqual(len(mocks["groq"].calls), 0)
81
+
82
+ def test_fallback_mantem_provider_original_se_sem_erro(self):
83
+ mocks = _make_mocks()
84
+ config = {"provider_fallback_order": ["openrouter", "mistral"]}
85
+ with unittest.mock.patch("thc_cli.core.provider_fallback.get_provider", side_effect=lambda n, _: mocks[n]):
86
+ result, used = chat_completion_with_fallback(
87
+ messages=[{"role": "user", "content": "oi"}],
88
+ model="mock-model",
89
+ max_tokens=16,
90
+ temperature=0.7,
91
+ config=config,
92
+ fallback_order=["openrouter", "mistral"],
93
+ provider_name="openrouter",
94
+ )
95
+ self.assertEqual(used, "openrouter")
96
+ self.assertEqual(len(mocks["openrouter"].calls), 1)
97
+ self.assertEqual(len(mocks["mistral"].calls), 0)
98
+
99
+ def test_fallback_todos_falham_levanta_erro_claro(self):
100
+ mocks = _make_mocks({"openrouter": True, "mistral": True})
101
+ config = {"provider_fallback_order": ["openrouter", "mistral"]}
102
+ with unittest.mock.patch("thc_cli.core.provider_fallback.get_provider", side_effect=lambda n, _: mocks[n]):
103
+ with self.assertRaises(ProviderRateLimitError):
104
+ chat_completion_with_fallback(
105
+ messages=[{"role": "user", "content": "oi"}],
106
+ model="mock-model",
107
+ max_tokens=16,
108
+ temperature=0.7,
109
+ config=config,
110
+ fallback_order=["openrouter", "mistral"],
111
+ provider_name="openrouter",
112
+ )
113
+
114
+ def test_fallback_ordem_configuravel_via_config(self):
115
+ mocks = _make_mocks({"mistral": True, "groq": True})
116
+ config = {"provider_fallback_order": ["mistral", "groq"]}
117
+ with unittest.mock.patch("thc_cli.core.provider_fallback.get_provider", side_effect=lambda n, _: mocks[n]):
118
+ with self.assertRaises(ProviderRateLimitError):
119
+ chat_completion_with_fallback(
120
+ messages=[{"role": "user", "content": "oi"}],
121
+ model="mock-model",
122
+ max_tokens=16,
123
+ temperature=0.7,
124
+ config=config,
125
+ fallback_order=["mistral", "groq"],
126
+ provider_name="mistral",
127
+ )
128
+ self.assertEqual(len(mocks["groq"].calls), 1)
129
+
130
+ def test_fallback_provider_invalido_na_lista_e_ignorado(self):
131
+ mocks = _make_mocks({"openrouter": True})
132
+ config = {"provider_fallback_order": ["openrouter", "invalid_provider", "mistral"]}
133
+
134
+ def _get_provider(name, cfg):
135
+ if name not in mocks:
136
+ raise ValueError(f"Provider desconhecido: {name}")
137
+ return mocks[name]
138
+
139
+ with unittest.mock.patch("thc_cli.core.provider_fallback.get_provider", side_effect=_get_provider):
140
+ result, used = chat_completion_with_fallback(
141
+ messages=[{"role": "user", "content": "oi"}],
142
+ model="mock-model",
143
+ max_tokens=16,
144
+ temperature=0.7,
145
+ config=config,
146
+ fallback_order=["openrouter", "invalid_provider", "mistral"],
147
+ provider_name="openrouter",
148
+ )
149
+ self.assertEqual(used, "mistral")
150
+ self.assertEqual(len(mocks["mistral"].calls), 1)
151
+
152
+ def test_fallback_contexto_mensagens_repassado_integralmente_ao_proximo_provider(self):
153
+ mocks = _make_mocks({"openrouter": True})
154
+ config = {"provider_fallback_order": ["openrouter", "mistral"]}
155
+ messages = [
156
+ {"role": "system", "content": "you are helpful"},
157
+ {"role": "user", "content": "hello"},
158
+ {"role": "assistant", "content": "hi"},
159
+ {"role": "user", "content": "world"},
160
+ ]
161
+ with unittest.mock.patch("thc_cli.core.provider_fallback.get_provider", side_effect=lambda n, _: mocks[n]):
162
+ result, used = chat_completion_with_fallback(
163
+ messages=messages,
164
+ model="mock-model",
165
+ max_tokens=16,
166
+ temperature=0.7,
167
+ config=config,
168
+ fallback_order=["openrouter", "mistral"],
169
+ provider_name="openrouter",
170
+ )
171
+ self.assertEqual(used, "mistral")
172
+ self.assertEqual(len(mocks["mistral"].calls), 1)
173
+ self.assertEqual(mocks["mistral"].calls[0]["messages"], messages)
174
+
175
+ def test_config_default_provider_e_openrouter(self):
176
+ import tempfile
177
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
178
+ json.dump({"endpoint": "http://example.com"}, f)
179
+ path = f.name
180
+ with unittest.mock.patch("thc_cli.core.config.CONFIG_PATH") as mock_path:
181
+ mock_path.exists.return_value = True
182
+ mock_path.__str__ = lambda self: path
183
+ with unittest.mock.patch("builtins.open", unittest.mock.mock_open(read_data=json.dumps({"endpoint": "http://example.com"}))):
184
+ cfg = load_config()
185
+ self.assertEqual(cfg["provider"], "openrouter")
186
+
187
+ def test_config_le_provider_fallback_order_do_thcrc(self):
188
+ import tempfile
189
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
190
+ json.dump({"provider_fallback_order": ["mistral", "groq"]}, f)
191
+ path = f.name
192
+ with unittest.mock.patch("thc_cli.core.config.CONFIG_PATH") as mock_path:
193
+ mock_path.exists.return_value = True
194
+ mock_path.__str__ = lambda self: path
195
+ with unittest.mock.patch("builtins.open", unittest.mock.mock_open(read_data=json.dumps({"provider_fallback_order": ["mistral", "groq"]}))):
196
+ cfg = load_config()
197
+ self.assertEqual(cfg["provider_fallback_order"], ["mistral", "groq"])
198
+
199
+ def test_config_fallback_order_default_sem_thcrc(self):
200
+ import tempfile
201
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
202
+ json.dump({}, f)
203
+ path = f.name
204
+ with unittest.mock.patch("thc_cli.core.config.CONFIG_PATH") as mock_path:
205
+ mock_path.exists.return_value = True
206
+ mock_path.__str__ = lambda self: path
207
+ with unittest.mock.patch("builtins.open", unittest.mock.mock_open(read_data=json.dumps({}))):
208
+ cfg = load_config()
209
+ self.assertEqual(cfg["provider_fallback_order"], DEFAULT_PROVIDER_FALLBACK_ORDER)
210
+
211
+ def test_fallback_http_429_via_mensagem(self):
212
+ import httpx
213
+ mocks = _make_mocks()
214
+ mocks["openrouter"]._fail_with_rate_limit = False
215
+ mocks["openrouter"]._fail_with_other = True
216
+
217
+ request = httpx.Request("POST", "http://example.com")
218
+ response = httpx.Response(429, request=request, text="Too Many Requests")
219
+ mocks["openrouter"].chat_completion = unittest.mock.Mock(side_effect=httpx.HTTPStatusError("429", request=request, response=response))
220
+
221
+ config = {"provider_fallback_order": ["openrouter", "mistral"]}
222
+ with unittest.mock.patch("thc_cli.core.provider_fallback.get_provider", side_effect=lambda n, _: mocks[n]):
223
+ result, used = chat_completion_with_fallback(
224
+ messages=[{"role": "user", "content": "oi"}],
225
+ model="mock-model",
226
+ max_tokens=16,
227
+ temperature=0.7,
228
+ config=config,
229
+ fallback_order=["openrouter", "mistral"],
230
+ provider_name="openrouter",
231
+ )
232
+ self.assertEqual(used, "mistral")
233
+
234
+ def test_fallback_nao_propaga_erro_nao_rate_limit(self):
235
+ mocks = _make_mocks({"openrouter": False})
236
+ mocks["openrouter"]._fail_with_rate_limit = False
237
+ mocks["openrouter"]._fail_with_other = True
238
+ mocks["openrouter"].chat_completion = unittest.mock.Mock(side_effect=ValueError("auth failed"))
239
+ config = {"provider_fallback_order": ["openrouter", "mistral"]}
240
+ with unittest.mock.patch("thc_cli.core.provider_fallback.get_provider", side_effect=lambda n, _: mocks[n]):
241
+ with self.assertRaises(ValueError):
242
+ chat_completion_with_fallback(
243
+ messages=[{"role": "user", "content": "oi"}],
244
+ model="mock-model",
245
+ max_tokens=16,
246
+ temperature=0.7,
247
+ config=config,
248
+ fallback_order=["openrouter", "mistral"],
249
+ provider_name="openrouter",
250
+ )
251
+ self.assertEqual(len(mocks["mistral"].calls), 0)
252
+
253
+ def test_chat_single_shot_fallback_sucesso(self):
254
+ from thc_cli.commands.chat import _provider_chat_with_fallback
255
+ mocks = _make_mocks({"openrouter": True})
256
+ config = {"provider_fallback_order": ["openrouter", "mistral"], "provider_keys": {}}
257
+ with unittest.mock.patch("thc_cli.core.provider_fallback.get_provider", side_effect=lambda n, _: mocks[n]):
258
+ result, used = _provider_chat_with_fallback(
259
+ provider=mocks["openrouter"],
260
+ provider_name="openrouter",
261
+ messages=[{"role": "user", "content": "oi"}],
262
+ model="mock-model",
263
+ mode="medium",
264
+ web=False,
265
+ max_tokens=16,
266
+ temperature=0.7,
267
+ config=config,
268
+ fallback_order=["openrouter", "mistral"],
269
+ )
270
+ self.assertEqual(used, "mistral")
271
+ self.assertIn("mistral", result["choices"][0]["message"]["content"])
272
+
273
+
274
+ if __name__ == "__main__":
275
+ unittest.main(verbosity=2)
tests/test_providers_rate_limit.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import unittest
3
+ import unittest.mock
4
+
5
+ import httpx
6
+
7
+ from thc_cli.core.agent import run_agent
8
+ from thc_cli.core.provider_fallback import ProviderRateLimitError
9
+ from thc_cli.core.providers import PROVIDER_CLASSES
10
+ from thc_cli.core.providers.groq_provider import GroqProvider
11
+ from thc_cli.core.providers.mistral_provider import MistralProvider
12
+ from thc_cli.core.providers.openrouter_provider import OpenRouterProvider
13
+ from thc_cli.core.providers.gemini_provider import GeminiProvider
14
+
15
+
16
+ def _make_429_response():
17
+ request = httpx.Request("POST", "https://example.com")
18
+ return httpx.Response(429, request=request, text="Too Many Requests")
19
+
20
+
21
+ class TestProviderRateLimit(unittest.TestCase):
22
+ def test_openrouter_rate_limit(self):
23
+ provider = OpenRouterProvider(api_key="fake-key")
24
+ with unittest.mock.patch("httpx.Client.post", return_value=_make_429_response()):
25
+ with self.assertRaises(ProviderRateLimitError) as ctx:
26
+ provider.chat_completion(
27
+ messages=[{"role": "user", "content": "oi"}],
28
+ model="google/gemma-4-31b-it:free",
29
+ )
30
+ self.assertEqual(ctx.exception.provider_name, "openrouter")
31
+ self.assertEqual(ctx.exception.status_code, 429)
32
+
33
+ def test_mistral_rate_limit(self):
34
+ provider = MistralProvider(api_key="fake-key")
35
+ with unittest.mock.patch("httpx.Client.post", return_value=_make_429_response()):
36
+ with self.assertRaises(ProviderRateLimitError) as ctx:
37
+ provider.chat_completion(
38
+ messages=[{"role": "user", "content": "oi"}],
39
+ model="mistral-small-latest",
40
+ )
41
+ self.assertEqual(ctx.exception.provider_name, "mistral")
42
+ self.assertEqual(ctx.exception.status_code, 429)
43
+
44
+ def test_groq_rate_limit(self):
45
+ provider = GroqProvider(api_key="fake-key")
46
+ with unittest.mock.patch("httpx.Client.post", return_value=_make_429_response()):
47
+ with self.assertRaises(ProviderRateLimitError) as ctx:
48
+ provider.chat_completion(
49
+ messages=[{"role": "user", "content": "oi"}],
50
+ model="llama-3.1-8b-instant",
51
+ )
52
+ self.assertEqual(ctx.exception.provider_name, "groq")
53
+ self.assertEqual(ctx.exception.status_code, 429)
54
+
55
+ def test_gemini_rate_limit_via_status(self):
56
+ provider = GeminiProvider(api_key="fake-key")
57
+ with unittest.mock.patch("httpx.Client.post", return_value=_make_429_response()):
58
+ with self.assertRaises(ProviderRateLimitError) as ctx:
59
+ provider.chat_completion(
60
+ messages=[{"role": "user", "content": "oi"}],
61
+ model="gemini-3.5-flash",
62
+ )
63
+ self.assertEqual(ctx.exception.provider_name, "gemini")
64
+ self.assertEqual(ctx.exception.status_code, 429)
65
+
66
+ def test_gemini_rate_limit_via_body(self):
67
+ provider = GeminiProvider(api_key="fake-key")
68
+ response = httpx.Response(
69
+ 200,
70
+ request=httpx.Request("POST", "https://example.com"),
71
+ json={
72
+ "error": {"status": "RESOURCE_EXHAUSTED", "message": "quota exceeded"},
73
+ },
74
+ )
75
+ with unittest.mock.patch("httpx.Client.post", return_value=response):
76
+ with self.assertRaises(ProviderRateLimitError) as ctx:
77
+ provider.chat_completion(
78
+ messages=[{"role": "user", "content": "oi"}],
79
+ model="gemini-3.5-flash",
80
+ )
81
+ self.assertEqual(ctx.exception.provider_name, "gemini")
82
+ self.assertEqual(ctx.exception.status_code, 429)
83
+
84
+ def test_run_agent_fallback_success_with_config_none(self):
85
+ fake_reply = {
86
+ "choices": [{"message": {"role": "assistant", "content": "ok"}}],
87
+ "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
88
+ }
89
+ fake_provider = unittest.mock.Mock()
90
+ fake_provider.name = "openrouter"
91
+ fake_provider.chat_completion.return_value = fake_reply
92
+
93
+ def _fake_get_provider(name, config):
94
+ return fake_provider
95
+
96
+ with unittest.mock.patch("thc_cli.core.agent.get_provider", side_effect=_fake_get_provider):
97
+ with unittest.mock.patch("thc_cli.core.provider_fallback.get_provider", side_effect=_fake_get_provider):
98
+ with unittest.mock.patch("thc_cli.core.agent.load_config", return_value={"provider_keys": {}}):
99
+ with unittest.mock.patch("thc_cli.core.agent.MemoryStore"):
100
+ reply = run_agent(
101
+ provider=fake_provider,
102
+ messages=[{"role": "user", "content": "oi"}],
103
+ model="mock",
104
+ fallback_order=["openrouter"],
105
+ config=None,
106
+ max_rounds=1,
107
+ )
108
+ self.assertIn("ok", reply)
109
+
110
+
111
+ if __name__ == "__main__":
112
+ unittest.main(verbosity=2)
thc_cli/commands/chat.py CHANGED
@@ -13,7 +13,9 @@ from ..core import print_thinking_spinner, print_diff, confirm
13
  from ..core.agent import run_agent
14
  from ..core.memory import MemoryStore
15
  from ..core.plan import generate_plan
 
16
  from ..core.providers import get_provider
 
17
  from ..core.session import list_sessions, load_session, save_session
18
  from ..core.skills import SkillStore
19
  from ..core.system_prompt import build_base_system_prompt
@@ -101,6 +103,25 @@ def _provider_chat(provider, messages, model, mode, web, max_tokens=8192, temper
101
  )
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def _format_elapsed(seconds: float) -> str:
105
  minutes = int(seconds // 60)
106
  secs = int(seconds % 60)
@@ -200,6 +221,8 @@ def run(args, config):
200
  on_tool_result=on_tool_result,
201
  on_thinking=on_thinking,
202
  skill=active_skill,
 
 
203
  )
204
  except Exception as e:
205
  print(f"Erro: {e}", file=sys.stderr)
@@ -212,17 +235,25 @@ def run(args, config):
212
  web_msg = _build_web_search_system_message(args.prompt)
213
  if web_msg:
214
  messages.append(web_msg)
215
- messages.append({"role": "user", "content": args.prompt})
216
  try:
217
  with print_thinking_spinner():
218
- result = _provider_chat(
219
- provider=provider,
220
- messages=messages,
221
- model=model,
222
- mode=mode,
223
- web=web,
224
- max_tokens=max_tokens,
225
- )
 
 
 
 
 
 
 
 
226
  reply = result["choices"][0]["message"]["content"]
227
  except Exception as e:
228
  print(f"Erro: {e}", file=sys.stderr)
@@ -518,6 +549,8 @@ def run(args, config):
518
  on_thinking=on_thinking,
519
  on_round_complete=_on_round_complete,
520
  skill=active_skill,
 
 
521
  )
522
  history.append({"role": "assistant", "content": reply})
523
  else:
@@ -529,14 +562,29 @@ def run(args, config):
529
  web_msg = _build_web_search_system_message(last_user_message)
530
  if web_msg:
531
  messages_to_send.insert(1, web_msg)
532
- result = _provider_chat(
533
- provider=provider,
534
- messages=messages_to_send,
535
- model=model,
536
- mode=mode,
537
- web=web,
538
- max_tokens=max_tokens,
539
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  reply = result["choices"][0]["message"]["content"]
541
  history.append({"role": "assistant", "content": reply})
542
  _render_reply(reply)
 
13
  from ..core.agent import run_agent
14
  from ..core.memory import MemoryStore
15
  from ..core.plan import generate_plan
16
+ from ..core.provider_fallback import chat_completion_with_fallback
17
  from ..core.providers import get_provider
18
+ from ..core.providers.base import ProviderRateLimitError
19
  from ..core.session import list_sessions, load_session, save_session
20
  from ..core.skills import SkillStore
21
  from ..core.system_prompt import build_base_system_prompt
 
103
  )
104
 
105
 
106
+ def _provider_chat_with_fallback(provider, provider_name, messages, model, mode, web, max_tokens, temperature, config, fallback_order):
107
+ if _is_thc_provider(provider):
108
+ return _provider_chat(provider, messages, model, mode, web, max_tokens, temperature), provider_name
109
+
110
+ if not fallback_order:
111
+ return _provider_chat(provider, messages, model, mode, web, max_tokens, temperature), provider_name
112
+
113
+ def _on_fallback(from_name, to_name):
114
+ console.print(f"[yellow]⚠ {from_name} atingiu rate limit — mudando para {to_name}[/yellow]")
115
+ try:
116
+ result, used_name = chat_completion_with_fallback(
117
+ messages, model, max_tokens, temperature, config or {},
118
+ fallback_order, provider_name, on_fallback=_on_fallback,
119
+ )
120
+ return result, used_name
121
+ except ProviderRateLimitError as e:
122
+ raise ProviderRateLimitError(provider_name, status_code=getattr(e, 'status_code', None), message=str(e))
123
+
124
+
125
  def _format_elapsed(seconds: float) -> str:
126
  minutes = int(seconds // 60)
127
  secs = int(seconds % 60)
 
221
  on_tool_result=on_tool_result,
222
  on_thinking=on_thinking,
223
  skill=active_skill,
224
+ fallback_order=config.get("provider_fallback_order", []),
225
+ config=config,
226
  )
227
  except Exception as e:
228
  print(f"Erro: {e}", file=sys.stderr)
 
235
  web_msg = _build_web_search_system_message(args.prompt)
236
  if web_msg:
237
  messages.append(web_msg)
238
+ messages.append({"role": "user", "content": args.prompt})
239
  try:
240
  with print_thinking_spinner():
241
+ try:
242
+ result, used_name = _provider_chat_with_fallback(
243
+ provider=provider,
244
+ provider_name=provider_name,
245
+ messages=messages,
246
+ model=model,
247
+ mode=mode,
248
+ web=web,
249
+ max_tokens=max_tokens,
250
+ temperature=0.7,
251
+ config=config,
252
+ fallback_order=config.get("provider_fallback_order", []),
253
+ )
254
+ except ProviderRateLimitError as e:
255
+ console.print(f"[red]Erro: todos os providers da lista de fallback falharam ({e})[/red]")
256
+ raise
257
  reply = result["choices"][0]["message"]["content"]
258
  except Exception as e:
259
  print(f"Erro: {e}", file=sys.stderr)
 
549
  on_thinking=on_thinking,
550
  on_round_complete=_on_round_complete,
551
  skill=active_skill,
552
+ fallback_order=config.get("provider_fallback_order", []),
553
+ config=config,
554
  )
555
  history.append({"role": "assistant", "content": reply})
556
  else:
 
562
  web_msg = _build_web_search_system_message(last_user_message)
563
  if web_msg:
564
  messages_to_send.insert(1, web_msg)
565
+ try:
566
+ result, used_name = _provider_chat_with_fallback(
567
+ provider=provider,
568
+ provider_name=provider_name,
569
+ messages=messages_to_send,
570
+ model=model,
571
+ mode=mode,
572
+ web=web,
573
+ max_tokens=max_tokens,
574
+ temperature=0.7,
575
+ config=config,
576
+ fallback_order=config.get("provider_fallback_order", []),
577
+ )
578
+ except ProviderRateLimitError as e:
579
+ console.print(f"[red]Erro: todos os providers da lista de fallback falharam ({e})[/red]")
580
+ raise
581
+ if used_name and used_name != provider_name:
582
+ try:
583
+ provider_name = used_name
584
+ provider = get_provider(used_name, config)
585
+ _banner(model, mode, web, agent_mode, plan_mode, provider_name)
586
+ except Exception as e:
587
+ console.print(f"[red]Erro ao aplicar fallback para {used_name}: {e}[/red]")
588
  reply = result["choices"][0]["message"]["content"]
589
  history.append({"role": "assistant", "content": reply})
590
  _render_reply(reply)
thc_cli/core/agent.py CHANGED
@@ -2,7 +2,11 @@ import json
2
  import re
3
  from typing import Optional
4
 
 
5
  from .memory import MemoryStore
 
 
 
6
  from .system_prompt import build_base_system_prompt
7
  from .tools import ALL_TOOLS, TOOLS_BY_NAME
8
 
@@ -117,6 +121,8 @@ def run_agent(
117
  on_thinking: Optional[callable] = None,
118
  on_round_complete: Optional[callable] = None,
119
  skill: Optional[dict] = None,
 
 
120
  ) -> str:
121
  tools_to_use = TOOLS_BY_NAME
122
  if skill and skill.get("tools_allowed"):
@@ -137,9 +143,41 @@ def run_agent(
137
  if skill and skill.get("system_prompt_extra"):
138
  system_prompt += "\n\n## Skill ativa: " + skill["name"] + "\n" + skill["system_prompt_extra"]
139
  messages = [{"role": "system", "content": system_prompt}] + list(messages)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  for _ in range(max_rounds):
141
  messages = _truncate_history(messages)
142
- result = _provider_chat_completion(provider, messages, model, mode, web, max_tokens, temperature)
143
  assistant_message = result["choices"][0]["message"]["content"]
144
  messages.append({"role": "assistant", "content": assistant_message})
145
  thinking = TOOL_CALL_PATTERN.split(assistant_message)[0].strip()
@@ -154,7 +192,7 @@ def run_agent(
154
  for retry in range(2):
155
  error_msg = "Seu último tool_call não é JSON válido ou está malformado. Responda novamente APENAS com o bloco ```tool_call``` corrigido."
156
  messages.append({"role": "user", "content": error_msg})
157
- retry_result = _provider_chat_completion(provider, messages, model, mode, web, max_tokens, temperature)
158
  retry_message = retry_result["choices"][0]["message"]["content"]
159
  messages.append({"role": "assistant", "content": retry_message})
160
  tool_calls = _extract_tool_calls(retry_message)
 
2
  import re
3
  from typing import Optional
4
 
5
+ from .config import load_config
6
  from .memory import MemoryStore
7
+ from .provider_fallback import chat_completion_with_fallback
8
+ from .providers import get_provider
9
+ from .providers.base import ProviderRateLimitError
10
  from .system_prompt import build_base_system_prompt
11
  from .tools import ALL_TOOLS, TOOLS_BY_NAME
12
 
 
121
  on_thinking: Optional[callable] = None,
122
  on_round_complete: Optional[callable] = None,
123
  skill: Optional[dict] = None,
124
+ fallback_order: list[str] = None,
125
+ config: dict = None,
126
  ) -> str:
127
  tools_to_use = TOOLS_BY_NAME
128
  if skill and skill.get("tools_allowed"):
 
143
  if skill and skill.get("system_prompt_extra"):
144
  system_prompt += "\n\n## Skill ativa: " + skill["name"] + "\n" + skill["system_prompt_extra"]
145
  messages = [{"role": "system", "content": system_prompt}] + list(messages)
146
+
147
+ state = {
148
+ "provider": provider,
149
+ "provider_name": getattr(provider, "name", None),
150
+ }
151
+
152
+ def _do_chat(messages_for_call):
153
+ if fallback_order and state["provider_name"] and state["provider_name"] != "thc":
154
+ def _on_fallback(from_name, to_name):
155
+ if on_tool_result:
156
+ on_tool_result("fallback", f"⚠ {from_name} atingiu rate limit — mudando para {to_name}")
157
+ try:
158
+ result, used_name = chat_completion_with_fallback(
159
+ messages=messages_for_call,
160
+ model=model,
161
+ max_tokens=max_tokens,
162
+ temperature=temperature,
163
+ config=config or load_config(),
164
+ fallback_order=fallback_order,
165
+ provider_name=state["provider_name"],
166
+ on_fallback=_on_fallback,
167
+ )
168
+ if used_name != state["provider_name"]:
169
+ state["provider_name"] = used_name
170
+ state["provider"] = get_provider(used_name, config or {})
171
+ return result
172
+ except ProviderRateLimitError:
173
+ if on_tool_result:
174
+ on_tool_result("fallback", "Erro: todos os providers da lista de fallback falharam")
175
+ raise
176
+ return _provider_chat_completion(state["provider"], messages_for_call, model, mode, web, max_tokens, temperature)
177
+
178
  for _ in range(max_rounds):
179
  messages = _truncate_history(messages)
180
+ result = _do_chat(messages)
181
  assistant_message = result["choices"][0]["message"]["content"]
182
  messages.append({"role": "assistant", "content": assistant_message})
183
  thinking = TOOL_CALL_PATTERN.split(assistant_message)[0].strip()
 
192
  for retry in range(2):
193
  error_msg = "Seu último tool_call não é JSON válido ou está malformado. Responda novamente APENAS com o bloco ```tool_call``` corrigido."
194
  messages.append({"role": "user", "content": error_msg})
195
+ retry_result = _do_chat(messages)
196
  retry_message = retry_result["choices"][0]["message"]["content"]
197
  messages.append({"role": "assistant", "content": retry_message})
198
  tool_calls = _extract_tool_calls(retry_message)
thc_cli/core/config.py CHANGED
@@ -4,6 +4,8 @@ from pathlib import Path
4
 
5
  DEFAULT_ENDPOINT = "https://hulktoigo-thcllm.hf.space"
6
  CONFIG_PATH = Path.home() / ".thcrc"
 
 
7
 
8
 
9
  def load_config() -> dict:
@@ -18,7 +20,7 @@ def load_config() -> dict:
18
 
19
  endpoint = os.environ.get("THC_ENDPOINT") or file_config.get("endpoint") or DEFAULT_ENDPOINT
20
  api_key = os.environ.get("THC_API_KEY") or file_config.get("api_key")
21
- provider = os.environ.get("THC_PROVIDER") or file_config.get("provider", "thc")
22
  provider_keys = dict(file_config.get("provider_keys", {}))
23
  for key_name, env_name in [
24
  ("groq", "GROQ_API_KEY"),
@@ -29,9 +31,13 @@ def load_config() -> dict:
29
  if env_name in os.environ:
30
  provider_keys[key_name] = os.environ[env_name]
31
 
 
 
 
32
  return {
33
  "endpoint": endpoint,
34
  "api_key": api_key,
35
  "provider": provider,
36
  "provider_keys": provider_keys,
 
37
  }
 
4
 
5
  DEFAULT_ENDPOINT = "https://hulktoigo-thcllm.hf.space"
6
  CONFIG_PATH = Path.home() / ".thcrc"
7
+ DEFAULT_PROVIDER_FALLBACK_ORDER = ["openrouter", "mistral", "groq", "gemini"]
8
+ _VALID_PROVIDERS = {"thc", "groq", "mistral", "gemini", "openrouter"}
9
 
10
 
11
  def load_config() -> dict:
 
20
 
21
  endpoint = os.environ.get("THC_ENDPOINT") or file_config.get("endpoint") or DEFAULT_ENDPOINT
22
  api_key = os.environ.get("THC_API_KEY") or file_config.get("api_key")
23
+ provider = os.environ.get("THC_PROVIDER") or file_config.get("provider", DEFAULT_PROVIDER_FALLBACK_ORDER[0])
24
  provider_keys = dict(file_config.get("provider_keys", {}))
25
  for key_name, env_name in [
26
  ("groq", "GROQ_API_KEY"),
 
31
  if env_name in os.environ:
32
  provider_keys[key_name] = os.environ[env_name]
33
 
34
+ raw_fallback_order = file_config.get("provider_fallback_order", DEFAULT_PROVIDER_FALLBACK_ORDER)
35
+ provider_fallback_order = [p for p in raw_fallback_order if p in _VALID_PROVIDERS]
36
+
37
  return {
38
  "endpoint": endpoint,
39
  "api_key": api_key,
40
  "provider": provider,
41
  "provider_keys": provider_keys,
42
+ "provider_fallback_order": provider_fallback_order,
43
  }
thc_cli/core/provider_fallback.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, Callable, List
2
+
3
+ from .providers import get_provider, PROVIDER_CLASSES
4
+ from .providers.base import ProviderRateLimitError
5
+
6
+
7
+ def chat_completion_with_fallback(
8
+ messages: list[dict],
9
+ model: str,
10
+ max_tokens: int,
11
+ temperature: float,
12
+ config: dict,
13
+ fallback_order: list[str],
14
+ provider_name: str,
15
+ on_fallback: Optional[Callable[[str, str], None]] = None,
16
+ ) -> tuple[dict, str]:
17
+ tried: List[str] = []
18
+ order = [provider_name] + [p for p in fallback_order if p != provider_name]
19
+ last_error: Optional[ProviderRateLimitError] = None
20
+
21
+ for name in order:
22
+ if name in tried:
23
+ continue
24
+ tried.append(name)
25
+ try:
26
+ provider = get_provider(name, config)
27
+ result = provider.chat_completion(
28
+ messages=messages,
29
+ model=model,
30
+ max_tokens=max_tokens,
31
+ temperature=temperature,
32
+ )
33
+ if name != provider_name and on_fallback:
34
+ on_fallback(provider_name, name)
35
+ return result, name
36
+ except ProviderRateLimitError as e:
37
+ last_error = e
38
+ continue
39
+ except ValueError as e:
40
+ if "desconhecido" in str(e):
41
+ continue
42
+ raise
43
+ except Exception as e:
44
+ status_code = getattr(getattr(e, "response", None), "status_code", None)
45
+ msg = str(e).lower()
46
+ is_rate = status_code == 429 or any(k in msg for k in [
47
+ "rate limit", "quota", "too many requests", "resource_exhausted", "resourseexhausted"
48
+ ])
49
+ if is_rate:
50
+ last_error = ProviderRateLimitError(
51
+ provider_name=name,
52
+ status_code=status_code,
53
+ message=str(e),
54
+ )
55
+ continue
56
+ raise
57
+
58
+ raise last_error or ProviderRateLimitError(
59
+ provider_name=provider_name,
60
+ message="Todos os providers da lista de fallback falharam",
61
+ )
thc_cli/core/providers/base.py CHANGED
@@ -1,3 +1,10 @@
 
 
 
 
 
 
 
1
  class BaseProvider:
2
  name: str = ""
3
 
 
1
+ class ProviderRateLimitError(Exception):
2
+ def __init__(self, provider_name: str, status_code: int = None, message: str = ""):
3
+ self.provider_name = provider_name
4
+ self.status_code = status_code
5
+ super().__init__(message or f"{provider_name}: rate limit ou quota excedida")
6
+
7
+
8
  class BaseProvider:
9
  name: str = ""
10
 
thc_cli/core/providers/gemini_provider.py CHANGED
@@ -4,7 +4,7 @@ from typing import Optional
4
 
5
  import httpx
6
 
7
- from .base import BaseProvider
8
 
9
 
10
  GEMINI_MODELS = [
@@ -59,6 +59,14 @@ class GeminiProvider(BaseProvider):
59
  url = f"{self.endpoint}/{model}:generateContent?key={self.api_key}"
60
  with httpx.Client(timeout=120.0) as client:
61
  resp = client.post(url, json=payload, headers={"Content-Type": "application/json"})
 
 
 
 
 
 
 
 
62
  resp.raise_for_status()
63
  data = resp.json()
64
  text = data["candidates"][0]["content"]["parts"][0]["text"]
 
4
 
5
  import httpx
6
 
7
+ from .base import BaseProvider, ProviderRateLimitError
8
 
9
 
10
  GEMINI_MODELS = [
 
59
  url = f"{self.endpoint}/{model}:generateContent?key={self.api_key}"
60
  with httpx.Client(timeout=120.0) as client:
61
  resp = client.post(url, json=payload, headers={"Content-Type": "application/json"})
62
+ if resp.status_code == 429:
63
+ raise ProviderRateLimitError(self.name, status_code=resp.status_code, message="Rate limited")
64
+ try:
65
+ data = resp.json()
66
+ except Exception:
67
+ data = {}
68
+ if isinstance(data.get("error"), dict) and data["error"].get("status") == "RESOURCE_EXHAUSTED":
69
+ raise ProviderRateLimitError(self.name, status_code=429, message="RESOURCE_EXHAUSTED")
70
  resp.raise_for_status()
71
  data = resp.json()
72
  text = data["candidates"][0]["content"]["parts"][0]["text"]
thc_cli/core/providers/groq_provider.py CHANGED
@@ -1,7 +1,7 @@
1
  import httpx
2
  from typing import Optional
3
 
4
- from .base import BaseProvider
5
 
6
 
7
  GROQ_MODELS = [
@@ -37,6 +37,8 @@ class GroqProvider(BaseProvider):
37
  }
38
  with httpx.Client(timeout=120.0) as client:
39
  resp = client.post(self.endpoint, json=payload, headers=headers)
 
 
40
  resp.raise_for_status()
41
  return resp.json()
42
 
 
1
  import httpx
2
  from typing import Optional
3
 
4
+ from .base import BaseProvider, ProviderRateLimitError
5
 
6
 
7
  GROQ_MODELS = [
 
37
  }
38
  with httpx.Client(timeout=120.0) as client:
39
  resp = client.post(self.endpoint, json=payload, headers=headers)
40
+ if resp.status_code == 429:
41
+ raise ProviderRateLimitError(self.name, status_code=resp.status_code, message="Rate limited")
42
  resp.raise_for_status()
43
  return resp.json()
44
 
thc_cli/core/providers/mistral_provider.py CHANGED
@@ -1,6 +1,6 @@
1
  import httpx
2
 
3
- from .base import BaseProvider
4
 
5
 
6
  MISTRAL_MODELS = [
@@ -36,6 +36,8 @@ class MistralProvider(BaseProvider):
36
  }
37
  with httpx.Client(timeout=120.0) as client:
38
  resp = client.post(self.endpoint, json=payload, headers=headers)
 
 
39
  resp.raise_for_status()
40
  return resp.json()
41
 
 
1
  import httpx
2
 
3
+ from .base import BaseProvider, ProviderRateLimitError
4
 
5
 
6
  MISTRAL_MODELS = [
 
36
  }
37
  with httpx.Client(timeout=120.0) as client:
38
  resp = client.post(self.endpoint, json=payload, headers=headers)
39
+ if resp.status_code == 429:
40
+ raise ProviderRateLimitError(self.name, status_code=resp.status_code, message="Rate limited")
41
  resp.raise_for_status()
42
  return resp.json()
43
 
thc_cli/core/providers/openrouter_provider.py CHANGED
@@ -1,6 +1,6 @@
1
  import httpx
2
 
3
- from .base import BaseProvider
4
 
5
 
6
  OPENROUTER_MODELS = [
@@ -36,6 +36,8 @@ class OpenRouterProvider(BaseProvider):
36
  }
37
  with httpx.Client(timeout=120.0) as client:
38
  resp = client.post(self.endpoint, json=payload, headers=headers)
 
 
39
  resp.raise_for_status()
40
  return resp.json()
41
 
 
1
  import httpx
2
 
3
+ from .base import BaseProvider, ProviderRateLimitError
4
 
5
 
6
  OPENROUTER_MODELS = [
 
36
  }
37
  with httpx.Client(timeout=120.0) as client:
38
  resp = client.post(self.endpoint, json=payload, headers=headers)
39
+ if resp.status_code == 429:
40
+ raise ProviderRateLimitError(self.name, status_code=resp.status_code, message="Rate limited")
41
  resp.raise_for_status()
42
  return resp.json()
43