latitude-sdk 3.0.1__py3-none-any.whl → 4.0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,17 @@
1
1
  import asyncio
2
2
  import json
3
3
  from contextlib import asynccontextmanager
4
- from typing import Any, AsyncGenerator, Optional
4
+ from typing import Any, AsyncGenerator, Awaitable, Callable, Optional
5
5
 
6
6
  import httpx
7
7
  import httpx_sse
8
8
 
9
- from latitude_sdk.client.payloads import ErrorResponse, RequestBody, RequestHandler, RequestParams
9
+ from latitude_sdk.client.payloads import (
10
+ ErrorResponse,
11
+ RequestBody,
12
+ RequestHandler,
13
+ RequestParams,
14
+ )
10
15
  from latitude_sdk.client.router import Router, RouterOptions
11
16
  from latitude_sdk.sdk.errors import (
12
17
  ApiError,
@@ -51,65 +56,169 @@ class Client:
51
56
 
52
57
  @asynccontextmanager
53
58
  async def request(
54
- self, handler: RequestHandler, params: RequestParams, body: Optional[RequestBody] = None
59
+ self,
60
+ handler: RequestHandler,
61
+ params: Optional[RequestParams] = None,
62
+ body: Optional[RequestBody] = None,
63
+ ) -> AsyncGenerator[ClientResponse, Any]:
64
+ """
65
+ Main request handler that delegates to streaming or non-streaming methods.
66
+ """
67
+ is_streaming = self._is_streaming_request(body)
68
+
69
+ if is_streaming:
70
+ async with self._streaming_request(handler, params, body) as response:
71
+ yield response
72
+ else:
73
+ async with self._non_streaming_request(handler, params, body) as response:
74
+ yield response
75
+
76
+ def _is_streaming_request(self, body: Optional[RequestBody]) -> bool:
77
+ """Check if this is a streaming request based on the body."""
78
+ return body is not None and hasattr(body, "stream") and body.stream # type: ignore
79
+
80
+ def _prepare_headers(self, is_streaming: bool) -> dict[str, str]:
81
+ """Prepare headers for the request."""
82
+ headers = {
83
+ "Authorization": f"Bearer {self.options.api_key}",
84
+ "Content-Type": "application/json",
85
+ }
86
+
87
+ if is_streaming:
88
+ headers["Accept"] = "text/event-stream"
89
+
90
+ return headers
91
+
92
+ def _prepare_content(self, body: Optional[RequestBody]) -> Optional[str]:
93
+ """Prepare request content from body."""
94
+ if not body:
95
+ return None
96
+
97
+ return json.dumps(
98
+ {
99
+ **json.loads(body.model_dump_json()),
100
+ "__internal": {"source": self.options.source},
101
+ }
102
+ )
103
+
104
+ @asynccontextmanager
105
+ async def _streaming_request(
106
+ self,
107
+ handler: RequestHandler,
108
+ params: Optional[RequestParams] = None,
109
+ body: Optional[RequestBody] = None,
55
110
  ) -> AsyncGenerator[ClientResponse, Any]:
56
- client = httpx.AsyncClient(
57
- headers={
58
- "Authorization": f"Bearer {self.options.api_key}",
59
- "Content-Type": "application/json",
60
- },
111
+ """Handle streaming requests with proper resource management."""
112
+ headers = self._prepare_headers(is_streaming=True)
113
+ content = self._prepare_content(body)
114
+ method, url = self.router.resolve(handler, params)
115
+
116
+ async with httpx.AsyncClient(
117
+ headers=headers,
61
118
  timeout=self.options.timeout,
62
119
  follow_redirects=False,
63
120
  max_redirects=0,
64
- )
65
- response = None
66
- attempt = 1
121
+ ) as client:
122
+ response = await self._execute_with_retry(
123
+ lambda: client.stream(method=method, url=url, content=content), # type: ignore
124
+ is_streaming=True,
125
+ )
67
126
 
68
- try:
69
- method, url = self.router.resolve(handler, params)
70
- content = None
71
- if body:
72
- content = json.dumps(
73
- {
74
- **json.loads(body.model_dump_json()),
75
- "__internal": {"source": self.options.source},
76
- }
77
- )
78
-
79
- while attempt <= self.options.retries:
80
- try:
81
- response = await client.request(method=method, url=url, content=content)
82
- response.raise_for_status()
83
-
84
- yield response # pyright: ignore [reportReturnType]
85
- break
127
+ # For streaming responses, yield the response directly
128
+ # The caller is responsible for consuming the stream
129
+ yield response
130
+
131
+ @asynccontextmanager
132
+ async def _non_streaming_request(
133
+ self,
134
+ handler: RequestHandler,
135
+ params: Optional[RequestParams] = None,
136
+ body: Optional[RequestBody] = None,
137
+ ) -> AsyncGenerator[ClientResponse, Any]:
138
+ """Handle non-streaming requests with proper resource management."""
139
+ headers = self._prepare_headers(is_streaming=False)
140
+ content = self._prepare_content(body)
141
+ method, url = self.router.resolve(handler, params)
142
+
143
+ async with httpx.AsyncClient(
144
+ headers=headers,
145
+ timeout=self.options.timeout,
146
+ follow_redirects=False,
147
+ max_redirects=0,
148
+ ) as client:
149
+ response = await self._execute_with_retry(
150
+ lambda: client.request(method=method, url=url, content=content), # type: ignore
151
+ is_streaming=False,
152
+ )
153
+
154
+ try:
155
+ # Pre-read the response text for non-streaming responses
156
+ # This ensures the response is fully loaded and validates it
157
+ _ = response.text
158
+ yield response
159
+ finally:
160
+ await response.aclose()
161
+
162
+ async def _execute_with_retry(
163
+ self,
164
+ request_func: Callable[[], Awaitable[ClientResponse]],
165
+ is_streaming: bool = False,
166
+ ) -> ClientResponse:
167
+ """Execute a request with retry logic."""
168
+ last_exception = None
169
+ last_response = None
86
170
 
87
- except Exception as exception:
88
- if isinstance(exception, ApiError):
89
- raise exception
171
+ for attempt in range(1, self.options.retries + 1):
172
+ response_cm = None
173
+ response = None
90
174
 
91
- if attempt >= self.options.retries:
92
- raise await self._exception(exception, response) from exception
175
+ try:
176
+ if is_streaming:
177
+ # For streaming, request_func returns a context manager
178
+ response_cm = request_func()
179
+ response = await response_cm.__aenter__() # type: ignore
180
+ # Store the context manager for proper cleanup
181
+ response._stream_cm = response_cm # pyright: ignore [reportAttributeAccessIssue]
182
+ else:
183
+ response = await request_func()
93
184
 
94
- if response and response.status_code in RETRIABLE_STATUSES:
95
- await asyncio.sleep(self.options.delay * (2 ** (attempt - 1)))
96
- else:
97
- raise await self._exception(exception, response) from exception
185
+ response.raise_for_status() # type: ignore
186
+ return response # type: ignore
98
187
 
99
- finally:
100
- if response:
101
- await response.aclose()
188
+ except Exception as exception:
189
+ last_exception = exception
102
190
 
103
- attempt += 1
191
+ # For HTTP errors, get the response from the exception
192
+ if isinstance(exception, httpx.HTTPStatusError):
193
+ last_response = exception.response
194
+ else:
195
+ last_response = getattr(exception, "response", None)
104
196
 
105
- except Exception as exception:
106
- if isinstance(exception, ApiError):
107
- raise exception
197
+ # Don't retry ApiErrors - they're business logic errors
198
+ if isinstance(exception, ApiError):
199
+ raise exception
108
200
 
109
- raise await self._exception(exception, response) from exception
201
+ # If this is the last attempt, break to raise the exception
202
+ if attempt >= self.options.retries:
203
+ break
204
+
205
+ # Check if we should retry based on status code
206
+ if (
207
+ isinstance(exception, httpx.HTTPStatusError)
208
+ and exception.response.status_code in RETRIABLE_STATUSES
209
+ ):
210
+ await asyncio.sleep(self._calculate_delay(attempt))
211
+ continue
212
+
213
+ # For non-retriable errors, don't retry
214
+ break
110
215
 
111
- finally:
112
- await client.aclose()
216
+ # If we get here, all retries failed
217
+ raise await self._exception(last_exception, last_response) from last_exception # type: ignore
218
+
219
+ def _calculate_delay(self, attempt: int) -> float:
220
+ """Calculate exponential backoff delay."""
221
+ return self.options.delay * (2 ** (attempt - 1))
113
222
 
114
223
  async def _exception(self, exception: Exception, response: Optional[httpx.Response] = None) -> ApiError:
115
224
  if not response:
@@ -120,14 +229,41 @@ class Client:
120
229
  response=str(exception),
121
230
  )
