Skip to content

Commit 90e9cde

Browse files
committed
perf: load opencode.types on first use
Generated resources now name response types as strings, resolved by type_adapter on first use, and import models only under TYPE_CHECKING. opencode.types is a lazy package attribute. import opencode no longer builds ~640 pydantic models (median ~400ms -> ~265ms locally; the model build moves to the first request, ~130ms).
1 parent cc76a66 commit 90e9cde

38 files changed

Lines changed: 596 additions & 476 deletions

‎codegen/render.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -348,14 +348,14 @@ def _render_method(operation: Operation, errors: dict[str, dict[str, Any]], *, i
348348
lines += [
349349
" return self._client.page(",
350350
*request_options,
351-
f" response_type={_resource_type(operation.response_type or 'Any')},",
351+
f" response_type={_resource_type(operation.response_type or 'Any')!r},",
352352
" )",
353353
]
354354
elif operation.kind == "stream":
355355
lines += [
356356
f" return {awaiting}self._client.stream(",
357357
*request_options,
358-
f" item_type={_resource_type(operation.response_type or 'Any')},",
358+
f" item_type={_resource_type(operation.response_type or 'Any')!r},",
359359
" )",
360360
]
361361
elif operation.kind == "void":
@@ -371,7 +371,7 @@ def _render_method(operation: Operation, errors: dict[str, dict[str, Any]], *, i
371371
lines += [
372372
f" return cast({returns!r}, {awaiting}self._client.request(",
373373
*request_options,
374-
f" response_type={_resource_type(operation.response_type or 'Any')},",
374+
f" response_type={_resource_type(operation.response_type or 'Any')!r},",
375375
*([" unwrap=True,"] if operation.unwrap else []),
376376
" ))",
377377
]
@@ -453,7 +453,7 @@ def render_resource_module(
453453
[*_resource_classes(resource, errors, is_async=False), *_resource_classes(resource, errors, is_async=True)]
454454
)
455455
uses = _names_in(source)
456-
typing_names = sorted({"Any", "Literal", "cast"} & uses)
456+
typing_names = sorted(({"Any", "Literal", "cast"} & uses) | ({"TYPE_CHECKING"} if type_names else set[str]()))
457457
abc_names = sorted({"Mapping", "Sequence"} & uses)
458458
runtime: list[str] = []
459459
if "encode_path_segment" in uses:
@@ -481,7 +481,18 @@ def render_resource_module(
481481
*(["from .._streaming import AsyncStream, Stream"] if any(op.kind == "stream" for op in operations) else []),
482482
"from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout"
483483
+ (", OpenEnum" if "OpenEnum" in uses else ""),
484-
*(["from ..types import (", *[f" {name}," for name in type_names], ")"] if type_names else []),
484+
# Models load on first use (see ``type_adapter``), so importing the client does not build them all.
485+
*(
486+
[
487+
"",
488+
"if TYPE_CHECKING:",
489+
" from ..types import (",
490+
*[f" {name}," for name in type_names],
491+
" )",
492+
]
493+
if type_names
494+
else []
495+
),
485496
"",
486497
f"__all__ = [{', '.join(repr(prefix + resource.class_name + 'Resource') for prefix in ('', 'Async'))}]\n\n",
487498
source,

‎src/opencode/__init__.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414

1515
from __future__ import annotations
1616

17-
from . import types
17+
from typing import TYPE_CHECKING, Any
18+
1819
from ._client import AsyncOpencode, Opencode
1920

2021
# <generated:error-imports>
@@ -108,6 +109,9 @@
108109
rpc_method,
109110
)
110111

112+
if TYPE_CHECKING:
113+
from . import types
114+
111115
# <generated:api-version>
112116
API_VERSION = "2.1.2"
113117
"""The OpenCode server version this client was generated from."""
@@ -206,3 +210,12 @@
206210
"WorktreeError",
207211
# </generated:error-exports>
208212
]
213+
214+
215+
def __getattr__(name: str) -> Any:
216+
# `opencode.types` builds several hundred models, so it is imported on first use rather than with the package.
217+
if name == "types":
218+
import importlib
219+
220+
return importlib.import_module(".types", __name__)
221+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

‎src/opencode/_base_client.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import base64
10+
import builtins
1011
import email.utils
1112
import functools
1213
import json
@@ -60,8 +61,18 @@ def _cached_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
6061
return pydantic.TypeAdapter(tp)
6162

6263

64+
@functools.cache
65+
def _resolve_type(expr: str) -> Any:
66+
# Generated resources name their response types as strings, so `import opencode` does not build every model.
67+
from . import types
68+
69+
return eval(expr, {**vars(types), "builtins": builtins}) # expressions come from the generator
70+
71+
6372
def type_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
64-
"""Returns a cached ``TypeAdapter`` for a response type."""
73+
"""Returns a cached ``TypeAdapter`` for a response type, or for a type expression over ``opencode.types``."""
74+
if isinstance(tp, str):
75+
tp = _resolve_type(tp)
6576
try:
6677
return _cached_adapter(tp)
6778
except TypeError: # unhashable type expression

‎src/opencode/_events.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,14 @@
3131
from typing_extensions import Self
3232

3333
from ._exceptions import APIConnectionError, APIStatusError, OpencodeError
34-
from .types import ServerConnectedEvent
3534

3635
if TYPE_CHECKING:
3736
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
3837
from types import TracebackType
3938

4039
from ._client import AsyncOpencode, Opencode
4140
from ._streaming import AsyncStream, Stream
42-
from .types import Event
41+
from .types import Event, ServerConnectedEvent
4342

4443
__all__ = [
4544
"SUBSCRIBER_CAPACITY",
@@ -302,6 +301,8 @@ def _run(self, connection: _SyncConnection) -> None:
302301

303302
def _read(self, connection: _SyncConnection) -> None:
304303
"""Reads one connection until it ends, setting ``connection.delivered`` once an event arrives."""
304+
from .types import ServerConnectedEvent
305+
305306
stream = self._client.event.subscribe()
306307
with self._lock:
307308
connection.stream = stream
@@ -605,6 +606,8 @@ async def _run(self, connection: _AsyncConnection) -> None:
605606

606607
async def _read(self, connection: _AsyncConnection) -> None:
607608
"""Reads one connection until it ends, setting ``connection.delivered`` once an event arrives."""
609+
from .types import ServerConnectedEvent
610+
608611
stream: AsyncStream[Event] = await self._client.event.subscribe()
609612
try:
610613
stream.on_activity = connection.activity

‎src/opencode/_rpc.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ class Counter(RpcDefinition, id="counter"):
4444
from ._exceptions import OpencodeError, validation_summary
4545
from ._types import NOT_GIVEN, NotGiven
4646
from .resources.rpc import AsyncRpcResource, RpcResource
47-
from .types import RpcEvent
4847

4948
if TYPE_CHECKING:
5049
from collections.abc import Mapping
@@ -53,7 +52,7 @@ class Counter(RpcDefinition, id="counter"):
5352
from ._client import AsyncOpencode, Opencode
5453
from ._events import AsyncSubscription, Subscription
5554
from ._types import Headers, Timeout
56-
from .types import Location
55+
from .types import Location, RpcEvent
5756

5857
__all__ = [
5958
"AsyncRpcClient",
@@ -327,11 +326,15 @@ def call(
327326

328327
def events(self) -> Subscription[RpcEvent]:
329328
"""Subscribes to every event of this RPC, as raw events, on the client's shared event stream."""
329+
from .types import RpcEvent
330+
330331
prefix = f"rpc.{self.definition.id}."
331332
return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix))
332333

333334
def subscribe(self, event: RpcEventDefinition[_T]) -> RpcEvents[_T]:
334335
"""Subscribes to one event of this RPC, with validated data, on the client's shared event stream."""
336+
from .types import RpcEvent
337+
335338
_check_event(self.definition, event)
336339
wire = self.definition.event_type(event)
337340
return RpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event)
@@ -396,11 +399,15 @@ async def call(
396399

397400
def events(self) -> AsyncSubscription[RpcEvent]:
398401
"""Subscribes to every event of this RPC, as raw events, on the client's shared event stream."""
402+
from .types import RpcEvent
403+
399404
prefix = f"rpc.{self.definition.id}."
400405
return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix))
401406

