22
33from __future__ import annotations
44
5+ import json
56import os
7+ import re
68from typing import Any , Dict , List , Optional
79
8- import requests
9-
1010
1111class KaybaAPIError (Exception ):
1212 """Structured error from the Kayba API."""
@@ -19,6 +19,39 @@ def __init__(self, code: str, message: str, status_code: int = 0):
1919
2020
2121DEFAULT_BASE_URL = "https://use.kayba.ai/api"
22+ MAX_TRACE_UPLOAD_BODY_BYTES = 900_000
23+
24+
25+ def _chunk_trace_uploads (
26+ traces : List [Dict [str , Any ]],
27+ ) -> List [List [Dict [str , Any ]]]:
28+ """Split uploads into request-sized batches under the body size cap."""
29+ batches : List [List [Dict [str , Any ]]] = []
30+ current : List [Dict [str , Any ]] = []
31+ current_size = len ('{"traces":[]}' )
32+
33+ for trace in traces :
34+ trace_size = len (
35+ json .dumps (trace , ensure_ascii = False , separators = ("," , ":" )).encode (
36+ "utf-8"
37+ )
38+ )
39+ separator_size = 1 if current else 0
40+ candidate_size = current_size + separator_size + trace_size
41+
42+ if current and candidate_size > MAX_TRACE_UPLOAD_BODY_BYTES :
43+ batches .append (current )
44+ current = [trace ]
45+ current_size = len ('{"traces":[]}' ) + trace_size
46+ continue
47+
48+ current .append (trace )
49+ current_size = candidate_size
50+
51+ if current :
52+ batches .append (current )
53+
54+ return batches
2255
2356
2457class KaybaClient :
@@ -35,6 +68,16 @@ def __init__(
3568 api_key : Optional [str ] = None ,
3669 base_url : Optional [str ] = None ,
3770 ):
71+ try :
72+ import requests
73+ except ImportError as exc :
74+ raise KaybaAPIError (
75+ "DEPENDENCY_MISSING" ,
76+ "The hosted Kayba CLI requires the cloud extra. Install with "
77+ "`uv add \" ace-framework[cloud]\" ` or "
78+ "`pip install 'ace-framework[cloud]'`." ,
79+ ) from exc
80+
3881 self .api_key = api_key or os .environ .get ("KAYBA_API_KEY" , "" )
3982 if not self .api_key :
4083 raise KaybaAPIError (
@@ -44,9 +87,19 @@ def __init__(
4487 self .base_url = (
4588 base_url or os .environ .get ("KAYBA_API_URL" ) or DEFAULT_BASE_URL
4689 ).rstrip ("/" )
47- self .session = requests .Session ()
90+ self .session : Any = requests .Session ()
4891 self .session .headers ["Authorization" ] = f"Bearer { self .api_key } "
4992
93+ @staticmethod
94+ def _summarize_http_body (body : str , limit : int = 240 ) -> str :
95+ """Collapse whitespace so raw HTML and proxy errors stay readable."""
96+ snippet = re .sub (r"\s+" , " " , body or "" ).strip ()
97+ if not snippet :
98+ return "Unexpected non-JSON error from the Kayba API."
99+ if len (snippet ) <= limit :
100+ return snippet
101+ return snippet [: limit - 3 ] + "..."
102+
50103 def _request (
51104 self ,
52105 method : str ,
@@ -69,15 +122,36 @@ def _request(
69122 message = err ,
70123 status_code = resp .status_code ,
71124 )
125+ message = err .get ("message" , resp .text )
126+ if (
127+ resp .status_code == 413
128+ or "maximum content size" in message .lower ()
129+ or "too large" in message .lower ()
130+ ):
131+ raise KaybaAPIError (
132+ code = "PAYLOAD_TOO_LARGE" ,
133+ message = message ,
134+ status_code = resp .status_code ,
135+ )
72136 raise KaybaAPIError (
73137 code = err .get ("code" , "UNKNOWN" ),
74- message = err . get ( " message" , resp . text ) ,
138+ message = message ,
75139 status_code = resp .status_code ,
76140 )
77141 except (ValueError , KeyError , AttributeError ):
142+ message = self ._summarize_http_body (resp .text )
143+ if resp .status_code == 413 :
144+ message = (
145+ "Upload rejected because the request body is too large. "
146+ "Try smaller traces or upload fewer files at once."
147+ )
148+ elif resp .status_code in (401 , 403 ):
149+ message = "Authentication failed; check KAYBA_API_KEY"
150+ else :
151+ message = f"HTTP { resp .status_code } from Kayba API: { message } "
78152 raise KaybaAPIError (
79153 code = "HTTP_ERROR" ,
80- message = resp . text ,
154+ message = message ,
81155 status_code = resp .status_code ,
82156 )
83157
@@ -93,7 +167,20 @@ def upload_traces(self, traces: List[Dict[str, Any]]) -> Dict[str, Any]:
93167 Args:
94168 traces: List of dicts with keys: filename, content, fileType.
95169 """
96- return self ._request ("POST" , "/traces" , json = {"traces" : traces })
170+ batches = _chunk_trace_uploads (traces )
171+ if len (batches ) == 1 :
172+ return self ._request ("POST" , "/traces" , json = {"traces" : traces })
173+
174+ combined : Dict [str , Any ] = {"count" : 0 , "traces" : []}
175+ for batch in batches :
176+ result = self ._request ("POST" , "/traces" , json = {"traces" : batch })
177+ uploaded = result .get ("traces" , [])
178+ combined ["count" ] += result .get ("count" , len (uploaded ) or len (batch ))
179+ combined ["traces" ].extend (uploaded )
180+ for key , value in result .items ():
181+ if key not in {"count" , "traces" } and key not in combined :
182+ combined [key ] = value
183+ return combined
97184
98185 def list_traces (self ) -> Dict [str , Any ]:
99186 """List all traces (metadata only, no content)."""
0 commit comments