122
231
 
232
+ # Try to safely get response text and content, handling streaming responses
233
+ response_text = ""
234
+ response_content = b""
235
+
236
+ try:
237
+ # Check if this is a streaming response
238
+ content_type = response.headers.get("content-type", "")
239
+ is_streaming = "text/event-stream" in content_type
240
+
241
+ # For streaming responses, try to read content but don't access text
242
+ if is_streaming:
243
+ # For streaming responses, we can access content but not text
244
+ response_content = response.content
245
+ response_text = ""
246
+ else:
247
+ # For non-streaming responses, we can safely access both
248
+ response_content = response.content
249
+ response_text = response.text
250
+ except Exception:
251
+ # If we can't read the response (e.g., streaming response that hasn't been read),
252
+ # try to get what we can
253
+ try:
254
+ response_content = response.content
255
+ except Exception:
256
+ response_content = b""
257
+ response_text = ""
258
+
123
259
  try:
124
- error = ErrorResponse.model_validate_json(response.content)
260
+ error = ErrorResponse.model_validate_json(response_content)
125
261
 
126
262
  return ApiError(
127
263
  status=response.status_code,
128
264
  code=error.code,
129
265
  message=error.message,
130
- response=response.text,
266
+ response=response_text,
131
267
  db_ref=ApiErrorDbRef(**dict(error.db_ref)) if error.db_ref else None,
132
268
  )
133
269
 
@@ -136,5 +272,5 @@ class Client:
136
272
  status=response.status_code,
137
273
  code=ApiErrorCodes.InternalServerError,
138
274
  message=str(exception),
