perplexity.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. #!/usr/bin/env python3
  2. """
  3. Perplexity CLI - Simple wrapper for Perplexity API.
  4. Zero external dependencies - uses only Python stdlib.
  5. Provides web-grounded AI answers with automatic citations.
  6. Usage:
  7. perplexity "What is Claude Code?"
  8. perplexity -m sonar-reasoning "Complex analysis question"
  9. cat code.py | perplexity "Review this code"
  10. perplexity --json "query" > output.json
  11. Environment:
  12. PERPLEXITY_API_KEY - API key (required)
  13. PERPLEXITY_VERBOSE - Show token usage when set
  14. """
  15. import argparse
  16. import json
  17. import os
  18. import sys
  19. import urllib.request
  20. import urllib.error
  21. API_URL = "https://api.perplexity.ai/chat/completions"
  22. MODELS = {
  23. "sonar": "Fast, cost-effective for quick facts",
  24. "sonar-pro": "Complex queries, more citations (default)",
  25. "sonar-reasoning": "Multi-step problem solving",
  26. "sonar-reasoning-pro": "Deep reasoning (DeepSeek-R1)",
  27. "sonar-deep-research": "Comprehensive research with agentic search",
  28. }
  29. DEFAULT_MODEL = "sonar-pro"
  30. def get_api_key():
  31. """Get API key from environment variable."""
  32. return os.getenv("PERPLEXITY_API_KEY")
  33. def query_perplexity(prompt, model=DEFAULT_MODEL, system_prompt=None, recency=None, domains=None):
  34. """Send query to Perplexity API and return response."""
  35. api_key = get_api_key()
  36. if not api_key:
  37. sys.exit(
  38. "Error: PERPLEXITY_API_KEY not set.\n"
  39. "Set via: export PERPLEXITY_API_KEY='your-key'\n"
  40. "Get key from: https://www.perplexity.ai/settings/api"
  41. )
  42. messages = []
  43. if system_prompt:
  44. messages.append({"role": "system", "content": system_prompt})
  45. messages.append({"role": "user", "content": prompt})
  46. payload = {
  47. "model": model,
  48. "messages": messages,
  49. }
  50. # Optional search filters
  51. if recency:
  52. payload["search_recency_filter"] = recency
  53. if domains:
  54. payload["search_domain_filter"] = domains
  55. data = json.dumps(payload).encode("utf-8")
  56. headers = {
  57. "Authorization": f"Bearer {api_key}",
  58. "Content-Type": "application/json",
  59. "Accept": "application/json",
  60. }
  61. req = urllib.request.Request(API_URL, data=data, headers=headers)
  62. try:
  63. with urllib.request.urlopen(req, timeout=120) as resp:
  64. result = json.loads(resp.read().decode("utf-8"))
  65. except urllib.error.HTTPError as e:
  66. if e.code == 401:
  67. sys.exit("Error: Invalid API key")
  68. elif e.code == 429:
  69. sys.exit("Error: Rate limited. Wait and retry.")
  70. else:
  71. body = ""
  72. try:
  73. body = e.read().decode("utf-8")
  74. except Exception:
  75. pass
  76. sys.exit(f"Error: HTTP {e.code} - {e.reason}\n{body}")
  77. except urllib.error.URLError as e:
  78. sys.exit(f"Error: Network error - {e.reason}")
  79. except Exception as e:
  80. sys.exit(f"Error: {e}")
  81. return result
  82. def safe_print(text):
  83. """Print text safely, handling encoding issues on Windows."""
  84. try:
  85. print(text)
  86. except UnicodeEncodeError:
  87. # Fallback: encode with replacement for unsupported chars
  88. print(text.encode(sys.stdout.encoding, errors='replace').decode(sys.stdout.encoding))
  89. def format_output(result, show_citations=True, json_output=False):
  90. """Format and print the response."""
  91. if json_output:
  92. print(json.dumps(result, indent=2, ensure_ascii=False))
  93. return
  94. # Extract content
  95. try:
  96. content = result["choices"][0]["message"]["content"]
  97. except (KeyError, IndexError):
  98. print("Error: Unexpected response format")
  99. print(json.dumps(result, indent=2))
  100. return
  101. citations = result.get("citations", [])
  102. usage = result.get("usage", {})
  103. safe_print(content)
  104. # Show citations if available and requested
  105. if show_citations and citations:
  106. safe_print("\n---")
  107. safe_print("Sources:")
  108. for i, cite in enumerate(citations, 1):
  109. safe_print(f" [{i}] {cite}")
  110. # Show usage if verbose
  111. if os.getenv("PERPLEXITY_VERBOSE"):
  112. total = usage.get("total_tokens", "N/A")
  113. print(f"\n[Tokens: {total}]")
  114. def main():
  115. parser = argparse.ArgumentParser(
  116. description="Perplexity CLI - Web-grounded AI answers with citations",
  117. formatter_class=argparse.RawDescriptionHelpFormatter,
  118. epilog="""
  119. Models:
  120. sonar Fast, cost-effective for quick facts
  121. sonar-pro Complex queries, more citations (default)
  122. sonar-reasoning Multi-step problem solving
  123. sonar-reasoning-pro Deep reasoning (DeepSeek-R1 based)
  124. sonar-deep-research Comprehensive agentic research
  125. Examples:
  126. perplexity "What's new in TypeScript 5.7?"
  127. perplexity -m sonar-reasoning "Analyze this security pattern"
  128. cat code.py | perplexity "Review this code for issues"
  129. perplexity --json "query" > output.json
  130. # Filter by recency (day, week, month, year)
  131. perplexity --recency day "Latest AI news"
  132. # Restrict to specific domains
  133. perplexity --domains "github.com,docs.python.org" "Python asyncio best practices"
  134. Environment:
  135. PERPLEXITY_API_KEY API key (required)
  136. PERPLEXITY_VERBOSE Show token usage when set
  137. """,
  138. )
  139. parser.add_argument("prompt", nargs="?", help="Query prompt")
  140. parser.add_argument(
  141. "-m",
  142. "--model",
  143. default=DEFAULT_MODEL,
  144. choices=list(MODELS.keys()),
  145. help=f"Model to use (default: {DEFAULT_MODEL})",
  146. )
  147. parser.add_argument("-s", "--system", default=None, help="System prompt")
  148. parser.add_argument(
  149. "--no-citations", action="store_true", help="Suppress citation output"
  150. )
  151. parser.add_argument(
  152. "--json", action="store_true", help="Output raw JSON response"
  153. )
  154. parser.add_argument(
  155. "--list-models", action="store_true", help="List available models"
  156. )
  157. parser.add_argument(
  158. "--recency",
  159. choices=["day", "week", "month", "year"],
  160. help="Filter search results by recency",
  161. )
  162. parser.add_argument(
  163. "--domains",
  164. type=str,
  165. help="Comma-separated domains to include (e.g., 'github.com,stackoverflow.com')",
  166. )
  167. args = parser.parse_args()
  168. # List models
  169. if args.list_models:
  170. print("Available models:")
  171. for name, desc in MODELS.items():
  172. marker = " (default)" if name == DEFAULT_MODEL else ""
  173. print(f" {name}{marker}: {desc}")
  174. return 0
  175. # Get prompt from argument and/or stdin
  176. prompt = args.prompt
  177. stdin_content = ""
  178. # Read stdin once if available (piped input)
  179. if not sys.stdin.isatty():
  180. try:
  181. stdin_content = sys.stdin.read().strip()
  182. except Exception:
  183. stdin_content = ""
  184. # Combine stdin and prompt argument
  185. if stdin_content and prompt:
  186. # Both stdin and argument: stdin as context, prompt as instruction
  187. prompt = f"{stdin_content}\n\n{prompt}"
  188. elif stdin_content:
  189. # Only stdin content
  190. prompt = stdin_content
  191. elif not prompt:
  192. # No input at all
  193. parser.print_help()
  194. return 1
  195. # Parse domains if provided
  196. domains = None
  197. if args.domains:
  198. domains = [d.strip() for d in args.domains.split(",")]
  199. # Query and output
  200. result = query_perplexity(
  201. prompt,
  202. model=args.model,
  203. system_prompt=args.system,
  204. recency=args.recency,
  205. domains=domains,
  206. )
  207. format_output(result, show_citations=not args.no_citations, json_output=args.json)
  208. return 0
  209. if __name__ == "__main__":
  210. sys.exit(main())