"""Unit tests for the GPT/Claude provider switch. The unit CI job installs only pytest, so the heavy runtime deps that ``ai_functions`` imports unguarded (``openai``, ``tiktoken``, ``other_functions`` -> ``discord``) are stubbed *only when genuinely absent*. Locally, where the real packages exist, the stubs are skipped and the real modules are used. """ import sys import types def _stub_if_missing(name: str, build): if name in sys.modules: return try: # real package present (local dev / bot image) -> use it __import__(name) except ImportError: sys.modules[name] = build() def _build_tiktoken(): mod = types.ModuleType("tiktoken") class _Enc: def encode(self, text): return list(text) mod.encoding_for_model = lambda _model: _Enc() return mod def _build_other_functions(): mod = types.ModuleType("other_functions") async def _noop(*_a, **_k): return None mod.discord_friendly_send = _noop mod.discord_friendly_reply = _noop return mod def _build_openai(): mod = types.ModuleType("openai") for cls_name in ( "APITimeoutError", "APIConnectionError", "BadRequestError", "APIResponseValidationError", "AuthenticationError", "PermissionDeniedError", "RateLimitError", "UnprocessableEntityError", "APIError", "OpenAIError", ): setattr(mod, cls_name, type(cls_name, (Exception,), {})) return mod _stub_if_missing("tiktoken", _build_tiktoken) _stub_if_missing("other_functions", _build_other_functions) _stub_if_missing("openai", _build_openai) import ai_functions # noqa: E402 (import after stubbing) import openai # noqa: E402 (real or stub, same object ai_functions uses) def _reset_active(name="gpt"): ai_functions._ACTIVE_CONFIG_NAME = name def test_split_extracts_system_and_starts_with_user(): system, convo = ai_functions._to_anthropic_messages( [ {"role": "system", "content": "SYS1"}, {"role": "system", "content": "SYS2"}, {"role": "assistant", "content": "leading-assistant-dropped"}, {"role": "user", "content": "u1"}, {"role": "assistant", "content": "a1"}, {"role": "user", "content": "hi"}, ] ) assert system == "SYS1\n\nSYS2" assert convo[0] == {"role": "user", "content": "u1"} assert convo == [ {"role": "user", "content": "u1"}, {"role": "assistant", "content": "a1"}, {"role": "user", "content": "hi"}, ] def test_split_synthesises_user_turn_when_only_system(): _system, convo = ai_functions._to_anthropic_messages( [{"role": "system", "content": "only"}] ) assert convo == [{"role": "user", "content": " "}] def test_select_model_gpt_active(): _reset_active("gpt") # legacy default auto-selects; MUSIC is cheap; explicit ids are honoured assert ai_functions.select_model("GENERAL", "gpt-4o") == "gpt-4o" assert ai_functions.select_model("MUSIC", "gpt-4o") == "gpt-4o-mini" assert ai_functions.select_model("MUSIC", "auto") == "gpt-4o-mini" assert ai_functions.select_model("GENERAL", "o1-preview") == "o1-preview" def test_select_model_claude_active_maps_legacy_default(): _reset_active("claude") try: # the old gpt-4o default must not leak to Claude - it auto-maps assert ai_functions.select_model("GENERAL", "gpt-4o") == "claude-opus-4-8" assert ai_functions.select_model("MUSIC", "gpt-4o") == "claude-haiku-4-5" # a real, deliberate model id is still honoured verbatim assert ai_functions.select_model("GENERAL", "claude-sonnet-5") == "claude-sonnet-5" finally: _reset_active("gpt") def test_list_ai_configs_hides_templates(): names = ai_functions.list_ai_configs() assert "_template" not in names assert {"gpt", "claude"}.issubset(set(names)) def _bare(cls): # Build an instance without invoking __init__ - the real openai SDK # exceptions require response/body kwargs, the CI stubs don't. isinstance # (all _map_openai_error cares about) works on __new__-created instances. return cls.__new__(cls) def test_map_openai_error_categories(): assert ai_functions._map_openai_error(_bare(openai.AuthenticationError)).category == "auth" assert ai_functions._map_openai_error(_bare(openai.RateLimitError)).category == "rate_limit" assert ai_functions._map_openai_error(_bare(openai.APITimeoutError)).category == "timeout" assert ai_functions._map_openai_error(ValueError("x")).category == "api"