File size: 8,773 Bytes
36fc86c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
from unittest.mock import MagicMock, patch

import pytest
import requests

from engine import openrouter_backend as ob


# ── prompt building / response parsing (pure functions) ────────────────────

def test_build_batch_prompt_numbers_lines_in_order():
    prompt = ob._build_batch_prompt("TEMPLATE", ["first", "second", "third"])
    assert "1. first" in prompt
    assert "2. second" in prompt
    assert "3. third" in prompt
    assert prompt.startswith("TEMPLATE")


def test_build_batch_prompt_includes_preceding_context_when_given():
    prompt = ob._build_batch_prompt("TEMPLATE", ["text"], preceding=["prev1", "prev2"])
    assert "prev1" in prompt
    assert "prev2" in prompt
    assert "translated immediately before" in prompt


def test_build_batch_prompt_omits_context_block_when_no_preceding():
    prompt = ob._build_batch_prompt("TEMPLATE", ["text"], preceding=None)
    assert "translated immediately before" not in prompt


def test_parse_batch_response_happy_path():
    response = "1. hello\n2. world\n3. foo bar"
    assert ob._parse_batch_response(response, 3) == ["hello", "world", "foo bar"]


def test_parse_batch_response_wrong_count_returns_none():
    response = "1. hello\n2. world"
    assert ob._parse_batch_response(response, 3) is None


def test_parse_batch_response_ignores_non_numbered_lines():
    response = "Sure, here you go:\n1. hello\n2. world\nThanks!"
    assert ob._parse_batch_response(response, 2) == ["hello", "world"]


def test_parse_batch_response_empty_input():
    assert ob._parse_batch_response("", 0) == []
    assert ob._parse_batch_response("", 1) is None


# ── model listing / curation ────────────────────────────────────────────────

def _model(id_, modality="text->text", output_modalities=None):
    return {
        "id": id_,
        "architecture": {
            "modality": modality,
            "output_modalities": output_modalities if output_modalities is not None else ["text"],
        },
    }


def test_is_text_model_accepts_text_to_text():
    assert ob._is_text_model(_model("a/a", modality="text->text"))


def test_is_text_model_accepts_multimodal_input_text_output():
    assert ob._is_text_model(_model("a/a", modality="text+image->text"))


def test_is_text_model_rejects_image_output():
    m = _model("a/a", modality="text->image", output_modalities=["image"])
    assert not ob._is_text_model(m)


def test_list_model_choices_puts_curated_first_then_rest_alphabetically():
    fetched = [
        _model("zzz/last"),
        _model(ob.CURATED_MODELS[1]),
        _model("aaa/first"),
        _model(ob.CURATED_MODELS[0]),
    ]
    with patch.object(ob, "fetch_available_models", return_value=fetched):
        choices = ob.list_model_choices()
    assert choices == [ob.CURATED_MODELS[0], ob.CURATED_MODELS[1], "aaa/first", "zzz/last"]


def test_list_model_choices_drops_curated_models_not_in_catalog():
    fetched = [_model("aaa/first")]
    with patch.object(ob, "fetch_available_models", return_value=fetched):
        choices = ob.list_model_choices()
    assert choices == ["aaa/first"]
    for curated in ob.CURATED_MODELS:
        assert curated not in choices


def test_list_model_choices_filters_out_non_text_models():
    fetched = [
        _model(ob.CURATED_MODELS[0]),
        _model("image/only", modality="text->image", output_modalities=["image"]),
    ]
    with patch.object(ob, "fetch_available_models", return_value=fetched):
        choices = ob.list_model_choices()
    assert "image/only" not in choices


def test_list_model_choices_falls_back_to_curated_on_fetch_failure():
    with patch.object(ob, "fetch_available_models", side_effect=requests.RequestException("network down")):
        choices = ob.list_model_choices()
    assert choices == list(ob.CURATED_MODELS)


def test_fetch_available_models_hits_public_models_endpoint():
    fake_resp = MagicMock()
    fake_resp.json.return_value = {"data": [{"id": "a/a"}]}
    with patch.object(ob.requests, "get", return_value=fake_resp) as mock_get:
        result = ob.fetch_available_models(timeout=5)
    mock_get.assert_called_once_with(f"{ob.BASE_URL}/models", timeout=5)
    fake_resp.raise_for_status.assert_called_once()
    assert result == [{"id": "a/a"}]


