Spaces:
Running
Running
| """ | |
| 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 | |
| # ================================================================= | |
| class AgentResponse: | |
| role: str | |
| model: str | |
| content: str | |
| elapsed: float | |
| iteration: int | |
| provider: str = "openrouter" | |
| 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) ----------------------------------- | |
| 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) ---------------------------------------- | |
| 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 | |
| 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) ------------------------------ | |
| 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") | |
| 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 | |
| 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, | |
| } | |
| 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) | |
| 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.) | |
| # ================================================================= | |
| 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) | |
| 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) | |
| 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} | |
| 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() | |