402407
def subscribe(self, event: RpcEventDefinition[_T]) -> AsyncRpcEvents[_T]:
403408
"""Subscribes to one event of this RPC, with validated data, on the client's shared event stream."""
409+
from .types import RpcEvent
410+
404411
_check_event(self.definition, event)
405412
wire = self.definition.event_type(event)
406413
return AsyncRpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event)

‎src/opencode/lib/_session.py‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,22 @@
1414

1515
from .._resource import AsyncAPIResource, SyncAPIResource
1616
from .._types import NOT_GIVEN, NotGiven, Timeout
17-
from ..types import (
18-
AgentID,
19-
ModelRef,
20-
PromptInputFileAttachment,
21-
SessionID,
22-
SessionInfo,
23-
SessionMessageAssistant,
24-
SessionMessageAssistantText,
25-
SessionMessageIdle,
26-
SessionMessageInfo,
27-
SessionMessageUser,
28-
SessionStructuredError,
29-
TokenUsageInfo,
30-
TokenUsageInfoCache,
31-
)
3217

3318
if TYPE_CHECKING:
3419
from collections.abc import Sequence
3520

3621
from ..resources.session import AsyncSessionResource, SessionResource
22+
from ..types import (
23+
AgentID,
24+
ModelRef,
25+
PromptInputFileAttachment,
26+
SessionID,
27+
SessionInfo,
28+
SessionMessageAssistant,
29+
SessionMessageInfo,
30+
SessionStructuredError,
31+
TokenUsageInfo,
32+
)
3733

