返回 JoyAI-Echo
searchusage.py
1 """Web search provider usage fetchers for /status command."""
2
3 from __future__ import annotations
4
5 import os
6 from dataclasses import dataclass
7 from typing import Any
8
9
10 @dataclass
11 class SearchUsageInfo:
12 """Structured usage info returned by a provider fetcher."""
13
14 provider: str
15 supported: bool = False # True if the provider has a usage API
16 error: str | None = None # Set when the API call failed
17
18 # Usage counters (None = not available for this provider)
19 used: int | None = None
20 limit: int | None = None
21 remaining: int | None = None
22 reset_date: str | None = None # ISO date string, e.g. "2026-05-01"
23
24 # Tavily-specific breakdown
25 search_used: int | None = None
26 extract_used: int | None = None
27 crawl_used: int | None = None
28
29 def format(self) -> str:
30 """Return a human-readable multi-line string for /status output."""
31 lines = [f"🔍 Web Search: {self.provider}"]
32
33 if not self.supported:
34 lines.append(" Usage tracking: not available for this provider")
35 return "\n".join(lines)
36
37 if self.error:
38 lines.append(f" Usage: unavailable ({self.error})")
39 return "\n".join(lines)
40
41 if self.used is not None and self.limit is not None:
42 lines.append(f" Usage: {self.used} / {self.limit} requests")
43 elif self.used is not None:
44 lines.append(f" Usage: {self.used} requests")
45
46 # Tavily breakdown
47 breakdown_parts = []
48 if self.search_used is not None:
49 breakdown_parts.append(f"Search: {self.search_used}")
50 if self.extract_used is not None:
51 breakdown_parts.append(f"Extract: {self.extract_used}")
52 if self.crawl_used is not None:
53 breakdown_parts.append(f"Crawl: {self.crawl_used}")
54 if breakdown_parts:
55 lines.append(f" Breakdown: {' | '.join(breakdown_parts)}")
56
57 if self.remaining is not None:
58 lines.append(f" Remaining: {self.remaining} requests")
59
60 if self.reset_date:
61 lines.append(f" Resets: {self.reset_date}")
62
63 return "\n".join(lines)
64
65
66 async def fetch_search_usage(
67 provider: str,
68 api_key: str | None = None,
69 ) -> SearchUsageInfo:
70 """
71 Fetch usage info for the configured web search provider.
72
73 Args:
74 provider: Provider name (e.g. "tavily", "brave", "duckduckgo").
75 api_key: API key for the provider (falls back to env vars).
76
77 Returns:
78 SearchUsageInfo with populated fields where available.
79 """
80 p = (provider or "duckduckgo").strip().lower()
81
82 if p == "tavily":
83 return await _fetch_tavily_usage(api_key)
84 else:
85 # brave, duckduckgo, searxng, jina, unknown — no usage API
86 return SearchUsageInfo(provider=p, supported=False)
87
88
89 # ---------------------------------------------------------------------------
90 # Tavily
91 # ---------------------------------------------------------------------------
92
93 async def _fetch_tavily_usage(api_key: str | None) -> SearchUsageInfo:
94 """Fetch usage from GET https://api.tavily.com/usage."""
95 import httpx
96
97 key = api_key or os.environ.get("TAVILY_API_KEY", "")
98 if not key:
99 return SearchUsageInfo(
100 provider="tavily",
101 supported=True,
102 error="TAVILY_API_KEY not configured",
103 )
104
105 try:
106 async with httpx.AsyncClient(timeout=8.0) as client:
107 r = await client.get(
108 "https://api.tavily.com/usage",
109 headers={"Authorization": f"Bearer {key}"},
110 )
111 r.raise_for_status()
112 data: dict[str, Any] = r.json()
113 return _parse_tavily_usage(data)
114 except httpx.HTTPStatusError as e:
115 return SearchUsageInfo(
116 provider="tavily",
117 supported=True,
118 error=f"HTTP {e.response.status_code}",
119 )
120 except Exception as e:
121 return SearchUsageInfo(
122 provider="tavily",
123 supported=True,
124 error=str(e)[:80],
125 )
126
127
128 def _parse_tavily_usage(data: dict[str, Any]) -> SearchUsageInfo:
129 """
130 Parse Tavily /usage response.
131
132 Actual API response shape:
133 {
134 "account": {
135 "current_plan": "Researcher",
136 "plan_usage": 20,
137 "plan_limit": 1000,
138 "search_usage": 20,
139 "crawl_usage": 0,
140 "extract_usage": 0,
141 "map_usage": 0,
142 "research_usage": 0,
143 "paygo_usage": 0,
144 "paygo_limit": null
145 }
146 }
147 """
148 account = data.get("account") or {}
149 used = account.get("plan_usage")
150 limit = account.get("plan_limit")
151
152 # Compute remaining
153 remaining = None
154 if used is not None and limit is not None:
155 remaining = max(0, limit - used)
156
157 return SearchUsageInfo(
158 provider="tavily",
159 supported=True,
160 used=used,
161 limit=limit,
162 remaining=remaining,
163 search_used=account.get("search_usage"),
164 extract_used=account.get("extract_usage"),
165 crawl_used=account.get("crawl_usage"),
166 )
167
168
169
169 lines PYTHON