97e91a83f3
Ruff / Ruff (push) Waiting to run
Test / Core Tests (push) Waiting to run
Test / Offline Coverage Tests (Python 3.10) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.11) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.12) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.13) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.9) (push) Waiting to run
Test / Full Coverage (Python 3.11) (push) Waiting to run
Test / Core Provider Tests (OpenAI) (push) Blocked by required conditions
Test / Core Provider Tests (Anthropic) (push) Blocked by required conditions
Test / Core Provider Tests (Google) (push) Blocked by required conditions
Test / Core Provider Tests (Other) (push) Blocked by required conditions
Test / Anthropic Tests (push) Blocked by required conditions
Test / Gemini Tests (push) Blocked by required conditions
Test / Google GenAI Tests (push) Blocked by required conditions
Test / Vertex AI Tests (push) Blocked by required conditions
Test / OpenAI Tests (push) Blocked by required conditions
Test / Writer Tests (push) Blocked by required conditions
Test / Auto Client Tests (push) Blocked by required conditions
ty / type-check (push) Waiting to run
224 lines
6.5 KiB
Python
224 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import instructor
|
|
import pytest
|
|
from instructor.cache import (
|
|
AutoCache,
|
|
BaseCache,
|
|
DiskCache,
|
|
load_cached_response,
|
|
make_cache_key,
|
|
store_cached_response,
|
|
)
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class User(BaseModel):
|
|
name: str
|
|
|
|
|
|
def test_cache_key_without_response_model_is_stable_and_schema_independent() -> None:
|
|
messages = [{"role": "user", "content": "hello"}]
|
|
|
|
without_schema = make_cache_key(
|
|
messages=messages, model="local", response_model=None, mode="json"
|
|
)
|
|
repeated = make_cache_key(
|
|
messages=messages, model="local", response_model=None, mode="json"
|
|
)
|
|
with_schema = make_cache_key(
|
|
messages=messages, model="local", response_model=User, mode="json"
|
|
)
|
|
|
|
assert without_schema == repeated
|
|
assert without_schema != with_schema
|
|
|
|
|
|
class MemoryCache(BaseCache):
|
|
def __init__(self) -> None:
|
|
self.values: dict[str, Any] = {}
|
|
self.writes: list[tuple[str, Any, int | None]] = []
|
|
|
|
def get(self, key: str) -> Any | None:
|
|
return self.values.get(key)
|
|
|
|
def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
|
self.values[key] = value
|
|
self.writes.append((key, value, ttl))
|
|
|
|
|
|
def completion(name: str) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(
|
|
message=SimpleNamespace(content=User(name=name).model_dump_json()),
|
|
finish_reason="stop",
|
|
)
|
|
],
|
|
usage={},
|
|
)
|
|
|
|
|
|
def test_auto_cache_rejects_invalid_size_and_evicts_least_recently_used() -> None:
|
|
with pytest.raises(ValueError, match="maxsize must be > 0"):
|
|
AutoCache(maxsize=0)
|
|
|
|
cache = AutoCache(maxsize=2)
|
|
assert cache.get("missing") is None
|
|
|
|
cache.set("first", "old")
|
|
cache.set("second", "value")
|
|
cache.set("first", "updated")
|
|
cache.set("third", "value")
|
|
|
|
assert cache.get("first") == "updated"
|
|
assert cache.get("second") is None
|
|
assert cache.get("third") == "value"
|
|
|
|
|
|
def test_disk_cache_round_trip_supports_plain_and_ttl_writes(tmp_path: Any) -> None:
|
|
cache = DiskCache(directory=str(tmp_path / "responses"))
|
|
try:
|
|
assert cache.get("missing") is None
|
|
|
|
cache.set("plain", {"name": "plain"})
|
|
cache.set("timed", {"name": "timed"}, ttl=60)
|
|
|
|
assert cache.get("plain") == {"name": "plain"}
|
|
assert cache.get("timed") == {"name": "timed"}
|
|
finally:
|
|
cache._cache.close()
|
|
|
|
|
|
def test_sync_client_stores_on_miss_and_uses_cache_on_hit() -> None:
|
|
calls: list[str] = []
|
|
|
|
def complete(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
|
|
calls.append("called")
|
|
return completion("sync")
|
|
|
|
cache = MemoryCache()
|
|
client = instructor.from_litellm(complete, mode=instructor.Mode.JSON)
|
|
first = client.create(
|
|
response_model=User,
|
|
cache=cache,
|
|
cache_ttl=30,
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
model="local",
|
|
)
|
|
second = client.create(
|
|
response_model=User,
|
|
cache=cache,
|
|
cache_ttl=30,
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
model="local",
|
|
)
|
|
|
|
assert isinstance(first, User)
|
|
assert isinstance(second, User)
|
|
assert first.name == second.name == "sync"
|
|
assert calls == ["called"]
|
|
assert len(cache.writes) == 1
|
|
assert cache.writes[0][2] == 30
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_client_stores_on_miss_and_uses_cache_on_hit() -> None:
|
|
calls: list[str] = []
|
|
|
|
async def complete(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
|
|
calls.append("called")
|
|
return completion("async")
|
|
|
|
cache = MemoryCache()
|
|
client = instructor.from_litellm(
|
|
complete, mode=instructor.Mode.JSON, async_client=True
|
|
)
|
|
first = await client.create(
|
|
response_model=User,
|
|
cache=cache,
|
|
cache_ttl=45,
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
model="local",
|
|
)
|
|
second = await client.create(
|
|
response_model=User,
|
|
cache=cache,
|
|
cache_ttl=45,
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
model="local",
|
|
)
|
|
|
|
assert isinstance(first, User)
|
|
assert isinstance(second, User)
|
|
assert first.name == second.name == "async"
|
|
assert calls == ["called"]
|
|
assert len(cache.writes) == 1
|
|
assert cache.writes[0][2] == 45
|
|
|
|
|
|
def test_load_cached_response_accepts_legacy_model_json() -> None:
|
|
cache = AutoCache()
|
|
cache.set("legacy", User(name="legacy").model_dump_json())
|
|
|
|
restored = load_cached_response(cache, "legacy", User)
|
|
|
|
assert restored == User(name="legacy")
|
|
assert not hasattr(restored, "_raw_response")
|
|
|
|
|
|
def test_store_cached_response_without_raw_response_round_trips() -> None:
|
|
cache = MemoryCache()
|
|
|
|
store_cached_response(cache, "model-only", User(name="model-only"), ttl=15)
|
|
payload = json.loads(cache.values["model-only"])
|
|
restored = load_cached_response(cache, "model-only", User)
|
|
|
|
assert payload["raw"] is None
|
|
assert restored == User(name="model-only")
|
|
assert cache.writes[0][2] == 15
|
|
|
|
|
|
def test_pydantic_raw_response_is_restored_as_completion_like_object() -> None:
|
|
class RawCompletion(BaseModel):
|
|
id: str
|
|
choices: list[dict[str, Any]]
|
|
|
|
cache = AutoCache()
|
|
user = User(name="pydantic")
|
|
object.__setattr__(
|
|
user,
|
|
"_raw_response",
|
|
RawCompletion(id="chatcmpl-1", choices=[{"message": {"content": "ok"}}]),
|
|
)
|
|
|
|
store_cached_response(cache, "pydantic", user, ttl=10)
|
|
restored = load_cached_response(cache, "pydantic", User)
|
|
|
|
assert isinstance(restored, User)
|
|
assert restored.name == "pydantic"
|
|
assert restored._raw_response.id == "chatcmpl-1"
|
|
assert restored._raw_response.choices[0].message.content == "ok"
|
|
|
|
|
|
def test_unserializable_raw_response_uses_string_fallback(caplog: Any) -> None:
|
|
cache = MemoryCache()
|
|
raw_response: list[Any] = []
|
|
raw_response.append(raw_response)
|
|
user = User(name="fallback")
|
|
object.__setattr__(user, "_raw_response", raw_response)
|
|
|
|
store_cached_response(cache, "fallback", user)
|
|
payload = json.loads(cache.values["fallback"])
|
|
restored = load_cached_response(cache, "fallback", User)
|
|
|
|
assert payload["raw"] == "[[...]]"
|
|
assert isinstance(restored, User)
|
|
assert restored.name == "fallback"
|
|
assert restored._raw_response == "[[...]]"
|
|
assert "Raw response could not be serialized as JSON" in caplog.text
|