139
- response=response.text,
275
+ response=response_text,
140
276
  )
@@ -45,6 +45,7 @@ class RunPromptRequestBody(Model):
45
45
  parameters: Optional[Dict[str, Any]] = None
46
46
  custom_identifier: Optional[str] = Field(default=None, alias=str("customIdentifier"))
47
47
  stream: Optional[bool] = None
48
+ tools: Optional[List[str]] = None
48
49
 
49
50
 
50
51
  class ChatPromptRequestParams(Model):
@@ -88,6 +89,16 @@ class AnnotateEvaluationRequestBody(Model):
88
89
  metadata: Optional[Metadata] = None
89
90
 
90
91
 
92
+ class ToolResultsRequestBody(Model):
93
+ tool_call_id: str = Field(alias=str("toolCallId"))
94
+ result: Any
95
+ is_error: Optional[bool] = Field(default=None, alias=str("isError"))
96
+
97
+
98
+ class CreateProjectRequestBody(Model):
99
+ name: str
100
+
101
+
91
102
  RequestParams = Union[
92
103
  GetPromptRequestParams,
93
104
  GetAllPromptRequestParams,
@@ -105,6 +116,8 @@ RequestBody = Union[
105
116
  ChatPromptRequestBody,
106
117
  CreateLogRequestBody,
107
118
  AnnotateEvaluationRequestBody,
119
+ ToolResultsRequestBody,
120
+ CreateProjectRequestBody,
108
121
  ]
109
122
 
110
123
 
@@ -116,3 +129,6 @@ class RequestHandler(StrEnum):
116
129
  ChatPrompt = "CHAT_PROMPT"
117
130
  CreateLog = "CREATE_LOG"
118
131
  AnnotateEvaluation = "ANNOTATE_EVALUATION"
132
+ ToolResults = "TOOL_RESULTS"
133
+ GetAllProjects = "GET_ALL_PROJECTS"
134
+ CreateProject = "CREATE_PROJECT"
@@ -27,7 +27,7 @@ class Router:
27
27
  def __init__(self, options: RouterOptions):
28
28
  self.options = options
29
29
 
30
- def resolve(self, handler: RequestHandler, params: RequestParams) -> Tuple[str, str]:
30
+ def resolve(self, handler: RequestHandler, params: Optional[RequestParams] = None) -> Tuple[str, str]:
31
31
  if handler == RequestHandler.GetPrompt:
32
32
  assert isinstance(params, GetPromptRequestParams)
33
33
 
@@ -90,6 +90,15 @@ class Router:
90
90
 
91
91
  return "POST", self.conversations().annotate(params.conversation_uuid, params.evaluation_uuid)
92
92
 
93
+ elif handler == RequestHandler.ToolResults:
94
+ return "POST", f"{self.options.gateway.base_url}/tools/results"
95
+
96
+ elif handler == RequestHandler.GetAllProjects:
97
+ return "GET", f"{self.options.gateway.base_url}/projects"
98
+
99
+ elif handler == RequestHandler.CreateProject:
100
+ return "POST", f"{self.options.gateway.base_url}/projects"
101
+
93
102
  raise TypeError(f"Unknown handler: {handler}")
94
103
 
95
104
  class Conversations(Model):
@@ -6,6 +6,7 @@ from latitude_sdk.client import Client, ClientOptions, RouterOptions
6
6
  from latitude_sdk.env import env
7
7
  from latitude_sdk.sdk.evaluations import Evaluations
8
8
  from latitude_sdk.sdk.logs import Logs
9
+ from latitude_sdk.sdk.projects import Projects
9
10
  from latitude_sdk.sdk.prompts import Prompts
10
11
  from latitude_sdk.sdk.types import GatewayOptions, LogSources, SdkOptions
11
12
  from latitude_sdk.util import Model
@@ -49,6 +50,7 @@ class Latitude:
49
50
 
50
51
  promptl: Promptl
51
52
 
53
+ projects: Projects
52
54
  prompts: Prompts
53
55
  logs: Logs
54
56
  evaluations: Evaluations
@@ -76,6 +78,7 @@ class Latitude:
76
78
  )
77
79
 
78
80
  self.promptl = Promptl(self._options.promptl)
81
+ self.projects = Projects(self._client, self._options)
79
82
  self.prompts = Prompts(self._client, self.promptl, self._options)
80
83
  self.logs = Logs(self._client, self._options)
81
84
  self.evaluations = Evaluations(self._client, self._options)
