Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import json | |
| import sys | |
| from tiny_trigger.automation import AutomationDocument | |
| from tiny_trigger.llm import ( | |
| DEFAULT_ANTHROPIC_MODEL, | |
| DEFAULT_OPENAI_MODEL, | |
| SYSTEM_PROMPT, | |
| _anthropic_message, | |
| _build_user_prompt, | |
| _chat_payload, | |
| _clean_api_key, | |
| _openai_chat_completion, | |
| compile_automation_with_anthropic, | |
| compile_automation_with_openai, | |
| compile_automation_with_replicate, | |
| _validate_compile_result, | |
| extract_json_object, | |
| ) | |
| def test_extract_json_object_from_fenced_response() -> None: | |
| raw = """```json | |
| {"rules": [{"name": "notify", "when": {"all": [{"present": {"label": "package"}}]}, "then": [{"type": "simulate", "name": "notify"}]}]} | |
| ```""" | |
| data = json.loads(extract_json_object(raw)) | |
| document = AutomationDocument.model_validate(data) | |
| assert document.rules[0].name == "notify" | |
| def test_openai_payload_uses_json_object() -> None: | |
| payload = _chat_payload(model="qwen", user_prompt="compile this", response_format="json_object") | |
| assert payload["response_format"] == {"type": "json_object"} | |
| assert payload["max_tokens"] == 512 | |
| def test_prompt_includes_validation_schema() -> None: | |
| prompt = _build_user_prompt(instruction="if package at door notify me", class_names=["package", "door"]) | |
| assert "Available detection labels: package, door" in prompt | |
| assert "Full validation schema" in prompt | |
| assert "AutomationDocument" in prompt | |
| def test_prompt_teaches_near_relations() -> None: | |
| prompt = _build_user_prompt( | |
| instruction="If person near steering wheel then turn on pc.", | |
| class_names=["person", "steering wheel"], | |
| ) | |
| assert '"near": {"a": "person", "b": "steering wheel", "max_gap_percent": 16}' in prompt | |
| assert "Do not replace a near relation with two present conditions." in SYSTEM_PROMPT | |
| assert "Use max_gap_percent for near/far box-edge distance." in SYSTEM_PROMPT | |
| assert "Use state gates in gate: enabled, cooldown." in SYSTEM_PROMPT | |
| assert 'Use trigger.on="enter" for state assertions' in SYSTEM_PROMPT | |
| assert 'trigger.on="change"' in SYSTEM_PROMPT | |
| assert '"trigger": {"on": "enter"}' in prompt | |
| assert '"gate": {"enabled": true}' in prompt | |
| assert '"gate": {"enabled": true, "cooldown": {"key": "package-at-door", "minutes": 15}}' in prompt | |
| def test_api_key_rejects_pasted_traceback() -> None: | |
| try: | |
| _clean_api_key(' File "/tmp/example.py", line 1\nrequests.exceptions.HTTPError', "Anthropic") | |
| except ValueError as exc: | |
| assert "not an API key" in str(exc) or "single-line token" in str(exc) | |
| else: | |
| raise AssertionError("Tracebacks should not be accepted as API keys") | |
| def test_invalid_empty_object_fails_validation() -> None: | |
| try: | |
| _validate_compile_result("{}") | |
| except Exception as exc: | |
| assert "rules" in str(exc) | |
| else: | |
| raise AssertionError("{} should not validate as an automation document") | |
| def test_replicate_compile_uses_stream_api(monkeypatch) -> None: | |
| class Replicate: | |
| class Client: | |
| def __init__(self, api_token): | |
| assert api_token == "test-token" | |
| def stream(self, model, input): | |
| assert model == "openai/gpt-5.2" | |
| assert input["messages"] == [] | |
| assert input["verbosity"] == "medium" | |
| assert input["reasoning_effort"] == "low" | |
| assert "Return only the JSON object." in input["prompt"] | |
| yield '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},' | |
| yield '"then":[{"type":"simulate","name":"notify"}]}]}' | |
| monkeypatch.setitem(sys.modules, "replicate", Replicate) | |
| result = compile_automation_with_replicate( | |
| instruction="if person present notify", | |
| class_names=["person"], | |
| api_token="test-token", | |
| model="openai/gpt-5.2", | |
| reasoning_effort="low", | |
| ) | |
| assert result.document.rules[0].name == "notify" | |
| def test_replicate_invalid_json_does_not_auto_repair(monkeypatch) -> None: | |
| class Replicate: | |
| class Client: | |
| calls = 0 | |
| def __init__(self, api_token): | |
| return None | |
| def stream(self, model, input): | |
| self.__class__.calls += 1 | |
| yield "not json" | |
| monkeypatch.setitem(sys.modules, "replicate", Replicate) | |
| try: | |
| compile_automation_with_replicate( | |
| instruction="if person present notify", | |
| class_names=["person"], | |
| api_token="test-token", | |
| model="openai/gpt-5.2", | |
| ) | |
| except ValueError as exc: | |
| assert "Raw response: not json" in str(exc) | |
| else: | |
| raise AssertionError("Invalid provider output should fail validation") | |
| assert Replicate.Client.calls == 1 | |
| def test_replicate_rate_limit_error_is_human_readable(monkeypatch) -> None: | |
| class RateLimitError(Exception): | |
| status = 429 | |
| detail = "Request was throttled. Your rate limit" | |
| class Replicate: | |
| class Client: | |
| def __init__(self, api_token): | |
| return None | |
| def stream(self, model, input): | |
| raise RateLimitError() | |
| yield "" | |
| monkeypatch.setitem(sys.modules, "replicate", Replicate) | |
| try: | |
| compile_automation_with_replicate( | |
| instruction="if person present notify", | |
| class_names=["person"], | |
| api_token="test-token", | |
| model="openai/gpt-5.2", | |
| ) | |
| except RuntimeError as exc: | |
| message = str(exc) | |
| assert "Replicate API request failed" in message | |
| assert "status 429" in message | |
| assert "provider rate limit" in message | |
| else: | |
| raise AssertionError("Rate limits should surface as RuntimeError") | |
| def test_openai_compile_uses_chat_completions(monkeypatch) -> None: | |
| monkeypatch.setitem(sys.modules, "openai", None) | |
| class Response: | |
| def raise_for_status(self) -> None: | |
| return None | |
| def json(self): | |
| return { | |
| "choices": [ | |
| { | |
| "message": { | |
| "content": ( | |
| '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},' | |
| '"then":[{"type":"simulate","name":"notify"}]}]}' | |
| ) | |
| } | |
| } | |
| ] | |
| } | |
| class Requests: | |
| def post(endpoint, headers, json, timeout): | |
| assert endpoint == "https://api.openai.com/v1/chat/completions" | |
| assert headers["Authorization"] == "Bearer test-key" | |
| assert json["model"] == "gpt-test" | |
| assert json["response_format"] == {"type": "json_object"} | |
| assert timeout == 120.0 | |
| return Response() | |
| monkeypatch.setitem(sys.modules, "requests", Requests) | |
| result = compile_automation_with_openai( | |
| instruction="if person present notify", | |
| class_names=["person"], | |
| api_key="test-key", | |
| model="gpt-test", | |
| ) | |
| assert result.document.rules[0].name == "notify" | |
| def test_openai_sdk_completion() -> None: | |
| class Message: | |
| content = '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},"then":[{"type":"simulate","name":"notify"}]}]}' | |
| class Choice: | |
| message = Message() | |
| class Response: | |
| choices = [Choice()] | |
| class Completions: | |
| def create(**payload): | |
| assert payload["model"] == DEFAULT_OPENAI_MODEL | |
| assert payload["response_format"] == {"type": "json_object"} | |
| return Response() | |
| class Chat: | |
| completions = Completions() | |
| class OpenAIClient: | |
| chat = Chat() | |
| def __init__(self, api_key, timeout): | |
| assert api_key == "test-key" | |
| assert timeout == 120.0 | |
| class OpenAIModule: | |
| OpenAI = OpenAIClient | |
| raw = _openai_chat_completion( | |
| api_key="test-key", | |
| model=DEFAULT_OPENAI_MODEL, | |
| user_prompt="compile", | |
| timeout=120.0, | |
| openai_module=OpenAIModule, | |
| ) | |
| assert json.loads(raw)["rules"][0]["name"] == "notify" | |
| def test_anthropic_compile_uses_messages_api(monkeypatch) -> None: | |
| monkeypatch.setitem(sys.modules, "anthropic", None) | |
| class Response: | |
| def raise_for_status(self) -> None: | |
| return None | |
| def json(self): | |
| return { | |
| "content": [ | |
| { | |
| "type": "text", | |
| "text": ( | |
| '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},' | |
| '"then":[{"type":"simulate","name":"notify"}]}]}' | |
| ), | |
| } | |
| ] | |
| } | |
| class Requests: | |
| def post(endpoint, headers, json, timeout): | |
| assert endpoint == "https://api.anthropic.com/v1/messages" | |
| assert headers["x-api-key"] == "test-key" | |
| assert headers["anthropic-version"] == "2023-06-01" | |
| assert json["model"] == "claude-test" | |
| assert json["system"] == SYSTEM_PROMPT | |
| assert timeout == 120.0 | |
| return Response() | |
| monkeypatch.setitem(sys.modules, "requests", Requests) | |
| result = compile_automation_with_anthropic( | |
| instruction="if person present notify", | |
| class_names=["person"], | |
| api_key="test-key", | |
| model="claude-test", | |
| ) | |
| assert result.document.rules[0].name == "notify" | |
| def test_anthropic_sdk_message() -> None: | |
| class TextBlock: | |
| text = '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},"then":[{"type":"simulate","name":"notify"}]}]}' | |
| class Response: | |
| content = [TextBlock()] | |
| class Messages: | |
| def create(**payload): | |
| assert payload["model"] == DEFAULT_ANTHROPIC_MODEL | |
| assert payload["system"] == SYSTEM_PROMPT | |
| return Response() | |
| class AnthropicClient: | |
| messages = Messages() | |
| def __init__(self, api_key, timeout): | |
| assert api_key == "test-key" | |
| assert timeout == 120.0 | |
| class AnthropicModule: | |
| Anthropic = AnthropicClient | |
| raw = _anthropic_message( | |
| api_key="test-key", | |
| model=DEFAULT_ANTHROPIC_MODEL, | |
| user_prompt="compile", | |
| timeout=120.0, | |
| anthropic_module=AnthropicModule, | |
| ) | |
| assert json.loads(raw)["rules"][0]["name"] == "notify" | |