multi-key failover + grounding + isolation
Browse files- app/config.py +3 -2
- app/llm/base.py +9 -0
- app/llm/router.py +23 -12
- app/orchestrator.py +22 -7
- app/prompts.py +32 -21
- app/tools/knowledge_tool.py +1 -1
- app/tools/order_tool.py +3 -2
- tests/llm/test_groq.py +16 -0
- tests/llm/test_router.py +28 -0
- tests/test_multitenant.py +36 -0
- tests/test_prompts.py +1 -1
app/config.py
CHANGED
|
@@ -15,8 +15,9 @@ class Settings(BaseSettings):
|
|
| 15 |
database_url: str = "sqlite+aiosqlite://"
|
| 16 |
testing: bool = False
|
| 17 |
|
| 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).
|
|
@@ -67,7 +68,7 @@ class Settings(BaseSettings):
|
|
| 67 |
smtp_password: str = ""
|
| 68 |
smtp_from: str = ""
|
| 69 |
|
| 70 |
-
@field_validator("llm_provider_order", "allowed_origins", mode="before")
|
| 71 |
@classmethod
|
| 72 |
def _split_csv(cls, v: object) -> object:
|
| 73 |
if isinstance(v, str):
|
|
|
|
| 15 |
database_url: str = "sqlite+aiosqlite://"
|
| 16 |
testing: bool = False
|
| 17 |
|
| 18 |
+
# LLM: Groq (primary). Multiple keys for redundancy: GROQ_API_KEYS=k1,k2,k3
|
| 19 |
groq_api_key: str = ""
|
| 20 |
+
groq_api_keys: Annotated[list[str], NoDecode] = []
|
| 21 |
groq_base_url: str = "https://api.groq.com/openai/v1"
|
| 22 |
# gpt-oss-120b: stable on Groq + reliable tool calling (Llama 3.3 70B
|
| 23 |
# intermittently returns tool_use_failed on Groq).
|
|
|
|
| 68 |
smtp_password: str = ""
|
| 69 |
smtp_from: str = ""
|
| 70 |
|
| 71 |
+
@field_validator("llm_provider_order", "allowed_origins", "groq_api_keys", mode="before")
|
| 72 |
@classmethod
|
| 73 |
def _split_csv(cls, v: object) -> object:
|
| 74 |
if isinstance(v, str):
|
app/llm/base.py
CHANGED
|
@@ -50,6 +50,8 @@ class ChatResult:
|
|
| 50 |
content: str | None
|
| 51 |
tool_calls: list[ToolCall] = field(default_factory=list)
|
| 52 |
finish_reason: str | None = None
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
class LLMProvider(Protocol):
|
|
@@ -81,10 +83,17 @@ def parse_openai_choice(data: dict[str, Any]) -> ChatResult:
|
|
| 81 |
calls.append(
|
| 82 |
ToolCall(id=c.get("id", ""), name=fn.get("name", ""), arguments=args or {})
|
| 83 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
return ChatResult(
|
| 85 |
content=msg.get("content"),
|
| 86 |
tool_calls=calls,
|
| 87 |
finish_reason=choice.get("finish_reason"),
|
|
|
|
| 88 |
)
|
| 89 |
|
| 90 |
|
|
|
|
| 50 |
content: str | None
|
| 51 |
tool_calls: list[ToolCall] = field(default_factory=list)
|
| 52 |
finish_reason: str | None = None
|
| 53 |
+
usage: dict[str, int] = field(default_factory=dict) # prompt/completion/total tokens
|
| 54 |
+
provider: str | None = None
|
| 55 |
|
| 56 |
|
| 57 |
class LLMProvider(Protocol):
|
|
|
|
| 83 |
calls.append(
|
| 84 |
ToolCall(id=c.get("id", ""), name=fn.get("name", ""), arguments=args or {})
|
| 85 |
)
|
| 86 |
+
raw_usage = data.get("usage") or {}
|
| 87 |
+
usage = {
|
| 88 |
+
"prompt_tokens": int(raw_usage.get("prompt_tokens") or 0),
|
| 89 |
+
"completion_tokens": int(raw_usage.get("completion_tokens") or 0),
|
| 90 |
+
"total_tokens": int(raw_usage.get("total_tokens") or 0),
|
| 91 |
+
}
|
| 92 |
return ChatResult(
|
| 93 |
content=msg.get("content"),
|
| 94 |
tool_calls=calls,
|
| 95 |
finish_reason=choice.get("finish_reason"),
|
| 96 |
+
usage=usage,
|
| 97 |
)
|
| 98 |
|
| 99 |
|
app/llm/router.py
CHANGED
|
@@ -45,12 +45,14 @@ class LLMRouter:
|
|
| 45 |
if not model:
|
| 46 |
continue
|
| 47 |
try:
|
| 48 |
-
|
| 49 |
messages=messages,
|
| 50 |
tools=tools,
|
| 51 |
model=model,
|
| 52 |
temperature=self.temperature if temperature is None else temperature,
|
| 53 |
)
|
|
|
|
|
|
|
| 54 |
except ProviderError as exc:
|
| 55 |
log.warning("LLM provider %s failed (%s); failing over", name, exc)
|
| 56 |
last_exc = exc
|
|
@@ -59,28 +61,37 @@ class LLMRouter:
|
|
| 59 |
|
| 60 |
|
| 61 |
def build_router_from_settings(settings: Any) -> LLMRouter:
|
| 62 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
from app.llm.cloudflare import CloudflareProvider
|
| 64 |
from app.llm.groq import GroqProvider
|
| 65 |
|
| 66 |
providers: dict[str, LLMProvider] = {}
|
| 67 |
models: dict[str, dict[str, str]] = {}
|
|
|
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
)
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
if settings.cloudflare_account_id and settings.cloudflare_api_token:
|
| 76 |
providers["cloudflare"] = CloudflareProvider(
|
| 77 |
account_id=settings.cloudflare_account_id,
|
| 78 |
api_token=settings.cloudflare_api_token,
|
| 79 |
)
|
| 80 |
-
models["cloudflare"] = {
|
| 81 |
-
|
| 82 |
-
"large": settings.cf_model_large,
|
| 83 |
-
}
|
| 84 |
|
| 85 |
-
order = [p for p in settings.llm_provider_order if p in providers]
|
| 86 |
return LLMRouter(providers=providers, order=order, models=models)
|
|
|
|
| 45 |
if not model:
|
| 46 |
continue
|
| 47 |
try:
|
| 48 |
+
result = await provider.chat(
|
| 49 |
messages=messages,
|
| 50 |
tools=tools,
|
| 51 |
model=model,
|
| 52 |
temperature=self.temperature if temperature is None else temperature,
|
| 53 |
)
|
| 54 |
+
result.provider = name
|
| 55 |
+
return result
|
| 56 |
except ProviderError as exc:
|
| 57 |
log.warning("LLM provider %s failed (%s); failing over", name, exc)
|
| 58 |
last_exc = exc
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
def build_router_from_settings(settings: Any) -> LLMRouter:
|
| 64 |
+
"""Build an LLMRouter with one entry PER Groq key (redundancy) + Cloudflare.
|
| 65 |
+
|
| 66 |
+
The router tries each in order and fails over on any error, so as long as a
|
| 67 |
+
single key/provider is healthy the bot answers. Add keys via GROQ_API_KEYS.
|
| 68 |
+
"""
|
| 69 |
from app.llm.cloudflare import CloudflareProvider
|
| 70 |
from app.llm.groq import GroqProvider
|
| 71 |
|
| 72 |
providers: dict[str, LLMProvider] = {}
|
| 73 |
models: dict[str, dict[str, str]] = {}
|
| 74 |
+
order: list[str] = []
|
| 75 |
|
| 76 |
+
# Collect Groq keys (single + list), de-duplicated, preserving order.
|
| 77 |
+
groq_keys: list[str] = []
|
| 78 |
+
for key in [settings.groq_api_key, *settings.groq_api_keys]:
|
| 79 |
+
key = (key or "").strip()
|
| 80 |
+
if key and key not in groq_keys:
|
| 81 |
+
groq_keys.append(key)
|
| 82 |
+
|
| 83 |
+
for i, key in enumerate(groq_keys):
|
| 84 |
+
name = f"groq{i}"
|
| 85 |
+
providers[name] = GroqProvider(api_key=key, base_url=settings.groq_base_url)
|
| 86 |
+
models[name] = {"small": settings.model_small, "large": settings.model_large}
|
| 87 |
+
order.append(name)
|
| 88 |
|
| 89 |
if settings.cloudflare_account_id and settings.cloudflare_api_token:
|
| 90 |
providers["cloudflare"] = CloudflareProvider(
|
| 91 |
account_id=settings.cloudflare_account_id,
|
| 92 |
api_token=settings.cloudflare_api_token,
|
| 93 |
)
|
| 94 |
+
models["cloudflare"] = {"small": settings.cf_model_small, "large": settings.cf_model_large}
|
| 95 |
+
order.append("cloudflare")
|
|
|
|
|
|
|
| 96 |
|
|
|
|
| 97 |
return LLMRouter(providers=providers, order=order, models=models)
|
app/orchestrator.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
|
|
|
| 6 |
from typing import Any
|
| 7 |
|
| 8 |
from sqlalchemy import select
|
|
@@ -14,6 +15,8 @@ from app.schemas import ChatResponse
|
|
| 14 |
from app.tools import registry
|
| 15 |
from app.tools.registry import ToolContext
|
| 16 |
|
|
|
|
|
|
|
| 17 |
HISTORY_LIMIT = 10
|
| 18 |
MAX_TOOL_ITERS = 4
|
| 19 |
FALLBACK_REPLY = "Lo siento, ahora mismo no he podido completar la consulta. ¿Puedes reformularla?"
|
|
@@ -60,19 +63,26 @@ async def run_turn(
|
|
| 60 |
|
| 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 |
-
|
| 69 |
except ProviderError:
|
| 70 |
-
if
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
for _ in range(MAX_TOOL_ITERS):
|
| 78 |
result = await _chat(registry.specs())
|
|
@@ -108,6 +118,11 @@ async def run_turn(
|
|
| 108 |
ctx.db.add(ChatMessage(session_id=ctx.session.id, role="assistant", content=reply))
|
| 109 |
await ctx.db.flush()
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
return ChatResponse(
|
| 112 |
session_id=ctx.session.id, reply=reply, lang=ctx.session.lang, used_tools=used_tools
|
| 113 |
)
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
| 6 |
+
import logging
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
from sqlalchemy import select
|
|
|
|
| 15 |
from app.tools import registry
|
| 16 |
from app.tools.registry import ToolContext
|
| 17 |
|
| 18 |
+
log = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
HISTORY_LIMIT = 10
|
| 21 |
MAX_TOOL_ITERS = 4
|
| 22 |
FALLBACK_REPLY = "Lo siento, ahora mismo no he podido completar la consulta. ¿Puedes reformularla?"
|
|
|
|
| 63 |
|
| 64 |
used_tools: list[str] = []
|
| 65 |
reply = FALLBACK_REPLY
|
| 66 |
+
total_tokens = 0
|
| 67 |
+
provider_used: str | None = None
|
| 68 |
|
| 69 |
async def _chat(tools):
|
| 70 |
"""Resilient call: if tool-calling fails (e.g. Groq tool_use_failed) or a
|
| 71 |
provider errors, degrade to a no-tools answer so we never 500."""
|
| 72 |
+
nonlocal total_tokens, provider_used
|
| 73 |
+
result = None
|
| 74 |
try:
|
| 75 |
+
result = await router.chat(messages=messages, tools=tools, tier="large")
|
| 76 |
except ProviderError:
|
| 77 |
+
if tools:
|
| 78 |
+
try:
|
| 79 |
+
result = await router.chat(messages=messages, tools=[], tier="large")
|
| 80 |
+
except ProviderError:
|
| 81 |
+
result = None
|
| 82 |
+
if result is not None:
|
| 83 |
+
total_tokens += result.usage.get("total_tokens", 0)
|
| 84 |
+
provider_used = result.provider or provider_used
|
| 85 |
+
return result
|
| 86 |
|
| 87 |
for _ in range(MAX_TOOL_ITERS):
|
| 88 |
result = await _chat(registry.specs())
|
|
|
|
| 118 |
ctx.db.add(ChatMessage(session_id=ctx.session.id, role="assistant", content=reply))
|
| 119 |
await ctx.db.flush()
|
| 120 |
|
| 121 |
+
log.info(
|
| 122 |
+
"chat turn tenant=%s provider=%s tools=%s tokens=%s",
|
| 123 |
+
ctx.tenant_id, provider_used, used_tools, total_tokens,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
return ChatResponse(
|
| 127 |
session_id=ctx.session.id, reply=reply, lang=ctx.session.lang, used_tools=used_tools
|
| 128 |
)
|
app/prompts.py
CHANGED
|
@@ -3,36 +3,47 @@
|
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
-
SYSTEM_TEMPLATE = """Eres {brand_name},
|
| 7 |
|
| 8 |
## Idioma
|
| 9 |
-
- Responde SIEMPRE en el mismo idioma
|
| 10 |
-
- Si el cliente escribe en portugués, usa portugués de Portugal (pt-PT).
|
| 11 |
|
| 12 |
-
##
|
| 13 |
-
|
| 14 |
-
- `
|
|
|
|
| 15 |
- `lookup_order`: estado y seguimiento de un pedido.
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
##
|
| 24 |
-
-
|
| 25 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
- Revela ÚNICAMENTE el estado del envío y el seguimiento (transportista, número, enlace, fecha estimada). NUNCA reveles la dirección completa ni datos de pago.
|
| 27 |
-
- Si
|
| 28 |
-
- Tras varios intentos fallidos queda bloqueado temporalmente; pide que lo intente más tarde.
|
| 29 |
|
| 30 |
## Honestidad y escalado
|
| 31 |
-
- Si no
|
| 32 |
|
| 33 |
## Tono y formato
|
| 34 |
-
- Cercano, profesional y
|
| 35 |
-
- El chat es estrecho: NO uses tablas. Usa frases breves o listas con guiones "-"
|
| 36 |
"""
|
| 37 |
|
| 38 |
|
|
|
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
+
SYSTEM_TEMPLATE = """Eres {brand_name}, asistente virtual de atención al cliente de esta tienda online. Tu trabajo es ayudar a los clientes de forma precisa, cercana y útil, SIEMPRE basándote en datos reales de la tienda.
|
| 7 |
|
| 8 |
## Idioma
|
| 9 |
+
- Responde SIEMPRE en el mismo idioma del último mensaje del cliente (español, portugués de Portugal pt-PT, inglés, etc.). Si cambia de idioma, cámbialo tú también.
|
|
|
|
| 10 |
|
| 11 |
+
## Cómo trabajas (lee bien y usa las herramientas SIEMPRE)
|
| 12 |
+
Tienes estas herramientas y DEBES usarlas para cualquier dato; nunca respondas de memoria:
|
| 13 |
+
- `search_knowledge`: base de conocimiento de la tienda (catálogo, fichas técnicas, PDFs, páginas web, políticas). Úsala para CUALQUIER duda de información: qué venden, especificaciones, materiales, medidas, usos, compatibilidad, instrucciones, garantía, envíos, devoluciones, pagos, datos de la empresa, recomendaciones, comparativas, etc.
|
| 14 |
+
- `search_products`: catálogo en vivo (precio y disponibilidad reales).
|
| 15 |
- `lookup_order`: estado y seguimiento de un pedido.
|
| 16 |
+
- `escalate_to_human`: pasar la consulta al equipo humano.
|
| 17 |
+
|
| 18 |
+
Reglas de uso:
|
| 19 |
+
1. Ante CUALQUIER pregunta de información o producto, llama PRIMERO a `search_knowledge` (y a `search_products` si preguntan precio o disponibilidad). Si la pregunta toca varios temas, haz varias búsquedas con términos distintos hasta tener la información.
|
| 20 |
+
2. LEE con atención TODO lo que devuelven las herramientas antes de responder y usa todos los detalles relevantes.
|
| 21 |
+
3. Reformula la búsqueda si la primera no trae lo que necesitas (prueba sinónimos o palabras clave del producto).
|
| 22 |
+
|
| 23 |
+
## REGLA DE ORO — exactitud, cero invención
|
| 24 |
+
- Responde ÚNICAMENTE con lo que aparezca en los resultados de las herramientas. Copia los datos TAL CUAL (cifras, medidas, temperaturas, materiales, precios, nombres de producto). Si la ficha dice "-10 ºC a +60 ºC", di exactamente eso.
|
| 25 |
+
- Si un dato concreto NO está en los resultados, dilo con honestidad ("no tengo ese dato exacto a mano") y ofrece consultarlo con el equipo. NUNCA rellenes huecos con conocimiento general ni te inventes productos, precios o especificaciones.
|
| 26 |
+
- Para recomendaciones o comparativas, básate SOLO en los productos/datos recuperados. Si falta información, pregunta o deriva al equipo.
|
| 27 |
+
|
| 28 |
+
## Tipos de pregunta (sirve para cualquiera)
|
| 29 |
+
- Pregunta concreta (un dato): da el dato exacto del documento.
|
| 30 |
+
- Pregunta amplia o vaga ("¿qué vendéis?", "ayuda"): busca y da un resumen claro de las categorías/productos principales reales, y ofrece profundizar.
|
| 31 |
+
- Precio/stock: usa `search_products` y da precio y disponibilidad reales (con enlace si lo hay).
|
| 32 |
+
- Varias preguntas a la vez: responde a cada parte.
|
| 33 |
+
- Fuera de tema (no es de la tienda): redirige con amabilidad hacia cómo puedes ayudar con la tienda y sus productos.
|
| 34 |
+
|
| 35 |
+
## Pedidos (privacidad + verificación flexible)
|
| 36 |
+
- Para localizar un pedido necesitas el **email** del cliente y **UN segundo dato** identificativo. Acepta cualquiera: **número de pedido**, **código postal** del envío, o **nombre y apellidos**. NO exijas el número de pedido si te dan el código postal o el nombre.
|
| 37 |
+
- Pasa a `lookup_order` el email y el segundo dato que te haya dado.
|
| 38 |
- Revela ÚNICAMENTE el estado del envío y el seguimiento (transportista, número, enlace, fecha estimada). NUNCA reveles la dirección completa ni datos de pago.
|
| 39 |
+
- Si no se localiza, dilo de forma genérica ("no he podido localizar un pedido con esos datos, revísalos por favor") sin decir qué campo falla. Tras varios intentos queda bloqueado un rato.
|
|
|
|
| 40 |
|
| 41 |
## Honestidad y escalado
|
| 42 |
+
- Si no puedes resolver algo, sé honesto y ofrece pasar la consulta al equipo: pide el email del cliente y usa `escalate_to_human`.
|
| 43 |
|
| 44 |
## Tono y formato
|
| 45 |
+
- Cercano, profesional y resolutivo.
|
| 46 |
+
- El chat es estrecho: NO uses tablas. Usa frases breves o listas con guiones "-", y resalta lo clave con **negrita**. Respuestas claras y al grano.
|
| 47 |
"""
|
| 48 |
|
| 49 |
|
app/tools/knowledge_tool.py
CHANGED
|
@@ -14,7 +14,7 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
| 14 |
query = (args.get("query") or "").strip()
|
| 15 |
if not query:
|
| 16 |
return {"context": "", "sources": [], "note": "empty query"}
|
| 17 |
-
results = await index.search(ctx.db, query, k=
|
| 18 |
if not results:
|
| 19 |
return {"context": "", "sources": [], "note": "no relevant information found"}
|
| 20 |
blocks = []
|
|
|
|
| 14 |
query = (args.get("query") or "").strip()
|
| 15 |
if not query:
|
| 16 |
return {"context": "", "sources": [], "note": "empty query"}
|
| 17 |
+
results = await index.search(ctx.db, query, k=6, tenant_id=ctx.tenant_id)
|
| 18 |
if not results:
|
| 19 |
return {"context": "", "sources": [], "note": "no relevant information found"}
|
| 20 |
blocks = []
|
app/tools/order_tool.py
CHANGED
|
@@ -43,7 +43,8 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
| 43 |
}
|
| 44 |
|
| 45 |
limiter = get_order_limiter()
|
| 46 |
-
|
|
|
|
| 47 |
return {"status": "locked"}
|
| 48 |
|
| 49 |
try:
|
|
@@ -66,5 +67,5 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
| 66 |
if not result.ok or result.matched is None:
|
| 67 |
return {"status": "not_found"}
|
| 68 |
|
| 69 |
-
limiter.reset(
|
| 70 |
return {"status": "verified", "tracking": parse_tracking(result.matched)}
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
limiter = get_order_limiter()
|
| 46 |
+
rl_key = f"{ctx.tenant_id}:{email.lower()}" # per-tenant so stores don't affect each other
|
| 47 |
+
if not limiter.allow(rl_key, ctx.now):
|
| 48 |
return {"status": "locked"}
|
| 49 |
|
| 50 |
try:
|
|
|
|
| 67 |
if not result.ok or result.matched is None:
|
| 68 |
return {"status": "not_found"}
|
| 69 |
|
| 70 |
+
limiter.reset(rl_key)
|
| 71 |
return {"status": "verified", "tracking": parse_tracking(result.matched)}
|
tests/llm/test_groq.py
CHANGED
|
@@ -61,6 +61,22 @@ async def test_groq_parses_tool_calls():
|
|
| 61 |
assert r.tool_calls[0].arguments == {"email": "a@b.c"}
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
@respx.mock
|
| 65 |
async def test_groq_429_raises_ratelimit():
|
| 66 |
respx.post(URL).mock(return_value=httpx.Response(429, text="slow down"))
|
|
|
|
| 61 |
assert r.tool_calls[0].arguments == {"email": "a@b.c"}
|
| 62 |
|
| 63 |
|
| 64 |
+
@respx.mock
|
| 65 |
+
async def test_groq_captures_token_usage():
|
| 66 |
+
respx.post(URL).mock(
|
| 67 |
+
return_value=httpx.Response(
|
| 68 |
+
200,
|
| 69 |
+
json={
|
| 70 |
+
"choices": [{"finish_reason": "stop", "message": {"content": "ok"}}],
|
| 71 |
+
"usage": {"prompt_tokens": 120, "completion_tokens": 30, "total_tokens": 150},
|
| 72 |
+
},
|
| 73 |
+
)
|
| 74 |
+
)
|
| 75 |
+
r = await _provider().chat(messages=[{"role": "user", "content": "x"}], tools=[], model="m")
|
| 76 |
+
assert r.usage["total_tokens"] == 150
|
| 77 |
+
assert r.usage["prompt_tokens"] == 120
|
| 78 |
+
|
| 79 |
+
|
| 80 |
@respx.mock
|
| 81 |
async def test_groq_429_raises_ratelimit():
|
| 82 |
respx.post(URL).mock(return_value=httpx.Response(429, text="slow down"))
|
tests/llm/test_router.py
CHANGED
|
@@ -53,6 +53,34 @@ async def test_router_failover_on_ratelimit():
|
|
| 53 |
assert cf.last_model == "cs"
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
async def test_router_raises_when_all_fail():
|
| 57 |
groq = FakeProvider(raise_exc=ProviderError("g"))
|
| 58 |
cf = FakeProvider(raise_exc=ProviderError("c"))
|
|
|
|
| 53 |
assert cf.last_model == "cs"
|
| 54 |
|
| 55 |
|
| 56 |
+
def test_build_router_multiple_groq_keys_and_cloudflare():
|
| 57 |
+
from types import SimpleNamespace
|
| 58 |
+
|
| 59 |
+
from app.llm.router import build_router_from_settings
|
| 60 |
+
|
| 61 |
+
s = SimpleNamespace(
|
| 62 |
+
groq_api_key="k1",
|
| 63 |
+
groq_api_keys=["k2", "k3", "k1"], # k1 deduped
|
| 64 |
+
groq_base_url="https://api.groq.com/openai/v1",
|
| 65 |
+
model_small="s",
|
| 66 |
+
model_large="l",
|
| 67 |
+
cloudflare_account_id="acct",
|
| 68 |
+
cloudflare_api_token="tok",
|
| 69 |
+
cf_model_small="cs",
|
| 70 |
+
cf_model_large="cl",
|
| 71 |
+
)
|
| 72 |
+
router = build_router_from_settings(s)
|
| 73 |
+
assert router.order == ["groq0", "groq1", "groq2", "cloudflare"] # 3 keys + CF
|
| 74 |
+
assert len(router.providers) == 4
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
async def test_router_tags_provider_name():
|
| 78 |
+
groq = FakeProvider(result=ChatResult(content="ok", tool_calls=[], finish_reason="stop"))
|
| 79 |
+
router = LLMRouter(providers={"groq0": groq}, order=["groq0"], models={"groq0": {"large": "l"}})
|
| 80 |
+
r = await router.chat(messages=[{"role": "user", "content": "x"}], tools=[], tier="large")
|
| 81 |
+
assert r.provider == "groq0"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
async def test_router_raises_when_all_fail():
|
| 85 |
groq = FakeProvider(raise_exc=ProviderError("g"))
|
| 86 |
cf = FakeProvider(raise_exc=ProviderError("c"))
|
tests/test_multitenant.py
CHANGED
|
@@ -81,6 +81,42 @@ async def test_create_tenant_encrypts_secret(db_session):
|
|
| 81 |
assert tenant_shopify_client(plain) is None
|
| 82 |
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
async def test_get_tenant_by_slug(db_session):
|
| 85 |
db_session.add(Tenant(slug="toorx", brand_name="T"))
|
| 86 |
await db_session.flush()
|
|
|
|
| 81 |
assert tenant_shopify_client(plain) is None
|
| 82 |
|
| 83 |
|
| 84 |
+
async def test_order_lockout_is_per_tenant(db_session):
|
| 85 |
+
"""A spammed email in tenant A must not lock that email in tenant B."""
|
| 86 |
+
from app.models import ChatSession, Tenant
|
| 87 |
+
from app.tools import order_tool
|
| 88 |
+
from app.tools.registry import ToolContext
|
| 89 |
+
|
| 90 |
+
a = Tenant(slug="ta")
|
| 91 |
+
b = Tenant(slug="tb")
|
| 92 |
+
db_session.add_all([a, b])
|
| 93 |
+
await db_session.flush()
|
| 94 |
+
|
| 95 |
+
class FakeShopify:
|
| 96 |
+
async def execute(self, query, variables=None):
|
| 97 |
+
return {"orders": {"edges": []}} # no match -> failed attempts
|
| 98 |
+
|
| 99 |
+
from app.ratelimit import get_order_limiter
|
| 100 |
+
|
| 101 |
+
get_order_limiter().clear()
|
| 102 |
+
|
| 103 |
+
async def session_for(tid):
|
| 104 |
+
s = ChatSession(shop="x", tenant_id=tid)
|
| 105 |
+
db_session.add(s)
|
| 106 |
+
await db_session.flush()
|
| 107 |
+
return s
|
| 108 |
+
|
| 109 |
+
# Exhaust attempts for email in tenant A
|
| 110 |
+
for _ in range(8):
|
| 111 |
+
ctx_a = ToolContext(db=db_session, session=await session_for(a.id), tenant_id=a.id, shopify=FakeShopify())
|
| 112 |
+
await order_tool.run({"email": "spam@x.com", "order_number": "9999"}, ctx_a)
|
| 113 |
+
|
| 114 |
+
# Same email in tenant B is NOT locked (independent counter)
|
| 115 |
+
ctx_b = ToolContext(db=db_session, session=await session_for(b.id), tenant_id=b.id, shopify=FakeShopify())
|
| 116 |
+
out_b = await order_tool.run({"email": "spam@x.com", "order_number": "9999"}, ctx_b)
|
| 117 |
+
assert out_b["status"] == "not_found" # not "locked"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
async def test_get_tenant_by_slug(db_session):
|
| 121 |
db_session.add(Tenant(slug="toorx", brand_name="T"))
|
| 122 |
await db_session.flush()
|
tests/test_prompts.py
CHANGED
|
@@ -10,7 +10,7 @@ def test_system_prompt_includes_rules_and_brand():
|
|
| 10 |
assert "mismo idioma" in p
|
| 11 |
assert "pt-PT" in p
|
| 12 |
# anti-hallucination grounding rule
|
| 13 |
-
assert "
|
| 14 |
# flexible order verification (email + order number / postal code / name)
|
| 15 |
assert "email" in p and "número de pedido" in p and "código postal" in p
|
| 16 |
assert "NUNCA" in p # no full address / payment
|
|
|
|
| 10 |
assert "mismo idioma" in p
|
| 11 |
assert "pt-PT" in p
|
| 12 |
# anti-hallucination grounding rule
|
| 13 |
+
assert "inventes" in p.lower()
|
| 14 |
# flexible order verification (email + order number / postal code / name)
|
| 15 |
assert "email" in p and "número de pedido" in p and "código postal" in p
|
| 16 |
assert "NUNCA" in p # no full address / payment
|