@@ -0,0 +1,41 @@
1
+ import json
2
+ from typing import List
3
+
4
+ from latitude_sdk.client import Client
5
+ from latitude_sdk.client.payloads import CreateProjectRequestBody, RequestHandler
6
+ from latitude_sdk.sdk.types import Project, SdkOptions, Version
7
+
8
+
9
+ class CreateProjectResponse:
10
+ def __init__(self, project: Project, version: Version):
11
+ self.project = project
12
+ self.version = version
13
+
14
+
15
+ class Projects:
16
+ _client: Client
17
+ _options: SdkOptions
18
+
19
+ def __init__(self, client: Client, options: SdkOptions):
20
+ self._client = client
21
+ self._options = options
22
+
23
+ async def get_all(self) -> List[Project]:
24
+ async with self._client.request(
25
+ handler=RequestHandler.GetAllProjects,
26
+ params=None,
27
+ ) as response:
28
+ projects_data = json.loads(response.content)
29
+ return [Project.model_validate_json(json.dumps(project)) for project in projects_data]
30
+
31
+ async def create(self, name: str) -> CreateProjectResponse:
32
+ async with self._client.request(
33
+ handler=RequestHandler.CreateProject,
34
+ params=None,
35
+ body=CreateProjectRequestBody(name=name),
36
+ ) as response:
37
+ response_data = json.loads(response.content)
38
+ return CreateProjectResponse(
39
+ project=Project.model_validate_json(json.dumps(response_data["project"])),
40
+ version=Version.model_validate_json(json.dumps(response_data["version"])),
41
+ )
@@ -1,7 +1,11 @@
1
- import asyncio
2
- from typing import Any, AsyncGenerator, List, Optional, Sequence, Tuple, Union
1
+ from typing import Any, AsyncGenerator, Callable, List, Optional, Sequence
3
2
 
4
- from promptl_ai import Adapter, Message, MessageLike, Promptl, ToolMessage, ToolResultContent
3
+ from promptl_ai import (
4
+ Adapter,
5
+ Message,
6
+ MessageLike,
7
+ Promptl,
8
+ )
5
9
  from promptl_ai.bindings.types import _Message
6
10
 
