jharness-models 0.2.1__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,7 @@
1
+ """Concrete model provider adapters.
2
+
3
+ Import provider-specific adapters from their provider namespace, for example
4
+ `jharness.models.openai`.
5
+ """
6
+
7
+ __all__: list[str] = []
@@ -0,0 +1,570 @@
1
+ """Shared HTTP transport primitives for provider adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping, Sequence
7
+ from contextlib import aclosing, asynccontextmanager
8
+ from dataclasses import dataclass, field
9
+ from time import time
10
+ from typing import Any, Generic, TypeVar, cast
11
+
12
+ import httpx
13
+
14
+ from jharness.kernel import (
15
+ DeltaSink,
16
+ ModelDelta,
17
+ ModelError,
18
+ ModelErrorInfo,
19
+ ModelResponse,
20
+ RunContext,
21
+ )
22
+
23
+ _RETRYABLE_STATUS_CODES = frozenset({408, 409, 429, 500, 502, 503, 504})
24
+ _CLIENT_OPTION_NAMES = frozenset(
25
+ {
26
+ "client",
27
+ "headers",
28
+ "max_sse_event_bytes",
29
+ "max_sse_line_bytes",
30
+ "profile",
31
+ "timeout",
32
+ }
33
+ )
34
+ _DEFAULT_HTTP_TIMEOUT_SECONDS = 60.0
35
+ _DEFAULT_CONNECT_TIMEOUT_SECONDS = 10.0
36
+ _DEFAULT_MAX_SSE_LINE_BYTES = 256 * 1024
37
+ _DEFAULT_MAX_SSE_EVENT_BYTES = 1024 * 1024
38
+
39
+ ProfileT = TypeVar("ProfileT")
40
+
41
+ PayloadFactory = Callable[[], Mapping[str, object]]
42
+ HeadersFactory = Callable[[Mapping[str, object]], Mapping[str, str]]
43
+ ResponseDecoder = Callable[[Mapping[str, object]], ModelResponse]
44
+ FrameDecoder = Callable[[str | None, str], tuple[bool, Sequence[ModelDelta]]]
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class ModelClientConfig(Generic[ProfileT]):
49
+ """Validated transport configuration shared by concrete provider clients."""
50
+
51
+ base_url: str
52
+ api_key: str = field(repr=False)
53
+ model: str
54
+ profile: ProfileT
55
+ timeout: float | httpx.Timeout | None
56
+ max_sse_line_bytes: int
57
+ max_sse_event_bytes: int
58
+ headers: Mapping[str, str]
59
+ client: httpx.AsyncClient | None
60
+
61
+
62
+ def model_client_config(
63
+ *,
64
+ base_url: str,
65
+ api_key: str,
66
+ model: str,
67
+ options: Mapping[str, Any],
68
+ default_profile: ProfileT,
69
+ constructor_name: str,
70
+ ) -> ModelClientConfig[ProfileT]:
71
+ """Normalize the common constructor surface for one provider client."""
72
+
73
+ unexpected = set(options).difference(_CLIENT_OPTION_NAMES)
74
+ if unexpected:
75
+ option = min(unexpected)
76
+ raise TypeError(f"{constructor_name}() got an unexpected keyword argument {option!r}")
77
+ if not base_url:
78
+ raise ValueError("base_url must not be empty")
79
+ if not api_key:
80
+ raise ValueError("api_key must not be empty")
81
+ if not model:
82
+ raise ValueError("model must not be empty")
83
+ profile = cast(ProfileT | None, options.get("profile")) or default_profile
84
+ timeout = cast(
85
+ float | httpx.Timeout | None,
86
+ (
87
+ options["timeout"]
88
+ if "timeout" in options
89
+ else httpx.Timeout(
90
+ _DEFAULT_HTTP_TIMEOUT_SECONDS,
91
+ connect=_DEFAULT_CONNECT_TIMEOUT_SECONDS,
92
+ )
93
+ ),
94
+ )
95
+ max_sse_line_bytes = _positive_int_option(
96
+ options.get("max_sse_line_bytes", _DEFAULT_MAX_SSE_LINE_BYTES),
97
+ "max_sse_line_bytes",
98
+ )
99
+ max_sse_event_bytes = _positive_int_option(
100
+ options.get("max_sse_event_bytes", _DEFAULT_MAX_SSE_EVENT_BYTES),
101
+ "max_sse_event_bytes",
102
+ )
103
+ if max_sse_event_bytes < max_sse_line_bytes:
104
+ raise ValueError("max_sse_event_bytes must be >= max_sse_line_bytes")
105
+ return ModelClientConfig(
106
+ base_url.rstrip("/"),
107
+ api_key,
108
+ model,
109
+ profile,
110
+ timeout,
111
+ max_sse_line_bytes,
112
+ max_sse_event_bytes,
113
+ dict(cast(Mapping[str, str] | None, options.get("headers")) or {}),
114
+ cast(httpx.AsyncClient | None, options.get("client")),
115
+ )
116
+
117
+
118
+ def _positive_int_option(value: object, label: str) -> int:
119
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
120
+ raise ValueError(f"{label} must be a positive integer")
121
+ return value
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class ModelErrorPolicy:
126
+ """Model-specific details needed by the shared transport error boundary."""
127
+
128
+ provider: str
129
+ codec_error: type[ValueError]
130
+ request_id_headers: tuple[str, ...]
131
+ error_code_keys: tuple[str, ...]
132
+ additional_retryable_status_codes: frozenset[int] = frozenset()
133
+ retryable_error_codes: frozenset[str] = frozenset()
134
+ body_request_id_key: str | None = None
135
+
136
+
137
+ @dataclass(frozen=True, slots=True)
138
+ class ServerSentEvent:
139
+ """One decoded SSE frame."""
140
+
141
+ event: str | None
142
+ data: str
143
+
144
+
145
+ async def invoke_json_model(
146
+ *,
147
+ client: httpx.AsyncClient | None,
148
+ timeout: float | httpx.Timeout | None,
149
+ context: RunContext,
150
+ url: str,
151
+ payload: PayloadFactory,
152
+ headers: HeadersFactory,
153
+ decode: ResponseDecoder,
154
+ errors: ModelErrorPolicy,
155
+ response_shape_error: str,
156
+ ) -> ModelResponse:
157
+ """Execute one JSON model request through the shared provider error boundary."""
158
+
159
+ response: httpx.Response | None = None
160
+ try:
161
+ body = payload()
162
+ async with managed_async_client(client, timeout) as http:
163
+ response = await http.post(
164
+ url,
165
+ headers=headers(body),
166
+ json=body,
167
+ timeout=_effective_timeout(timeout, context),
168
+ )
169
+ await ensure_success_response(response, errors)
170
+ value: object = response.json()
171
+ if not isinstance(value, Mapping):
172
+ raise errors.codec_error(response_shape_error)
173
+ decoded = cast(Mapping[str, object], value)
174
+ if "error" in decoded:
175
+ raise ModelError(
176
+ _body_error_info(
177
+ decoded,
178
+ errors,
179
+ status_code=response.status_code,
180
+ response_text="provider response error",
181
+ request_id=response_request_id(response, errors.request_id_headers),
182
+ metadata=response_error_metadata(response),
183
+ )
184
+ )
185
+ return decode(decoded)
186
+ except (ModelError, httpx.HTTPError, ValueError) as exc:
187
+ raise _model_error(exc, response, errors) from exc
188
+
189
+
190
+ async def invoke_sse_model(
191
+ *,
192
+ client: httpx.AsyncClient | None,
193
+ timeout: float | httpx.Timeout | None,
194
+ context: RunContext,
195
+ url: str,
196
+ payload: PayloadFactory,
197
+ headers: HeadersFactory,
198
+ decode_frame: FrameDecoder,
199
+ completed_response: Callable[[], ModelResponse],
200
+ emit_delta: DeltaSink | None,
201
+ errors: ModelErrorPolicy,
202
+ incomplete_error: str,
203
+ max_sse_line_bytes: int = _DEFAULT_MAX_SSE_LINE_BYTES,
204
+ max_sse_event_bytes: int = _DEFAULT_MAX_SSE_EVENT_BYTES,
205
+ ) -> ModelResponse:
206
+ """Execute one SSE model request and return its provider-assembled response."""
207
+
208
+ steps = _decoded_sse_steps(
209
+ client=client,
210
+ timeout=timeout,
211
+ context=context,
212
+ url=url,
213
+ payload=payload,
214
+ headers=headers,
215
+ decode_frame=decode_frame,
216
+ completed_response=completed_response,
217
+ errors=errors,
218
+ incomplete_error=incomplete_error,
219
+ max_sse_line_bytes=max_sse_line_bytes,
220
+ max_sse_event_bytes=max_sse_event_bytes,
221
+ )
222
+ async with aclosing(steps):
223
+ async for deltas, completed in steps:
224
+ if emit_delta is not None:
225
+ for delta in deltas:
226
+ await emit_delta(delta)
227
+ if completed is not None:
228
+ return completed
229
+ raise RuntimeError("provider stream ended without a result")
230
+
231
+
232
+ async def _decoded_sse_steps(
233
+ *,
234
+ client: httpx.AsyncClient | None,
235
+ timeout: float | httpx.Timeout | None,
236
+ context: RunContext,
237
+ url: str,
238
+ payload: PayloadFactory,
239
+ headers: HeadersFactory,
240
+ decode_frame: FrameDecoder,
241
+ completed_response: Callable[[], ModelResponse],
242
+ errors: ModelErrorPolicy,
243
+ incomplete_error: str,
244
+ max_sse_line_bytes: int,
245
+ max_sse_event_bytes: int,
246
+ ) -> AsyncGenerator[tuple[Sequence[ModelDelta], ModelResponse | None]]:
247
+ """Own provider resources while yielding outside the host sink boundary."""
248
+
249
+ response: httpx.Response | None = None
250
+ try:
251
+ body = payload()
252
+ async with (
253
+ managed_async_client(client, timeout) as http,
254
+ http.stream(
255
+ "POST",
256
+ url,
257
+ headers=headers(body),
258
+ json=body,
259
+ timeout=_effective_timeout(timeout, context),
260
+ ) as response,
261
+ ):
262
+ await ensure_success_response(response, errors)
263
+ async for frame in iter_server_sent_events(
264
+ response,
265
+ max_line_bytes=max_sse_line_bytes,
266
+ max_event_bytes=max_sse_event_bytes,
267
+ error=errors.codec_error,
268
+ ):
269
+ done, deltas = decode_frame(frame.event, frame.data)
270
+ completed = completed_response() if done else None
271
+ yield deltas, completed
272
+ if done:
273
+ return
274
+ raise errors.codec_error(incomplete_error)
275
+ except (ModelError, httpx.HTTPError, ValueError) as exc:
276
+ raise _model_error(exc, response, errors) from exc
277
+
278
+
279
+ @asynccontextmanager
280
+ async def managed_async_client(
281
+ client: httpx.AsyncClient | None,
282
+ timeout: float | httpx.Timeout | None,
283
+ ) -> AsyncGenerator[httpx.AsyncClient]:
284
+ """Reuse an injected client or own a short-lived provider client."""
285
+
286
+ if client is not None:
287
+ yield client
288
+ return
289
+ async with httpx.AsyncClient(timeout=timeout) as owned_client:
290
+ yield owned_client
291
+
292
+
293
+ def _effective_timeout(
294
+ configured: float | httpx.Timeout | None,
295
+ context: RunContext,
296
+ ) -> float | httpx.Timeout | None:
297
+ deadline = context.deadline
298
+ if deadline is None:
299
+ return configured
300
+ remaining = deadline - time()
301
+ if remaining <= 0:
302
+ raise httpx.ReadTimeout("run deadline expired before provider request")
303
+ if configured is None:
304
+ return remaining
305
+ if isinstance(configured, httpx.Timeout):
306
+ return httpx.Timeout(
307
+ connect=_clamp_timeout_phase(configured.connect, remaining),
308
+ read=_clamp_timeout_phase(configured.read, remaining),
309
+ write=_clamp_timeout_phase(configured.write, remaining),
310
+ pool=_clamp_timeout_phase(configured.pool, remaining),
311
+ )
312
+ return min(float(configured), remaining)
313
+
314
+
315
+ def _clamp_timeout_phase(value: float | None, remaining: float) -> float:
316
+ return remaining if value is None else min(value, remaining)
317
+
318
+
319
+ async def ensure_success_response(
320
+ response: httpx.Response,
321
+ policy: ModelErrorPolicy,
322
+ ) -> None:
323
+ """Accept only 2xx responses and preserve the complete HTTP error envelope."""
324
+
325
+ if 200 <= response.status_code < 300:
326
+ return
327
+ try:
328
+ _ = response.text
329
+ except httpx.ResponseNotRead:
330
+ await response.aread()
331
+ try:
332
+ body = response.json()
333
+ except ValueError:
334
+ body = None
335
+ raise ModelError(
336
+ _body_error_info(
337
+ body,
338
+ policy,
339
+ status_code=response.status_code,
340
+ response_text=response.text or response.reason_phrase or "provider error",
341
+ request_id=response_request_id(response, policy.request_id_headers),
342
+ metadata=response_error_metadata(response),
343
+ )
344
+ )
345
+
346
+
347
+ async def iter_server_sent_events(
348
+ response: httpx.Response,
349
+ *,
350
+ max_line_bytes: int = _DEFAULT_MAX_SSE_LINE_BYTES,
351
+ max_event_bytes: int = _DEFAULT_MAX_SSE_EVENT_BYTES,
352
+ error: type[ValueError] = ValueError,
353
+ ) -> AsyncIterator[ServerSentEvent]:
354
+ """Parse an HTTP response body into SSE frames according to field boundaries."""
355
+
356
+ event_name: str | None = None
357
+ data_lines: list[str] = []
358
+ event_bytes = 0
359
+ async for line, line_bytes in _iter_sse_lines(
360
+ response,
361
+ max_line_bytes=max_line_bytes,
362
+ error=error,
363
+ ):
364
+ if line == "":
365
+ if data_lines:
366
+ yield ServerSentEvent(event=event_name, data="\n".join(data_lines))
367
+ event_name = None
368
+ data_lines = []
369
+ event_bytes = 0
370
+ continue
371
+ event_bytes += line_bytes + 1
372
+ if event_bytes > max_event_bytes:
373
+ raise error(f"SSE event exceeds the configured {max_event_bytes}-byte limit")
374
+ if line.startswith(":"):
375
+ continue
376
+ field, separator, raw_value = line.partition(":")
377
+ if not separator:
378
+ raw_value = ""
379
+ value = raw_value[1:] if raw_value.startswith(" ") else raw_value
380
+ if field == "event":
381
+ event_name = value
382
+ elif field == "data":
383
+ data_lines.append(value)
384
+ if data_lines:
385
+ yield ServerSentEvent(event=event_name, data="\n".join(data_lines))
386
+
387
+
388
+ async def _iter_sse_lines(
389
+ response: httpx.Response,
390
+ *,
391
+ max_line_bytes: int,
392
+ error: type[ValueError],
393
+ ) -> AsyncIterator[tuple[str, int]]:
394
+ pending = bytearray()
395
+ skip_lf = False
396
+ async for chunk in response.aiter_bytes():
397
+ for item in chunk:
398
+ if skip_lf:
399
+ skip_lf = False
400
+ if item == 0x0A:
401
+ continue
402
+ if item == 0x0D:
403
+ yield _decode_sse_line(pending, error), len(pending)
404
+ pending.clear()
405
+ skip_lf = True
406
+ continue
407
+ if item == 0x0A:
408
+ yield _decode_sse_line(pending, error), len(pending)
409
+ pending.clear()
410
+ continue
411
+ if len(pending) >= max_line_bytes:
412
+ raise error(f"SSE line exceeds the configured {max_line_bytes}-byte limit")
413
+ pending.append(item)
414
+ if pending:
415
+ yield _decode_sse_line(pending, error), len(pending)
416
+
417
+
418
+ def _decode_sse_line(value: bytearray, error: type[ValueError]) -> str:
419
+ try:
420
+ return value.decode("utf-8", errors="strict")
421
+ except UnicodeDecodeError as exc:
422
+ raise error("SSE stream contains invalid UTF-8") from exc
423
+
424
+
425
+ def response_error_metadata(response: httpx.Response) -> dict[str, str]:
426
+ """Return portable HTTP context that is useful beyond provider error bodies."""
427
+
428
+ metadata: dict[str, str] = {}
429
+ location = response.headers.get("location")
430
+ if location:
431
+ metadata["location"] = location
432
+ retry_after = response.headers.get("retry-after")
433
+ if retry_after:
434
+ metadata["retry_after"] = retry_after
435
+ return metadata
436
+
437
+
438
+ def response_request_id(
439
+ response: httpx.Response,
440
+ header_names: Sequence[str],
441
+ ) -> str | None:
442
+ """Return the first non-empty request identifier from provider header names."""
443
+
444
+ for header_name in header_names:
445
+ request_id = response.headers.get(header_name)
446
+ if request_id:
447
+ return request_id
448
+ return None
449
+
450
+
451
+ def stream_body_error(
452
+ body: Mapping[str, object],
453
+ policy: ModelErrorPolicy,
454
+ ) -> ModelError:
455
+ """Build the standard model error for an error carried by an SSE payload."""
456
+
457
+ return ModelError(
458
+ _body_error_info(
459
+ body,
460
+ policy,
461
+ status_code=None,
462
+ response_text="provider stream error",
463
+ request_id=None,
464
+ )
465
+ )
466
+
467
+
468
+ def decode_json_object(
469
+ data: str,
470
+ error: type[ValueError],
471
+ error_message: str,
472
+ ) -> Mapping[str, object]:
473
+ parsed: object = json.loads(data)
474
+ if not isinstance(parsed, Mapping):
475
+ raise error(error_message)
476
+ return cast(Mapping[str, object], parsed)
477
+
478
+
479
+ def _model_error(
480
+ exc: Exception,
481
+ response: httpx.Response | None,
482
+ policy: ModelErrorPolicy,
483
+ ) -> ModelError:
484
+ semantic_model_error = isinstance(exc, ModelError)
485
+ if semantic_model_error:
486
+ info = exc.info
487
+ else:
488
+ if isinstance(exc, httpx.TimeoutException):
489
+ code, retryable = "timeout", True
490
+ elif isinstance(exc, httpx.NetworkError | httpx.RemoteProtocolError | httpx.ProxyError):
491
+ code, retryable = exc.__class__.__name__, True
492
+ elif isinstance(exc, httpx.HTTPError):
493
+ code, retryable = exc.__class__.__name__, False
494
+ elif isinstance(exc, policy.codec_error):
495
+ code, retryable = "codec_error", False
496
+ elif isinstance(exc, json.JSONDecodeError):
497
+ code, retryable = "invalid_json", False
498
+ else:
499
+ code, retryable = exc.__class__.__name__, False
500
+ info = ModelErrorInfo(
501
+ message=str(exc) or exc.__class__.__name__,
502
+ provider=policy.provider,
503
+ code=code,
504
+ retryable=retryable,
505
+ )
506
+ if response is not None:
507
+ metadata = dict(info.metadata)
508
+ metadata.update(response_error_metadata(response))
509
+ response_status = response.status_code
510
+ status_code = info.status_code
511
+ if status_code is None and (not semantic_model_error or not 200 <= response_status < 300):
512
+ status_code = response_status
513
+ info = ModelErrorInfo(
514
+ message=info.message,
515
+ provider=info.provider,
516
+ code=info.code,
517
+ status_code=status_code,
518
+ retryable=info.retryable,
519
+ request_id=(
520
+ response_request_id(response, policy.request_id_headers) or info.request_id
521
+ ),
522
+ metadata=metadata,
523
+ )
524
+ return ModelError(info)
525
+
526
+
527
+ def _body_error_info(
528
+ error_body: object,
529
+ policy: ModelErrorPolicy,
530
+ *,
531
+ status_code: int | None,
532
+ response_text: str,
533
+ request_id: str | None,
534
+ metadata: Mapping[str, object] | None = None,
535
+ ) -> ModelErrorInfo:
536
+ message = response_text
537
+ code: str | None = None
538
+ body_request_id: str | None = None
539
+ if isinstance(error_body, Mapping):
540
+ error_mapping = cast(Mapping[str, object], error_body)
541
+ if policy.body_request_id_key is not None:
542
+ raw_request_id = error_mapping.get(policy.body_request_id_key)
543
+ if isinstance(raw_request_id, str) and raw_request_id:
544
+ body_request_id = raw_request_id
545
+ error_value = error_mapping.get("error")
546
+ if isinstance(error_value, Mapping):
547
+ nested = cast(Mapping[str, object], error_value)
548
+ raw_message = nested.get("message")
549
+ if isinstance(raw_message, str) and raw_message:
550
+ message = raw_message
551
+ for key in policy.error_code_keys:
552
+ item = nested.get(key)
553
+ if isinstance(item, str) and item:
554
+ code = item
555
+ break
556
+ elif isinstance(error_value, str) and error_value:
557
+ message = error_value
558
+ return ModelErrorInfo(
559
+ message=message,
560
+ provider=policy.provider,
561
+ code=code or "provider_error",
562
+ status_code=status_code,
563
+ retryable=(
564
+ status_code in _RETRYABLE_STATUS_CODES
565
+ or status_code in policy.additional_retryable_status_codes
566
+ or code in policy.retryable_error_codes
567
+ ),
568
+ request_id=request_id or body_request_id,
569
+ metadata={} if metadata is None else metadata,
570
+ )
@@ -0,0 +1,43 @@
1
+ """Small scalar guards shared by provider wire codecs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, NoReturn, cast
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class JsonValues:
12
+ """Validate common JSON scalar shapes with a provider-local error type."""
13
+
14
+ error_type: type[ValueError]
15
+
16
+ def mapping(self, value: object, label: str) -> Mapping[str, Any]:
17
+ if not isinstance(value, Mapping):
18
+ self._raise(f"{label} must be an object")
19
+ return cast(Mapping[str, Any], value)
20
+
21
+ def required_string(self, value: object, label: str) -> str:
22
+ if not isinstance(value, str):
23
+ self._raise(f"{label} must be a string")
24
+ if not value:
25
+ self._raise(f"{label} must not be empty")
26
+ return value
27
+
28
+ def optional_string(self, value: object) -> str | None:
29
+ if value is None:
30
+ return None
31
+ if not isinstance(value, str):
32
+ self._raise("expected string or null")
33
+ return value
34
+
35
+ def optional_integer(self, value: object) -> int | None:
36
+ if value is None:
37
+ return None
38
+ if isinstance(value, bool) or not isinstance(value, int):
39
+ self._raise("expected integer or null")
40
+ return value
41
+
42
+ def _raise(self, message: str) -> NoReturn:
43
+ raise self.error_type(message)
@@ -0,0 +1,42 @@
1
+ """Small validation vocabulary shared by provider profile values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from copy import deepcopy
7
+ from typing import Any, cast
8
+
9
+
10
+ def required_string(value: object, label: str) -> str:
11
+ if not isinstance(value, str) or not value:
12
+ raise ValueError(f"{label} must be a non-empty string")
13
+ return value
14
+
15
+
16
+ def copy_json_mapping(value: object, label: str) -> dict[str, Any]:
17
+ if not isinstance(value, Mapping):
18
+ raise TypeError(f"{label} must be a mapping")
19
+ result: dict[str, Any] = {}
20
+ for key, item in cast(Mapping[object, object], value).items():
21
+ if not isinstance(key, str) or not key:
22
+ raise ValueError(f"{label} keys must be non-empty strings")
23
+ result[key] = deepcopy(item)
24
+ return result
25
+
26
+
27
+ def copy_string_mapping(
28
+ value: object,
29
+ label: str,
30
+ *,
31
+ entry_description: str = "non-empty strings",
32
+ ) -> dict[str, str]:
33
+ if not isinstance(value, Mapping):
34
+ raise TypeError(f"{label} must be a mapping")
35
+ result: dict[str, str] = {}
36
+ for key, item in cast(Mapping[object, object], value).items():
37
+ if not isinstance(key, str) or not key:
38
+ raise ValueError(f"{label} keys must be {entry_description}")
39
+ if not isinstance(item, str) or not item:
40
+ raise ValueError(f"{label} values must be {entry_description}")
41
+ result[key] = item
42
+ return result