ailogger-client-dev 0.1.51__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.
@@ -0,0 +1,96 @@
1
+ """
2
+ ailogger service client.
3
+
4
+ This package provides the client for the ai-logger service, distributed and
5
+ installed independently of the service itself. It is a wrapper around the
6
+ adapter framework's ``JsonRpcClient`` (from ``mcp_proxy_adapter.client.jsonrpc_client``)
7
+ and adds only the dialogue vocabulary of the ai-logger service: the command
8
+ methods, the generic call surface and the typed error vocabulary.
9
+
10
+ The wrapper is constructed from a named section of the consuming process's
11
+ configuration document - the same sectioned shape every fleet client in the
12
+ ai-logger fleet uses: a top-level ``protocol``, a ``server`` sub-section
13
+ carrying host and port, a ``client`` sub-section that may carry ssl material
14
+ overriding the server's own, a top-level ``ssl`` sub-section used as a
15
+ fallback, and an ``auth`` sub-section carrying a token and the header it
16
+ travels in.
17
+
18
+ Transport, request serialization, response parsing, retries, job tracking,
19
+ queue operations, file transfer with integrity checking and connection
20
+ management all belong to the adapter framework's client: this package wraps
21
+ that client and adds only the dialogue vocabulary on top.
22
+
23
+ A-009 SECTION: Published Surface
24
+
25
+ The package's public surface is declared explicitly by ``__all__`` and holds
26
+ the client class, the construction entry points, and the complete typed error
27
+ vocabulary. A caller that wants to catch everything this client can raise
28
+ catches the single base kind (``AiLoggerClientError``), and every specific kind
29
+ is reachable from the package root. This closes the vocabulary: a consumer
30
+ never reaches into a private module path to catch a typed error of this client.
31
+
32
+ Deliberately unpublished from the package root (but still importable via
33
+ submodule paths like ``ailogger_client.errors`` or ``ailogger_client.client``):
34
+ the adapter framework's client class and its entire transport and exception
35
+ hierarchy; the nine mixin classes (InvocationMixin, QueuedMixin,
36
+ ObservationMixin, SessionMixin, MessageMixin, ReadMixin, RetrievalMixin,
37
+ ServiceMixin, FileTransferMixin)
38
+ that compose the client; the translation table and its supporting machinery
39
+ (``translate_framework_failure``, ``translated_failures``, ``refusal_from_answer``);
40
+ and the private attribute holding the framework client. This design keeps
41
+ this package a wrapper over the framework rather than a second way of
42
+ importing it, and ensures that transport details remain behind the client's
43
+ boundary.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from .client import AiLoggerClient
49
+ from .config import (
50
+ ConfigError,
51
+ load_client_section,
52
+ load_config_kwargs,
53
+ load_config_kwargs_from_document,
54
+ )
55
+ from .errors import (
56
+ AiLoggerClientError,
57
+ CompletionFailedError,
58
+ CompletionTimeoutError,
59
+ DialogueFileTransferError,
60
+ InadmissibleArgumentValueError,
61
+ MissingArgumentError,
62
+ QueuedCompletionError,
63
+ QueuedProtocolError,
64
+ RequestRejectedError,
65
+ ResultUnavailableError,
66
+ SchemaDisagreementError,
67
+ ServiceRefusalError,
68
+ ServiceUnreachableError,
69
+ SyncFallbackWaitExhaustedError,
70
+ UnknownCommandError,
71
+ WrongArgumentTypeError,
72
+ )
73
+
74
+ __all__ = [
75
+ "AiLoggerClient",
76
+ "AiLoggerClientError",
77
+ "CompletionFailedError",
78
+ "CompletionTimeoutError",
79
+ "ConfigError",
80
+ "DialogueFileTransferError",
81
+ "InadmissibleArgumentValueError",
82
+ "MissingArgumentError",
83
+ "QueuedCompletionError",
84
+ "QueuedProtocolError",
85
+ "RequestRejectedError",
86
+ "ResultUnavailableError",
87
+ "SchemaDisagreementError",
88
+ "ServiceRefusalError",
89
+ "ServiceUnreachableError",
90
+ "SyncFallbackWaitExhaustedError",
91
+ "UnknownCommandError",
92
+ "WrongArgumentTypeError",
93
+ "load_client_section",
94
+ "load_config_kwargs",
95
+ "load_config_kwargs_from_document",
96
+ ]
@@ -0,0 +1,220 @@
1
+ """
2
+ Client for the ai-logger service.
3
+
4
+ This module declares the client class of the ai-logger service: the construction
5
+ and encapsulation boundary before any command of the surface reaches it.
6
+
7
+ CONSTRUCTION SHAPE. The client is built from its own named section of the
8
+ configuration document of the process that uses it, in the sectioned fleet shape,
9
+ by handing that section to the configuration translation of ailogger_client.config
10
+ and passing the resulting keyword arguments straight to the framework client's
11
+ constructor. Two construction routes are offered: from a section already read as
12
+ a mapping, and from a configuration document identified by path together with the
13
+ name of the section to read. Both routes end in the same single place where the
14
+ framework client is built, so there is exactly one construction path in the class.
15
+
16
+ ENCAPSULATION. The client holds an instance of JsonRpcClient from
17
+ mcp_proxy_adapter.client.jsonrpc_client on a private attribute. The held instance
18
+ leaves the object by no route: no public attribute, no property, no accessor method,
19
+ no return value, no argument default, no representation string or attribute-forwarding
20
+ hook. A caller holding this client cannot reach a connection through it. Inside the
21
+ package a single private route to the held instance exists, so the typed methods
22
+ have somewhere to call through without the attribute becoming part of the caller-facing
23
+ surface.
24
+
25
+ LIFECYCLE. Whatever opening and closing the client needs is delegated to the held
26
+ instance. The client supports use as an asynchronous context manager, so entering
27
+ and leaving a client is entering and leaving what the framework client already
28
+ provides. Closing twice is harmless.
29
+
30
+ CALLER SURFACE. No host, no connection, no retry, no status code and no transport
31
+ envelope appears in any argument, in any return value or in any readable attribute
32
+ of this class. The caller works only in the terms the service itself publishes.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ from collections.abc import Mapping
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ from mcp_proxy_adapter.client.jsonrpc_client import JsonRpcClient
43
+
44
+ from .config import (
45
+ ConfigError,
46
+ load_client_section,
47
+ load_config_kwargs,
48
+ load_sync_fallback_wait,
49
+ )
50
+ from .file_transfer import FileTransferMixin
51
+ from .invocation import InvocationMixin
52
+ from .message_methods import MessageMixin
53
+ from .observation_methods import ObservationMixin
54
+ from .queued_methods import QueuedMixin
55
+ from .read_methods import ReadMixin
56
+ from .retrieval_methods import RetrievalMixin
57
+ from .service_methods import ServiceMixin
58
+ from .session_methods import SessionMixin
59
+
60
+ __all__ = ["AiLoggerClient"]
61
+
62
+
63
+ class AiLoggerClient(
64
+ InvocationMixin,
65
+ QueuedMixin,
66
+ ObservationMixin,
67
+ SessionMixin,
68
+ MessageMixin,
69
+ ReadMixin,
70
+ RetrievalMixin,
71
+ ServiceMixin,
72
+ FileTransferMixin,
73
+ ):
74
+ """
75
+ Wrapper over the installed adapter framework's JsonRpcClient, adding the
76
+ dialogue vocabulary of the ai-logger service.
77
+
78
+ The client is constructed from the consuming process's configuration document,
79
+ in the sectioned fleet shape. It holds an instance of JsonRpcClient privately,
80
+ delegating all transport, connection, retry and job tracking to the framework
81
+ client.
82
+
83
+ The client supports use as an asynchronous context manager. Lifecycle operations
84
+ (enter, exit, close) are delegated to the held JsonRpcClient instance.
85
+
86
+ COMPOSITION (G-012/T-003/A-008). The client is assembled from nine mixin
87
+ classes (QueuedMixin, which owns the machinery of always-queued commands,
88
+ and ObservationMixin, which carries the probe, help, health-route and
89
+ answer-mode methods, precede the mixins whose typed methods build on
90
+ it), each contributing a set of typed methods to the caller-facing
91
+ surface:
92
+ InvocationMixin provides the private invocation seam and the generic call, plus
93
+ job observation; SessionMixin provides session lifecycle commands; MessageMixin
94
+ provides message append; ReadMixin provides the five session reads and session
95
+ listing; RetrievalMixin provides working-set assembly and similarity search;
96
+ ServiceMixin provides info and health; FileTransferMixin declares dialogue
97
+ import and export (not yet designed). Every caller-facing method reaches the
98
+ held JsonRpcClient through the single private _invoke entry point that
99
+ InvocationMixin establishes, so there is one seam and one route. The composition
100
+ is order-independent for callers: all typed methods are reachable on an instance
101
+ regardless of the base order, because each mixin declares disjoint public names.
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ section: Mapping[str, Any],
107
+ base_dir: str | None = None,
108
+ ) -> None:
109
+ """
110
+ Construct the client from a configuration section.
111
+
112
+ Args:
113
+ section: The "ailogger_client" section from the consuming process's
114
+ configuration document, as a mapping. Must carry the sectioned
115
+ fleet shape: a top-level ``protocol``, a ``server`` sub-section
116
+ with ``host`` and ``port``, optional ``client`` and ``ssl``
117
+ sub-sections, and an optional ``auth`` sub-section.
118
+
119
+ base_dir: When provided, the directory against which relative certificate
120
+ paths in the section are resolved. When absent, paths are resolved
121
+ against the calling process's current working directory.
122
+
123
+ Raises:
124
+ ConfigError: If the section is missing required keys or if any key
125
+ has the wrong type, including ``client.sync_fallback_wait_seconds``
126
+ that is not a number greater than zero.
127
+ """
128
+ kwargs = load_config_kwargs(section, base_dir)
129
+ self._sync_fallback_wait_seconds: float = load_sync_fallback_wait(section)
130
+ self._client: JsonRpcClient = JsonRpcClient(**kwargs)
131
+
132
+ @classmethod
133
+ def from_document_path(
134
+ cls,
135
+ path: str | Path,
136
+ section_name: str = "ailogger_client",
137
+ ) -> AiLoggerClient:
138
+ """
139
+ Construct the client from a configuration document file.
140
+
141
+ Reads the specified JSON configuration file, extracts the named section,
142
+ resolves relative certificate paths against the document's directory,
143
+ and constructs the client. This is a convenience route that combines
144
+ file reading, section extraction, and client construction.
145
+
146
+ Args:
147
+ path: The filesystem path to a JSON configuration document.
148
+
149
+ section_name: The name of the section to read from the document.
150
+ Defaults to "ailogger_client". This parameter exists for
151
+ flexibility but should remain at the default for the ai-logger client.
152
+
153
+ Returns:
154
+ An AiLoggerClient instance.
155
+
156
+ Raises:
157
+ ConfigError: If the document carries no section with the given name
158
+ or if the section is malformed.
159
+ FileNotFoundError: If the file does not exist.
160
+ json.JSONDecodeError: If the file is not valid JSON.
161
+ """
162
+ path_obj = Path(path).resolve()
163
+ with open(path_obj) as f:
164
+ document = json.load(f)
165
+
166
+ base_dir = str(path_obj.parent)
167
+
168
+ # Extract the section from the document
169
+ if section_name == "ailogger_client":
170
+ section = load_client_section(document)
171
+ else:
172
+ section = document.get(section_name)
173
+ if not isinstance(section, Mapping):
174
+ raise ConfigError(
175
+ f"configuration document carries no '{section_name}' "
176
+ "section for this client"
177
+ )
178
+
179
+ # Use the main constructor (single construction path)
180
+ return cls(section, base_dir=base_dir)
181
+
182
+ async def close(self) -> None:
183
+ """
184
+ Close the underlying transport and release resources.
185
+
186
+ Delegates to the held JsonRpcClient's close method. Closing twice
187
+ is harmless; the underlying client handles idempotency.
188
+ """
189
+ await self._client.close()
190
+
191
+ async def __aenter__(self) -> AiLoggerClient:
192
+ """
193
+ Async context manager entry.
194
+
195
+ Returns self for use in `async with` statements. The held JsonRpcClient
196
+ does not require explicit initialization, so no setup is performed here.
197
+ """
198
+ return self
199
+
200
+ async def __aexit__(
201
+ self,
202
+ exc_type: type[BaseException] | None,
203
+ exc_val: BaseException | None,
204
+ exc_tb: Any,
205
+ ) -> None:
206
+ """
207
+ Async context manager exit.
208
+
209
+ Closes the underlying transport and releases resources by delegating
210
+ to close().
211
+ """
212
+ await self.close()
213
+
214
+ def __repr__(self) -> str:
215
+ """
216
+ Return a string representation of the client.
217
+
218
+ Does not expose the held instance or any transport details.
219
+ """
220
+ return f"{self.__class__.__name__}()"
@@ -0,0 +1,386 @@
1
+ """
2
+ Configuration loading for the ai-logger client.
3
+
4
+ This module reads the ai-logger client section from a consuming process's
5
+ configuration document and produces the keyword arguments the JsonRpcClient
6
+ constructor requires. It is the only place in this client package where a
7
+ configuration value is read.
8
+
9
+ CONSTRUCTION SHAPE. The client section is named "ailogger_client" and carries:
10
+ a top-level ``protocol`` (http, https, or mtls), a ``server`` sub-section with
11
+ ``host`` and ``port``, a ``client`` sub-section that may carry ssl material
12
+ overriding the server's own, a top-level ``ssl`` sub-section used as a fallback,
13
+ and an ``auth`` sub-section carrying a ``token`` and the HTTP header name
14
+ ``token_header`` the token travels in.
15
+
16
+ The section is read from the configuration document of the consuming process,
17
+ never from the service's own document: a consumer runs in another process and
18
+ often on another host, so reading the service's own configuration would
19
+ introduce a second notion of where the service lives.
20
+
21
+ WHAT THIS MODULE PRODUCES. Exactly the keyword arguments the JsonRpcClient
22
+ constructor takes: protocol, host, port, token_header, token, cert, key, ca,
23
+ check_hostname and timeout. Certificate material is passed as absolute filesystem
24
+ paths for the client to use, never loaded or parsed here. The set is derived
25
+ from the JsonRpcClient constructor signature, verified at module load time.
26
+
27
+ WHAT THIS MODULE MUST NOT DO. It never builds a URL, base address or endpoint
28
+ string. It never loads, parses or turns certificate material into a security
29
+ context. It never opens a connection, socket or session. It reads no environment
30
+ variable to locate the service and writes no service address into itself. It
31
+ imports nothing from the service's own package, and makes no network call.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import inspect
37
+ import math
38
+ import os
39
+ from collections.abc import Mapping
40
+ from pathlib import Path
41
+ from typing import Any, Final
42
+
43
+ from mcp_proxy_adapter.client.jsonrpc_client import JsonRpcClient
44
+
45
+ __all__ = [
46
+ "SYNC_FALLBACK_WAIT_SECONDS",
47
+ "ConfigError",
48
+ "load_client_section",
49
+ "load_config_kwargs",
50
+ "load_config_kwargs_from_document",
51
+ "load_sync_fallback_wait",
52
+ ]
53
+
54
+
55
+ # The client owns these existing constructor parameters. The adapter may add
56
+ # explicitly allowed optional keyword-only extensions without breaking import.
57
+ _REQUIRED_KWARGS = frozenset(
58
+ {
59
+ "protocol",
60
+ "host",
61
+ "port",
62
+ "token_header",
63
+ "token",
64
+ "cert",
65
+ "key",
66
+ "ca",
67
+ "check_hostname",
68
+ "timeout",
69
+ }
70
+ )
71
+ # http_socket_options: adapter 8.11.3 and later. ws_config: adapter 8.11.9 and
72
+ # later. The client passes neither; each is optional on both adapter lines.
73
+ _OPTIONAL_EXTENSION_KWARGS = frozenset({"http_socket_options", "ws_config"})
74
+ _LIVE_SIGNATURE = inspect.signature(JsonRpcClient.__init__)
75
+ _LIVE_KWARGS = frozenset(_LIVE_SIGNATURE.parameters) - {"self"}
76
+ _UNEXPECTED_KWARGS = _LIVE_KWARGS - _REQUIRED_KWARGS - _OPTIONAL_EXTENSION_KWARGS
77
+ _MISSING_KWARGS = _REQUIRED_KWARGS - _LIVE_KWARGS
78
+ _LIVE_OPTIONAL_EXTENSIONS = tuple(
79
+ _LIVE_SIGNATURE.parameters[name]
80
+ for name in sorted(_OPTIONAL_EXTENSION_KWARGS)
81
+ if name in _LIVE_SIGNATURE.parameters
82
+ )
83
+
84
+ if (
85
+ _MISSING_KWARGS
86
+ or _UNEXPECTED_KWARGS
87
+ or any(
88
+ extension.kind is not inspect.Parameter.KEYWORD_ONLY
89
+ or extension.default is inspect.Parameter.empty
90
+ for extension in _LIVE_OPTIONAL_EXTENSIONS
91
+ )
92
+ ):
93
+ raise RuntimeError(
94
+ "JsonRpcClient constructor signature has diverged from supported kwargs. "
95
+ f"Required: {sorted(_REQUIRED_KWARGS)}, "
96
+ f"Allowed optional extensions: {sorted(_OPTIONAL_EXTENSION_KWARGS)}, "
97
+ f"Live: {sorted(_LIVE_KWARGS)}"
98
+ )
99
+
100
+
101
+ #: How long the client waits, in seconds, for the queued job of a command that
102
+ #: overran the service's sync cap, when ``client.sync_fallback_wait_seconds`` is
103
+ #: not configured.
104
+ SYNC_FALLBACK_WAIT_SECONDS: Final[float] = 600.0
105
+
106
+
107
+ class ConfigError(Exception):
108
+ """Raised when configuration is invalid or missing."""
109
+
110
+ pass
111
+
112
+
113
+ def _get_nested(section: Mapping[str, Any], *keys: str) -> Any:
114
+ """
115
+ Read a nested value from ``section``, returning None if any level is
116
+ absent or non-mapping.
117
+ """
118
+ node: Any = section
119
+ for key in keys:
120
+ if not isinstance(node, Mapping):
121
+ return None
122
+ node = node.get(key)
123
+ return node
124
+
125
+
126
+ def _to_absolute_path(path: str | None, base_dir: str | None = None) -> str | None:
127
+ """
128
+ Convert a path to an absolute filesystem path. Returns None if path is None.
129
+
130
+ When ``base_dir`` is provided, paths are resolved relative to that directory,
131
+ making the result independent of the calling process's working directory.
132
+
133
+ When ``base_dir`` is None (bare mapping route with no document context),
134
+ paths are resolved relative to the current working directory. In this case,
135
+ the result depends on the caller's cwd at call time.
136
+ """
137
+ if path is None:
138
+ return None
139
+ if base_dir is not None:
140
+ return str(Path(base_dir) / path).replace("//", "/")
141
+ return os.path.abspath(path)
142
+
143
+
144
+ def _extract_server_config(
145
+ section: Mapping[str, Any],
146
+ ) -> tuple[str, int, str]:
147
+ """
148
+ Extract protocol, host, and port from section. Host and port are required.
149
+ Protocol defaults to 'http' if absent.
150
+ """
151
+ protocol = section.get("protocol", "http")
152
+ if not isinstance(protocol, str):
153
+ raise ConfigError(f"protocol must be a string, got {type(protocol).__name__}")
154
+
155
+ server_section = section.get("server")
156
+ if not isinstance(server_section, Mapping):
157
+ raise ConfigError("server section must be a mapping")
158
+
159
+ host = server_section.get("host")
160
+ if not isinstance(host, str):
161
+ host_type = type(host).__name__ if host is not None else "None"
162
+ raise ConfigError(f"server.host must be a string, got {host_type}")
163
+
164
+ port = server_section.get("port")
165
+ if not isinstance(port, int):
166
+ port_type = type(port).__name__ if port is not None else "None"
167
+ raise ConfigError(f"server.port must be an integer, got {port_type}")
168
+
169
+ return protocol, port, host
170
+
171
+
172
+ def _extract_timeout(client_section: Mapping[str, Any] | None) -> float | None:
173
+ """Extract timeout from client section, or None if absent."""
174
+ if not isinstance(client_section, Mapping):
175
+ return None
176
+ resolved_timeout = client_section.get("timeout")
177
+ if resolved_timeout is None:
178
+ return None
179
+ if isinstance(resolved_timeout, (int, float)):
180
+ return float(resolved_timeout)
181
+ raise ConfigError(
182
+ f"client.timeout must be a number, got {type(resolved_timeout).__name__}"
183
+ )
184
+
185
+
186
+ def load_sync_fallback_wait(section: Mapping[str, Any]) -> float:
187
+ """
188
+ The wait, in seconds, for the queued job of a command that overran the
189
+ service's sync cap: ``client.sync_fallback_wait_seconds`` of the section, or
190
+ ``SYNC_FALLBACK_WAIT_SECONDS`` when it is absent.
191
+
192
+ Raises:
193
+ ConfigError: If the configured value is not a number greater than zero
194
+ (a string, a boolean, zero, a negative number, null or a
195
+ non-finite number is refused).
196
+ """
197
+ client_section = section.get("client")
198
+ if not isinstance(client_section, Mapping):
199
+ return SYNC_FALLBACK_WAIT_SECONDS
200
+ if "sync_fallback_wait_seconds" not in client_section:
201
+ return SYNC_FALLBACK_WAIT_SECONDS
202
+ wait = client_section["sync_fallback_wait_seconds"]
203
+ if (
204
+ isinstance(wait, bool)
205
+ or not isinstance(wait, (int, float))
206
+ or not math.isfinite(wait)
207
+ or wait <= 0
208
+ ):
209
+ raise ConfigError(
210
+ "client.sync_fallback_wait_seconds must be a number greater than 0, "
211
+ f"got {wait!r}"
212
+ )
213
+ return float(wait)
214
+
215
+
216
+ def _extract_ssl_config(
217
+ section: Mapping[str, Any],
218
+ client_section: Mapping[str, Any] | None,
219
+ base_dir: str | None = None,
220
+ ) -> tuple[str | None, str | None, str | None]:
221
+ """
222
+ Extract and validate cert, key, ca from client.ssl (taking precedence)
223
+ or ssl section (fallback). Resolves paths to absolute paths.
224
+ """
225
+ cert: str | None = None
226
+ key: str | None = None
227
+ ca: str | None = None
228
+
229
+ if isinstance(client_section, Mapping):
230
+ client_ssl = client_section.get("ssl")
231
+ if isinstance(client_ssl, Mapping):
232
+ cert = client_ssl.get("cert")
233
+ key = client_ssl.get("key")
234
+ ca = client_ssl.get("ca")
235
+
236
+ ssl_section = section.get("ssl")
237
+ if isinstance(ssl_section, Mapping):
238
+ if cert is None:
239
+ cert = ssl_section.get("cert")
240
+ if key is None:
241
+ key = ssl_section.get("key")
242
+ if ca is None:
243
+ ca = ssl_section.get("ca")
244
+
245
+ # Validate types
246
+ if cert is not None and not isinstance(cert, str):
247
+ raise ConfigError(f"cert must be a string path, got {type(cert).__name__}")
248
+ if key is not None and not isinstance(key, str):
249
+ raise ConfigError(f"key must be a string path, got {type(key).__name__}")
250
+ if ca is not None and not isinstance(ca, str):
251
+ raise ConfigError(f"ca must be a string path, got {type(ca).__name__}")
252
+
253
+ cert_abs = _to_absolute_path(cert, base_dir)
254
+ key_abs = _to_absolute_path(key, base_dir)
255
+ ca_abs = _to_absolute_path(ca, base_dir)
256
+
257
+ return cert_abs, key_abs, ca_abs
258
+
259
+
260
+ def _extract_check_hostname(ssl_section: Mapping[str, Any] | None) -> bool:
261
+ """Extract check_hostname from ssl section, defaulting to False."""
262
+ if not isinstance(ssl_section, Mapping):
263
+ return False
264
+ resolved_check_hostname = ssl_section.get("check_hostname")
265
+ if resolved_check_hostname is None:
266
+ return False
267
+ if isinstance(resolved_check_hostname, bool):
268
+ return resolved_check_hostname
269
+ raise ConfigError(
270
+ f"ssl.check_hostname must be a boolean, "
271
+ f"got {type(resolved_check_hostname).__name__}"
272
+ )
273
+
274
+
275
+ def _extract_auth_config(
276
+ auth_section: Mapping[str, Any] | None,
277
+ ) -> tuple[str | None, str | None]:
278
+ """Extract token and token_header from auth section."""
279
+ if not isinstance(auth_section, Mapping):
280
+ return None, None
281
+
282
+ token = auth_section.get("token")
283
+ token_header = auth_section.get("token_header")
284
+
285
+ if token is not None and not isinstance(token, str):
286
+ raise ConfigError(f"auth.token must be a string, got {type(token).__name__}")
287
+ if token_header is not None and not isinstance(token_header, str):
288
+ token_header_type = type(token_header).__name__
289
+ raise ConfigError(
290
+ f"auth.token_header must be a string, got {token_header_type}"
291
+ )
292
+
293
+ return token, token_header
294
+
295
+
296
+ def load_client_section(document: Mapping[str, Any]) -> Mapping[str, Any]:
297
+ """
298
+ Extract the "ailogger_client" configuration section from a consuming
299
+ process's configuration document.
300
+
301
+ Raises ConfigError if the document carries no "ailogger_client" section.
302
+
303
+ Returns the section mapping for further processing.
304
+ """
305
+ section = document.get("ailogger_client")
306
+ if not isinstance(section, Mapping):
307
+ raise ConfigError(
308
+ "configuration document carries no 'ailogger_client' "
309
+ "section for this client"
310
+ )
311
+ return section
312
+
313
+
314
+ def load_config_kwargs(
315
+ section: Mapping[str, Any], base_dir: str | None = None
316
+ ) -> dict[str, Any]:
317
+ """
318
+ Read the ai-logger client configuration section and produce the keyword
319
+ arguments the JsonRpcClient constructor requires.
320
+
321
+ ``section`` must be the mapping from a consuming process's configuration
322
+ document; this function does not read a configuration file, resolve a path,
323
+ or read an environment variable itself.
324
+
325
+ ``base_dir``, when provided, is the directory against which relative
326
+ certificate paths in the section are resolved. When absent, paths are
327
+ resolved against the calling process's current working directory.
328
+
329
+ Raises ConfigError if ``section`` is missing or if required keys are absent
330
+ or have the wrong type.
331
+
332
+ Returns a dict with keys: protocol, host, port, token_header, token, cert,
333
+ key, ca, check_hostname, timeout. Each key's value is the type JsonRpcClient
334
+ expects. Certificate paths, if present, are absolute paths.
335
+ """
336
+ protocol, port, host = _extract_server_config(section)
337
+
338
+ client_section = section.get("client")
339
+ timeout = _extract_timeout(client_section)
340
+
341
+ cert, key, ca = _extract_ssl_config(section, client_section, base_dir)
342
+
343
+ ssl_section = section.get("ssl")
344
+ check_hostname = _extract_check_hostname(ssl_section)
345
+
346
+ auth_section = section.get("auth")
347
+ token, token_header = _extract_auth_config(auth_section)
348
+
349
+ # Build and return the kwargs dict
350
+ kwargs: dict[str, Any] = {
351
+ "protocol": protocol,
352
+ "host": host,
353
+ "port": port,
354
+ "token_header": token_header,
355
+ "token": token,
356
+ "cert": cert,
357
+ "key": key,
358
+ "ca": ca,
359
+ "check_hostname": check_hostname,
360
+ "timeout": timeout,
361
+ }
362
+
363
+ return kwargs
364
+
365
+
366
+ def load_config_kwargs_from_document(
367
+ document: Mapping[str, Any], base_dir: str | None = None
368
+ ) -> dict[str, Any]:
369
+ """
370
+ Extract the "ailogger_client" section from a consuming process's
371
+ configuration document and produce the keyword arguments the JsonRpcClient
372
+ constructor requires. Combines load_client_section and load_config_kwargs
373
+ in a single call.
374
+
375
+ ``base_dir``, when provided, is the directory against which relative
376
+ certificate paths are resolved. When absent, paths are resolved against
377
+ the calling process's current working directory.
378
+
379
+ Raises ConfigError if the document carries no "ailogger_client" section
380
+ or if the section is malformed.
381
+
382
+ Returns a dict with keys: protocol, host, port, token_header, token, cert,
383
+ key, ca, check_hostname, timeout.
384
+ """
385
+ section = load_client_section(document)
386
+ return load_config_kwargs(section, base_dir)