7
11
  from latitude_sdk.client import (
@@ -16,10 +20,10 @@ from latitude_sdk.client import (
16
20
  RequestHandler,
17
21
  RunPromptRequestBody,
18
22
  RunPromptRequestParams,
23
+ ToolResultsRequestBody,
19
24
  )
20
25
  from latitude_sdk.sdk.errors import ApiError, ApiErrorCodes
21
26
  from latitude_sdk.sdk.types import (
22
- AGENT_END_TOOL_NAME,
23
27
  ChainEvents,
24
28
  FinishedResult,
25
29
  OnStep,
@@ -30,7 +34,6 @@ from latitude_sdk.sdk.types import (
30
34
  SdkOptions,
31
35
  StreamCallbacks,
32
36
  StreamEvents,
33
- ToolCall,
34
37
  ToolResult,
35
38
  _LatitudeEvent,
36
39
  )
@@ -52,10 +55,6 @@ _PROMPT_ATTR_TO_ADAPTER_ATTR = {
52
55
  }
53
56
 
54
57
 
55
- class OnToolCallPaused(Exception):
56
- pass
57
-
58
-
59
58
  class PromptOptions(Model):
60
59
  project_id: Optional[int] = None
61
60
  version_uuid: Optional[str] = None
@@ -142,28 +141,15 @@ class Prompts:
142
141
  response="Project ID is required",
143
142
  )
144
143
 
145
- async def _extract_agent_tool_requests(
146
- self, tool_requests: List[ToolCall]
147
- ) -> Tuple[List[ToolCall], List[ToolCall]]:
148
- agent: List[ToolCall] = []
149
- other: List[ToolCall] = []
150
-
151
- for tool in tool_requests:
152
- if tool.name == AGENT_END_TOOL_NAME:
153
- agent.append(tool)
154
- else:
155
- other.append(tool)
156
-
157
- return agent, other
158
-
159
144
  async def _handle_stream(
160
- self, stream: AsyncGenerator[ClientEvent, Any], on_event: Optional[StreamCallbacks.OnEvent]
145
+ self,
146
+ stream: AsyncGenerator[ClientEvent, Any],
147
+ on_event: Optional[StreamCallbacks.OnEvent],
148
+ on_tool_call: Optional[Callable[[dict[str, Any]], Any]] = None,
161
149
  ) -> FinishedResult:
162
150
  uuid = None
163
151
  conversation: List[Message] = []
164
152
  response = None
165
- agent_response = None
166
- tool_requests: List[ToolCall] = []
167
153
 
168
154
  async for stream_event in stream:
169
155
  event = None
@@ -176,9 +162,6 @@ class Prompts:
176
162
  if event.type == ChainEvents.ProviderCompleted:
177
163
  response = event.response
178
164
 
179
- elif event.type == ChainEvents.ToolsRequested:
180
- tool_requests = event.tools
181
-
182
165
  elif event.type == ChainEvents.ChainError:
183
166
  raise ApiError(
184
167
  status=400,
@@ -191,6 +174,10 @@ class Prompts:
191
174
  event = stream_event.json()
192
175
  event["event"] = StreamEvents.Provider
193
176
 
177
+ # Handle tool calls when received in the stream
178
+ if on_tool_call and event.get("type") == "tool-call":
179
+ await on_tool_call(event)
180
+
194
181
  else:
195
182
  raise ApiError(
196
183
  status=500,
@@ -210,22 +197,12 @@ class Prompts:
210
197
  response="Stream ended without a chain-complete event. Missing uuid or response.",
211
198
  )
212
199
 
213
- agent_requests, tool_requests = await self._extract_agent_tool_requests(tool_requests)
214
- if len(agent_requests) > 0:
215
- agent_response = agent_requests[0].arguments
216
-
217
200
  return FinishedResult(
218
201
  uuid=uuid,
219
202
  conversation=conversation,
220
203
  response=response,
221
- agent_response=agent_response,
222
- tool_requests=tool_requests,
223
204
  )
224
205
 
225
- @staticmethod
226
- def _pause_tool_execution() -> Any:
227
- raise OnToolCallPaused()
228
-
229
206
  @staticmethod
230
207
  async def _wrap_tool_handler(
231
208
  handler: OnToolCall, arguments: dict[str, Any], details: OnToolCallDetails
@@ -237,67 +214,64 @@ class Prompts:
237
214
 
238
215
  return ToolResult(**tool_result, result=result)
239
216
  except Exception as exception:
240
- if isinstance(exception, OnToolCallPaused):
241
- raise exception
242
-
243
217
  return ToolResult(**tool_result, result=str(exception), is_error=True)
244
218
 
245
- async def _handle_tool_calls(
246
- self, result: FinishedResult, options: Union[RunPromptOptions, ChatPromptOptions]
247
- ) -> Optional[FinishedResult]:
248
- if not options.tools:
249
- raise ApiError(
250
- status=400,
251
- code=ApiErrorCodes.AIRunError,
252
- message="Tools not supplied",
253
- response="Tools not supplied",
254
- )
255
-
256
- for tool_call in result.tool_requests:
257
- if tool_call.name not in options.tools:
258
- raise ApiError(
259
- status=400,
260
- code=ApiErrorCodes.AIRunError,
261
- message=f"Tool {tool_call.name} not supplied",
262
- response=f"Tool {tool_call.name} not supplied",
263
- )
219
+ async def _handle_tool_call(
220
+ self,
221
+ event: dict[str, Any],
222
+ tools: dict[str, OnToolCall],
223
+ ) -> None:
224
+ toolCallId: str = event["toolCallId"]
225
+ toolName: str = event["toolName"]
226
+ args: dict[str, Any] = event["args"]
227
+
228
+ tool = tools.get(toolName)
229
+ if not tool:
230
+ return
231
+
232
+ tool_result = await self._wrap_tool_handler(
233
+ tool,
234
+ args,
235
+ OnToolCallDetails(
236
+ id=toolCallId,
237
+ name=toolName,
238
+ arguments=args,
239
+ ),
240
+ )
264
241
 
265
- tool_results = await asyncio.gather(
266
- *[
267
- self._wrap_tool_handler(
268
- options.tools[tool_call.name],
269
- tool_call.arguments,
270
- OnToolCallDetails(
271
- id=tool_call.id,
272
- name=tool_call.name,
273
- conversation_uuid=result.uuid,
274
- messages=result.conversation,
275
- pause_execution=self._pause_tool_execution,
276
- requested_tool_calls=result.tool_requests,
277
- ),
242
+ try:
243
+ async with self._client.request(
244
+ handler=RequestHandler.ToolResults,
245
+ params=None,
246
+ body=ToolResultsRequestBody(
247
+ tool_call_id=toolCallId,
248
+ result=tool_result.result,
249
+ is_error=tool_result.is_error,
250
+ ),
251
+ ) as _:
252
+ pass
253
+ except Exception as exception:
254
+ if not isinstance(exception, ApiError):
255
+ exception = ApiError(
256
+ status=500,
257
+ code=ApiErrorCodes.InternalServerError,
258
+ message=str(exception),
259
+ response=str(exception),
278
260
  )
279
- for tool_call in result.tool_requests
280
- ],
281
- return_exceptions=False,
282
- )
283
261
 
284
- tool_messages = [
285
- ToolMessage(
286
- content=[
287
- ToolResultContent(
288
- id=tool_result.id,
289
- name=tool_result.name,
290
- result=tool_result.result,
291
- is_error=tool_result.is_error,
292
- )
293
- ]
294
- )
295
- for tool_result in tool_results
296
- ]
262
+ # Add context about which tool failed
263
+ message = f"Failed to execute tool {toolName}. \nLatitude API returned the following error:\
264
+ \n\n{exception.message}"
297
265
 
298
- next_result = await self.chat(result.uuid, tool_messages, ChatPromptOptions(**dict(options)))
266
+ raise ApiError(
267
+ status=exception.status,
268
+ code=exception.code,
269
+ message=message,
270
+ response=exception.response,
271
+ ) from exception
299
272
 
300
- return FinishedResult(**dict(next_result)) if next_result else None
273
+ def _on_tool_call(self, tools: dict[str, OnToolCall]) -> Callable[[dict[str, Any]], Any]:
274
+ return lambda event: self._handle_tool_call(event, tools)
301
275
 
302
276
  async def get(self, path: str, options: Optional[GetPromptOptions] = None) -> GetPromptResult:
303
277
  options = GetPromptOptions(**{**dict(self._options), **dict(options or {})})
@@ -365,21 +339,18 @@ class Prompts:
365
339
  parameters=options.parameters,
366
340
  custom_identifier=options.custom_identifier,
367
341
  stream=options.stream,
342
+ tools=list(options.tools.keys()) if options.tools and options.stream else None,
368
343
  ),
369
344
  ) as response:
370
345
  if options.stream:
371
- result = await self._handle_stream(response.sse(), options.on_event)
346
+ result = await self._handle_stream(
347
+ response.sse(),
348
+ options.on_event,
349
+ self._on_tool_call(options.tools if options.tools else {}),
350
+ )
372
351
  else:
373
352
  result = RunPromptResult.model_validate_json(response.content)
374
353
 
375
- if options.tools and result.tool_requests:
376
- try:
377
- # NOTE: The last sdk.chat called will already call on_finished
378
- final_result = await self._handle_tool_calls(result, options)
379
- return RunPromptResult(**dict(final_result)) if final_result else None
380
- except OnToolCallPaused:
381
- pass
382
-
383
354
  if options.on_finished:
384
355
  options.on_finished(FinishedResult(**dict(result)))
385
356
 
@@ -402,7 +373,10 @@ class Prompts:
402
373
  return None
403
374
 
404
375
  async def chat(
405
- self, uuid: str, messages: Sequence[MessageLike], options: Optional[ChatPromptOptions] = None
376
+ self,
377
+ uuid: str,
378
+ messages: Sequence[MessageLike],
379
+ options: Optional[ChatPromptOptions] = None,
406
380
  ) -> Optional[ChatPromptResult]:
407
381
  options = ChatPromptOptions(**{**dict(self._options), **dict(options or {})})
408
382
 
@@ -420,18 +394,14 @@ class Prompts:
420
394
  ),
421
395
  ) as response:
