Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 6 additions & 6 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# modelito – Project Status

Last updated: 2026-05-18 23:12
Last updated: 2026-05-18 23:16

## Project purpose

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -171,4 +171,4 @@ python -m twine check dist/*

---

Last updated: 2026-05-18 23:12
Last updated: 2026-05-18 23:16
82 changes: 80 additions & 2 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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"
Loading