diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ef11baa..365bcf8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,6 +23,28 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Validate tag matches package version + run: | + python - <<'PY' + import os + import tomllib + + ref = os.environ.get("GITHUB_REF_NAME", "") + if not ref.startswith("v"): + raise SystemExit(f"release tag must start with v, got {ref!r}") + + tag_version = ref[1:] + with open("pyproject.toml", "rb") as f: + package_version = tomllib.load(f)["project"]["version"] + + if tag_version != package_version: + raise SystemExit( + f"release tag v{tag_version} does not match pyproject version {package_version}" + ) + + print(f"release version OK: {package_version}") + PY + - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/README.md b/README.md index dd443af..bbe0ae8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ pip install modelito `pip install modelito` does not install FastAPI/Uvicorn. Those are optional and only needed for `modelito-serve`. +Release publishing uses PyPI trusted publishing through +`.github/workflows/publish.yml`. The PyPI project must have a matching trusted +publisher configured for repository `krahd/modelito`, workflow `publish.yml`, +and environment `pypi`. The workflow also checks that the tag version matches +`pyproject.toml` before building or publishing. + For development / contributor setup (editable install and dev dependencies): ```sh @@ -204,12 +210,17 @@ This software is provided "AS IS" and without warranties of any kind. See the included `LICENSE` file for the full MIT license text. CI / Integration Tests ----------------------- This repository includes a consolidated GitHub Actions workflow at `.github/workflows/ci.yml`. It runs linting/type checks and unit tests for pull requests and pushes to `main`, and builds docs on non-PR runs. +Release publishing uses PyPI trusted publishing through +`.github/workflows/publish.yml`. The PyPI project must have a matching trusted +publisher configured for repository `krahd/modelito`, workflow `publish.yml`, +and environment `pypi`. The workflow also checks that the tag version matches +`pyproject.toml` before building or publishing. + Ollama integration tests are intentionally gated and will only run when you explicitly enable them. To run integration tests locally or in CI set the environment variable `RUN_OLLAMA_INTEGRATION=1`. Additional optional flags: diff --git a/STATUS.md b/STATUS.md index 91a865e..9305a9b 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,6 @@ # modelito – Project Status -Last updated: 2026-05-18 23:12 +Last updated: 2026-05-18 23:16 ## Project purpose @@ -42,7 +42,7 @@ Phase 4 server-contract hardening pass complete: 10. Raw-provider non-dict completion payloads are treated as upstream bad responses (`ModelitoBadResponseError`) and return 502 errors. 11. Regression tests now cover lazy raw streaming, runtime config separation (server bind host/port are not forwarded to provider constructors), payload validators, error-shape/status helpers, malformed JSON handling, OpenAI provider raw/chat behavior, and Ollama dict/string message normalization. 12. README wording was tightened for Ollama extra semantics and `--profile`/`--profile-path` path handling, and Ollama raw passthrough remains explicitly deferred. -13. Publish workflow was reviewed and updated with explicit trusted-publishing prerequisites and PyPI environment URL metadata. +13. Publish workflow now includes a tag/version gate plus explicit trusted-publishing prerequisites and PyPI environment URL metadata. 14. `ChatProvider` — `@runtime_checkable` Protocol in `modelito/provider.py` formalising the `chat()` interface returning `Response`; exported from package root. 15. `MessageInput` type alias (`Union[Message, str, Mapping[str, Any]]`) added to `provider.py` and exported; `Client` method signatures broadened from `Iterable[Message]` to `Iterable[MessageInput]`; `SyncProvider.summarize()` and `AsyncProvider.acomplete()` signatures similarly broadened. 16. Provider readiness diagnostics added through `check_provider_ready()` / `ProviderStatus` and the `python -m modelito doctor` CLI. @@ -138,16 +138,16 @@ python -m twine check dist/* - Current provider typing includes `ChatProvider`, `MessageInput`, and `OpenAIMessageDict` exports, with `Client` chat-related methods accepting broadened message input types; provider protocols are aligned so `SyncProvider`, `AsyncProvider`, `StreamingProvider`, and `ChatProvider` all accept `Iterable[MessageInput]`. - `Client.chat_json()` now supports optional stronger schema validation via `strict_schema=True` using dataclass construction or Pydantic-style `model_validate`/`parse_obj` hooks, while preserving lightweight key-presence checks by default. - Validation should be confirmed by CI; local development most recently ran targeted `tests/test_serve.py` plus the full `pytest -q` suite, `ruff check .`, `mypy modelito --ignore-missing-imports`, and `python -c "import modelito; print(modelito.__version__)"`. -- Trusted publishing note: the workflow is configured for PyPI trusted publishing, but PyPI project-side trusted publisher settings must be verified before release. +- Trusted publishing note: the workflow is configured for PyPI trusted publishing, but PyPI project-side trusted publisher settings must be verified before release, and the release tag must match `pyproject.toml`. - Historical release narratives are maintained in `CHANGELOG.md`; STATUS.md is kept as a current-state snapshot. ## Pending tasks -- Revisit trusted publishing configuration before the next release. +- Verify PyPI project-side trusted publisher settings before the next release. ## Next steps -1. Fix PyPI trusted publishing configuration for `.github/workflows/publish.yml` before the next release. +1. Verify PyPI project-side trusted publisher settings before the next release. 2. Keep reviewing provider additions against the portable-common-surface rule. 3. Decide whether to add optional Ollama raw passthrough in a later milestone. @@ -171,4 +171,4 @@ python -m twine check dist/* --- -Last updated: 2026-05-18 23:12 +Last updated: 2026-05-18 23:16 diff --git a/tests/test_serve.py b/tests/test_serve.py index cf69b9b..71d4972 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -15,10 +15,12 @@ _chat_completion_response, _embedding_response, _error_payload, + _error_kind_for_exception, _http_status_for_exception, _messages_from_payload, _models_response, _requires_raw_tool_support, + _read_json_payload, _stream_completion_events, _stream_response_body, build_runtime, @@ -41,6 +43,11 @@ async def json(self): raise ValueError("invalid json") +class _ArrayJSONRequest: + async def json(self): + return [1, 2, 3] + + class _FakeJSONResponse: def __init__(self, payload, headers=None, status_code=200): self.payload = payload @@ -206,6 +213,43 @@ def test_http_status_mapping_for_exceptions(): assert _http_status_for_exception(RuntimeError("boom")) == 500 +def test_error_kind_mapping_for_exceptions(): + assert _error_kind_for_exception(ValueError("bad")) == "modelito_bad_request" + assert _error_kind_for_exception(TypeError("bad")) == "modelito_bad_request" + assert _error_kind_for_exception(ModelitoBadResponseError( + "bad upstream")) == "modelito_bad_response" + assert _error_kind_for_exception(ModelitoModelNotFoundError( + "missing")) == "modelito_model_not_found" + assert _error_kind_for_exception(ModelitoTimeoutError("timeout")) == "modelito_timeout_error" + assert _error_kind_for_exception(TimeoutError("timeout")) == "modelito_timeout_error" + assert _error_kind_for_exception(ModelitoConnectionError( + "offline")) == "modelito_connection_error" + assert _error_kind_for_exception(ModelitoProviderError("provider")) == "modelito_provider_error" + assert _error_kind_for_exception(RuntimeError("boom")) == "modelito_internal_error" + + +def test_read_json_payload_validation(): + async def _run(request): + return await _read_json_payload(request) + + try: + asyncio.run(_run(_InvalidJSONRequest())) + except ValueError as exc: + assert "valid JSON" in str(exc) + else: + raise AssertionError("invalid JSON should fail") + + try: + asyncio.run(_run(_ArrayJSONRequest())) + except ValueError as exc: + assert "JSON object" in str(exc) + else: + raise AssertionError("array JSON should fail") + + payload = asyncio.run(_run(_FakeRequest({"hello": "world"}))) + assert payload == {"hello": "world"} + + def test_models_response_returns_openai_shape(): runtime = _build_runtime() response = _models_response(runtime) @@ -715,11 +759,44 @@ def raw_stream(self, payload): app = create_app(runtime) handler = app.routes[("POST", "/v1/chat/completions")] - response = asyncio.run(handler(_FakeRequest({"messages": [{"role": "user", "content": "hello"}]}))) + response = asyncio.run(handler(_FakeRequest( + {"messages": [{"role": "user", "content": "hello"}]}))) _assert_openai_error(response, 502) assert response.payload["error"]["type"] == "modelito_bad_response" +def test_raw_provider_bad_request_payload_is_not_over_validated(monkeypatch): + class EchoRawProvider: + def __init__(self): + self.last_payload = None + + def raw_complete(self, payload): + self.last_payload = dict(payload) + return { + "id": "chatcmpl-echo", + "object": "chat.completion", + "created": 0, + "model": payload.get("model", "omlx"), + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + } + + def raw_stream(self, payload): + self.last_payload = dict(payload) + yield {"id": "chunk-1", "object": "chat.completion.chunk", "choices": [{"index": 0, "delta": {"content": "ok"}, "finish_reason": None}]} + + raw_provider = EchoRawProvider() + runtime = _build_runtime(raw_provider=raw_provider) + _patch_server_deps(monkeypatch) + app = create_app(runtime) + handler = app.routes[("POST", "/v1/chat/completions")] + + response = asyncio.run(handler(_FakeRequest( + {"model": "omlx", "messages": "hello", "extra": {"x": 1}}))) + assert response.status_code == 200 + assert raw_provider.last_payload["messages"] == "hello" + assert raw_provider.last_payload["extra"] == {"x": 1} + + def test_raw_provider_backend_error_maps_to_openai_error(monkeypatch): class FailingRawProvider: def raw_complete(self, payload): @@ -733,6 +810,7 @@ def raw_stream(self, payload): app = create_app(runtime) handler = app.routes[("POST", "/v1/chat/completions")] - response = asyncio.run(handler(_FakeRequest({"messages": [{"role": "user", "content": "hello"}]}))) + response = asyncio.run(handler(_FakeRequest( + {"messages": [{"role": "user", "content": "hello"}]}))) _assert_openai_error(response, 502) assert response.payload["error"]["type"] == "modelito_provider_error"