Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ What `/` promises today (`frontend/src/app/page.tsx`) that the app can't do yet:
- [ ] Suggested questions ignore `use_case`; no test that another user can't fetch your avatar.

## 5. AI engineering (see `docs/direction/PROGRESS.md` → Deferred)
- [x] Bring your own model keys: Settings page, encrypted per-user keys, free allowance on the platform key (SPEC-BYOK, 2026-10-20).
- [x] Live exchange-rate tool and follow-up suggestions in Ask Lumen (SPEC-CHAT-TOOLS, 2026-10-15).
- [x] The Ask Lumen audit as a 30-question eval with an offline grader (`ask_lumen`, 2026-10-17).
- [ ] **Owner: record the `ask_lumen` eval** with a real Groq key and `TAVILY_API_KEY` unset (command in
Expand Down
10 changes: 10 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ SUPABASE_JWT_AUD=authenticated
# Email encryption (optional — falls back to SECRET_KEY)
# EMAIL_ENCRYPTION_KEY=generate-a-long-random-string

# Users' own model keys (Settings page). Encrypts them at rest: a random 32+ character value. Changing it makes
# stored keys unreadable and users must re-add them. Without it, saving a key returns 503.
# KEYS_ENCRYPTION_KEY=generate-a-long-random-string
# Free allowance on Lumen's keys for users who haven't added their own
# PLATFORM_QUESTIONS_PER_DAY=20
# PLATFORM_INVOICE_READS=20
# Point the OpenAI / Anthropic providers at a test server (the tests do this; leave unset otherwise)
# LUMEN_OPENAI_BASE_URL=
# LUMEN_ANTHROPIC_BASE_URL=

# Upload limits
MAX_UPLOAD_MB=10

Expand Down
4 changes: 2 additions & 2 deletions backend/agent/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ def agent(state: AgentState) -> dict:
out_of_calls = state["tool_calls"] >= max_tool_calls
if out_of_calls:
messages.append({"role": "user", "content": FORCE_ANSWER})
result = complete(messages, tools=None if out_of_calls else schemas, temperature=0, max_tokens=900,
timeout=40, retries=1)
result = complete(messages, role="agent", tools=None if out_of_calls else schemas, temperature=0,
max_tokens=900, timeout=40, retries=1)
calls = [] if out_of_calls else (result.tool_calls or [])
for call in calls:
call.setdefault("id", f"call_{uuid.uuid4().hex[:12]}")
Expand Down
53 changes: 42 additions & 11 deletions backend/agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,19 +458,44 @@ def _post_tavily(body: dict, key: str) -> requests.Response:
return r


def check_tavily_key(key: str) -> tuple[bool, str]:
"""One basic search (costs one Tavily credit). Only the status goes in the message, never the reply."""
try:
r = requests.post(TAVILY, json={"query": "test", "search_depth": "basic", "max_results": 1,
"include_answer": False},
headers={"Authorization": f"Bearer {key}"}, timeout=8)
except requests.RequestException as e:
return False, f"could not reach Tavily ({type(e).__name__})."
if r.status_code != 200:
return False, f"Tavily rejected the key (HTTP {r.status_code})."
return True, "Tavily key OK."


def _web_search(ctx: ToolContext, args: WebSearchArgs) -> dict:
def used(key: str) -> int:
day, n = _web_searches.get(key, (ctx.today, 0))
return n if day == ctx.today else 0
mine, everyone = used(ctx.user_id), used("*") # "*": every account (user ids are uuids)
if mine >= WEB_SEARCH_PER_DAY:
return {"error": f"The daily web-search limit ({WEB_SEARCH_PER_DAY}) is reached; answer without it and say so."}
if everyone >= WEB_SEARCH_GLOBAL_PER_DAY:
return {"error": "Lumen's daily web-search limit is reached; answer without it and say so."}
_web_searches[ctx.user_id], _web_searches["*"] = (ctx.today, mine + 1), (ctx.today, everyone + 1)
from llm import keys

rejected = {"error": "Your Tavily key was rejected; update it in Settings. Answer without web search and say so."}
key = keys.user_key("tavily")
if key == keys.UNREADABLE:
keys.mark_failed("tavily", keys.UNREADABLE_DETAIL)
return rejected
if not key: # the platform key: capped per account and overall
def used(k: str) -> int:
day, n = _web_searches.get(k, (ctx.today, 0))
return n if day == ctx.today else 0
mine, everyone = used(ctx.user_id), used("*") # "*": every account (user ids are uuids)
if mine >= WEB_SEARCH_PER_DAY:
return {"error": f"The daily web-search limit ({WEB_SEARCH_PER_DAY}) is reached; answer without it and "
"say so."}
if everyone >= WEB_SEARCH_GLOBAL_PER_DAY:
return {"error": "Lumen's daily web-search limit is reached; answer without it and say so."}
_web_searches[ctx.user_id], _web_searches["*"] = (ctx.today, mine + 1), (ctx.today, everyone + 1)
try:
r = _post_tavily({"query": args.query, "search_depth": "basic", "max_results": 5, "include_answer": False},
os.environ.get("TAVILY_API_KEY", ""))
key or os.environ.get("TAVILY_API_KEY", ""))
if key and r.status_code in (401, 403):
keys.mark_failed("tavily", f"Tavily rejected the key (HTTP {r.status_code}).")
return rejected
r.raise_for_status()
results = [{"id": f"web:{ctx.web_ids + i}", "title": str(x.get("title") or "")[:120], "url": x["url"],
"site": urlparse(x["url"]).hostname or "", "published": x.get("published_date"),
Expand All @@ -483,6 +508,8 @@ def used(key: str) -> int:
while len(json.dumps(out, default=str)) >= MAX_TOOL_RESULT_CHARS:
out["results"].pop() # long URLs: drop the last result, never cut mid-JSON (a cut one could be cited)
ctx.web_ids += len(out["results"])
if key:
keys.note_ok("tavily")
return out
except (requests.RequestException, KeyError, ValueError, TypeError, AttributeError) as e:
status = getattr(getattr(e, "response", None), "status_code", None)
Expand Down Expand Up @@ -533,7 +560,11 @@ def _income_tax_india(ctx: ToolContext, args: IncomeTaxArgs) -> dict:


def _available(name: str) -> bool:
return name in TOOLS and (name != "web_search" or bool(os.environ.get("TAVILY_API_KEY")))
if name != "web_search":
return name in TOOLS
from llm import keys

return bool(os.environ.get("TAVILY_API_KEY") or keys.user_key("tavily"))


def _clean(schema: dict) -> dict:
Expand Down
34 changes: 16 additions & 18 deletions backend/api/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from sqlalchemy.orm import Session

from api.deps import current_user, rate_limit
from llm import keys
from llm.errors import LLMError

router = APIRouter(prefix="/api/agent", tags=["agent"])

Expand Down Expand Up @@ -53,38 +55,34 @@ class Question(BaseModel):
question: str = Field(min_length=1, max_length=1000)


DEMO_QUESTIONS_PER_DAY = 20 # all visitors share one free Groq quota (~1K requests/day; SPEC-DEPLOY)


def count_demo_question(claims: dict, authorization: str, client: str) -> bool:
"""One daily allowance per demo (anonymous) account, shared by /chat and /api/agent/ask. False = used up."""
def count_platform_question(claims: dict, authorization: str, client: str) -> bool:
"""Daily free allowance on the platform key for any user without their own agent key (demo or signed up),
shared by /chat and /api/agent/ask. Needs the `keys.for_user` context. False = used up."""
from limits import parse

from config import Config
from utils.limiter import limiter, rate_limit_key

if not claims.get("is_anonymous") or not limiter.enabled:
if not limiter.enabled or keys.has_key("agent"):
return True
return limiter.limiter.hit(parse(f"{DEMO_QUESTIONS_PER_DAY} per day"), rate_limit_key(authorization, client),
"demo-questions")


def demo_quota(request: Request, claims: dict = Depends(current_user)) -> None:
"""Anonymous demo visitors get a daily question cap; signed-up accounts don't."""
if not count_demo_question(claims, request.headers.get("Authorization", ""),
request.client.host if request.client else "127.0.0.1"):
raise HTTPException(429, f"The demo allows {DEMO_QUESTIONS_PER_DAY} questions a day. Sign up to keep going.")
return limiter.limiter.hit(parse(f"{Config.PLATFORM_QUESTIONS_PER_DAY} per day"),
rate_limit_key(authorization, client), "platform-questions")


@router.post("/ask", dependencies=[Depends(rate_limit("10 per minute")), Depends(demo_quota)])
def ask(body: Question, claims: dict = Depends(current_user), deps: AgentDeps = Depends(get_agent_deps)):
@router.post("/ask", dependencies=[Depends(rate_limit("10 per minute"))])
def ask(body: Question, request: Request, claims: dict = Depends(current_user), deps: AgentDeps = Depends(get_agent_deps)):
from agent.graph import run_agent
from agent.tools import ToolContext
from api.profile import profile_for

ctx = ToolContext(user_id=claims["sub"], engine=deps.engine, sql_agent=deps.sql_agent, rag=deps.rag,
today=deps.today or date.today(), profile=profile_for(deps.engine, claims["sub"]),
question=body.question)
out = run_agent(body.question, ctx, complete=deps.complete)
with keys.for_user(claims["sub"], deps.engine):
if not count_platform_question(claims, request.headers.get("Authorization", ""),
request.client.host if request.client else "127.0.0.1"):
raise LLMError(LLMError.NEEDS_KEY, "free question allowance used") # 402 needs_key (api/errors.py)
out = run_agent(body.question, ctx, complete=deps.complete)
return {"success": True, **out}


Expand Down
14 changes: 11 additions & 3 deletions backend/api/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
limits and error bodies come from api/deps.py and api/errors.py (API-01 parity)."""
from __future__ import annotations

from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from pydantic import BaseModel, Field

from api.deps import current_user, rate_limit
from config import Config
from llm import keys
from llm.errors import LLMError
from rag.service import DOC_TYPES, RagService, get_service

from utils import files
Expand Down Expand Up @@ -66,7 +68,13 @@ def search(body: Query, claims: dict = Depends(current_user), rag: RagService =


@router.post("/ask", dependencies=[Depends(rate_limit("10 per minute"))])
def ask(body: Question, claims: dict = Depends(current_user), rag: RagService = Depends(get_service)):
out = rag.ask(claims["sub"], body.question)
def ask(body: Question, request: Request, claims: dict = Depends(current_user), rag: RagService = Depends(get_service)):
from api.agent import count_platform_question

with keys.for_user(claims["sub"], rag.store.engine):
if not count_platform_question(claims, request.headers.get("Authorization", ""),
request.client.host if request.client else "127.0.0.1"):
raise LLMError(LLMError.NEEDS_KEY, "free question allowance used") # 402 needs_key (api/errors.py)
out = rag.ask(claims["sub"], body.question)
return {"success": True, "answer": out["answer"], "abstained": out["abstained"], "reason": out["reason"],
"sources": out["sources"]}
6 changes: 3 additions & 3 deletions backend/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from starlette.exceptions import HTTPException

from api.deps import AuthFailed
from utils.errors import llm_error_status
from utils.errors import llm_error_status, llm_message
from utils.llm import LLMError

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -37,14 +37,14 @@ async def http_error(_request: Request, e: HTTPException):

@app.exception_handler(RequestValidationError)
async def invalid_request(_request: Request, e: RequestValidationError):
logger.info("invalid request: %s", e.errors())
logger.info("invalid request: %s", [{k: v for k, v in err.items() if k != "input"} for err in e.errors()]) # input may be a secret
return _error("Invalid request", 422, "invalid_request")

@app.exception_handler(LLMError)
async def llm_error(request: Request, e: LLMError):
status, code, key = llm_error_status(e)
logger.error("%s: LLM %s: %s", request.url.path, e.kind, e.detail)
return _error(LLM_MESSAGES[key], status, code)
return _error(llm_message(e, key, LLM_MESSAGES), status, code)

@app.exception_handler(Exception)
async def unexpected(request: Request, e: Exception):
Expand Down
142 changes: 142 additions & 0 deletions backend/api/keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""SPEC-BYOK: a user's own model keys: add (validated, encrypted), list (last 4 only), test and remove."""
from __future__ import annotations

import json
from datetime import datetime

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session

from api.deps import current_user, rate_limit
from api.profile import get_engine
from llm import keys
from llm.registry import BYOK_PROVIDERS
from utils.crypto import decrypt_secret, encrypt_secret

router = APIRouter(prefix="/api/keys", tags=["keys"])
ROLES = ("agent", "text", "vision")
PROVIDERS_ALL = (*BYOK_PROVIDERS, "tavily") # tavily: web search only, no models


class KeyIn(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
key: str = Field(min_length=8, max_length=400)
models: dict[str, str] | None = None


def _catalogue(provider: str) -> dict[str, list[str]]:
from llm.client import registry

if provider == "tavily":
return {}
return {role: [e.model for e in registry().byok(provider, role)] for role in ROLES}


def _provider(provider: str) -> str:
if provider not in PROVIDERS_ALL:
raise HTTPException(404, "Unknown provider.")
return provider


def _row(session: Session, user_id: str, provider: str):
from models import UserKey

return session.scalars(select(UserKey).where(UserKey.user_id == user_id, UserKey.provider == provider)).first()


def _key(row) -> str | None:
"""The decrypted key, or None when it can't be decrypted (the encryption key changed)."""
key = decrypt_secret(row.key_enc, key_env=keys.KEY_ENV)
return None if key.startswith("enc:") else key


def _public(row) -> dict:
"""What the UI may see about a stored key: never the key itself."""
return {"last4": keys.last4(_key(row) or ""), "created_at": row.created_at, "last_ok_at": row.last_ok_at,
"last_error": row.last_error, "models": json.loads(row.models or "{}")}


def _check(provider: str, key: str) -> tuple[bool, str]:
from agent.tools import check_tavily_key
from llm.providers import PROVIDERS

return check_tavily_key(key) if provider == "tavily" else PROVIDERS[provider].check_key(key=key)


def _fail(status: int, message: str, code: str) -> JSONResponse:
return JSONResponse({"success": False, "error": message, "code": code}, status)


@router.get("", dependencies=[Depends(rate_limit())])
def list_keys(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
from models import UserKey

with Session(engine) as s:
rows = {r.provider: r for r in s.scalars(select(UserKey).where(UserKey.user_id == claims["sub"]))}
providers = [{"provider": p, "name": keys.PROVIDER_NAMES[p], "models": _catalogue(p),
"key": _public(rows[p]) if p in rows else None} for p in PROVIDERS_ALL]
with keys.for_user(claims["sub"], engine):
in_use = {}
for role in ROLES:
chain = keys.user_chain(role)
in_use[role] = f"{chain[0].provider}:{chain[0].model}" if chain else "platform"
return {"success": True, "providers": providers, "in_use": in_use}


@router.put("/{provider}", dependencies=[Depends(rate_limit("5 per minute"))])
def put_key(provider: str, body: KeyIn, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
from models import UserKey

if claims.get("is_anonymous"):
raise HTTPException(403, "Demo accounts can't save keys. Sign up to add yours.")
catalogue = _catalogue(_provider(provider))
picks = body.models or {}
if any(model not in catalogue.get(role, []) for role, model in picks.items()):
raise HTTPException(422, "That model isn't offered for this provider.")
key_enc = encrypt_secret(body.key, key_env=keys.KEY_ENV)
if not key_enc.startswith("enc:"):
return _fail(503, "Saving keys isn't available right now.", "keys_unavailable")
ok, message = _check(provider, body.key)
if not ok:
return _fail(400, keys.mask(message, body.key), "key_invalid")
with Session(engine) as s:
row = _row(s, claims["sub"], provider)
if row is None:
row = UserKey(user_id=claims["sub"], provider=provider)
s.add(row)
row.key_enc, row.models = key_enc, json.dumps(picks) if picks else None
row.last_ok_at, row.last_error = datetime.utcnow(), None
s.commit()
return {"success": True, "key": _public(row)}


@router.post("/{provider}/test", dependencies=[Depends(rate_limit("5 per minute"))])
def test_key(provider: str, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
with Session(engine) as s:
row = _row(s, claims["sub"], _provider(provider))
if row is None:
raise HTTPException(404, "No key saved for this provider.")
key = _key(row)
ok, message = _check(provider, key) if key else (False, keys.UNREADABLE_DETAIL)
message = keys.mask(message, key)
if not ok:
row.last_error = message[:500]
s.commit()
if ok:
keys.mark_ok(engine, claims["sub"], provider)
return {"success": ok, "message": message}


@router.delete("/{provider}", dependencies=[Depends(rate_limit())])
def delete_key(provider: str, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
with Session(engine) as s:
row = _row(s, claims["sub"], _provider(provider))
if row is None:
raise HTTPException(404, "No key saved for this provider.")
s.delete(row)
s.commit()
return {"success": True}
12 changes: 12 additions & 0 deletions backend/api/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,19 @@ def read_with_feedback(engine: Engine, user_id: str, image_base64: str, media_ty
"""Read an invoice; if the user has corrected this vendor's invoices before, read it again with those
corrections as hints (one extra call, only for vendors with a correction history). The hints used are kept on
the invoice (`feedback_hints`) so the reviewer can see them."""
from sqlalchemy import func

from config import Config
from extract import read
from llm import keys
from models import ReviewItem
from utils.llm import LLMError

if not keys.has_key("vision"):
with Session(engine) as session:
used = session.scalar(select(func.count()).select_from(ReviewItem).where(ReviewItem.user_id == user_id))
if used >= Config.PLATFORM_INVOICE_READS:
raise LLMError(LLMError.NEEDS_KEY, "free invoice reads used up")

inv = read.read_invoice(image_base64, media_type)
hints = corrections_for(engine, user_id, inv.get("vendor_name"))
Expand Down
Loading
Loading