polymath-agent 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polymath/__init__.py +2 -0
- polymath/adapters/__init__.py +7 -0
- polymath/adapters/base.py +175 -0
- polymath/adapters/claude.py +280 -0
- polymath/adapters/gemini.py +186 -0
- polymath/adapters/ollama.py +117 -0
- polymath/adapters/openai_adapter.py +168 -0
- polymath/bootstrap.py +159 -0
- polymath/command_registry.py +41 -0
- polymath/command_service.py +572 -0
- polymath/compressor.py +90 -0
- polymath/config.py +293 -0
- polymath/context_manager.py +76 -0
- polymath/context_store.py +336 -0
- polymath/detector.py +442 -0
- polymath/domain.py +78 -0
- polymath/execution_service.py +325 -0
- polymath/main.py +1293 -0
- polymath/memory/__init__.py +15 -0
- polymath/memory/chunker.py +6 -0
- polymath/memory/embedder.py +179 -0
- polymath/memory/migrate.py +2 -0
- polymath/memory/retriever.py +2 -0
- polymath/memory/store.py +9 -0
- polymath/memory/sync.py +2 -0
- polymath/memory/writer.py +9 -0
- polymath/model_policy.py +172 -0
- polymath/orchestrator/__init__.py +68 -0
- polymath/orchestrator/attempt_ledger.py +34 -0
- polymath/orchestrator/ensemble.py +229 -0
- polymath/orchestrator/fanout.py +322 -0
- polymath/orchestrator/output_policy.py +61 -0
- polymath/orchestrator/race.py +311 -0
- polymath/orchestrator/run_controller.py +91 -0
- polymath/orchestrator/speculative_review.py +120 -0
- polymath/orchestrator/state_responder.py +184 -0
- polymath/orchestrator/worker_pool.py +37 -0
- polymath/permissions.py +82 -0
- polymath/pipeline.py +700 -0
- polymath/project_config.py +229 -0
- polymath/project_runtime.py +109 -0
- polymath/router.py +127 -0
- polymath/setup_wizard.py +106 -0
- polymath/slash_commands.py +566 -0
- polymath/subagents.py +486 -0
- polymath/tools.py +333 -0
- polymath/ui_state.py +84 -0
- polymath/workspace.py +66 -0
- polymath_agent-0.4.0.dist-info/METADATA +693 -0
- polymath_agent-0.4.0.dist-info/RECORD +54 -0
- polymath_agent-0.4.0.dist-info/WHEEL +5 -0
- polymath_agent-0.4.0.dist-info/entry_points.txt +2 -0
- polymath_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
- polymath_agent-0.4.0.dist-info/top_level.txt +1 -0
polymath/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from polymath.adapters.base import BaseAdapter, Message
|
|
2
|
+
from polymath.adapters.claude import ClaudeAdapter
|
|
3
|
+
from polymath.adapters.gemini import GeminiAdapter
|
|
4
|
+
from polymath.adapters.openai_adapter import OpenAIAdapter
|
|
5
|
+
from polymath.adapters.ollama import OllamaAdapter
|
|
6
|
+
|
|
7
|
+
__all__ = ["BaseAdapter", "Message", "ClaudeAdapter", "GeminiAdapter", "OpenAIAdapter", "OllamaAdapter"]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, AsyncIterator, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
#: Output ceilings. Current models write up to 128K tokens, and 4096 was
|
|
9
|
+
#: cutting plans, reviews and file rewrites off mid-sentence. The
|
|
10
|
+
#: non-streaming figure stays well inside the SDK's HTTP timeout; streaming
|
|
11
|
+
#: has no such constraint, so it gets room.
|
|
12
|
+
DEFAULT_MAX_TOKENS = 16_000
|
|
13
|
+
DEFAULT_STREAM_MAX_TOKENS = 64_000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def truncation_notice(limit: int) -> str:
|
|
17
|
+
"""Appended to a reply the provider cut off at the output limit.
|
|
18
|
+
|
|
19
|
+
Without it a truncated answer is indistinguishable from a bad one: it
|
|
20
|
+
simply stops, and every layer downstream treats it as complete.
|
|
21
|
+
"""
|
|
22
|
+
return (
|
|
23
|
+
f"\n\n[truncated: the model hit its output limit of {limit:,} tokens. "
|
|
24
|
+
"Ask it to continue, or raise max_tokens.]"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthExpiredError(Exception):
|
|
29
|
+
"""Raised when an adapter receives an auth/401 error from the provider."""
|
|
30
|
+
def __init__(self, provider: str, relogin_cmd: str) -> None:
|
|
31
|
+
self.provider = provider
|
|
32
|
+
self.relogin_cmd = relogin_cmd
|
|
33
|
+
super().__init__(f"{provider} auth expired — run: {relogin_cmd}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_auth_failure(
|
|
37
|
+
exc: BaseException,
|
|
38
|
+
auth_types: tuple[type, ...] = (),
|
|
39
|
+
) -> bool:
|
|
40
|
+
"""Whether a provider exception means "your credentials are no longer good".
|
|
41
|
+
|
|
42
|
+
Every SDK raises a distinct class for this, so the class is the answer
|
|
43
|
+
whenever we can resolve it. Matching on the rendered message is the
|
|
44
|
+
fallback for the case where the SDK is too old to expose the class, and
|
|
45
|
+
it is deliberately narrow: an earlier version tested `"401" in text`,
|
|
46
|
+
which reported a rate limit whose body happened to mention 401 as an
|
|
47
|
+
expired login and sent the user off to log in again.
|
|
48
|
+
"""
|
|
49
|
+
if auth_types and isinstance(exc, auth_types):
|
|
50
|
+
return True
|
|
51
|
+
status = getattr(exc, "status_code", None)
|
|
52
|
+
if status is None:
|
|
53
|
+
response = getattr(exc, "response", None)
|
|
54
|
+
status = getattr(response, "status_code", None)
|
|
55
|
+
if status in (401, 403):
|
|
56
|
+
return True
|
|
57
|
+
text = str(exc).lower()
|
|
58
|
+
return any(
|
|
59
|
+
marker in text
|
|
60
|
+
for marker in (
|
|
61
|
+
"authentication_error", "authentication error",
|
|
62
|
+
"authentication failed", "unauthorized",
|
|
63
|
+
"invalid api key", "invalid_api_key",
|
|
64
|
+
"unauthenticated", "invalid_grant", "expired token",
|
|
65
|
+
"token has expired", "credentials",
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_rate_limit(exc: BaseException) -> bool:
|
|
71
|
+
"""Whether a provider exception means "slow down or you are out of quota".
|
|
72
|
+
|
|
73
|
+
Status first, then the SDK class name, then unambiguous phrases. Matching
|
|
74
|
+
on `"429" in str(exc)` alone classifies any error whose body happens to
|
|
75
|
+
contain those digits as a rate limit.
|
|
76
|
+
"""
|
|
77
|
+
status = getattr(exc, "status_code", None)
|
|
78
|
+
if status is None:
|
|
79
|
+
response = getattr(exc, "response", None)
|
|
80
|
+
status = getattr(response, "status_code", None)
|
|
81
|
+
if status == 429:
|
|
82
|
+
return True
|
|
83
|
+
if type(exc).__name__ in ("RateLimitError", "ResourceExhausted",
|
|
84
|
+
"TooManyRequests"):
|
|
85
|
+
return True
|
|
86
|
+
text = str(exc).lower()
|
|
87
|
+
return any(
|
|
88
|
+
marker in text
|
|
89
|
+
for marker in ("rate limit", "rate_limit", "insufficient_quota",
|
|
90
|
+
"quota exceeded", "too many requests")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_exception_types(module_name: str, *names: str) -> tuple[type, ...]:
|
|
95
|
+
"""Look up exception classes on an SDK without importing it at module
|
|
96
|
+
scope — the SDKs are optional extras, so a missing one must not break
|
|
97
|
+
the import. Names the SDK does not define are skipped."""
|
|
98
|
+
import importlib
|
|
99
|
+
try:
|
|
100
|
+
module = importlib.import_module(module_name)
|
|
101
|
+
except Exception:
|
|
102
|
+
return ()
|
|
103
|
+
found = []
|
|
104
|
+
for name in names:
|
|
105
|
+
cls = getattr(module, name, None)
|
|
106
|
+
if isinstance(cls, type) and issubclass(cls, BaseException):
|
|
107
|
+
found.append(cls)
|
|
108
|
+
return tuple(found)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class Message:
|
|
113
|
+
role: str # "user" | "assistant" | "system" | "tool"
|
|
114
|
+
content: str
|
|
115
|
+
model: str = ""
|
|
116
|
+
step: str = ""
|
|
117
|
+
tool_calls: List[ToolCall] = field(default_factory=list)
|
|
118
|
+
tool_call_id: Optional[str] = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class ToolCall:
|
|
123
|
+
id: str
|
|
124
|
+
name: str
|
|
125
|
+
arguments: Dict[str, Any]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class BaseAdapter(ABC):
|
|
129
|
+
"""Every model adapter must implement this interface."""
|
|
130
|
+
|
|
131
|
+
provider: str = ""
|
|
132
|
+
|
|
133
|
+
@abstractmethod
|
|
134
|
+
async def complete(
|
|
135
|
+
self,
|
|
136
|
+
messages: list[Message],
|
|
137
|
+
model_id: str,
|
|
138
|
+
system: str = "",
|
|
139
|
+
tools: list[Any] | None = None,
|
|
140
|
+
temperature: float = 0.7,
|
|
141
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
142
|
+
) -> str | Message:
|
|
143
|
+
"""Send messages and return the full response text or Message with tool calls."""
|
|
144
|
+
...
|
|
145
|
+
|
|
146
|
+
@abstractmethod
|
|
147
|
+
async def stream(
|
|
148
|
+
self,
|
|
149
|
+
messages: list[Message],
|
|
150
|
+
model_id: str,
|
|
151
|
+
system: str = "",
|
|
152
|
+
tools: list[Any] | None = None,
|
|
153
|
+
temperature: float = 0.7,
|
|
154
|
+
max_tokens: int = DEFAULT_STREAM_MAX_TOKENS,
|
|
155
|
+
) -> AsyncIterator[str | ToolCall]:
|
|
156
|
+
"""Stream response tokens or tool call chunks."""
|
|
157
|
+
...
|
|
158
|
+
|
|
159
|
+
def count_tokens(self, text: str) -> int:
|
|
160
|
+
"""Local token estimate. Never a network call, never exact.
|
|
161
|
+
|
|
162
|
+
Used where an async context is not available. Prefer
|
|
163
|
+
count_tokens_async, which asks the provider where the provider can
|
|
164
|
+
answer.
|
|
165
|
+
"""
|
|
166
|
+
return len(text) // 4
|
|
167
|
+
|
|
168
|
+
async def count_tokens_async(self, text: str, model_id: str = "") -> int:
|
|
169
|
+
"""Token count, exact where the provider offers a counting endpoint.
|
|
170
|
+
|
|
171
|
+
Defaults to the local estimate. Adapters that can do better override
|
|
172
|
+
this; they must fall back to the estimate rather than raise, since a
|
|
173
|
+
count is only ever used to budget context.
|
|
174
|
+
"""
|
|
175
|
+
return self.count_tokens(text)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from typing import Any, AsyncIterator
|
|
5
|
+
|
|
6
|
+
from polymath.adapters.base import (
|
|
7
|
+
AuthExpiredError, BaseAdapter, DEFAULT_MAX_TOKENS,
|
|
8
|
+
DEFAULT_STREAM_MAX_TOKENS, Message, ToolCall,
|
|
9
|
+
is_auth_failure, resolve_exception_types, truncation_notice,
|
|
10
|
+
)
|
|
11
|
+
from polymath.config import get_model
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _auth_types() -> tuple[type, ...]:
|
|
15
|
+
return resolve_exception_types(
|
|
16
|
+
"anthropic", "AuthenticationError", "PermissionDeniedError",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
#: Rough characters-per-token for English prose. Only used when the API
|
|
21
|
+
#: cannot be reached; count_tokens_async is the real answer.
|
|
22
|
+
_CLAUDE_CHARS_PER_TOKEN = 4
|
|
23
|
+
|
|
24
|
+
#: Model used for counting when the caller did not name one. Counting is
|
|
25
|
+
#: model-specific, so this is a fallback, not a default worth relying on.
|
|
26
|
+
_DEFAULT_COUNTING_MODEL = "claude-sonnet-5"
|
|
27
|
+
|
|
28
|
+
#: (model, sha256 of text) -> token count. Cleared wholesale when full;
|
|
29
|
+
#: counts are cheap to recompute and this is only a budget figure.
|
|
30
|
+
_TOKEN_COUNT_CACHE: dict[tuple[str, str], int] = {}
|
|
31
|
+
_TOKEN_COUNT_CACHE_MAX = 512
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _accepts_temperature(model_id: str) -> bool:
|
|
35
|
+
"""Whether to send a sampling temperature to this model.
|
|
36
|
+
|
|
37
|
+
Claude removed temperature/top_p/top_k with the 4.7 generation; sending
|
|
38
|
+
one returns a 400. An id we do not recognise is assumed to be a newer
|
|
39
|
+
model and gets no temperature — omitting it is valid on every Claude
|
|
40
|
+
model ever released, so unknown-means-omit is the safe default.
|
|
41
|
+
"""
|
|
42
|
+
model = get_model(model_id)
|
|
43
|
+
return bool(model and model.supports_temperature)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ClaudeAdapter(BaseAdapter):
|
|
47
|
+
provider = "claude"
|
|
48
|
+
|
|
49
|
+
def __init__(self, api_key: str) -> None:
|
|
50
|
+
import anthropic
|
|
51
|
+
self._client = anthropic.AsyncAnthropic(api_key=api_key)
|
|
52
|
+
self._last_input_tokens: int = 0
|
|
53
|
+
self._last_output_tokens: int = 0
|
|
54
|
+
|
|
55
|
+
def _convert_tools(self, tools: list[Any] | None) -> list[dict] | None:
|
|
56
|
+
if not tools:
|
|
57
|
+
return None
|
|
58
|
+
claude_tools = []
|
|
59
|
+
for t in tools:
|
|
60
|
+
schema = t.to_json_schema()
|
|
61
|
+
claude_tools.append({
|
|
62
|
+
"name": schema["name"],
|
|
63
|
+
"description": schema["description"],
|
|
64
|
+
"input_schema": schema["parameters"]
|
|
65
|
+
})
|
|
66
|
+
return claude_tools
|
|
67
|
+
|
|
68
|
+
async def complete(
|
|
69
|
+
self,
|
|
70
|
+
messages: list[Message],
|
|
71
|
+
model_id: str,
|
|
72
|
+
system: str = "",
|
|
73
|
+
tools: list[Any] | None = None,
|
|
74
|
+
temperature: float = 0.7,
|
|
75
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
76
|
+
) -> str | Message:
|
|
77
|
+
claude_msgs = []
|
|
78
|
+
for m in messages:
|
|
79
|
+
if m.role == "system":
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
content = []
|
|
83
|
+
if m.content:
|
|
84
|
+
content.append({"type": "text", "text": m.content})
|
|
85
|
+
|
|
86
|
+
for tc in m.tool_calls:
|
|
87
|
+
content.append({
|
|
88
|
+
"type": "tool_use",
|
|
89
|
+
"id": tc.id,
|
|
90
|
+
"name": tc.name,
|
|
91
|
+
"input": tc.arguments
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
if m.role == "tool":
|
|
95
|
+
# Special handling for tool results in Claude
|
|
96
|
+
claude_msgs.append({
|
|
97
|
+
"role": "user",
|
|
98
|
+
"content": [{
|
|
99
|
+
"type": "tool_result",
|
|
100
|
+
"tool_use_id": m.tool_call_id,
|
|
101
|
+
"content": m.content
|
|
102
|
+
}]
|
|
103
|
+
})
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
claude_msgs.append({"role": m.role, "content": content})
|
|
107
|
+
|
|
108
|
+
kwargs = dict(
|
|
109
|
+
model=model_id,
|
|
110
|
+
max_tokens=max_tokens,
|
|
111
|
+
messages=claude_msgs,
|
|
112
|
+
)
|
|
113
|
+
if _accepts_temperature(model_id):
|
|
114
|
+
kwargs["temperature"] = temperature
|
|
115
|
+
if system:
|
|
116
|
+
kwargs["system"] = system
|
|
117
|
+
if tools:
|
|
118
|
+
kwargs["tools"] = self._convert_tools(tools)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
resp = await self._client.messages.create(**kwargs)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
if is_auth_failure(e, _auth_types()):
|
|
124
|
+
raise AuthExpiredError("claude", "claude") from e
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
if hasattr(resp, "usage") and resp.usage:
|
|
128
|
+
self._last_input_tokens = getattr(resp.usage, "input_tokens", 0)
|
|
129
|
+
self._last_output_tokens = getattr(resp.usage, "output_tokens", 0)
|
|
130
|
+
|
|
131
|
+
# Collect all tool_use blocks (regardless of stop_reason)
|
|
132
|
+
tool_calls = []
|
|
133
|
+
content_text = ""
|
|
134
|
+
for block in resp.content:
|
|
135
|
+
if block.type == "text":
|
|
136
|
+
content_text += block.text
|
|
137
|
+
elif block.type == "tool_use":
|
|
138
|
+
tool_calls.append(ToolCall(
|
|
139
|
+
id=block.id,
|
|
140
|
+
name=block.name,
|
|
141
|
+
arguments=block.input,
|
|
142
|
+
))
|
|
143
|
+
|
|
144
|
+
if getattr(resp, "stop_reason", None) == "max_tokens":
|
|
145
|
+
content_text += truncation_notice(max_tokens)
|
|
146
|
+
|
|
147
|
+
if tool_calls:
|
|
148
|
+
return Message(
|
|
149
|
+
role="assistant",
|
|
150
|
+
content=content_text,
|
|
151
|
+
tool_calls=tool_calls,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return content_text
|
|
155
|
+
|
|
156
|
+
async def stream(
|
|
157
|
+
self,
|
|
158
|
+
messages: list[Message],
|
|
159
|
+
model_id: str,
|
|
160
|
+
system: str = "",
|
|
161
|
+
tools: list[Any] | None = None,
|
|
162
|
+
temperature: float = 0.7,
|
|
163
|
+
max_tokens: int = DEFAULT_STREAM_MAX_TOKENS,
|
|
164
|
+
) -> AsyncIterator[str | ToolCall]:
|
|
165
|
+
# Fall back to complete() when tools are present since streaming + tools is complex.
|
|
166
|
+
if tools:
|
|
167
|
+
result = await self.complete(
|
|
168
|
+
messages=messages,
|
|
169
|
+
model_id=model_id,
|
|
170
|
+
system=system,
|
|
171
|
+
tools=tools,
|
|
172
|
+
temperature=temperature,
|
|
173
|
+
max_tokens=max_tokens,
|
|
174
|
+
)
|
|
175
|
+
if isinstance(result, Message):
|
|
176
|
+
if result.content:
|
|
177
|
+
yield result.content
|
|
178
|
+
for tc in result.tool_calls:
|
|
179
|
+
yield tc
|
|
180
|
+
else:
|
|
181
|
+
yield result
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
# Build properly formatted messages for streaming (no tools)
|
|
185
|
+
claude_msgs = []
|
|
186
|
+
for m in messages:
|
|
187
|
+
if m.role == "system":
|
|
188
|
+
continue
|
|
189
|
+
if m.role == "tool":
|
|
190
|
+
claude_msgs.append({
|
|
191
|
+
"role": "user",
|
|
192
|
+
"content": [{
|
|
193
|
+
"type": "tool_result",
|
|
194
|
+
"tool_use_id": m.tool_call_id,
|
|
195
|
+
"content": m.content,
|
|
196
|
+
}]
|
|
197
|
+
})
|
|
198
|
+
continue
|
|
199
|
+
content = []
|
|
200
|
+
if m.content:
|
|
201
|
+
content.append({"type": "text", "text": m.content})
|
|
202
|
+
for tc in m.tool_calls:
|
|
203
|
+
content.append({
|
|
204
|
+
"type": "tool_use",
|
|
205
|
+
"id": tc.id,
|
|
206
|
+
"name": tc.name,
|
|
207
|
+
"input": tc.arguments,
|
|
208
|
+
})
|
|
209
|
+
claude_msgs.append({"role": m.role, "content": content})
|
|
210
|
+
|
|
211
|
+
kwargs = dict(
|
|
212
|
+
model=model_id,
|
|
213
|
+
max_tokens=max_tokens,
|
|
214
|
+
messages=claude_msgs,
|
|
215
|
+
)
|
|
216
|
+
if _accepts_temperature(model_id):
|
|
217
|
+
kwargs["temperature"] = temperature
|
|
218
|
+
if system:
|
|
219
|
+
kwargs["system"] = system
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
async with self._client.messages.stream(**kwargs) as stream:
|
|
223
|
+
async for text in stream.text_stream:
|
|
224
|
+
yield text
|
|
225
|
+
# The answer is already in the user's hands by this point.
|
|
226
|
+
# Reporting truncation is a nicety, so it must never be able
|
|
227
|
+
# to fail the run that just succeeded — an SDK without
|
|
228
|
+
# get_final_message would otherwise turn a complete answer
|
|
229
|
+
# into an exception.
|
|
230
|
+
try:
|
|
231
|
+
final = await stream.get_final_message()
|
|
232
|
+
truncated = getattr(final, "stop_reason", None) == "max_tokens"
|
|
233
|
+
except Exception:
|
|
234
|
+
truncated = False
|
|
235
|
+
if truncated:
|
|
236
|
+
yield truncation_notice(max_tokens)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
if is_auth_failure(e, _auth_types()):
|
|
239
|
+
raise AuthExpiredError("claude", "claude") from e
|
|
240
|
+
raise
|
|
241
|
+
|
|
242
|
+
def count_tokens(self, text: str) -> int:
|
|
243
|
+
"""Local estimate for Claude.
|
|
244
|
+
|
|
245
|
+
This used to load tiktoken's cl100k_base, which is OpenAI's encoding
|
|
246
|
+
and not Claude's, so the number was wrong by an unknown margin in an
|
|
247
|
+
unknown direction. There is no local Claude tokenizer; a character
|
|
248
|
+
ratio is the honest local answer, and count_tokens_async gets the
|
|
249
|
+
real one from the API.
|
|
250
|
+
"""
|
|
251
|
+
return max(1, len(text) // _CLAUDE_CHARS_PER_TOKEN) if text else 0
|
|
252
|
+
|
|
253
|
+
async def count_tokens_async(self, text: str, model_id: str = "") -> int:
|
|
254
|
+
"""Exact count from Anthropic's own counting endpoint.
|
|
255
|
+
|
|
256
|
+
Cached per (model, text): the context budget is recomputed on every
|
|
257
|
+
turn over a prompt that is mostly unchanged, and this would otherwise
|
|
258
|
+
be one network round trip each time.
|
|
259
|
+
"""
|
|
260
|
+
if not text:
|
|
261
|
+
return 0
|
|
262
|
+
model = model_id or _DEFAULT_COUNTING_MODEL
|
|
263
|
+
key = (model, hashlib.sha256(text.encode("utf-8")).hexdigest())
|
|
264
|
+
cached = _TOKEN_COUNT_CACHE.get(key)
|
|
265
|
+
if cached is not None:
|
|
266
|
+
return cached
|
|
267
|
+
try:
|
|
268
|
+
resp = await self._client.messages.count_tokens(
|
|
269
|
+
model=model,
|
|
270
|
+
messages=[{"role": "user", "content": text}],
|
|
271
|
+
)
|
|
272
|
+
count = int(resp.input_tokens)
|
|
273
|
+
except Exception:
|
|
274
|
+
# Offline, unkeyed, or an unknown model id. A budget figure is
|
|
275
|
+
# not worth failing a run over.
|
|
276
|
+
return self.count_tokens(text)
|
|
277
|
+
if len(_TOKEN_COUNT_CACHE) >= _TOKEN_COUNT_CACHE_MAX:
|
|
278
|
+
_TOKEN_COUNT_CACHE.clear()
|
|
279
|
+
_TOKEN_COUNT_CACHE[key] = count
|
|
280
|
+
return count
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, AsyncIterator
|
|
5
|
+
|
|
6
|
+
from polymath.adapters.base import (
|
|
7
|
+
AuthExpiredError, BaseAdapter, DEFAULT_MAX_TOKENS,
|
|
8
|
+
DEFAULT_STREAM_MAX_TOKENS, Message, ToolCall,
|
|
9
|
+
is_auth_failure, resolve_exception_types,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _auth_types() -> tuple[type, ...]:
|
|
14
|
+
return resolve_exception_types(
|
|
15
|
+
"google.genai.errors", "ClientError", "UnauthenticatedError",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GeminiAdapter(BaseAdapter):
|
|
20
|
+
provider = "gemini"
|
|
21
|
+
|
|
22
|
+
def __init__(self, api_key: str) -> None:
|
|
23
|
+
from google import genai
|
|
24
|
+
from google.genai import types
|
|
25
|
+
# OAuth tokens (from gemini CLI) start with "ya29." — use http_options
|
|
26
|
+
# to pass them as a bearer token instead of an API key.
|
|
27
|
+
if api_key.startswith("ya29."):
|
|
28
|
+
self._client = genai.Client(
|
|
29
|
+
http_options={"headers": {"Authorization": f"Bearer {api_key}"}},
|
|
30
|
+
)
|
|
31
|
+
else:
|
|
32
|
+
self._client = genai.Client(api_key=api_key)
|
|
33
|
+
self._types = types
|
|
34
|
+
self._last_input_tokens: int = 0
|
|
35
|
+
self._last_output_tokens: int = 0
|
|
36
|
+
|
|
37
|
+
def _build_contents(self, messages: list[Message]) -> list:
|
|
38
|
+
"""Convert Message list to Gemini contents format."""
|
|
39
|
+
contents = []
|
|
40
|
+
for m in messages:
|
|
41
|
+
if m.role == "system":
|
|
42
|
+
continue
|
|
43
|
+
role = "user" if m.role in ("user", "tool") else "model"
|
|
44
|
+
parts = []
|
|
45
|
+
|
|
46
|
+
if m.role == "tool":
|
|
47
|
+
# Tool result — send as function_response
|
|
48
|
+
parts.append(self._types.Part(
|
|
49
|
+
function_response=self._types.FunctionResponse(
|
|
50
|
+
name=m.model,
|
|
51
|
+
response={"result": m.content},
|
|
52
|
+
)
|
|
53
|
+
))
|
|
54
|
+
elif m.tool_calls:
|
|
55
|
+
# Assistant message with tool calls
|
|
56
|
+
if m.content:
|
|
57
|
+
parts.append(self._types.Part(text=m.content))
|
|
58
|
+
for tc in m.tool_calls:
|
|
59
|
+
parts.append(self._types.Part(
|
|
60
|
+
function_call=self._types.FunctionCall(
|
|
61
|
+
name=tc.name,
|
|
62
|
+
args=tc.arguments,
|
|
63
|
+
)
|
|
64
|
+
))
|
|
65
|
+
else:
|
|
66
|
+
parts.append(self._types.Part(text=m.content or ""))
|
|
67
|
+
|
|
68
|
+
contents.append(self._types.Content(role=role, parts=parts))
|
|
69
|
+
return contents
|
|
70
|
+
|
|
71
|
+
def _build_tools(self, tools: list[Any] | None):
|
|
72
|
+
if not tools:
|
|
73
|
+
return None
|
|
74
|
+
declarations = []
|
|
75
|
+
for t in tools:
|
|
76
|
+
schema = t.parameters.copy() if t.parameters else {}
|
|
77
|
+
declarations.append(self._types.FunctionDeclaration(
|
|
78
|
+
name=t.name,
|
|
79
|
+
description=t.description,
|
|
80
|
+
parameters=schema,
|
|
81
|
+
))
|
|
82
|
+
return [self._types.Tool(function_declarations=declarations)]
|
|
83
|
+
|
|
84
|
+
def _parse_response(self, resp) -> str | Message:
|
|
85
|
+
try:
|
|
86
|
+
usage = resp.usage_metadata
|
|
87
|
+
if usage:
|
|
88
|
+
self._last_input_tokens = getattr(usage, "prompt_token_count", 0) or 0
|
|
89
|
+
self._last_output_tokens = getattr(usage, "candidates_token_count", 0) or 0
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
tool_calls = []
|
|
95
|
+
content_text = ""
|
|
96
|
+
for part in resp.candidates[0].content.parts:
|
|
97
|
+
if part.function_call and part.function_call.name:
|
|
98
|
+
fc = part.function_call
|
|
99
|
+
tool_calls.append(ToolCall(
|
|
100
|
+
id=fc.name,
|
|
101
|
+
name=fc.name,
|
|
102
|
+
arguments=dict(fc.args) if fc.args else {},
|
|
103
|
+
))
|
|
104
|
+
elif part.text:
|
|
105
|
+
content_text += part.text
|
|
106
|
+
if tool_calls:
|
|
107
|
+
return Message(role="assistant", content=content_text, tool_calls=tool_calls)
|
|
108
|
+
return content_text
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return resp.text
|
|
114
|
+
except Exception:
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
async def complete(
|
|
118
|
+
self,
|
|
119
|
+
messages: list[Message],
|
|
120
|
+
model_id: str,
|
|
121
|
+
system: str = "",
|
|
122
|
+
tools: list[Any] | None = None,
|
|
123
|
+
temperature: float = 0.7,
|
|
124
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
125
|
+
) -> str | Message:
|
|
126
|
+
contents = self._build_contents(messages)
|
|
127
|
+
config = self._types.GenerateContentConfig(
|
|
128
|
+
system_instruction=system or None,
|
|
129
|
+
temperature=temperature,
|
|
130
|
+
max_output_tokens=max_tokens,
|
|
131
|
+
tools=self._build_tools(tools),
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
resp = await self._client.aio.models.generate_content(
|
|
135
|
+
model=model_id,
|
|
136
|
+
contents=contents,
|
|
137
|
+
config=config,
|
|
138
|
+
)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
if is_auth_failure(e):
|
|
141
|
+
raise AuthExpiredError("gemini", "gemini auth login") from e
|
|
142
|
+
raise
|
|
143
|
+
return self._parse_response(resp)
|
|
144
|
+
|
|
145
|
+
async def stream(
|
|
146
|
+
self,
|
|
147
|
+
messages: list[Message],
|
|
148
|
+
model_id: str,
|
|
149
|
+
system: str = "",
|
|
150
|
+
tools: list[Any] | None = None,
|
|
151
|
+
temperature: float = 0.7,
|
|
152
|
+
max_tokens: int = DEFAULT_STREAM_MAX_TOKENS,
|
|
153
|
+
) -> AsyncIterator[str | ToolCall]:
|
|
154
|
+
# If tools requested, fall back to complete() — streaming + tools is complex
|
|
155
|
+
if tools:
|
|
156
|
+
result = await self.complete(messages, model_id, system, tools, temperature, max_tokens)
|
|
157
|
+
if isinstance(result, Message):
|
|
158
|
+
if result.content:
|
|
159
|
+
yield result.content
|
|
160
|
+
for tc in result.tool_calls:
|
|
161
|
+
yield tc
|
|
162
|
+
else:
|
|
163
|
+
yield result
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
contents = self._build_contents(messages)
|
|
167
|
+
config = self._types.GenerateContentConfig(
|
|
168
|
+
system_instruction=system or None,
|
|
169
|
+
temperature=temperature,
|
|
170
|
+
max_output_tokens=max_tokens,
|
|
171
|
+
)
|
|
172
|
+
try:
|
|
173
|
+
async for chunk in await self._client.aio.models.generate_content_stream(
|
|
174
|
+
model=model_id,
|
|
175
|
+
contents=contents,
|
|
176
|
+
config=config,
|
|
177
|
+
):
|
|
178
|
+
try:
|
|
179
|
+
if chunk.text:
|
|
180
|
+
yield chunk.text
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
except Exception as e:
|
|
184
|
+
if is_auth_failure(e):
|
|
185
|
+
raise AuthExpiredError("gemini", "gemini auth login") from e
|
|
186
|
+
raise
|