from __future__ import annotations import json import ssl import subprocess import sys import unittest from pathlib import Path from flask import Flask from websocket import ABNF sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from src.application.app_context import AppContext from src.config.provider_manager import ProviderManager from src.config.provider_config import ProviderConfigSchema, RuntimeProviderSpec from src.external.stream_probe import probe_stream_response from src.external.upstream_websocket import ( WebSocketUpstreamResponse, collect_websocket_response_body, normalize_websocket_message, ) from src.proxy_core import resolve_stream_format from src.services.model_discovery_service import ModelDiscoveryService from src.utils.net import build_websocket_connect_options class FakeWebSocketConnection: def __init__(self, frames): self._frames = iter(frames) self.closed = False self.pongs = [] def recv_data(self, control_frame=True): return next(self._frames) def pong(self, payload): self.pongs.append(payload) def close(self): self.closed = True class FakeLogger: def __init__(self) -> None: self.records: list[tuple[str, str]] = [] def _log(self, level: str, msg: str, *args) -> None: rendered = msg % args if args else msg self.records.append((level, rendered)) def info(self, msg: str, *args) -> None: self._log("info", msg, *args) def warning(self, msg: str, *args) -> None: self._log("warning", msg, *args) def error(self, msg: str, *args) -> None: self._log("error", msg, *args) def debug(self, msg: str, *args) -> None: self._log("debug", msg, *args) class FakeAuthGroupManager: def __init__(self) -> None: self.registered: list[tuple[str, str]] = [] def register_legacy_provider_group(self, provider_name: str, api_key: str) -> str: self.registered.append((provider_name, api_key)) return f"__legacy_provider__/{provider_name}" class ProviderTransportTests(unittest.TestCase): def test_provider_enabled_defaults_to_true(self) -> None: schema = ProviderConfigSchema.from_mapping( { "name": "codex", "api": "https://example.com/v1/chat/completions", "api_key": "demo-key", "model_list": ["gpt-4.1"], } ) self.assertTrue(schema.enabled) runtime = RuntimeProviderSpec.from_schema(schema) self.assertTrue(runtime.enabled) def test_provider_transport_defaults_to_websocket_for_ws_scheme(self) -> None: schema = ProviderConfigSchema.from_mapping( { "name": "codex", "api": "wss://example.com/v1/chat/completions", "api_key": "demo-key", "model_list": ["gpt-4.1"], } ) self.assertEqual("websocket", schema.transport) def test_provider_timeout_defaults_to_1200_seconds(self) -> None: schema = ProviderConfigSchema.from_mapping( { "name": "codex", "api": "https://example.com/v1/chat/completions", "api_key": "demo-key", "model_list": ["gpt-4.1"], } ) self.assertIsNone(schema.timeout_seconds) runtime = RuntimeProviderSpec.from_schema(schema) self.assertEqual(1200, runtime.timeout_seconds) def test_provider_transport_allows_explicit_override(self) -> None: schema = ProviderConfigSchema.from_mapping( { "name": "codex", "api": "https://example.com/v1/chat/completions", "api_key": "demo-key", "transport": "websocket", "model_list": ["gpt-4.1"], } ) self.assertEqual("websocket", schema.transport) def test_provider_transport_rejects_http_transport_with_ws_scheme(self) -> None: with self.assertRaisesRegex(ValueError, "requires api to use http:// or https://"): ProviderConfigSchema.from_mapping( { "name": "bad-provider", "api": "wss://example.com/v1/chat/completions", "api_key": "demo-key", "transport": "http", "model_list": ["demo"], } ) def test_provider_defaults_source_and_target_formats(self) -> None: schema = ProviderConfigSchema.from_mapping( { "name": "demo", "api": "https://example.com/v1/chat/completions", "api_key": "demo-key", "model_list": ["gpt-4.1"], } ) self.assertEqual("openai_chat", schema.source_format) self.assertEqual("openai_chat", schema.target_format) self.assertEqual(("openai_chat",), schema.target_formats) runtime = RuntimeProviderSpec.from_schema(schema) self.assertEqual("openai_chat", runtime.source_format) self.assertEqual("openai_chat", runtime.target_format) self.assertEqual(("openai_chat",), runtime.target_formats) def test_provider_schema_accepts_multiple_target_formats(self) -> None: schema = ProviderConfigSchema.from_mapping( { "name": "demo", "api": "https://example.com/v1/chat/completions", "api_key": "demo-key", "target_formats": ["openai_chat", "claude_chat"], "model_list": ["gpt-4.1"], } ) self.assertEqual("openai_chat", schema.target_format) self.assertEqual(("openai_chat", "claude_chat"), schema.target_formats) def test_provider_schema_rejects_conflicting_target_formats(self) -> None: with self.assertRaisesRegex( ValueError, "Provider target_formats contains mutually exclusive formats: openai_responses, codex", ): ProviderConfigSchema.from_mapping( { "name": "demo", "api": "https://example.com/v1/chat/completions", "api_key": "demo-key", "target_formats": ["openai_responses", "codex"], "model_list": ["gpt-4.1"], } ) def test_provider_schema_rejects_legacy_target_format_field(self) -> None: with self.assertRaisesRegex(ValueError, "Unsupported provider field\\(s\\): target_format"): ProviderConfigSchema.from_mapping( { "name": "demo", "api": "https://example.com/v1/chat/completions", "api_key": "demo-key", "target_format": "claude_chat", "model_list": ["gpt-4.1"], } ) def test_provider_schema_rejects_removed_fields(self) -> None: with self.assertRaisesRegex(ValueError, "Unsupported provider field\\(s\\): format"): ProviderConfigSchema.from_mapping( { "name": "demo", "api": "https://example.com/v1/chat/completions", "api_key": "demo-key", "format": "openai_chat", "model_list": ["gpt-4.1"], } ) with self.assertRaisesRegex(ValueError, "Unsupported provider field\\(s\\): stream_format"): ProviderConfigSchema.from_mapping( { "name": "demo", "api": "https://example.com/v1/chat/completions", "api_key": "demo-key", "stream_format": "sse_json", "model_list": ["gpt-4.1"], } ) def test_internal_stream_detection_uses_transport_and_content_type(self) -> None: self.assertEqual("ws_json", resolve_stream_format(None, "", "websocket")) self.assertEqual("sse_json", resolve_stream_format(None, "text/event-stream; charset=utf-8", "http")) self.assertEqual("ndjson", resolve_stream_format(None, "application/x-ndjson", "http")) self.assertEqual("nonstream", resolve_stream_format(None, "application/json", "http")) def test_stream_probe_detects_sse_when_content_type_is_wrong(self) -> None: class FakeResponse: def __init__(self) -> None: self.status_code = 200 self.headers = {"Content-Type": "application/json"} self.closed = False self._chunks = iter( [ b"data: {\"ok\":true}\n\n", b"data: [DONE]\n\n", ] ) def iter_content(self, chunk_size=None): del chunk_size yield from self._chunks def close(self) -> None: self.closed = True response, is_stream = probe_stream_response(FakeResponse()) self.assertTrue(is_stream) self.assertEqual( [b"data: {\"ok\":true}\n\n", b"data: [DONE]\n\n"], list(response.iter_content()), ) class ProviderManagerEnabledTests(unittest.TestCase): def test_disabled_provider_is_not_registered_at_runtime(self) -> None: logger = FakeLogger() auth_group_manager = FakeAuthGroupManager() ctx = AppContext( logger=logger, config_manager=None, # type: ignore[arg-type] root_path=Path(__file__).resolve().parents[1], flask_app=Flask(__name__), ) manager = ProviderManager(ctx, auth_group_manager) manager.load_providers( ( ProviderConfigSchema.from_mapping( { "name": "disabled-provider", "enabled": False, "api": "https://example.com/v1/chat/completions", "api_key": "disabled-key", "model_list": ["gpt-4.1"], } ), ProviderConfigSchema.from_mapping( { "name": "enabled-provider", "api": "https://example.com/v1/chat/completions", "api_key": "enabled-key", "model_list": ["gpt-4.1-mini"], } ), ) ) self.assertEqual(("enabled-provider/gpt-4.1-mini",), manager.list_model_names()) self.assertIsNone(manager.get_provider_view("disabled-provider")) self.assertIsNotNone(manager.get_provider_view("enabled-provider")) self.assertEqual([("enabled-provider", "enabled-key")], auth_group_manager.registered) self.assertTrue( any("disabled-provider" in message and "skipped runtime registration" in message for _, message in logger.records) ) class WebSocketProxyBridgeTests(unittest.TestCase): def test_normalize_websocket_message_preserves_raw_payload(self) -> None: chunk = normalize_websocket_message(b'{"id":"evt_1"}') self.assertEqual(b'{"id":"evt_1"}', chunk) def test_stream_response_preserves_websocket_message_boundaries(self) -> None: connection = FakeWebSocketConnection( [ (ABNF.OPCODE_TEXT, b'{"id":"evt_1"}'), (ABNF.OPCODE_TEXT, b"data: [DONE]\n\n"), (ABNF.OPCODE_CLOSE, b""), ] ) response = WebSocketUpstreamResponse(connection) chunks = list(response.iter_content()) self.assertEqual( [ b'{"id":"evt_1"}', b"data: [DONE]\n\n", ], chunks, ) response.close() self.assertTrue(connection.closed) def test_collect_non_stream_websocket_body_uses_terminal_payload(self) -> None: connection = FakeWebSocketConnection( [ (ABNF.OPCODE_TEXT, b'{"delta":"hello"}'), ( ABNF.OPCODE_TEXT, b'{"choices":[],"usage":{"total_tokens":1},"model":"demo"}', ), (ABNF.OPCODE_CLOSE, b""), ] ) body = collect_websocket_response_body(connection) self.assertEqual( b'{"choices":[],"usage":{"total_tokens":1},"model":"demo"}', body, ) def test_collect_non_stream_websocket_body_extracts_sse_data(self) -> None: connection = FakeWebSocketConnection( [ (ABNF.OPCODE_TEXT, b"data: {\"id\":\"evt_1\"}\n\ndata: [DONE]\n\n"), (ABNF.OPCODE_CLOSE, b""), ] ) body = collect_websocket_response_body(connection) self.assertEqual(b'{"id":"evt_1"}', body) class WebSocketConnectOptionsTests(unittest.TestCase): def test_build_websocket_connect_options_supports_http_proxy(self) -> None: options = build_websocket_connect_options("http://user:pass@proxy.local:8080", False) self.assertEqual("proxy.local", options["http_proxy_host"]) self.assertEqual(8080, options["http_proxy_port"]) self.assertEqual(("user", "pass"), options["http_proxy_auth"]) self.assertEqual(ssl.CERT_NONE, options["sslopt"]["cert_reqs"]) class ModelDiscoveryCandidateTests(unittest.TestCase): def test_model_discovery_maps_websocket_scheme_to_https(self) -> None: candidates = ModelDiscoveryService._build_model_endpoint_candidates( "wss://example.com/v1/chat/completions" ) self.assertIn("https://example.com/v1/models", candidates) self.assertIn("https://example.com/models", candidates) self.assertNotIn("wss://example.com/v1/models", candidates) class ProviderTemplateTransportTests(unittest.TestCase): def test_provider_template_contains_clean_provider_fields_and_help(self) -> None: template_path = Path(__file__).resolve().parents[1] / "src" / "presentation" / "templates" / "providers.html" html = template_path.read_text(encoding="utf-8") users_template_path = Path(__file__).resolve().parents[1] / "src" / "presentation" / "templates" / "users.html" users_html = users_template_path.read_text(encoding="utf-8") css_path = ( Path(__file__).resolve().parents[1] / "src" / "presentation" / "static" / "css" / "providers.css" ) css = css_path.read_text(encoding="utf-8") self.assertIn('id="providerTransport"', html) self.assertIn('id="providerSourceFormat"', html) self.assertIn('id="providerTargetFormat"', html) self.assertNotIn('data-multi-select-badge="多选"', html) self.assertIn('data-multi-select-hint="可多选,点击已选项可取消;互斥项会自动替换"', html) self.assertIn('class="field-mode-badge">多选', html) self.assertNotIn('class="field-inline-note">可多选;点击已选项可取消,互斥项会自动替换。', html) self.assertIn('id="providerAuthMode"', html) self.assertIn('id="providerAuthGroup"', html) self.assertIn('providerTransportAutoSyncEnabled', html) self.assertIn('syncProviderTransportWithApi', html) self.assertIn("document.getElementById('providerApi').addEventListener('input', handleProviderApiInput);", html) self.assertIn("document.getElementById('providerTransport').addEventListener('change', handleProviderTransportChange);", html) self.assertIn('id="authGroupsContainer"', html) self.assertIn('id="authGroupModal"', html) self.assertIn('id="authEntryImportModal"', html) self.assertIn('id="authGroupRuntimeModal"', html) self.assertIn('id="providerModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false"', html) self.assertIn('id="authGroupModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false"', html) self.assertIn('id="authEntryImportModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false"', html) self.assertNotIn('id="providerFormat"', html) self.assertNotIn('id="providerStreamFormat"', html) for value in ("openai_chat", "openai_responses", "claude_chat", "codex"): self.assertIn(f'value="{value}"', html) for removed_value in ("gemini_chat", "gemini_cli", "antigravity"): self.assertNotIn(f'value="{removed_value}"', html) self.assertIn('data-provider-help-topic="transport"', html) self.assertIn('data-provider-help-topic="source_format"', html) self.assertIn('data-provider-help-topic="target_format"', html) self.assertIn('data-provider-help-topic="auth_group_field"', html) self.assertIn('data-provider-help-topic="auth_groups_overview"', html) self.assertIn('data-provider-help-topic="auth_group_strategy"', html) self.assertIn('data-provider-help-topic="auth_entries_editor"', html) self.assertIn('data-provider-help-topic="fetch_models"', html) self.assertIn('id="providerHelpPopover"', html) self.assertIn("function toggleProviderHelp(", html) self.assertIn("function syncProviderHelpPopover()", html) self.assertIn("function renderAuthGroups()", html) self.assertIn("function saveAuthGroup()", html) self.assertIn("function saveAuthEntriesFromYaml()", html) self.assertIn("function disableAuthGroupEntry(", html) self.assertIn("function enableAuthGroupEntry(", html) self.assertIn("function resetAuthGroupEntryRuntime(", html) self.assertIn("function openAuthEntryErrorModal(", html) self.assertIn("function updateProviderAuthModeFields()", html) self.assertIn("/api/auth-groups", html) self.assertIn("/api/auth-groups/import-entries", html) self.assertIn("'disable'", html) self.assertIn("'enable'", html) self.assertIn("'reset'", html) self.assertIn("setupCustomSelect('providerAuthMode');", html) self.assertIn("setupCustomSelect('providerAuthGroup');", html) self.assertIn("renderCustomSelectOptions('providerAuthGroup');", html) self.assertIn("setupCustomSelect('authGroupStrategy');", html) self.assertIn("setupCustomSelect('providerTransport');", html) self.assertIn("setupCustomSelect('providerSourceFormat');", html) self.assertIn("setupCustomSelect('providerTargetFormat');", html) self.assertIn("setupCustomSelect('providerVerifySsl');", html) self.assertIn("function buildMultiSelectTriggerMarkup(", html) self.assertNotIn("custom-select-trigger-badge", html) self.assertNotIn("已选 ${selectedLabels.length} 项", html) self.assertIn("custom-select-option custom-select-option-multi", html) self.assertIn("custom-select-menu-hint", html) self.assertIn("const defaultNewProviderTargetFormats = ['openai_chat', 'codex', 'claude_chat'];", html) self.assertIn("const providerTargetFormatConflictGroups = [", html) self.assertIn("function setProviderTargetFormatValues(", html) self.assertIn("target_formats: normalizedTargetFormats,", html) self.assertIn("id=\"selectedProviderCount\"", html) self.assertIn("id=\"enableSelectedProvidersBtn\"", html) self.assertIn("id=\"disableSelectedProvidersBtn\"", html) self.assertNotIn("id=\"deleteSelectedProvidersBtn\"", html) self.assertNotIn("id=\"clearSelectedProvidersBtn\"", html) self.assertIn("function updateProviderSelectionUi()", html) self.assertIn("function toggleProviderSelection(name, checked)", html) self.assertIn("function toggleAllProvidersSelection(checked)", html) self.assertIn("function clearSelectedProviders()", html) self.assertIn("function setProviderEnabled(name, enabled)", html) self.assertIn("function runProviderBatchAction(action)", html) self.assertIn("/api/providers/batch", html) self.assertIn("/api/providers/${encodeURIComponent(normalizedName)}/${enabled ? 'enable' : 'disable'}", html) self.assertIn('id="providerSelectAllCheckbox"', html) self.assertIn('data-provider-row-checkbox="${encodedProviderName}"', html) self.assertIn("class=\"btn-action ${isEnabled ? 'btn-delete' : 'btn-edit'}\"", html) self.assertNotIn("window.confirm(`确认批量删除已选中的 ${selectedNames.length} 个 Provider?`)", html) self.assertNotIn("setupCustomSelect('providerFormat');", html) self.assertNotIn("setupCustomSelect('providerStreamFormat');", html) self.assertNotIn('id="providerFormatMatrix"', html) self.assertNotIn('49 pairs', html) self.assertNotIn('data-bs-toggle="tooltip"', html) self.assertIn('id="fetchModelSelectAllCheckbox"', html) self.assertIn('toggleFilteredFetchedModels(this.checked)', html) self.assertIn('class="provider-model-cell"', html) self.assertIn('class="provider-meta-line"', html) self.assertIn('class="providers-table-shell auth-groups-table-shell"', html) self.assertIn('