oagi 0.1.0__tar.gz → 0.2.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.

Potentially problematic release.


This version of oagi might be problematic. Click here for more details.

Files changed (48) hide show
  1. oagi-0.2.0/.github/workflows/release.yml +41 -0
  2. {oagi-0.1.0 → oagi-0.2.0}/PKG-INFO +21 -1
  3. oagi-0.2.0/README.md +21 -0
  4. {oagi-0.1.0 → oagi-0.2.0}/examples/execute_task_manual.py +11 -5
  5. oagi-0.2.0/examples/single_step.py +19 -0
  6. {oagi-0.1.0 → oagi-0.2.0}/pyproject.toml +2 -2
  7. oagi-0.2.0/src/oagi/__init__.py +53 -0
  8. oagi-0.2.0/src/oagi/exceptions.py +75 -0
  9. oagi-0.2.0/src/oagi/short_task.py +44 -0
  10. oagi-0.2.0/src/oagi/single_step.py +82 -0
  11. {oagi-0.1.0 → oagi-0.2.0}/src/oagi/sync_client.py +118 -36
  12. oagi-0.1.0/src/oagi/short_task.py → oagi-0.2.0/src/oagi/task.py +20 -33
  13. {oagi-0.1.0 → oagi-0.2.0}/src/oagi/types/image.py +2 -1
  14. {oagi-0.1.0 → oagi-0.2.0}/tests/conftest.py +50 -3
  15. {oagi-0.1.0 → oagi-0.2.0}/tests/test_logging.py +5 -8
  16. oagi-0.2.0/tests/test_short_task.py +148 -0
  17. oagi-0.2.0/tests/test_single_step.py +193 -0
  18. {oagi-0.1.0 → oagi-0.2.0}/tests/test_sync_client.py +33 -38
  19. oagi-0.2.0/tests/test_task.py +275 -0
  20. {oagi-0.1.0 → oagi-0.2.0}/uv.lock +1 -1
  21. oagi-0.1.0/.claude/settings.local.json +0 -19
  22. oagi-0.1.0/CLAUDE.md +0 -105
  23. oagi-0.1.0/README.md +0 -1
  24. oagi-0.1.0/examples/screenshot.png +0 -0
  25. oagi-0.1.0/examples/test.py +0 -20
  26. oagi-0.1.0/examples/test_screenshot.py +0 -41
  27. oagi-0.1.0/src/oagi/__init__.py +0 -13
  28. {oagi-0.1.0 → oagi-0.2.0}/.github/workflows/ci.yml +0 -0
  29. {oagi-0.1.0 → oagi-0.2.0}/.gitignore +0 -0
  30. {oagi-0.1.0 → oagi-0.2.0}/.python-version +0 -0
  31. {oagi-0.1.0 → oagi-0.2.0}/CONTRIBUTING.md +0 -0
  32. {oagi-0.1.0 → oagi-0.2.0}/LICENSE +0 -0
  33. {oagi-0.1.0 → oagi-0.2.0}/Makefile +0 -0
  34. {oagi-0.1.0 → oagi-0.2.0}/examples/execute_task_auto.py +0 -0
  35. {oagi-0.1.0 → oagi-0.2.0}/examples/google_weather.py +0 -0
  36. {oagi-0.1.0 → oagi-0.2.0}/examples/hotel_booking.py +0 -0
  37. {oagi-0.1.0 → oagi-0.2.0}/src/oagi/logging.py +0 -0
  38. {oagi-0.1.0 → oagi-0.2.0}/src/oagi/pyautogui_action_handler.py +0 -0
  39. {oagi-0.1.0 → oagi-0.2.0}/src/oagi/screenshot_maker.py +0 -0
  40. {oagi-0.1.0 → oagi-0.2.0}/src/oagi/types/__init__.py +0 -0
  41. {oagi-0.1.0 → oagi-0.2.0}/src/oagi/types/action_handler.py +0 -0
  42. {oagi-0.1.0 → oagi-0.2.0}/src/oagi/types/image_provider.py +0 -0
  43. {oagi-0.1.0 → oagi-0.2.0}/src/oagi/types/models/__init__.py +0 -0
  44. {oagi-0.1.0 → oagi-0.2.0}/src/oagi/types/models/action.py +0 -0
  45. {oagi-0.1.0 → oagi-0.2.0}/src/oagi/types/models/step.py +0 -0
  46. {oagi-0.1.0 → oagi-0.2.0}/tests/__init__.py +0 -0
  47. {oagi-0.1.0 → oagi-0.2.0}/tests/test_pyautogui_action_handler.py +0 -0
  48. {oagi-0.1.0 → oagi-0.2.0}/tests/test_screenshot_maker.py +0 -0
