|
| 1 | +"""Catalog discovery follows the selected host without starting model Turns.""" |
| 2 | +import json |
| 3 | +import sys |
| 4 | +from types import SimpleNamespace |
| 5 | + |
| 6 | +import pytest |
| 7 | + |
| 8 | +from loopx.chat_model_catalog import chat_model_catalog, codex_models |
| 9 | +from loopx.chat_model_catalog_api import ModelCatalogRequestMixin |
| 10 | + |
| 11 | + |
| 12 | +def _codex(tmp_path, *, malformed=False, legacy=False): |
| 13 | + executable = tmp_path / "codex-fixture" |
| 14 | + trace = tmp_path / "requests.jsonl" |
| 15 | + executable.write_text(f"#!{sys.executable}\n" + f"trace={str(trace)!r}\nmalformed={malformed!r}\nlegacy={legacy!r}\n" + ''' |
| 16 | +import json,os,sys |
| 17 | +if sys.argv[1:3] == ['debug','models']: |
| 18 | + print(json.dumps({'models':[{'slug':'compat-model','base_instructions':'synthetic host instructions'}]}));sys.exit(0) |
| 19 | +for line in sys.stdin: |
| 20 | + d=json.loads(line) |
| 21 | + with open(trace,'a') as f:f.write(json.dumps({'method':d['method'],'home':os.environ.get('CODEX_HOME')})+'\\n') |
| 22 | + if 'id' not in d:continue |
| 23 | + if d['method']=='initialize':result={} |
| 24 | + elif d['method']=='model/list': |
| 25 | + if legacy and '-c' not in sys.argv: |
| 26 | + print(json.dumps({'id':d['id'],'error':{'message':'model_catalog_json missing field base_instructions'}}),flush=True);continue |
| 27 | + if malformed:result={'data':[],'nextCursor':'repeated'} |
| 28 | + elif not d['params'].get('cursor'): |
| 29 | + result={'data':[{'id':'host-one','model':'host-one','displayName':'Host One','base_instructions':'never project these'}],'nextCursor':'second'} |
| 30 | + else:result={'data':[{'id':'host-two','model':'host-two','displayName':'Host Two'}],'nextCursor':None} |
| 31 | + else:raise RuntimeError('Catalog must not start a thread or Turn') |
| 32 | + print(json.dumps({'id':d['id'],'result':result}),flush=True) |
| 33 | +''') |
| 34 | + executable.chmod(0o700) |
| 35 | + return executable, trace |
| 36 | + |
| 37 | + |
| 38 | +def test_codex_model_list_pages_use_actual_home_and_never_start_a_turn(tmp_path): |
| 39 | + executable, trace = _codex(tmp_path) |
| 40 | + home = tmp_path / "codex-home" |
| 41 | + result = codex_models(str(executable), home) |
| 42 | + assert [model["id"] for model in result] == ["host-one", "host-two"] |
| 43 | + assert "never project" not in json.dumps(result) |
| 44 | + requests = [json.loads(line) for line in trace.read_text().splitlines()] |
| 45 | + assert [r["method"] for r in requests] == ["initialize", "initialized", "model/list", "model/list"] |
| 46 | + assert all(r["home"] == str(home) for r in requests) |
| 47 | + |
| 48 | + |
| 49 | +def test_invalid_pagination_is_unavailable_without_placeholder_models(tmp_path): |
| 50 | + executable, _ = _codex(tmp_path, malformed=True) |
| 51 | + controller = SimpleNamespace(codex_bin=str(executable), codex_home=tmp_path) |
| 52 | + result = chat_model_catalog(controller, "codex") |
| 53 | + assert result["available"] is False and result["models"] == [] |
| 54 | + assert str(tmp_path) not in json.dumps(result) |
| 55 | + |
| 56 | + |
| 57 | +def test_legacy_catalog_retry_remains_visible_in_discovery(tmp_path): |
| 58 | + executable, _ = _codex(tmp_path, legacy=True) |
| 59 | + result = chat_model_catalog(SimpleNamespace(codex_bin=str(executable), codex_home=tmp_path), "codex") |
| 60 | + assert result["available"] is True and result["compatibility_applied"] is True |
| 61 | + assert [row["id"] for row in result["models"]] == ["host-one", "host-two"] |
| 62 | + |
| 63 | + |
| 64 | +def test_native_picker_returns_declared_models_without_env_or_codex_defaults(monkeypatch, tmp_path): |
| 65 | + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) |
| 66 | + (tmp_path / "settings.json").write_text(json.dumps({"env": {"PRIVATE_FIELD": "not model metadata"}, |
| 67 | + "modelPicker": {"options": [{"model": "native-one[1m]", "label": "Native One"}, |
| 68 | + {"model": "native-two", "description": "Native Two"}]}})) |
| 69 | + result = chat_model_catalog(SimpleNamespace(), "claude-code") |
| 70 | + assert result["available"] and [row["id"] for row in result["models"]] == ["native-one[1m]", "native-two"] |
| 71 | + assert "not model metadata" not in json.dumps(result) |
| 72 | + assert str(tmp_path) not in json.dumps(result) |
| 73 | + (tmp_path / "settings.json").write_text("[]") |
| 74 | + assert chat_model_catalog(SimpleNamespace(), "claude-code")["available"] is False |
| 75 | + |
| 76 | + |
| 77 | +@pytest.mark.parametrize("query", ["", "?endpoint_id=", "?endpoint_id=codex&endpoint_id=claude-code", "?endpoint_id=codex&command=arbitrary"]) |
| 78 | +def test_catalog_api_does_not_accept_commands_or_ambiguous_hosts(query): |
| 79 | + class Handler(ModelCatalogRequestMixin): |
| 80 | + path = "/api/chat/models" + query |
| 81 | + def _send_error(self, message, **kwargs): |
| 82 | + self.error = kwargs |
| 83 | + handler = Handler() |
| 84 | + handler._model_catalog() |
| 85 | + assert handler.error["status"] == 400 |
0 commit comments