teepin 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.
teepin/__init__.py ADDED
@@ -0,0 +1,187 @@
1
+ # Copyright 2026 TEEPIN Project
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """The Python SDK of Teepin Web Services (TWS): Teepin Storage, Teepin
5
+ Inference and compute.
6
+
7
+ >>> from teepin import Client
8
+ >>> client = Client(api_key="tpk_...")
9
+ >>> client.storage.upload_file("my-bucket", "videos/a.mp4", "a.mp4")
10
+ >>> client.chat.completions.create(model="teepin/llama-3.3-70b",
11
+ ... messages=[{"role": "user", "content": "Hello"}])
12
+
13
+ :class:`AsyncClient` is the same API for asyncio.
14
+ """
15
+
16
+ from ._core import (
17
+ DEFAULT_CONCURRENCY,
18
+ DEFAULT_PART_SIZE,
19
+ MAX_OBJECT_SIZE,
20
+ MAX_PART_SIZE,
21
+ MAX_PARTS,
22
+ MAX_PUT_SIZE,
23
+ MIN_PART_SIZE,
24
+ MULTIPART_THRESHOLD,
25
+ ProgressFunc,
26
+ compute_etag,
27
+ part_size_for,
28
+ )
29
+ from ._inference import ChatCompletionParams, ChatMessageParam
30
+ from ._version import __version__
31
+ from .async_client import AsyncClient, AsyncInstancesService
32
+ from .async_inference import (
33
+ AsyncChatCompletionsService,
34
+ AsyncChatCompletionStream,
35
+ AsyncChatService,
36
+ AsyncModelsService,
37
+ )
38
+ from .async_storage import AsyncObjectReader, AsyncStorageService
39
+ from .client import Client, InstancesService
40
+ from .exceptions import (
41
+ APIConnectionError,
42
+ APIError,
43
+ BadRequestError,
44
+ ChecksumMismatchError,
45
+ ConflictError,
46
+ ForbiddenError,
47
+ InsufficientCreditError,
48
+ NoCreditError,
49
+ NotFoundError,
50
+ ObjectChangedError,
51
+ OnHoldError,
52
+ PaymentRequiredError,
53
+ PermissionDeniedError,
54
+ PreconditionFailedError,
55
+ RateLimitError,
56
+ StreamError,
57
+ TeepinError,
58
+ TooLargeError,
59
+ UnauthorizedError,
60
+ UploadError,
61
+ error_code,
62
+ is_retryable,
63
+ )
64
+ from .inference import ChatCompletionsService, ChatCompletionStream, ChatService, ModelsService
65
+ from .models import (
66
+ Bucket,
67
+ ChatCompletion,
68
+ ChatCompletionChunk,
69
+ ChatMessage,
70
+ Choice,
71
+ ChoiceDelta,
72
+ ChunkChoice,
73
+ CompletedPart,
74
+ CreateInstanceRequest,
75
+ DeleteError,
76
+ DeleteResult,
77
+ FunctionCall,
78
+ Hold,
79
+ InferenceModel,
80
+ Instance,
81
+ InstanceType,
82
+ ModelAttestation,
83
+ ModelPricing,
84
+ ObjectInfo,
85
+ ObjectPage,
86
+ Part,
87
+ PortMapping,
88
+ RateLimit,
89
+ ResponseMeta,
90
+ SignedUpload,
91
+ SignedURL,
92
+ StorageObject,
93
+ ToolCall,
94
+ Upload,
95
+ Usage,
96
+ )
97
+ from .storage import ObjectReader, StorageService
98
+
99
+ __all__ = [
100
+ "__version__",
101
+ # clients
102
+ "Client",
103
+ "AsyncClient",
104
+ "StorageService",
105
+ "AsyncStorageService",
106
+ "InstancesService",
107
+ "AsyncInstancesService",
108
+ "ObjectReader",
109
+ "AsyncObjectReader",
110
+ "ModelsService",
111
+ "AsyncModelsService",
112
+ "ChatService",
113
+ "AsyncChatService",
114
+ "ChatCompletionsService",
115
+ "AsyncChatCompletionsService",
116
+ "ChatCompletionStream",
117
+ "AsyncChatCompletionStream",
118
+ # storage
119
+ "Bucket",
120
+ "StorageObject",
121
+ "ObjectInfo",
122
+ "ObjectPage",
123
+ "Hold",
124
+ "DeleteError",
125
+ "DeleteResult",
126
+ "SignedURL",
127
+ "SignedUpload",
128
+ "Upload",
129
+ "Part",
130
+ "CompletedPart",
131
+ "compute_etag",
132
+ "part_size_for",
133
+ "ProgressFunc",
134
+ "MIN_PART_SIZE",
135
+ "MAX_PART_SIZE",
136
+ "MAX_PARTS",
137
+ "MAX_OBJECT_SIZE",
138
+ "MAX_PUT_SIZE",
139
+ "DEFAULT_PART_SIZE",
140
+ "MULTIPART_THRESHOLD",
141
+ "DEFAULT_CONCURRENCY",
142
+ # compute
143
+ "Instance",
144
+ "CreateInstanceRequest",
145
+ "InstanceType",
146
+ "PortMapping",
147
+ # inference
148
+ "InferenceModel",
149
+ "ModelPricing",
150
+ "ModelAttestation",
151
+ "ChatMessageParam",
152
+ "ChatCompletionParams",
153
+ "ChatCompletion",
154
+ "ChatMessage",
155
+ "Choice",
156
+ "ChatCompletionChunk",
157
+ "ChunkChoice",
158
+ "ChoiceDelta",
159
+ "ToolCall",
160
+ "FunctionCall",
161
+ "Usage",
162
+ "ResponseMeta",
163
+ "RateLimit",
164
+ # errors
165
+ "TeepinError",
166
+ "APIError",
167
+ "BadRequestError",
168
+ "UnauthorizedError",
169
+ "PaymentRequiredError",
170
+ "NoCreditError",
171
+ "InsufficientCreditError",
172
+ "OnHoldError",
173
+ "ForbiddenError",
174
+ "PermissionDeniedError",
175
+ "RateLimitError",
176
+ "StreamError",
177
+ "NotFoundError",
178
+ "ConflictError",
179
+ "PreconditionFailedError",
180
+ "TooLargeError",
181
+ "APIConnectionError",
182
+ "ObjectChangedError",
183
+ "ChecksumMismatchError",
184
+ "UploadError",
185
+ "error_code",
186
+ "is_retryable",
187
+ ]
teepin/_core.py ADDED
@@ -0,0 +1,344 @@
1
+ # Copyright 2026 TEEPIN Project
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """What the synchronous and asynchronous clients share: retry rules, error
5
+ decoding, ETags and part sizes. Nothing here does I/O on the network, so the
6
+ two clients cannot drift apart on any of it.
7
+
8
+ The rules match the Go and Node SDKs (teepin-go/teepin, teepin-node/src).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import mimetypes
16
+ import os
17
+ import random
18
+ import threading
19
+ from datetime import datetime
20
+ from enum import Enum
21
+ from typing import IO, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Union
22
+ from urllib.parse import quote
23
+
24
+ from .exceptions import APIConnectionError, APIError, api_error
25
+
26
+ DEFAULT_BASE_URL = "https://api.teepin.com"
27
+ DEFAULT_TIMEOUT = 60.0
28
+ #: Told the bytes moved so far and the total (-1 when not known yet).
29
+ ProgressFunc = Callable[[int, int], None]
30
+
31
+ #: How long a transfer may go without receiving a byte before it is cut off.
32
+ DEFAULT_TRANSFER_READ_TIMEOUT = 300.0
33
+ DEFAULT_RETRIES = 4
34
+ #: Retries of a Teepin Inference request (INFERENCE-SDK-CONTRACT.md).
35
+ DEFAULT_INFERENCE_RETRIES = 2
36
+ #: How long a chat request waits for its answer (a stream: for its headers).
37
+ #: A long answer takes minutes, and the server sends nothing until it is done.
38
+ DEFAULT_CHAT_TIMEOUT = 600.0
39
+
40
+ #: The smallest part but the last.
41
+ MIN_PART_SIZE = 5 << 20
42
+ #: The largest part.
43
+ MAX_PART_SIZE = 5 << 30
44
+ #: The most parts of one upload.
45
+ MAX_PARTS = 10000
46
+ #: The largest object.
47
+ MAX_OBJECT_SIZE = 5 << 40
48
+ #: The largest object one request may carry.
49
+ MAX_PUT_SIZE = 5 << 30
50
+ #: Part size of uploads in parts and of parallel download ranges.
51
+ DEFAULT_PART_SIZE = 16 << 20
52
+ #: Larger uploads go in parts.
53
+ MULTIPART_THRESHOLD = 16 << 20
54
+ #: Parts or ranges moved at once.
55
+ DEFAULT_CONCURRENCY = 8
56
+
57
+ META_PREFIX = "X-Teepin-Meta-"
58
+
59
+
60
+ class RetryMode(Enum):
61
+ """When a failed request may be sent again."""
62
+
63
+ #: The request can be repeated with the same effect (reads, PUTs that
64
+ #: replace, deletes).
65
+ SAFE = "safe"
66
+ #: Only when the server certainly did not act on it: it never arrived, or
67
+ #: the server said it was busy and did nothing.
68
+ UNSENT = "unsent"
69
+ #: Teepin Inference's reads (models, attestation): 429, any 5xx and any
70
+ #: connection error, with backoff from 0.5 s up to 8 s and Retry-After
71
+ #: honoured up to 30 s.
72
+ INFERENCE = "inference"
73
+ #: A Teepin Inference chat request, which the server bills once it has the
74
+ #: model's answer, even if the client has gone: sent again only when it
75
+ #: certainly did not bill. That is a 429; Teepin's own 500 internal_error,
76
+ #: 502 upstream_error, 503 model_unavailable or billing_unavailable (told
77
+ #: by its OpenAI-style error body, unlike a proxy's 502 page); or a
78
+ #: connection that was never opened. Never after a timeout or a connection
79
+ #: lost once the request was sent, and never once a stream has started.
80
+ #: Backoff as INFERENCE.
81
+ INFERENCE_CHAT = "inference_chat"
82
+
83
+
84
+ #: The 5xx answers of Teepin Inference that come before any billing.
85
+ _UNBILLED_5XX = {
86
+ 500: ("internal_error",),
87
+ 502: ("upstream_error",),
88
+ 503: ("model_unavailable", "billing_unavailable"),
89
+ }
90
+
91
+
92
+ def retryable_status(status: int, mode: RetryMode) -> bool:
93
+ """Whether an error status is worth another attempt. For INFERENCE_CHAT
94
+ only a 429 is, by its status alone; see retryable_error for the 5xx."""
95
+ if mode is RetryMode.INFERENCE:
96
+ return status in (429, 500, 502, 503, 504)
97
+ if mode is RetryMode.INFERENCE_CHAT:
98
+ return status == 429
99
+ if status in (429, 503):
100
+ return True # busy, or credit could not be checked: nothing was done
101
+ if status == 502:
102
+ return True # the storage backend failed; the API says nothing was stored
103
+ if status in (500, 504):
104
+ return mode is RetryMode.SAFE
105
+ return False
106
+
107
+
108
+ def retryable_error(err: BaseException, mode: RetryMode) -> bool:
109
+ """Whether a failed attempt is worth another one."""
110
+ if isinstance(err, APIConnectionError):
111
+ return mode in (RetryMode.SAFE, RetryMode.INFERENCE) or bool(getattr(err, "unsent", False))
112
+ if isinstance(err, APIError):
113
+ if mode is RetryMode.INFERENCE_CHAT and err.status_code in _UNBILLED_5XX:
114
+ # Only Teepin's own answer says nothing was billed; a proxy's 502
115
+ # page may hide a task that died after billing.
116
+ return err._inference_envelope and err.code in _UNBILLED_5XX[err.status_code]
117
+ return retryable_status(err.status_code, mode)
118
+ return False
119
+
120
+
121
+ def backoff(attempt: int, err: Optional[BaseException] = None, mode: RetryMode = RetryMode.SAFE) -> float:
122
+ """Seconds to wait before attempt+1: the server's Retry-After when it sent
123
+ one (at most a minute), else exponential with full jitter. Inference waits
124
+ 0.5 s doubling up to 8 s, and at most 30 s for a Retry-After."""
125
+ if mode in (RetryMode.INFERENCE, RetryMode.INFERENCE_CHAT):
126
+ if isinstance(err, APIError) and err.retry_after:
127
+ return min(err.retry_after, 30.0)
128
+ return random.uniform(0, min(0.5 * (2**attempt), 8.0))
129
+ if isinstance(err, APIError) and err.retry_after:
130
+ return min(err.retry_after, 60.0)
131
+ ceiling = min(0.2 * (2**attempt), 10.0)
132
+ return random.uniform(0, ceiling) + 0.05
133
+
134
+
135
+ def decode_error(status: int, headers: Mapping[str, str], body: bytes) -> APIError:
136
+ """The APIError for an error answer: ``{error, code}``, or Teepin Inference's
137
+ OpenAI-style ``{"error": {message, type, code}}``, with the request id,
138
+ Retry-After and, for storage_on_hold, delete_after."""
139
+ retry_after: Optional[float] = None
140
+ raw = headers.get("Retry-After") or headers.get("retry-after")
141
+ if raw:
142
+ try:
143
+ secs = int(raw.strip())
144
+ if secs > 0:
145
+ retry_after = float(secs)
146
+ except ValueError:
147
+ pass
148
+ request_id = headers.get("X-Request-ID") or headers.get("x-request-id")
149
+ message, code, error_type, delete_after = "", "", "", None
150
+ try:
151
+ data = json.loads(body[: 64 << 10] or b"null")
152
+ except ValueError:
153
+ data = None
154
+ if isinstance(data, dict) and isinstance(data.get("error"), dict):
155
+ envelope = data["error"]
156
+ message = str(envelope.get("message") or "")
157
+ code = str(envelope.get("code") or "")
158
+ error_type = str(envelope.get("type") or "")
159
+ elif isinstance(data, dict) and (data.get("error") or data.get("code")):
160
+ message = str(data.get("error") or "")
161
+ code = str(data.get("code") or "")
162
+ if data.get("delete_after"):
163
+ try:
164
+ delete_after = datetime.fromisoformat(str(data["delete_after"]).replace("Z", "+00:00"))
165
+ except ValueError:
166
+ pass
167
+ else:
168
+ message = body[: 64 << 10].decode("utf-8", "replace").strip()
169
+ if not message:
170
+ message = f"HTTP {status}"
171
+ err = api_error(status, message, code, request_id, retry_after, delete_after, error_type)
172
+ err._inference_envelope = isinstance(data, dict) and isinstance(data.get("error"), dict)
173
+ return err
174
+
175
+
176
+ def bucket_path(bucket: str) -> str:
177
+ return "/v1/storage/buckets/" + quote(bucket, safe="")
178
+
179
+
180
+ def upload_path(bucket: str, upload_id: str) -> str:
181
+ return bucket_path(bucket) + "/uploads/" + quote(upload_id, safe="")
182
+
183
+
184
+ def quote_etag(etag: str) -> str:
185
+ if etag == "*" or etag.startswith('"') or etag.startswith('W/"'):
186
+ return etag
187
+ return f'"{etag}"'
188
+
189
+
190
+ def unquote_etag(etag: Optional[str]) -> str:
191
+ if not etag:
192
+ return ""
193
+ etag = etag.strip()
194
+ if etag.startswith("W/"):
195
+ etag = etag[2:]
196
+ return etag.strip('"')
197
+
198
+
199
+ def put_headers(
200
+ content_type: Optional[str] = None,
201
+ metadata: Optional[Mapping[str, str]] = None,
202
+ if_match: Optional[str] = None,
203
+ if_none_match: bool = False,
204
+ ) -> Dict[str, str]:
205
+ """Headers of a one-request upload."""
206
+ h: Dict[str, str] = {}
207
+ if content_type:
208
+ h["Content-Type"] = content_type
209
+ for name, value in (metadata or {}).items():
210
+ h[META_PREFIX + name] = value
211
+ if if_match:
212
+ h["If-Match"] = quote_etag(if_match)
213
+ if if_none_match:
214
+ h["If-None-Match"] = "*"
215
+ return h
216
+
217
+
218
+ def range_header(start: int, end: Optional[int]) -> str:
219
+ """``bytes=start-end`` (inclusive); end None is to the end."""
220
+ return f"bytes={start}-" if end is None else f"bytes={start}-{end}"
221
+
222
+
223
+ def total_size(headers: Mapping[str, str], fallback: int) -> int:
224
+ """The whole object's size from Content-Range (``bytes 0-99/1234``)."""
225
+ cr = headers.get("Content-Range") or headers.get("content-range") or ""
226
+ if "/" in cr:
227
+ try:
228
+ return int(cr.rsplit("/", 1)[1])
229
+ except ValueError:
230
+ pass
231
+ return fallback
232
+
233
+
234
+ def guess_content_type(path: Union[str, os.PathLike]) -> Optional[str]:
235
+ return mimetypes.guess_type(str(path))[0]
236
+
237
+
238
+ def part_size_for(size: int, want: int = 0) -> int:
239
+ """The part size an upload of ``size`` bytes uses when ``want`` (0 for the
240
+ default) is asked for: at least 5 MiB, doubled until the parts fit in
241
+ 10,000."""
242
+ p = want if want > 0 else DEFAULT_PART_SIZE
243
+ p = max(p, MIN_PART_SIZE)
244
+ while p < MAX_PART_SIZE and (size + p - 1) // p > MAX_PARTS:
245
+ p *= 2
246
+ return min(p, MAX_PART_SIZE)
247
+
248
+
249
+ def part_spans(size: int, part_size: int) -> List[Tuple[int, int, int]]:
250
+ """``(number, offset, length)`` of each part, numbered from 1."""
251
+ out = []
252
+ num, off = 1, 0
253
+ while off < size:
254
+ n = min(part_size, size - off)
255
+ out.append((num, off, n))
256
+ num += 1
257
+ off += n
258
+ return out
259
+
260
+
261
+ def composite_etag(part_sha256s: Iterable[bytes]) -> str:
262
+ """``<sha256 of the parts' sha256s>-<parts>``: the ETag of an upload in parts."""
263
+ h = hashlib.sha256()
264
+ n = 0
265
+ for digest in part_sha256s:
266
+ h.update(digest)
267
+ n += 1
268
+ return f"{h.hexdigest()}-{n}"
269
+
270
+
271
+ def compute_etag(
272
+ source: Union[str, os.PathLike, bytes, bytearray, memoryview, IO[bytes]],
273
+ part_size: int = 0,
274
+ parts: int = 0,
275
+ ) -> str:
276
+ """The ETag Teepin gives these bytes: the SHA-256 when ``parts`` is 0, or for
277
+ an upload in parts of ``part_size`` bytes the SHA-256 of the parts'
278
+ SHA-256s with ``-<parts>``. Compare it with ``Object.etag`` to tell whether
279
+ a local file and an object hold the same bytes.
280
+
281
+ ``source`` is a path, bytes, or a binary file object read from its start.
282
+ """
283
+ if isinstance(source, (bytes, bytearray, memoryview)):
284
+ data = bytes(source)
285
+ if parts == 0:
286
+ return hashlib.sha256(data).hexdigest()
287
+ _check_parts(len(data), part_size, parts)
288
+ return composite_etag(hashlib.sha256(data[o : o + n]).digest() for _, o, n in part_spans(len(data), part_size))
289
+ if isinstance(source, (str, os.PathLike)):
290
+ with open(source, "rb") as f:
291
+ return compute_etag(f, part_size, parts)
292
+ f = source
293
+ f.seek(0, os.SEEK_END)
294
+ size = f.tell()
295
+ if parts == 0:
296
+ f.seek(0)
297
+ h = hashlib.sha256()
298
+ for chunk in iter(lambda: f.read(1 << 20), b""):
299
+ h.update(chunk)
300
+ return h.hexdigest()
301
+ _check_parts(size, part_size, parts)
302
+ digests = []
303
+ for _, off, n in part_spans(size, part_size):
304
+ f.seek(off)
305
+ h = hashlib.sha256()
306
+ left = n
307
+ while left:
308
+ chunk = f.read(min(left, 1 << 20))
309
+ if not chunk:
310
+ raise ValueError("the file ended early")
311
+ h.update(chunk)
312
+ left -= len(chunk)
313
+ digests.append(h.digest())
314
+ return composite_etag(digests)
315
+
316
+
317
+ def _check_parts(size: int, part_size: int, parts: int) -> None:
318
+ if part_size <= 0 or (size + part_size - 1) // part_size != parts:
319
+ raise ValueError(f"{size} bytes in parts of {part_size} is not {parts} parts")
320
+
321
+
322
+ def absolute(base_url: str, path: str) -> str:
323
+ """A signed link the API returned as a path, made absolute."""
324
+ if path.startswith("http://") or path.startswith("https://"):
325
+ return path
326
+ return base_url + path
327
+
328
+
329
+ class Progress:
330
+ """Adds up bytes moved and tells the caller's callback (done, total).
331
+ Thread safe; an attempt that failed can be taken back."""
332
+
333
+ def __init__(self, total: int, fn: Optional[ProgressFunc]) -> None:
334
+ self.total = total
335
+ self.done = 0
336
+ self.fn = fn
337
+ self._lock = threading.Lock()
338
+
339
+ def add(self, n: int) -> None:
340
+ if not self.fn or n == 0:
341
+ return
342
+ with self._lock:
343
+ self.done += n
344
+ self.fn(self.done, self.total)