422
396
  if options.stream:
423
- result = await self._handle_stream(response.sse(), options.on_event)
397
+ result = await self._handle_stream(
398
+ response.sse(),
399
+ options.on_event,
400
+ self._on_tool_call(options.tools if options.tools else {}),
401
+ )
424
402
  else:
425
403
  result = ChatPromptResult.model_validate_json(response.content)
426
404
 
427
- if options.tools and result.tool_requests:
428
- try:
429
- # NOTE: The last sdk.chat called will already call on_finished
430
- final_result = await self._handle_tool_calls(result, options)
431
- return ChatPromptResult(**dict(final_result)) if final_result else None
432
- except OnToolCallPaused:
433
- pass
434
-
435
405
  if options.on_finished:
436
406
  options.on_finished(FinishedResult(**dict(result)))
437
407
 
@@ -481,7 +451,10 @@ class Prompts:
481
451
  )
482
452
 
483
453
  async def render_chain(
484
- self, prompt: Prompt, on_step: OnStep, options: Optional[RenderChainOptions] = None
454
+ self,
455
+ prompt: Prompt,
456
+ on_step: OnStep,
457
+ options: Optional[RenderChainOptions] = None,
485
458
  ) -> RenderChainResult:
486
459
  options = RenderChainOptions(**{**dict(self._options), **dict(options or {})})
487
460
  adapter = options.adapter or _PROVIDER_TO_ADAPTER.get(prompt.provider or Providers.OpenAI, Adapter.OpenAI)
