reliable tools (gpt-oss-120b) + resilient orchestrator
Browse files- app/config.py +3 -1
- app/orchestrator.py +20 -4
- tests/test_orchestrator.py +36 -1
app/config.py
CHANGED
|
@@ -18,7 +18,9 @@ class Settings(BaseSettings):
|
|
| 18 |
# LLM: Groq (primary)
|
| 19 |
groq_api_key: str = ""
|
| 20 |
groq_base_url: str = "https://api.groq.com/openai/v1"
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
model_small: str = "llama-3.1-8b-instant"
|
| 23 |
|
| 24 |
# LLM: Cloudflare Workers AI (failover)
|
|
|
|
| 18 |
# LLM: Groq (primary)
|
| 19 |
groq_api_key: str = ""
|
| 20 |
groq_base_url: str = "https://api.groq.com/openai/v1"
|
| 21 |
+
# gpt-oss-120b: stable on Groq + reliable tool calling (Llama 3.3 70B
|
| 22 |
+
# intermittently returns tool_use_failed on Groq).
|
| 23 |
+
model_large: str = "openai/gpt-oss-120b"
|
| 24 |
model_small: str = "llama-3.1-8b-instant"
|
| 25 |
|
| 26 |
# LLM: Cloudflare Workers AI (failover)
|
app/orchestrator.py
CHANGED
|
@@ -7,7 +7,7 @@ from typing import Any
|
|
| 7 |
|
| 8 |
from sqlalchemy import select
|
| 9 |
|
| 10 |
-
from app.llm.base import ToolCall
|
| 11 |
from app.models import ChatMessage
|
| 12 |
from app.prompts import build_system_prompt
|
| 13 |
from app.schemas import ChatResponse
|
|
@@ -61,8 +61,24 @@ async def run_turn(
|
|
| 61 |
used_tools: list[str] = []
|
| 62 |
reply = FALLBACK_REPLY
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
for _ in range(MAX_TOOL_ITERS):
|
| 65 |
-
result = await
|
|
|
|
|
|
|
|
|
|
| 66 |
if result.tool_calls:
|
| 67 |
messages.append(
|
| 68 |
{
|
|
@@ -86,8 +102,8 @@ async def run_turn(
|
|
| 86 |
break
|
| 87 |
else:
|
| 88 |
# Tool budget exhausted: force a final answer with no tools.
|
| 89 |
-
final = await
|
| 90 |
-
reply = final.content or FALLBACK_REPLY
|
| 91 |
|
| 92 |
ctx.db.add(ChatMessage(session_id=ctx.session.id, role="assistant", content=reply))
|
| 93 |
await ctx.db.flush()
|
|
|
|
| 7 |
|
| 8 |
from sqlalchemy import select
|
| 9 |
|
| 10 |
+
from app.llm.base import ProviderError, ToolCall
|
| 11 |
from app.models import ChatMessage
|
| 12 |
from app.prompts import build_system_prompt
|
| 13 |
from app.schemas import ChatResponse
|
|
|
|
| 61 |
used_tools: list[str] = []
|
| 62 |
reply = FALLBACK_REPLY
|
| 63 |
|
| 64 |
+
async def _chat(tools):
|
| 65 |
+
"""Resilient call: if tool-calling fails (e.g. Groq tool_use_failed) or a
|
| 66 |
+
provider errors, degrade to a no-tools answer so we never 500."""
|
| 67 |
+
try:
|
| 68 |
+
return await router.chat(messages=messages, tools=tools, tier="large")
|
| 69 |
+
except ProviderError:
|
| 70 |
+
if not tools:
|
| 71 |
+
return None
|
| 72 |
+
try:
|
| 73 |
+
return await router.chat(messages=messages, tools=[], tier="large")
|
| 74 |
+
except ProviderError:
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
for _ in range(MAX_TOOL_ITERS):
|
| 78 |
+
result = await _chat(registry.specs())
|
| 79 |
+
if result is None:
|
| 80 |
+
reply = FALLBACK_REPLY
|
| 81 |
+
break
|
| 82 |
if result.tool_calls:
|
| 83 |
messages.append(
|
| 84 |
{
|
|
|
|
| 102 |
break
|
| 103 |
else:
|
| 104 |
# Tool budget exhausted: force a final answer with no tools.
|
| 105 |
+
final = await _chat([])
|
| 106 |
+
reply = (final.content if final else None) or FALLBACK_REPLY
|
| 107 |
|
| 108 |
ctx.db.add(ChatMessage(session_id=ctx.session.id, role="assistant", content=reply))
|
| 109 |
await ctx.db.flush()
|
tests/test_orchestrator.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
-
from app.llm.base import ChatResult, ToolCall
|
| 6 |
from app.models import ChatMessage, ChatSession
|
| 7 |
from app.orchestrator import run_turn
|
| 8 |
from app.tools.registry import ToolContext
|
|
@@ -87,6 +87,41 @@ async def test_order_turn_unverified_asks_for_credentials(db_session):
|
|
| 87 |
assert resp.used_tools == ["lookup_order"]
|
| 88 |
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
async def test_tool_budget_exhausted_forces_final_answer(db_session):
|
| 91 |
# Always returns tool_calls; orchestrator must stop and force a final answer.
|
| 92 |
loop_result = ChatResult(
|
|
|
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
+
from app.llm.base import ChatResult, ProviderError, ToolCall
|
| 6 |
from app.models import ChatMessage, ChatSession
|
| 7 |
from app.orchestrator import run_turn
|
| 8 |
from app.tools.registry import ToolContext
|
|
|
|
| 87 |
assert resp.used_tools == ["lookup_order"]
|
| 88 |
|
| 89 |
|
| 90 |
+
class _ToolFailRouter:
|
| 91 |
+
"""Fails (ProviderError) whenever tools are sent; answers when no tools."""
|
| 92 |
+
|
| 93 |
+
def __init__(self, reply="Respuesta sin herramientas."):
|
| 94 |
+
self.reply = reply
|
| 95 |
+
self.calls_with_tools = 0
|
| 96 |
+
|
| 97 |
+
async def chat(self, messages, tools, tier="large", temperature=None):
|
| 98 |
+
if tools:
|
| 99 |
+
self.calls_with_tools += 1
|
| 100 |
+
raise ProviderError("tool_use_failed")
|
| 101 |
+
return ChatResult(content=self.reply, tool_calls=[], finish_reason="stop")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class _AlwaysFailRouter:
|
| 105 |
+
async def chat(self, messages, tools, tier="large", temperature=None):
|
| 106 |
+
raise ProviderError("down")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def test_degrades_to_no_tools_when_tool_calling_fails(db_session):
|
| 110 |
+
router = _ToolFailRouter()
|
| 111 |
+
ctx = ToolContext(db=db_session, session=await _session(db_session))
|
| 112 |
+
resp = await run_turn(router, ctx, "¿qué vendéis?")
|
| 113 |
+
assert resp.reply == "Respuesta sin herramientas."
|
| 114 |
+
assert router.calls_with_tools >= 1 # it tried tools first, then degraded
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
async def test_total_provider_failure_returns_fallback_not_500(db_session):
|
| 118 |
+
from app.orchestrator import FALLBACK_REPLY
|
| 119 |
+
|
| 120 |
+
ctx = ToolContext(db=db_session, session=await _session(db_session))
|
| 121 |
+
resp = await run_turn(_AlwaysFailRouter(), ctx, "hola")
|
| 122 |
+
assert resp.reply == FALLBACK_REPLY
|
| 123 |
+
|
| 124 |
+
|
| 125 |
async def test_tool_budget_exhausted_forces_final_answer(db_session):
|
| 126 |
# Always returns tool_calls; orchestrator must stop and force a final answer.
|
| 127 |
loop_result = ChatResult(
|