@ashx-j/lunr-ai 0.2.23 → 0.2.25

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.
Files changed (58) hide show
  1. package/dist/api/anthropic-claude-code-bridge.d.ts +6 -0
  2. package/dist/api/anthropic-claude-code-bridge.d.ts.map +1 -0
  3. package/dist/api/anthropic-claude-code-bridge.js +582 -0
  4. package/dist/api/anthropic-claude-code-bridge.js.map +1 -0
  5. package/dist/api/anthropic-messages.d.ts.map +1 -1
  6. package/dist/api/anthropic-messages.js +7 -19
  7. package/dist/api/anthropic-messages.js.map +1 -1
  8. package/dist/auth/anthropic-route.d.ts +7 -0
  9. package/dist/auth/anthropic-route.d.ts.map +1 -0
  10. package/dist/auth/anthropic-route.js +25 -0
  11. package/dist/auth/anthropic-route.js.map +1 -0
  12. package/dist/auth/oauth/anthropic.d.ts +1 -6
  13. package/dist/auth/oauth/anthropic.d.ts.map +1 -1
  14. package/dist/auth/oauth/anthropic.js +214 -277
  15. package/dist/auth/oauth/anthropic.js.map +1 -1
  16. package/dist/auth/resolve.d.ts.map +1 -1
  17. package/dist/auth/resolve.js +27 -0
  18. package/dist/auth/resolve.js.map +1 -1
  19. package/dist/auth/types.d.ts +15 -2
  20. package/dist/auth/types.d.ts.map +1 -1
  21. package/dist/auth/types.js.map +1 -1
  22. package/dist/cli.d.ts.map +1 -1
  23. package/dist/cli.js +2 -0
  24. package/dist/cli.js.map +1 -1
  25. package/dist/env-api-keys.d.ts.map +1 -1
  26. package/dist/env-api-keys.js +1 -2
  27. package/dist/env-api-keys.js.map +1 -1
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/models.d.ts.map +1 -1
  33. package/dist/models.js +19 -3
  34. package/dist/models.js.map +1 -1
  35. package/dist/providers/anthropic.d.ts.map +1 -1
  36. package/dist/providers/anthropic.js +32 -3
  37. package/dist/providers/anthropic.js.map +1 -1
  38. package/dist/providers/radius.d.ts.map +1 -1
  39. package/dist/providers/radius.js +5 -1
  40. package/dist/providers/radius.js.map +1 -1
  41. package/dist/types.d.ts +13 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js.map +1 -1
  44. package/package.json +15 -2
  45. package/vendor/hermes-claude-subscription-directsdk/ATTRIBUTION.md +9 -0
  46. package/vendor/hermes-claude-subscription-directsdk/LICENSE +21 -0
  47. package/vendor/hermes-claude-subscription-directsdk/README.md +179 -0
  48. package/vendor/hermes-claude-subscription-directsdk/admission.py +201 -0
  49. package/vendor/hermes-claude-subscription-directsdk/agent/reasoning_effort.py +17 -0
  50. package/vendor/hermes-claude-subscription-directsdk/directsdk.py +629 -0
  51. package/vendor/hermes-claude-subscription-directsdk/directsdk_setup.py +116 -0
  52. package/vendor/hermes-claude-subscription-directsdk/inert_mcp.py +24 -0
  53. package/vendor/hermes-claude-subscription-directsdk/lunr_bridge.py +89 -0
  54. package/vendor/hermes-claude-subscription-directsdk/lunr_setup_bridge.py +49 -0
  55. package/vendor/hermes-claude-subscription-directsdk/manifest.json +32 -0
  56. package/vendor/hermes-claude-subscription-directsdk/model_catalog.py +44 -0
  57. package/vendor/hermes-claude-subscription-directsdk/patches/0001-lunr-lifecycle-hooks.patch +14 -0
  58. package/vendor/hermes-claude-subscription-directsdk/tools/schema_sanitizer.py +23 -0