3834
__all__ = ["AsyncSessionHelpers", "RunResult", "SessionHelpers"]
3935

@@ -44,17 +40,21 @@ class RunResult:
4440

4541
session: SessionInfo
4642
"""The session as it was after the run. ``session.cost`` and ``session.tokens`` cover the whole session."""
47-
messages: list[SessionMessageInfo] = field(default_factory=list[SessionMessageInfo])
43+
messages: list[SessionMessageInfo] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
4844
"""Messages this run added, oldest first, starting with the prompt."""
4945

5046
@property
5147
def assistant_messages(self) -> list[SessionMessageAssistant]:
5248
"""The assistant messages of this run, oldest first."""
49+
from ..types import SessionMessageAssistant
50+
5351
return [message for message in self.messages if isinstance(message, SessionMessageAssistant)]
5452

5553
@property
5654
def text(self) -> str:
5755
"""Text of the last assistant message, or ``""`` when the agent produced none."""
56+
from ..types import SessionMessageAssistantText
57+
5858
for message in reversed(self.assistant_messages):
5959
parts = [item.text for item in message.content if isinstance(item, SessionMessageAssistantText)]
6060
if parts:
@@ -64,6 +64,8 @@ def text(self) -> str:
6464
@property
6565
def outcome(self) -> str | None:
6666
"""``succeeded``, ``failed`` or ``interrupted``, from the run's idle marker or the session."""
67+
from ..types import SessionMessageIdle
68+
6769
for message in reversed(self.messages):
6870
if isinstance(message, SessionMessageIdle):
6971
return message.outcome
@@ -72,6 +74,8 @@ def outcome(self) -> str | None:
7274
@property
7375
def error(self) -> SessionStructuredError | None:
7476
"""Why the run failed, from its idle marker or last assistant message, or ``None``."""
77+
from ..types import SessionMessageAssistant, SessionMessageIdle
78+
7579
for message in reversed(self.messages):
7680
if isinstance(message, (SessionMessageIdle, SessionMessageAssistant)) and message.error is not None:
7781
return message.error
@@ -85,6 +89,8 @@ def cost(self) -> float:
8589
@property
8690
def tokens(self) -> TokenUsageInfo:
8791
"""Token usage of this run, summed over its assistant messages."""
92+
from ..types import TokenUsageInfo, TokenUsageInfoCache
93+
8894
total = TokenUsageInfo(input=0, output=0, reasoning=0, cache=TokenUsageInfoCache(read=0, write=0))
8995
for message in self.assistant_messages:
9096
usage = message.tokens
@@ -99,6 +105,8 @@ def tokens(self) -> TokenUsageInfo:
99105

