lazycontroller 0.1.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.
- lazycontroller/__init__.py +9 -0
- lazycontroller/build.py +94 -0
- lazycontroller/client.py +279 -0
- lazycontroller/lazy.py +265 -0
- lazycontroller/py.typed +0 -0
- lazycontroller/requests.py +161 -0
- lazycontroller/schema.py +298 -0
- lazycontroller/shadow.py +126 -0
- lazycontroller-0.1.0.dist-info/METADATA +257 -0
- lazycontroller-0.1.0.dist-info/RECORD +12 -0
- lazycontroller-0.1.0.dist-info/WHEEL +4 -0
- lazycontroller-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Drop-in ``openai`` clients: a fast decision model answers the structured calls it is sure of."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncOpenAI, OpenAI
|
|
4
|
+
from .lazy import FastConfig, Route
|
|
5
|
+
from .shadow import ShadowReport
|
|
6
|
+
|
|
7
|
+
__all__ = ["AsyncOpenAI", "FastConfig", "OpenAI", "Route", "ShadowReport"]
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
lazycontroller/build.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Build OpenAI response objects carrying the fast model's answer, indistinguishable to callers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# The SDK's own lenient constructor: a field added in a later SDK version must not break us.
|
|
10
|
+
from openai._models import construct_type_unchecked
|
|
11
|
+
from openai.types.chat import ChatCompletion
|
|
12
|
+
from openai.types.responses import Response
|
|
13
|
+
from typesafe_sdk import SystemOneResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def chat_completion(output: Mapping[str, Any], answer: SystemOneResponse) -> ChatCompletion:
|
|
17
|
+
usage = _usage(answer)
|
|
18
|
+
return construct_type_unchecked(
|
|
19
|
+
type_=ChatCompletion,
|
|
20
|
+
value={
|
|
21
|
+
"id": f"chatcmpl-lazy-{uuid.uuid4().hex}",
|
|
22
|
+
"object": "chat.completion",
|
|
23
|
+
"created": int(time.time()),
|
|
24
|
+
"model": answer.model,
|
|
25
|
+
"choices": [
|
|
26
|
+
{
|
|
27
|
+
"index": 0,
|
|
28
|
+
"finish_reason": "stop",
|
|
29
|
+
"logprobs": None,
|
|
30
|
+
"message": {
|
|
31
|
+
"role": "assistant",
|
|
32
|
+
"content": json.dumps(output),
|
|
33
|
+
"refusal": None,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"usage": {
|
|
38
|
+
"prompt_tokens": usage[0],
|
|
39
|
+
"completion_tokens": usage[1],
|
|
40
|
+
"total_tokens": usage[0] + usage[1],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def response(
|
|
47
|
+
output: Mapping[str, Any], answer: SystemOneResponse, kwargs: Mapping[str, Any]
|
|
48
|
+
) -> Response:
|
|
49
|
+
usage = _usage(answer)
|
|
50
|
+
text = kwargs.get("text") or {}
|
|
51
|
+
return construct_type_unchecked(
|
|
52
|
+
type_=Response,
|
|
53
|
+
value={
|
|
54
|
+
"id": f"resp_lazy_{uuid.uuid4().hex}",
|
|
55
|
+
"object": "response",
|
|
56
|
+
"created_at": time.time(),
|
|
57
|
+
"status": "completed",
|
|
58
|
+
"model": answer.model,
|
|
59
|
+
"output": [
|
|
60
|
+
{
|
|
61
|
+
"type": "message",
|
|
62
|
+
"id": f"msg_lazy_{uuid.uuid4().hex}",
|
|
63
|
+
"role": "assistant",
|
|
64
|
+
"status": "completed",
|
|
65
|
+
"content": [
|
|
66
|
+
{
|
|
67
|
+
"type": "output_text",
|
|
68
|
+
"text": json.dumps(output),
|
|
69
|
+
"annotations": [],
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"instructions": kwargs.get("instructions"),
|
|
75
|
+
"metadata": kwargs.get("metadata"),
|
|
76
|
+
"text": text,
|
|
77
|
+
"tools": [],
|
|
78
|
+
"tool_choice": "auto",
|
|
79
|
+
"parallel_tool_calls": True,
|
|
80
|
+
"temperature": kwargs.get("temperature"),
|
|
81
|
+
"top_p": kwargs.get("top_p"),
|
|
82
|
+
"usage": {
|
|
83
|
+
"input_tokens": usage[0],
|
|
84
|
+
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
|
|
85
|
+
"output_tokens": usage[1],
|
|
86
|
+
"output_tokens_details": {"reasoning_tokens": 0},
|
|
87
|
+
"total_tokens": usage[0] + usage[1],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _usage(answer: SystemOneResponse) -> tuple[int, int]:
|
|
94
|
+
return answer.usage.input_tokens or 0, answer.usage.output_tokens or 0
|
lazycontroller/client.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""``OpenAI`` and ``AsyncOpenAI`` subclasses that let a fast model answer structured-output calls.
|
|
2
|
+
|
|
3
|
+
Intercepted: ``chat.completions.create`` / ``parse`` and ``responses.create`` / ``parse``.
|
|
4
|
+
Everything else is the stock SDK, and so is every intercepted call the fast model can't take.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from functools import cached_property
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
import openai
|
|
13
|
+
from openai.lib._parsing import parse_chat_completion, type_to_response_format_param
|
|
14
|
+
from openai.lib._parsing._responses import parse_response, type_to_text_format_param
|
|
15
|
+
from openai.resources.chat import AsyncChat, AsyncCompletions, Chat, Completions
|
|
16
|
+
from openai.resources.responses import AsyncResponses, Responses
|
|
17
|
+
|
|
18
|
+
from . import build
|
|
19
|
+
from .lazy import (
|
|
20
|
+
AsyncRouter,
|
|
21
|
+
Call,
|
|
22
|
+
FastConfig,
|
|
23
|
+
Router,
|
|
24
|
+
chat_output_json,
|
|
25
|
+
responses_output_json,
|
|
26
|
+
)
|
|
27
|
+
from .requests import chat_request, given, responses_request
|
|
28
|
+
from .schema import Ineligible
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _chat_call(kwargs: dict[str, Any]) -> Call:
|
|
32
|
+
return Call(
|
|
33
|
+
"chat.completions",
|
|
34
|
+
kwargs.get("model"),
|
|
35
|
+
lambda: chat_request(kwargs),
|
|
36
|
+
lambda output, answer: build.chat_completion(output, answer),
|
|
37
|
+
chat_output_json,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _chat_parse_call(kwargs: dict[str, Any]) -> Call:
|
|
42
|
+
response_format = kwargs.get("response_format")
|
|
43
|
+
|
|
44
|
+
def prepare():
|
|
45
|
+
if not isinstance(response_format, type):
|
|
46
|
+
raise Ineligible("response_format is not a type")
|
|
47
|
+
return chat_request(
|
|
48
|
+
{
|
|
49
|
+
**kwargs,
|
|
50
|
+
"response_format": type_to_response_format_param(response_format),
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def make(output, answer):
|
|
55
|
+
return parse_chat_completion(
|
|
56
|
+
response_format=response_format,
|
|
57
|
+
input_tools=kwargs.get("tools", openai.omit),
|
|
58
|
+
chat_completion=build.chat_completion(output, answer),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return Call("chat.completions", kwargs.get("model"), prepare, make, chat_output_json)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _responses_call(kwargs: dict[str, Any]) -> Call:
|
|
65
|
+
return Call(
|
|
66
|
+
"responses",
|
|
67
|
+
kwargs.get("model"),
|
|
68
|
+
lambda: responses_request(kwargs),
|
|
69
|
+
lambda output, answer: build.response(output, answer, given(kwargs)),
|
|
70
|
+
responses_output_json,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _responses_parse_call(kwargs: dict[str, Any]) -> Call:
|
|
75
|
+
text_format = kwargs.get("text_format")
|
|
76
|
+
|
|
77
|
+
def with_format() -> dict[str, Any]:
|
|
78
|
+
if not isinstance(text_format, type):
|
|
79
|
+
raise Ineligible("text_format is not a type")
|
|
80
|
+
if "parse_as" in given(kwargs):
|
|
81
|
+
raise Ineligible("uses parse_as")
|
|
82
|
+
rest = {k: v for k, v in kwargs.items() if k != "text_format"}
|
|
83
|
+
return {
|
|
84
|
+
**rest,
|
|
85
|
+
"text": {
|
|
86
|
+
**(rest.get("text") or {}),
|
|
87
|
+
"format": type_to_text_format_param(text_format),
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
def make(output, answer):
|
|
92
|
+
return parse_response(
|
|
93
|
+
text_format=text_format,
|
|
94
|
+
input_tools=kwargs.get("tools"),
|
|
95
|
+
response=build.response(output, answer, given(with_format())),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return Call(
|
|
99
|
+
"responses",
|
|
100
|
+
kwargs.get("model"),
|
|
101
|
+
lambda: responses_request(with_format()),
|
|
102
|
+
make,
|
|
103
|
+
responses_output_json,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _Completions(Completions):
|
|
108
|
+
# The overrides take **kwargs; type checkers keep seeing the SDK's full signatures.
|
|
109
|
+
if not TYPE_CHECKING:
|
|
110
|
+
|
|
111
|
+
def create(self, **kwargs):
|
|
112
|
+
return self._client._router.run(
|
|
113
|
+
_chat_call(kwargs), lambda: super(_Completions, self).create(**kwargs)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def parse(self, **kwargs):
|
|
117
|
+
return self._client._router.run(
|
|
118
|
+
_chat_parse_call(kwargs),
|
|
119
|
+
lambda: super(_Completions, self).parse(**kwargs),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _AsyncCompletions(AsyncCompletions):
|
|
124
|
+
if not TYPE_CHECKING:
|
|
125
|
+
|
|
126
|
+
async def create(self, **kwargs):
|
|
127
|
+
return await self._client._router.run(
|
|
128
|
+
_chat_call(kwargs),
|
|
129
|
+
lambda: super(_AsyncCompletions, self).create(**kwargs),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
async def parse(self, **kwargs):
|
|
133
|
+
return await self._client._router.run(
|
|
134
|
+
_chat_parse_call(kwargs),
|
|
135
|
+
lambda: super(_AsyncCompletions, self).parse(**kwargs),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _Responses(Responses):
|
|
140
|
+
if not TYPE_CHECKING:
|
|
141
|
+
|
|
142
|
+
def create(self, **kwargs):
|
|
143
|
+
return self._client._router.run(
|
|
144
|
+
_responses_call(kwargs),
|
|
145
|
+
lambda: super(_Responses, self).create(**kwargs),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def parse(self, **kwargs):
|
|
149
|
+
return self._client._router.run(
|
|
150
|
+
_responses_parse_call(kwargs),
|
|
151
|
+
lambda: super(_Responses, self).parse(**kwargs),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _AsyncResponses(AsyncResponses):
|
|
156
|
+
if not TYPE_CHECKING:
|
|
157
|
+
|
|
158
|
+
async def create(self, **kwargs):
|
|
159
|
+
return await self._client._router.run(
|
|
160
|
+
_responses_call(kwargs),
|
|
161
|
+
lambda: super(_AsyncResponses, self).create(**kwargs),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def parse(self, **kwargs):
|
|
165
|
+
return await self._client._router.run(
|
|
166
|
+
_responses_parse_call(kwargs),
|
|
167
|
+
lambda: super(_AsyncResponses, self).parse(**kwargs),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class _Chat(Chat):
|
|
172
|
+
@cached_property
|
|
173
|
+
def completions(self) -> Completions:
|
|
174
|
+
return _Completions(self._client)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _AsyncChat(AsyncChat):
|
|
178
|
+
@cached_property
|
|
179
|
+
def completions(self) -> AsyncCompletions:
|
|
180
|
+
return _AsyncCompletions(self._client)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
184
|
+
_CREDENTIALS = ("api_key", "admin_api_key", "workload_identity")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _with_local_key(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
188
|
+
"""Fill in a placeholder key for a local server (Ollama, vLLM, LM Studio...).
|
|
189
|
+
|
|
190
|
+
The SDK refuses to start without credentials, but local servers ignore them. Only applies
|
|
191
|
+
where ``openai.OpenAI`` would have raised, so nothing changes for a configured client.
|
|
192
|
+
"""
|
|
193
|
+
if any(kwargs.get(name) is not None for name in _CREDENTIALS):
|
|
194
|
+
return kwargs
|
|
195
|
+
if os.environ.get("OPENAI_API_KEY") or os.environ.get("OPENAI_ADMIN_KEY"):
|
|
196
|
+
return kwargs
|
|
197
|
+
base_url = kwargs.get("base_url") or os.environ.get("OPENAI_BASE_URL")
|
|
198
|
+
host = urlsplit(str(base_url)).hostname if base_url else None
|
|
199
|
+
if host in _LOCAL_HOSTS or (host or "").endswith(".localhost"):
|
|
200
|
+
return {**kwargs, "api_key": "local"}
|
|
201
|
+
return kwargs
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class OpenAI(openai.OpenAI):
|
|
205
|
+
"""``openai.OpenAI`` that lets a fast model answer the structured-output calls it is sure of.
|
|
206
|
+
|
|
207
|
+
Takes every ``openai.OpenAI`` argument, plus ``fast`` for the fast model. A local
|
|
208
|
+
``base_url`` such as Ollama's needs no ``api_key``.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
_router: Router
|
|
212
|
+
|
|
213
|
+
def __init__(self, *args: Any, fast: FastConfig | None = None, **kwargs: Any) -> None:
|
|
214
|
+
super().__init__(*args, **_with_local_key(kwargs))
|
|
215
|
+
self._router = Router(fast or FastConfig())
|
|
216
|
+
|
|
217
|
+
@cached_property
|
|
218
|
+
def chat(self) -> Chat:
|
|
219
|
+
return _Chat(self)
|
|
220
|
+
|
|
221
|
+
@cached_property
|
|
222
|
+
def responses(self) -> Responses:
|
|
223
|
+
return _Responses(self)
|
|
224
|
+
|
|
225
|
+
def copy(self, *args: Any, **kwargs: Any) -> "OpenAI":
|
|
226
|
+
new = super().copy(*args, **kwargs)
|
|
227
|
+
new._router = self._router
|
|
228
|
+
return new
|
|
229
|
+
|
|
230
|
+
with_options = copy
|
|
231
|
+
|
|
232
|
+
def with_fast(self, **changes: Any) -> "OpenAI":
|
|
233
|
+
"""A copy of this client with some ``FastConfig`` settings changed.
|
|
234
|
+
|
|
235
|
+
The ``with_options`` of routing: ``client.with_fast(min_confidence=0.95)`` for calls
|
|
236
|
+
where a wrong answer costs more, ``client.with_fast(enabled=False)`` to skip the fast
|
|
237
|
+
model.
|
|
238
|
+
The original client is unchanged.
|
|
239
|
+
"""
|
|
240
|
+
new = self.copy()
|
|
241
|
+
new._router = self._router.derive(**changes)
|
|
242
|
+
return new
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class AsyncOpenAI(openai.AsyncOpenAI):
|
|
246
|
+
"""``openai.AsyncOpenAI`` counterpart of ``lazycontroller.OpenAI``."""
|
|
247
|
+
|
|
248
|
+
_router: AsyncRouter
|
|
249
|
+
|
|
250
|
+
def __init__(self, *args: Any, fast: FastConfig | None = None, **kwargs: Any) -> None:
|
|
251
|
+
super().__init__(*args, **_with_local_key(kwargs))
|
|
252
|
+
self._router = AsyncRouter(fast or FastConfig())
|
|
253
|
+
|
|
254
|
+
@cached_property
|
|
255
|
+
def chat(self) -> AsyncChat:
|
|
256
|
+
return _AsyncChat(self)
|
|
257
|
+
|
|
258
|
+
@cached_property
|
|
259
|
+
def responses(self) -> AsyncResponses:
|
|
260
|
+
return _AsyncResponses(self)
|
|
261
|
+
|
|
262
|
+
def copy(self, *args: Any, **kwargs: Any) -> "AsyncOpenAI":
|
|
263
|
+
new = super().copy(*args, **kwargs)
|
|
264
|
+
new._router = self._router
|
|
265
|
+
return new
|
|
266
|
+
|
|
267
|
+
with_options = copy
|
|
268
|
+
|
|
269
|
+
def with_fast(self, **changes: Any) -> "AsyncOpenAI":
|
|
270
|
+
"""A copy of this client with some ``FastConfig`` settings changed.
|
|
271
|
+
|
|
272
|
+
The ``with_options`` of routing: ``client.with_fast(min_confidence=0.95)`` for calls
|
|
273
|
+
where a wrong answer costs more, ``client.with_fast(enabled=False)`` to skip the fast
|
|
274
|
+
model.
|
|
275
|
+
The original client is unchanged.
|
|
276
|
+
"""
|
|
277
|
+
new = self.copy()
|
|
278
|
+
new._router = self._router.derive(**changes)
|
|
279
|
+
return new
|
lazycontroller/lazy.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Decide, per request, whether the fast model answers or the call goes on to the slow one.
|
|
2
|
+
|
|
3
|
+
The fast model is a decision model on a System One API: Jev on TypeSafe, a local Kev on
|
|
4
|
+
Ollama, or the same models hosted elsewhere. The slow model is whatever the caller's
|
|
5
|
+
OpenAI-compatible client was going to call anyway.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
11
|
+
from dataclasses import dataclass, field, replace
|
|
12
|
+
from typing import Any, Literal, Self
|
|
13
|
+
|
|
14
|
+
from typesafe_sdk import (
|
|
15
|
+
AsyncTypeSafeClient,
|
|
16
|
+
SystemOneResponse,
|
|
17
|
+
TypeSafeClient,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .requests import Request
|
|
21
|
+
from .schema import Ineligible, Plan, compile_schema
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("lazycontroller")
|
|
24
|
+
|
|
25
|
+
type Api = Literal["chat.completions", "responses"]
|
|
26
|
+
type Reason = Literal[
|
|
27
|
+
"confident", "low_confidence", "ineligible", "fast_model_error", "disabled", "shadow"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Route:
|
|
33
|
+
"""How one request was answered; passed to ``FastConfig.on_route``."""
|
|
34
|
+
|
|
35
|
+
api: Api
|
|
36
|
+
answered_by: Literal["fast", "slow"]
|
|
37
|
+
reason: Reason
|
|
38
|
+
"""Why: the fast model was ``confident``, or the slow model was called because of
|
|
39
|
+
``low_confidence``, an ``ineligible`` request, a ``fast_model_error``, routing being
|
|
40
|
+
``disabled``, or ``shadow`` mode."""
|
|
41
|
+
slow_model: str | None
|
|
42
|
+
"""The model the caller asked for."""
|
|
43
|
+
fast_model: str | None = None
|
|
44
|
+
confidence: float | None = None
|
|
45
|
+
"""The lowest field confidence, which is what ``min_confidence`` is compared with."""
|
|
46
|
+
field_confidences: Mapping[str, float] = field(default_factory=dict)
|
|
47
|
+
fast_output: Mapping[str, Any] | None = None
|
|
48
|
+
"""The fast model's answer as a JSON object, whenever it was asked (also when the slow model
|
|
49
|
+
answered in the end, so shadow disagreements can be inspected)."""
|
|
50
|
+
fast_input_tokens: int | None = None
|
|
51
|
+
detail: str | None = None
|
|
52
|
+
"""Why the request was ineligible, or the fast model's error."""
|
|
53
|
+
shadow_mismatches: tuple[str, ...] | None = None
|
|
54
|
+
"""In shadow mode, the fields where the two models disagreed (empty when they agree)."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class FastConfig:
|
|
59
|
+
"""The fast decision model, and when its answer is used instead of the slow model's."""
|
|
60
|
+
|
|
61
|
+
base_url: str | None = None
|
|
62
|
+
"""The System One server: ``https://api.typesafe.ai``, ``https://openrouter.ai/api``,
|
|
63
|
+
``http://localhost:11434`` for Ollama, ... Without it (or ``client``) the fast side is off
|
|
64
|
+
and every call goes to the slow model."""
|
|
65
|
+
api_key: str | None = None
|
|
66
|
+
"""Key for ``base_url``, and sent nowhere else. Local servers need none."""
|
|
67
|
+
model: str | None = None
|
|
68
|
+
"""The decision model; ``None`` means the server's default."""
|
|
69
|
+
min_confidence: float = 0.9
|
|
70
|
+
"""Every field's confidence must reach this for the fast model's answer to be used;
|
|
71
|
+
otherwise the call goes on to the slow model."""
|
|
72
|
+
enabled: bool = True
|
|
73
|
+
"""``False`` sends every call straight to the slow model."""
|
|
74
|
+
shadow: bool = False
|
|
75
|
+
"""Ask the fast model but always return the slow model's answer, reporting where they
|
|
76
|
+
differ."""
|
|
77
|
+
on_route: Callable[[Route], None] | None = None
|
|
78
|
+
client: TypeSafeClient | AsyncTypeSafeClient | None = None
|
|
79
|
+
"""Use this System One client instead of creating one."""
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
if not 0.0 <= self.min_confidence <= 1.0:
|
|
83
|
+
raise ValueError("min_confidence must be between 0 and 1")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Router[C]:
|
|
87
|
+
def __init__(self, config: FastConfig, client_cls: type[C]) -> None:
|
|
88
|
+
self.config = config
|
|
89
|
+
self._client_cls = client_cls
|
|
90
|
+
self._fast: C | None = config.client # type: ignore[assignment]
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def fast(self) -> C | None:
|
|
94
|
+
if self._fast is None:
|
|
95
|
+
if base_url := self.config.base_url:
|
|
96
|
+
# Passed explicitly, so the SDK reads neither TYPESAFE_API_KEY nor
|
|
97
|
+
# TYPESAFE_BASE_URL. It insists on some key; local servers ignore it.
|
|
98
|
+
api_key = self.config.api_key or "local"
|
|
99
|
+
self._fast = self._client_cls(api_key=api_key, base_url=base_url)
|
|
100
|
+
return self._fast
|
|
101
|
+
|
|
102
|
+
def derive(self, **changes: Any) -> Self:
|
|
103
|
+
"""A router with some settings changed, sharing the fast model's connection when it can."""
|
|
104
|
+
config = replace(self.config, **changes)
|
|
105
|
+
router = type(self)(config)
|
|
106
|
+
if all(
|
|
107
|
+
getattr(config, name) == getattr(self.config, name)
|
|
108
|
+
for name in ("api_key", "base_url", "client")
|
|
109
|
+
):
|
|
110
|
+
router._fast = self._fast
|
|
111
|
+
return router
|
|
112
|
+
|
|
113
|
+
def plan(
|
|
114
|
+
self, api: Api, prepare: Callable[[], Request], model: str | None
|
|
115
|
+
) -> tuple[Request, Plan] | Route:
|
|
116
|
+
if not self.config.enabled:
|
|
117
|
+
return Route(api, "slow", "disabled", model, detail="disabled in FastConfig")
|
|
118
|
+
try:
|
|
119
|
+
if self.fast is None:
|
|
120
|
+
return Route(api, "slow", "disabled", model, detail="no base_url in FastConfig")
|
|
121
|
+
request = prepare()
|
|
122
|
+
return request, compile_schema(request.schema, title=request.name)
|
|
123
|
+
except Ineligible as error:
|
|
124
|
+
return Route(api, "slow", "ineligible", model, detail=str(error))
|
|
125
|
+
except Exception as error:
|
|
126
|
+
# A request we can't read is the slow model's to accept or reject, not ours.
|
|
127
|
+
logger.warning("Could not read request, passing it to the slow model: %r", error)
|
|
128
|
+
detail = f"could not read request: {type(error).__name__}: {error}"
|
|
129
|
+
return Route(api, "slow", "ineligible", model, detail=detail)
|
|
130
|
+
|
|
131
|
+
def judge(
|
|
132
|
+
self, api: Api, plan: Plan, answer: SystemOneResponse, model: str | None
|
|
133
|
+
) -> tuple[dict[str, Any], Route]:
|
|
134
|
+
output, confidences = plan.assemble(answer.answers)
|
|
135
|
+
confidence = min(confidences.values())
|
|
136
|
+
confident = confidence >= self.config.min_confidence
|
|
137
|
+
route = Route(
|
|
138
|
+
api,
|
|
139
|
+
"fast" if confident and not self.config.shadow else "slow",
|
|
140
|
+
"shadow" if self.config.shadow else "confident" if confident else "low_confidence",
|
|
141
|
+
model,
|
|
142
|
+
fast_model=answer.model,
|
|
143
|
+
confidence=confidence,
|
|
144
|
+
field_confidences=confidences,
|
|
145
|
+
fast_output=output,
|
|
146
|
+
fast_input_tokens=answer.usage.input_tokens,
|
|
147
|
+
)
|
|
148
|
+
return output, route
|
|
149
|
+
|
|
150
|
+
def fast_failed(self, api: Api, model: str | None, error: Exception) -> Route:
|
|
151
|
+
logger.warning("Fast model failed, falling back to the slow model: %r", error)
|
|
152
|
+
detail = f"{type(error).__name__}: {error}"
|
|
153
|
+
return Route(api, "slow", "fast_model_error", model, detail=detail)
|
|
154
|
+
|
|
155
|
+
def report(self, route: Route) -> None:
|
|
156
|
+
if self.config.on_route is not None:
|
|
157
|
+
self.config.on_route(route)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class Call:
|
|
162
|
+
"""One intercepted SDK call: how to read it, how to answer it, how to pass it on."""
|
|
163
|
+
|
|
164
|
+
api: Api
|
|
165
|
+
model: str | None
|
|
166
|
+
prepare: Callable[[], Request]
|
|
167
|
+
build: Callable[[dict[str, Any], SystemOneResponse], Any]
|
|
168
|
+
output_json: Callable[[Any], Any]
|
|
169
|
+
"""Extract the JSON object from a slow-model response, for shadow comparisons."""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Router(_Router[TypeSafeClient]):
|
|
173
|
+
def __init__(self, config: FastConfig) -> None:
|
|
174
|
+
super().__init__(config, TypeSafeClient)
|
|
175
|
+
|
|
176
|
+
def run(self, call: Call, slow: Callable[[], Any]) -> Any:
|
|
177
|
+
planned = self.plan(call.api, call.prepare, call.model)
|
|
178
|
+
if isinstance(planned, Route):
|
|
179
|
+
self.report(planned)
|
|
180
|
+
return slow()
|
|
181
|
+
request, plan = planned
|
|
182
|
+
try:
|
|
183
|
+
answer = self.fast.system_one( # type: ignore[union-attr]
|
|
184
|
+
state=request.state, questions=plan.questions, model=self.config.model
|
|
185
|
+
)
|
|
186
|
+
output, route = self.judge(call.api, plan, answer, call.model)
|
|
187
|
+
except Exception as error:
|
|
188
|
+
self.report(self.fast_failed(call.api, call.model, error))
|
|
189
|
+
return slow()
|
|
190
|
+
if route.answered_by == "fast":
|
|
191
|
+
try:
|
|
192
|
+
result = call.build(output, answer)
|
|
193
|
+
except Exception as error: # e.g. a validator in the caller's model rejects it
|
|
194
|
+
self.report(self.fast_failed(call.api, call.model, error))
|
|
195
|
+
return slow()
|
|
196
|
+
self.report(route)
|
|
197
|
+
return result
|
|
198
|
+
result = slow()
|
|
199
|
+
self.report(_with_shadow(route, plan, output, call, result))
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class AsyncRouter(_Router[AsyncTypeSafeClient]):
|
|
204
|
+
def __init__(self, config: FastConfig) -> None:
|
|
205
|
+
super().__init__(config, AsyncTypeSafeClient)
|
|
206
|
+
|
|
207
|
+
async def run(self, call: Call, slow: Callable[[], Awaitable[Any]]) -> Any:
|
|
208
|
+
planned = self.plan(call.api, call.prepare, call.model)
|
|
209
|
+
if isinstance(planned, Route):
|
|
210
|
+
self.report(planned)
|
|
211
|
+
return await slow()
|
|
212
|
+
request, plan = planned
|
|
213
|
+
try:
|
|
214
|
+
answer = await self.fast.system_one( # type: ignore[union-attr]
|
|
215
|
+
state=request.state, questions=plan.questions, model=self.config.model
|
|
216
|
+
)
|
|
217
|
+
output, route = self.judge(call.api, plan, answer, call.model)
|
|
218
|
+
except Exception as error:
|
|
219
|
+
self.report(self.fast_failed(call.api, call.model, error))
|
|
220
|
+
return await slow()
|
|
221
|
+
if route.answered_by == "fast":
|
|
222
|
+
try:
|
|
223
|
+
result = call.build(output, answer)
|
|
224
|
+
except Exception as error: # e.g. a validator in the caller's model rejects it
|
|
225
|
+
self.report(self.fast_failed(call.api, call.model, error))
|
|
226
|
+
return await slow()
|
|
227
|
+
self.report(route)
|
|
228
|
+
return result
|
|
229
|
+
result = await slow()
|
|
230
|
+
self.report(_with_shadow(route, plan, output, call, result))
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _with_shadow(
|
|
235
|
+
route: Route, plan: Plan, output: dict[str, Any], call: Call, result: Any
|
|
236
|
+
) -> Route:
|
|
237
|
+
if route.reason != "shadow":
|
|
238
|
+
return route
|
|
239
|
+
try:
|
|
240
|
+
theirs = call.output_json(result)
|
|
241
|
+
except (TypeError, ValueError, AttributeError, IndexError):
|
|
242
|
+
theirs = None
|
|
243
|
+
mismatches = tuple(
|
|
244
|
+
".".join(f.path) for f in plan.fields if _get(output, f.path) != _get(theirs, f.path)
|
|
245
|
+
)
|
|
246
|
+
return Route(**{**route.__dict__, "shadow_mismatches": mismatches})
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _get(obj: Any, path: tuple[str, ...]) -> Any:
|
|
250
|
+
for key in path:
|
|
251
|
+
if not isinstance(obj, Mapping) or key not in obj:
|
|
252
|
+
return _MISSING
|
|
253
|
+
obj = obj[key]
|
|
254
|
+
return obj
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
_MISSING = object()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def chat_output_json(completion: Any) -> Any:
|
|
261
|
+
return json.loads(completion.choices[0].message.content)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def responses_output_json(response: Any) -> Any:
|
|
265
|
+
return json.loads(response.output_text)
|
lazycontroller/py.typed
ADDED
|
File without changes
|