anti-client 0.1.0__tar.gz
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.
- anti_client-0.1.0/LICENSE +21 -0
- anti_client-0.1.0/PKG-INFO +74 -0
- anti_client-0.1.0/README.md +60 -0
- anti_client-0.1.0/pyproject.toml +21 -0
- anti_client-0.1.0/setup.cfg +4 -0
- anti_client-0.1.0/src/anti_client/__init__.py +22 -0
- anti_client-0.1.0/src/anti_client/agent.py +188 -0
- anti_client-0.1.0/src/anti_client/client.py +615 -0
- anti_client-0.1.0/src/anti_client/exceptions.py +14 -0
- anti_client-0.1.0/src/anti_client/types.py +115 -0
- anti_client-0.1.0/src/anti_client.egg-info/PKG-INFO +74 -0
- anti_client-0.1.0/src/anti_client.egg-info/SOURCES.txt +13 -0
- anti_client-0.1.0/src/anti_client.egg-info/dependency_links.txt +1 -0
- anti_client-0.1.0/src/anti_client.egg-info/requires.txt +1 -0
- anti_client-0.1.0/src/anti_client.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anti-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A full-featured async Python client for the internal Antigravity API with Tool Calling and Streaming support.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/makobcki/anti-client
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: httpx
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# anti-client
|
|
16
|
+
|
|
17
|
+
A full-featured Python client library for interacting with the internal Anrigravity API (Gemini).
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **OAuth 2.0 Authentication**: Built-in authentication flow with token caching (`~/.anti-api/accounts.json`).
|
|
22
|
+
- **Function Calling**: Complete support for tool execution by agents.
|
|
23
|
+
- **Streaming**: Native streaming capabilities, seamlessly integrated with function calling.
|
|
24
|
+
- **Resource Management**: Automated model discovery, quota checking, and precise usage statistics.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Install the package directly from the source directory:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage Example
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from anti_client import Client, Agent, Tool
|
|
38
|
+
|
|
39
|
+
# 1. Initialize Client (Triggers OAuth flow if tokens are missing)
|
|
40
|
+
client = Client()
|
|
41
|
+
|
|
42
|
+
# 2. Define Tools
|
|
43
|
+
def get_weather(city: str) -> str:
|
|
44
|
+
"""Retrieve weather information for a specific city."""
|
|
45
|
+
return f"The weather in {city} is clear and sunny."
|
|
46
|
+
|
|
47
|
+
weather_tool = Tool(
|
|
48
|
+
name="get_weather",
|
|
49
|
+
description="Retrieve the current weather for a specified city.",
|
|
50
|
+
parameters={
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"city": {"type": "string", "description": "The name of the city."}
|
|
54
|
+
},
|
|
55
|
+
"required": ["city"],
|
|
56
|
+
},
|
|
57
|
+
func=get_weather
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# 3. Initialize Agent
|
|
61
|
+
agent = Agent(
|
|
62
|
+
client=client,
|
|
63
|
+
model="gemini-3.1-pro-low",
|
|
64
|
+
system_prompt="You are a helpful assistant.",
|
|
65
|
+
tools=[weather_tool]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# 4. Generate Response
|
|
69
|
+
response = agent.run("Hello! What's the weather like in London?")
|
|
70
|
+
|
|
71
|
+
if not isinstance(response, type((x for x in []))): # If stream=False
|
|
72
|
+
print("Response:", response.text)
|
|
73
|
+
print("Tokens Used:", response.usage.total_tokens)
|
|
74
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# anti-client
|
|
2
|
+
|
|
3
|
+
A full-featured Python client library for interacting with the internal Anrigravity API (Gemini).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **OAuth 2.0 Authentication**: Built-in authentication flow with token caching (`~/.anti-api/accounts.json`).
|
|
8
|
+
- **Function Calling**: Complete support for tool execution by agents.
|
|
9
|
+
- **Streaming**: Native streaming capabilities, seamlessly integrated with function calling.
|
|
10
|
+
- **Resource Management**: Automated model discovery, quota checking, and precise usage statistics.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Install the package directly from the source directory:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install .
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage Example
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from anti_client import Client, Agent, Tool
|
|
24
|
+
|
|
25
|
+
# 1. Initialize Client (Triggers OAuth flow if tokens are missing)
|
|
26
|
+
client = Client()
|
|
27
|
+
|
|
28
|
+
# 2. Define Tools
|
|
29
|
+
def get_weather(city: str) -> str:
|
|
30
|
+
"""Retrieve weather information for a specific city."""
|
|
31
|
+
return f"The weather in {city} is clear and sunny."
|
|
32
|
+
|
|
33
|
+
weather_tool = Tool(
|
|
34
|
+
name="get_weather",
|
|
35
|
+
description="Retrieve the current weather for a specified city.",
|
|
36
|
+
parameters={
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"city": {"type": "string", "description": "The name of the city."}
|
|
40
|
+
},
|
|
41
|
+
"required": ["city"],
|
|
42
|
+
},
|
|
43
|
+
func=get_weather
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# 3. Initialize Agent
|
|
47
|
+
agent = Agent(
|
|
48
|
+
client=client,
|
|
49
|
+
model="gemini-3.1-pro-low",
|
|
50
|
+
system_prompt="You are a helpful assistant.",
|
|
51
|
+
tools=[weather_tool]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# 4. Generate Response
|
|
55
|
+
response = agent.run("Hello! What's the weather like in London?")
|
|
56
|
+
|
|
57
|
+
if not isinstance(response, type((x for x in []))): # If stream=False
|
|
58
|
+
print("Response:", response.text)
|
|
59
|
+
print("Tokens Used:", response.usage.total_tokens)
|
|
60
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "anti-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A full-featured async Python client for the internal Antigravity API with Tool Calling and Streaming support."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"httpx",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
"Homepage" = "https://github.com/makobcki/anti-client"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .client import Client, authenticate
|
|
2
|
+
from .agent import Agent
|
|
3
|
+
from .types import Message, Tool, ToolCall, ChatResponse, UsageStats, QuotaInfo, ModelInfo
|
|
4
|
+
from .exceptions import AgentAPIError, AuthError, ModelNotFoundError, RateLimitError, ToolExecutionError
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Client",
|
|
8
|
+
"Agent",
|
|
9
|
+
"authenticate",
|
|
10
|
+
"Message",
|
|
11
|
+
"Tool",
|
|
12
|
+
"ToolCall",
|
|
13
|
+
"ChatResponse",
|
|
14
|
+
"UsageStats",
|
|
15
|
+
"QuotaInfo",
|
|
16
|
+
"ModelInfo",
|
|
17
|
+
"AgentAPIError",
|
|
18
|
+
"AuthError",
|
|
19
|
+
"ModelNotFoundError",
|
|
20
|
+
"RateLimitError",
|
|
21
|
+
"ToolExecutionError",
|
|
22
|
+
]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from typing import AsyncIterator, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from .client import Client
|
|
6
|
+
from .exceptions import AgentAPIError
|
|
7
|
+
from .types import Message, Tool, ToolCall, ChatResponse
|
|
8
|
+
|
|
9
|
+
class Agent:
|
|
10
|
+
"""An agent that interacts with the AI model asynchronously using history and tools.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
client (Client): An authenticated Client instance.
|
|
14
|
+
model (str): The name of the model to use (e.g., 'gemini-3.1-pro-low').
|
|
15
|
+
name (str, optional): The name of the agent. Defaults to "Assistant".
|
|
16
|
+
system_prompt (str, optional): The system instruction prompt. Defaults to "You are a helpful assistant.".
|
|
17
|
+
tools (Optional[List[Tool]], optional): A list of tools the agent can use. Defaults to None.
|
|
18
|
+
"""
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
client: Client,
|
|
22
|
+
model: str,
|
|
23
|
+
name: str = "Assistant",
|
|
24
|
+
system_prompt: str = "You are a helpful assistant.",
|
|
25
|
+
tools: Optional[List[Tool]] = None,
|
|
26
|
+
):
|
|
27
|
+
self.client = client
|
|
28
|
+
self.model = model
|
|
29
|
+
self.name = name
|
|
30
|
+
self.system_prompt = system_prompt
|
|
31
|
+
self.tools = tools or []
|
|
32
|
+
self._tool_map = {t.name: t for t in self.tools}
|
|
33
|
+
self.history: List[Message] = []
|
|
34
|
+
|
|
35
|
+
if self.system_prompt:
|
|
36
|
+
self.history.append(Message(role="system", content=self.system_prompt))
|
|
37
|
+
|
|
38
|
+
async def run(
|
|
39
|
+
self,
|
|
40
|
+
prompt: str,
|
|
41
|
+
temperature: float = 0.8,
|
|
42
|
+
stream: bool = False,
|
|
43
|
+
max_steps: int = 5,
|
|
44
|
+
**kwargs,
|
|
45
|
+
) -> Union[ChatResponse, AsyncIterator[str]]:
|
|
46
|
+
"""Run the agent asynchronously with a new user prompt.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
prompt (str): The user's input prompt.
|
|
50
|
+
temperature (float, optional): The temperature for generation. Defaults to 0.8.
|
|
51
|
+
stream (bool, optional): If True, streams the response as an async iterator. Defaults to False.
|
|
52
|
+
max_steps (int, optional): The maximum number of tool execution steps allowed. Defaults to 5.
|
|
53
|
+
**kwargs: Additional keyword arguments passed to the client's generate method.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Union[ChatResponse, AsyncIterator[str]]: A ChatResponse object if stream is False, otherwise an async iterator yielding strings.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
AgentAPIError: If the maximum number of steps is exceeded while executing tools.
|
|
60
|
+
"""
|
|
61
|
+
self.history.append(Message(role="user", content=prompt))
|
|
62
|
+
|
|
63
|
+
if stream:
|
|
64
|
+
return self._run_stream(temperature, max_steps, **kwargs)
|
|
65
|
+
|
|
66
|
+
for step in range(max_steps):
|
|
67
|
+
response: ChatResponse = await self.client.generate(
|
|
68
|
+
model=self.model,
|
|
69
|
+
messages=self.history,
|
|
70
|
+
temperature=temperature,
|
|
71
|
+
stream=False,
|
|
72
|
+
tools=self.tools,
|
|
73
|
+
**kwargs,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self.history.append(
|
|
77
|
+
Message(
|
|
78
|
+
role="assistant",
|
|
79
|
+
content=response.text,
|
|
80
|
+
tool_calls=response.tool_calls,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if not response.tool_calls:
|
|
85
|
+
return response
|
|
86
|
+
|
|
87
|
+
for tool_call in response.tool_calls:
|
|
88
|
+
result = await self._execute_tool(tool_call)
|
|
89
|
+
self.history.append(
|
|
90
|
+
Message(role="tool", content=result, tool_call_id=tool_call.id)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
raise AgentAPIError(
|
|
94
|
+
f"Agent exceeded the maximum number of steps ({max_steps}) while executing tools."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def _execute_tool(self, tool_call: ToolCall) -> str:
|
|
98
|
+
"""Execute a requested tool call asynchronously.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
tool_call (ToolCall): The tool call requested by the model.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
str: A JSON-encoded string containing the result or error.
|
|
105
|
+
"""
|
|
106
|
+
tool = self._tool_map.get(tool_call.name)
|
|
107
|
+
if not tool:
|
|
108
|
+
return json.dumps({"error": f"Tool '{tool_call.name}' not found."})
|
|
109
|
+
|
|
110
|
+
if not tool.func:
|
|
111
|
+
return json.dumps(
|
|
112
|
+
{"error": f"Tool '{tool_call.name}' has no executable function."}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
if asyncio.iscoroutinefunction(tool.func):
|
|
117
|
+
result = await tool.func(**tool_call.arguments)
|
|
118
|
+
else:
|
|
119
|
+
result = tool.func(**tool_call.arguments)
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
json.dumps(result, ensure_ascii=False)
|
|
123
|
+
if isinstance(result, (dict, list))
|
|
124
|
+
else str(result)
|
|
125
|
+
)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
return json.dumps({"error": f"Execution failed: {str(e)}"})
|
|
128
|
+
|
|
129
|
+
async def _run_stream(self, temperature: float, max_steps: int, **kwargs) -> AsyncIterator[str]:
|
|
130
|
+
"""Run the agent in streaming mode asynchronously.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
temperature (float): The temperature for generation.
|
|
134
|
+
max_steps (int): The maximum number of tool execution steps allowed.
|
|
135
|
+
**kwargs: Additional keyword arguments.
|
|
136
|
+
|
|
137
|
+
Yields:
|
|
138
|
+
str: Chunks of generated text.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
AgentAPIError: If the maximum number of steps is exceeded while executing tools.
|
|
142
|
+
"""
|
|
143
|
+
for step in range(max_steps):
|
|
144
|
+
full_text = ""
|
|
145
|
+
tool_calls = []
|
|
146
|
+
|
|
147
|
+
stream_response = await self.client.generate(
|
|
148
|
+
model=self.model,
|
|
149
|
+
messages=self.history,
|
|
150
|
+
temperature=temperature,
|
|
151
|
+
stream=True,
|
|
152
|
+
tools=self.tools,
|
|
153
|
+
**kwargs,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async for chunk in stream_response:
|
|
157
|
+
if isinstance(chunk, list):
|
|
158
|
+
tool_calls = chunk
|
|
159
|
+
break
|
|
160
|
+
full_text += chunk
|
|
161
|
+
yield chunk
|
|
162
|
+
|
|
163
|
+
self.history.append(
|
|
164
|
+
Message(
|
|
165
|
+
role="assistant",
|
|
166
|
+
content=full_text if full_text else None,
|
|
167
|
+
tool_calls=tool_calls if tool_calls else None
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if not tool_calls:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
for tool_call in tool_calls:
|
|
175
|
+
result = await self._execute_tool(tool_call)
|
|
176
|
+
self.history.append(
|
|
177
|
+
Message(role="tool", content=result, tool_call_id=tool_call.id)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
raise AgentAPIError(
|
|
181
|
+
f"Agent exceeded the maximum number of steps ({max_steps}) while executing tools."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def clear_memory(self):
|
|
185
|
+
"""Clear the conversation history, retaining only the system prompt."""
|
|
186
|
+
self.history = []
|
|
187
|
+
if self.system_prompt:
|
|
188
|
+
self.history.append(Message(role="system", content=self.system_prompt))
|
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
import threading
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import uuid
|
|
7
|
+
import webbrowser
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
from typing import AsyncIterator, Dict, List, Optional, Union
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .exceptions import AgentAPIError, AuthError
|
|
14
|
+
from .types import (
|
|
15
|
+
ChatResponse,
|
|
16
|
+
Message,
|
|
17
|
+
ModelInfo,
|
|
18
|
+
QuotaInfo,
|
|
19
|
+
Tool,
|
|
20
|
+
ToolCall,
|
|
21
|
+
UsageStats,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
OAUTH_CONFIG = {
|
|
25
|
+
"client_id": os.environ.get("ANTI_CLIENT_ID", ""),
|
|
26
|
+
"client_secret": os.environ.get("ANTI_CLIENT_SECRET", ""),
|
|
27
|
+
"callback_port": 51121,
|
|
28
|
+
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
|
29
|
+
"token_url": "https://oauth2.googleapis.com/token",
|
|
30
|
+
"project_url": "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
|
31
|
+
"scopes": [
|
|
32
|
+
"https://www.googleapis.com/auth/cloud-platform",
|
|
33
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
34
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
35
|
+
"https://www.googleapis.com/auth/cclog",
|
|
36
|
+
"https://www.googleapis.com/auth/experimentsandconfigs",
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
auth_code = None
|
|
41
|
+
auth_error = None
|
|
42
|
+
server_ready = threading.Event()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
46
|
+
"""Handles the OAuth callback redirect locally."""
|
|
47
|
+
def do_GET(self):
|
|
48
|
+
"""Processes the GET request for the OAuth callback."""
|
|
49
|
+
global auth_code, auth_error
|
|
50
|
+
parsed_path = urllib.parse.urlparse(self.path)
|
|
51
|
+
|
|
52
|
+
if parsed_path.path == "/oauth-callback":
|
|
53
|
+
query_params = urllib.parse.parse_qs(parsed_path.query)
|
|
54
|
+
|
|
55
|
+
if "error" in query_params:
|
|
56
|
+
auth_error = query_params["error"][0]
|
|
57
|
+
elif "code" in query_params:
|
|
58
|
+
auth_code = query_params["code"][0]
|
|
59
|
+
|
|
60
|
+
self.send_response(302)
|
|
61
|
+
self.send_header("Location", "https://antigravity.google/auth-success")
|
|
62
|
+
self.end_headers()
|
|
63
|
+
|
|
64
|
+
threading.Thread(target=self.server.shutdown).start()
|
|
65
|
+
else:
|
|
66
|
+
self.send_response(404)
|
|
67
|
+
self.end_headers()
|
|
68
|
+
self.wfile.write(b"Not Found")
|
|
69
|
+
|
|
70
|
+
def log_message(self, format, *args):
|
|
71
|
+
"""Suppress standard log messages."""
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def start_server() -> int:
|
|
76
|
+
"""Starts the local HTTP server to receive the OAuth callback.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
int: The port number the server is listening on.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
Exception: If no available port could be bound.
|
|
83
|
+
"""
|
|
84
|
+
port = OAUTH_CONFIG["callback_port"]
|
|
85
|
+
max_offset = 10
|
|
86
|
+
httpd = None
|
|
87
|
+
|
|
88
|
+
for offset in range(max_offset + 1):
|
|
89
|
+
try:
|
|
90
|
+
httpd = HTTPServer(("127.0.0.1", port + offset), OAuthCallbackHandler)
|
|
91
|
+
break
|
|
92
|
+
except OSError:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
if not httpd:
|
|
96
|
+
raise Exception("Could not bind to any port for OAuth callback")
|
|
97
|
+
|
|
98
|
+
server_ready.set()
|
|
99
|
+
httpd.serve_forever()
|
|
100
|
+
return httpd.server_port
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def authenticate():
|
|
104
|
+
"""Authenticates the user via Google OAuth 2.0.
|
|
105
|
+
|
|
106
|
+
This function starts a local server, opens the user's browser, waits for the OAuth
|
|
107
|
+
callback, exchanges the code for tokens, retrieves the project ID, and saves the
|
|
108
|
+
authentication data to `~/.anti-api/accounts.json`.
|
|
109
|
+
"""
|
|
110
|
+
global auth_code, auth_error
|
|
111
|
+
auth_code = None
|
|
112
|
+
auth_error = None
|
|
113
|
+
server_ready.clear()
|
|
114
|
+
|
|
115
|
+
state = secrets.token_urlsafe(16)
|
|
116
|
+
|
|
117
|
+
server_thread = threading.Thread(target=start_server)
|
|
118
|
+
server_thread.daemon = True
|
|
119
|
+
server_thread.start()
|
|
120
|
+
|
|
121
|
+
server_ready.wait()
|
|
122
|
+
redirect_uri = f"http://localhost:{OAUTH_CONFIG['callback_port']}/oauth-callback"
|
|
123
|
+
|
|
124
|
+
params = {
|
|
125
|
+
"client_id": OAUTH_CONFIG["client_id"],
|
|
126
|
+
"redirect_uri": redirect_uri,
|
|
127
|
+
"response_type": "code",
|
|
128
|
+
"scope": " ".join(OAUTH_CONFIG["scopes"]),
|
|
129
|
+
"access_type": "offline",
|
|
130
|
+
"prompt": "consent",
|
|
131
|
+
"state": state,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
auth_url = f"{OAUTH_CONFIG['auth_url']}?{urllib.parse.urlencode(params)}"
|
|
135
|
+
|
|
136
|
+
print(
|
|
137
|
+
f"Opening browser for authorization...\nIf the browser does not open automatically, please visit:\n{auth_url}\n"
|
|
138
|
+
)
|
|
139
|
+
webbrowser.open(auth_url)
|
|
140
|
+
|
|
141
|
+
server_thread.join(timeout=300)
|
|
142
|
+
|
|
143
|
+
if auth_error:
|
|
144
|
+
print(f"Authorization error: {auth_error}")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
if not auth_code:
|
|
148
|
+
print("Authorization was not completed.")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
print("Authorization code received. Exchanging for tokens...")
|
|
152
|
+
|
|
153
|
+
token_data = {
|
|
154
|
+
"code": auth_code,
|
|
155
|
+
"client_id": OAUTH_CONFIG["client_id"],
|
|
156
|
+
"client_secret": OAUTH_CONFIG["client_secret"],
|
|
157
|
+
"redirect_uri": redirect_uri,
|
|
158
|
+
"grant_type": "authorization_code",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
response = httpx.post(
|
|
162
|
+
OAUTH_CONFIG["token_url"], data=token_data, timeout=60.0
|
|
163
|
+
)
|
|
164
|
+
if response.status_code != 200:
|
|
165
|
+
print(f"Token exchange error: {response.status_code}\n{response.text}")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
tokens = response.json()
|
|
169
|
+
access_token = tokens.get("access_token")
|
|
170
|
+
|
|
171
|
+
print("Retrieving Project ID...")
|
|
172
|
+
project_response = httpx.post(
|
|
173
|
+
OAUTH_CONFIG["project_url"],
|
|
174
|
+
headers={
|
|
175
|
+
"Authorization": f"Bearer {access_token}",
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
"User-Agent": "antigravity/2.0.10 macos/arm64",
|
|
178
|
+
},
|
|
179
|
+
json={"metadata": {"ideType": "ANTIGRAVITY"}},
|
|
180
|
+
timeout=60.0,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
project_id = "unknown"
|
|
184
|
+
if project_response.status_code == 200:
|
|
185
|
+
project_data = project_response.json()
|
|
186
|
+
project_id = project_data.get("cloudaicompanionProject", "unknown")
|
|
187
|
+
else:
|
|
188
|
+
print(
|
|
189
|
+
f"Failed to get Project ID: {project_response.status_code}\n{project_response.text}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
data_dir = os.environ.get("ANTI_API_DATA_DIR")
|
|
193
|
+
if not data_dir:
|
|
194
|
+
home = (
|
|
195
|
+
os.environ.get("HOME")
|
|
196
|
+
or os.environ.get("USERPROFILE")
|
|
197
|
+
or os.path.expanduser("~")
|
|
198
|
+
)
|
|
199
|
+
data_dir = os.path.join(home, ".anti-api")
|
|
200
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
201
|
+
|
|
202
|
+
accounts_file = os.path.join(data_dir, "accounts.json")
|
|
203
|
+
with open(accounts_file, "w", encoding="utf-8") as f:
|
|
204
|
+
json.dump(
|
|
205
|
+
{"accounts": [{"accessToken": access_token, "projectId": project_id}]}, f
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
print("Authorization completed and saved successfully!")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ModelsResource:
|
|
212
|
+
"""Provides methods for querying available models."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, client: "Client"):
|
|
215
|
+
"""Initializes the ModelsResource with a client.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
client (Client): The main client instance.
|
|
219
|
+
"""
|
|
220
|
+
self._client = client
|
|
221
|
+
|
|
222
|
+
async def list(self) -> List[ModelInfo]:
|
|
223
|
+
"""Fetches a list of all available models asynchronously.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
List[ModelInfo]: A list of available models and their metadata.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
AgentAPIError: If the API request fails.
|
|
230
|
+
"""
|
|
231
|
+
url = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"
|
|
232
|
+
|
|
233
|
+
headers = {
|
|
234
|
+
"Authorization": f"Bearer {self._client.api_key}",
|
|
235
|
+
"Content-Type": "application/json",
|
|
236
|
+
"User-Agent": "antigravity/2.0.10 macos/arm64",
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
data = {"project": self._client.project_id}
|
|
240
|
+
|
|
241
|
+
async with httpx.AsyncClient(timeout=60.0) as http_client:
|
|
242
|
+
response = await http_client.post(url, json=data, headers=headers)
|
|
243
|
+
|
|
244
|
+
if response.status_code != 200:
|
|
245
|
+
raise AgentAPIError(
|
|
246
|
+
f"Error fetching models {response.status_code}: {response.text}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
response_data = response.json()
|
|
250
|
+
models_dict = response_data.get("models", {})
|
|
251
|
+
|
|
252
|
+
parsed_models = []
|
|
253
|
+
for name, info in models_dict.items():
|
|
254
|
+
quota_raw = info.get("quotaInfo", {})
|
|
255
|
+
quota_info = (
|
|
256
|
+
QuotaInfo(
|
|
257
|
+
remaining_fraction=quota_raw.get("remainingFraction", 0.0),
|
|
258
|
+
reset_time=quota_raw.get("resetTime"),
|
|
259
|
+
)
|
|
260
|
+
if quota_raw
|
|
261
|
+
else None
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
parsed_models.append(
|
|
265
|
+
ModelInfo(
|
|
266
|
+
id=name,
|
|
267
|
+
internal_model_id=info.get("model", ""),
|
|
268
|
+
display_name=info.get("displayName", name),
|
|
269
|
+
api_provider=info.get("apiProvider", ""),
|
|
270
|
+
model_provider=info.get("modelProvider", ""),
|
|
271
|
+
max_tokens=info.get("maxTokens", 0),
|
|
272
|
+
max_output_tokens=None,
|
|
273
|
+
is_internal=info.get("isInternal", False),
|
|
274
|
+
supports_images=info.get("supportsImages", False),
|
|
275
|
+
supports_thinking=info.get("supportsThinking", False),
|
|
276
|
+
quota_info=quota_info,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return parsed_models
|
|
281
|
+
|
|
282
|
+
async def get(self) -> Dict[str, QuotaInfo]:
|
|
283
|
+
"""Gets a mapping of model IDs to their quota info asynchronously.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Dict[str, QuotaInfo]: A dictionary mapping model IDs to their quotas.
|
|
287
|
+
"""
|
|
288
|
+
models = await self.list()
|
|
289
|
+
return {m.id: m.quota_info for m in models if m.quota_info}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class Client:
|
|
293
|
+
"""The main async client for interacting with the Cloud Code (Gemini) API.
|
|
294
|
+
|
|
295
|
+
Attributes:
|
|
296
|
+
api_key (str): The OAuth access token.
|
|
297
|
+
project_id (str): The Google Cloud project ID.
|
|
298
|
+
models (ModelsResource): Resource for interacting with models.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
def __init__(self, api_key: Optional[str] = None, project_id: Optional[str] = None):
|
|
302
|
+
"""Initializes the client.
|
|
303
|
+
|
|
304
|
+
If `api_key` or `project_id` are not provided, it will attempt to load them from
|
|
305
|
+
the local cache file or environment variables.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
api_key (Optional[str], optional): The OAuth access token. Defaults to None.
|
|
309
|
+
project_id (Optional[str], optional): The Google Cloud project ID. Defaults to None.
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
AuthError: If authentication tokens are missing.
|
|
313
|
+
"""
|
|
314
|
+
self.api_key = api_key or os.environ.get("MY_API_KEY")
|
|
315
|
+
self.project_id = project_id
|
|
316
|
+
|
|
317
|
+
if not self.api_key or not self.project_id:
|
|
318
|
+
token, proj = self._load_account_info()
|
|
319
|
+
if not self.api_key:
|
|
320
|
+
self.api_key = token
|
|
321
|
+
if not self.project_id:
|
|
322
|
+
self.project_id = proj
|
|
323
|
+
|
|
324
|
+
if not self.api_key or self.api_key == "YOUR_ACCESS_TOKEN":
|
|
325
|
+
raise AuthError(
|
|
326
|
+
"API key not provided. Run anti_client.authenticate()"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
self.models = ModelsResource(self)
|
|
330
|
+
|
|
331
|
+
def _load_account_info(self):
|
|
332
|
+
"""Loads account information from the local credentials file.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
tuple: A tuple containing (access_token, project_id) or (None, None).
|
|
336
|
+
"""
|
|
337
|
+
data_dir = os.environ.get("ANTI_API_DATA_DIR")
|
|
338
|
+
if not data_dir:
|
|
339
|
+
home = (
|
|
340
|
+
os.environ.get("HOME")
|
|
341
|
+
or os.environ.get("USERPROFILE")
|
|
342
|
+
or os.path.expanduser("~")
|
|
343
|
+
)
|
|
344
|
+
data_dir = os.path.join(home, ".anti-api")
|
|
345
|
+
|
|
346
|
+
accounts_file = os.path.join(data_dir, "accounts.json")
|
|
347
|
+
|
|
348
|
+
if os.path.exists(accounts_file):
|
|
349
|
+
with open(accounts_file, "r", encoding="utf-8") as f:
|
|
350
|
+
data = json.load(f)
|
|
351
|
+
accounts = data.get("accounts", [])
|
|
352
|
+
if accounts:
|
|
353
|
+
return accounts[0].get("accessToken"), accounts[0].get(
|
|
354
|
+
"projectId", "unknown"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
return None, None
|
|
358
|
+
|
|
359
|
+
async def generate(
|
|
360
|
+
self,
|
|
361
|
+
model: str,
|
|
362
|
+
messages: List[Message],
|
|
363
|
+
temperature: float = 0.8,
|
|
364
|
+
stream: bool = False,
|
|
365
|
+
tools: Optional[List[Tool]] = None,
|
|
366
|
+
**kwargs,
|
|
367
|
+
) -> Union[ChatResponse, AsyncIterator[Union[str, List[ToolCall]]]]:
|
|
368
|
+
"""Generates a response from the model asynchronously.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
model (str): The model ID to use.
|
|
372
|
+
messages (List[Message]): The conversation history.
|
|
373
|
+
temperature (float, optional): The sampling temperature. Defaults to 0.8.
|
|
374
|
+
stream (bool, optional): Whether to stream the response. Defaults to False.
|
|
375
|
+
tools (Optional[List[Tool]], optional): A list of tools available to the model. Defaults to None.
|
|
376
|
+
**kwargs: Additional generation parameters.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Union[ChatResponse, AsyncIterator[Union[str, List[ToolCall]]]]: The full ChatResponse, or an async iterator yielding text chunks and eventually a list of tool calls.
|
|
380
|
+
|
|
381
|
+
Raises:
|
|
382
|
+
AgentAPIError: If the generation request fails.
|
|
383
|
+
"""
|
|
384
|
+
url = "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"
|
|
385
|
+
|
|
386
|
+
contents = []
|
|
387
|
+
system_instruction = None
|
|
388
|
+
|
|
389
|
+
for msg in messages:
|
|
390
|
+
if msg.role == "system":
|
|
391
|
+
system_instruction = {"role": "user", "parts": [{"text": msg.content}]}
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
role = "model" if msg.role == "assistant" else "user"
|
|
395
|
+
|
|
396
|
+
parts = []
|
|
397
|
+
if msg.content:
|
|
398
|
+
parts.append({"text": msg.content})
|
|
399
|
+
|
|
400
|
+
if msg.tool_calls:
|
|
401
|
+
for tc in msg.tool_calls:
|
|
402
|
+
part_dict = {
|
|
403
|
+
"functionCall": {
|
|
404
|
+
"id": tc.id,
|
|
405
|
+
"name": tc.name,
|
|
406
|
+
"args": tc.arguments,
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if tc.thought_signature:
|
|
410
|
+
part_dict["thoughtSignature"] = tc.thought_signature
|
|
411
|
+
parts.append(part_dict)
|
|
412
|
+
|
|
413
|
+
if msg.role == "tool":
|
|
414
|
+
try:
|
|
415
|
+
resp_data = json.loads(msg.content)
|
|
416
|
+
except:
|
|
417
|
+
resp_data = {"result": msg.content}
|
|
418
|
+
|
|
419
|
+
parts = [
|
|
420
|
+
{
|
|
421
|
+
"functionResponse": {
|
|
422
|
+
"name": msg.tool_call_id or "unknown_tool",
|
|
423
|
+
"response": resp_data,
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
]
|
|
427
|
+
role = "user"
|
|
428
|
+
|
|
429
|
+
contents.append({"role": role, "parts": parts})
|
|
430
|
+
|
|
431
|
+
payload = {
|
|
432
|
+
"model": model,
|
|
433
|
+
"userAgent": "antigravity/2.0.10 macos/arm64",
|
|
434
|
+
"requestType": "agent",
|
|
435
|
+
"project": self.project_id,
|
|
436
|
+
"requestId": f"agent-{uuid.uuid4()}",
|
|
437
|
+
"request": {
|
|
438
|
+
"contents": contents,
|
|
439
|
+
"sessionId": "-1234567890",
|
|
440
|
+
"generationConfig": {
|
|
441
|
+
"maxOutputTokens": 64000,
|
|
442
|
+
"stopSequences": ["\n\nHuman:", "[DONE]"],
|
|
443
|
+
"temperature": temperature,
|
|
444
|
+
},
|
|
445
|
+
"safetySettings": [
|
|
446
|
+
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
|
|
447
|
+
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
|
|
448
|
+
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
|
|
449
|
+
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
|
|
450
|
+
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
|
|
451
|
+
],
|
|
452
|
+
},
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if system_instruction:
|
|
456
|
+
payload["request"]["systemInstruction"] = system_instruction
|
|
457
|
+
|
|
458
|
+
if tools:
|
|
459
|
+
func_decls = []
|
|
460
|
+
for t in tools:
|
|
461
|
+
func_decls.append(
|
|
462
|
+
{
|
|
463
|
+
"name": t.name,
|
|
464
|
+
"description": t.description,
|
|
465
|
+
"parameters": t.parameters,
|
|
466
|
+
}
|
|
467
|
+
)
|
|
468
|
+
payload["request"]["tools"] = [{"functionDeclarations": func_decls}]
|
|
469
|
+
payload["request"]["toolConfig"] = {
|
|
470
|
+
"functionCallingConfig": {"mode": "ANY"}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
headers = {
|
|
474
|
+
"Content-Type": "application/json",
|
|
475
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
476
|
+
"User-Agent": "antigravity/2.0.10 macos/arm64",
|
|
477
|
+
"Accept": "text/event-stream",
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async def _do_stream() -> AsyncIterator[Union[str, List[ToolCall]]]:
|
|
481
|
+
async with httpx.AsyncClient(timeout=60.0) as http_client:
|
|
482
|
+
async with http_client.stream("POST", url, json=payload, headers=headers) as response:
|
|
483
|
+
if response.status_code != 200:
|
|
484
|
+
error_text = await response.aread()
|
|
485
|
+
raise AgentAPIError(f"Error {response.status_code}: {error_text.decode('utf-8', errors='replace')}")
|
|
486
|
+
|
|
487
|
+
async for chunk in self._stream_response(response):
|
|
488
|
+
yield chunk
|
|
489
|
+
|
|
490
|
+
async def _do_sync() -> ChatResponse:
|
|
491
|
+
async with httpx.AsyncClient(timeout=60.0) as http_client:
|
|
492
|
+
response = await http_client.post(url, json=payload, headers=headers)
|
|
493
|
+
if response.status_code != 200:
|
|
494
|
+
raise AgentAPIError(f"Error {response.status_code}: {response.text}")
|
|
495
|
+
return self._sync_response(response)
|
|
496
|
+
|
|
497
|
+
if stream:
|
|
498
|
+
return _do_stream()
|
|
499
|
+
else:
|
|
500
|
+
return await _do_sync()
|
|
501
|
+
|
|
502
|
+
def _sync_response(self, response: httpx.Response) -> ChatResponse:
|
|
503
|
+
"""Parses a synchronous (non-streaming) response from the API.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
response (httpx.Response): The raw HTTP response.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
ChatResponse: The fully parsed response containing text, usage, and tool calls.
|
|
510
|
+
"""
|
|
511
|
+
full_text = ""
|
|
512
|
+
tool_calls = []
|
|
513
|
+
finish_reason = "stop"
|
|
514
|
+
usage = UsageStats(0, 0, 0)
|
|
515
|
+
|
|
516
|
+
for line_str in response.iter_lines():
|
|
517
|
+
if line_str:
|
|
518
|
+
if line_str.startswith("data: "):
|
|
519
|
+
data_str = line_str[6:]
|
|
520
|
+
if data_str == "[DONE]":
|
|
521
|
+
break
|
|
522
|
+
try:
|
|
523
|
+
chunks = json.loads(data_str)
|
|
524
|
+
if not isinstance(chunks, list):
|
|
525
|
+
chunks = [chunks]
|
|
526
|
+
for chunk in chunks:
|
|
527
|
+
resp = chunk.get("response", {})
|
|
528
|
+
|
|
529
|
+
if "usageMetadata" in resp:
|
|
530
|
+
meta = resp["usageMetadata"]
|
|
531
|
+
usage = UsageStats(
|
|
532
|
+
prompt_tokens=meta.get("promptTokenCount", 0),
|
|
533
|
+
completion_tokens=meta.get(
|
|
534
|
+
"candidatesTokenCount", 0
|
|
535
|
+
),
|
|
536
|
+
total_tokens=meta.get("totalTokenCount", 0),
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
candidates = resp.get("candidates", [])
|
|
540
|
+
for candidate in candidates:
|
|
541
|
+
if "finishReason" in candidate:
|
|
542
|
+
finish_reason = candidate["finishReason"]
|
|
543
|
+
|
|
544
|
+
parts = candidate.get("content", {}).get("parts", [])
|
|
545
|
+
for part in parts:
|
|
546
|
+
if "text" in part:
|
|
547
|
+
full_text += part["text"]
|
|
548
|
+
if "functionCall" in part:
|
|
549
|
+
fc = part["functionCall"]
|
|
550
|
+
tool_calls.append(
|
|
551
|
+
ToolCall(
|
|
552
|
+
id=fc.get("id", fc.get("name")),
|
|
553
|
+
name=fc.get("name"),
|
|
554
|
+
arguments=fc.get("args", {}),
|
|
555
|
+
thought_signature=part.get(
|
|
556
|
+
"thoughtSignature"
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
except json.JSONDecodeError:
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
return ChatResponse(
|
|
564
|
+
text=full_text if full_text else None,
|
|
565
|
+
usage=usage,
|
|
566
|
+
finish_reason=finish_reason,
|
|
567
|
+
tool_calls=tool_calls if tool_calls else None,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
async def _stream_response(self, response: httpx.Response) -> AsyncIterator[Union[str, List[ToolCall]]]:
|
|
571
|
+
"""Parses an async streaming response from the API.
|
|
572
|
+
|
|
573
|
+
Yields text chunks and optionally yields a list of tool calls at the end.
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
response (httpx.Response): The raw HTTP response.
|
|
577
|
+
|
|
578
|
+
Yields:
|
|
579
|
+
Union[str, List[ToolCall]]: Chunks of text generated by the model, or a list of tool calls.
|
|
580
|
+
"""
|
|
581
|
+
tool_calls = []
|
|
582
|
+
async for line_str in response.aiter_lines():
|
|
583
|
+
if line_str:
|
|
584
|
+
if line_str.startswith("data: "):
|
|
585
|
+
data_str = line_str[6:]
|
|
586
|
+
if data_str == "[DONE]":
|
|
587
|
+
break
|
|
588
|
+
try:
|
|
589
|
+
chunks = json.loads(data_str)
|
|
590
|
+
if not isinstance(chunks, list):
|
|
591
|
+
chunks = [chunks]
|
|
592
|
+
for chunk in chunks:
|
|
593
|
+
candidates = chunk.get("response", {}).get("candidates", [])
|
|
594
|
+
for candidate in candidates:
|
|
595
|
+
parts = candidate.get("content", {}).get("parts", [])
|
|
596
|
+
for part in parts:
|
|
597
|
+
if "text" in part:
|
|
598
|
+
yield part["text"]
|
|
599
|
+
if "functionCall" in part:
|
|
600
|
+
fc = part["functionCall"]
|
|
601
|
+
tool_calls.append(
|
|
602
|
+
ToolCall(
|
|
603
|
+
id=fc.get("id", fc.get("name")),
|
|
604
|
+
name=fc.get("name"),
|
|
605
|
+
arguments=fc.get("args", {}),
|
|
606
|
+
thought_signature=part.get(
|
|
607
|
+
"thoughtSignature"
|
|
608
|
+
),
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
except json.JSONDecodeError:
|
|
612
|
+
pass
|
|
613
|
+
|
|
614
|
+
if tool_calls:
|
|
615
|
+
yield tool_calls
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class AgentAPIError(Exception):
|
|
2
|
+
"""Base exception for API errors."""
|
|
3
|
+
|
|
4
|
+
class AuthError(AgentAPIError):
|
|
5
|
+
"""Exception raised for authorization errors (e.g., invalid token)."""
|
|
6
|
+
|
|
7
|
+
class ModelNotFoundError(AgentAPIError):
|
|
8
|
+
"""Exception raised when a requested model is not found or is unavailable."""
|
|
9
|
+
|
|
10
|
+
class RateLimitError(AgentAPIError):
|
|
11
|
+
"""Exception raised when the rate limit is exceeded (HTTP 429)."""
|
|
12
|
+
|
|
13
|
+
class ToolExecutionError(AgentAPIError):
|
|
14
|
+
"""Exception raised when a tool (function) execution fails."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class ToolCall:
|
|
6
|
+
"""Represents a tool call requested by the model.
|
|
7
|
+
|
|
8
|
+
Attributes:
|
|
9
|
+
id (str): A unique identifier for the tool call.
|
|
10
|
+
name (str): The name of the tool/function to call.
|
|
11
|
+
arguments (Dict[str, Any]): The arguments to pass to the tool.
|
|
12
|
+
thought_signature (Optional[str]): An optional signature representing the model's reasoning process.
|
|
13
|
+
"""
|
|
14
|
+
id: str
|
|
15
|
+
name: str
|
|
16
|
+
arguments: Dict[str, Any]
|
|
17
|
+
thought_signature: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Tool:
|
|
21
|
+
"""Represents an executable tool that the agent can use.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
name (str): The name of the tool.
|
|
25
|
+
description (str): A description of what the tool does.
|
|
26
|
+
parameters (Dict[str, Any]): A JSON schema describing the tool's parameters.
|
|
27
|
+
func (Optional[Callable]): The actual Python function to execute.
|
|
28
|
+
"""
|
|
29
|
+
name: str
|
|
30
|
+
description: str
|
|
31
|
+
parameters: Dict[str, Any]
|
|
32
|
+
func: Optional[Callable] = None
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Message:
|
|
36
|
+
"""Represents a message in the conversation history.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
role (str): The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').
|
|
40
|
+
content (Optional[str]): The text content of the message.
|
|
41
|
+
tool_calls (Optional[List[ToolCall]]): A list of tool calls initiated in this message.
|
|
42
|
+
tool_call_id (Optional[str]): The ID of the tool call this message is responding to (if role is 'tool').
|
|
43
|
+
"""
|
|
44
|
+
role: str
|
|
45
|
+
content: Optional[str] = None
|
|
46
|
+
tool_calls: Optional[List[ToolCall]] = None
|
|
47
|
+
tool_call_id: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class UsageStats:
|
|
51
|
+
"""Holds token usage statistics for a generation request.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
prompt_tokens (int): The number of tokens in the prompt.
|
|
55
|
+
completion_tokens (int): The number of tokens generated in the response.
|
|
56
|
+
total_tokens (int): The total number of tokens used.
|
|
57
|
+
"""
|
|
58
|
+
prompt_tokens: int
|
|
59
|
+
completion_tokens: int
|
|
60
|
+
total_tokens: int
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ChatResponse:
|
|
64
|
+
"""Represents the final response from a chat generation request.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
text (Optional[str]): The generated text content.
|
|
68
|
+
usage (UsageStats): The token usage statistics.
|
|
69
|
+
finish_reason (str): The reason the generation finished (e.g., 'STOP', 'MAX_TOKENS').
|
|
70
|
+
tool_calls (Optional[List[ToolCall]]): Any tool calls requested by the model.
|
|
71
|
+
"""
|
|
72
|
+
text: Optional[str]
|
|
73
|
+
usage: UsageStats
|
|
74
|
+
finish_reason: str
|
|
75
|
+
tool_calls: Optional[List[ToolCall]] = None
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class QuotaInfo:
|
|
79
|
+
"""Contains quota information for a specific model.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
remaining_fraction (float): The remaining fraction of the quota.
|
|
83
|
+
reset_time (Optional[str]): The timestamp or description of when the quota resets.
|
|
84
|
+
"""
|
|
85
|
+
remaining_fraction: float
|
|
86
|
+
reset_time: Optional[str]
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class ModelInfo:
|
|
90
|
+
"""Contains metadata and configuration for an available model.
|
|
91
|
+
|
|
92
|
+
Attributes:
|
|
93
|
+
id (str): The identifier of the model.
|
|
94
|
+
internal_model_id (str): The internal identifier used by the API.
|
|
95
|
+
display_name (str): The human-readable name of the model.
|
|
96
|
+
model_provider (str): The provider of the model.
|
|
97
|
+
api_provider (str): The API provider backend.
|
|
98
|
+
max_tokens (int): The maximum number of total tokens supported.
|
|
99
|
+
max_output_tokens (Optional[int]): The maximum number of tokens the model can generate.
|
|
100
|
+
is_internal (bool): Whether the model is an internal-only model.
|
|
101
|
+
supports_images (bool): Whether the model supports image inputs.
|
|
102
|
+
supports_thinking (bool): Whether the model supports reasoning/thinking capabilities.
|
|
103
|
+
quota_info (Optional[QuotaInfo]): The quota information for this model, if available.
|
|
104
|
+
"""
|
|
105
|
+
id: str
|
|
106
|
+
internal_model_id: str
|
|
107
|
+
display_name: str
|
|
108
|
+
model_provider: str
|
|
109
|
+
api_provider: str
|
|
110
|
+
max_tokens: int
|
|
111
|
+
max_output_tokens: Optional[int]
|
|
112
|
+
is_internal: bool
|
|
113
|
+
supports_images: bool
|
|
114
|
+
supports_thinking: bool
|
|
115
|
+
quota_info: Optional[QuotaInfo]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anti-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A full-featured async Python client for the internal Antigravity API with Tool Calling and Streaming support.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/makobcki/anti-client
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: httpx
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# anti-client
|
|
16
|
+
|
|
17
|
+
A full-featured Python client library for interacting with the internal Anrigravity API (Gemini).
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **OAuth 2.0 Authentication**: Built-in authentication flow with token caching (`~/.anti-api/accounts.json`).
|
|
22
|
+
- **Function Calling**: Complete support for tool execution by agents.
|
|
23
|
+
- **Streaming**: Native streaming capabilities, seamlessly integrated with function calling.
|
|
24
|
+
- **Resource Management**: Automated model discovery, quota checking, and precise usage statistics.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Install the package directly from the source directory:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage Example
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from anti_client import Client, Agent, Tool
|
|
38
|
+
|
|
39
|
+
# 1. Initialize Client (Triggers OAuth flow if tokens are missing)
|
|
40
|
+
client = Client()
|
|
41
|
+
|
|
42
|
+
# 2. Define Tools
|
|
43
|
+
def get_weather(city: str) -> str:
|
|
44
|
+
"""Retrieve weather information for a specific city."""
|
|
45
|
+
return f"The weather in {city} is clear and sunny."
|
|
46
|
+
|
|
47
|
+
weather_tool = Tool(
|
|
48
|
+
name="get_weather",
|
|
49
|
+
description="Retrieve the current weather for a specified city.",
|
|
50
|
+
parameters={
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"city": {"type": "string", "description": "The name of the city."}
|
|
54
|
+
},
|
|
55
|
+
"required": ["city"],
|
|
56
|
+
},
|
|
57
|
+
func=get_weather
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# 3. Initialize Agent
|
|
61
|
+
agent = Agent(
|
|
62
|
+
client=client,
|
|
63
|
+
model="gemini-3.1-pro-low",
|
|
64
|
+
system_prompt="You are a helpful assistant.",
|
|
65
|
+
tools=[weather_tool]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# 4. Generate Response
|
|
69
|
+
response = agent.run("Hello! What's the weather like in London?")
|
|
70
|
+
|
|
71
|
+
if not isinstance(response, type((x for x in []))): # If stream=False
|
|
72
|
+
print("Response:", response.text)
|
|
73
|
+
print("Tokens Used:", response.usage.total_tokens)
|
|
74
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/anti_client/__init__.py
|
|
5
|
+
src/anti_client/agent.py
|
|
6
|
+
src/anti_client/client.py
|
|
7
|
+
src/anti_client/exceptions.py
|
|
8
|
+
src/anti_client/types.py
|
|
9
|
+
src/anti_client.egg-info/PKG-INFO
|
|
10
|
+
src/anti_client.egg-info/SOURCES.txt
|
|
11
|
+
src/anti_client.egg-info/dependency_links.txt
|
|
12
|
+
src/anti_client.egg-info/requires.txt
|
|
13
|
+
src/anti_client.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
anti_client
|