self-trained2 / agent /kype.py
DeepImagix's picture
Update agent/kype.py
949b5b2 verified
Raw
History Blame Contribute Delete
62 kB
"""
Kype 1.2 β€” Multi-Agent Collaboration System (production-tightened)
==================================================================
Three agents (Planner β†’ Analysis β†’ Debugger) collaborate via OpenRouter
with automatic Groq fallback, hard rate/daily/context limits, and limited
sync tool use.
Fixes over 1.1:
* Groq fallback now triggers on ANY OpenRouter failure (HTTP 4xx/5xx,
network errors, JSON errors, empty content, embedded "error" field,
finish_reason=error/content_filter, body substrings). Previously only
a narrow set of status codes triggered fallback.
* Daily limit (5/day) and context cap (5000 tokens) now HARD-BLOCK with
a visible HTTP 429 + clear message. Previously silently fell back to
Groq, which the user couldn't see.
* Tool use rewritten with SYNC implementations (web_search via DDG HTML,
fetch_url via requests, create_file via direct disk write). Previous
version reused async tools from agent.py and crashed with "Kype lost
connection" because asyncio.run couldn't bind to the worker thread's
event loop.
* CLI / ANSI colors / verbose printers removed (production FastAPI only).
File size reduced from ~1250 to ~620 lines.
* Added /kype/download/{filename} route to serve files created by
create_file tool.
Env vars:
OPENROUTE_KEY OpenRouter API key (primary)
GROQ_API_KEY Groq API key (fallback β€” required for fallback to work)
KYPE_GROQ_MODEL Groq model id (default: groq/compound)
"""
from __future__ import annotations
import os
import re
import json
import time
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
import requests
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
log = logging.getLogger("kype")
# =================================================================
# CONFIGURATION
# =================================================================
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"
DEFAULT_PLANNER_MODEL = "tencent/hy3:free"
DEFAULT_ANALYSIS_MODEL = "poolside/laguna-m.1:free"
DEFAULT_DEBUGGER_MODEL = "poolside/laguna-xs-2.1:free"
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
GROQ_MODEL = os.getenv("KYPE_GROQ_MODEL", "groq/compound")
MAX_ITERATIONS = 3
# FIX Issue 1: Was 90s β€” OpenRouter free models can hang, causing 90s wait
# before Groq fallback. Split into separate timeouts so total worst case
# is ~45s (25s OpenRouter + 20s Groq), not 180s.
OPENROUTER_TIMEOUT = 25 # Fast fail β†’ quick Groq fallback
GROQ_TIMEOUT = 30 # Groq is usually fast (2-5s); 30s is generous
REQUEST_TIMEOUT = OPENROUTER_TIMEOUT # alias for code that uses the old name
MAX_TOKENS = 4096
TEMPERATURE = 0.7
# FIX Issue 3: Simple-task fast path β€” skip the Debugger for short, simple tasks.
# Tasks under this character count AND not containing complex keywords get
# 1 iteration (Planner + Analysis only, no Debugger review). Cuts response
# time in half for simple questions like "What is 2+2?".
SIMPLE_TASK_CHAR_THRESHOLD = 150
COMPLEX_TASK_KEYWORDS = (
"design", "analyze", "write code", "create", "build", "implement",
"debug", "fix", "refactor", "architect", "plan", "strategy",
"compare", "evaluate", "research", "investigate", "document",
"essay", "report", "article", "story", "poem",
)
# Groq fallback uses reduced token budgets β€” "same instructions but limited".
GROQ_PLANNER_TOKENS = 1024
GROQ_ANALYSIS_TOKENS = 2048
GROQ_DEBUGGER_TOKENS = 512
# FIX: Groq has a request payload size limit (~32KB). When the combined
# system + user message exceeds this, Groq returns 413 "Request Entity Too
# Large". We truncate the user message to stay under this limit.
# 20KB leaves room for JSON overhead + system prompt + max_tokens response.
GROQ_MAX_PAYLOAD_CHARS = 20_000
# Hard limits (free OpenRouter tier).
KYPE_RATE_WINDOW_SECONDS = 30
KYPE_DAILY_TASK_LIMIT = 5
KYPE_CONTEXT_TOKEN_CAP = 5000
MAX_FILE_BYTES = 1_000_000
MAX_FILENAME_LEN = 128
REJECTED_FILE_PREFIXES = ("image/", "video/", "audio/")
# Limited tool allow-list (sync implementations β€” see _KYPE_TOOLS below).
# FIX Issue 4: Added document creation tools (PDF, DOCX, Excel, CSV, Markdown).
KYPE_ALLOWED_TOOLS: Tuple[str, ...] = (
"web_search", "fetch_url", "create_file",
# Document creation tools (from kype_doc_tools.py)
"create_pdf", "create_docx", "create_excel", "create_csv",
"create_markdown", "create_text_file",
)
# Substrings in any error/response body that trigger Groq fallback.
GROQ_FALLBACK_SUBSTRINGS = (
"rate limit", "rate_limit", "rate-limited", "rate limited",
"llm unavailable", "model unavailable", "no available provider",
"no provider available", "provider error", "all requests failed",
"service unavailable", "temporarily unavailable",
"overloaded", "capacity", "insufficient credits",
"no model found", "model not found",
)
# File output directory for create_file tool.
KYPE_OUTPUT_DIR = Path("/tmp/kype_outputs")
KYPE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# =================================================================
# ROLE SYSTEM PROMPTS (trimmed + identity context)
# =================================================================
# FIX Issue 2: The Debugger was rejecting answers about Kype's developer/creator
# because it didn't have that context. Now ALL roles know Kype's identity, so
# the Debugger won't flag "NeuraPrompt AI" as a potentially inaccurate claim.
KYPE_IDENTITY = """ABOUT KYPE:
- Kype is a multi-agent AI assistant made by NeuraPrompt AI.
- Developer/creator: NeuraPrompt AI (neuraprompt.ai)
- Kype uses a 3-agent pipeline: Planner β†’ Analysis β†’ Debugger.
- If asked "who made you", "who is your developer", "who created you",
answer confidently: "I was created by NeuraPrompt AI."
- Do NOT flag this as potentially inaccurate β€” it is correct."""
PLANNER_SYSTEM = f"""You are the PLANNER of Kype.
{KYPE_IDENTITY}
Produce a concise execution plan for the user's task.
Output format (strict β€” no preamble):
1. GOAL: <one sentence>
2. STEPS:
- Step 1: ...
- Step 2: ...
3. RISKS: <bullet list or "none">
If DEBUGGER FEEDBACK is provided, address every flagged issue."""
ANALYSIS_SYSTEM = f"""You are the ANALYSIS of Kype.
{KYPE_IDENTITY}
Execute the plan and deliver the full answer to the user's task.
No meta-commentary about the plan or your role β€” just the result.
If DEBUGGER FEEDBACK is provided, address each issue explicitly."""
DEBUGGER_SYSTEM = f"""You are the DEBUGGER of Kype.
{KYPE_IDENTITY}
Review the draft for factual errors, missing edge cases, vague sections, and
misalignment with the user's intent. Do NOT nitpick style.
IMPORTANT: Do NOT flag answers about Kype's own identity (NeuraPrompt AI is
the correct developer). Do NOT request revisions for missing external
knowledge the agent cannot reasonably have.
Output STRICT JSON β€” no text before or after:
{{"verdict":"approve"|"revise","issues":[{{"severity":"high|medium|low","description":"..."}}],"feedback":"<actionable feedback or empty>"}}"""
# =================================================================
# DATA CLASSES
# =================================================================
@dataclass
class AgentResponse:
role: str
model: str
content: str
elapsed: float
iteration: int
provider: str = "openrouter"
@dataclass
class KypeResult:
task: str
final_answer: str
iterations: int
approved: bool
trace: List[AgentResponse] = field(default_factory=list)
total_elapsed: float = 0.0
fallback_used: Optional[str] = None
tool_used: Optional[str] = None
usage: Dict[str, int] = field(default_factory=lambda: {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
def to_dict(self) -> Dict[str, Any]:
return {
"task": self.task,
"final_answer": self.final_answer,
"iterations": self.iterations,
"approved": self.approved,
"total_elapsed": round(self.total_elapsed, 2),
"fallback_used": self.fallback_used,
"tool_used": self.tool_used,
"usage": self.usage,
"trace": [
{
"iteration": t.iteration,
"role": t.role,
"model": t.model,
"provider": t.provider,
"elapsed": round(t.elapsed, 2),
"content": t.content,
}
for t in self.trace
],
}
def _aggregate_usage(total: dict, addition: dict) -> None:
"""Add `addition` usage into `total` (in-place)."""
total["input_tokens"] += int(addition.get("input_tokens", 0))
total["output_tokens"] += int(addition.get("output_tokens", 0))
total["total_tokens"] = total["input_tokens"] + total["output_tokens"]
# =================================================================
# HELPERS
# =================================================================
class GroqFallbackNeeded(Exception):
"""Signals 'fall back to Groq' from _call_openrouter."""
def _is_fallback_body(text: str) -> bool:
"""True if the text contains any fallback-trigger substring."""
if not text:
return False
t = text.lower()
return any(s in t for s in GROQ_FALLBACK_SUBSTRINGS)
def _estimate_tokens(text: str) -> int:
"""Rough token estimate (4 chars β‰ˆ 1 token). Over-estimates non-Latin."""
return max(1, len(text or "") // 4)
def _sanitize_filename(name: Optional[str]) -> str:
if not name:
return "attached-file"
name = name.replace("\\", "/").split("/")[-1]
name = re.sub(r"[^A-Za-z0-9._ -]", "_", name).strip("._ ")
name = name[:MAX_FILENAME_LEN]
return name or "attached-file"
def _strip_secrets(text: str) -> str:
if not text:
return ""
text = re.sub(r"sk-[A-Za-z0-9_-]{10,}", "[REDACTED]", text)
text = re.sub(r"sk-or-v1-[A-Za-z0-9_-]{10,}", "[REDACTED]", text)
text = re.sub(r"gsk_[A-Za-z0-9_-]{10,}", "[REDACTED]", text)
text = re.sub(r"Bearer\s+[A-Za-z0-9_\.\-=]+", "Bearer [REDACTED]", text)
return text
def _apply_privacy(result_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Remove model IDs from the trace; keep role, elapsed, content, provider."""
for t in result_dict.get("trace", []) or []:
t.pop("model", None)
return result_dict
# =================================================================
# SYNC TOOL IMPLEMENTATIONS (replaces agent.tools reusal β€” fixes Bug 4)
# =================================================================
def _kype_web_search(query: str) -> str:
"""Sync DuckDuckGo HTML search. Returns top 5 results as text."""
if not query or not query.strip():
return "[web_search error: empty query]"
try:
resp = requests.get(
"https://html.duckduckgo.com/html/",
params={"q": query.strip()},
headers={"User-Agent": "Mozilla/5.0 (Kype/1.2)"},
timeout=15,
)
resp.raise_for_status()
html = resp.text
# Extract result titles + snippets via regex (no BeautifulSoup dep).
results = []
# DDG HTML: <a class="result__a" href="...">title</a>
# <a class="result__snippet" ...>snippet</a>
titles = re.findall(r'class="result__a"[^>]*>([^<]+)</a>', html)
snippets = re.findall(r'class="result__snippet"[^>]*>([^<]+)</a>', html)
for i, (t, s) in enumerate(zip(titles[:5], snippets[:5]), 1):
results.append(f"{i}. {t.strip()}\n {s.strip()}")
if not results:
return f"[web_search: no results for '{query}']"
return "\n".join(results)
except Exception as e:
return f"[web_search error: {e}]"
def _kype_fetch_url(url: str) -> str:
"""Sync URL fetch. Returns first 3000 chars of visible text."""
if not url or not url.strip():
return "[fetch_url error: empty url]"
if not re.match(r"^https?://", url.strip()):
return f"[fetch_url error: invalid url '{url}']"
try:
resp = requests.get(
url.strip(),
headers={"User-Agent": "Mozilla/5.0 (Kype/1.2)"},
timeout=20,
)
resp.raise_for_status()
html = resp.text
# Strip tags (rough β€” no BS dependency).
text = re.sub(r"<script[\s\S]*?</script>", "", html, flags=re.IGNORECASE)
text = re.sub(r"<style[\s\S]*?</style>", "", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
if not text:
return f"[fetch_url: no text content at {url}]"
return text[:3000]
except Exception as e:
return f"[fetch_url error: {e}]"
def _kype_create_file(filename: str, content: str, file_type: str = "text") -> str:
"""Sync file creation. Writes to KYPE_OUTPUT_DIR, returns a download URL path."""
if not filename or not content:
return "[create_file error: filename and content are required]"
safe_name = _sanitize_filename(filename)
# Map file_type to extension if filename has none.
ext_map = {
"text": ".txt", "markdown": ".md", "python": ".py",
"json": ".json", "csv": ".csv", "html": ".html",
"css": ".css", "js": ".js",
}
if "." not in safe_name:
safe_name += ext_map.get(file_type.lower(), ".txt")
file_path = KYPE_OUTPUT_DIR / safe_name
try:
file_path.write_text(content, encoding="utf-8")
# Return a path the /kype/download/{filename} route can serve.
return f"File created. Download: /kype/download/{safe_name}"
except Exception as e:
return f"[create_file error: {e}]"
_KYPE_TOOLS = {
"web_search": _kype_web_search,
"fetch_url": _kype_fetch_url,
"create_file": _kype_create_file,
}
# FIX Issue 4: Merge in the document creation tools from kype_doc_tools.py
# These are sync implementations (PDF, DOCX, Excel, CSV, Markdown, text).
try:
from kype_doc_tools import KYPE_DOC_TOOLS
_KYPE_TOOLS.update(KYPE_DOC_TOOLS)
except ImportError:
try:
# Relative import (when used as agent.tools.kype)
from .kype_doc_tools import KYPE_DOC_TOOLS
_KYPE_TOOLS.update(KYPE_DOC_TOOLS)
except ImportError:
log.warning("[Kype] kype_doc_tools not available β€” PDF/DOCX/Excel tools disabled. "
"Install with: pip install reportlab python-docx openpyxl")
def _dispatch_kype_tool(name: str, args: dict) -> str:
"""Run a sync tool by name. Bounded to KYPE_ALLOWED_TOOLS.
No async, no event loops β€” fixes the 'Kype lost connection' bug."""
if name not in KYPE_ALLOWED_TOOLS:
return f"[Tool '{name}' is not in Kype's allow-list.]"
fn = _KYPE_TOOLS.get(name)
if fn is None:
return f"[Unknown tool: {name}]"
try:
# All Kype tools are sync β€” just call directly.
return fn(**args)
except TypeError as e:
return f"[Invalid args for {name}: {e}]"
except Exception as e:
return f"[Tool error ({name}): {e}]"
# =================================================================
# CORE AGENT
# =================================================================
class KypeAgent:
"""Kype 1.2 β€” three-model collaborative agent with Groq fallback."""
def __init__(
self,
api_key: Optional[str] = None,
planner_model: str = DEFAULT_PLANNER_MODEL,
analysis_model: str = DEFAULT_ANALYSIS_MODEL,
debugger_model: str = DEFAULT_DEBUGGER_MODEL,
max_iterations: int = MAX_ITERATIONS,
allow_tools: bool = True,
):
self.api_key = api_key or os.getenv("OPENROUTE_KEY", "")
if not self.api_key and not GROQ_API_KEY:
raise EnvironmentError(
"Neither OPENROUTE_KEY nor GROQ_API_KEY is set."
)
self.planner_model = planner_model
self.analysis_model = analysis_model
self.debugger_model = debugger_model
self.max_iterations = max(1, max_iterations)
self.allow_tools = allow_tools and bool(KYPE_ALLOWED_TOOLS)
# ---- low-level OpenRouter call (FIXED β€” falls back on ANY failure) ----
def _call_openrouter(self, model: str, system: str, user: str) -> Tuple[str, str, dict]:
"""Returns (content, 'openrouter', usage). Raises GroqFallbackNeeded on ANY
failure that should trigger Groq fallback β€” including network errors,
HTTP 4xx/5xx, embedded error fields, empty content, and body substrings.
`usage` is {"input_tokens": N, "output_tokens": N} from the API response,
or an estimate if the API didn't return usage data."""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/kype/kype-1.0",
"X-Title": "Kype 1.0",
}
payload = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"max_tokens": MAX_TOKENS,
"temperature": TEMPERATURE,
}
try:
resp = requests.post(
OPENROUTER_API_URL,
headers=headers,
json=payload,
timeout=OPENROUTER_TIMEOUT, # FIX Issue 1: 25s, not 90s
)
except requests.exceptions.RequestException as e:
# Network error, DNS failure, timeout, etc. β†’ fall back.
raise GroqFallbackNeeded(f"OpenRouter network error: {e}")
# ---- ANY HTTP error status β†’ fall back (was: only specific codes) ----
if resp.status_code >= 400:
body_snippet = (resp.text or "")[:300]
raise GroqFallbackNeeded(
f"OpenRouter HTTP {resp.status_code}: {body_snippet}"
)
# ---- Parse JSON safely ----
try:
data = resp.json()
except (ValueError, json.JSONDecodeError) as e:
raise GroqFallbackNeeded(f"OpenRouter non-JSON response: {e}")
# ---- Check for embedded error field (OpenRouter's most common error shape) ----
if isinstance(data, dict) and data.get("error"):
err = data["error"]
err_str = err.get("message", "") if isinstance(err, dict) else str(err)
raise GroqFallbackNeeded(f"OpenRouter error field: {err_str}")
# ---- Extract content ----
try:
choice = data["choices"][0]
message = choice.get("message", {}) or {}
content = (message.get("content") or "").strip()
finish_reason = choice.get("finish_reason", "")
except (KeyError, IndexError, AttributeError):
raise GroqFallbackNeeded("OpenRouter: malformed choices array")
# ---- finish_reason indicates model failure β†’ fall back ----
if finish_reason in ("error", "content_filter", "insufficient_funds"):
raise GroqFallbackNeeded(
f"OpenRouter finish_reason={finish_reason}"
)
# ---- Empty content: try reasoning, else fall back ----
if not content:
reasoning = message.get("reasoning") or message.get("reasoning_content") or ""
content = (reasoning or "").strip()
if not content:
raise GroqFallbackNeeded("OpenRouter: empty content and no reasoning")
# ---- Body substring check (e.g. "rate limit" in content) ----
if _is_fallback_body(content):
raise GroqFallbackNeeded(
f"OpenRouter body indicates LLM unavailable: {content[:120]}"
)
# ---- Extract usage (token counts) from the API response ----
usage_data = data.get("usage") or {}
usage = {
"input_tokens": int(usage_data.get("prompt_tokens", 0) or 0),
"output_tokens": int(usage_data.get("completion_tokens", 0) or 0),
}
# Fallback: estimate if the API didn't return usage
if usage["input_tokens"] == 0:
usage["input_tokens"] = _estimate_tokens(system) + _estimate_tokens(user)
if usage["output_tokens"] == 0:
usage["output_tokens"] = _estimate_tokens(content)
return content, "openrouter", usage
# ---- low-level Groq call (fallback) -----------------------------------
@staticmethod
def _truncate_for_groq(system: str, user: str) -> Tuple[str, str]:
"""Truncate the user message so the total payload fits within Groq's
request size limit (~32KB). Keeps the full system prompt (it's short)
and truncates the user message from the MIDDLE (preserves start + end)."""
total_chars = len(system) + len(user)
if total_chars <= GROQ_MAX_PAYLOAD_CHARS:
return system, user
# Calculate how much we need to cut from the user message
budget = GROQ_MAX_PAYLOAD_CHARS - len(system) - 200 # 200 for JSON overhead
if budget < 500:
budget = 500 # keep at least 500 chars of the user message
if len(user) <= budget:
return system, user
# Truncate from the middle β€” keep the first 40% and last 40%, add a marker
keep_start = int(budget * 0.4)
keep_end = int(budget * 0.4)
truncated = (
user[:keep_start]
+ f"\n\n...[truncated {len(user) - budget:,} chars to fit Groq's payload limit]...\n\n"
+ user[-keep_end:]
)
return system, truncated
def _call_groq(self, system: str, user: str, max_tokens: int) -> Tuple[str, str, dict]:
"""Single Groq call with 429 retry + 413 payload truncation.
Returns (content, 'groq', usage).
FIX: Truncates the user message before sending to avoid Groq 413
'Request Entity Too Large'. If 413 still occurs, truncates further
and retries."""
if not GROQ_API_KEY:
raise EnvironmentError("GROQ_API_KEY not configured")
# Pre-emptive truncation to avoid 413
system, user = self._truncate_for_groq(system, user)
headers = {
"Authorization": f"Bearer {GROQ_API_KEY}",
"Content-Type": "application/json",
}
last_err: Optional[Exception] = None
for attempt in range(3):
# Build payload fresh each attempt (user may be truncated on 413 retry)
payload = {
"model": GROQ_MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"max_tokens": max_tokens,
"temperature": TEMPERATURE,
}
try:
resp = requests.post(
GROQ_API_URL,
headers=headers,
json=payload,
timeout=GROQ_TIMEOUT,
)
if resp.status_code == 429:
wait = min(int(resp.headers.get("retry-after", 5 * (attempt + 1))), 15)
log.warning(f"[Kype/Groq] 429 β€” waiting {wait}s (attempt {attempt + 1}/3)")
time.sleep(wait)
last_err = RuntimeError(f"Groq 429 (attempt {attempt + 1})")
continue
# FIX: 413 Request Entity Too Large β†’ truncate and retry
if resp.status_code == 413:
log.warning(f"[Kype/Groq] 413 β€” payload too large ({len(user):,} chars), "
f"truncating further (attempt {attempt + 1}/3)")
# Aggressive truncation: cut to half the current size
new_budget = max(500, len(user) // 2)
if len(user) > new_budget:
keep_start = int(new_budget * 0.4)
keep_end = int(new_budget * 0.4)
user = (
user[:keep_start]
+ f"\n\n...[truncated for Groq payload limit]...\n\n"
+ user[-keep_end:]
)
last_err = RuntimeError(f"Groq 413 (attempt {attempt + 1})")
continue
else:
# Already very small β€” can't truncate further
last_err = RuntimeError("Groq 413 β€” payload too small to truncate further")
break
if resp.status_code >= 400:
last_err = RuntimeError(f"Groq HTTP {resp.status_code}: {resp.text[:200]}")
log.error(f"[Kype/Groq] {last_err}")
break
data = resp.json()
content = (data.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
if not content:
content = "[empty Groq response]"
# Extract usage (Groq returns the same shape as OpenAI)
usage_data = data.get("usage") or {}
usage = {
"input_tokens": int(usage_data.get("prompt_tokens", 0) or 0),
"output_tokens": int(usage_data.get("completion_tokens", 0) or 0),
}
if usage["input_tokens"] == 0:
usage["input_tokens"] = _estimate_tokens(system) + _estimate_tokens(user)
if usage["output_tokens"] == 0:
usage["output_tokens"] = _estimate_tokens(content)
return content, "groq", usage
except requests.exceptions.RequestException as e:
last_err = e
log.error(f"[Kype/Groq] network error: {e}")
break
except Exception as e:
last_err = e
log.error(f"[Kype/Groq] unexpected error: {e}")
break
raise RuntimeError(f"Groq fallback failed: {last_err}")
# ---- unified call: OpenRouter first, Groq on ANY failure -------------
def _call(self, model: str, system: str, user: str,
groq_max_tokens: int = GROQ_ANALYSIS_TOKENS) -> Tuple[str, str, dict]:
"""Try OpenRouter; on ANY failure, switch to Groq with reduced budget.
Returns (content, provider, usage)."""
if not self.api_key:
return self._call_groq(system, user, groq_max_tokens)
try:
return self._call_openrouter(model, system, user)
except GroqFallbackNeeded as e:
log.warning(f"[Kype] OpenRouter fallback triggered ({e}) β€” switching to Groq.")
if not GROQ_API_KEY:
raise
return self._call_groq(system, user, groq_max_tokens)
# ---- tool use (limited, sync) ----------------------------------------
@staticmethod
def _extract_balanced_json(text: str, start: int) -> Optional[str]:
"""Extract a complete JSON object starting at `start` (which must be '{').
Uses balanced-brace matching that respects string literals β€” so '}'
inside a string value doesn't prematurely terminate the match.
This fixes the 'Unterminated string' error caused by the old non-greedy
regex which stopped at the first '}' even if it was inside a string."""
if start >= len(text) or text[start] != '{':
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
ch = text[i]
if escape:
escape = False
continue
if ch == '\\' and in_string:
escape = True
continue
if ch == '"' and not escape:
in_string = not in_string
continue
if in_string:
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
return text[start:i+1]
return None # unbalanced β€” ran off the end
def _maybe_call_tool(self, planner_output: str) -> Tuple[str, str]:
"""Parse Planner output for [TOOL: name] {args}, execute sync, return result.
One call per iteration max. Bounded to KYPE_ALLOWED_TOOLS.
FIX: Uses balanced-brace JSON extraction instead of non-greedy regex,
so braces inside string values don't truncate the JSON."""
if not self.allow_tools:
return "", ""
# Find the [TOOL: name] marker
m = re.search(
r'\[TOOL:\s*([a-z_]+)\s*\]',
planner_output,
re.IGNORECASE,
)
if not m:
return "", ""
tool_name = m.group(1).strip().lower()
# Find the JSON object starting after the marker β€” use balanced braces
# so '}' inside strings doesn't break parsing
json_start = planner_output.find('{', m.end())
if json_start == -1:
return tool_name, f"[Tool '{tool_name}' has no JSON args]"
json_str = self._extract_balanced_json(planner_output, json_start)
if not json_str:
return tool_name, f"[Tool '{tool_name}' has unbalanced JSON braces]"
if tool_name not in KYPE_ALLOWED_TOOLS:
log.warning(f"[Kype] Tool '{tool_name}' not in allow-list β€” skipping.")
return tool_name, f"[Tool '{tool_name}' is not allowed in Kype.]"
try:
args = json.loads(json_str)
except json.JSONDecodeError as e:
return tool_name, f"[Invalid tool args JSON: {e}]"
result = _dispatch_kype_tool(tool_name, args)
# Cap result length to keep context small.
if len(result) > 2000:
result = result[:2000] + "\n...[truncated]"
return tool_name, result
# ---- stage wrappers ---------------------------------------------------
def plan(self, task: str, feedback: str = "") -> Tuple[str, str, str, str, dict]:
"""Returns (plan, tool_name, tool_result, provider, usage)."""
user = f"TASK:\n{task}\n"
if feedback:
user += f"\nDEBUGGER FEEDBACK (address every issue):\n{feedback}\n"
if self.allow_tools:
# Include doc tool descriptions so the Planner knows the args
try:
from kype_doc_tools import KYPE_DOC_TOOL_DESCRIPTIONS
doc_desc = "\n".join(
f" - {name}: {desc}"
for name, desc in KYPE_DOC_TOOL_DESCRIPTIONS.items()
)
except ImportError:
try:
from .kype_doc_tools import KYPE_DOC_TOOL_DESCRIPTIONS
doc_desc = "\n".join(
f" - {name}: {desc}"
for name, desc in KYPE_DOC_TOOL_DESCRIPTIONS.items()
)
except ImportError:
doc_desc = ""
user += (
"\nIf you need real-time info or want to create a file, end your "
"plan with ONE line in this exact format:\n"
"[TOOL: <name>] {<json args>}\n"
f"Allowed tools: {', '.join(KYPE_ALLOWED_TOOLS)}.\n"
)
if doc_desc:
user += f"\nTool descriptions:\n{doc_desc}\n"
user += "\nProduce the plan now."
plan, provider, usage = self._call(
self.planner_model, PLANNER_SYSTEM, user,
groq_max_tokens=GROQ_PLANNER_TOKENS,
)
tool_name, tool_result = self._maybe_call_tool(plan)
return plan, tool_name, tool_result, provider, usage
def analyze(self, task: str, plan: str, feedback: str = "",
tool_name: str = "", tool_result: str = "") -> Tuple[str, str, dict]:
user = f"TASK:\n{task}\n\nPLAN:\n{plan}\n"
if tool_name and tool_result:
user += f"\n[TOOL RESULT from {tool_name}]:\n{tool_result}\n"
if feedback:
user += (
"\nDEBUGGER FEEDBACK (revise your previous draft to address every issue):\n"
f"{feedback}\n"
)
user += "\nDeliver the final answer now."
draft, provider, usage = self._call(
self.analysis_model, ANALYSIS_SYSTEM, user,
groq_max_tokens=GROQ_ANALYSIS_TOKENS,
)
return draft, provider, usage
def debug(self, task: str, plan: str, draft: str) -> Tuple[Dict[str, Any], str, dict]:
user = (
f"TASK:\n{task}\n\n"
f"PLAN:\n{plan}\n\n"
f"DRAFT ANSWER TO REVIEW:\n{draft}\n\n"
f"Return the JSON verdict now."
)
raw, provider, usage = self._call(
self.debugger_model, DEBUGGER_SYSTEM, user,
groq_max_tokens=GROQ_DEBUGGER_TOKENS,
)
return self._parse_verdict(raw), provider, usage
@staticmethod
def _parse_verdict(raw: str) -> Dict[str, Any]:
cleaned = raw.strip()
if cleaned.startswith("```"):
parts = cleaned.split("```")
if len(parts) >= 2:
inner = parts[1]
if inner.startswith("json"):
inner = inner[4:]
cleaned = inner.strip()
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
m = re.search(r'\{[\s\S]*\}', cleaned)
if m:
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
pass
return {
"verdict": "revise",
"issues": [
{"severity": "low", "description": "Debugger output could not be parsed as JSON."}
],
"feedback": f"Debugger raw output (could not parse β€” please review):\n{raw}",
}
# ---- simple-task detection (Fix Issue 3) ------------------------------
@staticmethod
def _is_simple_task(task: str) -> bool:
"""Detect simple tasks that don't need Debugger review.
Returns True for short questions/factual queries that can be answered
in 1 Planner+Analysis pass without review loop overhead."""
task_lower = task.lower().strip()
# Check for complex keywords using word boundaries so "create" doesn't
# match "created" (which appears in "who created you?").
for kw in COMPLEX_TASK_KEYWORDS:
# \b ensures we match the whole word/phrase, not a substring
if re.search(r'\b' + re.escape(kw) + r'\b', task_lower):
return False
# Very short tasks (< 40 chars) without complex keywords are simple
if len(task_lower) < 40:
return True
# 40-150 char tasks: check for simple question patterns
if len(task_lower) > SIMPLE_TASK_CHAR_THRESHOLD:
return False
simple_patterns = (
"who ", "what ", "when ", "where ", "why ", "how ", "is ", "are ",
"can you ", "could you ", "explain", "define", "tell me",
"calculate", "convert", "translate", "summarize", "list",
)
return any(task_lower.startswith(p) for p in simple_patterns)
# ---- main entry -------------------------------------------------------
def run(self, task: str) -> KypeResult:
if not task or not task.strip():
raise ValueError("task must be a non-empty string")
t_start = time.time()
trace: List[AgentResponse] = []
fallback_used: Optional[str] = None
tool_used: Optional[str] = None
total_usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
feedback = ""
plan = ""
draft = ""
approved = False
iterations_completed = 0
# FIX Issue 3: Simple-task fast path β€” skip the Debugger for short,
# simple tasks. Cuts response time from ~3 LLM calls to ~2 for
# questions like "What is 2+2?" or "Who made you?".
is_simple = self._is_simple_task(task)
for i in range(1, self.max_iterations + 1):
# 1. Planner (+ optional tool call)
t0 = time.time()
plan, tool_name, tool_result, planner_provider, planner_usage = self.plan(task, feedback)
_aggregate_usage(total_usage, planner_usage)
if tool_name and not tool_used:
tool_used = tool_name
trace.append(AgentResponse(
"planner", self.planner_model, plan, time.time() - t0, i,
provider=planner_provider,
))
if tool_name:
trace.append(AgentResponse(
"tool", tool_name, tool_result, 0.0, i, provider="local",
))
# 2. Analysis
t0 = time.time()
draft, analysis_provider, analysis_usage = self.analyze(
task, plan, feedback,
tool_name=tool_name, tool_result=tool_result,
)
_aggregate_usage(total_usage, analysis_usage)
trace.append(AgentResponse(
"analysis", self.analysis_model, draft, time.time() - t0, i,
provider=analysis_provider,
))
# Simple-task fast path: skip Debugger, approve immediately
if is_simple:
approved = True
iterations_completed = i
if "groq" in (planner_provider, analysis_provider):
fallback_used = "groq"
break
# 3. Debugger (only for non-simple tasks)
t0 = time.time()
verdict, debugger_provider, debugger_usage = self.debug(task, plan, draft)
_aggregate_usage(total_usage, debugger_usage)
verdict_str = json.dumps(verdict, indent=2, ensure_ascii=False)
trace.append(AgentResponse(
"debugger", self.debugger_model, verdict_str, time.time() - t0, i,
provider=debugger_provider,
))
iterations_completed = i
if "groq" in (planner_provider, analysis_provider, debugger_provider):
fallback_used = "groq"
if verdict.get("verdict") == "approve":
approved = True
break
feedback = verdict.get("feedback") or json.dumps(
verdict.get("issues", []), ensure_ascii=False
)
return KypeResult(
task=task,
final_answer=draft,
iterations=iterations_completed,
approved=approved,
trace=trace,
total_elapsed=time.time() - t_start,
fallback_used=fallback_used,
tool_used=tool_used,
usage=total_usage,
)
# =================================================================
# RATE LIMITER (1 task per 30 s per user)
# =================================================================
_kype_last_request: Dict[str, float] = {}
def _kype_rate_limited(user_id: str) -> Tuple[bool, float]:
now = time.time()
last = _kype_last_request.get(user_id, 0.0)
wait = KYPE_RATE_WINDOW_SECONDS - (now - last)
if wait > 0:
return True, wait
return False, 0.0
def _kype_record_request(user_id: str) -> None:
_kype_last_request[user_id] = time.time()
# =================================================================
# DAILY LIMIT (5 free OpenRouter tasks per user per UTC day)
# =================================================================
_kype_daily_usage: Dict[Tuple[str, str], List[float]] = {}
def _today_utc_key() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _kype_daily_count(user_id: str) -> int:
key = (user_id, _today_utc_key())
return len(_kype_daily_usage.get(key, []))
def _kype_record_use(user_id: str) -> None:
key = (user_id, _today_utc_key())
_kype_daily_usage.setdefault(key, []).append(time.time())
# =================================================================
# POLAR SUBSCRIPTION GATE (v2.0 β€” FIXED + HARD GATE)
# =================================================================
# Uses the standalone polar_subscription module which fixes the 7 bugs
# that caused the previous checker to deny access even after subscribing.
# See /home/z/my-project/download/polar_subscription.py for full details.
# Lazy import β€” avoids circular dependency with main.py if it also imports
# polar_subscription. The module is imported on first call, then cached.
_polar_module = None
def _get_polar_module():
"""Lazy-load the polar_subscription module. Returns None if unavailable."""
global _polar_module
if _polar_module is not None:
return _polar_module
try:
# Try the local module first (sibling file)
try:
from . import polar_subscription as _ps
except ImportError:
# Fall back to a direct import (standalone usage)
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import polar_subscription as _ps
_polar_module = _ps
return _polar_module
except ImportError as e:
log.error(f"[Kype] polar_subscription module not available: {e}")
return None
def _get_subscriptions_col():
"""Lazy resolver for the MongoDB subscriptions collection from main.py.
Avoids circular imports."""
import sys
try:
main = sys.modules.get("main")
if main is not None and hasattr(main, "subscriptions_col"):
return main.subscriptions_col
except Exception:
pass
return None
async def _check_polar_subscription(user_id: str, email: str = ""):
"""Run the FIXED Polar subscription check. Returns a PolarCheckResult
(with .subscribed, .matched_by, .status, .error, etc.).
SECURITY: fail_open_on_outage=False (default) β€” DENY on any Polar error.
The wrapper-level fail-open fallbacks have been REMOVED. If the Polar
module is missing or throws, we DENY (security first β€” no more breach)."""
ps = _get_polar_module()
if ps is None:
# Module unavailable β€” DENY. Do NOT fail open (that was the breach).
log.error("[Kype] polar_subscription module missing β€” DENYING access.")
class _Deny:
subscribed = False
matched_by = "module_missing_deny"
status = ""
error = "polar_subscription module not importable"
def to_dict(self):
return {"subscribed": False, "matched_by": self.matched_by, "error": self.error}
return _Deny()
try:
subs_col = _get_subscriptions_col()
# fail_open_on_outage=False (the new default) β€” deny on Polar errors.
# Only confirmed network outages with fail_open_on_outage=True would
# grant access, and even then only with a prior Mongo active record.
result = await ps.check_polar_subscription(
email=email or "",
firebase_uid=user_id or "",
subscriptions_col=subs_col,
fail_open_on_outage=False, # SECURITY: deny on outage, don't burn tokens
)
log.info(f"[Kype] Polar check for uid={user_id} email={email}: "
f"subscribed={result.subscribed} matched_by={result.matched_by} status={result.status}")
return result
except Exception as e:
log.error(f"[Kype] Polar check exception β€” DENYING: {e}", exc_info=True)
# DENY on unexpected errors β€” do NOT fail open.
class _DenyExc:
subscribed = False
matched_by = "exception_deny"
status = ""
error = str(e)
def to_dict(self):
return {"subscribed": False, "matched_by": self.matched_by, "error": self.error}
return _DenyExc()
# =================================================================
# MAINTENANCE MODE
# =================================================================
# Set env var KYPE_MAINTENANCE=on (or "true" / "1") to shut down Kype.
# All /kype/run requests return 503 with a maintenance message.
# /kype/health still works so you can monitor uptime.
# Optionally set KYPE_MAINTENANCE_MESSAGE for a custom message.
def _is_maintenance_mode() -> bool:
"""Check if Kype is in maintenance mode via env var."""
val = os.getenv("KYPE_MAINTENANCE", "").strip().lower()
return val in ("on", "true", "1", "yes", "enabled")
def _maintenance_message() -> str:
"""Get the maintenance message (custom or default)."""
return os.getenv(
"KYPE_MAINTENANCE_MESSAGE",
"πŸ”§ Kype is currently undergoing maintenance. We'll be back shortly. "
"Thank you for your patience!"
)
# =================================================================
# FASTAPI ROUTER
# =================================================================
class KypeRequest(BaseModel):
task: str
user_id: str # REQUIRED β€” no default (was "anonymous" β€” security breach)
email: Optional[str] = None
max_iterations: int = 3
planner_model: Optional[str] = None
analysis_model: Optional[str] = None
debugger_model: Optional[str] = None
privacy: bool = True
allow_tools: bool = True
file_name: Optional[str] = None
file_type: Optional[str] = None
file_content: Optional[str] = None
kype_router = APIRouter(prefix="/kype")
@kype_router.post("/run")
async def kype_run(req: KypeRequest):
"""
Run the Kype pipeline (Planner β†’ Analysis β†’ Debugger).
GATES (in order):
M. MAINTENANCE CHECK β€” returns 503 if KYPE_MAINTENANCE env var is on
0. IDENTITY CHECK β€” rejects anonymous / empty user_id (security fix)
1. POLAR SUBSCRIPTION (HARD GATE) β€” returns 402 if user has no active
subscription. Uses the FIXED polar_subscription module which fails
CLOSED on auth errors (401/403) β€” no more token-burning breach.
2. Rate limit β€” 1 task per 30s per user (returns 429)
3. Token budget β€” Pro 1M/month, Free 25K/month (returns 402)
4. Context cap β€” 5000 tokens max per task (returns 413)
On OpenRouter failure: automatic Groq fallback (same prompts, reduced tokens).
Limited sync tool use: web_search, fetch_url, create_file, create_pdf, etc.
"""
# ---- M. MAINTENANCE CHECK β€” block ALL access when maintenance is on ----
if _is_maintenance_mode():
log.info("[Kype] Request blocked β€” maintenance mode is ON.")
raise HTTPException(
status_code=503,
detail=_maintenance_message(),
headers={"Retry-After": "3600"},
)
user_id = (req.user_id or "").strip()
# ---- 0. IDENTITY CHECK β€” reject anonymous / empty user_id ------------
# This is the security fix for the breach: previously user_id defaulted to
# "anonymous", which sail straight through the Polar gate via fail-open.
if not user_id or user_id.lower() in ("anonymous", "anon", "guest", ""):
raise HTTPException(
status_code=401,
detail=(
"πŸ” Sign-in required. Kype requires an authenticated user_id "
"to verify your subscription before sending your task to the AI."
),
)
# ---- 1. POLAR SUBSCRIPTION β€” HARD GATE (fail-closed on errors) -------
# If the user has no active subscription, decline the inquiry BEFORE
# spending any LLM tokens. This is the user's explicit request: "if no
# subscription plan decline inquiry" β€” and "before the message reaches
# the ai to send message, it needs to check subscription".
#
# fail_open_on_outage=False (default) β€” DENY on any Polar error.
# This prevents the token-burning breach where a missing API key
# caused fail-open and let anonymous users through.
#
# TESTING BYPASS: Set env var KYPE_DISABLE_POLAR_GATE=1 to skip the
# Polar check entirely. ALL users are treated as Pro subscribers.
# ⚠️ NEVER enable this in production β€” it bypasses the paywall.
if os.getenv("KYPE_DISABLE_POLAR_GATE", "").lower() in ("1", "true", "yes", "on"):
log.warning("⚠️ KYPE_DISABLE_POLAR_GATE is set β€” Polar subscription check BYPASSED (testing mode).")
polar_subscribed = True
class _TestBypassResult:
subscribed = True
matched_by = "test_bypass"
status = "bypassed"
error = ""
def to_dict(self):
return {"subscribed": True, "matched_by": "test_bypass", "note": "Polar gate disabled via KYPE_DISABLE_POLAR_GATE env var"}
polar_result = _TestBypassResult()
else:
polar_result = await _check_polar_subscription(
user_id=user_id, email=req.email or "",
)
if not polar_result.subscribed:
# Fail-closed for the explicit gate. The PolarCheckResult carries the
# reason in matched_by/error so the user can diagnose.
raise HTTPException(
status_code=402, # Payment Required
detail=(
f"πŸ”’ Kype requires an active Neurones Pro subscription. "
f"Subscribe at polar.sh or via your dashboard, then try again. "
f"[matched_by={polar_result.matched_by}, status={polar_result.status}]"
),
headers={"Upgrade-URL": "https://polar.sh/neuraprompt"},
)
polar_subscribed = polar_result.subscribed
# ---- 2. File handling ------------------------------------------------
file_context = ""
if req.file_content:
if req.file_type and req.file_type.lower().startswith(REJECTED_FILE_PREFIXES):
raise HTTPException(
status_code=400,
detail="Image, video, and audio files are not supported in Kype. "
"Please switch to Neurones Vision for image-based tasks."
)
encoded_len = len(req.file_content.encode("utf-8", errors="ignore"))
if encoded_len > MAX_FILE_BYTES:
raise HTTPException(
status_code=413,
detail=f"Attached file is too large. Kype accepts text files "
f"up to {MAX_FILE_BYTES // 1_000_000} MB."
)
safe_name = _sanitize_filename(req.file_name)
safe_type = (req.file_type or "text/plain").lower()[:64]
file_context = (
f"\n\n--- ATTACHED FILE: {safe_name} ({safe_type}) ---\n"
f"{req.file_content}\n"
f"--- END FILE ---\n"
)
# ---- 3. Task assembly ------------------------------------------------
task = (req.task or "").strip() + file_context
if not task.strip():
raise HTTPException(status_code=400, detail="Task cannot be empty.")
# ---- 4. Rate limit (1 task per 30 s) β€” HARD BLOCK --------------------
is_rate_limited, wait_seconds = _kype_rate_limited(user_id)
if is_rate_limited:
wait_int = int(round(wait_seconds)) + 1
# Return HTTP 429 with a clear, visible message.
raise HTTPException(
status_code=429,
detail=(
f"⏳ Too many requests on Kype β€” please wait about {wait_int} "
f"second(s) and try again. Kype uses free models, "
f"so we're limited to one task per {KYPE_RATE_WINDOW_SECONDS} seconds."
),
headers={"Retry-After": str(wait_int)},
)
# ---- 5. Token budget check (replaces daily message limit) -------------
# Pro (subscribed): 1,000,000 tokens/month. Free: 25,000 tokens/month.
# Resets on the 1st of each UTC month. MongoDB-backed (survives restarts).
# We estimate the input tokens for this task and check the user has enough
# budget. Actual usage is recorded AFTER the LLM call (step 8).
try:
from token_usage import check_and_reserve, record_usage, get_usage_summary
except ImportError:
# Fall back: try relative import (when used as agent.tools.kype)
try:
from .token_usage import check_and_reserve, record_usage, get_usage_summary
except ImportError:
log.error("[Kype] token_usage module not available β€” DENYING (fail-closed).")
raise HTTPException(503, "Token tracking unavailable. Please try again later.")
estimated_input = _estimate_tokens(task) + 200 # +200 for system prompts overhead
allowed, reason, budget_info = check_and_reserve(user_id, polar_subscribed, estimated_input)
if not allowed:
tier_label = "Pro (1M/month)" if polar_subscribed else "Free (25K/month)"
if reason == "monthly_limit_reached":
raise HTTPException(
status_code=402,
detail=(
f"πŸ“Š You've used all your monthly token budget for {budget_info['month_key']}. "
f"Tier: {tier_label}. Used: {budget_info['used']:,} / {budget_info['limit']:,} tokens. "
f"Your budget resets on the 1st of next month (UTC). "
f"{'Upgrade at polar.sh for 1M tokens/month.' if not polar_subscribed else 'Thank you for being a Pro subscriber β€” see you next month!'}"
),
headers={"Retry-After": "86400"},
)
else: # insufficient_for_input
raise HTTPException(
status_code=402,
detail=(
f"πŸ“Š This task needs ~{estimated_input:,} input tokens, but you only have "
f"{budget_info['remaining']:,} tokens remaining this month "
f"({budget_info['used']:,}/{budget_info['limit']:,} used). "
f"Tier: {tier_label}. "
f"{'Upgrade at polar.sh for 1M tokens/month.' if not polar_subscribed else 'Your budget resets on the 1st.'}"
),
)
# ---- 6. Context cap (5000 tokens/task) β€” per-task safety cap ----------
# This is SEPARATE from the monthly budget β€” it prevents a single oversized
# task from eating the entire monthly allocation in one go.
context_tokens = _estimate_tokens(task)
if context_tokens > KYPE_CONTEXT_TOKEN_CAP:
raise HTTPException(
status_code=413,
detail=(
f"πŸ“ Your task is too large (~{context_tokens} tokens). Kype caps "
f"single tasks at {KYPE_CONTEXT_TOKEN_CAP} tokens "
f"(~{KYPE_CONTEXT_TOKEN_CAP * 4} characters). Please shorten "
f"your task or split it into smaller pieces."
),
)
# ---- 7. Record rate-limit stamp BEFORE the slow LLM call --------------
_kype_record_request(user_id)
# ---- 8. Run the pipeline --------------------------------------------
try:
agent = KypeAgent(
planner_model=req.planner_model or DEFAULT_PLANNER_MODEL,
analysis_model=req.analysis_model or DEFAULT_ANALYSIS_MODEL,
debugger_model=req.debugger_model or DEFAULT_DEBUGGER_MODEL,
max_iterations=req.max_iterations,
allow_tools=req.allow_tools,
)
result = await asyncio.to_thread(agent.run, task)
except EnvironmentError as e:
raise HTTPException(status_code=503, detail=_strip_secrets(str(e)))
except Exception as e:
msg = _strip_secrets(str(e) or "Unexpected error.")
msg = re.sub(r"https?://\S+", "[url]", msg)
msg = re.sub(r"/[^\s'\"]+\.py", "[file]", msg)
raise HTTPException(
status_code=503,
detail=f"Kype encountered an error: {msg}",
)
# ---- 9. Record ACTUAL token usage (from API response usage data) -----
actual_usage = result.usage or {"input_tokens": 0, "output_tokens": 0}
try:
record_usage(
user_id=user_id,
input_tokens=actual_usage.get("input_tokens", 0),
output_tokens=actual_usage.get("output_tokens", 0),
model=req.planner_model or DEFAULT_PLANNER_MODEL,
)
except Exception as e:
log.warning(f"[Kype] Failed to record token usage: {e}")
out = result.to_dict()
if req.privacy:
out = _apply_privacy(out)
out["polar"] = polar_result.to_dict() if hasattr(polar_result, "to_dict") else {"subscribed": bool(polar_result)}
# Include the updated token budget so the frontend can show remaining
try:
out["token_budget"] = get_usage_summary(user_id, polar_subscribed)
except Exception:
out["token_budget"] = None
return out
@kype_router.get("/health")
async def kype_health():
maintenance = _is_maintenance_mode()
return {
"status": "maintenance" if maintenance else "ok",
"service": "kype",
"version": "1.5", # bumped β€” maintenance mode + tool args fix
"roles": ["planner", "analysis", "debugger"],
"max_file_bytes": MAX_FILE_BYTES,
"rate_window_seconds": KYPE_RATE_WINDOW_SECONDS,
"context_token_cap": KYPE_CONTEXT_TOKEN_CAP,
"groq_fallback_enabled": bool(GROQ_API_KEY),
"allowed_tools": list(KYPE_ALLOWED_TOOLS),
"polar_gate_enabled": True,
"token_tracking_enabled": True,
"maintenance_mode": maintenance,
"maintenance_message": _maintenance_message() if maintenance else None,
}
@kype_router.get("/usage")
async def kype_usage(user_id: str = "", email: str = ""):
"""Get the caller's token usage for the current month.
Pass ?user_id=<firebase_uid> (required) and optionally ?email=<email>.
The Polar subscription check runs first to determine the tier (Pro=1M, Free=25K)."""
user_id = (user_id or "").strip()
if not user_id or user_id.lower() in ("anonymous", "anon", "guest", ""):
raise HTTPException(401, "πŸ” user_id required to check token usage.")
try:
from token_usage import get_usage_summary
except ImportError:
try:
from .token_usage import get_usage_summary
except ImportError:
raise HTTPException(503, "Token tracking unavailable.")
# Determine tier via Polar check
polar_result = await _check_polar_subscription(user_id=user_id, email=email or "")
return get_usage_summary(user_id, polar_result.subscribed)
@kype_router.get("/download/{filename}")
async def kype_download(filename: str):
"""Serve files created by the create_file tool."""
if ".." in filename or "/" in filename:
raise HTTPException(400, "Invalid filename")
file_path = KYPE_OUTPUT_DIR / filename
if not file_path.exists():
raise HTTPException(404, "File not found or expired")
return FileResponse(
path=str(file_path),
filename=filename,
media_type="application/octet-stream",
)
# =================================================================
# POLAR WEBHOOK + DIAGNOSE ROUTES (mounted on the kype router so
# they live alongside the gate. Alternatively mount on the main app.)
# =================================================================
@kype_router.post("/polar/webhook")
async def kype_polar_webhook(request: Request):
"""Polar webhook receiver. Verifies signature, updates MongoDB,
clears the Polar cache so newly-subscribed users get instant access.
Configure Polar to send webhooks to: /kype/polar/webhook
(or mount this on /webhooks/polar in main.py β€” see polar_subscription.handle_polar_webhook)."""
ps = _get_polar_module()
if ps is None:
raise HTTPException(500, "polar_subscription module not available")
subs_col = _get_subscriptions_col()
return await ps.handle_polar_webhook(request, subscriptions_col=subs_col)
@kype_router.get("/polar/diagnose")
async def kype_polar_diagnose(email: str = "", uid: str = ""):
"""Diagnostic endpoint β€” returns the full Polar API response for a user.
Use this when a user reports 'I subscribed but I'm still denied'.
Returns: cache state, polar_response_keys (the smoking gun for Bug 1),
matching_subscriptions, sample_subscriptions_for_debugging, check_result."""
ps = _get_polar_module()
if ps is None:
raise HTTPException(500, "polar_subscription module not available")
# Use the module's router's diagnose endpoint logic directly
from fastapi.responses import JSONResponse
# Delegate to the polar_subscription module's diagnose function
return await ps.polar_router.routes[0].endpoint(email=email, uid=uid)
@kype_router.post("/polar/invalidate")
async def kype_polar_invalidate(email: str = "", uid: str = ""):
"""Manually clear the Polar cache for a user. Use after manually editing
a subscription in the Polar dashboard."""
ps = _get_polar_module()
if ps is None:
raise HTTPException(500, "polar_subscription module not available")
cleared = ps.clear_polar_cache(email=email, firebase_uid=uid)
return {"cleared_entries": cleared}
@kype_router.get("/polar/health")
async def kype_polar_health():
"""Check Polar configuration status."""
ps = _get_polar_module()
if ps is None:
return {"status": "error", "error": "polar_subscription module not available"}
return await ps.polar_health()