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
10 changes: 5 additions & 5 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Validate tag matches package version
run: |
python - <<'PY'
Expand All @@ -45,11 +50,6 @@ jobs:
print(f"release version OK: {package_version}")
PY

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Build distributions
run: |
python -m pip install --upgrade pip
Expand Down
15 changes: 11 additions & 4 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# modelito – Project Status

Last updated: 2026-05-19 01:13
Last updated: 2026-05-19 01:21

## Project purpose

Expand Down Expand Up @@ -53,7 +53,7 @@ Phase 4 server-contract hardening and provider cleanup pass complete:
19. Latest review-feedback fixes made fallback streaming lazy, scoped warning headers to tool fallbacks, shared readiness probes between client and doctor, and cleaned up package exports.

- `modelito-serve` exposes `/v1/models`, `/v1/chat/completions`, and `/v1/embeddings` for Pi and other OpenAI-compatible consumers.
- Validation completed locally in this session: `python scripts/check_no_legacy_dicts.py` -> no literal dict-shaped message examples found in docs/examples; `ruff check .` clean; `mypy modelito --ignore-missing-imports` clean; `pytest -q` -> `236 passed, 2 skipped`; `python -c "from modelito import flatten_message_inputs; print(flatten_message_inputs(['hello']))"` -> `[{"role": "user", "content": "hello"}]`.
- Validation status is recorded in the current session snapshot under Recent changes.

## Architecture overview

Expand Down Expand Up @@ -150,7 +150,14 @@ python -m twine check dist/*
- Current oMLX stack uses `OpenAICompatibleHTTPProvider` with strict-mode typed error handling.
- 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 completed locally in this session: `python scripts/check_no_legacy_dicts.py` -> no literal dict-shaped message examples found in docs/examples; `ruff check .` clean; `mypy modelito --ignore-missing-imports` clean; `pytest -q --ignore=tests/integration tests` -> `250 passed, 1 skipped`; `python -c "from modelito import get_model_metadata; print(get_model_metadata('gpt-4o-mini')['provider'])"` -> `openai`; optional checks: `python -m build` succeeded and `python -m twine check dist/*` passed.
- Validation completed locally in this session:
- `python scripts/check_no_legacy_dicts.py` -> clean
- `ruff check .` -> clean
- `mypy modelito --ignore-missing-imports` -> clean
- `pytest -q --ignore=tests/integration tests` -> `257 passed, 1 skipped`
- `python -c "import modelito; print(modelito.__version__)"` -> `1.4.4`
- `python -m build` -> succeeded
- `python -m twine check dist/*` -> passed
- 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.

Expand Down Expand Up @@ -187,4 +194,4 @@ python -m twine check dist/*

---

Last updated: 2026-05-19 01:13
Last updated: 2026-05-19 01:21
9 changes: 5 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,13 @@ Structured output helpers
-------------------------

`Client.chat_json(messages, schema=None, settings=None, strict_schema=False) -> dict`
: Request structured JSON output, keep the parsed dict, and optionally apply
lightweight schema validation.
: Request structured JSON output and return a parsed `dict`; optionally apply
key-presence schema checks and stricter runtime validation when
`strict_schema=True`.

`Client.chat_parsed(messages, schema, settings=None, strict_schema=True) -> Any`
: Request structured JSON output and construct the schema object directly when
the schema is a dataclass or Pydantic-style model.
: Request structured JSON output and return a parsed schema object when
supported (dataclass or Pydantic-style model hooks).

Ollama helpers
--------------
Expand Down
121 changes: 121 additions & 0 deletions tests/test_client_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from dataclasses import dataclass
from typing import TypedDict

import pytest

from modelito.client import Client
from modelito.messages import Response


class FakeSummarizeProvider:
model = "fake"

def __init__(self, payload: str):
self.payload = payload
self.last_settings = None

def list_models(self):
return [self.model]

def summarize(self, messages, settings=None):
self.last_settings = settings
return self.payload


class FakeChatProvider(FakeSummarizeProvider):
def __init__(self, payload: str):
super().__init__(payload)
self.chat_called = False
self.summarize_called = False

def summarize(self, messages, settings=None):
self.summarize_called = True
return super().summarize(messages, settings=settings)

def chat(self, messages, settings=None):
self.chat_called = True
self.last_settings = settings
return Response(text=self.payload)


class PersonSchema(TypedDict):
name: str
age: int


@dataclass
class Person:
name: str
age: int


def test_chat_json_injects_json_response_format():
provider = FakeSummarizeProvider('{"name": "Ada", "age": 36}')
client = Client(provider=provider)

result = client.chat_json(["hello"])

assert result == {"name": "Ada", "age": 36}
assert provider.last_settings["response_format"] == {"type": "json_object"}


def test_chat_json_preserves_existing_settings():
provider = FakeSummarizeProvider('{"name": "Ada", "age": 36}')
client = Client(provider=provider)

client.chat_json(["hello"], settings={"temperature": 0})

assert provider.last_settings["temperature"] == 0
assert provider.last_settings["response_format"] == {"type": "json_object"}


def test_chat_json_raises_for_invalid_json():
provider = FakeSummarizeProvider("not json")
client = Client(provider=provider)

with pytest.raises(ValueError):
client.chat_json(["hello"])


def test_chat_json_key_presence_schema_validation():
provider = FakeSummarizeProvider('{"name": "Ada"}')
client = Client(provider=provider)

with pytest.raises(ValueError, match="missing required keys"):
client.chat_json(["hello"], schema=PersonSchema)


def test_chat_json_strict_schema_dataclass_success_and_failure():
ok_provider = FakeSummarizeProvider('{"name": "Ada", "age": 36}')
ok_client = Client(provider=ok_provider)

ok = ok_client.chat_json(["hello"], schema=Person, strict_schema=True)
assert ok == {"name": "Ada", "age": 36}

bad_provider = FakeSummarizeProvider('{"name": "Ada", "age": 36, "extra": true}')
bad_client = Client(provider=bad_provider)

with pytest.raises(ValueError, match="dataclass validation"):
bad_client.chat_json(["hello"], schema=Person, strict_schema=True)


def test_chat_parsed_returns_dataclass_instance():
provider = FakeSummarizeProvider('{"name": "Ada", "age": 36}')
client = Client(provider=provider)

obj = client.chat_parsed(["hello"], Person)

assert isinstance(obj, Person)
assert obj.name == "Ada"
assert obj.age == 36


def test_chat_json_uses_provider_chat_when_available():
provider = FakeChatProvider('{"ok": true}')
client = Client(provider=provider)

result = client.chat_json(["hello"])

assert result == {"ok": True}
assert provider.chat_called is True
assert provider.summarize_called is False
Loading