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: @staticmethod 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: @staticmethod 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: @staticmethod 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: @staticmethod 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"