diff --git a/hooks/example_hook.py b/hooks/example_hook.py index 722c8ec..88a0743 100644 --- a/hooks/example_hook.py +++ b/hooks/example_hook.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Any from src.hooks import BaseHook, HookContext, HookErrorType diff --git a/src/application/app_context.py b/src/application/app_context.py index 3eb9a1f..23d205d 100644 --- a/src/application/app_context.py +++ b/src/application/app_context.py @@ -4,7 +4,9 @@ from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING + +from ..utils.compat import Protocol if TYPE_CHECKING: from ..config import ConfigManager diff --git a/src/config/auth_group_manager.py b/src/config/auth_group_manager.py index 03d810e..39d1d5f 100644 --- a/src/config/auth_group_manager.py +++ b/src/config/auth_group_manager.py @@ -4,7 +4,6 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import datetime, timedelta, timezone from email.utils import parsedate_to_datetime from typing import Any, Dict, Mapping, Optional, Sequence @@ -12,6 +11,7 @@ from typing import Any, Dict, Mapping, Optional, Sequence from ..application.app_context import AppContext, Logger from ..hooks import HookErrorType from ..repositories import AuthGroupRepository +from ..utils.compat import dataclass from ..utils.local_time import format_local_datetime, now_local_datetime, parse_local_datetime from .provider_config import ( AuthEntrySchema, diff --git a/src/config/provider_config.py b/src/config/provider_config.py index 1a236fc..4812c6f 100644 --- a/src/config/provider_config.py +++ b/src/config/provider_config.py @@ -4,10 +4,10 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence from urllib.parse import urlparse +from ..utils.compat import dataclass from ..utils.net import normalize_proxy_url DEFAULT_PROVIDER_TIMEOUT_SECONDS = 1200 diff --git a/src/config/provider_manager.py b/src/config/provider_manager.py index c50f037..49bf60c 100644 --- a/src/config/provider_manager.py +++ b/src/config/provider_manager.py @@ -2,6 +2,8 @@ # -*- coding: utf-8 -*- """Provider 管理器:负责加载配置、构建运行时对象并暴露只读注册表接口。""" +from __future__ import annotations + import hashlib import importlib.util import inspect diff --git a/src/executors/contracts.py b/src/executors/contracts.py index 9bce071..4b97e64 100644 --- a/src/executors/contracts.py +++ b/src/executors/contracts.py @@ -4,10 +4,10 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, Dict, Optional, Protocol +from typing import Any, Dict, Optional from ..external import LLMProvider +from ..utils.compat import Protocol, dataclass @dataclass(frozen=True, slots=True) diff --git a/src/external/llm_provider.py b/src/external/llm_provider.py index 850531f..07882be 100644 --- a/src/external/llm_provider.py +++ b/src/external/llm_provider.py @@ -2,10 +2,12 @@ # -*- coding: utf-8 -*- """LLM provider 运行时对象。""" -from dataclasses import dataclass +from __future__ import annotations + from typing import Any, Dict, Optional from ..hooks import HookContext, HookModule +from ..utils.compat import dataclass @dataclass(frozen=True, slots=True) diff --git a/src/hooks/contracts.py b/src/hooks/contracts.py index 382493b..0da0f00 100644 --- a/src/hooks/contracts.py +++ b/src/hooks/contracts.py @@ -3,11 +3,11 @@ """Hook type contracts.""" from dataclasses import dataclass -from enum import StrEnum from pathlib import Path -from typing import Any, Dict, Optional, Protocol +from typing import Any, Dict, Optional from ..application.app_context import Logger +from ..utils.compat import Protocol, StrEnum class HookAbortError(Exception): diff --git a/src/proxy_core/contracts.py b/src/proxy_core/contracts.py index 1b7f61f..2355d4d 100644 --- a/src/proxy_core/contracts.py +++ b/src/proxy_core/contracts.py @@ -4,8 +4,9 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, Dict, Literal, Optional +from typing import Any, Dict, Optional + +from ..utils.compat import Literal, dataclass @dataclass(frozen=True, slots=True) diff --git a/src/repositories/user_repository.py b/src/repositories/user_repository.py index 5bbc4e9..410a590 100644 --- a/src/repositories/user_repository.py +++ b/src/repositories/user_repository.py @@ -2,6 +2,8 @@ # -*- coding: utf-8 -*- """用户仓储。""" +from __future__ import annotations + from typing import Any, Dict, List, Optional from ..utils.database import ConnectionFactory diff --git a/src/services/proxy_service.py b/src/services/proxy_service.py index 4fc48d9..d0d52d5 100644 --- a/src/services/proxy_service.py +++ b/src/services/proxy_service.py @@ -5,7 +5,7 @@ from __future__ import annotations import json -from dataclasses import dataclass, replace +from dataclasses import replace from typing import Any, Callable, Dict, Iterator, Optional, Tuple import requests @@ -26,6 +26,7 @@ from ..proxy_core import ( should_emit_terminal_chunk, ) from ..translators import Translator, build_default_translator_registry +from ..utils.compat import dataclass from ..utils.net import build_requests_proxies diff --git a/src/services/user_service.py b/src/services/user_service.py index 8d870e8..ad591b4 100644 --- a/src/services/user_service.py +++ b/src/services/user_service.py @@ -2,6 +2,8 @@ # -*- coding: utf-8 -*- """用户服务。""" +from __future__ import annotations + import json import threading from typing import Any, Dict, Iterable, List, Optional, Sequence diff --git a/src/translators/registry.py b/src/translators/registry.py index 704ba29..f65488a 100644 --- a/src/translators/registry.py +++ b/src/translators/registry.py @@ -6,10 +6,10 @@ from __future__ import annotations import json import time -from dataclasses import dataclass -from typing import Any, Dict, Optional, Protocol +from typing import Any, Dict, Optional from ..proxy_core.contracts import DownstreamChunk, StreamEvent +from ..utils.compat import Protocol, dataclass from .claude_bridge import ( convert_claude_request_to_openai_chat_request as _convert_claude_request_to_openai_chat_request, convert_openai_chat_response_to_claude as _convert_openai_chat_response_to_claude, @@ -175,8 +175,11 @@ class OpenAIResponsesTranslator: if isinstance(item, dict) and str(item.get("type") or "").strip().lower() == "function_call": item_id = str(item.get("id") or payload.get("item_id") or "").strip() if item_id: + call_id = str(item.get("call_id") or "").strip() + if not call_id: + call_id = item_id[3:] if item_id.startswith("fc_") else item_id items[item_id] = { - "id": str(item.get("call_id") or item_id.removeprefix("fc_") or item_id), + "id": call_id, "name": str(item.get("name") or ""), "index": int(payload.get("output_index") or 0), "delta_emitted": False, diff --git a/src/utils/compat.py b/src/utils/compat.py new file mode 100644 index 0000000..b7a827d --- /dev/null +++ b/src/utils/compat.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Compatibility helpers for supported Python runtimes.""" + +import sys +from dataclasses import dataclass as _dataclass +from enum import Enum +from typing import Any + +try: + from enum import StrEnum +except ImportError: + class StrEnum(str, Enum): + """Fallback StrEnum for Python versions before 3.11.""" + + pass + +try: + from typing import Literal +except ImportError: + try: + from typing_extensions import Literal # type: ignore + except ImportError: + class _LiteralCompat(object): + def __getitem__(self, _item): + return Any + + Literal = _LiteralCompat() + +try: + from typing import Protocol +except ImportError: + try: + from typing_extensions import Protocol # type: ignore + except ImportError: + class Protocol(object): + """Fallback Protocol base when typing_extensions is unavailable.""" + + pass + + +def dataclass(_cls=None, **kwargs): + """Mirror dataclasses.dataclass while skipping unsupported keywords.""" + + if sys.version_info < (3, 10): + kwargs.pop("slots", None) + if _cls is None: + return _dataclass(**kwargs) + return _dataclass(_cls, **kwargs) + + +__all__ = ["dataclass", "Literal", "Protocol", "StrEnum"] diff --git a/src/utils/net.py b/src/utils/net.py index cac673a..26f9209 100644 --- a/src/utils/net.py +++ b/src/utils/net.py @@ -29,8 +29,12 @@ def is_valid_ip(ip_value: Optional[str]) -> bool: if not ip_value: return False + value = ip_value.strip() + if value.startswith('::ffff:'): + value = value[7:] + try: - ipaddress.ip_address(ip_value.strip().removeprefix('::ffff:')) + ipaddress.ip_address(value) return True except ValueError: return False diff --git a/tests/test_local_time_storage.py b/tests/test_local_time_storage.py deleted file mode 100644 index a45f5d8..0000000 --- a/tests/test_local_time_storage.py +++ /dev/null @@ -1,190 +0,0 @@ -import sys -import tempfile -import unittest -from pathlib import Path -from types import SimpleNamespace - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from src.repositories import LogRepository, UserRepository -from src.services import LogService, UserService -from src.utils.database import create_connection_factory -from src.utils.local_time import parse_local_datetime - - -class DummyLogger: - def info(self, *args, **kwargs): - return None - - def error(self, *args, **kwargs): - return None - - def warning(self, *args, **kwargs): - return None - - def debug(self, *args, **kwargs): - return None - - -class LocalTimeRepositoryTests(unittest.TestCase): - def setUp(self) -> None: - self._tempdir = tempfile.TemporaryDirectory() - self._db_path = Path(self._tempdir.name) / "requests.db" - self._get_connection = create_connection_factory(self._db_path) - - def tearDown(self) -> None: - self._tempdir.cleanup() - - def _table_sql(self, table_name: str) -> str: - with self._get_connection() as conn: - row = conn.execute( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", - (table_name,), - ).fetchone() - self.assertIsNotNone(row) - return row["sql"] - - def test_user_table_schema_removes_timestamp_defaults(self) -> None: - UserRepository(self._get_connection) - - sql = self._table_sql("users") - - self.assertIn("created_at TEXT NOT NULL", sql) - self.assertIn("updated_at TEXT NOT NULL", sql) - self.assertNotIn("CURRENT_TIMESTAMP", sql) - - def test_log_tables_schema_remove_timestamp_defaults(self) -> None: - LogRepository(self._get_connection) - - request_logs_sql = self._table_sql("request_logs") - daily_stats_sql = self._table_sql("daily_request_stats") - - self.assertIn("start_time TEXT NOT NULL", request_logs_sql) - self.assertIn("created_at TEXT NOT NULL", request_logs_sql) - self.assertNotIn("CURRENT_TIMESTAMP", request_logs_sql) - self.assertIn("created_at TEXT NOT NULL", daily_stats_sql) - self.assertIn("updated_at TEXT NOT NULL", daily_stats_sql) - self.assertNotIn("CURRENT_TIMESTAMP", daily_stats_sql) - - def test_user_repository_create_writes_local_time_text(self) -> None: - repository = UserRepository(self._get_connection) - - user_id = repository.create("alice", "127.0.0.1") - user = repository.get_by_id(user_id) - - self.assertIsNotNone(user) - self.assertEqual(user["created_at"], user["updated_at"]) - self.assertIn(".", user["created_at"]) - self.assertIsNotNone(parse_local_datetime(user["created_at"])) - - def test_log_repository_insert_writes_local_times_and_stat_date(self) -> None: - repository = LogRepository(self._get_connection) - - log_id = repository.insert( - request_model="demo-model", - response_model="demo-response", - total_tokens=12, - prompt_tokens=5, - completion_tokens=7, - start_time="2026-03-30 09:10:11.123456", - end_time="2026-03-30 09:10:12.654321", - ip_address="127.0.0.1", - ) - - with self._get_connection() as conn: - log_row = conn.execute( - "SELECT start_time, end_time, created_at FROM request_logs WHERE id = ?", - (log_id,), - ).fetchone() - stat_row = conn.execute( - """ - SELECT stat_date, created_at, updated_at - FROM daily_request_stats - WHERE ip_address = ? AND request_model = ? - """, - ("127.0.0.1", "demo-model"), - ).fetchone() - - self.assertEqual("2026-03-30 09:10:11.123456", log_row["start_time"]) - self.assertEqual("2026-03-30 09:10:12.654321", log_row["end_time"]) - self.assertIn(".", log_row["created_at"]) - self.assertEqual("2026-03-30", stat_row["stat_date"]) - self.assertIn(".", stat_row["created_at"]) - self.assertIn(".", stat_row["updated_at"]) - - -class LocalTimeServiceTests(unittest.TestCase): - def setUp(self) -> None: - self._tempdir = tempfile.TemporaryDirectory() - self._db_path = Path(self._tempdir.name) / "requests.db" - self._get_connection = create_connection_factory(self._db_path) - self._ctx = SimpleNamespace(logger=DummyLogger()) - - def tearDown(self) -> None: - self._tempdir.cleanup() - - def test_user_service_returns_normalized_local_time_strings(self) -> None: - repository = UserRepository(self._get_connection) - user_id = repository.create("alice", "127.0.0.1") - - with self._get_connection() as conn: - conn.execute( - "UPDATE users SET created_at = ?, updated_at = ? WHERE id = ?", - ("2026-03-30 08:09:10", "2026-03-30 08:09:11", user_id), - ) - - service = UserService(self._ctx, repository) - user = service.get_user_by_id(user_id) - - self.assertEqual("2026-03-30 08:09:10.000000", user["created_at"]) - self.assertEqual("2026-03-30 08:09:11.000000", user["updated_at"]) - - def test_log_service_returns_normalized_local_time_strings(self) -> None: - UserRepository(self._get_connection) - repository = LogRepository(self._get_connection) - log_id = repository.insert( - request_model="demo-model", - response_model="demo-response", - total_tokens=4, - start_time="2026-03-30 08:09:10", - end_time="2026-03-30 08:09:11", - ip_address="127.0.0.1", - ) - - with self._get_connection() as conn: - conn.execute( - "UPDATE request_logs SET created_at = ? WHERE id = ?", - ("2026-03-30 08:09:12", log_id), - ) - - service = LogService(self._ctx, repository) - payload = service.get_request_logs(page=1, page_size=10) - log = payload["logs"][0] - - self.assertEqual("2026-03-30 08:09:10.000000", log["start_time"]) - self.assertEqual("2026-03-30 08:09:11.000000", log["end_time"]) - self.assertEqual("2026-03-30 08:09:12.000000", log["created_at"]) - - -class LocalTimeSqlScriptTests(unittest.TestCase): - def test_manual_sql_scripts_exist(self) -> None: - root = Path(__file__).resolve().parents[1] - migrate_sql = (root / "scripts" / "sql" / "001_migrate_created_at_to_localtime.sql").read_text( - encoding="utf-8" - ) - rebuild_sql = (root / "scripts" / "sql" / "002_rebuild_tables_remove_time_defaults.sql").read_text( - encoding="utf-8" - ) - - self.assertIn("datetime(created_at, 'localtime') || '.000000'", migrate_sql) - self.assertIn("ALTER TABLE users RENAME TO users_old;", rebuild_sql) - self.assertIn("DROP INDEX IF EXISTS idx_users_ip;", rebuild_sql) - self.assertIn("DROP INDEX IF EXISTS idx_start_time;", rebuild_sql) - self.assertIn("DROP INDEX IF EXISTS idx_daily_stats_date;", rebuild_sql) - self.assertIn("CREATE TABLE request_logs (", rebuild_sql) - self.assertIn("created_at TEXT NOT NULL", rebuild_sql) - self.assertNotIn("CURRENT_TIMESTAMP", rebuild_sql) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_proxy_error_handling.py b/tests/test_proxy_error_handling.py index 463d102..f3e5ad6 100644 --- a/tests/test_proxy_error_handling.py +++ b/tests/test_proxy_error_handling.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys import unittest from pathlib import Path diff --git a/tests/test_proxy_transport.py b/tests/test_proxy_transport.py index bb700f6..4472cff 100644 --- a/tests/test_proxy_transport.py +++ b/tests/test_proxy_transport.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import ssl import subprocess