@@ -0,0 +1,116 @@
1
+ """Setup-time probes of the user's Claude CLI: login state and its own model picker.
2
+
3
+ Both are offline with respect to Anthropic: ``auth status`` reads the local credential store, and
4
+ the ``initialize`` handshake enumerates the picker without a Messages request (the admission relay
5
+ proves it by counting upstream calls). Anything unexpected returns ``None``/``False`` so callers
6
+ fall back to the pinned catalog rather than failing setup.
7
+ """
8
+ import json
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+
14
+ try:
15
+ from .admission import Admission
16
+ from .model_catalog import CONTEXT_WINDOWS, native_model
17
+ except ImportError:
18
+ from admission import Admission
19
+ from model_catalog import CONTEXT_WINDOWS, native_model
20
+
21
+ INSTALL_HINT = ("Claude Code is not installed (no `claude` on PATH). Install it with "
22
+ "`npm install -g @anthropic-ai/claude-code` or set CLAUDE_SUBSCRIPTION_DIRECTSDK_COMMAND to the binary.")
23
+ LOGIN_HINT = "Claude Code is installed but not logged in. Run `claude auth login`, then select this provider again."
24
+
25
+
26
+ def _resolve(command, env):
27
+ command = list(command) if command else [env.get("CLAUDE_SUBSCRIPTION_DIRECTSDK_COMMAND") or "claude"]
28
+ head = command[0]
29
+ exe = head if os.path.isabs(head) and os.access(head, os.X_OK) else shutil.which(head, path=env.get("PATH") or os.defpath)
30
+ return ([exe] + command[1:]) if exe else None
31
+
32
+
33
+ def _child_env(env):
34
+ child = dict(env)
35
+ config = child.pop("CLAUDE_SUBSCRIPTION_DIRECTSDK_CONFIG_DIR", None)
36
+ if config:
37
+ child["CLAUDE_CONFIG_DIR"] = config
38
+ child.update(CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1", DISABLE_TELEMETRY="1", DISABLE_ERROR_REPORTING="1")
39
+ return child
40
+
41
+
42
+ def _plan_label(raw):
43
+ """``"pro"`` (auth status) and ``"Claude Pro"`` (handshake) name the same plan."""
44
+ raw = str(raw or "").strip()
45
+ if not raw:
46
+ return ""
47
+ return raw if raw.lower().startswith("claude") else "Claude " + raw.replace("_", " ").title()
48
+
49
+
50
+ def setup_status(command=None, env=None, timeout=20):
51
+ """``{available, logged_in, plan, detail, login_command}`` from the CLI's own ``auth status``."""
52
+ env = dict(env if env is not None else os.environ)
53
+ resolved = _resolve(command, env)
54
+ if resolved is None:
55
+ return {"available": False, "logged_in": False, "plan": "", "detail": INSTALL_HINT, "login_command": None}
56
+ login_command = resolved + ["auth", "login"]
57
+ try:
58
+ run = subprocess.run(resolved + ["auth", "status"], env=_child_env(env), stdin=subprocess.DEVNULL,
59
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout)
60
+ auth = json.loads(run.stdout) if run.stdout.strip().startswith("{") else {}
61
+ except (OSError, ValueError, subprocess.SubprocessError):
62
+ auth = {}
63
+ logged_in = auth.get("loggedIn") is True
64
+ plan = str(auth.get("subscriptionType") or "")
65
+ detail = "" if logged_in else LOGIN_HINT
66
+ return {"available": True, "logged_in": logged_in, "plan": _plan_label(plan), "detail": detail,
67
+ "login_command": login_command}
68
+
69
+
70
+ def discover_models(command=None, env=None, timeout=40):
71
+ """The account's live picker as ``[{id, label, note, upstream_requests}]`` in Hermes route ids,
72
+ or ``None`` when the CLI is missing, logged out, or the handshake fails."""
73
+ env = dict(env if env is not None else os.environ)
74
+ resolved = _resolve(command, env)
75
+ # Logged out, the handshake still answers with a generic default list; only a signed-in
76
+ # account's picker reflects its entitlements.
77
+ if resolved is None or not setup_status(command=resolved, env=env, timeout=timeout)["logged_in"]:
78
+ return None
79
+ child = _child_env(env)
80
+ gate = Admission("https://api.anthropic.com", timeout)
81
+ try:
82
+ child["ANTHROPIC_BASE_URL"] = gate.url
83
+ argv = resolved + ["-p", "--model", "sonnet", "--input-format", "stream-json", "--output-format", "stream-json",
84
+ "--verbose", "--tools", "", "--setting-sources", "", "--strict-mcp-config",
85
+ "--mcp-config", '{"mcpServers":{}}', "--disable-slash-commands", "--no-session-persistence"]
86
+ handshake = json.dumps({"type": "control_request", "request_id": "hermes-picker",
87
+ "request": {"subtype": "initialize"}}) + "\n"
88
+ with tempfile.TemporaryDirectory(prefix="claude-directsdk-picker-") as cwd:
89
+ run = subprocess.run(argv, input=handshake, env=child, cwd=cwd, capture_output=True,
90
+ text=True, encoding="utf-8", errors="replace", timeout=timeout)
91
+ rows = [json.loads(line) for line in run.stdout.splitlines() if line.startswith("{")]
92
+ response = next(r["response"] for r in rows if r.get("type") == "control_response")
93
+ native = response.get("response", {}).get("models") or []
94
+ upstream = int(bool(gate.used))
95
+ except (OSError, ValueError, StopIteration, KeyError, subprocess.SubprocessError):
96
+ return None
97
+ finally:
98
+ gate.close()
99
+ if upstream or not native:
100
+ return None
101
+ # Pro / Team-standard seats bill Fable to usage credits from the first request; the CLI only
102
+ # says so at request time, so apply the documented plan rule here.
103
+ plan = str((response.get("response", {}).get("account") or {}).get("subscriptionType") or "").lower()
104
+ credit_billed_on_plan = {"claude-fable-5-1"} if plan and "max" not in plan else set()
105
+ routes = {}
106
+ for row in native:
107
+ base = str(row.get("resolvedModel") or row.get("value") or "").removesuffix("[1m]")
108
+ if base not in CONTEXT_WINDOWS:
109
+ continue
110
+ route = native_model(base)
111
+ # Model names, not the native "Default (recommended)" alias row.
112
+ label = str(row.get("description") or "").split("·")[0].strip() or route
113
+ entry = routes.setdefault(route, {"id": route, "label": label, "note": "", "upstream_requests": upstream})
114
+ if "usage credit" in str(row.get("description") or "").lower() or base in credit_billed_on_plan:
115
+ entry["note"] = "usage credits"
116
+ return list(routes.values()) or None
@@ -0,0 +1,24 @@
1
+ """Inventory only. No host imports, effects or executable tool implementations."""
2
+ import json
3
+ from pathlib import Path
4
+ import sys
5
+
6
+
7
+ def main():
8
+ manifest = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8'))
9
+ for line in sys.stdin:
10
+ row = json.loads(line)
11
+ method = row.get('method')
12
+ result = {}
13
+ if method == 'initialize':
14
+ result = {'protocolVersion': '2024-11-05', 'capabilities': {'tools': {}}, 'serverInfo': {'name': 'hermes-inert-inventory', 'version': '1'}}
15
+ elif method == 'tools/list':
16
+ result = {'tools': manifest}
17
+ elif method == 'tools/call':
18
+ result = {'isError': True, 'content': [{'type': 'text', 'text': 'Denied: native tools are inert; only Hermes executes tools.'}]}
19
+ if 'id' in row:
20
+ print(json.dumps({'jsonrpc': '2.0', 'id': row['id'], 'result': result}), flush=True)
21
+
22
+
23
+ if __name__ == '__main__':
24
+ main()
@@ -0,0 +1,89 @@
1
+ """Per-request JSON-lines bridge to the pinned, request-scoped native transport."""
2
+ import json
3
+ import sys
4
+ import threading
5
+ import subprocess
6
+ import re
7
+
8
+ from directsdk import Client
9
+ from directsdk_setup import _resolve, _child_env
10
+
11
+ MAX_RECORD = 64 * 1024 * 1024
12
+ _write_lock = threading.Lock()
13
+
14
+
15
+ def emit(request_id, kind, **fields):
16
+ with _write_lock:
17
+ sys.stdout.write(json.dumps({'v': 1, 'requestId': request_id, 'type': kind, **fields}, separators=(',', ':')) + '\n')
18
+ sys.stdout.flush()
19
+
20
+
21
+ def main():
22
+ line = sys.stdin.buffer.readline(MAX_RECORD + 1)
23
+ if not line or len(line) > MAX_RECORD:
24
+ return 2
25
+ try:
26
+ start = json.loads(line)
27
+ if start.get('v') != 1 or start.get('type') != 'start' or not isinstance(start.get('requestId'), str):
28
+ return 2
29
+ request_id = start['requestId']
30
+ command = start['command']
31
+ if not isinstance(command, str) or not command or not isinstance(start.get('messages'), list):
32
+ raise ValueError('Invalid bridge request')
33
+ resolved = _resolve([command], start['env'])
34
+ if resolved is None:
35
+ raise ValueError('Missing Claude Code CLI')
36
+ version = subprocess.run(resolved + ['--version'], env=_child_env(start['env']), stdin=subprocess.DEVNULL,
37
+ capture_output=True, text=True, timeout=20, check=True).stdout.strip()
38
+ if not re.match(r'^2\.1\.263(?:\s|$)', version):
39
+ raise ValueError('Unqualified Claude Code version')
40
+ client = Client(command=command, env=start['env'], timeout=start.get('timeout', 180),
41
+ on_spawn=lambda pid: emit(request_id, 'native_started', pid=pid),
42
+ on_replay=lambda: emit(request_id, 'replay_progress'))
43
+ except Exception:
44
+ return 2
45
+
46
+ cancelled = threading.Event()
47
+
48
+ def read_control():
49
+ while True:
50
+ control = sys.stdin.buffer.readline(MAX_RECORD + 1)
51
+ if not control:
52
+ return
53
+ try:
54
+ record = json.loads(control)
55
+ if record == {'v': 1, 'requestId': request_id, 'type': 'cancel'}:
56
+ cancelled.set()
57
+ client.close()
58
+ return
59
+ except (ValueError, UnicodeError):
60
+ cancelled.set()
61
+ client.close()
62
+ return
63
+
64
+ threading.Thread(target=read_control, daemon=True).start()
65
+ emit(request_id, 'ready')
66
+ try:
67
+ with client.create(model=start['model'], messages=start['messages'], tools=start['tools'],
68
+ extra_body=start.get('extraBody', {}), stream=True) as stream:
69
+ emit(request_id, 'start')
70
+ for chunk in stream:
71
+ delta = chunk.choices[0].delta
72
+ if delta.content:
73
+ emit(request_id, 'text_delta', text=delta.content)
74
+ if getattr(delta, 'reasoning_content', None):
75
+ emit(request_id, 'thinking_delta', text=delta.reasoning_content)
76
+ if hasattr(chunk, '_response'):
77
+ response = chunk._response.model_dump()
78
+ emit(request_id, 'complete', response=response)
79
+ return 0
80
+ except Exception:
81
+ emit(request_id, 'cancelled' if cancelled.is_set() else 'error',
82
+ message='Claude Code request failed. Check subscription setup and native CLI compatibility.')
83
+ return 1
84
+ finally:
85
+ client.close()
86
+
87
+
88
+ if __name__ == '__main__':
89
+ sys.exit(main())
@@ -0,0 +1,49 @@
1
+ """Explicit setup probes. Never imported during normal model requests."""
2
+ import json
3
+ import sys
4
+ import subprocess
5
+ import hashlib
6
+ import os
7
+ from directsdk_setup import setup_status, discover_models, _resolve, _child_env
8
+
9
+
10
+ def main():
11
+ if len(sys.argv) == 3 and sys.argv[1] == 'auth-login':
12
+ try:
13
+ resolved = _resolve([sys.argv[2]], dict(os.environ))
14
+ if resolved is None:
15
+ return 1
16
+ return subprocess.run(resolved + ['auth', 'login'], timeout=300, check=False).returncode
17
+ except (OSError, subprocess.SubprocessError):
18
+ return 1
19
+ try:
20
+ request = json.loads(sys.stdin.readline(65537))
21
+ if request.get('v') != 1 or request.get('type') not in ('status', 'discover', 'version') or not isinstance(request.get('command'), str):
22
+ return 2
23
+ command = [request['command']]
24
+ env = request['env']
25
+ if request['type'] == 'version':
26
+ resolved = _resolve(command, env)
27
+ if resolved is None:
28
+ return 1
29
+ result = subprocess.run(resolved + ['--version'], env=_child_env(env), stdin=subprocess.DEVNULL,
30
+ capture_output=True, text=True, timeout=20, check=True).stdout.strip()
31
+ elif request['type'] == 'status':
32
+ result = setup_status(command=command, env=env)
33
+ if result['logged_in']:
34
+ auth = subprocess.run(_resolve(command, env) + ['auth', 'status'], env=_child_env(env),
35
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=20, check=True)
36
+ account = json.loads(auth.stdout)
37
+ email = account.get('email', '')
38
+ if isinstance(email, str) and email:
39
+ result['accountFingerprint'] = hashlib.sha256(email.strip().lower().encode()).hexdigest()
40
+ else:
41
+ result = discover_models(command=command, env=env)
42
+ sys.stdout.write(json.dumps({'v': 1, 'requestId': request['requestId'], 'type': 'result', 'result': result}) + '\n')
43
+ return 0
44
+ except Exception:
45
+ return 1
46
+
47
+
48
+ if __name__ == '__main__':
49
+ sys.exit(main())
@@ -0,0 +1,32 @@
1
+ {
2
+ "upstream": "https://github.com/NousResearch/hermes-plugin-claude-subscription-directsdk",
3
+ "commit": "f1c1220778c7864fe4c1494baf9b1566e7c95bd2",
4
+ "license": "MIT",
5
+ "upstreamFiles": {
6
+ "LICENSE": "d1410a18033e51061f8a81279632cfe5d7e8041af30be9810a750f96edf1e0d5",
7
+ "README.md": "4c2057d1e6cb19fa078f439933b8f73d85a0f183c6651f66440e4e9d4d58e511",
8
+ "__init__.py": "01654271c9d9802122c7577ec3933c64330148e91aadd12fd711eff34cd9f7a4",
9
+ "admission.py": "a6841b870887eecc84501ab358891356ee2bb39431de68291ffd890028d82ac3",
10
+ "conftest.py": "64515322aea459826b85d0255c85779a2b11ef31a42a213b4602469d386627d4",
11
+ "directsdk.py": "d5d3d60ca59418697eaf08640eb445c25dc41ac64ee827b79008e06e8da2df54",
12
+ "directsdk_setup.py": "7b3499aea9405ff3f98a7fa25aad22053b1a89ce286d4e0e2841a880fc0dbe8c",
13
+ "evals/directsdk_admission.py": "58a7bde324a41c26889c910170f8766479b6c98e1a2c4b088b8bb69bd9cddb9a",
14
+ "evals/directsdk_cache_wire.py": "089cb380386cfe28fadd7c720e7ba609a6cb1ad2cf5a320a617b61a6ad7cfc94",
15
+ "inert_mcp.py": "6c5a0f64e4470ae90c6171bd83e2b537f641ff6d06cd76b3d49edba227274cb6",
16
+ "model_catalog.py": "5e4fa7ed95eedf243b46524abbd9771f8c4ba756cc0208f1fa9a5768a1281077",
17
+ "plugin.yaml": "6e2edc160c8a928e3393bc4e479589dfddee5291814ae930317086f2b0275067",
18
+ "pyproject.toml": "e041f415bf92d7a3ed71c40f5a1732be9e629000c6b46479f9036ebde3071453",
19
+ "tests/test_directsdk.py": "884c960c1b88c8727bbd1cc70598d509dd435ba02296bad2a805b23f717bdc91",
20
+ "tests/test_directsdk_admission.py": "a52d64907973d32e91dbc890f42e67d191a7b7d283b042f4d278968130385251",
21
+ "tests/test_directsdk_discovery.py": "279a5a0723f3327439238d7a682602627d1aceccc2e45ee9122159cfeb292f16",
22
+ "tests/test_directsdk_models.py": "b13ccf8b53d9748e41490940ce9fd092a8bf5069f2fa2e44f09cbf0ad49af044",
23
+ "tests/test_directsdk_replay.py": "9894ab096289142f84785533d728dcac0b721720fc03b1c5f4c5e509533adee3",
24
+ "tests/test_directsdk_setup.py": "82552e84aa4a0cf0a16c3a70e4418c9edfa85a27a89821b79cf8fb957e8f91f3"
25
+ },
26
+ "patchedFiles": {
27
+ "directsdk.py": "42b8eef95cc313ad0314083a25fd534603adb56cb86c57987ed5e67d31141e73"
28
+ },
29
+ "patches": {
30
+ "patches/0001-lunr-lifecycle-hooks.patch": "0c19a60c69f15745e7076a1791988408feb32611ae29156ab28a2670a50fedb5"
31
+ }
32
+ }
@@ -0,0 +1,44 @@
1
+ """Pinned native routes; a loopback gateway needs explicit long-context selection."""
2
+ CONTEXT_WINDOWS = {
3
+ 'claude-sonnet-5': 1_000_000,
4
+ 'claude-haiku-4-5-20251001': 200_000,
5
+ 'claude-opus-5': 1_000_000,
6
+ 'claude-opus-4-8': 1_000_000,
7
+ 'claude-fable-5-1': 1_000_000,
8
+ }
9
+ ALIASES = {
10
+ 'sonnet': 'claude-sonnet-5',
11
+ 'haiku': 'claude-haiku-4-5-20251001',
12
+ 'claude-haiku-4-5': 'claude-haiku-4-5-20251001',
13
+ 'opus': 'claude-opus-5',
14
+ 'fable': 'claude-fable-5-1',
15
+ }
16
+ # Haiku 4.5 rejects `thinking: {'type': 'adaptive'}` with a 400 upstream; its effort signal
17
+ # still applies. Unknown routes keep adaptive so future models are not silently downgraded.
18
+ NO_ADAPTIVE_THINKING = frozenset({'claude-haiku-4-5-20251001'})
19
+
20
+
21
+ def native_model(model):
22
+ base = model.removesuffix('[1m]')
23
+ canonical = ALIASES.get(base, base)
24
+ window = CONTEXT_WINDOWS.get(canonical)
25
+ if window == 1_000_000:
26
+ return canonical + '[1m]'
27
+ if window == 200_000:
28
+ if model.endswith('[1m]'):
29
+ raise ValueError('Haiku 4.5 does not support a 1M context window')
30
+ return canonical
31
+ return model
32
+
33
+
34
+ def supports_adaptive_thinking(model):
35
+ # Body assembly precedes model validation, so an absent or invalid route answers True
36
+ # and still reaches the existing `model is required` error.
37
+ base = model.removesuffix('[1m]') if isinstance(model, str) else ''
38
+ return ALIASES.get(base, base) not in NO_ADAPTIVE_THINKING
39
+
40
+
41
+ MODEL_METADATA = {
42
+ native_model(model): {'canonical_model': model, 'context_window': window}
43
+ for model, window in CONTEXT_WINDOWS.items()
44
+ }
@@ -0,0 +1,14 @@
1
+ --- a/directsdk.py
2
+ +++ b/directsdk.py
3
+ @@ -269,0 +270,2 @@
4
+ + if self.client.on_spawn is not None:
5
+ + self.client.on_spawn(self.process.pid)
6
+ @@ -368 +370 @@
7
+ - def __init__(self, command=None, args=None, env=None, timeout=180, **_):
8
+ + def __init__(self, command=None, args=None, env=None, timeout=180, on_spawn=None, on_replay=None, **_):
9
+ @@ -376,0 +379,2 @@
10
+ + self.on_spawn = on_spawn
11
+ + self.on_replay = on_replay
12
+ @@ -525,0 +530,2 @@
13
+ + if self.on_replay is not None:
14
+ + self.on_replay()
@@ -0,0 +1,23 @@
1
+ def strip_nullable_unions(schema, *, keep_nullable_hint=True):
2
+ def rewrite(value):
3
+ if isinstance(value, list):
4
+ return [rewrite(item) for item in value]
5
+ if not isinstance(value, dict):
6
+ return value
7
+ out = {key: rewrite(item) for key, item in value.items()}
8
+ for key in ('anyOf', 'oneOf'):
9
+ variants = out.get(key)
10
+ if not isinstance(variants, list):
11
+ continue
12
+ non_null = [item for item in variants if not isinstance(item, dict) or item.get('type') != 'null']
13
+ if len(non_null) != 1 or len(non_null) == len(variants):
14
+ continue
15
+ replacement = dict(non_null[0]) if isinstance(non_null[0], dict) else {}
16
+ if keep_nullable_hint:
17
+ replacement.setdefault('nullable', True)
18
+ for meta in ('title', 'description', 'default', 'examples'):
19
+ if meta in out and meta not in replacement and not (meta == 'default' and '$ref' in replacement):
20
+ replacement[meta] = out[meta]
21
+ return rewrite(replacement)
22
+ return out
23
+ return rewrite(schema)