# ── _call_openrouter (network layer) ────────────────────────────────────────

def _http_response(status_code, json_body=None, text=""):
    resp = MagicMock()
    resp.status_code = status_code
    resp.text = text
    if json_body is not None:
        resp.json.return_value = json_body
    return resp


def test_call_openrouter_success_returns_message_content():
    ok = _http_response(200, {"choices": [{"message": {"content": "translated text"}}]})
    with patch.object(ob.requests, "post", return_value=ok) as mock_post:
        result = ob._call_openrouter("key123", "some/model", "prompt text")
    assert result == "translated text"

    _, kwargs = mock_post.call_args
    assert kwargs["headers"]["Authorization"] == "Bearer key123"
    assert kwargs["json"]["models"][0] == "some/model"
    assert kwargs["json"]["messages"] == [{"role": "user", "content": "prompt text"}]


def test_call_openrouter_includes_curated_models_as_serverside_fallback():
    ok = _http_response(200, {"choices": [{"message": {"content": "x"}}]})
    with patch.object(ob.requests, "post", return_value=ok) as mock_post:
        ob._call_openrouter("key", ob.CURATED_MODELS[2], "prompt")
    sent_models = mock_post.call_args.kwargs["json"]["models"]
    assert sent_models[0] == ob.CURATED_MODELS[2]
    assert set(sent_models) == set(ob.CURATED_MODELS)
    assert len(sent_models) == len(ob.CURATED_MODELS)  # no duplicate of the primary model


def test_call_openrouter_retries_on_retryable_status_then_succeeds():
    bad = _http_response(429, text="rate limited")
    good = _http_response(200, {"choices": [{"message": {"content": "ok"}}]})
    with patch.object(ob.requests, "post", side_effect=[bad, good]), patch.object(ob.time, "sleep"):
        result = ob._call_openrouter("key", "model", "prompt")
    assert result == "ok"


def test_call_openrouter_raises_immediately_on_non_retryable_status():
    unauthorized = _http_response(401, text="invalid api key")
    with patch.object(ob.requests, "post", return_value=unauthorized) as mock_post:
        with pytest.raises(RuntimeError, match="401"):
            ob._call_openrouter("bad-key", "model", "prompt")
    assert mock_post.call_count == 1


def test_call_openrouter_exhausts_retries_and_raises_last_error():
    bad = _http_response(503, text="unavailable")
    with patch.object(ob.requests, "post", return_value=bad), patch.object(ob.time, "sleep"):
        with pytest.raises(RuntimeError, match="503"):
            ob._call_openrouter("key", "model", "prompt")


def test_call_openrouter_retries_on_network_exception():
    good = _http_response(200, {"choices": [{"message": {"content": "recovered"}}]})
    with patch.object(
        ob.requests, "post", side_effect=[requests.ConnectionError("boom"), good]
    ), patch.object(ob.time, "sleep"):
        result = ob._call_openrouter("key", "model", "prompt")
    assert result == "recovered"


# ── translate_one / translate_batch ─────────────────────────────────────────

def test_translate_one_strips_whitespace_from_response():
    with patch.object(ob, "_call_openrouter", return_value="  translated  \n"):
        result = ob.translate_one("source text", "key", "model", "template")
    assert result == "translated"


def test_translate_batch_empty_texts_short_circuits_without_network():
    with patch.object(ob, "_call_openrouter") as mock_call:
        result = ob.translate_batch([], "key", "model", "template")
    assert result == []
    mock_call.assert_not_called()


def test_translate_batch_happy_path():
    with patch.object(ob, "_call_openrouter", return_value="1. one\n2. two"):
        result = ob.translate_batch(["a", "b"], "key", "model", "template")
    assert result == ["one", "two"]


def test_translate_batch_returns_none_on_parse_mismatch():
    with patch.object(ob, "_call_openrouter", return_value="1. only one line"):
        result = ob.translate_batch(["a", "b"], "key", "model", "template")
    assert result is None


def test_translate_batch_returns_none_on_network_error():
    with patch.object(ob, "_call_openrouter", side_effect=RuntimeError("boom")):
        result = ob.translate_batch(["a", "b"], "key", "model", "template")
    assert result is None