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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ anti_client