From 0c2021fcede777d00fe32d5e1f31451d50f5bb58 Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Sun, 6 Sep 2026 20:57:51 +0000 Subject: [PATCH] Add LLM client interface with LiteLLM adapter (A5/A11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2 of the steps-3/4 plan. - llm.py: LLMClient protocol — complete(events, tools, model) -> canonical events — present from the first real LLM call. LiteLLMClient renders requests via the A10 renderer, targets the proxy's OpenAI-compatible route, and maps provider tool-call IDs to locally-minted canonical IDs at the boundary - RoleMap (A11): executor/planner/delegate/consultant resolved from env; pinned() returns the mapping to record at task start - Config strictly from env: NISSE_LLM_BASE_URL, NISSE_LLM_TOKEN; envfile.py is a minimal .env loader where real env wins; .env now gitignored with a committed .env.example documenting all variables (Matrix ones included for the upcoming transport PR) - Live smoke test against the proxy is integration-marked and skips without credentials — CI stays credential-free per operator decision; the test runs locally once .env exists - httpx added as nisse-dev's HTTP client Co-Authored-By: Claude Fable 5 --- .env.example | 19 ++++ .gitignore | 1 + nisse-dev/pyproject.toml | 2 +- nisse-dev/src/nisse_dev/envfile.py | 22 +++++ nisse-dev/src/nisse_dev/llm.py | 144 ++++++++++++++++++++++++++++ nisse-dev/tests/test_llm.py | 147 +++++++++++++++++++++++++++++ tests/integration/test_llm_live.py | 46 +++++++++ uv.lock | 83 +++++++++++++++- 8 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 nisse-dev/src/nisse_dev/envfile.py create mode 100644 nisse-dev/src/nisse_dev/llm.py create mode 100644 nisse-dev/tests/test_llm.py create mode 100644 tests/integration/test_llm_live.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fef6a29 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Copy to .env (chmod 600). Real environment variables win over this file. +# .env is gitignored; never commit real values. + +# --- LLM access (A5): the LiteLLM proxy is the single LLM destination --- +NISSE_LLM_BASE_URL=http://litellm.example:4000 +NISSE_LLM_TOKEN=sk-your-litellm-virtual-key + +# --- Role mapping (A11): roles resolve to concrete model strings --- +NISSE_ROLE_EXECUTOR=anthropic/claude-haiku-4-5-20251001 +NISSE_ROLE_PLANNER=anthropic/claude-opus-5 +NISSE_ROLE_DELEGATE=anthropic/claude-opus-5 +NISSE_ROLE_CONSULTANT=anthropic/claude-opus-5 + +# --- Matrix transport (A1), used by nisse-dev's MatrixTransport --- +NISSE_MATRIX_HOMESERVER=https://matrix.example +NISSE_MATRIX_USER_ID=@nisse-dev:example +NISSE_MATRIX_TOKEN=syt_your_matrix_access_token +NISSE_MATRIX_ROOM_ID=!roomid:example +NISSE_MATRIX_OPERATORS=@you:example diff --git a/.gitignore b/.gitignore index c869ed4..7efcb3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env .venv/ __pycache__/ *.pyc diff --git a/nisse-dev/pyproject.toml b/nisse-dev/pyproject.toml index 7b5e4ab..050c4b4 100644 --- a/nisse-dev/pyproject.toml +++ b/nisse-dev/pyproject.toml @@ -3,7 +3,7 @@ name = "nisse-dev" version = "0.1.0" description = "Dev agent: plans and implements software projects, produces Gitea PRs" requires-python = ">=3.12" -dependencies = ["nisse-core"] +dependencies = ["nisse-core", "httpx>=0.27"] [project.scripts] nisse-dev = "nisse_dev.main:main" diff --git a/nisse-dev/src/nisse_dev/envfile.py b/nisse-dev/src/nisse_dev/envfile.py new file mode 100644 index 0000000..3361cd2 --- /dev/null +++ b/nisse-dev/src/nisse_dev/envfile.py @@ -0,0 +1,22 @@ +"""Minimal .env loader (spec A5: config from env/.env, nothing hardcoded). + +Deliberately tiny: KEY=VALUE lines, comments and blanks ignored, existing +environment variables never overridden (real env wins over the file). +""" + +import os +from collections.abc import MutableMapping +from pathlib import Path + + +def load_env_file(path: Path, env: MutableMapping[str, str] = os.environ) -> None: + if not path.exists(): + return + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if key and key not in env: + env[key] = value.strip() diff --git a/nisse-dev/src/nisse_dev/llm.py b/nisse-dev/src/nisse_dev/llm.py new file mode 100644 index 0000000..b7e439d --- /dev/null +++ b/nisse-dev/src/nisse_dev/llm.py @@ -0,0 +1,144 @@ +"""LLM access (spec A5, A11). + +One client interface, present from the first real LLM call. All traffic goes +to the LiteLLM proxy — the VM's single LLM destination; the agent holds one +LiteLLM token and no vendor credentials. Config comes from the environment +(``NISSE_LLM_BASE_URL``, ``NISSE_LLM_TOKEN``), nothing hardcoded. + +Model identity is expressed as roles (A11), resolved to concrete model strings +from env config; the resolved mapping is pinned at task start so a mid-task +config change cannot split a task across two planners. +""" + +import json +import os +from collections.abc import Mapping +from dataclasses import asdict, dataclass +from typing import Any, ClassVar, Protocol + +import httpx + +from nisse_dev.canonical import ( + AssistantText, + CanonicalEvent, + StoredEvent, + ToolCall, + mint_call_id, +) +from nisse_dev.render import OpenAIChatRenderer + + +class LLMError(RuntimeError): + pass + + +class LLMClient(Protocol): + def complete( + self, + events: list[StoredEvent], + tools: list[dict[str, Any]] | None, + model: str, + system: str | None = None, + ) -> list[CanonicalEvent]: ... + + +@dataclass(frozen=True) +class RoleMap: + executor: str + planner: str + delegate: str + consultant: str + + _ENV_VARS: ClassVar[dict[str, str]] = { + "executor": "NISSE_ROLE_EXECUTOR", + "planner": "NISSE_ROLE_PLANNER", + "delegate": "NISSE_ROLE_DELEGATE", + "consultant": "NISSE_ROLE_CONSULTANT", + } + + @classmethod + def from_env(cls, env: Mapping[str, str] = os.environ) -> "RoleMap": + missing = [var for var in cls._ENV_VARS.values() if not env.get(var)] + if missing: + raise LLMError(f"role mapping incomplete; set: {', '.join(missing)}") + return cls(**{role: env[var] for role, var in cls._ENV_VARS.items()}) + + def resolve(self, role: str) -> str: + if role not in self._ENV_VARS: + raise LLMError(f"unknown role: {role!r}") + return str(getattr(self, role)) + + def pinned(self) -> dict[str, str]: + """The full resolved mapping, to record at task start (A11).""" + return asdict(self) + + +class LiteLLMClient: + def __init__( + self, + base_url: str, + token: str, + *, + timeout_s: float = 120.0, + transport: httpx.BaseTransport | None = None, + ) -> None: + self._http = httpx.Client( + base_url=base_url.rstrip("/"), + headers={"Authorization": f"Bearer {token}"}, + timeout=timeout_s, + transport=transport, + ) + self._renderer = OpenAIChatRenderer() + + @classmethod + def from_env(cls, env: Mapping[str, str] = os.environ) -> "LiteLLMClient": + base_url = env.get("NISSE_LLM_BASE_URL") + token = env.get("NISSE_LLM_TOKEN") + if not base_url or not token: + raise LLMError("set NISSE_LLM_BASE_URL and NISSE_LLM_TOKEN") + return cls(base_url, token) + + def complete( + self, + events: list[StoredEvent], + tools: list[dict[str, Any]] | None, + model: str, + system: str | None = None, + ) -> list[CanonicalEvent]: + body = self._renderer.render(events, tools, model, system=system) + try: + response = self._http.post("/chat/completions", json=body) + except httpx.HTTPError as exc: + raise LLMError(f"LLM request failed: {exc}") from exc + if response.status_code != 200: + raise LLMError( + f"LLM request returned {response.status_code}: {response.text[:500]}" + ) + return self._parse(response.json(), requested_model=model) + + def _parse( + self, data: dict[str, Any], requested_model: str + ) -> list[CanonicalEvent]: + try: + message = data["choices"][0]["message"] + except (KeyError, IndexError) as exc: + raise LLMError(f"malformed completion response: {exc}") from exc + produced_by = data.get("model") or requested_model + out: list[CanonicalEvent] = [] + if message.get("content"): + out.append(AssistantText(text=message["content"], model=produced_by)) + # Provider tool-call IDs are mapped at the boundary (A10): each call + # gets a locally-minted canonical ID; the provider's is dropped, since + # every future request is rendered wholly from the canonical log. + for call in message.get("tool_calls") or []: + out.append( + ToolCall( + call_id=mint_call_id(), + tool=call["function"]["name"], + arguments=json.loads(call["function"]["arguments"] or "{}"), + ) + ) + return out + + def close(self) -> None: + self._http.close() diff --git a/nisse-dev/tests/test_llm.py b/nisse-dev/tests/test_llm.py new file mode 100644 index 0000000..56ec00e --- /dev/null +++ b/nisse-dev/tests/test_llm.py @@ -0,0 +1,147 @@ +import json +from pathlib import Path + +import httpx +import pytest + +from nisse_dev.canonical import ( + AssistantText, + StoredEvent, + ToolCall, + UserMessage, +) +from nisse_dev.envfile import load_env_file +from nisse_dev.llm import LiteLLMClient, LLMError, RoleMap + +EVENTS = [StoredEvent(id=1, ts="t", event=UserMessage(text="hi", source="m:1"))] + + +def make_client(handler) -> LiteLLMClient: + return LiteLLMClient( + "http://litellm.test/v1", + "sk-test", + transport=httpx.MockTransport(handler), + ) + + +def completion(message: dict) -> httpx.Response: + return httpx.Response( + 200, json={"model": "gpt-test-1", "choices": [{"message": message}]} + ) + + +def test_request_shape_and_auth() -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["auth"] = request.headers.get("authorization") + seen["body"] = json.loads(request.content) + return completion({"content": "hello"}) + + make_client(handler).complete(EVENTS, tools=None, model="local/qwen", system="s") + assert seen["url"] == "http://litellm.test/v1/chat/completions" + assert seen["auth"] == "Bearer sk-test" + assert seen["body"]["model"] == "local/qwen" + assert seen["body"]["messages"][0] == {"role": "system", "content": "s"} + assert seen["body"]["messages"][1] == {"role": "user", "content": "hi"} + + +def test_assistant_text_parsed_with_producing_model() -> None: + client = make_client(lambda request: completion({"content": "hello"})) + [event] = client.complete(EVENTS, tools=None, model="local/qwen") + assert event == AssistantText(text="hello", model="gpt-test-1") + + +def test_tool_calls_get_locally_minted_canonical_ids() -> None: + message = { + "content": None, + "tool_calls": [ + { + "id": "call_provider_abc", + "type": "function", + "function": { + "name": "run_tests", + "arguments": '{"path": "tests/"}', + }, + } + ], + } + client = make_client(lambda request: completion(message)) + [event] = client.complete(EVENTS, tools=None, model="local/qwen") + assert isinstance(event, ToolCall) + assert event.tool == "run_tests" + assert event.arguments == {"path": "tests/"} + assert event.call_id != "call_provider_abc" # provider ID never a join key + assert event.call_id.startswith("call-") + + +def test_http_error_status_raises_llm_error() -> None: + client = make_client(lambda request: httpx.Response(429, text="rate limited")) + with pytest.raises(LLMError, match="429"): + client.complete(EVENTS, tools=None, model="local/qwen") + + +def test_connection_failure_raises_llm_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("refused") + + with pytest.raises(LLMError, match="request failed"): + make_client(handler).complete(EVENTS, tools=None, model="local/qwen") + + +def test_malformed_response_raises_llm_error() -> None: + client = make_client(lambda request: httpx.Response(200, json={"oops": True})) + with pytest.raises(LLMError, match="malformed"): + client.complete(EVENTS, tools=None, model="local/qwen") + + +ROLE_ENV = { + "NISSE_ROLE_EXECUTOR": "local/qwen", + "NISSE_ROLE_PLANNER": "anthropic/claude-opus-5", + "NISSE_ROLE_DELEGATE": "anthropic/claude-opus-5", + "NISSE_ROLE_CONSULTANT": "anthropic/claude-opus-5", +} + + +def test_role_map_resolves_and_pins() -> None: + roles = RoleMap.from_env(ROLE_ENV) + assert roles.resolve("executor") == "local/qwen" + assert roles.pinned() == { + "executor": "local/qwen", + "planner": "anthropic/claude-opus-5", + "delegate": "anthropic/claude-opus-5", + "consultant": "anthropic/claude-opus-5", + } + + +def test_role_map_missing_env_raises() -> None: + with pytest.raises(LLMError, match="NISSE_ROLE_PLANNER"): + RoleMap.from_env({"NISSE_ROLE_EXECUTOR": "local/qwen"}) + + +def test_unknown_role_raises() -> None: + with pytest.raises(LLMError, match="unknown role"): + RoleMap.from_env(ROLE_ENV).resolve("boss") + + +def test_client_from_env_requires_config() -> None: + with pytest.raises(LLMError, match="NISSE_LLM_BASE_URL"): + LiteLLMClient.from_env({}) + + +def test_load_env_file(tmp_path: Path) -> None: + envfile = tmp_path / ".env" + envfile.write_text( + "# comment\n\nNISSE_LLM_TOKEN=sk-abc\nNISSE_LLM_BASE_URL = http://x \n" + ) + env: dict[str, str] = {"NISSE_LLM_TOKEN": "sk-already-set"} + load_env_file(envfile, env) + assert env["NISSE_LLM_TOKEN"] == "sk-already-set" # real env wins + assert env["NISSE_LLM_BASE_URL"] == "http://x" + + +def test_load_env_file_missing_is_noop(tmp_path: Path) -> None: + env: dict[str, str] = {} + load_env_file(tmp_path / "nope.env", env) + assert env == {} diff --git a/tests/integration/test_llm_live.py b/tests/integration/test_llm_live.py new file mode 100644 index 0000000..86d5707 --- /dev/null +++ b/tests/integration/test_llm_live.py @@ -0,0 +1,46 @@ +"""Live smoke test against the real LiteLLM proxy. + +Skips unless NISSE_LLM_BASE_URL, NISSE_LLM_TOKEN, and NISSE_ROLE_EXECUTOR are +present (from the environment or the repo-root .env) — CI stays credential-free +by choice; this runs from the operator's machine. +""" + +import os +from pathlib import Path + +import pytest + +from nisse_dev.canonical import AssistantText, StoredEvent, UserMessage +from nisse_dev.envfile import load_env_file +from nisse_dev.llm import LiteLLMClient + +load_env_file(Path(__file__).resolve().parents[2] / ".env") + +REQUIRED = ("NISSE_LLM_BASE_URL", "NISSE_LLM_TOKEN", "NISSE_ROLE_EXECUTOR") + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not all(os.environ.get(var) for var in REQUIRED), + reason=f"live LLM config absent; set {', '.join(REQUIRED)}", + ), +] + + +def test_executor_model_completes_via_litellm() -> None: + client = LiteLLMClient.from_env() + events = [ + StoredEvent( + id=1, + ts="t", + event=UserMessage( + text="Reply with exactly one word: pong", source="smoke-test" + ), + ) + ] + result = client.complete( + events, tools=None, model=os.environ["NISSE_ROLE_EXECUTOR"] + ) + texts = [e.text for e in result if isinstance(e, AssistantText)] + assert texts, f"no assistant text in {result!r}" + assert "pong" in texts[0].lower() diff --git a/uv.lock b/uv.lock index 78b6855..c86747e 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,28 @@ dev = [ { name = "ruff", specifier = ">=0.6" }, ] +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -24,6 +46,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -43,11 +111,15 @@ name = "nisse-dev" version = "0.1.0" source = { editable = "nisse-dev" } dependencies = [ + { name = "httpx" }, { name = "nisse-core" }, ] [package.metadata] -requires-dist = [{ name = "nisse-core", editable = "nisse-core" }] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "nisse-core", editable = "nisse-core" }, +] [[package]] name = "nisse-ops" @@ -127,3 +199,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] -- 2.54.0