latitude_sdk/sdk/types.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from datetime import datetime
2
2
  from typing import (
3
3
  Any,
4
- Callable,
5
4
  List,
6
5
  Literal,
7
6
  Optional,
@@ -70,7 +69,6 @@ class FinishReason(StrEnum):
70
69
 
71
70
 
72
71
  AGENT_START_TOOL_NAME = "start_autonomous_chain"
73
- AGENT_END_TOOL_NAME = "end_autonomous_chain"
74
72
 
75
73
 
76
74
  class ToolCall(Model):
@@ -131,7 +129,6 @@ class ChainEvents(StrEnum):
131
129
  StepCompleted = "step-completed"
132
130
  ChainCompleted = "chain-completed"
133
131
  ChainError = "chain-error"
134
- ToolsRequested = "tools-requested"
135
132
 
136
133
 
137
134
  class GenericChainEvent(Model):
@@ -185,11 +182,6 @@ class ChainEventChainError(GenericChainEvent):
185
182
  error: ChainError
186
183
 
187
184
 
188
- class ChainEventToolsRequested(GenericChainEvent):
189
- type: Literal[ChainEvents.ToolsRequested] = ChainEvents.ToolsRequested
190
- tools: List[ToolCall]
191
-
192
-
193
185
  ChainEvent = Union[
194
186
  ChainEventChainStarted,
195
187
  ChainEventStepStarted,
@@ -200,7 +192,6 @@ ChainEvent = Union[
200
192
  ChainEventStepCompleted,
201
193
  ChainEventChainCompleted,
202
194
  ChainEventChainError,
203
- ChainEventToolsRequested,
204
195
  ]
205
196
 
206
197
  LatitudeEvent = ChainEvent
@@ -211,8 +202,6 @@ class FinishedResult(Model):
211
202
  uuid: str
212
203
  conversation: List[Message]
213
204
  response: ChainResponse
214
- agent_response: Optional[dict[str, Any]] = Field(default=None, alias=str("agentResponse"))
215
- tool_requests: List[ToolCall] = Field(alias=str("toolRequests"))
216
205
 
217
206
 
218
207
  StreamEvent = Union[ProviderEvent, LatitudeEvent]
@@ -240,6 +229,22 @@ class Log(Model):
240
229
  updated_at: datetime = Field(alias=str("updatedAt"))
241
230
 
242
231
 
232
+ class Project(Model):
233
+ id: int
234
+ uuid: Optional[str] = None
235
+ name: str
236
+ created_at: datetime = Field(alias=str("createdAt"))
237
+ updated_at: datetime = Field(alias=str("updatedAt"))
238
+
239
+
240
+ class Version(Model):
241
+ uuid: str
242
+ name: str
243
+ project_id: int = Field(alias=str("projectId"))
244
+ created_at: datetime = Field(alias=str("createdAt"))
245
+ updated_at: datetime = Field(alias=str("updatedAt"))
246
+
247
+
243
248
  class StreamCallbacks(Model):
244
249
  @runtime_checkable
245
250
  class OnEvent(Protocol):
@@ -263,10 +268,7 @@ class StreamCallbacks(Model):
263
268
  class OnToolCallDetails(Model):
264
269
  id: str
265
270
  name: str
266
- conversation_uuid: str
267
- messages: List[Message]
268
- pause_execution: Callable[[], ToolResult]
269
- requested_tool_calls: List[ToolCall]
271
+ arguments: dict[str, Any]
270
272
 
271
273
 
272
274
  @runtime_checkable
@@ -1,13 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: latitude-sdk
3
- Version: 3.0.1
3
+ Version: 4.0.0b1
4
4
  Summary: Latitude SDK for Python
5
5
  Project-URL: repository, https://github.com/latitude-dev/latitude-llm/tree/main/packages/sdks/python
6
6
  Project-URL: homepage, https://github.com/latitude-dev/latitude-llm/tree/main/packages/sdks/python#readme
7
7
  Project-URL: documentation, https://github.com/latitude-dev/latitude-llm/tree/main/packages/sdks/python#readme
8
8
  Author-email: Latitude Data SL <hello@latitude.so>
9
9
  Maintainer-email: Latitude Data SL <hello@latitude.so>
10
- License-Expression: LGPL-3.0
10
+ License-Expression: MIT
11
+ License-File: LICENSE.md
11
12
  Requires-Python: <3.13,>=3.9
12
13
  Requires-Dist: httpx-sse>=0.4.0
13
14
  Requires-Dist: httpx>=0.28.1
@@ -45,7 +46,7 @@ await sdk.prompts.run("joke-teller", RunPromptOptions(
45
46
  ))
46
47
  ```
47
48
 
48
- Find more [examples](https://github.com/latitude-dev/latitude-llm/tree/main/examples/sdks/python).
49
+ Find more [examples](https://docs.latitude.so/examples/sdk).
49
50
 
50
51
  ## Development
51
52
 
@@ -59,7 +60,9 @@ Requires uv `0.5.10` or higher.
59
60
  - Build package: `uv build`
60
61
  - Publish package: `uv publish`
61
62
 
62
- ## Run only one test
63
+ ### Running only a specific test
64
+
65
+ Mark the test with an `only` marker:
63
66
 
64
67
  ```python
65
68
  import pytest
@@ -69,13 +72,13 @@ async def my_test(self):
69
72
  # ... your code
70
73
  ```
71
74
 
72
- And then run the tests with the marker `only`:
75
+ ...and then run the tests with the marker `only`:
73
76
 
74
77
  ```sh
75
78
  uv run scripts/test.py -m only
76
79
  ```
77
80
 
78
- Other way is all in line:
81
+ Another way is to specify the test in line:
79
82
 
80
83
  ```python
81
84
  uv run scripts/test.py <test_path>::<test_case>::<test_name>
@@ -83,4 +86,4 @@ uv run scripts/test.py <test_path>::<test_case>::<test_name>
83
86
 
84
87
  ## License
85
88
 
86
- The SDK is licensed under the [LGPL-3.0 License](https://opensource.org/licenses/LGPL-3.0) - read the [LICENSE](/LICENSE) file for details.
89
+ The SDK is licensed under the [MIT License](https://opensource.org/licenses/MIT) - read the [LICENSE](/LICENSE) file for details.
@@ -0,0 +1,22 @@
1
+ latitude_sdk/__init__.py,sha256=-AbNXLmzDZeGbRdDIOpNjdCbacOvLBflSJwQtLlZfgk,19
2
+ latitude_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ latitude_sdk/client/__init__.py,sha256=d8CnNB8UoGwcftiIeeC0twdg01qNvfpj-v7O40I7IiE,68
4
+ latitude_sdk/client/client.py,sha256=PR89Qy0hL0GQEEKs4eys7wc3BhIPF_P9N-AJB6ufrV8,9676
5
+ latitude_sdk/client/payloads.py,sha256=z0tLYE9clybYaFWHgMVJgj9w-6exUQgfdB8egLw1-5E,2998
6
+ latitude_sdk/client/router.py,sha256=k5OT42_3riJlTm5Q2BU3AplmpN1FdMxQeIj8GptcllQ,4616
7
+ latitude_sdk/env/__init__.py,sha256=66of5veJ-u1aNI025L65Rrj321AjrYevMqomTMYIrPQ,19
8
+ latitude_sdk/env/env.py,sha256=MnXexPOHE6aXcAszrDCbW7hzACUv4YtU1bfxpYwvHNw,455
9
+ latitude_sdk/sdk/__init__.py,sha256=C9LlIjfnrS7KOK3-ruXKmbT77nSQMm23nZ6-t8sO8ME,137
10
+ latitude_sdk/sdk/errors.py,sha256=9GlGdDE8LGy3dE2Ry_BipBg-tDbQx7LWXJfSnTJSSBE,1747
11
+ latitude_sdk/sdk/evaluations.py,sha256=UP0DKMOLbqcCYrJcxiqgsqUM3anGs8pkAZu1DoOSwxI,1845
12
+ latitude_sdk/sdk/latitude.py,sha256=-569RfclTdfjuEIrbz0y6mH3o3LF7KAAWqphLPBN0dU,2702
13
+ latitude_sdk/sdk/logs.py,sha256=CyHkRJvPl_p7wTSvR9bgxEI5akS0Tjc9FeQRb2C2vMg,1997
14
+ latitude_sdk/sdk/projects.py,sha256=1l6Vvu01-Oi7tp2L1lAhWyZelWIqJVju6rJlR91MVCQ,1458
15
+ latitude_sdk/sdk/prompts.py,sha256=pi2eAYssCcc6q6kwfzyYfV4z2gRoN_eAS1rXYEAobd8,15949
16
+ latitude_sdk/sdk/types.py,sha256=lfSjZUrPed8tNHeJ4vi0HLvpWHFthN9MFCKWGcNY-vg,7481
17
+ latitude_sdk/util/__init__.py,sha256=alIDGBnxWH4JvP-UW-7N99seBBi0r1GV1h8f1ERFBec,21
18
+ latitude_sdk/util/utils.py,sha256=hMOmF-u1QaDgOwXN6ME6n4TaQ70yZKLvijDUqNCMwXI,2844
19
+ latitude_sdk-4.0.0b1.dist-info/METADATA,sha256=o9eFEHQ7z5h7c-A5qpHwwbUDR6pLhO7Bhs-Gpo0u2tA,2372
20
+ latitude_sdk-4.0.0b1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ latitude_sdk-4.0.0b1.dist-info/licenses/LICENSE.md,sha256=yFReu_tr5pjxslWkQREfxA9yVm2r6gay2s6SFCh0XfQ,1073
22
+ latitude_sdk-4.0.0b1.dist-info/RECORD,,
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Latitude Data SL 2025
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.
@@ -1,20 +0,0 @@
1
- latitude_sdk/__init__.py,sha256=-AbNXLmzDZeGbRdDIOpNjdCbacOvLBflSJwQtLlZfgk,19
2
- latitude_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- latitude_sdk/client/__init__.py,sha256=d8CnNB8UoGwcftiIeeC0twdg01qNvfpj-v7O40I7IiE,68
4
- latitude_sdk/client/client.py,sha256=Tlg0IU7_64O9yo0BfQEdVwVsjp-5_DUwJZqlX3uEb0w,4399
5
- latitude_sdk/client/payloads.py,sha256=XiGadgaZhCMKa2auBPLln6RTEt8RwEqx_H8FoSv5h2c,2554
6
- latitude_sdk/client/router.py,sha256=sdNvUNXqoM5TDmvN7toyJWuI8Lt4Z6MhKmWWKitEFes,4218
7
- latitude_sdk/env/__init__.py,sha256=66of5veJ-u1aNI025L65Rrj321AjrYevMqomTMYIrPQ,19
8
- latitude_sdk/env/env.py,sha256=MnXexPOHE6aXcAszrDCbW7hzACUv4YtU1bfxpYwvHNw,455
9
- latitude_sdk/sdk/__init__.py,sha256=C9LlIjfnrS7KOK3-ruXKmbT77nSQMm23nZ6-t8sO8ME,137
10
- latitude_sdk/sdk/errors.py,sha256=9GlGdDE8LGy3dE2Ry_BipBg-tDbQx7LWXJfSnTJSSBE,1747
11
- latitude_sdk/sdk/evaluations.py,sha256=UP0DKMOLbqcCYrJcxiqgsqUM3anGs8pkAZu1DoOSwxI,1845
12
- latitude_sdk/sdk/latitude.py,sha256=lUlGOiZXSFt0zm3sTHfBgjKzbcVJueMk6MXTGn9WCn8,2570
13
- latitude_sdk/sdk/logs.py,sha256=CyHkRJvPl_p7wTSvR9bgxEI5akS0Tjc9FeQRb2C2vMg,1997
14
- latitude_sdk/sdk/prompts.py,sha256=WztCpDSt0mxng0N2hyIrrzEw1TanqGRIl7Cpc_5ei4M,17369
15
- latitude_sdk/sdk/types.py,sha256=e_AQ6YGRNgYvPJOxD5rndH5zPtslKMBhBtLocWlIHQE,7626
16
- latitude_sdk/util/__init__.py,sha256=alIDGBnxWH4JvP-UW-7N99seBBi0r1GV1h8f1ERFBec,21
17
- latitude_sdk/util/utils.py,sha256=hMOmF-u1QaDgOwXN6ME6n4TaQ70yZKLvijDUqNCMwXI,2844
18
- latitude_sdk-3.0.1.dist-info/METADATA,sha256=9z1D0tOFBqpvUZl89T75YQdbJZqbkhVDI3VPlT4yhZg,2327
19
- latitude_sdk-3.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
20
- latitude_sdk-3.0.1.dist-info/RECORD,,