100106

101107
def _new_messages(newest_first: list[SessionMessageInfo], marker: str | None) -> list[SessionMessageInfo]:
108+
from ..types import SessionMessageUser
109+
102110
collected: list[SessionMessageInfo] = []
103111
for message in newest_first:
104112
if marker is not None and message.id == marker:

‎src/opencode/resources/agent.py‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,20 @@
55

66
from __future__ import annotations
77

8-
from typing import cast
8+
from typing import TYPE_CHECKING, cast
99

1010
from .._base_client import RequestOptions
1111
from .._qs import encode_path_segment
1212
from .._resource import AsyncAPIResource, SyncAPIResource
1313
from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout
14-
from ..types import (
15-
AgentGetResponse,
16-
AgentID,
17-
AgentListResponse,
18-
Location,
19-
)
14+
15+
if TYPE_CHECKING:
16+
from ..types import (
17+
AgentGetResponse,
18+
AgentID,
19+
AgentListResponse,
20+
Location,
21+
)
2022

2123
__all__ = ["AgentResource", "AsyncAgentResource"]
2224

@@ -56,7 +58,7 @@ def list(
5658
extra_body=extra_body,
5759
timeout=timeout,
5860
),
59-
response_type=AgentListResponse,
61+
response_type="AgentListResponse",
6062
),
6163
)
6264

@@ -94,7 +96,7 @@ def get(
9496
extra_body=extra_body,
9597
timeout=timeout,
9698
),
97-
response_type=AgentGetResponse,
99+
response_type="AgentGetResponse",
98100
),
99101
)
100102

@@ -134,7 +136,7 @@ async def list(
134136
extra_body=extra_body,
135137
timeout=timeout,
136138
),
137-
response_type=AgentListResponse,
139+
response_type="AgentListResponse",
138140
),
139141
)
140142

@@ -172,6 +174,6 @@ async def get(
172174
extra_body=extra_body,
173175
timeout=timeout,
174176
),
175-
response_type=AgentGetResponse,
177+
response_type="AgentGetResponse",
176178
),
177179
)

‎src/opencode/resources/command.py‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@
55

66
from __future__ import annotations
77

8-
from typing import cast
8+
from typing import TYPE_CHECKING, cast
99

1010
from .._base_client import RequestOptions
1111
from .._resource import AsyncAPIResource, SyncAPIResource
1212
from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout
13-
from ..types import (
14-
CommandListResponse,
15-
Location,
16-
)
13+
14+
if TYPE_CHECKING:
15+
from ..types import (
16+
CommandListResponse,
17+
Location,
18+
)
1719

1820
__all__ = ["CommandResource", "AsyncCommandResource"]
1921

@@ -53,7 +55,7 @@ def list(
5355
extra_body=extra_body,
5456
timeout=timeout,
5557
),
56-
response_type=CommandListResponse,
58+
response_type="CommandListResponse",
5759
),
5860
)
5961

@@ -93,6 +95,6 @@ async def list(
9395
extra_body=extra_body,
9496
timeout=timeout,
9597
),
96-
response_type=CommandListResponse,
98+
response_type="CommandListResponse",
9799
),
98100
)

0 commit comments

Comments
 (0)