urouter-sdk 0.1.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.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.5
2
+ Name: urouter-sdk
3
+ Version: 0.1.0
4
+ Summary: OpenAI-compatible Python client for uRouter (free + paid)
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx[http2]>=0.27.0
8
+ Requires-Dist: orjson>=3.10.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == 'dev'
11
+ Requires-Dist: pytest-asyncio; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # urouter
15
+
16
+ OpenAI-compatible Python client for [uRouter](https://urouter.net) / [chat.urouter.net](https://chat.urouter.net).
17
+
18
+ Free presets (no API key): **normal** (censored) and **uncensored**.
19
+ Paid path uses `https://api.urouter.net/v1` with a Bearer key.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install urouter
25
+ ```
26
+
27
+ ## Free chat
28
+
29
+ ```python
30
+ from urouter import URouter
31
+
32
+ client = URouter()
33
+
34
+ # censored free
35
+ r = client.chat.completions.create(
36
+ model="normal",
37
+ messages=[{"role": "user", "content": "Hello"}],
38
+ )
39
+ print(r.choices[0].message.content)
40
+
41
+ # uncensored free
42
+ r = client.chat.completions.create(
43
+ model="uncensored",
44
+ messages=[{"role": "user", "content": "Hello"}],
45
+ )
46
+ print(r.content)
47
+ ```
48
+
49
+ Aliases: `normal` / `censored` / `free` → `deepseek-v4.1-flash`
50
+ `uncensored` → `gemma-4-uncensored`
51
+
52
+ ## Streaming
53
+
54
+ ```python
55
+ stream = client.chat.completions.create(
56
+ model="normal",
57
+ messages=[{"role": "user", "content": "Hi"}],
58
+ stream=True,
59
+ )
60
+
61
+ for chunk in stream:
62
+ delta = chunk.choices[0].delta.content
63
+ if delta:
64
+ print(delta, end="", flush=True)
65
+ ```
66
+
67
+ ## Paid (OpenAI-compatible)
68
+
69
+ ```python
70
+ from urouter import OpenAI
71
+
72
+ client = OpenAI(api_key="ur-...")
73
+
74
+ r = client.chat.completions.create(
75
+ model="deepseek-v4-flash",
76
+ messages=[{"role": "user", "content": "Hi"}],
77
+ )
78
+ ```
79
+
80
+ ## Models
81
+
82
+ ```python
83
+ for m in client.models.list().data:
84
+ print(m.id)
85
+ ```
86
+
87
+ ## Drop-in shape
88
+
89
+ ```python
90
+ client.chat.completions.create(...)
91
+ client.models.list()
92
+ client.models.retrieve(id)
93
+ ```
@@ -0,0 +1,80 @@
1
+ # urouter
2
+
3
+ OpenAI-compatible Python client for [uRouter](https://urouter.net) / [chat.urouter.net](https://chat.urouter.net).
4
+
5
+ Free presets (no API key): **normal** (censored) and **uncensored**.
6
+ Paid path uses `https://api.urouter.net/v1` with a Bearer key.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pip install urouter
12
+ ```
13
+
14
+ ## Free chat
15
+
16
+ ```python
17
+ from urouter import URouter
18
+
19
+ client = URouter()
20
+
21
+ # censored free
22
+ r = client.chat.completions.create(
23
+ model="normal",
24
+ messages=[{"role": "user", "content": "Hello"}],
25
+ )
26
+ print(r.choices[0].message.content)
27
+
28
+ # uncensored free
29
+ r = client.chat.completions.create(
30
+ model="uncensored",
31
+ messages=[{"role": "user", "content": "Hello"}],
32
+ )
33
+ print(r.content)
34
+ ```
35
+
36
+ Aliases: `normal` / `censored` / `free` → `deepseek-v4.1-flash`
37
+ `uncensored` → `gemma-4-uncensored`
38
+
39
+ ## Streaming
40
+
41
+ ```python
42
+ stream = client.chat.completions.create(
43
+ model="normal",
44
+ messages=[{"role": "user", "content": "Hi"}],
45
+ stream=True,
46
+ )
47
+
48
+ for chunk in stream:
49
+ delta = chunk.choices[0].delta.content
50
+ if delta:
51
+ print(delta, end="", flush=True)
52
+ ```
53
+
54
+ ## Paid (OpenAI-compatible)
55
+
56
+ ```python
57
+ from urouter import OpenAI
58
+
59
+ client = OpenAI(api_key="ur-...")
60
+
61
+ r = client.chat.completions.create(
62
+ model="deepseek-v4-flash",
63
+ messages=[{"role": "user", "content": "Hi"}],
64
+ )
65
+ ```
66
+
67
+ ## Models
68
+
69
+ ```python
70
+ for m in client.models.list().data:
71
+ print(m.id)
72
+ ```
73
+
74
+ ## Drop-in shape
75
+
76
+ ```python
77
+ client.chat.completions.create(...)
78
+ client.models.list()
79
+ client.models.retrieve(id)
80
+ ```
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "urouter-sdk"
3
+ version = "0.1.0"
4
+ description = "OpenAI-compatible Python client for uRouter (free + paid)"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "httpx[http2]>=0.27.0",
8
+ "orjson>=3.10.0",
9
+ ]
10
+ readme = "README.md"
11
+ license = { text = "MIT" }
12
+
13
+ [project.optional-dependencies]
14
+ dev = ["pytest", "pytest-asyncio"]
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["urouter"]
22
+
23
+ [tool.hatch.build.targets.sdist]
24
+ include = [
25
+ "urouter/**",
26
+ "README.md",
27
+ "pyproject.toml",
28
+ ]
29
+ exclude = [
30
+ ".github/**",
31
+ "examples/**",
32
+ "**/__pycache__/**",
33
+ "**/*.pyc",
34
+ ]
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from urouter._client import Client, OpenAI, URouter
4
+ from urouter._exceptions import (
5
+ APIConnectionError,
6
+ APIError,
7
+ APITimeoutError,
8
+ AuthenticationError,
9
+ BadRequestError,
10
+ NotFoundError,
11
+ RateLimitError,
12
+ URouterError,
13
+ )
14
+ from urouter._types import (
15
+ ChatCompletion,
16
+ ChatCompletionChunk,
17
+ ChatCompletionMessage,
18
+ Choice,
19
+ ChoiceDelta,
20
+ ChunkChoice,
21
+ CompletionUsage,
22
+ Model,
23
+ ModelList,
24
+ Stream,
25
+ )
26
+
27
+ __all__ = (
28
+ "URouter",
29
+ "OpenAI",
30
+ "Client",
31
+ "URouterError",
32
+ "APIError",
33
+ "AuthenticationError",
34
+ "RateLimitError",
35
+ "BadRequestError",
36
+ "NotFoundError",
37
+ "APIConnectionError",
38
+ "APITimeoutError",
39
+ "ChatCompletion",
40
+ "ChatCompletionChunk",
41
+ "ChatCompletionMessage",
42
+ "Choice",
43
+ "ChoiceDelta",
44
+ "ChunkChoice",
45
+ "CompletionUsage",
46
+ "Model",
47
+ "ModelList",
48
+ "Stream",
49
+ )
50
+
51
+ __version__ = "0.1.0"
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from urouter._constants import API_BASE, CHAT_BASE, DEFAULT_HEADERS, DEFAULT_TIMEOUT
6
+ from urouter._http import HTTP
7
+ from urouter.resources.chat import Chat
8
+ from urouter.resources.models import Models
9
+
10
+
11
+ class URouter:
12
+ __slots__ = (
13
+ "api_key",
14
+ "base_url",
15
+ "timeout",
16
+ "_http",
17
+ "chat",
18
+ "models",
19
+ )
20
+
21
+ def __init__(
22
+ self,
23
+ api_key: str | None = None,
24
+ *,
25
+ base_url: str | None = None,
26
+ timeout: float = DEFAULT_TIMEOUT,
27
+ proxy: str | None = None,
28
+ http2: bool = True,
29
+ default_headers: dict[str, str] | None = None,
30
+ ) -> None:
31
+ self.api_key = api_key
32
+
33
+ self.base_url = base_url or (API_BASE if api_key else CHAT_BASE)
34
+
35
+ self.timeout = timeout
36
+
37
+ headers = dict(DEFAULT_HEADERS)
38
+
39
+ if default_headers:
40
+ headers.update(default_headers)
41
+
42
+ self._http = HTTP(
43
+ timeout=timeout,
44
+ headers=headers,
45
+ proxy=proxy,
46
+ http2=http2,
47
+ )
48
+
49
+ self.chat = Chat(self)
50
+
51
+ self.models = Models(self)
52
+
53
+ def _auth_headers(self) -> dict[str, str]:
54
+ headers: dict[str, str] = {}
55
+
56
+ if self.api_key:
57
+ headers["Authorization"] = f"Bearer {self.api_key}"
58
+
59
+ return headers
60
+
61
+ def _chat_headers(self) -> dict[str, str]:
62
+ return {
63
+ "Origin": CHAT_BASE,
64
+ "Referer": f"{CHAT_BASE}/",
65
+ "Accept": "application/json",
66
+ "Content-Type": "application/json",
67
+ }
68
+
69
+ def close(self) -> None:
70
+ self._http.close()
71
+
72
+ def __enter__(self) -> URouter:
73
+ return self
74
+
75
+ def __exit__(self, *args: object) -> None:
76
+ self.close()
77
+
78
+
79
+ class OpenAI(URouter):
80
+ pass
81
+
82
+
83
+ Client = URouter
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ CHAT_BASE: str = "https://chat.urouter.net"
4
+
5
+ API_BASE: str = "https://api.urouter.net/v1"
6
+
7
+ DEFAULT_TIMEOUT: float = 120.0
8
+
9
+ FREE_PRESETS: dict[str, tuple[str, str]] = {
10
+ "normal": ("deepseek-v4.1-flash", "normal"),
11
+ "censored": ("deepseek-v4.1-flash", "normal"),
12
+ "free": ("deepseek-v4.1-flash", "normal"),
13
+ "uncensored": ("gemma-4-uncensored", "uncensored"),
14
+ "deepseek-v4.1-flash": ("deepseek-v4.1-flash", "normal"),
15
+ "gemma-4-uncensored": ("gemma-4-uncensored", "uncensored"),
16
+ }
17
+
18
+ DEFAULT_HEADERS: dict[str, str] = {
19
+ "Accept": "application/json",
20
+ "Content-Type": "application/json",
21
+ }
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class URouterError(Exception):
7
+ def __init__(self, message: str, *, status_code: int | None = None, body: Any = None) -> None:
8
+ super().__init__(message)
9
+
10
+ self.message = message
11
+
12
+ self.status_code = status_code
13
+
14
+ self.body = body
15
+
16
+
17
+ class APIError(URouterError):
18
+ pass
19
+
20
+
21
+ class AuthenticationError(APIError):
22
+ pass
23
+
24
+
25
+ class RateLimitError(APIError):
26
+ pass
27
+
28
+
29
+ class BadRequestError(APIError):
30
+ pass
31
+
32
+
33
+ class NotFoundError(APIError):
34
+ pass
35
+
36
+
37
+ class APIConnectionError(URouterError):
38
+ pass
39
+
40
+
41
+ class APITimeoutError(URouterError):
42
+ pass
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterator
4
+
5
+ import httpx
6
+ import orjson
7
+
8
+ from urouter._exceptions import (
9
+ APIConnectionError,
10
+ APIError,
11
+ APITimeoutError,
12
+ AuthenticationError,
13
+ BadRequestError,
14
+ NotFoundError,
15
+ RateLimitError,
16
+ )
17
+
18
+
19
+ class HTTP:
20
+ __slots__ = ("_client",)
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ timeout: float,
26
+ headers: dict[str, str],
27
+ proxy: str | None = None,
28
+ http2: bool = True,
29
+ ) -> None:
30
+ kwargs: dict[str, Any] = {
31
+ "timeout": httpx.Timeout(timeout, connect=10.0),
32
+ "headers": headers,
33
+ "http2": http2,
34
+ "follow_redirects": True,
35
+ }
36
+
37
+ if proxy:
38
+ kwargs["proxy"] = proxy
39
+
40
+ self._client = httpx.Client(**kwargs)
41
+
42
+ def close(self) -> None:
43
+ self._client.close()
44
+
45
+ def request(
46
+ self,
47
+ method: str,
48
+ url: str,
49
+ *,
50
+ json_body: Any = None,
51
+ params: dict[str, Any] | None = None,
52
+ headers: dict[str, str] | None = None,
53
+ ) -> httpx.Response:
54
+ content = None
55
+
56
+ req_headers = dict(headers or {})
57
+
58
+ if json_body is not None:
59
+ content = orjson.dumps(json_body)
60
+
61
+ req_headers.setdefault("Content-Type", "application/json")
62
+
63
+ try:
64
+ resp = self._client.request(
65
+ method,
66
+ url,
67
+ content=content,
68
+ params=params,
69
+ headers=req_headers,
70
+ )
71
+ except httpx.TimeoutException as e:
72
+ raise APITimeoutError(str(e)) from e
73
+ except httpx.HTTPError as e:
74
+ raise APIConnectionError(str(e)) from e
75
+
76
+ return resp
77
+
78
+ def stream(
79
+ self,
80
+ method: str,
81
+ url: str,
82
+ *,
83
+ json_body: Any = None,
84
+ headers: dict[str, str] | None = None,
85
+ ) -> Iterator[bytes]:
86
+ content = None
87
+
88
+ req_headers = dict(headers or {})
89
+
90
+ if json_body is not None:
91
+ content = orjson.dumps(json_body)
92
+
93
+ req_headers.setdefault("Content-Type", "application/json")
94
+
95
+ try:
96
+ with self._client.stream(
97
+ method,
98
+ url,
99
+ content=content,
100
+ headers=req_headers,
101
+ ) as resp:
102
+ if resp.status_code >= 400:
103
+ raw = resp.read()
104
+
105
+ self.raise_for_status(resp.status_code, raw)
106
+
107
+ yield from resp.iter_bytes()
108
+ except httpx.TimeoutException as e:
109
+ raise APITimeoutError(str(e)) from e
110
+ except httpx.HTTPError as e:
111
+ raise APIConnectionError(str(e)) from e
112
+
113
+ @staticmethod
114
+ def parse_json(resp: httpx.Response) -> Any:
115
+ if not resp.content:
116
+ return None
117
+
118
+ return orjson.loads(resp.content)
119
+
120
+ @staticmethod
121
+ def raise_for_status(status: int, body: bytes | Any) -> None:
122
+ data: Any
123
+
124
+ if isinstance(body, (bytes, bytearray)):
125
+ try:
126
+ data = orjson.loads(body) if body else None
127
+ except Exception:
128
+ data = body.decode("utf-8", errors="replace") if body else None
129
+ else:
130
+ data = body
131
+
132
+ message = None
133
+
134
+ if isinstance(data, dict):
135
+ err = data.get("error")
136
+
137
+ if isinstance(err, dict):
138
+ message = err.get("message") or err.get("code")
139
+ elif isinstance(err, str):
140
+ message = err
141
+ else:
142
+ message = data.get("message")
143
+
144
+ if not message:
145
+ message = f"HTTP {status}"
146
+
147
+ if status == 401:
148
+ raise AuthenticationError(str(message), status_code=status, body=data)
149
+
150
+ if status == 404:
151
+ raise NotFoundError(str(message), status_code=status, body=data)
152
+
153
+ if status == 429:
154
+ raise RateLimitError(str(message), status_code=status, body=data)
155
+
156
+ if 400 <= status < 500:
157
+ raise BadRequestError(str(message), status_code=status, body=data)
158
+
159
+ raise APIError(str(message), status_code=status, body=data)
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Iterator, Literal
5
+
6
+
7
+ Role = Literal["system", "user", "assistant", "tool", "function", "developer"]
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class ChatCompletionMessage:
12
+ role: str
13
+
14
+ content: str | None = None
15
+
16
+ reasoning: str | None = None
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class Choice:
21
+ index: int
22
+
23
+ message: ChatCompletionMessage
24
+
25
+ finish_reason: str | None = "stop"
26
+
27
+ logprobs: Any = None
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class CompletionUsage:
32
+ prompt_tokens: int = 0
33
+
34
+ completion_tokens: int = 0
35
+
36
+ total_tokens: int = 0
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class ChatCompletion:
41
+ id: str
42
+
43
+ choices: list[Choice]
44
+
45
+ created: int
46
+
47
+ model: str
48
+
49
+ object: str = "chat.completion"
50
+
51
+ usage: CompletionUsage | None = None
52
+
53
+ system_fingerprint: str | None = None
54
+
55
+ @property
56
+ def content(self) -> str:
57
+ if not self.choices:
58
+ return ""
59
+
60
+ return self.choices[0].message.content or ""
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class ChoiceDelta:
65
+ role: str | None = None
66
+
67
+ content: str | None = None
68
+
69
+ reasoning: str | None = None
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class ChunkChoice:
74
+ index: int
75
+
76
+ delta: ChoiceDelta
77
+
78
+ finish_reason: str | None = None
79
+
80
+
81
+ @dataclass(slots=True)
82
+ class ChatCompletionChunk:
83
+ id: str
84
+
85
+ choices: list[ChunkChoice]
86
+
87
+ created: int
88
+
89
+ model: str
90
+
91
+ object: str = "chat.completion.chunk"
92
+
93
+
94
+ @dataclass(slots=True)
95
+ class Model:
96
+ id: str
97
+
98
+ object: str = "model"
99
+
100
+ created: int = 0
101
+
102
+ owned_by: str = "urouter"
103
+
104
+ name: str | None = None
105
+
106
+ brand: str | None = None
107
+
108
+ raw: dict[str, Any] = field(default_factory=dict)
109
+
110
+
111
+ @dataclass(slots=True)
112
+ class ModelList:
113
+ data: list[Model]
114
+
115
+ object: str = "list"
116
+
117
+
118
+ class Stream[T]:
119
+ __slots__ = ("_iterator",)
120
+
121
+ def __init__(self, iterator: Iterator[T]) -> None:
122
+ self._iterator = iterator
123
+
124
+ def __iter__(self) -> Iterator[T]:
125
+ return self._iterator
126
+
127
+ def __next__(self) -> T:
128
+ return next(self._iterator)
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from urouter.resources.chat import Chat, Completions
4
+ from urouter.resources.models import Models
5
+
6
+ __all__ = ("Chat", "Completions", "Models")
@@ -0,0 +1,494 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from typing import TYPE_CHECKING, Any, Iterator, Literal, overload
6
+
7
+ import orjson
8
+
9
+ from urouter._constants import CHAT_BASE, FREE_PRESETS
10
+ from urouter._types import (
11
+ ChatCompletion,
12
+ ChatCompletionChunk,
13
+ ChatCompletionMessage,
14
+ Choice,
15
+ ChoiceDelta,
16
+ ChunkChoice,
17
+ CompletionUsage,
18
+ Stream,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from urouter._client import URouter
23
+
24
+
25
+ class Completions:
26
+ __slots__ = ("_client",)
27
+
28
+ def __init__(self, client: URouter) -> None:
29
+ self._client = client
30
+
31
+ @overload
32
+ def create(
33
+ self,
34
+ *,
35
+ messages: list[dict[str, Any]],
36
+ model: str = ...,
37
+ stream: Literal[False] = False,
38
+ max_tokens: int | None = ...,
39
+ temperature: float | None = ...,
40
+ top_p: float | None = ...,
41
+ stop: str | list[str] | None = ...,
42
+ presence_penalty: float | None = ...,
43
+ frequency_penalty: float | None = ...,
44
+ user: str | None = ...,
45
+ extra_body: dict[str, Any] | None = ...,
46
+ ) -> ChatCompletion: ...
47
+
48
+ @overload
49
+ def create(
50
+ self,
51
+ *,
52
+ messages: list[dict[str, Any]],
53
+ model: str = ...,
54
+ stream: Literal[True],
55
+ max_tokens: int | None = ...,
56
+ temperature: float | None = ...,
57
+ top_p: float | None = ...,
58
+ stop: str | list[str] | None = ...,
59
+ presence_penalty: float | None = ...,
60
+ frequency_penalty: float | None = ...,
61
+ user: str | None = ...,
62
+ extra_body: dict[str, Any] | None = ...,
63
+ ) -> Stream[ChatCompletionChunk]: ...
64
+
65
+ def create(
66
+ self,
67
+ *,
68
+ messages: list[dict[str, Any]],
69
+ model: str = "normal",
70
+ stream: bool = False,
71
+ max_tokens: int | None = None,
72
+ temperature: float | None = None,
73
+ top_p: float | None = None,
74
+ stop: str | list[str] | None = None,
75
+ presence_penalty: float | None = None,
76
+ frequency_penalty: float | None = None,
77
+ user: str | None = None,
78
+ extra_body: dict[str, Any] | None = None,
79
+ ) -> ChatCompletion | Stream[ChatCompletionChunk]:
80
+ if self._client.api_key:
81
+ return self._paid_create(
82
+ messages=messages,
83
+ model=model,
84
+ stream=stream,
85
+ max_tokens=max_tokens,
86
+ temperature=temperature,
87
+ top_p=top_p,
88
+ stop=stop,
89
+ presence_penalty=presence_penalty,
90
+ frequency_penalty=frequency_penalty,
91
+ user=user,
92
+ extra_body=extra_body,
93
+ )
94
+
95
+ return self._free_create(
96
+ messages=messages,
97
+ model=model,
98
+ stream=stream,
99
+ max_tokens=max_tokens,
100
+ extra_body=extra_body,
101
+ )
102
+
103
+ def _resolve_free(self, model: str) -> tuple[str, str]:
104
+ key = model.strip().lower()
105
+
106
+ if key in FREE_PRESETS:
107
+ return FREE_PRESETS[key]
108
+
109
+ return model, "normal"
110
+
111
+ def _free_create(
112
+ self,
113
+ *,
114
+ messages: list[dict[str, Any]],
115
+ model: str,
116
+ stream: bool,
117
+ max_tokens: int | None,
118
+ extra_body: dict[str, Any] | None,
119
+ ) -> ChatCompletion | Stream[ChatCompletionChunk]:
120
+ model_id, preset = self._resolve_free(model)
121
+
122
+ content = ""
123
+
124
+ history: list[dict[str, Any]] = []
125
+
126
+ for msg in messages:
127
+ role = str(msg.get("role") or "user")
128
+
129
+ text = msg.get("content")
130
+
131
+ if text is None:
132
+ continue
133
+
134
+ if not isinstance(text, str):
135
+ text = str(text)
136
+
137
+ if role == "system":
138
+ continue
139
+
140
+ if role == "user":
141
+ content = text
142
+
143
+ history.append(
144
+ {
145
+ "id": str(uuid.uuid4()),
146
+ "role": role,
147
+ "content": text,
148
+ }
149
+ )
150
+
151
+ if history and history[-1]["role"] == "user":
152
+ history = history[:-1]
153
+
154
+ if not content and messages:
155
+ last = messages[-1].get("content")
156
+
157
+ content = last if isinstance(last, str) else str(last or "")
158
+
159
+ turn_id = str(uuid.uuid4())
160
+
161
+ body: dict[str, Any] = {
162
+ "turnId": turn_id,
163
+ "model": model_id,
164
+ "mode": "free",
165
+ "preset": preset,
166
+ "message": {
167
+ "id": str(uuid.uuid4()),
168
+ "role": "user",
169
+ "content": content,
170
+ },
171
+ "history": history,
172
+ }
173
+
174
+ if max_tokens is not None:
175
+ body["maxTokens"] = max_tokens
176
+
177
+ if extra_body:
178
+ body.update(extra_body)
179
+
180
+ url = f"{CHAT_BASE}/api/chat/send"
181
+
182
+ headers = self._client._chat_headers()
183
+
184
+ headers["Accept"] = "text/event-stream"
185
+
186
+ if stream:
187
+ return Stream(self._iter_free_stream(url, body, headers, model_id, turn_id))
188
+
189
+ text_parts: list[str] = []
190
+
191
+ reasoning_parts: list[str] = []
192
+
193
+ request_id = turn_id
194
+
195
+ for event in self._iter_sse(url, body, headers):
196
+ etype = event.get("type")
197
+
198
+ if etype == "start":
199
+ request_id = str(event.get("requestId") or request_id)
200
+
201
+ elif etype == "delta":
202
+ chunk = event.get("text")
203
+
204
+ if chunk:
205
+ text_parts.append(str(chunk))
206
+
207
+ reason = event.get("reasoning")
208
+
209
+ if reason:
210
+ reasoning_parts.append(str(reason))
211
+
212
+ elif etype == "done":
213
+ break
214
+
215
+ created = int(time.time())
216
+
217
+ message = ChatCompletionMessage(
218
+ role="assistant",
219
+ content="".join(text_parts),
220
+ reasoning="".join(reasoning_parts) or None,
221
+ )
222
+
223
+ return ChatCompletion(
224
+ id=str(request_id),
225
+ choices=[Choice(index=0, message=message, finish_reason="stop")],
226
+ created=created,
227
+ model=model_id,
228
+ )
229
+
230
+ def _iter_free_stream(
231
+ self,
232
+ url: str,
233
+ body: dict[str, Any],
234
+ headers: dict[str, str],
235
+ model_id: str,
236
+ turn_id: str,
237
+ ) -> Iterator[ChatCompletionChunk]:
238
+ created = int(time.time())
239
+
240
+ request_id = turn_id
241
+
242
+ for event in self._iter_sse(url, body, headers):
243
+ etype = event.get("type")
244
+
245
+ if etype == "start":
246
+ request_id = str(event.get("requestId") or request_id)
247
+
248
+ yield ChatCompletionChunk(
249
+ id=str(request_id),
250
+ choices=[
251
+ ChunkChoice(
252
+ index=0,
253
+ delta=ChoiceDelta(role="assistant", content=""),
254
+ )
255
+ ],
256
+ created=created,
257
+ model=model_id,
258
+ )
259
+
260
+ elif etype == "delta":
261
+ text = event.get("text")
262
+
263
+ reason = event.get("reasoning")
264
+
265
+ if not text and not reason:
266
+ continue
267
+
268
+ yield ChatCompletionChunk(
269
+ id=str(request_id),
270
+ choices=[
271
+ ChunkChoice(
272
+ index=0,
273
+ delta=ChoiceDelta(
274
+ content=str(text) if text else None,
275
+ reasoning=str(reason) if reason else None,
276
+ ),
277
+ )
278
+ ],
279
+ created=created,
280
+ model=model_id,
281
+ )
282
+
283
+ elif etype == "done":
284
+ yield ChatCompletionChunk(
285
+ id=str(request_id),
286
+ choices=[
287
+ ChunkChoice(
288
+ index=0,
289
+ delta=ChoiceDelta(),
290
+ finish_reason="stop",
291
+ )
292
+ ],
293
+ created=created,
294
+ model=model_id,
295
+ )
296
+
297
+ break
298
+
299
+ def _iter_sse(
300
+ self,
301
+ url: str,
302
+ body: dict[str, Any],
303
+ headers: dict[str, str],
304
+ ) -> Iterator[dict[str, Any]]:
305
+ buf = b""
306
+
307
+ for chunk in self._client._http.stream("POST", url, json_body=body, headers=headers):
308
+ buf += chunk
309
+
310
+ while b"\n" in buf:
311
+ line, buf = buf.split(b"\n", 1)
312
+
313
+ line = line.strip()
314
+
315
+ if not line or line.startswith(b":"):
316
+ continue
317
+
318
+ if line.startswith(b"data:"):
319
+ payload = line[5:].strip()
320
+
321
+ if payload == b"[DONE]":
322
+ return
323
+
324
+ try:
325
+ yield orjson.loads(payload)
326
+ except Exception:
327
+ continue
328
+
329
+ def _paid_create(
330
+ self,
331
+ *,
332
+ messages: list[dict[str, Any]],
333
+ model: str,
334
+ stream: bool,
335
+ max_tokens: int | None,
336
+ temperature: float | None,
337
+ top_p: float | None,
338
+ stop: str | list[str] | None,
339
+ presence_penalty: float | None,
340
+ frequency_penalty: float | None,
341
+ user: str | None,
342
+ extra_body: dict[str, Any] | None,
343
+ ) -> ChatCompletion | Stream[ChatCompletionChunk]:
344
+ model_id, _ = self._resolve_free(model)
345
+
346
+ body: dict[str, Any] = {
347
+ "model": model_id,
348
+ "messages": messages,
349
+ "stream": stream,
350
+ }
351
+
352
+ if max_tokens is not None:
353
+ body["max_tokens"] = max_tokens
354
+
355
+ if temperature is not None:
356
+ body["temperature"] = temperature
357
+
358
+ if top_p is not None:
359
+ body["top_p"] = top_p
360
+
361
+ if stop is not None:
362
+ body["stop"] = stop
363
+
364
+ if presence_penalty is not None:
365
+ body["presence_penalty"] = presence_penalty
366
+
367
+ if frequency_penalty is not None:
368
+ body["frequency_penalty"] = frequency_penalty
369
+
370
+ if user is not None:
371
+ body["user"] = user
372
+
373
+ if extra_body:
374
+ body.update(extra_body)
375
+
376
+ url = f"{self._client.base_url.rstrip('/')}/chat/completions"
377
+
378
+ headers = self._client._auth_headers()
379
+
380
+ if stream:
381
+ return Stream(self._iter_paid_stream(url, body, headers, model_id))
382
+
383
+ resp = self._client._http.request(
384
+ "POST",
385
+ url,
386
+ json_body=body,
387
+ headers=headers,
388
+ )
389
+
390
+ if resp.status_code >= 400:
391
+ self._client._http.raise_for_status(resp.status_code, resp.content)
392
+
393
+ data = self._client._http.parse_json(resp) or {}
394
+
395
+ return self._parse_completion(data, fallback_model=model_id)
396
+
397
+ def _iter_paid_stream(
398
+ self,
399
+ url: str,
400
+ body: dict[str, Any],
401
+ headers: dict[str, str],
402
+ model_id: str,
403
+ ) -> Iterator[ChatCompletionChunk]:
404
+ headers = {**headers, "Accept": "text/event-stream"}
405
+
406
+ created = int(time.time())
407
+
408
+ for event in self._iter_sse(url, body, headers):
409
+ if event.get("object") == "chat.completion.chunk" or "choices" in event:
410
+ choices_in = event.get("choices") or []
411
+
412
+ choices: list[ChunkChoice] = []
413
+
414
+ for i, ch in enumerate(choices_in):
415
+ delta = ch.get("delta") or {}
416
+
417
+ choices.append(
418
+ ChunkChoice(
419
+ index=int(ch.get("index") or i),
420
+ delta=ChoiceDelta(
421
+ role=delta.get("role"),
422
+ content=delta.get("content"),
423
+ ),
424
+ finish_reason=ch.get("finish_reason"),
425
+ )
426
+ )
427
+
428
+ yield ChatCompletionChunk(
429
+ id=str(event.get("id") or ""),
430
+ choices=choices,
431
+ created=int(event.get("created") or created),
432
+ model=str(event.get("model") or model_id),
433
+ )
434
+
435
+ continue
436
+
437
+ etype = event.get("type")
438
+
439
+ if etype == "delta" and event.get("text"):
440
+ yield ChatCompletionChunk(
441
+ id=str(event.get("requestId") or ""),
442
+ choices=[
443
+ ChunkChoice(
444
+ index=0,
445
+ delta=ChoiceDelta(content=str(event.get("text"))),
446
+ )
447
+ ],
448
+ created=created,
449
+ model=model_id,
450
+ )
451
+
452
+ def _parse_completion(self, data: dict[str, Any], *, fallback_model: str) -> ChatCompletion:
453
+ choices: list[Choice] = []
454
+
455
+ for i, ch in enumerate(data.get("choices") or []):
456
+ msg = ch.get("message") or {}
457
+
458
+ choices.append(
459
+ Choice(
460
+ index=int(ch.get("index") or i),
461
+ message=ChatCompletionMessage(
462
+ role=str(msg.get("role") or "assistant"),
463
+ content=msg.get("content"),
464
+ ),
465
+ finish_reason=ch.get("finish_reason"),
466
+ )
467
+ )
468
+
469
+ usage = None
470
+
471
+ raw_usage = data.get("usage")
472
+
473
+ if isinstance(raw_usage, dict):
474
+ usage = CompletionUsage(
475
+ prompt_tokens=int(raw_usage.get("prompt_tokens") or 0),
476
+ completion_tokens=int(raw_usage.get("completion_tokens") or 0),
477
+ total_tokens=int(raw_usage.get("total_tokens") or 0),
478
+ )
479
+
480
+ return ChatCompletion(
481
+ id=str(data.get("id") or ""),
482
+ choices=choices,
483
+ created=int(data.get("created") or time.time()),
484
+ model=str(data.get("model") or fallback_model),
485
+ usage=usage,
486
+ system_fingerprint=data.get("system_fingerprint"),
487
+ )
488
+
489
+
490
+ class Chat:
491
+ __slots__ = ("completions",)
492
+
493
+ def __init__(self, client: URouter) -> None:
494
+ self.completions = Completions(client)
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from urouter._constants import API_BASE, CHAT_BASE
6
+ from urouter._types import Model, ModelList
7
+
8
+ if TYPE_CHECKING:
9
+ from urouter._client import URouter
10
+
11
+
12
+ class Models:
13
+ __slots__ = ("_client",)
14
+
15
+ def __init__(self, client: URouter) -> None:
16
+ self._client = client
17
+
18
+ def list(self) -> ModelList:
19
+ if self._client.api_key:
20
+ url = f"{self._client.base_url.rstrip('/')}/models"
21
+
22
+ resp = self._client._http.request(
23
+ "GET",
24
+ url,
25
+ headers=self._client._auth_headers(),
26
+ )
27
+
28
+ if resp.status_code >= 400:
29
+ self._client._http.raise_for_status(resp.status_code, resp.content)
30
+
31
+ payload = self._client._http.parse_json(resp) or {}
32
+
33
+ items = []
34
+
35
+ for row in payload.get("data") or []:
36
+ items.append(
37
+ Model(
38
+ id=str(row.get("id") or ""),
39
+ name=row.get("name"),
40
+ brand=row.get("brand"),
41
+ owned_by=str(row.get("brand") or row.get("owned_by") or "urouter"),
42
+ raw=row if isinstance(row, dict) else {},
43
+ )
44
+ )
45
+
46
+ return ModelList(data=items)
47
+
48
+ resp = self._client._http.request(
49
+ "GET",
50
+ f"{CHAT_BASE}/api/chat/state",
51
+ headers=self._client._chat_headers(),
52
+ )
53
+
54
+ if resp.status_code >= 400:
55
+ self._client._http.raise_for_status(resp.status_code, resp.content)
56
+
57
+ payload = self._client._http.parse_json(resp) or {}
58
+
59
+ items = []
60
+
61
+ for row in payload.get("models") or []:
62
+ mid = str(row.get("id") or "")
63
+
64
+ items.append(
65
+ Model(
66
+ id=mid,
67
+ name=mid,
68
+ owned_by="urouter",
69
+ raw=row if isinstance(row, dict) else {},
70
+ )
71
+ )
72
+
73
+ for row in payload.get("presets") or []:
74
+ mid = str(row.get("modelId") or row.get("id") or "")
75
+
76
+ if mid and not any(m.id == mid for m in items):
77
+ items.append(
78
+ Model(
79
+ id=mid,
80
+ name=str(row.get("label") or mid),
81
+ owned_by="urouter-free",
82
+ raw=row if isinstance(row, dict) else {},
83
+ )
84
+ )
85
+
86
+ return ModelList(data=items)
87
+
88
+ def retrieve(self, model: str) -> Model:
89
+ if self._client.api_key:
90
+ url = f"{self._client.base_url.rstrip('/')}/models/{model}"
91
+
92
+ resp = self._client._http.request(
93
+ "GET",
94
+ url,
95
+ headers=self._client._auth_headers(),
96
+ )
97
+
98
+ if resp.status_code >= 400:
99
+ self._client._http.raise_for_status(resp.status_code, resp.content)
100
+
101
+ row: dict[str, Any] = self._client._http.parse_json(resp) or {}
102
+
103
+ return Model(
104
+ id=str(row.get("id") or model),
105
+ name=row.get("name"),
106
+ brand=row.get("brand"),
107
+ owned_by=str(row.get("brand") or "urouter"),
108
+ raw=row,
109
+ )
110
+
111
+ listed = self.list()
112
+
113
+ for item in listed.data:
114
+ if item.id == model:
115
+ return item
116
+
117
+ return Model(id=model, owned_by="urouter")