@@ -0,0 +1,41 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write # For trusted publishing
13
+ contents: write # For GitHub release
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v3
20
+ with:
21
+ enable-cache: true
22
+
23
+ - name: Set up Python
24
+ run: uv python install 3.12
25
+
26
+ - name: Build package
27
+ run: uv build
28
+
29
+ - name: Publish to PyPI
30
+ uses: pypa/gh-action-pypi-publish@release/v1
31
+ with:
32
+ password: ${{ secrets.PYPI_API_TOKEN }}
33
+ # Another option: Use Trusted Publishing (recommended, no token needed)
34
+ # Configure at: https://pypi.org/manage/project/oagi/settings/publishing/
35
+
36
+
37
+ - name: Create GitHub Release
38
+ uses: softprops/action-gh-release@v1
39
+ with:
40
+ files: dist/*
41
+ generate_release_notes: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: oagi
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Official API of OpenAGI Foundation
5
5
  Project-URL: Homepage, https://github.com/agiopen-org/oagi
6
6
  Author-email: OpenAGI Foundation <contact@agiopen.org>
@@ -33,3 +33,23 @@ Requires-Dist: pydantic>=2.0.0
33
33
  Description-Content-Type: text/markdown
34
34
 
35
35
  # OAGI Python SDK
36
+
37
+ ## Basic Usage
38
+ ```bash
39
+ pip install oagi # python >= 3.10
40
+ ```
41
+ ```bash
42
+ export OAGI_BASE_URL=""
43
+ export OAGI_API_KEY="sk-xxxx"
44
+ ```
45
+
46
+ ```python
47
+ from oagi import PyautoguiActionHandler, ScreenshotMaker, ShortTask
48
+ short_task = ShortTask()
49
+ is_completed = short_task.auto_mode(
50
+ "Search weather with Google",
51
+ max_steps=5,
52
+ executor=PyautoguiActionHandler(),
53
+ image_provider=(sm := ScreenshotMaker()),
54
+ )
55
+ ```
oagi-0.2.0/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # OAGI Python SDK
2
+
3
+ ## Basic Usage
4
+ ```bash
5
+ pip install oagi # python >= 3.10
6
+ ```
7
+ ```bash
8
+ export OAGI_BASE_URL=""
9
+ export OAGI_API_KEY="sk-xxxx"
10
+ ```
11
+
12
+ ```python
13
+ from oagi import PyautoguiActionHandler, ScreenshotMaker, ShortTask
14
+ short_task = ShortTask()
15
+ is_completed = short_task.auto_mode(
16
+ "Search weather with Google",
17
+ max_steps=5,
18
+ executor=PyautoguiActionHandler(),
19
+ image_provider=(sm := ScreenshotMaker()),
20
+ )
21
+ ```
@@ -6,23 +6,29 @@
6
6
  # Licensed under the MIT License.
7
7
  # -----------------------------------------------------------------------------
8
8
 
9
- from oagi import PyautoguiActionHandler, ScreenshotMaker, ShortTask
9
+ from oagi import PyautoguiActionHandler, ScreenshotMaker, Task
10
10
 
11
11
 
12
12
  def execute_task_manual(task_desc, max_steps=5):
13
13
  # set OAGI_API_KEY and OAGI_BASE_URL
14
14
  # or ShortTask(api_key="your_api_key", base_url="your_base_url")
15
- short_task = ShortTask()
16
- short_task.init_task(task_desc, max_steps=max_steps)
15
+ task = Task()
16
+ task.init_task(task_desc, max_steps=max_steps)
17
17
  executor = (
18
18
  PyautoguiActionHandler()
19
19
  ) # executor = lambda actions: print(actions) for debugging
20
20
  image_provider = ScreenshotMaker()
21
21
 
22
22
  for i in range(max_steps):
23
+ # image can also be bytes
24
+ # with open("test_screenshot.png", "rb") as f:
25
+ # image = f.read()
23
26
  image = image_provider()
24
- # do something with image, maybe save it or OCR then break
25
- step = short_task.step(image)
27
+
28
+ # For additional instructions
29
+ # step = task.step(image, instruction="some instruction")
30
+ step = task.step(image)
31
+
26
32
  # do something with step, maybe print to debug
27
33
  print(f"Step {i}: {step.reason=}")
28
34
 
@@ -0,0 +1,19 @@
1
+ # -----------------------------------------------------------------------------
2
+ # Copyright (c) OpenAGI Foundation
3
+ # All rights reserved.
4
+ #
5
+ # This file is part of the official API project.
6
+ # Licensed under the MIT License.
7
+ # -----------------------------------------------------------------------------
8
+
9
+ from oagi import single_step
10
+
11
+ step = single_step(
12
+ task_description="Search weather with Google",
13
+ screenshot="some/path/to/local/image", # bytes or Path object or Image object
14
+ instruction="The operating system is macos", # optional instruction
15
+ # api_key="your-api-key", if not set with OAGI_API_KEY env var
16
+ # base_url="https://api.example.com" if not set with OAGI_BASE_URL env var
17
+ )
18
+
19
+ print(step)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "oagi"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Official API of OpenAGI Foundation"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -32,4 +32,4 @@ dev = [
32
32
  ]
33
33
 
34
34
  [tool.ruff.lint]
35
- extend-select = ["I"]
35
+ extend-select = ["I", "PLC0415"]
@@ -0,0 +1,53 @@
1
+ # -----------------------------------------------------------------------------
2
+ # Copyright (c) OpenAGI Foundation
3
+ # All rights reserved.
4
+ #
5
+ # This file is part of the official API project.
6
+ # Licensed under the MIT License.
7
+ # -----------------------------------------------------------------------------
8
+
9
+ from oagi.exceptions import (
10
+ APIError,
11
+ AuthenticationError,
12
+ ConfigurationError,
13
+ NetworkError,
14
+ NotFoundError,
15
+ OAGIError,
16
+ RateLimitError,
17
+ RequestTimeoutError,
18
+ ServerError,
19
+ ValidationError,
20
+ )
21
+ from oagi.pyautogui_action_handler import PyautoguiActionHandler
22
+ from oagi.screenshot_maker import ScreenshotMaker
23
+ from oagi.short_task import ShortTask
24
+ from oagi.single_step import single_step
25
+ from oagi.sync_client import ErrorDetail, ErrorResponse, LLMResponse, SyncClient
26
+ from oagi.task import Task
27
+
28
+ __all__ = [
29
+ # Core classes
30
+ "Task",
31
+ "ShortTask",
32
+ "SyncClient",
33
+ # Functions
34
+ "single_step",
35
+ # Handler classes
36
+ "PyautoguiActionHandler",
37
+ "ScreenshotMaker",
38
+ # Response models
39
+ "LLMResponse",
40
+ "ErrorResponse",
41
+ "ErrorDetail",
42
+ # Exceptions
43
+ "OAGIError",
44
+ "APIError",
45
+ "AuthenticationError",
46
+ "ConfigurationError",
47
+ "NetworkError",
48
+ "NotFoundError",
49
+ "RateLimitError",
50
+ "ServerError",
51
+ "RequestTimeoutError",
52
+ "ValidationError",
53
+ ]
@@ -0,0 +1,75 @@
1
+ # -----------------------------------------------------------------------------
2
+ # Copyright (c) OpenAGI Foundation
3
+ # All rights reserved.
4
+ #
5
+ # This file is part of the official API project.
6
+ # Licensed under the MIT License.
7
+ # -----------------------------------------------------------------------------
8
+
9
+ import httpx
10
+
11
+
12
+ class OAGIError(Exception):
13
+ pass
14
+
15
+
16
+ class APIError(OAGIError):
17
+ def __init__(
18
+ self,
19
+ message: str,
20
+ code: str | None = None,
21
+ status_code: int | None = None,
22
+ response: httpx.Response | None = None,
23
+ ):
24
+ """Initialize APIError.
25
+
26
+ Args:
27
+ message: Human-readable error message
28
+ code: API error code for programmatic handling
29
+ status_code: HTTP status code
30
+ response: Original HTTP response object
31
+ """
32
+ super().__init__(message)
33
+ self.message = message
34
+ self.code = code
35
+ self.status_code = status_code
36
+ self.response = response
37
+
38
+ def __str__(self) -> str:
39
+ if self.code:
40
+ return f"API Error [{self.code}]: {self.message}"
41
+ return f"API Error: {self.message}"
42
+
43
+
44
+ class AuthenticationError(APIError):
45
+ pass
46
+
47
+
48
+ class RateLimitError(APIError):
49
+ pass
50
+
51
+
52
+ class ValidationError(APIError):
53
+ pass
54
+
55
+
56
+ class NotFoundError(APIError):
57
+ pass
58
+
59
+
60
+ class ServerError(APIError):
61
+ pass
62
+
63
+
64
+ class NetworkError(OAGIError):
65
+ def __init__(self, message: str, original_error: Exception | None = None):
66
+ super().__init__(message)
67
+ self.original_error = original_error
68
+
69
+
70
+ class RequestTimeoutError(NetworkError):
71
+ pass
72
+
73
+
74
+ class ConfigurationError(OAGIError):
75
+ pass
@@ -0,0 +1,44 @@
1
+ # -----------------------------------------------------------------------------
2
+ # Copyright (c) OpenAGI Foundation
3
+ # All rights reserved.
4
+ #
5
+ # This file is part of the official API project.
6
+ # Licensed under the MIT License.
7
+ # -----------------------------------------------------------------------------
8
+
9
+ from .logging import get_logger
10
+ from .task import Task
11
+ from .types import ActionHandler, ImageProvider
12
+
13
+ logger = get_logger("short_task")
14
+
15
+
16
+ class ShortTask(Task):
17
+ """Task implementation with automatic mode for short-duration tasks."""
18
+
19
+ def auto_mode(
20
+ self,
21
+ task_desc: str,
22
+ max_steps: int = 5,
23
+ executor: ActionHandler = None,
24
+ image_provider: ImageProvider = None,
25
+ ) -> bool:
26
+ """Run the task in automatic mode with the provided executor and image provider."""
27
+ logger.info(
28
+ f"Starting auto mode for task: '{task_desc}' (max_steps: {max_steps})"
29
+ )
30
+ self.init_task(task_desc, max_steps=max_steps)
31
+
32
+ for i in range(max_steps):
33
+ logger.debug(f"Auto mode step {i + 1}/{max_steps}")
34
+ image = image_provider()
35
+ step = self.step(image)
36
+ if step.stop:
37
+ logger.info(f"Auto mode completed successfully after {i + 1} steps")
38
+ return True
39
+ if executor:
40
+ logger.debug(f"Executing {len(step.actions)} actions")
41
+ executor(step.actions)
42
+
43
+ logger.warning(f"Auto mode reached max steps ({max_steps}) without completion")
44
+ return False
@@ -0,0 +1,82 @@
1
+ # -----------------------------------------------------------------------------
2
+ # Copyright (c) OpenAGI Foundation
3
+ # All rights reserved.
4
+ #
5
+ # This file is part of the official API project.
6
+ # Licensed under the MIT License.
7
+ # -----------------------------------------------------------------------------
8
+
9
+ from pathlib import Path
10
+
11
+ from .task import Task
12
+ from .types import Image, Step
13
+
14
+
15
+ def single_step(
16
+ task_description: str,
17
+ screenshot: str | bytes | Path | Image,
18
+ instruction: str | None = None,
19
+ api_key: str | None = None,
20
+ base_url: str | None = None,
21
+ ) -> Step:
22
+ """
23
+ Perform a single-step inference without maintaining task state.
24
+
25
+ This is useful for one-off analyses where you don't need to maintain
26
+ a conversation or task context across multiple steps.
27
+
28
+ Args:
29
+ task_description: Description of the task to perform
30
+ screenshot: Screenshot as Image, bytes, or file path
31
+ instruction: Optional additional instruction for the task
32
+ api_key: OAGI API key (uses environment variable if not provided)
33
+ base_url: OAGI base URL (uses environment variable if not provided)
34
+
35
+ Returns:
36
+ Step: Object containing reasoning, actions, and completion status
37
+
38
+ Example:
39
+ >>> # Using with bytes
40
+ >>> with open("screenshot.png", "rb") as f:
41
+ ... image_bytes = f.read()
42
+ >>> step = single_step(
43
+ ... task_description="Click the submit button",
44
+ ... screenshot=image_bytes
45
+ ... )
46
+
47
+ >>> # Using with file path
48
+ >>> step = single_step(
49
+ ... task_description="Fill in the form",
50
+ ... screenshot=Path("screenshot.png"),
51
+ ... instruction="Use test@example.com for email"
52
+ ... )
53
+
54
+ >>> # Using with Image object
55
+ >>> from oagi.types import Image
56
+ >>> image = Image(...)
57
+ >>> step = single_step(
58
+ ... task_description="Navigate to settings",
59
+ ... screenshot=image
60
+ ... )
61
+ """
62
+ # Convert file paths to bytes
63
+ if isinstance(screenshot, (str, Path)):
64
+ path = Path(screenshot) if isinstance(screenshot, str) else screenshot
65
+ if path.exists():
66
+ with open(path, "rb") as f:
67
+ screenshot_bytes = f.read()
68
+ else:
69
+ raise FileNotFoundError(f"Screenshot file not found: {path}")
70
+ elif isinstance(screenshot, bytes):
71
+ screenshot_bytes = screenshot
72
+ elif isinstance(screenshot, Image):
73
+ screenshot_bytes = screenshot.read()
74
+ else:
75
+ raise ValueError(
76
+ f"screenshot must be Image, bytes, str, or Path, got {type(screenshot)}"
77
+ )
78
+
79
+ # Use Task to perform single step
80
+ with Task(api_key=api_key, base_url=base_url) as task:
81
+ task.init_task(task_description)
82
+ return task.step(screenshot_bytes, instruction=instruction)
@@ -8,11 +8,21 @@
8
8
 
9
9
  import base64
10
10
  import os
11
- from typing import Optional
12
11
 
13
12
  import httpx
14
13
  from pydantic import BaseModel
15
14
 
15
+ from .exceptions import (
16
+ APIError,
17
+ AuthenticationError,
18
+ ConfigurationError,
19
+ NetworkError,
20
+ NotFoundError,
21
+ RateLimitError,
22
+ RequestTimeoutError,
23
+ ServerError,
24
+ ValidationError,
25
+ )
16
26
  from .logging import get_logger
17
27
  from .types import Action
18
28
 
@@ -25,6 +35,19 @@ class Usage(BaseModel):
25
35
  total_tokens: int
26
36
 
27
37
 
38
+ class ErrorDetail(BaseModel):
39
+ """Detailed error information."""
40
+
41
+ code: str
42
+ message: str
43
+
44
+
45
+ class ErrorResponse(BaseModel):
46
+ """Standard error response format."""
47
+
48
+ error: ErrorDetail | None
49
+
50
+
28
51
  class LLMResponse(BaseModel):
29
52
  id: str
30
53
  task_id: str
@@ -37,29 +60,24 @@ class LLMResponse(BaseModel):
37
60
  actions: list[Action]
38
61
  reason: str | None = None
39
62
  usage: Usage
40
-
41
-
42
- class ErrorResponse(BaseModel):
43
- error: str
44
- message: str
45
- code: int
63
+ error: ErrorDetail | None = None
46
64
 
47
65
 
48
66
  class SyncClient:
49
- def __init__(self, base_url: Optional[str] = None, api_key: Optional[str] = None):
67
+ def __init__(self, base_url: str | None = None, api_key: str | None = None):
50
68
  # Get from environment if not provided
51
69
  self.base_url = base_url or os.getenv("OAGI_BASE_URL")
52
70
  self.api_key = api_key or os.getenv("OAGI_API_KEY")
53
71
 
54
72
  # Validate required configuration
55
73
  if not self.base_url:
56
- raise ValueError(
74
+ raise ConfigurationError(
57
75
  "OAGI base URL must be provided either as 'base_url' parameter or "
58
76
  "OAGI_BASE_URL environment variable"
59
77
  )
60
78
 
61
79
  if not self.api_key:
62
- raise ValueError(
80
+ raise ConfigurationError(
63
81
  "OAGI API key must be provided either as 'api_key' parameter or "
64
82
  "OAGI_API_KEY environment variable"
65
83
  )
@@ -84,10 +102,11 @@ class SyncClient:
84
102
  self,
85
103
  model: str,
86
104
  screenshot: str, # base64 encoded
87
- task_description: Optional[str] = None,
88
- task_id: Optional[str] = None,
89
- max_actions: Optional[int] = 5,
90
- api_version: Optional[str] = None,
105
+ task_description: str | None = None,
106
+ task_id: str | None = None,
107
+ instruction: str | None = None,
108
+ max_actions: int | None = 5,
109
+ api_version: str | None = None,
91
110
  ) -> LLMResponse:
92
111
  """
93
112
  Call the /v1/message endpoint to analyze task and screenshot
@@ -97,6 +116,7 @@ class SyncClient:
97
116
  screenshot: Base64-encoded screenshot image
98
117
  task_description: Description of the task (required for new sessions)
99
118
  task_id: Task ID for continuing existing task
119
+ instruction: Additional instruction when continuing a session (only works with task_id)
100
120
  max_actions: Maximum number of actions to return (1-20)
101
121
  api_version: API version header
102
122
 
@@ -118,6 +138,8 @@ class SyncClient:
118
138
  payload["task_description"] = task_description
119
139
  if task_id is not None:
120
140
  payload["task_id"] = task_id
141
+ if instruction is not None:
142
+ payload["instruction"] = instruction
121
143
  if max_actions is not None:
122
144
  payload["max_actions"] = max_actions
123
145
 
@@ -126,32 +148,92 @@ class SyncClient:
126
148
  f"Request includes task_description: {task_description is not None}, task_id: {task_id is not None}"
127
149
  )
128
150
 
129
- response = self.client.post(
130
- "/v1/message", json=payload, headers=headers, timeout=self.timeout
131
- )
151
+ try:
152
+ response = self.client.post(
153
+ "/v1/message", json=payload, headers=headers, timeout=self.timeout
154
+ )
155
+ except httpx.TimeoutException as e:
156
+ logger.error(f"Request timed out after {self.timeout} seconds")
157
+ raise RequestTimeoutError(
158
+ f"Request timed out after {self.timeout} seconds", e
159
+ )
160
+ except httpx.NetworkError as e:
161
+ logger.error(f"Network error: {e}")
162
+ raise NetworkError(f"Network error: {e}", e)
132
163
 
133
- if response.status_code == 200:
134
- result = LLMResponse(**response.json())
135
- logger.info(
136
- f"API request successful - task_id: {result.task_id}, step: {result.current_step}, complete: {result.is_complete}"
164
+ try:
165
+ response_data = response.json()
166
+ except ValueError:
167
+ # If response is not JSON, raise API error
168
+ logger.error(f"Non-JSON API response: {response.status_code}")
169
+ raise APIError(
170
+ f"Invalid response format (status {response.status_code})",
171
+ status_code=response.status_code,
172
+ response=response,
137
173
  )
138
- logger.debug(f"Response included {len(result.actions)} actions")
139
- return result
140
- else:
141
- # Handle error responses
142
- try:
143
- error_data = response.json()
144
- error = ErrorResponse(**error_data)
145
- logger.error(f"API Error {error.code}: {error.error} - {error.message}")
146
- raise httpx.HTTPStatusError(
147
- f"API Error {error.code}: {error.error} - {error.message}",
148
- request=response.request,
174
+
175
+ # Check if it's an error response (non-200 status or has error field)
176
+ if response.status_code != 200:
177
+ error_resp = ErrorResponse(**response_data)
178
+ if error_resp.error:
179
+ error_code = error_resp.error.code
180
+ error_msg = error_resp.error.message
181
+ logger.error(f"API Error [{error_code}]: {error_msg}")
182
+
183
+ # Map to specific exception types based on status code
184
+ exception_class = self._get_exception_class(response.status_code)
185
+ raise exception_class(
186
+ error_msg,
187
+ code=error_code,
188
+ status_code=response.status_code,
189
+ response=response,
190
+ )
191
+ else:
192
+ # Error response without error details
193
+ logger.error(
194
+ f"API error response without details: {response.status_code}"
195
+ )
196
+ exception_class = self._get_exception_class(response.status_code)
197
+ raise exception_class(
198
+ f"API error (status {response.status_code})",
199
+ status_code=response.status_code,
149
200
  response=response,
150
201
  )
151
- except ValueError:
152
- # If response is not JSON, raise generic error
153
- logger.error(f"Non-JSON API error response: {response.status_code}")
154
- response.raise_for_status()
202
+
203
+ # Parse successful response
204
+ result = LLMResponse(**response_data)
205
+
206
+ # Check if the response contains an error (even with 200 status)
207
+ if result.error:
208
+ logger.error(
209
+ f"API Error in response: [{result.error.code}]: {result.error.message}"
210
+ )
211
+ raise APIError(
212
+ result.error.message,
213
+ code=result.error.code,
214
+ status_code=200,
215
+ response=response,
216
+ )
217
+
218
+ logger.info(
219
+ f"API request successful - task_id: {result.task_id}, step: {result.current_step}, complete: {result.is_complete}"
220
+ )
221
+ logger.debug(f"Response included {len(result.actions)} actions")
222
+ return result
223
+
224
+ def _get_exception_class(self, status_code: int) -> type[APIError]:
225
+ """Get the appropriate exception class based on status code."""
226
+ status_map = {
227
+ 401: AuthenticationError,
228
+ 404: NotFoundError,
229
+ 422: ValidationError,
230
+ 429: RateLimitError,
231
+ }
232
+
233
+ if status_code >= 500:
234
+ return ServerError
235
+
236
+ return status_map.get(status_code, APIError)
155
237
 
156
238
  def health_check(self) -> dict:
157
239
  """