somewhere-tech 0.2.0__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.
- somewhere/__init__.py +168 -0
- somewhere/auth.py +210 -0
- somewhere/chat.py +297 -0
- somewhere/client.py +542 -0
- somewhere/emails.py +106 -0
- somewhere/errors.py +45 -0
- somewhere/query_builder.py +312 -0
- somewhere/storage.py +233 -0
- somewhere_tech-0.2.0.dist-info/METADATA +150 -0
- somewhere_tech-0.2.0.dist-info/RECORD +13 -0
- somewhere_tech-0.2.0.dist-info/WHEEL +5 -0
- somewhere_tech-0.2.0.dist-info/licenses/LICENSE +21 -0
- somewhere_tech-0.2.0.dist-info/top_level.txt +1 -0
somewhere/__init__.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Official Python SDK for the somewhere.tech platform API.
|
|
2
|
+
|
|
3
|
+
Supabase-compatible interface with query builder, auth, storage, email,
|
|
4
|
+
and OpenAI-compatible chat completions.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from somewhere import Somewhere
|
|
8
|
+
|
|
9
|
+
sw = Somewhere(key='smt_...', project_id='my-project')
|
|
10
|
+
|
|
11
|
+
# Database (Supabase-style query builder)
|
|
12
|
+
result = sw.from_('users').select('*').eq('role', 'admin').execute()
|
|
13
|
+
|
|
14
|
+
# Auth
|
|
15
|
+
result = sw.auth.sign_up(email='u@test.com', password='pass123')
|
|
16
|
+
|
|
17
|
+
# Storage
|
|
18
|
+
result = sw.storage.from_('avatars').upload('photo.png', file_bytes)
|
|
19
|
+
|
|
20
|
+
# Email
|
|
21
|
+
result = sw.emails.send(to='user@test.com', subject='Hi', html='<h1>Hello</h1>')
|
|
22
|
+
|
|
23
|
+
# AI (OpenAI-compatible)
|
|
24
|
+
completion = sw.chat.completions.create(model='claude-sonnet-4-6', messages=[...])
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Dict, Optional
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
from .auth import AuthClient
|
|
33
|
+
from .chat import ChatClient
|
|
34
|
+
from .client import (
|
|
35
|
+
BillingClient,
|
|
36
|
+
CronClient,
|
|
37
|
+
DeployClient,
|
|
38
|
+
DomainsClient,
|
|
39
|
+
EnvClient,
|
|
40
|
+
FeedbackClient,
|
|
41
|
+
HttpClient,
|
|
42
|
+
JobsClient,
|
|
43
|
+
LogsClient,
|
|
44
|
+
PreviewClient,
|
|
45
|
+
ProjectsClient,
|
|
46
|
+
QueueClient,
|
|
47
|
+
UsageClient,
|
|
48
|
+
)
|
|
49
|
+
from .emails import EmailsClient
|
|
50
|
+
from .errors import SomewhereError
|
|
51
|
+
from .query_builder import QueryBuilder, Result
|
|
52
|
+
from .storage import StorageClient
|
|
53
|
+
|
|
54
|
+
__all__ = ["Somewhere", "SomewhereError", "Result"]
|
|
55
|
+
__version__ = "0.2.0"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Somewhere:
|
|
59
|
+
"""Entry point for the somewhere.tech SDK.
|
|
60
|
+
|
|
61
|
+
Provides a Supabase-compatible interface with query builder, auth,
|
|
62
|
+
storage, email, and OpenAI-compatible chat completions.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
key: Developer API key (smt_...). Mutually exclusive with token.
|
|
66
|
+
token: App-user JWT. Mutually exclusive with key.
|
|
67
|
+
project_id: Default project ID for all operations.
|
|
68
|
+
base_url: API base URL (defaults to https://api.somewhere.tech/v1).
|
|
69
|
+
http_client: Custom httpx.Client instance (optional).
|
|
70
|
+
headers: Extra headers to send with every request (optional).
|
|
71
|
+
timeout: Request timeout in seconds (default 60).
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
sw = Somewhere(key='smt_...', project_id='booking-app')
|
|
75
|
+
result = sw.from_('users').select('*').eq('role', 'admin').execute()
|
|
76
|
+
print(result.data)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
key: Optional[str] = None,
|
|
83
|
+
token: Optional[str] = None,
|
|
84
|
+
project_id: Optional[str] = None,
|
|
85
|
+
base_url: str = "https://api.somewhere.tech/v1",
|
|
86
|
+
http_client: Optional[httpx.Client] = None,
|
|
87
|
+
headers: Optional[Dict[str, str]] = None,
|
|
88
|
+
timeout: float = 60.0,
|
|
89
|
+
) -> None:
|
|
90
|
+
self._http = HttpClient(
|
|
91
|
+
key=key,
|
|
92
|
+
token=token,
|
|
93
|
+
project_id=project_id,
|
|
94
|
+
base_url=base_url,
|
|
95
|
+
http_client=http_client,
|
|
96
|
+
headers=headers,
|
|
97
|
+
timeout=timeout,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Supabase-compatible clients
|
|
101
|
+
self.auth = AuthClient(
|
|
102
|
+
project_id=self._http.project_id,
|
|
103
|
+
http_client=self._http.client,
|
|
104
|
+
base_url=self._http.base_url,
|
|
105
|
+
auth_header=self._http.auth_header,
|
|
106
|
+
extra_headers=self._http.extra_headers,
|
|
107
|
+
)
|
|
108
|
+
self.storage = StorageClient(
|
|
109
|
+
project_id=self._http.project_id,
|
|
110
|
+
http_client=self._http.client,
|
|
111
|
+
base_url=self._http.base_url,
|
|
112
|
+
auth_header=self._http.auth_header,
|
|
113
|
+
extra_headers=self._http.extra_headers,
|
|
114
|
+
)
|
|
115
|
+
self.emails = EmailsClient(
|
|
116
|
+
project_id=self._http.project_id,
|
|
117
|
+
http_client=self._http.client,
|
|
118
|
+
base_url=self._http.base_url,
|
|
119
|
+
auth_header=self._http.auth_header,
|
|
120
|
+
extra_headers=self._http.extra_headers,
|
|
121
|
+
)
|
|
122
|
+
self.chat = ChatClient(
|
|
123
|
+
project_id=self._http.project_id,
|
|
124
|
+
http_client=self._http.client,
|
|
125
|
+
base_url=self._http.base_url,
|
|
126
|
+
auth_header=self._http.auth_header,
|
|
127
|
+
extra_headers=self._http.extra_headers,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Simple resource clients
|
|
131
|
+
self.env = EnvClient(self._http)
|
|
132
|
+
self.jobs = JobsClient(self._http)
|
|
133
|
+
self.cron = CronClient(self._http)
|
|
134
|
+
self.domains = DomainsClient(self._http)
|
|
135
|
+
self.projects = ProjectsClient(self._http)
|
|
136
|
+
self.deploy = DeployClient(self._http)
|
|
137
|
+
self.logs = LogsClient(self._http)
|
|
138
|
+
self.queue = QueueClient(self._http)
|
|
139
|
+
self.billing = BillingClient(self._http)
|
|
140
|
+
self.usage = UsageClient(self._http)
|
|
141
|
+
self.feedback = FeedbackClient(self._http)
|
|
142
|
+
self.preview = PreviewClient(self._http)
|
|
143
|
+
|
|
144
|
+
def from_(self, table: str) -> QueryBuilder:
|
|
145
|
+
"""Start a Supabase-style query builder for the given table.
|
|
146
|
+
|
|
147
|
+
Uses `from_` because `from` is a reserved word in Python.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
table: The database table name.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
A QueryBuilder that supports .select(), .insert(), .update(),
|
|
154
|
+
.delete(), .upsert(), plus filter chains (.eq(), .gt(), etc.)
|
|
155
|
+
ending with .execute().
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
result = sw.from_('users').select('*').eq('role', 'admin').execute()
|
|
159
|
+
result = sw.from_('users').insert({'name': 'A'}).execute()
|
|
160
|
+
"""
|
|
161
|
+
return QueryBuilder(
|
|
162
|
+
table=table,
|
|
163
|
+
project_id=self._http.project_id,
|
|
164
|
+
http_client=self._http.client,
|
|
165
|
+
base_url=self._http.base_url,
|
|
166
|
+
auth_header=self._http.auth_header,
|
|
167
|
+
extra_headers=self._http.extra_headers,
|
|
168
|
+
)
|
somewhere/auth.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Auth client for the somewhere.tech platform -- Supabase-compatible interface.
|
|
2
|
+
|
|
3
|
+
Provides sign_up, sign_in_with_password, get_user, sign_out, and other
|
|
4
|
+
auth operations. All methods return Result objects (never raise).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .query_builder import Result
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthClient:
|
|
16
|
+
"""Supabase-compatible auth interface.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
result = sw.auth.sign_up(email='u@test.com', password='pass123')
|
|
20
|
+
result = sw.auth.sign_in_with_password(email='u@test.com', password='pass123')
|
|
21
|
+
result = sw.auth.get_user()
|
|
22
|
+
result = sw.auth.sign_out()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
project_id: str,
|
|
28
|
+
http_client: httpx.Client,
|
|
29
|
+
base_url: str,
|
|
30
|
+
auth_header: str,
|
|
31
|
+
extra_headers: Dict[str, str],
|
|
32
|
+
) -> None:
|
|
33
|
+
self._project_id = project_id
|
|
34
|
+
self._http = http_client
|
|
35
|
+
self._base_url = base_url
|
|
36
|
+
self._auth_header = auth_header
|
|
37
|
+
self._extra_headers = extra_headers
|
|
38
|
+
self._session_token: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
def sign_up(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
email: str,
|
|
44
|
+
password: str,
|
|
45
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
46
|
+
) -> Result:
|
|
47
|
+
"""Create a new user account."""
|
|
48
|
+
return self._post("/auth/signup", {
|
|
49
|
+
"project_id": self._project_id,
|
|
50
|
+
"email": email,
|
|
51
|
+
"password": password,
|
|
52
|
+
"metadata": metadata,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
def sign_in_with_password(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
email: str,
|
|
59
|
+
password: str,
|
|
60
|
+
) -> Result:
|
|
61
|
+
"""Sign in with email and password. Stores session token internally."""
|
|
62
|
+
result = self._post("/auth/login", {
|
|
63
|
+
"project_id": self._project_id,
|
|
64
|
+
"email": email,
|
|
65
|
+
"password": password,
|
|
66
|
+
})
|
|
67
|
+
if result.data and isinstance(result.data, list) and len(result.data) > 0:
|
|
68
|
+
session = result.data[0]
|
|
69
|
+
if isinstance(session, dict):
|
|
70
|
+
self._session_token = session.get("token") or session.get("session_token")
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
def get_user(self) -> Result:
|
|
74
|
+
"""Get the currently authenticated user."""
|
|
75
|
+
return self._get("/auth/me")
|
|
76
|
+
|
|
77
|
+
def sign_out(self) -> Result:
|
|
78
|
+
"""Sign out the current user."""
|
|
79
|
+
payload: Dict[str, Any] = {"project_id": self._project_id}
|
|
80
|
+
if self._session_token:
|
|
81
|
+
payload["session_token"] = self._session_token
|
|
82
|
+
result = self._post("/auth/logout", payload)
|
|
83
|
+
if not result.error:
|
|
84
|
+
self._session_token = None
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
def forgot_password(self, *, email: str) -> Result:
|
|
88
|
+
"""Send a password reset email."""
|
|
89
|
+
return self._post("/auth/forgot", {
|
|
90
|
+
"project_id": self._project_id,
|
|
91
|
+
"email": email,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
def reset_password(self, *, token: str, new_password: str) -> Result:
|
|
95
|
+
"""Reset password using a reset token."""
|
|
96
|
+
return self._post("/auth/reset", {
|
|
97
|
+
"project_id": self._project_id,
|
|
98
|
+
"token": token,
|
|
99
|
+
"new_password": new_password,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
def verify_email(self, *, code: str) -> Result:
|
|
103
|
+
"""Verify email with a verification code."""
|
|
104
|
+
return self._post("/auth/verify-email", {"code": code})
|
|
105
|
+
|
|
106
|
+
def request_verification(self) -> Result:
|
|
107
|
+
"""Request a new email verification code."""
|
|
108
|
+
return self._post("/auth/request-email-verification", {})
|
|
109
|
+
|
|
110
|
+
def update_user(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
display_name: Optional[str] = None,
|
|
114
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
115
|
+
) -> Result:
|
|
116
|
+
"""Update the current user's profile."""
|
|
117
|
+
payload: Dict[str, Any] = {}
|
|
118
|
+
if display_name is not None:
|
|
119
|
+
payload["display_name"] = display_name
|
|
120
|
+
if metadata is not None:
|
|
121
|
+
payload["metadata"] = metadata
|
|
122
|
+
return self._request("PATCH", "/auth/users/me", json_body=payload)
|
|
123
|
+
|
|
124
|
+
def delete_user(self) -> Result:
|
|
125
|
+
"""Delete the current user's account."""
|
|
126
|
+
return self._request("DELETE", "/auth/users/me")
|
|
127
|
+
|
|
128
|
+
def list_users(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
search: Optional[str] = None,
|
|
132
|
+
limit: Optional[int] = None,
|
|
133
|
+
cursor: Optional[str] = None,
|
|
134
|
+
ids: Optional[List[str]] = None,
|
|
135
|
+
) -> Result:
|
|
136
|
+
"""List users (admin operation)."""
|
|
137
|
+
params: Dict[str, Any] = {"project_id": self._project_id}
|
|
138
|
+
if search is not None:
|
|
139
|
+
params["search"] = search
|
|
140
|
+
if limit is not None:
|
|
141
|
+
params["limit"] = limit
|
|
142
|
+
if cursor is not None:
|
|
143
|
+
params["cursor"] = cursor
|
|
144
|
+
if ids is not None:
|
|
145
|
+
params["ids"] = ",".join(ids)
|
|
146
|
+
return self._get("/auth/users", params=params)
|
|
147
|
+
|
|
148
|
+
# --- Internal helpers ---
|
|
149
|
+
|
|
150
|
+
def _headers(self) -> Dict[str, str]:
|
|
151
|
+
return {
|
|
152
|
+
"Authorization": self._auth_header,
|
|
153
|
+
"Accept": "application/json",
|
|
154
|
+
"Content-Type": "application/json",
|
|
155
|
+
**self._extra_headers,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
def _post(self, path: str, payload: Dict[str, Any]) -> Result:
|
|
159
|
+
return self._request("POST", path, json_body=payload)
|
|
160
|
+
|
|
161
|
+
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Result:
|
|
162
|
+
return self._request("GET", path, params=params)
|
|
163
|
+
|
|
164
|
+
def _request(
|
|
165
|
+
self,
|
|
166
|
+
method: str,
|
|
167
|
+
path: str,
|
|
168
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
169
|
+
params: Optional[Dict[str, Any]] = None,
|
|
170
|
+
) -> Result:
|
|
171
|
+
url = f"{self._base_url}{path}"
|
|
172
|
+
try:
|
|
173
|
+
if json_body is not None:
|
|
174
|
+
# Strip None values
|
|
175
|
+
json_body = {k: v for k, v in json_body.items() if v is not None}
|
|
176
|
+
if params is not None:
|
|
177
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
178
|
+
|
|
179
|
+
response = self._http.request(
|
|
180
|
+
method,
|
|
181
|
+
url,
|
|
182
|
+
json=json_body,
|
|
183
|
+
params=params,
|
|
184
|
+
headers=self._headers(),
|
|
185
|
+
)
|
|
186
|
+
return self._parse(response)
|
|
187
|
+
except httpx.HTTPError as exc:
|
|
188
|
+
return Result(error={"code": "NETWORK_ERROR", "message": str(exc)})
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _parse(response: httpx.Response) -> Result:
|
|
192
|
+
try:
|
|
193
|
+
body = response.json()
|
|
194
|
+
except ValueError:
|
|
195
|
+
return Result(error={
|
|
196
|
+
"code": "INVALID_RESPONSE",
|
|
197
|
+
"message": f"Non-JSON response (status {response.status_code})",
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
if isinstance(body, dict) and body.get("ok") is True:
|
|
201
|
+
data = body.get("data")
|
|
202
|
+
if isinstance(data, list):
|
|
203
|
+
return Result(data=data, count=len(data))
|
|
204
|
+
if isinstance(data, dict):
|
|
205
|
+
return Result(data=[data], count=1)
|
|
206
|
+
return Result(data=[data] if data is not None else [], count=1 if data else 0)
|
|
207
|
+
|
|
208
|
+
error_code = body.get("error", "UNKNOWN_ERROR") if isinstance(body, dict) else "UNKNOWN_ERROR"
|
|
209
|
+
error_message = body.get("message", "Unknown error") if isinstance(body, dict) else str(body)
|
|
210
|
+
return Result(error={"code": error_code, "message": error_message})
|
somewhere/chat.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""OpenAI-compatible chat completions client for the somewhere.tech AI API.
|
|
2
|
+
|
|
3
|
+
This module deliberately raises SomewhereError on failure (instead of returning
|
|
4
|
+
Result) to match the OpenAI SDK convention where chat.completions.create raises
|
|
5
|
+
on error.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import SomewhereError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChatMessage:
|
|
17
|
+
"""A single message in a chat completion response."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, role: str, content: str) -> None:
|
|
20
|
+
self.role = role
|
|
21
|
+
self.content = content
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
preview = self.content[:80] + "..." if len(self.content) > 80 else self.content
|
|
25
|
+
return f"ChatMessage(role={self.role!r}, content={preview!r})"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ChatChoice:
|
|
29
|
+
"""A single choice in a chat completion response."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
index: int,
|
|
34
|
+
message: ChatMessage,
|
|
35
|
+
finish_reason: Optional[str] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.index = index
|
|
38
|
+
self.message = message
|
|
39
|
+
self.finish_reason = finish_reason
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return f"ChatChoice(index={self.index}, finish_reason={self.finish_reason!r})"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ChatUsage:
|
|
46
|
+
"""Token usage for a chat completion."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
prompt_tokens: int = 0,
|
|
51
|
+
completion_tokens: int = 0,
|
|
52
|
+
total_tokens: int = 0,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.prompt_tokens = prompt_tokens
|
|
55
|
+
self.completion_tokens = completion_tokens
|
|
56
|
+
self.total_tokens = total_tokens
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
return f"ChatUsage(prompt={self.prompt_tokens}, completion={self.completion_tokens}, total={self.total_tokens})"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ChatCompletion:
|
|
63
|
+
"""OpenAI-compatible chat completion response object.
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
completion = sw.chat.completions.create(
|
|
67
|
+
model='claude-sonnet-4-6',
|
|
68
|
+
messages=[{'role': 'user', 'content': 'Hello'}],
|
|
69
|
+
)
|
|
70
|
+
print(completion.choices[0].message.content)
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
id: str,
|
|
76
|
+
model: str,
|
|
77
|
+
choices: List[ChatChoice],
|
|
78
|
+
usage: Optional[ChatUsage] = None,
|
|
79
|
+
created: Optional[int] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.id = id
|
|
82
|
+
self.object = "chat.completion"
|
|
83
|
+
self.model = model
|
|
84
|
+
self.choices = choices
|
|
85
|
+
self.usage = usage
|
|
86
|
+
self.created = created
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return f"ChatCompletion(id={self.id!r}, model={self.model!r}, choices={len(self.choices)})"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ChatCompletionsClient:
|
|
93
|
+
"""OpenAI-compatible completions interface.
|
|
94
|
+
|
|
95
|
+
Accessed via sw.chat.completions.create(...).
|
|
96
|
+
Raises SomewhereError on failure (matches OpenAI SDK behavior).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
project_id: str,
|
|
102
|
+
http_client: httpx.Client,
|
|
103
|
+
base_url: str,
|
|
104
|
+
auth_header: str,
|
|
105
|
+
extra_headers: Dict[str, str],
|
|
106
|
+
) -> None:
|
|
107
|
+
self._project_id = project_id
|
|
108
|
+
self._http = http_client
|
|
109
|
+
self._base_url = base_url
|
|
110
|
+
self._auth_header = auth_header
|
|
111
|
+
self._extra_headers = extra_headers
|
|
112
|
+
|
|
113
|
+
def create(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
model: str,
|
|
117
|
+
messages: List[Dict[str, str]],
|
|
118
|
+
system: Optional[str] = None,
|
|
119
|
+
max_tokens: Optional[int] = None,
|
|
120
|
+
temperature: Optional[float] = None,
|
|
121
|
+
top_p: Optional[float] = None,
|
|
122
|
+
stop: Optional[List[str]] = None,
|
|
123
|
+
provider: Optional[str] = None,
|
|
124
|
+
) -> ChatCompletion:
|
|
125
|
+
"""Create a chat completion.
|
|
126
|
+
|
|
127
|
+
This method raises SomewhereError on failure (matching OpenAI SDK behavior).
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
model: Model identifier (e.g. 'claude-sonnet-4-6', 'gpt-4o').
|
|
131
|
+
messages: List of message dicts with 'role' and 'content' keys.
|
|
132
|
+
system: Optional system prompt.
|
|
133
|
+
max_tokens: Maximum tokens to generate.
|
|
134
|
+
temperature: Sampling temperature (0-2).
|
|
135
|
+
top_p: Nucleus sampling parameter.
|
|
136
|
+
stop: Stop sequences.
|
|
137
|
+
provider: Override the AI provider.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
ChatCompletion with choices[0].message.content containing the response.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
SomewhereError: On API errors (matches OpenAI SDK convention).
|
|
144
|
+
"""
|
|
145
|
+
payload: Dict[str, Any] = {
|
|
146
|
+
"project_id": self._project_id,
|
|
147
|
+
"model": model,
|
|
148
|
+
"messages": messages,
|
|
149
|
+
}
|
|
150
|
+
if system is not None:
|
|
151
|
+
payload["system"] = system
|
|
152
|
+
if max_tokens is not None:
|
|
153
|
+
payload["max_tokens"] = max_tokens
|
|
154
|
+
if temperature is not None:
|
|
155
|
+
payload["temperature"] = temperature
|
|
156
|
+
if top_p is not None:
|
|
157
|
+
payload["top_p"] = top_p
|
|
158
|
+
if stop is not None:
|
|
159
|
+
payload["stop"] = stop
|
|
160
|
+
if provider is not None:
|
|
161
|
+
payload["provider"] = provider
|
|
162
|
+
|
|
163
|
+
url = f"{self._base_url}/ai/complete"
|
|
164
|
+
headers = {
|
|
165
|
+
"Authorization": self._auth_header,
|
|
166
|
+
"Accept": "application/json",
|
|
167
|
+
"Content-Type": "application/json",
|
|
168
|
+
**self._extra_headers,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
response = self._http.post(url, json=payload, headers=headers)
|
|
173
|
+
except httpx.HTTPError as exc:
|
|
174
|
+
raise SomewhereError(
|
|
175
|
+
code="NETWORK_ERROR",
|
|
176
|
+
message=str(exc),
|
|
177
|
+
status_code=0,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
body = response.json()
|
|
182
|
+
except ValueError:
|
|
183
|
+
raise SomewhereError(
|
|
184
|
+
code="INVALID_RESPONSE",
|
|
185
|
+
message=f"Non-JSON response (status {response.status_code})",
|
|
186
|
+
status_code=response.status_code,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Error response
|
|
190
|
+
if isinstance(body, dict) and body.get("ok") is False:
|
|
191
|
+
raise SomewhereError(
|
|
192
|
+
code=body.get("error", "UNKNOWN_ERROR"),
|
|
193
|
+
message=body.get("message", "Unknown error"),
|
|
194
|
+
status_code=response.status_code,
|
|
195
|
+
retry=bool(body.get("retry", False)),
|
|
196
|
+
retry_after_ms=body.get("retry_after_ms"),
|
|
197
|
+
body=body,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if not isinstance(body, dict) or body.get("ok") is not True:
|
|
201
|
+
raise SomewhereError(
|
|
202
|
+
code="INVALID_RESPONSE",
|
|
203
|
+
message=f"Unexpected response shape (status {response.status_code})",
|
|
204
|
+
status_code=response.status_code,
|
|
205
|
+
body=body,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
data = body.get("data", {})
|
|
209
|
+
return self._to_completion(data, model)
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _to_completion(data: Any, requested_model: str) -> ChatCompletion:
|
|
213
|
+
"""Convert API response data into an OpenAI-compatible ChatCompletion."""
|
|
214
|
+
if not isinstance(data, dict):
|
|
215
|
+
data = {}
|
|
216
|
+
|
|
217
|
+
# The API may return data in different shapes. Handle both
|
|
218
|
+
# OpenAI-compatible and platform-native formats.
|
|
219
|
+
|
|
220
|
+
# If the response already has 'choices', use them directly
|
|
221
|
+
if "choices" in data:
|
|
222
|
+
choices = []
|
|
223
|
+
for i, choice_data in enumerate(data["choices"]):
|
|
224
|
+
msg = choice_data.get("message", {})
|
|
225
|
+
choices.append(ChatChoice(
|
|
226
|
+
index=choice_data.get("index", i),
|
|
227
|
+
message=ChatMessage(
|
|
228
|
+
role=msg.get("role", "assistant"),
|
|
229
|
+
content=msg.get("content", ""),
|
|
230
|
+
),
|
|
231
|
+
finish_reason=choice_data.get("finish_reason"),
|
|
232
|
+
))
|
|
233
|
+
usage_data = data.get("usage", {})
|
|
234
|
+
usage = ChatUsage(
|
|
235
|
+
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
|
236
|
+
completion_tokens=usage_data.get("completion_tokens", 0),
|
|
237
|
+
total_tokens=usage_data.get("total_tokens", 0),
|
|
238
|
+
) if usage_data else None
|
|
239
|
+
|
|
240
|
+
return ChatCompletion(
|
|
241
|
+
id=data.get("id", ""),
|
|
242
|
+
model=data.get("model", requested_model),
|
|
243
|
+
choices=choices,
|
|
244
|
+
usage=usage,
|
|
245
|
+
created=data.get("created"),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Platform-native format: data might have 'content' or 'text' directly
|
|
249
|
+
content = data.get("content") or data.get("text") or data.get("response") or ""
|
|
250
|
+
model_used = data.get("model", requested_model)
|
|
251
|
+
|
|
252
|
+
usage_data = data.get("usage", {})
|
|
253
|
+
# Platform returns input_tokens/output_tokens, OpenAI uses prompt/completion
|
|
254
|
+
prompt = usage_data.get("prompt_tokens") or usage_data.get("input_tokens") or 0
|
|
255
|
+
completion = usage_data.get("completion_tokens") or usage_data.get("output_tokens") or 0
|
|
256
|
+
usage = ChatUsage(
|
|
257
|
+
prompt_tokens=prompt,
|
|
258
|
+
completion_tokens=completion,
|
|
259
|
+
total_tokens=usage_data.get("total_tokens") or (prompt + completion),
|
|
260
|
+
) if usage_data else None
|
|
261
|
+
|
|
262
|
+
return ChatCompletion(
|
|
263
|
+
id=data.get("id", ""),
|
|
264
|
+
model=model_used,
|
|
265
|
+
choices=[
|
|
266
|
+
ChatChoice(
|
|
267
|
+
index=0,
|
|
268
|
+
message=ChatMessage(role="assistant", content=str(content)),
|
|
269
|
+
finish_reason=data.get("finish_reason", "stop"),
|
|
270
|
+
),
|
|
271
|
+
],
|
|
272
|
+
usage=usage,
|
|
273
|
+
created=data.get("created"),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class ChatClient:
|
|
278
|
+
"""Top-level chat client. Access via sw.chat.
|
|
279
|
+
|
|
280
|
+
Provides sw.chat.completions.create(...) for OpenAI-compatible chat.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def __init__(
|
|
284
|
+
self,
|
|
285
|
+
project_id: str,
|
|
286
|
+
http_client: httpx.Client,
|
|
287
|
+
base_url: str,
|
|
288
|
+
auth_header: str,
|
|
289
|
+
extra_headers: Dict[str, str],
|
|
290
|
+
) -> None:
|
|
291
|
+
self.completions = ChatCompletionsClient(
|
|
292
|
+
project_id=project_id,
|
|
293
|
+
http_client=http_client,
|
|
294
|
+
base_url=base_url,
|
|
295
|
+
auth_header=auth_header,
|
|
296
|
+
extra_headers=extra_headers,
|
|
297
|
+
)
|