codespar 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,12 @@
1
+ .venv/
2
+ venv/
3
+ __pycache__/
4
+ *.pyc
5
+ *.pyo
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ *.egg
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: codespar
3
+ Version: 0.1.0
4
+ Summary: Python SDK for CodeSpar — commerce infrastructure for AI agents in Latin America.
5
+ Project-URL: Homepage, https://codespar.dev
6
+ Project-URL: Documentation, https://docs.codespar.dev
7
+ Project-URL: Repository, https://github.com/codespar/codespar
8
+ Project-URL: Issues, https://github.com/codespar/codespar/issues
9
+ Author-email: CodeSpar <hello@codespar.dev>
10
+ License: MIT
11
+ Keywords: agents,ai,commerce,latam,mcp,nfe,pix,stripe
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx>=0.27.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy>=1.11; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.6; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # codespar — Python SDK
33
+
34
+ Commerce infrastructure for AI agents in Latin America. Pix, NF-e,
35
+ WhatsApp, shipping, banking — one API, no provider-key boilerplate.
36
+
37
+ [![PyPI](https://img.shields.io/pypi/v/codespar.svg)](https://pypi.org/project/codespar/)
38
+ [![Python versions](https://img.shields.io/pypi/pyversions/codespar.svg)](https://pypi.org/project/codespar/)
39
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/codespar/codespar/blob/main/LICENSE)
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install codespar
45
+ ```
46
+
47
+ Python 3.10+ required.
48
+
49
+ ## Quick start
50
+
51
+ ```python
52
+ from codespar import CodeSpar
53
+
54
+ cs = CodeSpar(api_key="csk_live_...")
55
+
56
+ session = cs.create(
57
+ "user_123",
58
+ preset="brazilian", # zoop, nuvem-fiscal, melhor-envio, z-api, omie
59
+ # project_id="prj_...", # optional — defaults to the org's default project
60
+ )
61
+
62
+ result = session.send(
63
+ "Charge R$500 via Pix to +5511999887766 and send the QR code by WhatsApp."
64
+ )
65
+ print(result.message)
66
+ for call in result.tool_calls:
67
+ print(f" → {call.tool_name} ({call.duration_ms}ms)")
68
+
69
+ session.close()
70
+ cs.close()
71
+ ```
72
+
73
+ Or as a context manager:
74
+
75
+ ```python
76
+ with CodeSpar(api_key="csk_live_...") as cs:
77
+ session = cs.create("user_123", preset="brazilian")
78
+ print(session.send("Quero pagar R$125 via Pix").message)
79
+ ```
80
+
81
+ ## Streaming
82
+
83
+ ```python
84
+ for event in session.send_stream("Process order #BR-7721"):
85
+ if event.type == "assistant_text":
86
+ print(event.content, end="", flush=True)
87
+ elif event.type == "tool_use":
88
+ print(f"\n→ calling {event.name}...")
89
+ elif event.type == "tool_result":
90
+ print(f" {event.tool_call.status} in {event.tool_call.duration_ms}ms")
91
+ ```
92
+
93
+ ## Async
94
+
95
+ ```python
96
+ import asyncio
97
+ from codespar import AsyncCodeSpar
98
+
99
+ async def main():
100
+ async with AsyncCodeSpar(api_key="csk_live_...") as cs:
101
+ session = await cs.create("user_123", preset="brazilian")
102
+ result = await session.send("charge R$500 via Pix")
103
+ print(result.message)
104
+ await session.close()
105
+
106
+ asyncio.run(main())
107
+ ```
108
+
109
+ ## Multi-environment (projects)
110
+
111
+ CodeSpar scopes every session to an environment (`prj_<id>`). Pass a
112
+ project id on the client for the whole lifetime, or per-session when
113
+ you want to target a different environment:
114
+
115
+ ```python
116
+ # Pin every session this client spawns to the staging project
117
+ cs = CodeSpar(api_key="csk_live_...", project_id="prj_staging0123abcd")
118
+
119
+ # Override per session
120
+ session = cs.create("user_123", preset="brazilian", project_id="prj_prod0123abcd")
121
+ ```
122
+
123
+ When you omit `project_id`, CodeSpar routes to the org's **default
124
+ project** — always defined, self-healed on first read.
125
+
126
+ ## Raw HTTP proxy
127
+
128
+ Skip the agent loop and hit a provider API directly through CodeSpar's
129
+ credential vault:
130
+
131
+ ```python
132
+ from codespar import ProxyRequest
133
+
134
+ response = session.proxy_execute(ProxyRequest(
135
+ server="stripe-acp",
136
+ endpoint="/v1/charges",
137
+ method="POST",
138
+ body={"amount": 2000, "currency": "brl"},
139
+ ))
140
+ print(response.status, response.data)
141
+ ```
142
+
143
+ No API key leaves your machine — CodeSpar injects it server-side.
144
+
145
+ ## Connect Links (OAuth)
146
+
147
+ ```python
148
+ from codespar import AuthConfig
149
+
150
+ link = session.authorize(
151
+ "stripe-acp",
152
+ AuthConfig(redirect_uri="https://your.app/connected"),
153
+ )
154
+ print(f"Open this URL to connect Stripe: {link.authorize_url}")
155
+ ```
156
+
157
+ After the user completes the OAuth flow, CodeSpar stores the tokens in
158
+ the per-project vault and forwards them back to `redirect_uri` with
159
+ `?status=connected&connection_id=<id>` appended.
160
+
161
+ ## Errors
162
+
163
+ Every failure is wrapped:
164
+
165
+ ```python
166
+ from codespar import ApiError, ConfigError, StreamError
167
+
168
+ try:
169
+ session = cs.create("user_123", preset="brazilian")
170
+ except ConfigError as exc:
171
+ print(f"Bad config: {exc}")
172
+ except ApiError as exc:
173
+ print(f"Backend said {exc.status}: {exc.code}")
174
+ ```
175
+
176
+ ## Design parity with the JS SDK
177
+
178
+ This package mirrors [`@codespar/sdk`](https://www.npmjs.com/package/@codespar/sdk)
179
+ method-for-method. Same backend, same payloads, same preset names — pick
180
+ the language that fits your stack without giving anything up.
181
+
182
+ ## Links
183
+
184
+ - [Documentation](https://docs.codespar.dev)
185
+ - [Dashboard](https://dashboard.codespar.dev)
186
+ - [JS SDK (npm)](https://www.npmjs.com/package/@codespar/sdk)
187
+ - [Report a bug](https://github.com/codespar/codespar/issues)
@@ -0,0 +1,156 @@
1
+ # codespar — Python SDK
2
+
3
+ Commerce infrastructure for AI agents in Latin America. Pix, NF-e,
4
+ WhatsApp, shipping, banking — one API, no provider-key boilerplate.
5
+
6
+ [![PyPI](https://img.shields.io/pypi/v/codespar.svg)](https://pypi.org/project/codespar/)
7
+ [![Python versions](https://img.shields.io/pypi/pyversions/codespar.svg)](https://pypi.org/project/codespar/)
8
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/codespar/codespar/blob/main/LICENSE)
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install codespar
14
+ ```
15
+
16
+ Python 3.10+ required.
17
+
18
+ ## Quick start
19
+
20
+ ```python
21
+ from codespar import CodeSpar
22
+
23
+ cs = CodeSpar(api_key="csk_live_...")
24
+
25
+ session = cs.create(
26
+ "user_123",
27
+ preset="brazilian", # zoop, nuvem-fiscal, melhor-envio, z-api, omie
28
+ # project_id="prj_...", # optional — defaults to the org's default project
29
+ )
30
+
31
+ result = session.send(
32
+ "Charge R$500 via Pix to +5511999887766 and send the QR code by WhatsApp."
33
+ )
34
+ print(result.message)
35
+ for call in result.tool_calls:
36
+ print(f" → {call.tool_name} ({call.duration_ms}ms)")
37
+
38
+ session.close()
39
+ cs.close()
40
+ ```
41
+
42
+ Or as a context manager:
43
+
44
+ ```python
45
+ with CodeSpar(api_key="csk_live_...") as cs:
46
+ session = cs.create("user_123", preset="brazilian")
47
+ print(session.send("Quero pagar R$125 via Pix").message)
48
+ ```
49
+
50
+ ## Streaming
51
+
52
+ ```python
53
+ for event in session.send_stream("Process order #BR-7721"):
54
+ if event.type == "assistant_text":
55
+ print(event.content, end="", flush=True)
56
+ elif event.type == "tool_use":
57
+ print(f"\n→ calling {event.name}...")
58
+ elif event.type == "tool_result":
59
+ print(f" {event.tool_call.status} in {event.tool_call.duration_ms}ms")
60
+ ```
61
+
62
+ ## Async
63
+
64
+ ```python
65
+ import asyncio
66
+ from codespar import AsyncCodeSpar
67
+
68
+ async def main():
69
+ async with AsyncCodeSpar(api_key="csk_live_...") as cs:
70
+ session = await cs.create("user_123", preset="brazilian")
71
+ result = await session.send("charge R$500 via Pix")
72
+ print(result.message)
73
+ await session.close()
74
+
75
+ asyncio.run(main())
76
+ ```
77
+
78
+ ## Multi-environment (projects)
79
+
80
+ CodeSpar scopes every session to an environment (`prj_<id>`). Pass a
81
+ project id on the client for the whole lifetime, or per-session when
82
+ you want to target a different environment:
83
+
84
+ ```python
85
+ # Pin every session this client spawns to the staging project
86
+ cs = CodeSpar(api_key="csk_live_...", project_id="prj_staging0123abcd")
87
+
88
+ # Override per session
89
+ session = cs.create("user_123", preset="brazilian", project_id="prj_prod0123abcd")
90
+ ```
91
+
92
+ When you omit `project_id`, CodeSpar routes to the org's **default
93
+ project** — always defined, self-healed on first read.
94
+
95
+ ## Raw HTTP proxy
96
+
97
+ Skip the agent loop and hit a provider API directly through CodeSpar's
98
+ credential vault:
99
+
100
+ ```python
101
+ from codespar import ProxyRequest
102
+
103
+ response = session.proxy_execute(ProxyRequest(
104
+ server="stripe-acp",
105
+ endpoint="/v1/charges",
106
+ method="POST",
107
+ body={"amount": 2000, "currency": "brl"},
108
+ ))
109
+ print(response.status, response.data)
110
+ ```
111
+
112
+ No API key leaves your machine — CodeSpar injects it server-side.
113
+
114
+ ## Connect Links (OAuth)
115
+
116
+ ```python
117
+ from codespar import AuthConfig
118
+
119
+ link = session.authorize(
120
+ "stripe-acp",
121
+ AuthConfig(redirect_uri="https://your.app/connected"),
122
+ )
123
+ print(f"Open this URL to connect Stripe: {link.authorize_url}")
124
+ ```
125
+
126
+ After the user completes the OAuth flow, CodeSpar stores the tokens in
127
+ the per-project vault and forwards them back to `redirect_uri` with
128
+ `?status=connected&connection_id=<id>` appended.
129
+
130
+ ## Errors
131
+
132
+ Every failure is wrapped:
133
+
134
+ ```python
135
+ from codespar import ApiError, ConfigError, StreamError
136
+
137
+ try:
138
+ session = cs.create("user_123", preset="brazilian")
139
+ except ConfigError as exc:
140
+ print(f"Bad config: {exc}")
141
+ except ApiError as exc:
142
+ print(f"Backend said {exc.status}: {exc.code}")
143
+ ```
144
+
145
+ ## Design parity with the JS SDK
146
+
147
+ This package mirrors [`@codespar/sdk`](https://www.npmjs.com/package/@codespar/sdk)
148
+ method-for-method. Same backend, same payloads, same preset names — pick
149
+ the language that fits your stack without giving anything up.
150
+
151
+ ## Links
152
+
153
+ - [Documentation](https://docs.codespar.dev)
154
+ - [Dashboard](https://dashboard.codespar.dev)
155
+ - [JS SDK (npm)](https://www.npmjs.com/package/@codespar/sdk)
156
+ - [Report a bug](https://github.com/codespar/codespar/issues)
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "codespar"
7
+ version = "0.1.0"
8
+ description = "Python SDK for CodeSpar — commerce infrastructure for AI agents in Latin America."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "CodeSpar", email = "hello@codespar.dev" },
14
+ ]
15
+ keywords = ["ai", "agents", "commerce", "latam", "mcp", "stripe", "pix", "nfe"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ ]
28
+ dependencies = [
29
+ "httpx>=0.27.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=8.0",
35
+ "pytest-asyncio>=0.23",
36
+ "pytest-httpx>=0.30",
37
+ "ruff>=0.6",
38
+ "mypy>=1.11",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://codespar.dev"
43
+ Documentation = "https://docs.codespar.dev"
44
+ Repository = "https://github.com/codespar/codespar"
45
+ Issues = "https://github.com/codespar/codespar/issues"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/codespar"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ asyncio_mode = "auto"
53
+
54
+ [tool.ruff]
55
+ line-length = 100
56
+ target-version = "py310"
57
+
58
+ [tool.ruff.lint]
59
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
60
+
61
+ [tool.mypy]
62
+ python_version = "3.10"
63
+ strict = true
64
+ warn_return_any = true
65
+ warn_unused_ignores = true
@@ -0,0 +1,107 @@
1
+ """
2
+ CodeSpar Python SDK — commerce infrastructure for AI agents in Latin America.
3
+
4
+ Two import surfaces:
5
+
6
+ * ``CodeSpar`` — sync client. Use from scripts, Jupyter, sync web
7
+ frameworks (Flask, Django views).
8
+ * ``AsyncCodeSpar`` — async client. Use from FastAPI, LangChain,
9
+ anything already running on asyncio.
10
+
11
+ Both wrap the same backend (``api.codespar.dev``) and expose the same
12
+ session API, so you can start with sync and upgrade to async without
13
+ changing the surrounding code.
14
+
15
+ Quick start::
16
+
17
+ from codespar import CodeSpar
18
+
19
+ cs = CodeSpar(api_key="csk_live_...")
20
+ session = cs.create("user_123", preset="brazilian")
21
+ result = session.send("Charge R$500 via Pix to +5511999887766")
22
+ print(result.message)
23
+ session.close()
24
+ cs.close()
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from ._async_client import AsyncCodeSpar
30
+ from ._async_session import AsyncSession
31
+ from ._sync_client import CodeSpar, Session
32
+ from .errors import (
33
+ ApiError,
34
+ CodeSparError,
35
+ ConfigError,
36
+ NotConnectedError,
37
+ StreamError,
38
+ )
39
+ from .types import (
40
+ AssistantTextEvent,
41
+ AuthConfig,
42
+ AuthResult,
43
+ DoneEvent,
44
+ ErrorEvent,
45
+ HttpMethod,
46
+ ManageConnections,
47
+ Preset,
48
+ ProxyRequest,
49
+ ProxyResult,
50
+ SendResult,
51
+ ServerConnection,
52
+ SessionConfig,
53
+ SessionInfo,
54
+ SessionStatus,
55
+ StreamEvent,
56
+ Tool,
57
+ ToolCallRecord,
58
+ ToolResult,
59
+ ToolResultEvent,
60
+ ToolUseEvent,
61
+ UserMessageEvent,
62
+ )
63
+
64
+ __version__ = "0.1.0"
65
+
66
+ __all__ = [
67
+ "ApiError",
68
+ "AssistantTextEvent",
69
+ "AsyncCodeSpar",
70
+ "AsyncSession",
71
+ # Connect Links
72
+ "AuthConfig",
73
+ "AuthResult",
74
+ # Clients
75
+ "CodeSpar",
76
+ # Errors
77
+ "CodeSparError",
78
+ "ConfigError",
79
+ "DoneEvent",
80
+ "ErrorEvent",
81
+ "HttpMethod",
82
+ "ManageConnections",
83
+ "NotConnectedError",
84
+ "Preset",
85
+ # Proxy
86
+ "ProxyRequest",
87
+ "ProxyResult",
88
+ "SendResult",
89
+ "ServerConnection",
90
+ "Session",
91
+ # Configuration
92
+ "SessionConfig",
93
+ # Session output
94
+ "SessionInfo",
95
+ "SessionStatus",
96
+ "StreamError",
97
+ # Streaming events
98
+ "StreamEvent",
99
+ "Tool",
100
+ "ToolCallRecord",
101
+ "ToolResult",
102
+ "ToolResultEvent",
103
+ "ToolUseEvent",
104
+ "UserMessageEvent",
105
+ # Version
106
+ "__version__",
107
+ ]
@@ -0,0 +1,178 @@
1
+ """
2
+ ``AsyncCodeSpar`` — the canonical client class.
3
+
4
+ Holds an httpx.AsyncClient, exposes ``create(user_id, ...)`` to start a
5
+ session, and mirrors the TS ``CodeSpar`` constructor 1:1. The sync
6
+ ``CodeSpar`` in ``_sync_client.py`` wraps every call through
7
+ ``asyncio.run`` so the lightweight use-case works without the caller
8
+ having to write ``async def``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from types import TracebackType
14
+
15
+ import httpx
16
+
17
+ from ._async_session import (
18
+ AsyncSession,
19
+ build_session_info,
20
+ wait_for_connections,
21
+ )
22
+ from ._http import DEFAULT_BASE_URL, request_json
23
+ from ._presets import preset_to_servers
24
+ from .errors import ApiError, ConfigError
25
+ from .types import SessionConfig
26
+
27
+
28
+ class AsyncCodeSpar:
29
+ """
30
+ Async CodeSpar client. Pass an API key, create sessions, run them,
31
+ close them. One client can spawn many sessions in parallel.
32
+
33
+ Example::
34
+
35
+ async with AsyncCodeSpar(api_key="csk_live_...") as cs:
36
+ session = await cs.create("user_123", preset="brazilian")
37
+ result = await session.send("charge R$500 via Pix")
38
+ print(result.message)
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ api_key: str,
45
+ base_url: str = DEFAULT_BASE_URL,
46
+ project_id: str | None = None,
47
+ timeout: float = 60.0,
48
+ client: httpx.AsyncClient | None = None,
49
+ ) -> None:
50
+ if not api_key or not api_key.startswith("csk_"):
51
+ raise ConfigError(
52
+ "api_key is required and must start with 'csk_'. "
53
+ "Get one from https://dashboard.codespar.dev."
54
+ )
55
+ self._api_key = api_key
56
+ self._base_url = base_url.rstrip("/")
57
+ self._project_id = project_id
58
+ # Share one transport across every session spawned by this
59
+ # client. Closing the client closes every in-flight request.
60
+ self._owns_client = client is None
61
+ self._client = client or httpx.AsyncClient(
62
+ base_url=self._base_url,
63
+ timeout=timeout,
64
+ )
65
+
66
+ @property
67
+ def base_url(self) -> str:
68
+ return self._base_url
69
+
70
+ @property
71
+ def project_id(self) -> str | None:
72
+ return self._project_id
73
+
74
+ async def create(
75
+ self,
76
+ user_id: str,
77
+ config: SessionConfig | None = None,
78
+ /,
79
+ **kwargs: object,
80
+ ) -> AsyncSession:
81
+ """
82
+ Start a session scoped to ``user_id``.
83
+
84
+ ``config`` can be passed as a ``SessionConfig`` dataclass or as
85
+ keyword arguments — both shapes work::
86
+
87
+ await cs.create("user_123", preset="brazilian")
88
+ await cs.create("user_123", SessionConfig(preset="brazilian"))
89
+ """
90
+ resolved = self._resolve_config(config, kwargs)
91
+ servers = resolved.servers or preset_to_servers(resolved.preset)
92
+ project_id = resolved.project_id or self._project_id
93
+
94
+ body: dict[str, object] = {"servers": servers, "user_id": user_id}
95
+ if resolved.metadata:
96
+ body["metadata"] = resolved.metadata
97
+
98
+ data = await request_json(
99
+ self._client,
100
+ "POST",
101
+ "/v1/sessions",
102
+ api_key=self._api_key,
103
+ project_id=project_id,
104
+ body=body,
105
+ )
106
+ if not isinstance(data, dict):
107
+ raise ApiError("create: malformed response", status=0, body=data)
108
+
109
+ info = build_session_info(
110
+ data,
111
+ base_url=self._base_url,
112
+ api_key=self._api_key,
113
+ project_id=project_id,
114
+ )
115
+ session = AsyncSession(
116
+ info=info,
117
+ client=self._client,
118
+ api_key=self._api_key,
119
+ project_id=project_id,
120
+ base_url=self._base_url,
121
+ )
122
+
123
+ if resolved.manage_connections and resolved.manage_connections.wait_for_connections:
124
+ await wait_for_connections(
125
+ session,
126
+ timeout_ms=resolved.manage_connections.timeout,
127
+ )
128
+
129
+ return session
130
+
131
+ # ── lifecycle ───────────────────────────────────────────────────────
132
+
133
+ async def aclose(self) -> None:
134
+ """Close the underlying httpx transport."""
135
+ if self._owns_client:
136
+ await self._client.aclose()
137
+
138
+ async def __aenter__(self) -> AsyncCodeSpar:
139
+ return self
140
+
141
+ async def __aexit__(
142
+ self,
143
+ exc_type: type[BaseException] | None,
144
+ exc: BaseException | None,
145
+ tb: TracebackType | None,
146
+ ) -> None:
147
+ await self.aclose()
148
+
149
+ # ── internals ───────────────────────────────────────────────────────
150
+
151
+ def _resolve_config(
152
+ self,
153
+ config: SessionConfig | None,
154
+ kwargs: dict[str, object],
155
+ ) -> SessionConfig:
156
+ """Accept either a SessionConfig dataclass or kwargs, never both."""
157
+ if config is not None and kwargs:
158
+ raise ConfigError(
159
+ "Pass SessionConfig or keyword arguments, not both."
160
+ )
161
+ if config is not None:
162
+ return config
163
+ if not kwargs:
164
+ return SessionConfig()
165
+
166
+ allowed = {
167
+ "servers",
168
+ "preset",
169
+ "manage_connections",
170
+ "metadata",
171
+ "project_id",
172
+ }
173
+ unknown = set(kwargs) - allowed
174
+ if unknown:
175
+ raise ConfigError(
176
+ f"create(): unknown keyword argument(s): {', '.join(sorted(unknown))}"
177
+ )
178
+ return SessionConfig(**kwargs) # type: ignore[arg-type]