Initial project import

This commit is contained in:
XM
2026-09-18 03:46:58 +08:00
commit b600da4b76
209 changed files with 45904 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+118
View File
@@ -0,0 +1,118 @@
import base64
import json
import unittest
from typing import Any
from services.account_service import AccountService
class MemoryStorage:
def __init__(self, accounts: list[dict[str, Any]] | None = None) -> None:
self.accounts = list(accounts or [])
def load_accounts(self) -> list[dict[str, Any]]:
return list(self.accounts)
def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
self.accounts = list(accounts)
def load_auth_keys(self) -> list[dict[str, Any]]:
return []
def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
pass
def health_check(self) -> dict[str, Any]:
return {"ok": True}
def get_backend_info(self) -> dict[str, Any]:
return {"type": "memory"}
def make_jwt(payload: dict[str, Any]) -> str:
def encode(value: dict[str, Any]) -> str:
raw = json.dumps(value, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
return f'{encode({"alg": "none", "typ": "JWT"})}.{encode(payload)}.sig'
class AccountExportTests(unittest.TestCase):
def test_build_export_items_uses_codex_shape_and_jwt_claims(self) -> None:
access_token = make_jwt(
{
"exp": 0,
"iat": 3600,
"https://api.openai.com/auth": {"chatgpt_account_id": "acct_123"},
"https://api.openai.com/profile": {"email": "test@example.com"},
}
)
id_token = make_jwt({"email": "fallback@example.com"})
service = AccountService(
MemoryStorage(
[
{
"access_token": access_token,
"id_token": id_token,
"refresh_token": "rt_test",
}
]
)
)
[item] = service.build_export_items([access_token])
self.assertEqual(item["type"], "codex")
self.assertEqual(item["email"], "test@example.com")
self.assertEqual(item["expired"], "1970-01-01T08:00:00+08:00")
self.assertEqual(item["account_id"], "acct_123")
self.assertEqual(item["access_token"], access_token)
self.assertEqual(item["last_refresh"], "1970-01-01T09:00:00+08:00")
self.assertEqual(item["id_token"], id_token)
self.assertEqual(item["refresh_token"], "rt_test")
def test_build_export_items_skips_accounts_missing_complete_tokens(self) -> None:
complete_access_token = make_jwt({"exp": 0})
complete_id_token = make_jwt({"email": "complete@example.com"})
service = AccountService(
MemoryStorage(
[
{"access_token": "only_access"},
{"access_token": "missing_id", "refresh_token": "rt_missing_id"},
{"access_token": complete_access_token, "id_token": complete_id_token, "refresh_token": "rt_complete"},
]
)
)
items = service.build_export_items()
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["access_token"], complete_access_token)
self.assertEqual(items[0]["id_token"], complete_id_token)
self.assertEqual(items[0]["refresh_token"], "rt_complete")
def test_add_account_items_preserves_export_fields_without_overwriting_plan_type(self) -> None:
service = AccountService(MemoryStorage())
result = service.add_account_items(
[
{
"type": "codex",
"access_token": "access_token_test",
"refresh_token": "rt_test",
"account_id": "acct_123",
}
]
)
account = service.get_account("access_token_test")
self.assertEqual(result["added"], 1)
self.assertIsNotNone(account)
self.assertEqual(account["type"], "free")
self.assertEqual(account["export_type"], "codex")
self.assertEqual(account["refresh_token"], "rt_test")
self.assertEqual(account["account_id"], "acct_123")
if __name__ == "__main__":
unittest.main()
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("CHATGPT2API_AUTH_KEY", "test-auth")
from services.account_service import AccountService
from services.auth_service import AuthService
from services.config import config
from services.openai_backend_api import InvalidAccessTokenError
from services.storage.json_storage import JSONStorageBackend
from utils.helper import anonymize_token, split_image_model
class AccountCapabilityTests(unittest.TestCase):
def test_image_accounts_require_positive_quota(self) -> None:
self.assertFalse(
AccountService._is_image_account_available(
{"status": "限流", "quota": 1}
)
)
self.assertFalse(
AccountService._is_image_account_available(
{"status": "正常", "quota": 0}
)
)
self.assertTrue(AccountService._is_image_account_available({"status": "正常", "quota": 1}))
def test_prolite_variants_are_normalized(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
self.assertEqual(service._normalize_account_type("prolite"), "ProLite")
self.assertEqual(service._normalize_account_type("pro_lite"), "ProLite")
def test_search_account_type_ignores_unrelated_scalar_values(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
self.assertIsNone(
service._search_account_type(
{
"amr": ["pwd", "otp", "mfa"],
"chatgpt_compute_residency": "no_constraint",
"chatgpt_data_residency": "no_constraint",
"user_id": "user-I52GFfLGFM0dokFk2dBiKEBn",
}
)
)
def test_mark_image_result_consumes_quota(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
service.add_accounts(["token-1"])
service.update_account(
"token-1",
{
"status": "正常",
"quota": 1,
},
)
updated = service.mark_image_result("token-1", success=True)
self.assertIsNotNone(updated)
self.assertEqual(updated["quota"], 0)
self.assertEqual(updated["status"], "限流")
def test_split_image_model_supports_plan_type_prefix(self) -> None:
self.assertEqual(split_image_model("gpt-image-2"), (None, "gpt-image-2"))
self.assertEqual(split_image_model("plus-codex-gpt-image-2"), ("plus", "codex-gpt-image-2"))
self.assertEqual(split_image_model("team-codex-gpt-image-2"), ("team", "codex-gpt-image-2"))
self.assertEqual(split_image_model("pro-codex-gpt-image-2"), ("pro", "codex-gpt-image-2"))
self.assertEqual(split_image_model("plus-gpt-image-2"), (None, None))
self.assertEqual(split_image_model("unknown-image-model"), (None, None))
def test_get_available_access_token_filters_by_plan_type(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
service.add_account_items(
[
{"access_token": "token-plus", "type": "Plus", "status": "正常", "quota": 3},
{"access_token": "token-pro", "type": "Pro", "status": "正常", "quota": 3},
]
)
service.fetch_remote_info = lambda access_token, event="fetch_remote_info": service.get_account(access_token)
plus_token = service.get_available_access_token(plan_type="plus")
pro_token = service.get_available_access_token(plan_type="pro")
service.release_image_slot(plus_token)
service.release_image_slot(pro_token)
self.assertEqual(plus_token, "token-plus")
self.assertEqual(pro_token, "token-pro")
def test_refresh_accounts_can_remove_invalid_token_without_confirmation_delay(self) -> None:
original_value = config.data.get("auto_remove_invalid_accounts")
config.data["auto_remove_invalid_accounts"] = True
try:
with tempfile.TemporaryDirectory() as tmp_dir:
service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
service.add_account_items([{"access_token": "invalid-token", "status": "正常"}])
with patch(
"services.openai_backend_api.OpenAIBackendAPI.get_user_info",
side_effect=InvalidAccessTokenError("token invalidated (/backend-api/me)"),
):
result = service.refresh_accounts(["invalid-token"], defer_invalid_removal=False)
self.assertEqual(result["refreshed"], 0)
self.assertEqual(len(result["errors"]), 1)
self.assertEqual(result["items"], [])
self.assertIsNone(service.get_account("invalid-token"))
finally:
if original_value is None:
config.data.pop("auto_remove_invalid_accounts", None)
else:
config.data["auto_remove_invalid_accounts"] = original_value
def test_refresh_accounts_defers_invalid_token_removal_by_default(self) -> None:
original_value = config.data.get("auto_remove_invalid_accounts")
config.data["auto_remove_invalid_accounts"] = True
try:
with tempfile.TemporaryDirectory() as tmp_dir:
service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
service.add_account_items([{"access_token": "invalid-token", "status": "正常"}])
with patch(
"services.openai_backend_api.OpenAIBackendAPI.get_user_info",
side_effect=InvalidAccessTokenError("token invalidated (/backend-api/me)"),
):
result = service.refresh_accounts(["invalid-token"])
account = service.get_account("invalid-token")
self.assertEqual(result["refreshed"], 0)
self.assertEqual(len(result["errors"]), 1)
self.assertIsNotNone(account)
self.assertEqual(account["invalid_count"], 1)
finally:
if original_value is None:
config.data.pop("auto_remove_invalid_accounts", None)
else:
config.data["auto_remove_invalid_accounts"] = original_value
class TokenLogTests(unittest.TestCase):
def test_anonymize_token_hides_raw_value(self) -> None:
token = "super-secret-token"
token_ref = anonymize_token(token)
self.assertTrue(token_ref.startswith("token:"))
self.assertNotIn(token, token_ref)
class AuthServiceTests(unittest.TestCase):
def test_create_authenticate_disable_and_delete_user_key(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json"))
item, raw_key = service.create_key(role="user", name="Alice")
self.assertEqual(item["role"], "user")
self.assertEqual(item["name"], "Alice")
self.assertTrue(item["enabled"])
self.assertTrue(raw_key.startswith("sk-"))
authed = service.authenticate(raw_key)
self.assertIsNotNone(authed)
self.assertEqual(authed["id"], item["id"])
self.assertEqual(authed["role"], "user")
self.assertIsNotNone(authed["last_used_at"])
updated = service.update_key(item["id"], {"enabled": False}, role="user")
self.assertIsNotNone(updated)
self.assertFalse(updated["enabled"])
self.assertIsNone(service.authenticate(raw_key))
self.assertTrue(service.delete_key(item["id"], role="user"))
self.assertFalse(service.delete_key(item["id"], role="user"))
self.assertEqual(service.list_keys(role="user"), [])
def test_authenticate_ignores_last_used_save_failure(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json"))
item, raw_key = service.create_key(role="user", name="Alice")
def fail_save() -> None:
raise OSError("disk unavailable")
service._save = fail_save
authed = service.authenticate(raw_key)
self.assertIsNotNone(authed)
self.assertEqual(authed["id"], item["id"])
self.assertIsNotNone(authed["last_used_at"])
def test_update_user_key_replaces_raw_key(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json"))
item, raw_key = service.create_key(role="user", name="Alice")
updated = service.update_key(item["id"], {"key": "sk-user-custom-key"}, role="user")
self.assertIsNotNone(updated)
self.assertIsNone(service.authenticate(raw_key))
authed = service.authenticate("sk-user-custom-key")
self.assertIsNotNone(authed)
self.assertEqual(authed["id"], item["id"])
def test_user_key_name_must_be_unique(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json"))
first, _ = service.create_key(role="user", name="Alice")
second, _ = service.create_key(role="user", name="Bob")
with self.assertRaisesRegex(ValueError, "这个名称已经在使用中了"):
service.create_key(role="user", name="Alice")
with self.assertRaisesRegex(ValueError, "这个名称已经在使用中了"):
service.update_key(second["id"], {"name": "Alice"}, role="user")
updated = service.update_key(first["id"], {"name": "Alice"}, role="user")
self.assertIsNotNone(updated)
self.assertEqual(updated["name"], "Alice")
if __name__ == "__main__":
unittest.main()
+94
View File
@@ -0,0 +1,94 @@
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
from services.account_service import AccountService
from services.storage.json_storage import JSONStorageBackend
from services.upload_limits import UploadLimit, UploadUnavailableError
class AccountLiveValidationTests(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
for target in ("services.account_service.AccountService._save_cumulative_total",
"services.account_service.log_service.add"):
patcher = patch(target)
patcher.start()
self.addCleanup(patcher.stop)
self.service = AccountService(JSONStorageBackend(Path(temp.name) / "accounts.json"))
self.service.add_account_items([{"access_token": "test-token", "quota": 5, "status": "正常"}])
patcher = patch("services.openai_backend_api.OpenAIBackendAPI")
self.backend = patcher.start()
self.addCleanup(patcher.stop)
self.backend.return_value.get_user_info.return_value = {"quota": 5, "status": "正常"}
def acquire(self, **kwargs):
token = self.service.get_available_access_token(**kwargs)
self.service.release_image_slot(token)
return token
def test_repeated_selection_always_refreshes_remote_quota(self):
self.acquire()
self.service.mark_image_result("test-token", True)
self.backend.return_value.get_user_info.return_value = {"quota": 2, "status": "正常"}
self.acquire()
self.assertEqual(self.backend.return_value.get_user_info.call_count, 2)
self.assertEqual(self.service.get_account("test-token")["quota"], 2)
def test_recent_background_refresh_does_not_skip_live_validation(self):
self.service.fetch_remote_info("test-token")
self.acquire()
self.assertEqual(self.backend.return_value.get_user_info.call_count, 2)
def test_remote_quota_exhaustion_is_detected_immediately(self):
self.acquire()
self.backend.return_value.get_user_info.return_value = {"quota": 0, "status": "限流"}
with self.assertRaises(RuntimeError):
self.acquire()
self.assertEqual(self.backend.return_value.get_user_info.call_count, 2)
self.assertEqual(self.service._image_inflight, {})
def test_generation_failure_is_followed_by_live_validation(self):
self.acquire()
self.service.mark_image_result("test-token", False)
self.acquire()
self.assertEqual(self.backend.return_value.get_user_info.call_count, 2)
def test_manual_disable_and_zero_quota_are_excluded_before_validation(self):
self.acquire()
self.service.update_account("test-token", {"status": "禁用"}, manual=True)
with self.assertRaises(RuntimeError):
self.acquire()
self.service.update_account("test-token", {"status": "正常", "quota": 0}, manual=True)
with self.assertRaises(RuntimeError):
self.acquire()
self.assertEqual(self.backend.return_value.get_user_info.call_count, 1)
def test_upload_cooldown_is_respected_but_text_generation_revalidates(self):
self.acquire()
self.service.mark_upload_limited("test-token", UploadLimit(datetime.now(timezone.utc) + timedelta(hours=1), "limited"))
with self.assertRaises(UploadUnavailableError):
self.acquire(requires_upload=True)
self.assertEqual(self.acquire(), "test-token")
self.assertEqual(self.backend.return_value.get_user_info.call_count, 2)
def test_remote_failure_does_not_fall_back_to_previous_validation(self):
self.acquire()
self.backend.return_value.get_user_info.side_effect = RuntimeError("upstream unavailable")
with self.assertRaises(RuntimeError):
self.acquire()
self.assertEqual(self.backend.return_value.get_user_info.call_count, 2)
self.assertEqual(self.service._image_inflight, {})
def test_stats_count_active_upload_limits_independently(self):
self.service.add_account_items([{"access_token": "expired", "quota": 5, "status": "正常"}])
now = datetime.now(timezone.utc)
self.service.mark_upload_limited("test-token", UploadLimit(now + timedelta(hours=1), "limited"))
self.service.mark_upload_limited("expired", UploadLimit(now - timedelta(seconds=1), "expired"))
stats = self.service.get_stats()
self.assertEqual(stats["upload_limited"], 1)
self.assertEqual(stats["active"], 2)
self.assertEqual(stats["limited"], 0)
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
import api.accounts as accounts_api
from services.account_service import AccountService
from services.config import config
from services.storage.json_storage import JSONStorageBackend
class ManualAccountStatusTests(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.storage = JSONStorageBackend(Path(temp.name) / "accounts.json")
self.storage.save_accounts([
{"access_token": "token", "status": "正常", "quota": 3},
])
self.service = AccountService(self.storage)
settings = patch.dict(config.data, {
"auto_remove_rate_limited_accounts": False,
"auto_remove_invalid_accounts": False,
"auto_relogin_after_refresh": False,
})
settings.start()
self.addCleanup(settings.stop)
logs = patch("services.account_service.log_service.add")
logs.start()
self.addCleanup(logs.stop)
def refresh(self, during_fetch=None):
def remote_info(_backend):
if during_fetch:
during_fetch()
return {"status": "正常", "quota": 5, "email": "test@example.test"}
with patch.object(self.service, "refresh_access_token", side_effect=lambda token, **_: token), patch(
"services.openai_backend_api.OpenAIBackendAPI.get_user_info", autospec=True, side_effect=remote_info
):
return self.service.fetch_remote_info("token", "test")
def test_refresh_finishing_after_manual_edit_preserves_status(self):
for status in ("禁用", "限流"):
with self.subTest(status=status):
# Model the admin edit happening while the remote request is running.
result = self.refresh(lambda: self.service.update_account("token", {"status": status}, manual=True))
self.assertEqual(result["status"], status)
self.assertEqual(result["quota"], 5)
self.assertEqual(result["email"], "test@example.test")
self.assertFalse(self.service._is_image_account_available(result))
def test_task_completion_does_not_clear_manual_status_or_delete_account(self):
with patch.dict(config.data, {"auto_remove_rate_limited_accounts": True}):
for status in ("禁用", "限流"):
for quota in (1, 3):
with self.subTest(status=status, quota=quota):
self.service.update_account("token", {"status": status, "quota": quota}, manual=True)
result = self.service.mark_image_result("token", True)
self.assertIsNotNone(result)
self.assertEqual(result["status"], status)
self.assertEqual(result["quota"], quota - 1)
self.assertIsNotNone(self.service.get_account("token"))
def test_manual_status_survives_reload_token_rotation_and_background_update(self):
for status in ("禁用", "限流"):
with self.subTest(status=status):
self.service.update_account("token", {"status": status}, manual=True)
reloaded = AccountService(self.storage)
new_token = reloaded._apply_refreshed_tokens("token", {"access_token": "rotated"}, "test")
result = reloaded.update_account(new_token, {"status": "正常"}, quiet=True)
self.assertEqual(result["status"], status)
reloaded._apply_refreshed_tokens(new_token, {"access_token": "token"}, "test")
def test_manual_normal_clears_override_and_allows_automatic_recovery(self):
self.service.update_account("token", {"status": "限流"}, manual=True)
self.assertNotIn("token", self.service.list_limited_tokens())
self.service.update_account("token", {"status": "正常"}, manual=True)
self.service.update_account("token", {"status": "限流", "quota": 0})
self.assertIn("token", self.service.list_limited_tokens())
self.assertEqual(self.refresh()["status"], "正常")
self.assertIsNone(self.service.get_account("token")["manual_status"])
def test_legacy_disabled_accounts_stay_disabled_but_automatic_limits_recover(self):
for status in ("禁用", "限流"):
with self.subTest(status=status):
self.storage.save_accounts([{"access_token": "token", "status": status, "quota": 0}])
self.service = AccountService(self.storage)
expected = "禁用" if status == "禁用" else "正常"
self.assertEqual(self.refresh()["status"], expected)
def test_background_invalid_token_cleanup_keeps_manual_accounts(self):
with patch.dict(config.data, {"auto_remove_invalid_accounts": True}):
for status in ("禁用", "限流"):
with self.subTest(status=status):
self.service.update_account("token", {"status": status}, manual=True)
self.assertFalse(self.service.remove_invalid_token("token", "test"))
self.assertEqual(self.service.get_account("token")["status"], status)
def test_admin_update_endpoint_sets_and_clears_manual_override(self):
app = FastAPI()
app.include_router(accounts_api.create_router())
with patch.object(accounts_api, "account_service", self.service), patch.object(
accounts_api, "require_admin", return_value={"id": "admin", "role": "admin"}
), TestClient(app) as client:
for status in ("禁用", "限流", "正常"):
response = client.post("/api/accounts/update", json={"access_token": "token", "status": status})
self.assertEqual(response.status_code, 200, response.text)
item = response.json()["item"]
self.assertEqual(item["manual_status"], None if status == "正常" else status)
client.post("/api/accounts/update", json={"access_token": "token", "status": "禁用"})
response = client.post("/api/accounts/update", json={"access_token": "token", "quota": 8})
self.assertEqual(response.json()["item"]["manual_status"], "禁用")
def test_manual_edit_during_invalid_cleanup_prevents_deletion(self):
delete = self.service.delete_accounts
def concurrent_delete(tokens, **kwargs):
self.service.update_account("token", {"status": "禁用"}, manual=True)
return delete(tokens, **kwargs)
with patch.dict(config.data, {"auto_remove_invalid_accounts": True}), patch.object(
self.service, "delete_accounts", side_effect=concurrent_delete
):
self.assertFalse(self.service.remove_invalid_token("token", "test"))
self.assertEqual(self.service.get_account("token")["status"], "禁用")
# Explicit administrator deletion is still allowed.
self.assertEqual(self.service.delete_accounts(["token"])["removed"], 1)
if __name__ == "__main__":
unittest.main()
+643
View File
@@ -0,0 +1,643 @@
from __future__ import annotations
import unittest
from unittest import mock
import json
import base64
from services.config import config
from services.protocol import openai_v1_chat_complete, openai_v1_response
from services.protocol.chat_completion_cache import cache_key, chat_completion_cache
from services.protocol.conversation import iter_conversation_payloads, sanitize_output_text
from utils.helper import extract_image_from_message_content
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR4nGP8z8BQDwAFgwJ/luzl4wAAAABJRU5ErkJggg=="
)
PNG_1X1_DATA_URL = "data:image/png;base64," + base64.b64encode(PNG_1X1).decode("ascii")
class ChatCompletionCacheTests(unittest.TestCase):
def setUp(self) -> None:
self.old_cache_settings = config.data.get("chat_completion_cache")
config.data["chat_completion_cache"] = {
"enabled": True,
"ttl_seconds": 60,
"max_entries": 32,
"dedupe_inflight": True,
"stream_cache": True,
"normalize_messages": True,
"drop_adjacent_duplicates": True,
"drop_assistant_history": False,
}
chat_completion_cache.clear()
def tearDown(self) -> None:
if self.old_cache_settings is None:
config.data.pop("chat_completion_cache", None)
else:
config.data["chat_completion_cache"] = self.old_cache_settings
chat_completion_cache.clear()
def test_repeated_non_stream_text_completion_uses_cache(self) -> None:
calls = 0
def fake_collect_text(_backend, _request):
nonlocal calls
calls += 1
return f"cached answer {calls}"
body = {
"model": "auto",
"messages": [{"role": "user", "content": "cache this exact prompt"}],
}
with (
mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
mock.patch("services.protocol.openai_v1_chat_complete.collect_text", side_effect=fake_collect_text),
):
first = openai_v1_chat_complete.handle(body)
second = openai_v1_chat_complete.handle(body)
self.assertEqual(calls, 1)
self.assertEqual(
first["choices"][0]["message"]["content"],
second["choices"][0]["message"]["content"],
)
def test_cache_key_distinguishes_thinking_effort_inputs(self) -> None:
messages = [{"role": "user", "content": "same prompt"}]
base = {"model": "auto", "messages": messages}
default_key = cache_key(base, messages, stream=False)
thinking_key = cache_key({**base, "thinking_effort": "high"}, messages, stream=False)
reasoning_key = cache_key({**base, "reasoning": {"effort": "high"}}, messages, stream=False)
self.assertNotEqual(default_key, thinking_key)
self.assertNotEqual(default_key, reasoning_key)
self.assertNotEqual(thinking_key, reasoning_key)
def test_chat_completion_reasoning_effort_reaches_conversation_request(self) -> None:
captured_efforts: list[str] = []
def fake_collect_text(_backend, request):
captured_efforts.append(request.thinking_effort)
return "ok"
body = {
"model": "auto",
"reasoning_effort": "xhigh",
"messages": [{"role": "user", "content": "use more reasoning"}],
}
with (
mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
mock.patch("services.protocol.openai_v1_chat_complete.collect_text", side_effect=fake_collect_text),
):
openai_v1_chat_complete.handle(body)
self.assertEqual(captured_efforts, ["extended"])
def test_responses_reasoning_effort_reaches_conversation_request(self) -> None:
captured_efforts: list[str] = []
def fake_stream_text_deltas(_backend, request):
captured_efforts.append(request.thinking_effort)
yield "ok"
body = {
"model": "auto",
"input": "use more reasoning",
"reasoning": {"effort": "xhigh"},
}
with (
mock.patch("services.protocol.openai_v1_response.text_backend", return_value=object()),
mock.patch("services.protocol.openai_v1_response.stream_text_deltas", side_effect=fake_stream_text_deltas),
):
openai_v1_response.handle(body)
self.assertEqual(captured_efforts, ["extended"])
def test_repeated_stream_text_completion_replays_cached_chunks(self) -> None:
calls = 0
def fake_stream_text_deltas(_backend, _request):
nonlocal calls
calls += 1
yield "streamed"
yield " answer"
body = {
"model": "auto",
"stream": True,
"messages": [{"role": "user", "content": "stream cache this exact prompt"}],
}
with (
mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
mock.patch(
"services.protocol.openai_v1_chat_complete.stream_text_deltas",
side_effect=fake_stream_text_deltas,
),
):
first = list(openai_v1_chat_complete.handle(body))
second = list(openai_v1_chat_complete.handle(body))
self.assertEqual(calls, 1)
self.assertEqual(first, second)
content = "".join(str(chunk["choices"][0]["delta"].get("content") or "") for chunk in second)
self.assertEqual(content, "streamed answer")
def test_adjacent_duplicate_messages_are_removed_before_upstream_call(self) -> None:
captured_messages = []
def fake_collect_text(_backend, request):
captured_messages.extend(request.messages or [])
return "ok"
body = {
"model": "auto",
"messages": [
{"role": "user", "content": "repeat me"},
{"role": "user", "content": "repeat me"},
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "next prompt"},
],
}
with (
mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
mock.patch("services.protocol.openai_v1_chat_complete.collect_text", side_effect=fake_collect_text),
):
openai_v1_chat_complete.handle(body)
self.assertEqual(
captured_messages,
[
{"role": "user", "content": "repeat me"},
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "next prompt"},
],
)
def test_chat_completion_usage_includes_cached_tokens(self) -> None:
with (
mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
mock.patch("services.protocol.openai_v1_chat_complete.collect_text", return_value="ok"),
):
response = openai_v1_chat_complete.handle({
"model": "auto",
"messages": [{"role": "user", "content": "usage shape"}],
})
details = response["usage"]["prompt_tokens_details"]
self.assertEqual(details["cached_tokens"], 0)
output_details = response["usage"]["completion_tokens_details"]
self.assertEqual(output_details["reasoning_tokens"], 0)
def test_responses_completed_usage_includes_cached_tokens(self) -> None:
with (
mock.patch("services.protocol.openai_v1_response.text_backend", return_value=object()),
mock.patch("services.protocol.openai_v1_response.stream_text_deltas", return_value=iter(["ok"])),
):
response = openai_v1_response.handle({
"model": "auto",
"input": "usage shape",
})
details = response["usage"]["input_tokens_details"]
self.assertEqual(details["cached_tokens"], 0)
output_details = response["usage"]["output_tokens_details"]
self.assertEqual(output_details["reasoning_tokens"], 0)
def test_repeated_responses_text_request_uses_cache(self) -> None:
calls = 0
def fake_stream_text_deltas(_backend, _request):
nonlocal calls
calls += 1
yield f"response cache {calls}"
body = {
"model": "auto",
"input": "cache this responses prompt",
"stream": True,
}
with (
mock.patch("services.protocol.openai_v1_response.text_backend", return_value=object()),
mock.patch("services.protocol.openai_v1_response.stream_text_deltas", side_effect=fake_stream_text_deltas),
):
first = list(openai_v1_response.handle(body))
second = list(openai_v1_response.handle(body))
self.assertEqual(calls, 1)
self.assertEqual(first, second)
def test_output_sanitizer_removes_chatgpt_annotation_markup(self) -> None:
text = (
"Repo: \ue200url\ue202basketikun/chatgpt2api"
"\ue202https://github.com/basketikun/chatgpt2api\ue201 "
"details \ue200cite\ue202turn0search0\ue201."
)
self.assertEqual(
sanitize_output_text(text),
"Repo: basketikun/chatgpt2api (https://github.com/basketikun/chatgpt2api) details.",
)
def test_output_sanitizer_preserves_annotated_entity_text(self) -> None:
text = (
"The character is from \ue200entity\ue202Invincible\ue201, "
"which is based on the comic series \ue200entity\ue202Invincible\ue201."
)
self.assertEqual(
sanitize_output_text(text),
"The character is from Invincible, which is based on the comic series Invincible.",
)
def test_output_sanitizer_preserves_readable_cite_label(self) -> None:
text = "The character is \ue200cite\ue202Invincible\ue202turn0search0\ue201."
self.assertEqual(sanitize_output_text(text), "The character is Invincible.")
def test_output_sanitizer_preserves_code_spaces_before_punctuation(self) -> None:
self.assertEqual(sanitize_output_text("find ."), "find .")
self.assertEqual(sanitize_output_text("if ! test -f x; then"), "if ! test -f x; then")
def test_stream_sanitizer_does_not_emit_partial_annotation_or_repeat_prefix(self) -> None:
events = [
{"p": "/message/content/parts/0", "o": "append", "v": "Repo: \ue200url\ue202chat"},
{"p": "/message/content/parts/0", "o": "append", "v": "gpt2api\ue202turn0search0\ue201 done \ue200cite\ue202turn0\ue201."},
"[DONE]",
]
payloads = [json.dumps(event, ensure_ascii=False) if isinstance(event, dict) else event for event in events]
deltas = [
str(event.get("delta") or "")
for event in iter_conversation_payloads(iter(payloads))
if event.get("type") == "conversation.delta"
]
self.assertEqual("".join(deltas), "Repo: chatgpt2api done.")
self.assertFalse(any("\ue200" in delta or "\ue202" in delta or "\ue201" in delta for delta in deltas))
def test_stream_ignores_internal_tool_recipient_messages(self) -> None:
events = [
{
"message": {
"author": {"role": "assistant"},
"content": {
"content_type": "code",
"text": 'search("\\u56fd\\u5bb6\\u6d77\\u6d0b")',
},
"recipient": "web",
"status": "finished_successfully",
},
},
{
"message": {
"author": {"name": "web.run", "role": "tool"},
"content": {"content_type": "text", "parts": [""]},
"recipient": "all",
"status": "finished_successfully",
},
},
{
"v": {
"message": {
"author": {"role": "assistant"},
"content": {
"content_type": "code",
"text": '{"aspect_ratio":"16:9","query":["museum"],"num_per_query":2}',
},
"recipient": "web",
"status": "finished_successfully",
},
},
},
{
"message": {
"author": {"role": "assistant"},
"channel": "final",
"content": {"content_type": "text", "parts": ["Final answer."]},
"recipient": "all",
"status": "finished_successfully",
},
},
"[DONE]",
]
payloads = [json.dumps(event, ensure_ascii=False) if isinstance(event, dict) else event for event in events]
deltas = [
str(event.get("delta") or "")
for event in iter_conversation_payloads(iter(payloads))
if event.get("type") == "conversation.delta"
]
self.assertEqual("".join(deltas), "Final answer.")
def test_stream_ignores_hidden_and_non_final_assistant_messages(self) -> None:
events = [
{
"message": {
"author": {"role": "assistant"},
"channel": "final",
"content": {"content_type": "text", "parts": ["Hidden text."]},
"metadata": {"is_visually_hidden_from_conversation": True},
"recipient": "all",
},
},
{
"message": {
"author": {"role": "assistant"},
"channel": "analysis",
"content": {"content_type": "text", "parts": ["Private reasoning."]},
"recipient": "all",
},
},
{
"message": {
"author": {"role": "assistant"},
"channel": "final",
"content": {"content_type": "text", "parts": ["Visible text."]},
"recipient": "all",
},
},
"[DONE]",
]
payloads = [json.dumps(event, ensure_ascii=False) if isinstance(event, dict) else event for event in events]
deltas = [
str(event.get("delta") or "")
for event in iter_conversation_payloads(iter(payloads))
if event.get("type") == "conversation.delta"
]
self.assertEqual("".join(deltas), "Visible text.")
def test_stream_preserves_user_visible_code_messages(self) -> None:
events = [
{
"message": {
"author": {"role": "assistant"},
"channel": "final",
"content": {"content_type": "code", "text": 'print("hello")'},
"recipient": "all",
},
},
"[DONE]",
]
payloads = [json.dumps(event, ensure_ascii=False) if isinstance(event, dict) else event for event in events]
deltas = [
str(event.get("delta") or "")
for event in iter_conversation_payloads(iter(payloads))
if event.get("type") == "conversation.delta"
]
self.assertEqual("".join(deltas), 'print("hello")')
def test_responses_tools_add_honest_no_tool_guard(self) -> None:
model, messages = openai_v1_response.text_response_parts({
"model": "auto",
"input": "run echo hi",
"tools": [{"type": "function", "name": "shell"}],
})
self.assertEqual(model, "auto")
self.assertEqual(messages[0]["role"], "system")
self.assertIn("cannot execute local tools", str(messages[0]["content"]))
def test_responses_web_search_tool_returns_search_output(self) -> None:
search_result = {
"answer": "Latest answer.",
"sources": [{"title": "Example", "url": "https://example.com/news", "snippet": "Snippet"}],
}
body = {
"model": "auto",
"input": "latest example news",
"tools": [{"type": "web_search"}],
}
with mock.patch("services.protocol.openai_v1_response.run_web_search", return_value=search_result) as search:
response = openai_v1_response.handle(body)
search.assert_called_once_with("latest example news")
self.assertEqual(response["output"][0]["type"], "web_search_call")
self.assertEqual(response["output"][0]["status"], "completed")
self.assertEqual(response["output"][0]["action"]["query"], "latest example news")
message = response["output"][1]
self.assertEqual(message["type"], "message")
content = message["content"][0]
self.assertIn("Latest answer.", content["text"])
self.assertEqual(content["annotations"][0]["type"], "url_citation")
self.assertEqual(content["annotations"][0]["url"], "https://example.com/news")
def test_responses_web_search_tool_streams_search_events(self) -> None:
search_result = {
"answer": "Streamed search answer.",
"sources": [{"title": "Example", "url": "https://example.com/stream", "snippet": ""}],
}
body = {
"model": "auto",
"stream": True,
"input": "stream search",
"tools": [{"type": "web_search_preview"}],
}
with mock.patch("services.protocol.openai_v1_response.run_web_search", return_value=search_result):
events = list(openai_v1_response.handle(body))
event_types = [event["type"] for event in events]
self.assertIn("response.web_search_call.in_progress", event_types)
self.assertIn("response.web_search_call.searching", event_types)
self.assertIn("response.web_search_call.completed", event_types)
completed = events[-1]["response"]
self.assertEqual(completed["output"][0]["type"], "web_search_call")
self.assertEqual(completed["output"][1]["type"], "message")
def test_responses_versioned_web_search_tool_returns_search_output(self) -> None:
search_result = {
"answer": "Versioned search answer.",
"sources": [{"title": "Example", "url": "https://example.com/versioned", "snippet": ""}],
}
body = {
"model": "auto",
"input": "versioned search",
"tools": [{"type": "web_search_preview_2025_03_11"}],
}
with mock.patch("services.protocol.openai_v1_response.run_web_search", return_value=search_result) as search:
response = openai_v1_response.handle(body)
search.assert_called_once_with("versioned search")
self.assertEqual(response["output"][0]["type"], "web_search_call")
self.assertIn("Versioned search answer.", response["output"][1]["content"][0]["text"])
def test_chat_completions_web_search_tool_returns_search_answer(self) -> None:
search_result = {
"answer": "Chat search answer.",
"sources": [{"title": "Example", "url": "https://example.com/chat", "snippet": ""}],
}
body = {
"model": "auto",
"messages": [{"role": "user", "content": "search chat"}],
"tools": [{"type": "web_search"}],
}
with mock.patch("services.protocol.openai_v1_chat_complete.run_web_search", return_value=search_result) as search:
response = openai_v1_chat_complete.handle(body)
search.assert_called_once_with("search chat")
message = response["choices"][0]["message"]
self.assertIn("Chat search answer.", message["content"])
self.assertEqual(message["annotations"][0]["type"], "url_citation")
self.assertEqual(message["annotations"][0]["url_citation"]["url"], "https://example.com/chat")
def test_chat_completions_web_search_options_trigger_search(self) -> None:
search_result = {
"answer": "Options search answer.",
"sources": [{"title": "Example", "url": "https://example.com/options", "snippet": ""}],
}
body = {
"model": "auto",
"messages": [{"role": "user", "content": "search options"}],
"web_search_options": {"search_context_size": "low"},
}
with mock.patch("services.protocol.openai_v1_chat_complete.run_web_search", return_value=search_result) as search:
response = openai_v1_chat_complete.handle(body)
search.assert_called_once_with("search options")
self.assertIn("Options search answer.", response["choices"][0]["message"]["content"])
def test_chat_completions_search_model_triggers_search(self) -> None:
search_result = {
"answer": "Search model answer.",
"sources": [{"title": "Example", "url": "https://example.com/model", "snippet": ""}],
}
body = {
"model": "gpt-5-search-api-2026-06-01",
"messages": [{"role": "user", "content": "search model"}],
}
with mock.patch("services.protocol.openai_v1_chat_complete.run_web_search", return_value=search_result) as search:
response = openai_v1_chat_complete.handle(body)
search.assert_called_once_with("search model")
self.assertEqual(response["model"], "gpt-5-search-api-2026-06-01")
self.assertIn("Search model answer.", response["choices"][0]["message"]["content"])
def test_chat_completions_search_like_model_does_not_trigger_search(self) -> None:
body = {
"model": "gpt-5-search-apiary",
"messages": [{"role": "user", "content": "not actually a search model"}],
}
with (
mock.patch("services.protocol.openai_v1_chat_complete.run_web_search") as search,
mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
mock.patch("services.protocol.openai_v1_chat_complete.collect_text", return_value="plain text answer"),
):
response = openai_v1_chat_complete.handle(body)
search.assert_not_called()
self.assertIn("plain text answer", response["choices"][0]["message"]["content"])
def test_chat_completions_accepts_remote_image_url(self) -> None:
class FakeImageResponse:
status_code = 200
headers = {"content-type": "image/png", "content-length": str(len(PNG_1X1))}
content = PNG_1X1
with mock.patch("utils.helper.requests.get", return_value=FakeImageResponse()) as request_get:
model, messages = openai_v1_chat_complete.text_chat_parts({
"model": "auto",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Describe this"},
{"type": "image_url", "image_url": {"url": "https://example.test/image.png"}},
],
}],
})
request_get.assert_called_once()
self.assertEqual(model, "auto")
content = messages[0]["content"]
self.assertEqual(content[0], {"type": "text", "text": "Describe this"})
self.assertEqual(content[1]["type"], "image")
self.assertEqual(content[1]["data"], PNG_1X1)
self.assertEqual(content[1]["mime"], "image/png")
def test_responses_text_request_preserves_input_image(self) -> None:
captured = {}
def fake_stream_text_deltas(_backend, request):
captured["messages"] = request.messages
yield "red"
body = {
"model": "auto",
"input": [
{"type": "input_text", "text": "What color is this image?"},
{"type": "input_image", "image_url": PNG_1X1_DATA_URL},
],
}
with (
mock.patch("services.protocol.openai_v1_response.text_backend", return_value=object()),
mock.patch("services.protocol.openai_v1_response.stream_text_deltas", side_effect=fake_stream_text_deltas),
):
response = openai_v1_response.handle(body)
self.assertEqual(response["output"][0]["content"][0]["text"], "red")
content = captured["messages"][0]["content"]
self.assertEqual(content[0], {"type": "text", "text": "What color is this image?"})
self.assertEqual(content[1]["type"], "image")
self.assertEqual(content[1]["mime"], "image/png")
self.assertEqual(content[1]["data"], PNG_1X1)
self.assertGreater(response["usage"]["input_tokens_details"]["image_tokens"], 0)
def test_responses_text_request_accepts_remote_input_image_url(self) -> None:
class FakeImageResponse:
status_code = 200
headers = {"content-type": "image/png", "content-length": str(len(PNG_1X1))}
content = PNG_1X1
with mock.patch("utils.helper.requests.get", return_value=FakeImageResponse()) as request_get:
_model, messages = openai_v1_response.text_response_parts({
"model": "auto",
"input": [{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "Describe this"},
{"type": "input_image", "image_url": {"url": "https://example.test/image.png"}},
],
}],
})
request_get.assert_called_once()
content = messages[0]["content"]
self.assertEqual(content[0], {"type": "text", "text": "Describe this"})
self.assertEqual(content[1]["type"], "image")
self.assertEqual(content[1]["data"], PNG_1X1)
self.assertEqual(content[1]["mime"], "image/png")
def test_image_extractor_supports_extra_image_object_shapes(self) -> None:
encoded = base64.b64encode(PNG_1X1).decode("ascii")
images = extract_image_from_message_content([
{"type": "image", "data": PNG_1X1, "mime": "image/png"},
{"type": "input_image", "base64": encoded, "mime_type": "image/png"},
{"type": "input_image", "source": {"type": "base64", "data": encoded, "media_type": "image/png"}},
])
self.assertEqual(len(images), 3)
self.assertEqual([image[1] for image in images], ["image/png", "image/png", "image/png"])
self.assertTrue(all(image[0] == PNG_1X1 for image in images))
if __name__ == "__main__":
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
# Test script: directly call https://chatgpt.com/backend-api/codex/responses to generate one 2K image.
# Only edit ACCESS_TOKEN, then run: python codex_responses_image_test.py
# Fixed request parameters:
# prompt: A highly detailed square 2K image of a quiet futuristic library at sunrise
# responses model: gpt-5.5
# image model: gpt-image-2
# size: 2048x2048
# quality: auto
# output_format: png
# output file: codex_4k.png
import base64
import json
import time
import urllib.request
ACCESS_TOKEN = ""
def parse_events(raw):
ctype, text = raw.headers.get("content-type", ""), raw.read().decode("utf-8", "replace")
if "application/json" in ctype:
return [json.loads(text)]
events, lines = [], []
for line in text.splitlines() + [""]:
if not line:
if lines:
data = "\n".join(lines).strip()
if data and data != "[DONE]":
events.append(json.loads(data))
lines = []
elif line.startswith("data:"):
lines.append(line[5:].lstrip())
return events
def find_images(value):
if isinstance(value, dict):
if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
result = value["result"].strip()
return [result.split(",", 1)[1] if result.startswith("data:image/") else result]
return [image for item in value.values() for image in find_images(item)]
if isinstance(value, list):
return [image for item in value for image in find_images(item)]
return []
def main():
start_time = time.time()
body = {
"model": "gpt-5.5",
"instructions": "Use the image_generation tool to create exactly one image for the user's request. Return the generated image result.",
"store": False,
"input": [{"role": "user", "content": [{"type": "input_text", "text": "A highly detailed square 2K image of a quiet futuristic library at sunrise"}]}],
"tools": [{
"type": "image_generation",
"model": "gpt-image-2",
"action": "generate",
"size": "3840x2160",
"quality": "auto",
"output_format": "png"
}],
"tool_choice": {"type": "image_generation"},
"stream": True
}
headers = {"Authorization": f"Bearer {ACCESS_TOKEN}", "Content-Type": "application/json"}
req = urllib.request.Request("https://chatgpt.com/backend-api/codex/responses", json.dumps(body).encode(), headers, method="POST")
try:
images = find_images(parse_events(urllib.request.urlopen(req, timeout=1200)))
except urllib.error.HTTPError as error:
raise SystemExit(f"HTTP {error.code}: {error.read().decode('utf-8', 'replace')[:1000]}")
if not images:
raise SystemExit("No image result found in response")
with open("codex_4k.png", "wb") as file:
file.write(base64.b64decode(images[0]))
print("saved codex_4k.png")
end_time = time.time()
print(f"total time: {end_time - start_time:.2f} seconds")
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
import json
import tempfile
import unittest
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[1]
ROOT_CONFIG_FILE = ROOT_DIR / "config.json"
class ConfigLoadingTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls._created_root_config = False
if not ROOT_CONFIG_FILE.exists():
ROOT_CONFIG_FILE.write_text(json.dumps({"auth-key": "test-auth"}), encoding="utf-8")
cls._created_root_config = True
from services import config as config_module
cls.config_module = config_module
@classmethod
def tearDownClass(cls) -> None:
if cls._created_root_config and ROOT_CONFIG_FILE.exists():
ROOT_CONFIG_FILE.unlink()
def test_load_settings_ignores_directory_config_path(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
base_dir = Path(tmp_dir)
data_dir = base_dir / "data"
config_dir = base_dir / "config.json"
os_auth_key = "env-auth"
config_dir.mkdir()
module = self.config_module
old_base_dir = module.BASE_DIR
old_data_dir = module.DATA_DIR
old_config_file = module.CONFIG_FILE
old_env_auth_key = module.os.environ.get("CHATGPT2API_AUTH_KEY")
try:
module.BASE_DIR = base_dir
module.DATA_DIR = data_dir
module.CONFIG_FILE = config_dir
module.os.environ["CHATGPT2API_AUTH_KEY"] = os_auth_key
settings = module._load_settings()
self.assertEqual(settings.auth_key, os_auth_key)
self.assertEqual(settings.refresh_account_interval_minute, 5)
finally:
module.BASE_DIR = old_base_dir
module.DATA_DIR = old_data_dir
module.CONFIG_FILE = old_config_file
if old_env_auth_key is None:
module.os.environ.pop("CHATGPT2API_AUTH_KEY", None)
else:
module.os.environ["CHATGPT2API_AUTH_KEY"] = old_env_auth_key
if __name__ == "__main__":
unittest.main()
+159
View File
@@ -0,0 +1,159 @@
import json
import pytest
from services.storage.database_storage import (
AccountModel,
AuthKeyModel,
DatabaseStorageBackend,
)
def _account_rows(backend: DatabaseStorageBackend) -> dict[str, AccountModel]:
session = backend.Session()
try:
return {
row.access_token: row
for row in session.query(AccountModel).order_by(AccountModel.id).all()
}
finally:
session.close()
def test_save_accounts_preserves_existing_rows_and_updates_only_changed_data(tmp_path):
backend = DatabaseStorageBackend(f"sqlite:///{tmp_path / 'accounts.db'}")
backend.save_accounts(
[
{"access_token": "token-a", "name": "A"},
{"access_token": "token-b", "name": "B"},
]
)
before = _account_rows(backend)
before_ids = {token: row.id for token, row in before.items()}
before_b_data = before["token-b"].data
backend.save_accounts(
[
{"access_token": "token-b", "name": "B"},
{"access_token": "token-a", "name": "A updated"},
{"access_token": "token-c", "name": "C"},
]
)
after = _account_rows(backend)
assert set(after) == {"token-a", "token-b", "token-c"}
assert after["token-a"].id == before_ids["token-a"]
assert after["token-b"].id == before_ids["token-b"]
assert after["token-b"].data == before_b_data
assert json.loads(after["token-a"].data)["name"] == "A updated"
def test_save_accounts_deletes_only_rows_missing_from_new_snapshot(tmp_path):
backend = DatabaseStorageBackend(f"sqlite:///{tmp_path / 'accounts.db'}")
backend.save_accounts(
[
{"access_token": "token-a", "name": "A"},
{"access_token": "token-b", "name": "B"},
]
)
before = _account_rows(backend)
token_b_id = before["token-b"].id
backend.save_accounts([{"access_token": "token-b", "name": "B"}])
after = _account_rows(backend)
assert set(after) == {"token-b"}
assert after["token-b"].id == token_b_id
def test_save_accounts_rejects_duplicate_tokens_and_rolls_back(tmp_path):
backend = DatabaseStorageBackend(f"sqlite:///{tmp_path / 'accounts.db'}")
original = {"access_token": "token-a", "name": "A"}
backend.save_accounts([original])
with pytest.raises(ValueError, match="Duplicate access_token") as exc_info:
backend.save_accounts(
[
{"access_token": "token-a", "name": "first update"},
{"access_token": "token-a", "name": "second update"},
]
)
assert "token-a" not in str(exc_info.value)
assert backend.load_accounts() == [original]
def test_save_accounts_rejects_duplicate_new_tokens_and_rolls_back(tmp_path):
backend = DatabaseStorageBackend(f"sqlite:///{tmp_path / 'accounts.db'}")
original = {"access_token": "token-a", "name": "A"}
backend.save_accounts([original])
with pytest.raises(ValueError, match="Duplicate access_token") as exc_info:
backend.save_accounts(
[
original,
{"access_token": "token-b", "name": "first new"},
{"access_token": "token-b", "name": "second new"},
]
)
assert "token-b" not in str(exc_info.value)
assert backend.load_accounts() == [original]
def test_save_auth_keys_preserves_ids_with_target_key_mapping(tmp_path):
backend = DatabaseStorageBackend(f"sqlite:///{tmp_path / 'auth-keys.db'}")
backend.save_auth_keys(
[
{"id": "key-a", "name": "A"},
{"id": "key-b", "name": "B"},
]
)
session = backend.Session()
try:
before_ids = {
row.key_id: row.id
for row in session.query(AuthKeyModel).order_by(AuthKeyModel.id).all()
}
finally:
session.close()
backend.save_auth_keys(
[
{"id": "key-b", "name": "B"},
{"id": "key-a", "name": "A updated"},
{"id": "key-c", "name": "C"},
]
)
session = backend.Session()
try:
after = {
row.key_id: row
for row in session.query(AuthKeyModel).order_by(AuthKeyModel.id).all()
}
assert set(after) == {"key-a", "key-b", "key-c"}
assert after["key-a"].id == before_ids["key-a"]
assert after["key-b"].id == before_ids["key-b"]
assert json.loads(after["key-a"].data)["name"] == "A updated"
finally:
session.close()
def test_save_auth_keys_rejects_duplicate_ids_and_rolls_back(tmp_path):
backend = DatabaseStorageBackend(f"sqlite:///{tmp_path / 'auth-keys.db'}")
original = {"id": "key-a", "name": "A"}
backend.save_auth_keys([original])
with pytest.raises(ValueError, match="Duplicate id") as exc_info:
backend.save_auth_keys(
[
{"id": "key-a", "name": "first update"},
{"id": "key-a", "name": "second update"},
]
)
assert "key-a" not in str(exc_info.value)
assert backend.load_auth_keys() == [original]
+12
View File
@@ -0,0 +1,12 @@
from test.utils import post_json, save_image
def main() -> None:
prompt = "A cute orange cat sitting on a chair"
result = post_json("/v1/images/generations", {"prompt": prompt, "model": "gpt-image-2", "n": 1})
for index, item in enumerate(result["data"], start=1):
print(save_image(item["b64_json"], f"generations_{index}"))
if __name__ == "__main__":
main()
+19
View File
@@ -0,0 +1,19 @@
from test.utils import post_json
def main() -> None:
result = post_json(
"/v1/images/generations",
{
"prompt": "一只橘猫坐在窗边,午后阳光,写实摄影",
"model": "gpt-image-2",
"n": 1,
"response_format": "url",
},
)
for item in result.get("data", []):
print(item.get("url", ""))
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from uuid import uuid4
BASE_URL = "http://127.0.0.1:8000"
PROMPT = "生成一份 2026 年 Q2 电商运营复盘 PPT,8 页以内,商务科技风,包含销售、用户、渠道、广告、618 活动和 Q3 规划。"
BASE64_IMAGES: list[str] = []
TIMEOUT_SECS = 600
POLL_INTERVAL_SECS = 5
def request_json(method: str, path: str, payload: dict | None = None) -> dict:
api_key = json.loads((Path(__file__).resolve().parents[1] / "config.json").read_text(encoding="utf-8"))["auth-key"]
if not api_key.strip():
raise ValueError("API_KEY is empty")
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode()
request = urllib.request.Request(
BASE_URL.rstrip("/") + path,
data=data,
method=method,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
raise RuntimeError(exc.read().decode("utf-8", "replace")) from exc
def main() -> None:
task = request_json("POST", "/v1/ppt/generations", {
"client_task_id": str(uuid4()),
"prompt": PROMPT,
"base64_images": BASE64_IMAGES,
})
task_id = str(task.get("taskId") or task.get("id") or "")
if not task_id:
raise RuntimeError(f"missing taskId: {task}")
print(json.dumps(task, ensure_ascii=False, indent=2))
deadline = time.time() + TIMEOUT_SECS
while time.time() < deadline:
time.sleep(POLL_INTERVAL_SECS)
status = request_json("GET", "/v1/editable-file-tasks?ids=" + urllib.parse.quote(task_id))
print(json.dumps(status, ensure_ascii=False, indent=2))
item = (status.get("items") or [{}])[0]
if item.get("status") in {"success", "error"}:
return
raise TimeoutError(f"task timeout: {task_id}")
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from uuid import uuid4
BASE_URL = "http://127.0.0.1:8000"
PROMPT = "按原图位置拆分海报元素并合成可编辑 PSD,同时输出每个图层素材 zip。"
BASE64_IMAGES: list[str] = []
TIMEOUT_SECS = 600
POLL_INTERVAL_SECS = 5
def request_json(method: str, path: str, payload: dict | None = None) -> dict:
api_key = json.loads((Path(__file__).resolve().parents[1] / "config.json").read_text(encoding="utf-8"))["auth-key"]
if not api_key.strip():
raise ValueError("API_KEY is empty")
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode()
request = urllib.request.Request(
BASE_URL.rstrip("/") + path,
data=data,
method=method,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
raise RuntimeError(exc.read().decode("utf-8", "replace")) from exc
def main() -> None:
task = request_json("POST", "/v1/psd/generations", {
"client_task_id": str(uuid4()),
"prompt": PROMPT,
"base64_images": BASE64_IMAGES,
})
task_id = str(task.get("taskId") or task.get("id") or "")
if not task_id:
raise RuntimeError(f"missing taskId: {task}")
print(json.dumps(task, ensure_ascii=False, indent=2))
deadline = time.time() + TIMEOUT_SECS
while time.time() < deadline:
time.sleep(POLL_INTERVAL_SECS)
status = request_json("GET", "/v1/editable-file-tasks?ids=" + urllib.parse.quote(task_id))
print(json.dumps(status, ensure_ascii=False, indent=2))
item = (status.get("items") or [{}])[0]
if item.get("status") in {"success", "error"}:
return
raise TimeoutError(f"task timeout: {task_id}")
if __name__ == "__main__":
main()
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from services.openai_backend_api import OpenAIBackendAPI
def main() -> None:
ACCESS_TOKEN = ""
PROMPT = "帮我去网上搜索关于chatgpt2api的相关项目"
if not ACCESS_TOKEN.strip():
raise ValueError("ACCESS_TOKEN is empty")
print(json.dumps(OpenAIBackendAPI(ACCESS_TOKEN).search(PROMPT), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from services.protocol import openai_v1_image_generations
from test.utils import save_image
def main() -> None:
prompt = "一只橘猫坐在窗台上,午后阳光,写实摄影"
data = openai_v1_image_generations.handle({"prompt": prompt, "model": "gpt-5-3", "n": 1})
for index, item in enumerate(data["data"], start=1):
print(save_image(item["b64_json"], f"image_{index}"))
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
import unittest
from types import SimpleNamespace
from unittest import mock
import api.support as api_support
class ImageBaseUrlApiTests(unittest.TestCase):
def setUp(self) -> None:
self.fake_config = SimpleNamespace(base_url="https://public.example.com")
patcher = mock.patch.object(api_support, "config", self.fake_config)
patcher.start()
self.addCleanup(patcher.stop)
def test_prefers_configured_base_url(self) -> None:
request = SimpleNamespace(
url=SimpleNamespace(scheme="http", netloc="127.0.0.1:8000"),
headers={"host": "127.0.0.1:8000"},
)
self.assertEqual(api_support.resolve_image_base_url(request), "https://public.example.com")
def test_falls_back_to_request_host(self) -> None:
self.fake_config.base_url = ""
request = SimpleNamespace(
url=SimpleNamespace(scheme="http", netloc="127.0.0.1:8000"),
headers={"host": "internal.example:9000"},
)
self.assertEqual(api_support.resolve_image_base_url(request), "http://internal.example:9000")
def test_falls_back_to_request_netloc_when_host_missing(self) -> None:
self.fake_config.base_url = ""
request = SimpleNamespace(
url=SimpleNamespace(scheme="https", netloc="public.example.com"),
headers={},
)
self.assertEqual(api_support.resolve_image_base_url(request), "https://public.example.com")
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
import json
import urllib.error
import urllib.request
from test.utils import BASE_URL, load_auth_key
def main() -> None:
payload = {
"prompt": "一只橘猫坐在窗台上,午后阳光,写实摄影",
"model": "gpt-image-2",
"n": 1,
"response_format": "url",
}
request = urllib.request.Request(
BASE_URL + "/v1/images/generations",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {load_auth_key()}"},
method="POST",
)
try:
with urllib.request.urlopen(request) as response:
body = response.read().decode()
except urllib.error.HTTPError as error:
body = error.read().decode()
try:
print(json.dumps(json.loads(body), ensure_ascii=False, indent=2))
except json.JSONDecodeError:
print(body)
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
import threading
import unittest
from services.config import config
from services.protocol.conversation import _remove_image_conversation_later
class FakeBackend:
def __init__(self) -> None:
self.called = threading.Event()
def delete_conversation(self, conversation_id: str) -> dict:
self.called.set()
return {}
class RemoveImageConversationGateTests(unittest.TestCase):
def setUp(self) -> None:
self._saved = dict(config.data)
def tearDown(self) -> None:
config.data = self._saved
def _removed(self, *, after_result: bool, always: bool, success: bool) -> bool:
config.data = dict(
self._saved,
image_remove_conversation_after_result=after_result,
image_remove_conversation_always=always,
)
backend = FakeBackend()
_remove_image_conversation_later(backend, "conv-1", success=success)
return backend.called.wait(2.0)
def test_both_off_never_removes(self) -> None:
self.assertFalse(self._removed(after_result=False, always=False, success=True))
self.assertFalse(self._removed(after_result=False, always=False, success=False))
def test_after_result_only_removes_on_success(self) -> None:
self.assertTrue(self._removed(after_result=True, always=False, success=True))
self.assertFalse(self._removed(after_result=True, always=False, success=False))
def test_always_removes_regardless_of_success(self) -> None:
self.assertTrue(self._removed(after_result=False, always=True, success=True))
self.assertTrue(self._removed(after_result=False, always=True, success=False))
def test_empty_conversation_id_is_noop(self) -> None:
config.data = dict(self._saved, image_remove_conversation_always=True)
backend = FakeBackend()
_remove_image_conversation_later(backend, "", success=False)
self.assertFalse(backend.called.wait(0.2))
if __name__ == "__main__":
unittest.main()
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from PIL import Image
from services.image_storage_service import ImageStorageService
def png_bytes() -> bytes:
path = Path(tempfile.gettempdir()) / "chatgpt2api-test-image.png"
Image.new("RGB", (2, 2), color=(255, 0, 0)).save(path, format="PNG")
return path.read_bytes()
class FakeWebDAVClient:
uploaded: dict[str, bytes] = {}
deleted: list[str] = []
def __init__(self, _settings):
pass
def put(self, rel: str, payload: bytes) -> str:
self.uploaded[rel] = payload
return f"https://dav.example.test/{rel}"
def get(self, rel: str) -> bytes:
return self.uploaded[rel]
def delete(self, rel: str) -> bool:
self.deleted.append(rel)
self.uploaded.pop(rel, None)
return True
def test(self) -> dict[str, object]:
self.put(".chatgpt2api_webdav_test.txt", b"chatgpt2api webdav test\n")
self.delete(".chatgpt2api_webdav_test.txt")
return {"ok": True, "status": 200, "error": None}
class ImageStorageServiceTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.data_dir = Path(self.tmp.name)
self.images_dir = self.data_dir / "images"
self.settings = {
"enabled": False,
"mode": "local",
"webdav_url": "",
"webdav_username": "",
"webdav_password": "",
"webdav_root_path": "chatgpt2api/images",
"public_base_url": "",
}
self.config_patcher = mock.patch("services.image_storage_service.config")
self.mock_config = self.config_patcher.start()
self.addCleanup(self.config_patcher.stop)
self.mock_config.images_dir = self.images_dir
self.mock_config.base_url = "http://app.test"
self.mock_config.cleanup_old_images.return_value = 0
self.mock_config.get_image_storage_settings.side_effect = lambda: dict(self.settings)
FakeWebDAVClient.uploaded = {}
FakeWebDAVClient.deleted = []
def service(self) -> ImageStorageService:
return ImageStorageService(self.data_dir / "image_index.json")
def test_local_mode_saves_to_local_directory(self):
stored = self.service().save(png_bytes(), "http://app.test")
self.assertEqual(stored.storage, "local")
self.assertTrue((self.images_dir / stored.rel).is_file())
self.assertEqual(stored.url, f"http://app.test/images/{stored.rel}")
def test_webdav_mode_uploads_without_local_file(self):
self.settings.update({
"enabled": True,
"mode": "webdav",
"webdav_url": "https://dav.example.test",
"webdav_password": "secret",
})
with mock.patch("services.image_storage_service.WebDAVClient", FakeWebDAVClient):
stored = self.service().save(png_bytes(), "http://app.test")
payload = self.service().get_bytes(stored.rel)
self.assertEqual(stored.storage, "webdav")
self.assertFalse((self.images_dir / stored.rel).exists())
self.assertIn(stored.rel, FakeWebDAVClient.uploaded)
self.assertEqual(payload, FakeWebDAVClient.uploaded[stored.rel])
def test_list_items_ignores_non_image_files(self):
image = png_bytes()
image_path = self.images_dir / "2026" / "05" / "07" / "sample.png"
image_path.parent.mkdir(parents=True, exist_ok=True)
image_path.write_bytes(image)
(self.images_dir / ".DS_Store").write_text("not an image", encoding="utf-8")
(self.images_dir / "2026" / ".DS_Store").write_text("not an image", encoding="utf-8")
items = self.service().list_items("http://app.test")
self.assertEqual([item["rel"] for item in items], ["2026/05/07/sample.png"])
self.assertEqual(items[0]["storage"], "local")
def test_both_mode_saves_to_local_and_webdav(self):
self.settings.update({
"enabled": True,
"mode": "both",
"webdav_url": "https://dav.example.test",
"webdav_password": "secret",
"public_base_url": "https://cdn.example.test/images",
})
with mock.patch("services.image_storage_service.WebDAVClient", FakeWebDAVClient):
stored = self.service().save(png_bytes(), "http://app.test")
self.assertEqual(stored.storage, "both")
self.assertTrue((self.images_dir / stored.rel).is_file())
self.assertIn(stored.rel, FakeWebDAVClient.uploaded)
self.assertEqual(stored.url, f"https://cdn.example.test/images/{stored.rel}")
def test_test_webdav_writes_and_deletes_probe_file(self):
self.settings.update({
"enabled": True,
"mode": "webdav",
"webdav_url": "https://dav.example.test",
"webdav_password": "secret",
})
with mock.patch("services.image_storage_service.WebDAVClient", FakeWebDAVClient):
result = self.service().test_webdav()
self.assertTrue(result["ok"])
self.assertIn(".chatgpt2api_webdav_test.txt", FakeWebDAVClient.deleted)
if __name__ == "__main__":
unittest.main()
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
import json
import tempfile
import time
import threading
import unittest
from pathlib import Path
from services.image_task_service import ImageTaskService
OWNER = {"id": "owner-1", "name": "Owner", "role": "admin"}
OTHER_OWNER = {"id": "owner-2", "name": "Other", "role": "user"}
def wait_for_task(service: ImageTaskService, identity: dict[str, object], task_id: str, status: str, timeout: float = 2.0):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
result = service.list_tasks(identity, [task_id])
last = (result.get("items") or [None])[0]
if last and last.get("status") == status:
return last
time.sleep(0.02)
raise AssertionError(f"task {task_id} did not reach {status}, last={last}")
class ImageTaskServiceTests(unittest.TestCase):
def test_elapsed_time_includes_startup_and_stage_timings_survive_reload(self):
entered = threading.Event()
proceed = threading.Event()
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir) / "tasks.json"
def handler(payload):
payload["progress_callback"]("starting_generation")
entered.set()
if not proceed.wait(timeout=2):
raise RuntimeError("test did not release task")
payload["progress_callback"]("image_stream_resolve_start")
return {"data": [{"url": "http://example.test/image.png"}]}
service = self.make_service(path, handler)
service.submit_generation(OWNER, client_task_id="timed", prompt="cat", model="gpt-image-2", size=None, base_url="http://local.test")
try:
self.assertTrue(entered.wait(timeout=2))
running = service.list_tasks(OWNER, ["timed"])["items"][0]
self.assertIn("elapsed_secs", running)
self.assertEqual(running["progress"], "starting_generation")
started_ts = next(iter(service._tasks.values()))["started_ts"]
finally:
proceed.set()
task = wait_for_task(service, OWNER, "timed", "success")
self.assertEqual(next(iter(service._tasks.values()))["started_ts"], started_ts)
self.assertIn("starting_generation", task["stage_durations_ms"])
self.assertIn("image_stream_resolve_start", task["stage_durations_ms"])
reloaded = self.make_service(path).list_tasks(OWNER, ["timed"])["items"][0]
self.assertEqual(reloaded["stage_durations_ms"], task["stage_durations_ms"])
def make_service(self, path: Path, handler=None) -> ImageTaskService:
return ImageTaskService(
path,
generation_handler=handler or (lambda _payload: {"data": [{"url": "http://example.test/image.png"}]}),
edit_handler=handler or (lambda _payload: {"data": [{"url": "http://example.test/edit.png"}]}),
retention_days_getter=lambda: 30,
)
def test_duplicate_submit_uses_existing_task(self):
with tempfile.TemporaryDirectory() as tmp_dir:
calls = 0
def handler(_payload):
nonlocal calls
calls += 1
time.sleep(0.05)
return {"data": [{"url": "http://example.test/image.png"}]}
service = self.make_service(Path(tmp_dir) / "image_tasks.json", handler)
first = service.submit_generation(
OWNER,
client_task_id="task-1",
prompt="cat",
model="gpt-image-2",
size=None,
base_url="http://local.test",
)
second = service.submit_generation(
OWNER,
client_task_id="task-1",
prompt="cat",
model="gpt-image-2",
size=None,
base_url="http://local.test",
)
self.assertEqual(first["id"], "task-1")
self.assertEqual(second["id"], "task-1")
task = wait_for_task(service, OWNER, "task-1", "success")
self.assertEqual(task["data"][0]["url"], "http://example.test/image.png")
self.assertEqual(calls, 1)
def test_different_owner_cannot_query_task(self):
with tempfile.TemporaryDirectory() as tmp_dir:
service = self.make_service(Path(tmp_dir) / "image_tasks.json")
service.submit_generation(
OWNER,
client_task_id="private-task",
prompt="cat",
model="gpt-image-2",
size=None,
base_url="http://local.test",
)
wait_for_task(service, OWNER, "private-task", "success")
result = service.list_tasks(OTHER_OWNER, ["private-task"])
self.assertEqual(result["items"], [])
self.assertEqual(result["missing_ids"], ["private-task"])
def test_success_task_persists_to_new_service_instance(self):
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir) / "image_tasks.json"
service = self.make_service(path)
service.submit_generation(
OWNER,
client_task_id="persisted-task",
prompt="cat",
model="gpt-image-2",
size=None,
base_url="http://local.test",
)
wait_for_task(service, OWNER, "persisted-task", "success")
reloaded = self.make_service(path)
result = reloaded.list_tasks(OWNER, ["persisted-task"])
self.assertEqual(result["missing_ids"], [])
self.assertEqual(result["items"][0]["status"], "success")
self.assertEqual(result["items"][0]["data"][0]["url"], "http://example.test/image.png")
def test_startup_marks_unfinished_tasks_as_error(self):
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir) / "image_tasks.json"
path.write_text(
json.dumps(
{
"tasks": [
{
"id": "queued-task",
"owner_id": "owner-1",
"status": "queued",
"mode": "generate",
"model": "gpt-image-2",
"created_at": "2099-01-01 00:00:00",
"updated_at": "2099-01-01 00:00:00",
},
{
"id": "running-task",
"owner_id": "owner-1",
"status": "running",
"mode": "generate",
"model": "gpt-image-2",
"created_at": "2099-01-01 00:00:00",
"updated_at": "2099-01-01 00:00:00",
},
]
}
),
encoding="utf-8",
)
service = self.make_service(path)
result = service.list_tasks(OWNER, ["queued-task", "running-task"])
self.assertEqual([item["status"] for item in result["items"]], ["error", "error"])
self.assertTrue(all("已中断" in item.get("error", "") for item in result["items"]))
if __name__ == "__main__":
unittest.main()
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import base64
import unittest
from unittest import mock
from fastapi import FastAPI
from fastapi.testclient import TestClient
import api.image_tasks as image_tasks_module
AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"}
PNG_BYTES = b"\x89PNG\r\n\x1a\n"
DATA_IMAGE_URL = f"data:image/png;base64,{base64.b64encode(PNG_BYTES).decode('ascii')}"
class FakeImageTaskService:
def __init__(self):
self.generation_calls = []
self.edit_calls = []
def submit_generation(self, identity, **kwargs):
self.generation_calls.append((identity, kwargs))
return {
"id": kwargs["client_task_id"],
"status": "success",
"mode": "generate",
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-01-01 00:00:00",
"data": [{"url": f"{kwargs['base_url']}/images/fake.png"}],
}
def submit_edit(self, identity, **kwargs):
self.edit_calls.append((identity, kwargs))
return {
"id": kwargs["client_task_id"],
"status": "queued",
"mode": "edit",
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-01-01 00:00:00",
}
def list_tasks(self, _identity, ids):
return {
"items": [
{
"id": task_id,
"status": "success",
"mode": "generate",
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-01-01 00:00:00",
"data": [{"url": "http://testserver/images/fake.png"}],
}
for task_id in ids
if task_id != "missing"
],
"missing_ids": [task_id for task_id in ids if task_id == "missing"],
}
class ImageTasksApiTests(unittest.TestCase):
def setUp(self):
self.fake_service = FakeImageTaskService()
self.service_patcher = mock.patch.object(image_tasks_module, "image_task_service", self.fake_service)
self.service_patcher.start()
self.addCleanup(self.service_patcher.stop)
app = FastAPI()
app.include_router(image_tasks_module.create_router())
self.client = TestClient(app)
def test_create_generation_task(self):
response = self.client.post(
"/api/image-tasks/generations",
headers=AUTH_HEADERS,
json={"client_task_id": "task-1", "prompt": "cat", "model": "gpt-image-2"},
)
self.assertEqual(response.status_code, 200, response.text)
payload = response.json()
self.assertEqual(payload["id"], "task-1")
self.assertEqual(payload["status"], "success")
self.assertEqual(len(self.fake_service.generation_calls), 1)
def test_create_edit_task_accepts_multiple_images(self):
"""测试图片编辑任务接口支持多个上传图片。"""
response = self.client.post(
"/api/image-tasks/edits",
headers=AUTH_HEADERS,
data={"client_task_id": "edit-1", "prompt": "edit", "model": "gpt-image-2"},
files=[
("image", ("one.png", b"one", "image/png")),
("image", ("two.png", b"two", "image/png")),
],
)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["id"], "edit-1")
self.assertEqual(len(self.fake_service.edit_calls), 1)
images = self.fake_service.edit_calls[0][1]["images"]
self.assertEqual(len(images), 2)
def test_create_edit_task_accepts_image_url(self):
"""测试图片编辑任务接口支持表单 image_url 引用。"""
response = self.client.post(
"/api/image-tasks/edits",
headers=AUTH_HEADERS,
data={
"client_task_id": "edit-url-1",
"prompt": "edit",
"model": "gpt-image-2",
"image_url": DATA_IMAGE_URL,
},
)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(len(self.fake_service.edit_calls), 1)
images = self.fake_service.edit_calls[0][1]["images"]
self.assertEqual(images, [(PNG_BYTES, "image_url.png", "image/png")])
def test_list_tasks_reports_missing_ids(self):
response = self.client.get("/api/image-tasks?ids=task-1,missing", headers=AUTH_HEADERS)
self.assertEqual(response.status_code, 200, response.text)
payload = response.json()
self.assertEqual([item["id"] for item in payload["items"]], ["task-1"])
self.assertEqual(payload["missing_ids"], ["missing"])
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import unittest
from utils.image_tokens import (
count_image_input_tokens,
count_image_output_tokens,
)
class ImageTokenTests(unittest.TestCase):
def test_patch_token_examples_match_openai_docs(self):
self.assertEqual(count_image_input_tokens(1024, 1024, "gpt-4.1-mini", "high"), 1659)
self.assertEqual(count_image_input_tokens(1800, 2400, "gpt-4.1-mini", "high"), 2353)
def test_image_input_tokens_force_gpt_54_mini(self):
expected = count_image_input_tokens(1024, 1024, "gpt-5.4-mini", "low")
self.assertEqual(expected, 415)
self.assertEqual(count_image_input_tokens(1024, 1024, "gpt-4o", "low"), expected)
self.assertEqual(count_image_input_tokens(1024, 1024, "gpt-image-2", "low"), expected)
def test_image_output_tokens_scale_by_count_and_size(self):
single = count_image_output_tokens("1024x1024", "auto", 1)
self.assertGreater(single, 0)
self.assertEqual(count_image_output_tokens("1024x1024", "auto", 2), single * 2)
if __name__ == "__main__":
unittest.main()
+117
View File
@@ -0,0 +1,117 @@
import errno
import importlib.util
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "init_proxy_config.py"
def load_script_module():
spec = importlib.util.spec_from_file_location("init_proxy_config_under_test", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
assert spec is not None and spec.loader is not None
spec.loader.exec_module(module)
return module
class InitProxyConfigTests(unittest.TestCase):
def test_creates_warp_defaults_when_proxy_runtime_missing(self) -> None:
module = load_script_module()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
path.write_text(json.dumps({"auth-key": "secret", "proxy": ""}), encoding="utf-8")
with patch.dict(os.environ, {"CHATGPT2API_CONFIG_FILE": str(path)}, clear=False):
self.assertEqual(module.main(), 0)
data = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(data["auth-key"], "secret")
runtime = data["proxy_runtime"]
self.assertTrue(runtime["enabled"])
self.assertEqual(runtime["egress_mode"], "single_proxy")
self.assertEqual(runtime["proxy_url"], "http://privoxy:8118")
self.assertTrue(runtime["clearance"]["enabled"])
self.assertEqual(runtime["clearance"]["mode"], "flaresolverr")
self.assertEqual(runtime["clearance"]["flaresolverr_url"], "http://flaresolverr:8191")
def test_existing_custom_runtime_is_not_overwritten(self) -> None:
module = load_script_module()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
path.write_text(
json.dumps(
{
"auth-key": "secret",
"proxy_runtime": {
"enabled": False,
"egress_mode": "single_proxy",
"proxy_url": "http://custom.proxy:8080",
"clearance": {
"enabled": True,
"mode": "manual",
"cf_clearance": "manual-token",
},
},
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"CHATGPT2API_CONFIG_FILE": str(path)}, clear=False):
self.assertEqual(module.main(), 0)
runtime = json.loads(path.read_text(encoding="utf-8"))["proxy_runtime"]
self.assertFalse(runtime["enabled"])
self.assertEqual(runtime["proxy_url"], "http://custom.proxy:8080")
self.assertEqual(runtime["clearance"]["mode"], "manual")
self.assertEqual(runtime["clearance"]["cf_clearance"], "manual-token")
self.assertIn("timeout_sec", runtime["clearance"])
self.assertIn("reset_session_status_codes", runtime)
def test_env_can_disable_runtime_defaults_for_warp_compose(self) -> None:
module = load_script_module()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
path.write_text(json.dumps({"auth-key": "secret"}), encoding="utf-8")
with patch.dict(
os.environ,
{
"CHATGPT2API_CONFIG_FILE": str(path),
"CHATGPT2API_PROXY_RUNTIME_ENABLED": "false",
"CHATGPT2API_PROXY_RUNTIME_CLEARANCE_ENABLED": "false",
},
clear=False,
):
self.assertEqual(module.main(), 0)
runtime = json.loads(path.read_text(encoding="utf-8"))["proxy_runtime"]
self.assertFalse(runtime["enabled"])
self.assertFalse(runtime["clearance"]["enabled"])
self.assertEqual(runtime["clearance"]["mode"], "none")
def test_bind_mounted_config_file_falls_back_when_atomic_replace_is_busy(self) -> None:
module = load_script_module()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
path.write_text(json.dumps({"auth-key": "secret", "proxy": ""}), encoding="utf-8")
original_replace = Path.replace
def replace_with_ebusy(self: Path, target: Path) -> Path:
if self.name == "config.json.tmp":
raise OSError(errno.EBUSY, "Device or resource busy")
return original_replace(self, target)
with patch.dict(os.environ, {"CHATGPT2API_CONFIG_FILE": str(path)}, clear=False):
with patch.object(Path, "replace", replace_with_ebusy):
self.assertEqual(module.main(), 0)
data = json.loads(path.read_text(encoding="utf-8"))
self.assertTrue(data["proxy_runtime"]["enabled"])
self.assertFalse(path.with_suffix(".json.tmp").exists())
if __name__ == "__main__":
unittest.main()
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
import tempfile
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from services.account_service import AccountService
from services.model_service import ModelCatalogService
from services.storage.json_storage import JSONStorageBackend
def model_list(*model_ids: str) -> dict:
return {
"object": "list",
"data": [
{
"id": model_id,
"object": "model",
"created": 0,
"owned_by": "chatgpt",
"permission": [],
"root": model_id,
"parent": None,
}
for model_id in model_ids
],
}
class FakeBackend:
def __init__(self, access_token: str, outcomes: dict[str, object], calls: list[str], closed: list[str]) -> None:
self.access_token = access_token
self._outcomes = outcomes
self._calls = calls
self._closed = closed
def list_models(self) -> dict:
self._calls.append(self.access_token)
outcome = self._outcomes[self.access_token]
if isinstance(outcome, Exception):
raise outcome
return outcome
def close(self) -> None:
self._closed.append(self.access_token)
class ModelCatalogServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.accounts = AccountService(
JSONStorageBackend(Path(self.temp_dir.name) / "accounts.json")
)
self.accounts.add_account_items(
[
{"access_token": "free-bad", "type": "free", "status": "正常"},
{"access_token": "free-good", "type": "FREE", "status": "正常"},
{"access_token": "plus", "type": "Plus", "status": "正常"},
{"access_token": "pro", "type": "pro", "status": "正常"},
{"access_token": "team-disabled", "type": "Team", "status": "禁用"},
]
)
self.accounts.refresh_access_token = lambda token, **_kwargs: token
self.now = 1000.0
self.calls: list[str] = []
self.closed: list[str] = []
self.outcomes: dict[str, object] = {
"": model_list("anon", "shared"),
"free-bad": RuntimeError("expired"),
"free-good": model_list("free-only", "shared"),
"plus": model_list("plus-only", "shared"),
"pro": model_list("pro-only"),
}
self.catalog = ModelCatalogService(
self.accounts,
backend_factory=lambda access_token="": FakeBackend(
access_token, self.outcomes, self.calls, self.closed
),
cache_ttl_seconds=300,
clock=lambda: self.now,
)
def test_catalog_unions_anonymous_and_each_active_account_type(self) -> None:
result = self.catalog.list_models()
self.assertEqual(
[item["id"] for item in result["data"]],
["anon", "free-only", "plus-only", "pro-only", "shared"],
)
self.assertCountEqual(self.calls, ["", "free-bad", "free-good", "plus", "pro"])
self.assertCountEqual(self.closed, self.calls)
self.assertNotIn("team-disabled", self.calls)
pro_route = self.catalog.route_for_model("pro-only")
self.assertEqual(pro_route.account_types, frozenset({"Pro"}))
self.assertFalse(pro_route.allow_anonymous)
shared_route = self.catalog.route_for_model("shared")
self.assertEqual(shared_route.account_types, frozenset({"free", "Plus"}))
self.assertTrue(shared_route.allow_anonymous)
def test_catalog_is_cached_until_ttl_expires(self) -> None:
self.catalog.list_models()
self.catalog.list_models()
self.catalog.route_for_model("pro-only")
self.assertEqual(self.calls.count("pro"), 1)
self.assertEqual(self.calls.count(""), 1)
def test_concurrent_readers_share_one_catalog_refresh(self) -> None:
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(lambda _index: self.catalog.list_models(), range(8)))
self.assertTrue(all(result == results[0] for result in results))
self.assertEqual(self.calls.count(""), 1)
self.assertEqual(self.calls.count("free-good"), 1)
self.assertEqual(self.calls.count("plus"), 1)
self.assertEqual(self.calls.count("pro"), 1)
def test_failed_refresh_keeps_last_successful_models_for_that_type(self) -> None:
self.catalog.list_models()
self.outcomes["pro"] = RuntimeError("temporary upstream failure")
self.now += 301
result = self.catalog.list_models()
self.assertIn("pro-only", {item["id"] for item in result["data"]})
self.assertEqual(
self.catalog.route_for_model("pro-only").account_types,
frozenset({"Pro"}),
)
self.assertEqual(self.calls.count("pro"), 2)
def test_removed_account_type_drops_its_stale_capabilities(self) -> None:
self.catalog.list_models()
self.accounts.delete_accounts(["pro"])
result = self.catalog.list_models()
self.assertNotIn("pro-only", {item["id"] for item in result["data"]})
self.assertEqual(
self.catalog.route_for_model("pro-only").account_types,
frozenset(),
)
if __name__ == "__main__":
unittest.main()
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
import base64
import unittest
from unittest import mock
from services.config import config
from services.openai_backend_api import OpenAIBackendAPI
from services.protocol.conversation import ImageOutput, extract_conversation_ids
from services.protocol.openai_v1_response import stream_image_response
def _conversation(file_ids: list[str], sediment_ids: list[str] | None = None) -> dict:
parts: list[object] = [
{"content_type": "image_asset_pointer", "asset_pointer": f"file-service://{file_id}"}
for file_id in file_ids
]
parts.extend(f"sediment://{sediment_id}" for sediment_id in (sediment_ids or []))
return {
"mapping": {
"tool": {
"message": {
"author": {"role": "tool"},
"create_time": 1,
"metadata": {"async_task_type": "image_gen"},
"content": {"content_type": "multimodal_text", "parts": parts},
}
}
}
}
class FakeBackend(OpenAIBackendAPI):
def __init__(self, conversations: list[dict] | None = None) -> None:
self.conversations = conversations or []
self.calls = 0
self.file_urls: dict[str, str] = {}
self.sediment_urls: dict[str, str] = {}
def _get_conversation(self, conversation_id: str) -> dict:
self.calls += 1
index = min(self.calls - 1, len(self.conversations) - 1)
return self.conversations[index]
def _get_file_download_url(self, file_id: str) -> str:
return self.file_urls.get(file_id, "")
def _get_attachment_download_url(self, conversation_id: str, attachment_id: str) -> str:
return self.sediment_urls.get(attachment_id, "")
class MultiImageResultTests(unittest.TestCase):
def test_stream_id_extractor_keeps_full_file_ids(self) -> None:
payload = (
'{"conversation_id":"conv-1"} '
'file-service://file-first_123-extra sediment://sed-second_456-extra'
)
conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
self.assertEqual(conversation_id, "conv-1")
self.assertEqual(file_ids, ["file-first_123-extra"])
self.assertEqual(sediment_ids, ["sed-second_456-extra"])
def test_conversation_record_extractor_finds_all_generated_assets(self) -> None:
backend = FakeBackend()
conversation = {
"mapping": {
"user": {
"message": {
"author": {"role": "user"},
"content": {"parts": ["file-service://file-user-input"]},
}
},
"tool": {
"message": {
"author": {"role": "tool"},
"create_time": 1,
"metadata": {
"async_task_type": "image_gen",
"nested": {"asset": "file-service://file-second"},
},
"content": {
"content_type": "text",
"parts": [
{"content_type": "image_asset_pointer", "asset_pointer": "file-service://file-first"},
"sediment://sed-first",
],
},
}
},
"assistant": {
"message": {
"author": {"role": "assistant"},
"create_time": 2,
"metadata": {},
"content": {
"parts": [
{"content_type": "image_asset_pointer", "asset_pointer": "file-service://file-third"}
]
},
}
},
}
}
records = backend._extract_image_tool_records(conversation)
file_ids = [file_id for record in records for file_id in record["file_ids"]]
sediment_ids = [sediment_id for record in records for sediment_id in record["sediment_ids"]]
self.assertEqual(file_ids, ["file-first", "file-second", "file-third"])
self.assertEqual(sediment_ids, ["sed-first"])
def test_poll_waits_for_generated_asset_ids_to_settle(self) -> None:
backend = FakeBackend([
_conversation(["file-one"]),
_conversation(["file-one", "file-two"], ["sed-one"]),
_conversation(["file-one", "file-two"], ["sed-one"]),
])
with (
mock.patch.dict(config.data, {
"image_poll_initial_wait_secs": 0,
"image_poll_interval_secs": 0.5,
"image_check_before_hit_enabled": True,
"image_settle_enabled": True,
}),
mock.patch("services.openai_backend_api.time.sleep", lambda _seconds: None),
):
file_ids, sediment_ids = backend._poll_image_results("conv-1", timeout_secs=10)
self.assertEqual(file_ids, ["file-one", "file-two"])
self.assertEqual(sediment_ids, ["sed-one"])
self.assertEqual(backend.calls, 3)
def test_resolver_uses_file_and_sediment_urls(self) -> None:
backend = FakeBackend()
backend.file_urls = {"file-one": "https://files.test/one.png"}
backend.sediment_urls = {
"sed-one": "https://attachments.test/one.png",
"sed-two": "https://attachments.test/two.png",
}
urls = backend._resolve_image_urls("conv-1", ["file-one"], ["sed-one", "sed-two"])
self.assertEqual(urls, [
"https://files.test/one.png",
"https://attachments.test/one.png",
"https://attachments.test/two.png",
])
def test_resolver_keeps_stream_ids_when_poll_extension_fails(self) -> None:
backend = FakeBackend()
backend.file_urls = {"file-one": "https://files.test/one.png"}
backend._get_conversation = mock.Mock(side_effect=RuntimeError("poll failed"))
with mock.patch("services.openai_backend_api.time.sleep", lambda _seconds: None):
urls = backend.resolve_conversation_image_urls("conv-1", ["file-one"], [], poll=True)
self.assertEqual(urls, ["https://files.test/one.png"])
def test_responses_stream_emits_all_image_output_items(self) -> None:
first = base64.b64encode(b"first").decode("ascii")
second = base64.b64encode(b"second").decode("ascii")
events = list(stream_image_response(
[ImageOutput(
kind="result",
model="gpt-image-2",
index=1,
total=1,
data=[{"b64_json": first}, {"b64_json": second}],
)],
"draw two options",
"gpt-image-2",
))
done_events = [event for event in events if event.get("type") == "response.output_item.done"]
completed = next(event["response"] for event in events if event.get("type") == "response.completed")
self.assertEqual([event["output_index"] for event in done_events], [0, 1])
self.assertEqual([item["result"] for item in completed["output"]], [first, second])
if __name__ == "__main__":
unittest.main()
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import copy
import unittest
from unittest import mock
from fastapi import FastAPI
from fastapi.testclient import TestClient
import api.system as system_module
from services.config import DEFAULT_PROXY_RUNTIME
AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"}
class FakeStorage:
def get_backend_info(self) -> dict[str, object]:
return {"type": "json"}
def health_check(self) -> dict[str, object]:
return {"ok": True}
class FakeConfig:
def __init__(self) -> None:
self.data: dict[str, object] = {
"proxy": "",
"proxy_runtime": copy.deepcopy(DEFAULT_PROXY_RUNTIME),
}
def get(self) -> dict[str, object]:
return copy.deepcopy(self.data)
def update(self, updates: dict[str, object]) -> dict[str, object]:
self.data.update(copy.deepcopy(updates))
return self.get()
def get_proxy_settings(self) -> str:
return str(self.data.get("proxy") or "").strip()
def get_proxy_runtime_settings(self) -> dict[str, object]:
return copy.deepcopy(self.data["proxy_runtime"]) # type: ignore[index]
def get_public_proxy_runtime_settings(self) -> dict[str, object]:
return self.get_proxy_runtime_settings()
def get_storage_backend(self) -> FakeStorage:
return FakeStorage()
class FakeProxySettings:
def get_runtime_status(self) -> dict[str, object]:
return {
"enabled": True,
"egress_mode": "single_proxy",
"proxy_source": "proxy_runtime",
"has_proxy": True,
"clearance_enabled": True,
"clearance_mode": "flaresolverr",
"has_clearance_bundle": False,
"cached_clearance_hosts": [],
}
class FakeAccountService:
def get_stats(self) -> dict[str, object]:
return {
"total": 1,
"cumulative_total": 1,
"active": 1,
"total_quota": 1,
"limited": 0,
"abnormal": 0,
"disabled": 0,
"total_success": 0,
"total_fail": 0,
"by_type": {"web": 1},
}
class ProxyRuntimeApiTests(unittest.TestCase):
def setUp(self) -> None:
self.fake_config = FakeConfig()
self.fake_proxy_settings = FakeProxySettings()
self.test_proxy_calls: list[str] = []
self.test_clearance_calls: list[str] = []
def fake_test_proxy(url: str = "") -> dict[str, object]:
self.test_proxy_calls.append(url)
return {
"ok": True,
"status": 204,
"latency_ms": 12,
"error": None,
"proxy_source": "proxy_runtime" if not url else "input",
"has_proxy": True,
}
def fake_test_clearance(target_url: str = "https://chatgpt.com") -> dict[str, object]:
self.test_clearance_calls.append(target_url)
return {
"ok": True,
"status": "ok",
"latency_ms": 34,
"has_cookies": True,
"user_agent": "Flare UA",
"error": None,
"runtime": self.fake_proxy_settings.get_runtime_status(),
}
self.patchers = [
mock.patch.object(system_module, "config", self.fake_config),
mock.patch.object(system_module, "require_admin", lambda _authorization: {"role": "admin"}),
mock.patch.object(system_module, "test_proxy", fake_test_proxy),
mock.patch.object(system_module, "test_clearance", fake_test_clearance, create=True),
mock.patch.object(system_module, "proxy_settings", self.fake_proxy_settings, create=True),
mock.patch("services.account_service.account_service", FakeAccountService()),
]
for patcher in self.patchers:
patcher.start()
self.addCleanup(patcher.stop)
app = FastAPI()
app.include_router(system_module.create_router("9.9.9-test"))
self.client = TestClient(app)
def test_proxy_test_can_use_active_runtime_when_url_is_empty(self) -> None:
response = self.client.post("/api/proxy/test", headers=AUTH_HEADERS, json={})
self.assertEqual(response.status_code, 200, response.text)
payload = response.json()["result"]
self.assertTrue(payload["ok"])
self.assertEqual(payload["proxy_source"], "proxy_runtime")
self.assertEqual(self.test_proxy_calls, [""])
def test_proxy_runtime_endpoint_reads_and_updates_runtime_config(self) -> None:
get_response = self.client.get("/api/proxy/runtime", headers=AUTH_HEADERS)
self.assertEqual(get_response.status_code, 200, get_response.text)
self.assertEqual(get_response.json()["runtime"]["enabled"], False)
self.assertEqual(get_response.json()["status"]["proxy_source"], "proxy_runtime")
runtime = copy.deepcopy(DEFAULT_PROXY_RUNTIME)
runtime["enabled"] = True
runtime["egress_mode"] = "single_proxy"
runtime["proxy_url"] = "http://privoxy:8118"
runtime["clearance"]["enabled"] = True # type: ignore[index]
runtime["clearance"]["mode"] = "flaresolverr" # type: ignore[index]
runtime["clearance"]["flaresolverr_url"] = "http://flaresolverr:8191" # type: ignore[index]
post_response = self.client.post("/api/proxy/runtime", headers=AUTH_HEADERS, json=runtime)
self.assertEqual(post_response.status_code, 200, post_response.text)
self.assertTrue(post_response.json()["runtime"]["enabled"])
self.assertEqual(self.fake_config.data["proxy_runtime"], runtime)
def test_clearance_test_endpoint_runs_clearance_refresh_without_returning_cookie_values(self) -> None:
response = self.client.post(
"/api/proxy/clearance/test",
headers=AUTH_HEADERS,
json={"target_url": "https://chatgpt.com/backend-api/models"},
)
self.assertEqual(response.status_code, 200, response.text)
payload = response.json()["result"]
self.assertTrue(payload["ok"])
self.assertTrue(payload["has_cookies"])
self.assertNotIn("cf_clearance", response.text)
self.assertEqual(self.test_clearance_calls, ["https://chatgpt.com/backend-api/models"])
def test_health_json_includes_proxy_runtime_status(self) -> None:
response = self.client.get("/health?format=json")
self.assertEqual(response.status_code, 200, response.text)
payload = response.json()
self.assertEqual(payload["version"], "9.9.9-test")
self.assertEqual(payload["proxy_runtime"]["clearance_mode"], "flaresolverr")
if __name__ == "__main__":
unittest.main()
+220
View File
@@ -0,0 +1,220 @@
import copy
import json
import math
import tempfile
import unittest
from pathlib import Path
from services.config import (
DEFAULT_PROXY_RUNTIME,
ConfigStore,
_normalize_proxy_runtime_settings,
)
class ProxyRuntimeConfigTests(unittest.TestCase):
def _make_store(self, initial: dict[str, object] | None = None) -> tuple[tempfile.TemporaryDirectory[str], ConfigStore]:
tmp_dir = tempfile.TemporaryDirectory()
path = Path(tmp_dir.name) / "config.json"
data = {"auth-key": "test-auth"}
if initial:
data.update(initial)
path.write_text(json.dumps(data), encoding="utf-8")
return tmp_dir, ConfigStore(path)
def test_defaults_are_safe_and_included_in_public_config(self) -> None:
tmp_dir, store = self._make_store({"proxy": " http://legacy.example:8080 "})
with tmp_dir:
expected_default = copy.deepcopy(DEFAULT_PROXY_RUNTIME)
runtime = store.get_proxy_runtime_settings()
self.assertEqual(runtime, expected_default)
self.assertEqual(store.get_proxy_settings(), "http://legacy.example:8080")
expected_public = copy.deepcopy(expected_default)
expected_public["clearance"]["has_cf_cookies"] = False
expected_public["clearance"]["has_cf_clearance"] = False
public_config = store.get()
self.assertEqual(public_config["proxy"], " http://legacy.example:8080 ")
self.assertEqual(public_config["proxy_runtime"], expected_public)
self.assertNotIn("auth-key", public_config)
runtime["enabled"] = True
runtime["clearance"]["enabled"] = True
self.assertEqual(DEFAULT_PROXY_RUNTIME, expected_default)
self.assertEqual(store.get_proxy_runtime_settings(), expected_default)
def test_normalize_proxy_runtime_sanitizes_invalid_values(self) -> None:
normalized = _normalize_proxy_runtime_settings(
{
"enabled": "yes",
"egress_mode": "tor",
"proxy_url": " http://proxy.example:8080 ",
"resource_proxy_url": " socks5://resource.example:1080 ",
"skip_ssl_verify": "on",
"reset_session_status_codes": ["403", 429, 99, 600, "bad", None, True],
"clearance": {
"enabled": "0",
"mode": "manual",
"cf_cookies": " a=b; c=d ",
"cf_clearance": " token ",
"user_agent": " Custom UA ",
"browser": " firefox ",
"flaresolverr_url": " http://flare.example/ ",
"timeout_sec": 0,
"refresh_interval": 59,
"warm_up_on_start": "true",
},
}
)
self.assertTrue(normalized["enabled"])
self.assertEqual(normalized["egress_mode"], "direct")
self.assertEqual(normalized["proxy_url"], "http://proxy.example:8080")
self.assertEqual(normalized["resource_proxy_url"], "socks5://resource.example:1080")
self.assertTrue(normalized["skip_ssl_verify"])
self.assertEqual(normalized["reset_session_status_codes"], [403, 429])
clearance = normalized["clearance"]
self.assertFalse(clearance["enabled"])
self.assertEqual(clearance["mode"], "manual")
self.assertEqual(clearance["cf_cookies"], "a=b; c=d")
self.assertEqual(clearance["cf_clearance"], "token")
self.assertEqual(clearance["user_agent"], "Custom UA")
self.assertEqual(clearance["browser"], "firefox")
self.assertEqual(clearance["flaresolverr_url"], "http://flare.example/")
self.assertEqual(clearance["timeout_sec"], 1)
self.assertEqual(clearance["refresh_interval"], 60)
self.assertTrue(clearance["warm_up_on_start"])
def test_normalize_proxy_runtime_uses_defaults_for_missing_or_empty_values(self) -> None:
self.assertEqual(_normalize_proxy_runtime_settings(None), DEFAULT_PROXY_RUNTIME)
self.assertEqual(
_normalize_proxy_runtime_settings(
{
"egress_mode": "single_proxy",
"reset_session_status_codes": ["bad", 99, 600],
"clearance": {
"enabled": True,
"mode": "invalid",
"user_agent": "",
"browser": "",
"timeout_sec": "bad",
"refresh_interval": "bad",
},
}
),
{
**DEFAULT_PROXY_RUNTIME,
"egress_mode": "single_proxy",
"reset_session_status_codes": [403],
"clearance": {
**DEFAULT_PROXY_RUNTIME["clearance"],
"enabled": True,
"mode": "none",
},
},
)
def test_malformed_proxy_runtime_values_fall_back_safely(self) -> None:
normalized = _normalize_proxy_runtime_settings(
{
"enabled": "maybe",
"skip_ssl_verify": "maybe",
"reset_session_status_codes": [math.inf, "NaN", "403"],
"clearance": {
"enabled": "maybe",
"timeout_sec": math.inf,
"refresh_interval": -math.inf,
"warm_up_on_start": "maybe",
},
}
)
self.assertFalse(normalized["enabled"])
self.assertFalse(normalized["skip_ssl_verify"])
self.assertEqual(normalized["reset_session_status_codes"], [403])
clearance = normalized["clearance"]
self.assertFalse(clearance["enabled"])
self.assertEqual(clearance["timeout_sec"], DEFAULT_PROXY_RUNTIME["clearance"]["timeout_sec"])
self.assertEqual(clearance["refresh_interval"], DEFAULT_PROXY_RUNTIME["clearance"]["refresh_interval"])
self.assertFalse(clearance["warm_up_on_start"])
def test_update_normalizes_and_persists_proxy_runtime(self) -> None:
tmp_dir, store = self._make_store()
with tmp_dir:
public_config = store.update(
{
"proxy_runtime": {
"enabled": "true",
"egress_mode": "single_proxy",
"proxy_url": " http://proxy.example ",
"reset_session_status_codes": [401, "403", "nope"],
"clearance": {
"enabled": "yes",
"mode": "flaresolverr",
"flaresolverr_url": " http://localhost:8191 ",
"timeout_sec": "30",
"refresh_interval": "120",
},
}
}
)
expected = {
**DEFAULT_PROXY_RUNTIME,
"enabled": True,
"egress_mode": "single_proxy",
"proxy_url": "http://proxy.example",
"reset_session_status_codes": [401, 403],
"clearance": {
**DEFAULT_PROXY_RUNTIME["clearance"],
"enabled": True,
"mode": "flaresolverr",
"flaresolverr_url": "http://localhost:8191",
"timeout_sec": 30,
"refresh_interval": 120,
},
}
expected_public = copy.deepcopy(expected)
expected_public["clearance"]["cf_cookies"] = ""
expected_public["clearance"]["cf_clearance"] = ""
expected_public["clearance"]["has_cf_cookies"] = False
expected_public["clearance"]["has_cf_clearance"] = False
self.assertEqual(public_config["proxy_runtime"], expected_public)
raw_saved = json.loads(store.path.read_text(encoding="utf-8"))
self.assertEqual(raw_saved["proxy_runtime"], expected)
reloaded = ConfigStore(store.path)
self.assertEqual(reloaded.get_proxy_runtime_settings(), expected)
def test_public_proxy_runtime_redacts_and_preserves_existing_clearance_values(self) -> None:
existing = copy.deepcopy(DEFAULT_PROXY_RUNTIME)
existing["enabled"] = True
existing["clearance"]["enabled"] = True
existing["clearance"]["mode"] = "manual"
existing["clearance"]["cf_cookies"] = "session=secret-cookie"
existing["clearance"]["cf_clearance"] = "secret-clearance"
tmp_dir, store = self._make_store({"proxy_runtime": existing})
with tmp_dir:
public_config = store.get()
public_runtime = public_config["proxy_runtime"]
public_clearance = public_runtime["clearance"]
self.assertEqual(public_clearance["cf_cookies"], "")
self.assertEqual(public_clearance["cf_clearance"], "")
self.assertTrue(public_clearance["has_cf_cookies"])
self.assertTrue(public_clearance["has_cf_clearance"])
self.assertNotIn("secret-cookie", json.dumps(public_config))
self.assertNotIn("secret-clearance", json.dumps(public_config))
public_clearance["user_agent"] = "Updated UA"
updated_public = store.update({"proxy_runtime": public_runtime})
updated_raw = json.loads(store.path.read_text(encoding="utf-8"))["proxy_runtime"]
self.assertEqual(updated_raw["clearance"]["cf_cookies"], "session=secret-cookie")
self.assertEqual(updated_raw["clearance"]["cf_clearance"], "secret-clearance")
self.assertEqual(updated_raw["clearance"]["user_agent"], "Updated UA")
self.assertEqual(updated_public["proxy_runtime"]["clearance"]["cf_cookies"], "")
self.assertEqual(updated_public["proxy_runtime"]["clearance"]["cf_clearance"], "")
if __name__ == "__main__":
unittest.main()
+415
View File
@@ -0,0 +1,415 @@
import copy
import json
import threading
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
from urllib.parse import urlparse
from services.config import DEFAULT_PROXY_RUNTIME
from services.proxy_service import (
ClearanceBundle,
FlareSolverrClearanceProvider,
ProxySettingsStore,
normalize_proxy_url,
)
class FakeConfig:
def __init__(self, legacy_proxy: str = "", runtime: dict[str, object] | None = None) -> None:
self.legacy_proxy = legacy_proxy
self.runtime = runtime if runtime is not None else copy.deepcopy(DEFAULT_PROXY_RUNTIME)
def get_proxy_settings(self) -> str:
return self.legacy_proxy
def get_proxy_runtime_settings(self) -> dict[str, object]:
return copy.deepcopy(self.runtime)
def make_runtime(**overrides: object) -> dict[str, object]:
runtime = copy.deepcopy(DEFAULT_PROXY_RUNTIME)
clearance = overrides.pop("clearance", None)
runtime.update(overrides)
if clearance is not None:
runtime["clearance"].update(clearance) # type: ignore[index,union-attr]
return runtime
class ProxyServiceTests(unittest.TestCase):
def test_normalize_proxy_url_strips_and_converts_socks_schemes(self) -> None:
self.assertEqual(normalize_proxy_url(" http://proxy.example:8080 "), "http://proxy.example:8080")
self.assertEqual(normalize_proxy_url("\thttps://proxy.example:8443\n"), "https://proxy.example:8443")
self.assertEqual(normalize_proxy_url(" socks://proxy.example:1080 "), "socks5h://proxy.example:1080")
self.assertEqual(normalize_proxy_url("socks5://proxy.example:1080"), "socks5h://proxy.example:1080")
self.assertEqual(normalize_proxy_url(" socks5h://proxy.example:1080 "), "socks5h://proxy.example:1080")
self.assertEqual(normalize_proxy_url(" "), "")
def test_build_session_kwargs_keeps_legacy_global_proxy_when_runtime_disabled(self) -> None:
store = ProxySettingsStore(FakeConfig(legacy_proxy=" http://legacy.example:8080 "))
kwargs = store.build_session_kwargs(impersonate="chrome")
self.assertEqual(kwargs["impersonate"], "chrome")
self.assertEqual(kwargs["proxy"], "http://legacy.example:8080")
def test_runtime_proxy_is_limited_to_upstream_scope_by_default(self) -> None:
runtime = make_runtime(enabled=True, egress_mode="single_proxy", proxy_url="http://runtime.example:8080")
store = ProxySettingsStore(FakeConfig(legacy_proxy="http://legacy.example:8080", runtime=runtime))
self.assertEqual(
store.build_session_kwargs()["proxy"],
"http://legacy.example:8080",
)
self.assertEqual(
store.build_session_kwargs(upstream=True)["proxy"],
"http://runtime.example:8080",
)
def test_account_proxy_wins_over_runtime_and_global_proxy(self) -> None:
runtime = make_runtime(enabled=True, egress_mode="single_proxy", proxy_url="http://runtime.example:8080")
store = ProxySettingsStore(FakeConfig(legacy_proxy="http://legacy.example:8080", runtime=runtime))
kwargs = store.build_session_kwargs(account={"proxy": " socks://account.example:1080 "}, upstream=True)
self.assertEqual(kwargs["proxy"], "socks5h://account.example:1080")
def test_proxy_runtime_single_proxy_wins_over_explicit_and_legacy_proxy_when_enabled(self) -> None:
runtime = make_runtime(enabled=True, egress_mode="single_proxy", proxy_url=" socks5://runtime.example:1080 ")
store = ProxySettingsStore(FakeConfig(legacy_proxy="http://legacy.example:8080", runtime=runtime))
kwargs = store.build_session_kwargs(proxy="http://explicit.example:8080", upstream=True)
self.assertEqual(kwargs["proxy"], "socks5h://runtime.example:1080")
def test_explicit_proxy_wins_over_legacy_global_proxy_when_runtime_disabled(self) -> None:
store = ProxySettingsStore(FakeConfig(legacy_proxy="http://legacy.example:8080"))
kwargs = store.build_session_kwargs(proxy=" socks5://explicit.example:1080 ")
self.assertEqual(kwargs["proxy"], "socks5h://explicit.example:1080")
def test_resource_requests_use_resource_proxy_url_when_configured(self) -> None:
runtime = make_runtime(
enabled=True,
egress_mode="single_proxy",
proxy_url="http://runtime.example:8080",
resource_proxy_url=" socks5://resource.example:1080 ",
)
store = ProxySettingsStore(FakeConfig(legacy_proxy="http://legacy.example:8080", runtime=runtime))
kwargs = store.build_session_kwargs(resource=True, upstream=True)
self.assertEqual(kwargs["proxy"], "socks5h://resource.example:1080")
def test_manual_clearance_merges_cookies_and_preserves_explicit_user_agent(self) -> None:
runtime = make_runtime(
enabled=True,
egress_mode="single_proxy",
proxy_url="http://runtime.example:8080",
clearance={
"enabled": True,
"mode": "manual",
"cf_cookies": "foo=bar; session=ok",
"cf_clearance": "manual-token",
"user_agent": "Manual UA",
},
)
store = ProxySettingsStore(FakeConfig(runtime=runtime))
headers = store.build_headers(headers={"Cookie": "existing=1"}, target_url="https://chatgpt.com/backend-api")
self.assertEqual(headers["User-Agent"], "Manual UA")
self.assertEqual(headers["Cookie"], "existing=1; foo=bar; session=ok; cf_clearance=manual-token")
headers_with_ua = store.build_headers(
headers={"User-Agent": "Caller UA", "Cookie": "cf_clearance=caller-token"},
target_url="https://chatgpt.com/backend-api",
)
self.assertEqual(headers_with_ua["User-Agent"], "Caller UA")
self.assertEqual(headers_with_ua["Cookie"], "cf_clearance=caller-token; foo=bar; session=ok")
self.assertNotIn("cf_clearance=manual-token", headers_with_ua["Cookie"])
def test_flaresolverr_provider_parses_solution_and_filters_cookies_by_host(self) -> None:
calls: list[tuple[str, dict[str, object], dict[str, str], float]] = []
def fake_request(endpoint: str, body: bytes, headers: dict[str, str], timeout: float) -> bytes:
payload = json.loads(body.decode("utf-8"))
calls.append((endpoint, payload, headers, timeout))
return json.dumps(
{
"status": "ok",
"solution": {
"userAgent": "Flare UA",
"cookies": [
{"name": "cf_clearance", "value": "host-token", "domain": ".chatgpt.com"},
{"name": "no_domain", "value": "kept"},
{"name": "wrong_host", "value": "dropped", "domain": "example.net"},
],
},
}
).encode("utf-8")
provider = FlareSolverrClearanceProvider("http://flare.local/", request_method=fake_request)
bundle = provider.get_clearance(
"https://chatgpt.com/backend-api/conversation",
proxy_url="socks5h://proxy.example:1080",
timeout_sec=12,
)
self.assertIsNotNone(bundle)
assert bundle is not None
endpoint, payload, headers, timeout = calls[0]
self.assertEqual(endpoint, "http://flare.local/v1")
self.assertEqual(payload["cmd"], "request.get")
self.assertEqual(payload["url"], "https://chatgpt.com/backend-api/conversation")
self.assertEqual(payload["maxTimeout"], 12000)
self.assertEqual(payload["proxy"], {"url": "socks5h://proxy.example:1080"})
self.assertEqual(headers["Content-Type"], "application/json")
self.assertEqual(timeout, 12)
self.assertEqual(bundle.user_agent, "Flare UA")
self.assertEqual(bundle.cookies, {"cf_clearance": "host-token", "no_domain": "kept"})
def test_flaresolverr_provider_keeps_only_matching_or_no_domain_cookies(self) -> None:
def fake_request(endpoint: str, body: bytes, headers: dict[str, str], timeout: float) -> bytes:
return json.dumps(
{
"status": "ok",
"solution": {
"userAgent": "Filtered UA",
"cookies": [
{"name": "wrong_host", "value": "dropped", "domain": "example.net"},
{"name": "also_wrong", "value": "dropped-too", "domain": ".example.org"},
{"name": "no_domain", "value": "kept"},
],
},
}
).encode("utf-8")
provider = FlareSolverrClearanceProvider("http://flare.local", request_method=fake_request)
bundle = provider.get_clearance("https://chatgpt.com", timeout_sec=5)
self.assertIsNotNone(bundle)
assert bundle is not None
self.assertEqual(bundle.cookies, {"no_domain": "kept"})
self.assertEqual(bundle.user_agent, "Filtered UA")
def test_flaresolverr_provider_drops_all_wrong_domain_cookies(self) -> None:
def fake_request(endpoint: str, body: bytes, headers: dict[str, str], timeout: float) -> bytes:
return json.dumps(
{
"status": "ok",
"solution": {
"cookies": [
{"name": "wrong_host", "value": "dropped", "domain": "example.net"},
],
},
}
).encode("utf-8")
provider = FlareSolverrClearanceProvider("http://flare.local", request_method=fake_request)
self.assertIsNone(provider.get_clearance("https://chatgpt.com", timeout_sec=5))
def test_cached_flaresolverr_bundle_is_merged_by_build_headers_and_can_be_invalidated(self) -> None:
runtime = make_runtime(
enabled=True,
egress_mode="single_proxy",
proxy_url="http://runtime.example:8080",
clearance={
"enabled": True,
"mode": "flaresolverr",
"flaresolverr_url": "http://flare.local",
"timeout_sec": 5,
},
)
bundle = ClearanceBundle(
target_host="chatgpt.com",
proxy_url="http://runtime.example:8080",
cookies={"cf_clearance": "flare-token"},
user_agent="Flare UA",
)
class FakeProvider:
def get_clearance(self, target_url: str, proxy_url: str = "", timeout_sec: int = 60) -> ClearanceBundle | None:
return bundle
store = ProxySettingsStore(FakeConfig(runtime=runtime), clearance_provider_factory=lambda _url: FakeProvider())
store.refresh_clearance(target_url="https://chatgpt.com", force=True)
headers = store.build_headers(headers={"Cookie": "existing=1"}, target_url="https://chatgpt.com/backend-api")
self.assertEqual(headers["User-Agent"], "Flare UA")
self.assertEqual(headers["Cookie"], "existing=1; cf_clearance=flare-token")
store.invalidate_clearance(target_url="https://chatgpt.com")
self.assertEqual(
store.build_headers(headers={"Cookie": "existing=1"}, target_url="https://chatgpt.com/backend-api"),
{"Cookie": "existing=1"},
)
def test_flaresolverr_refresh_failure_keeps_old_cached_bundle(self) -> None:
runtime = make_runtime(
enabled=True,
egress_mode="single_proxy",
proxy_url="http://runtime.example:8080",
clearance={
"enabled": True,
"mode": "flaresolverr",
"flaresolverr_url": "http://flare.local",
"timeout_sec": 5,
},
)
first_bundle = ClearanceBundle(
target_host="chatgpt.com",
proxy_url="http://runtime.example:8080",
cookies={"cf_clearance": "old-token"},
user_agent="Old UA",
)
class FakeProvider:
def __init__(self) -> None:
self.calls = 0
def get_clearance(self, target_url: str, proxy_url: str = "", timeout_sec: int = 60) -> ClearanceBundle | None:
self.calls += 1
return first_bundle if self.calls == 1 else None
provider = FakeProvider()
store = ProxySettingsStore(FakeConfig(runtime=runtime), clearance_provider_factory=lambda _url: provider)
refreshed = store.refresh_clearance(target_url="https://chatgpt.com", force=True)
fallback = store.refresh_clearance(target_url="https://chatgpt.com", force=True)
self.assertIsNotNone(refreshed)
assert refreshed is not None
self.assertEqual(refreshed.cookies, first_bundle.cookies)
self.assertIsNotNone(refreshed.expires_at)
assert refreshed.expires_at is not None
self.assertAlmostEqual(refreshed.expires_at - refreshed.created_at, 3600, places=2)
self.assertIs(fallback, refreshed)
self.assertEqual(provider.calls, 2)
def test_flaresolverr_bundle_expires_after_refresh_interval(self) -> None:
runtime = make_runtime(
enabled=True,
egress_mode="single_proxy",
proxy_url="http://runtime.example:8080",
clearance={
"enabled": True,
"mode": "flaresolverr",
"flaresolverr_url": "http://flare.local",
"timeout_sec": 5,
"refresh_interval": 60,
},
)
class FakeProvider:
def get_clearance(self, target_url: str, proxy_url: str = "", timeout_sec: int = 60) -> ClearanceBundle | None:
return ClearanceBundle(
target_host="chatgpt.com",
proxy_url=proxy_url,
cookies={"cf_clearance": "expiring-token"},
user_agent="Expiring UA",
created_at=1000,
)
store = ProxySettingsStore(FakeConfig(runtime=runtime), clearance_provider_factory=lambda _url: FakeProvider())
with patch("services.proxy_service.time.time", return_value=1000):
bundle = store.refresh_clearance(target_url="https://chatgpt.com", force=True)
self.assertIsNotNone(bundle)
assert bundle is not None
self.assertEqual(bundle.expires_at, 1060)
self.assertTrue(bundle.is_valid_for("chatgpt.com", "http://runtime.example:8080", now=1059))
self.assertFalse(bundle.is_valid_for("chatgpt.com", "http://runtime.example:8080", now=1060))
def test_profile_repr_does_not_expose_clearance_secrets(self) -> None:
runtime = make_runtime(
enabled=True,
clearance={
"enabled": True,
"mode": "manual",
"cf_cookies": "foo=secret-cookie",
"cf_clearance": "secret-clearance",
},
)
profile = ProxySettingsStore(FakeConfig(runtime=runtime)).get_profile()
text = repr(profile)
self.assertNotIn("secret-cookie", text)
self.assertNotIn("secret-clearance", text)
def test_proxy_test_error_redacts_proxy_credentials(self) -> None:
class FailingSession:
def __init__(self, **kwargs: object) -> None:
pass
def get(self, *args: object, **kwargs: object) -> object:
raise RuntimeError("proxy failed for http://user:pass@proxy.example:8080")
def close(self) -> None:
pass
with patch("services.proxy_service.Session", FailingSession):
result = __import__("services.proxy_service", fromlist=["test_proxy"]).test_proxy(
"http://user:pass@proxy.example:8080"
)
self.assertFalse(result["ok"])
self.assertIn("[REDACTED]", result["error"])
self.assertNotIn("user:pass", result["error"])
def test_concurrent_flaresolverr_refresh_uses_single_flight_per_proxy_and_host(self) -> None:
runtime = make_runtime(
enabled=True,
egress_mode="single_proxy",
proxy_url="http://runtime.example:8080",
clearance={
"enabled": True,
"mode": "flaresolverr",
"flaresolverr_url": "http://flare.local",
"timeout_sec": 5,
},
)
class SlowProvider:
def __init__(self) -> None:
self.calls = 0
self.lock = threading.Lock()
def get_clearance(self, target_url: str, proxy_url: str = "", timeout_sec: int = 60) -> ClearanceBundle | None:
with self.lock:
self.calls += 1
time.sleep(0.15)
return ClearanceBundle(
target_host=urlparse(target_url).hostname or "",
proxy_url=proxy_url,
cookies={"cf_clearance": "thread-token"},
user_agent="Thread UA",
)
provider = SlowProvider()
store = ProxySettingsStore(FakeConfig(runtime=runtime), clearance_provider_factory=lambda _url: provider)
workers = 6
start = threading.Barrier(workers)
def refresh() -> ClearanceBundle | None:
start.wait(timeout=5)
return store.refresh_clearance(target_url="https://chatgpt.com", force=True)
with ThreadPoolExecutor(max_workers=workers) as executor:
results = list(executor.map(lambda _index: refresh(), range(workers)))
self.assertEqual(provider.calls, 1)
self.assertTrue(all(result is results[0] for result in results))
self.assertIsNotNone(results[0])
if __name__ == "__main__":
unittest.main()
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
from services.account_service import AccountService
from services.model_service import ModelRoute, ModelUnavailableError
from services.protocol import (
anthropic_v1_messages,
conversation,
openai_v1_chat_complete,
openai_v1_response,
)
from services.storage.json_storage import JSONStorageBackend
class TextAccountRoutingTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.service = AccountService(
JSONStorageBackend(Path(self.temp_dir.name) / "accounts.json")
)
self.service.add_account_items(
[
{"access_token": "free", "type": "free", "status": "正常"},
{"access_token": "plus", "type": "Plus", "status": "正常"},
{"access_token": "pro", "type": "Pro", "status": "正常"},
{"access_token": "pro-disabled", "type": "Pro", "status": "禁用"},
]
)
self.service.refresh_access_token = lambda token, **_kwargs: token
def test_explicit_model_selects_only_advertising_account_type(self) -> None:
route = ModelRoute(account_types=frozenset({"Pro"}), allow_anonymous=False)
with mock.patch(
"services.model_service.model_catalog_service.route_for_model",
return_value=route,
):
token = self.service.get_text_access_token(model="pro-only")
self.assertEqual(token, "pro")
def test_auto_model_keeps_existing_unfiltered_rotation(self) -> None:
with mock.patch(
"services.model_service.model_catalog_service.route_for_model",
side_effect=AssertionError("auto must not load the model catalog"),
):
tokens = {
self.service.get_text_access_token(model="auto"),
self.service.get_text_access_token(model="auto"),
self.service.get_text_access_token(model="auto"),
}
self.assertEqual(tokens, {"free", "plus", "pro"})
def test_anonymous_model_uses_anonymous_backend(self) -> None:
route = ModelRoute(account_types=frozenset(), allow_anonymous=True)
with mock.patch(
"services.model_service.model_catalog_service.route_for_model",
return_value=route,
):
token = self.service.get_text_access_token(model="anon-only")
self.assertEqual(token, "")
def test_model_without_eligible_account_fails_closed(self) -> None:
route = ModelRoute(account_types=frozenset({"Team"}), allow_anonymous=False)
with mock.patch(
"services.model_service.model_catalog_service.route_for_model",
return_value=route,
), self.assertRaisesRegex(ModelUnavailableError, "team-only"):
self.service.get_text_access_token(model="team-only")
class TextProtocolRoutingTests(unittest.TestCase):
def test_text_backend_passes_requested_model_to_account_selector(self) -> None:
backend = mock.Mock()
with (
mock.patch.object(
conversation.account_service,
"get_text_access_token",
return_value="pro",
) as selector,
mock.patch.object(conversation, "OpenAIBackendAPI", return_value=backend),
):
result = conversation.text_backend("pro-only")
self.assertIs(result, backend)
selector.assert_called_once_with(model="pro-only")
def test_chat_completions_passes_requested_model_to_text_backend(self) -> None:
body = {
"model": "pro-chat",
"messages": [{"role": "user", "content": "route chat"}],
}
with (
mock.patch.object(openai_v1_chat_complete, "text_backend", return_value=object()) as backend,
mock.patch.object(openai_v1_chat_complete, "collect_text", return_value="ok"),
):
openai_v1_chat_complete.handle(body)
backend.assert_called_once_with("pro-chat")
def test_responses_passes_requested_model_to_text_backend(self) -> None:
body = {"model": "pro-response", "input": "route response"}
with (
mock.patch.object(openai_v1_response, "text_backend", return_value=object()) as backend,
mock.patch.object(openai_v1_response, "stream_text_deltas", return_value=iter(["ok"])),
):
openai_v1_response.handle(body)
backend.assert_called_once_with("pro-response")
def test_anthropic_messages_passes_requested_model_to_account_selector(self) -> None:
with (
mock.patch.object(
anthropic_v1_messages.account_service,
"get_text_access_token",
return_value="pro",
) as selector,
mock.patch.object(anthropic_v1_messages, "OpenAIBackendAPI"),
):
request = anthropic_v1_messages.message_request({
"model": "pro-anthropic",
"messages": [{"role": "user", "content": "route anthropic"}],
})
self.assertEqual(request.model, "pro-anthropic")
selector.assert_called_once_with(model="pro-anthropic")
def test_invalid_token_retry_keeps_requested_model_filter(self) -> None:
initial_backend = SimpleNamespace(access_token="bad")
request = conversation.ConversationRequest(
model="pro-only",
messages=[{"role": "user", "content": "hello"}],
)
def fake_events(backend, **_kwargs):
if backend.access_token == "bad":
raise RuntimeError("token_invalidated")
yield {"type": "conversation.delta", "delta": "ok"}
with (
mock.patch.object(conversation, "OpenAIBackendAPI", side_effect=lambda access_token: SimpleNamespace(
access_token=access_token,
close=lambda: None,
)),
mock.patch.object(conversation, "conversation_events", side_effect=fake_events),
mock.patch.object(
conversation.account_service,
"refresh_access_token",
return_value="bad",
),
mock.patch.object(conversation.account_service, "remove_invalid_token"),
mock.patch.object(
conversation.account_service,
"get_text_access_token",
return_value="pro",
) as selector,
mock.patch.object(conversation.account_service, "mark_text_used"),
):
result = list(conversation.stream_text_deltas(initial_backend, request))
self.assertEqual(result, ["ok"])
selector.assert_called_once_with(
excluded_tokens={"bad"},
model="pro-only",
)
if __name__ == "__main__":
unittest.main()
+274
View File
@@ -0,0 +1,274 @@
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
os.environ.setdefault("CHATGPT2API_AUTH_KEY", "test-auth")
from services.account_service import AccountService
from services.config import config
from services.openai_backend_api import OpenAIBackendAPI
from services.protocol import conversation
from services.storage.json_storage import JSONStorageBackend
from services.upload_limits import UploadLimit, UploadUnavailableError, parse_upload_limit
from utils.helper import UpstreamHTTPError, ensure_ok
def limited(message="你已达到文件上传上限。请20小时 内重试。", **kwargs):
return UpstreamHTTPError("/backend-api/files", 429, {
"detail": {"code": "throttled", "message": message},
}, **kwargs)
class UploadLimitParsingTests(unittest.TestCase):
def test_chinese_and_english_reset_times(self):
now = datetime(2026, 9, 17, tzinfo=timezone.utc)
for message, seconds in [
("你已达到文件上传上限。请20小时 内重试。", 72000),
("Upload limit reached. Try again in 1 hour 30 minutes.", 5400),
("请30分钟后重试", 1800),
("Try again later", 900),
]:
with self.subTest(message=message):
self.assertEqual(parse_upload_limit(limited(message), now).blocked_until, now + timedelta(seconds=seconds))
def test_structured_reset_and_retry_after_take_precedence(self):
now = datetime(2026, 9, 17, tzinfo=timezone.utc)
error = limited(retry_after=120)
self.assertEqual(parse_upload_limit(error, now).blocked_until, now + timedelta(seconds=120))
error.body["detail"]["reset_at"] = "2026-09-18T00:00:00Z"
self.assertEqual(parse_upload_limit(error, now).blocked_until, now + timedelta(days=1))
def test_other_failures_are_not_upload_quota(self):
for error in [
UpstreamHTTPError("/backend-api/conversation", 429, {"detail": {"code": "throttled"}}),
UpstreamHTTPError("/backend-api/files", 403, {"detail": {"code": "throttled"}}),
UpstreamHTTPError("/backend-api/files", 429, {"detail": {"code": "server_busy"}}),
RuntimeError("/backend-api/files status=429 throttled"),
]:
self.assertIsNone(parse_upload_limit(error))
def test_json_string_body(self):
import json
error = limited()
error.body = json.dumps(error.body)
self.assertIsNotNone(parse_upload_limit(error))
def test_retry_after_http_date(self):
from email.utils import format_datetime
future = datetime.now(timezone.utc) + timedelta(hours=1)
response = SimpleNamespace(status_code=429, text="", json=lambda: {},
headers={"Retry-After": format_datetime(future, usegmt=True)})
with self.assertRaises(UpstreamHTTPError) as caught:
ensure_ok(response, "/backend-api/files")
self.assertTrue(3590 <= caught.exception.retry_after <= 3600)
class UploadPoolTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
for target in ("services.account_service.AccountService._save_cumulative_total",
"services.account_service.log_service.add"):
patcher = patch(target)
patcher.start()
self.addCleanup(patcher.stop)
self.storage = JSONStorageBackend(Path(self.temp.name) / "accounts.json")
self.service = AccountService(self.storage)
self.service.add_account_items([
{"access_token": token, "status": "正常", "quota": 3, "type": "free"}
for token in ("first", "second")
])
self.service.fetch_remote_info = lambda token, event: self.service.get_account(token)
self.future = datetime.now(timezone.utc) + timedelta(hours=20)
def block(self, token="first", until=None):
self.service.mark_upload_limited(token, UploadLimit(until or self.future, "文件上传额度已用尽"))
def test_cooling_survives_restart_and_refresh_without_changing_image_quota(self):
self.block()
self.service.update_account("first", {"status": "正常", "quota": 4}, quiet=True)
restored = AccountService(self.storage)
account = restored.get_account("first")
self.assertEqual(account["upload_blocked_until"], self.future.isoformat())
self.assertEqual(account["quota"], 4)
self.assertEqual(account["status"], "正常")
self.assertNotIn("first", restored._list_ready_candidate_tokens(requires_upload=True))
self.assertIn("first", restored._list_ready_candidate_tokens())
def test_expired_limit_allows_upload_and_success_clears_it(self):
self.block(until=datetime.now(timezone.utc) - timedelta(seconds=1))
token = self.service.get_available_access_token(requires_upload=True)
self.assertEqual(token, "first")
self.service.clear_expired_upload_limit(token)
self.service.release_image_slot(token)
self.assertNotIn("upload_blocked_until", self.service.get_account(token))
def test_success_cannot_clear_newer_concurrent_limit(self):
self.block()
self.service.clear_expired_upload_limit("first")
self.assertEqual(self.service.get_account("first")["upload_blocked_until"], self.future.isoformat())
def test_all_blocked_raises_without_remote_requests_or_slot_leaks(self):
self.block("first")
self.block("second")
with patch.object(self.service, "fetch_remote_info") as fetch:
with self.assertRaises(UploadUnavailableError) as caught:
self.service.get_available_access_token(requires_upload=True)
fetch.assert_not_called()
self.assertIn("最早预计恢复时间", str(caught.exception))
self.assertEqual(self.service._image_inflight, {})
def test_selection_excludes_rotated_token_alias(self):
self.service._token_aliases["old-first"] = "first"
token = self.service.get_available_access_token(requires_upload=True, excluded_tokens={"old-first"})
self.assertEqual(token, "second")
self.service.release_image_slot(token)
def test_limit_discovered_during_refresh_is_not_lost_to_stale_snapshot(self):
def refresh(token, event):
snapshot = self.service.get_account(token)
if token == "first":
self.block("first")
return snapshot
self.service.fetch_remote_info = refresh
token = self.service.get_available_access_token(requires_upload=True)
self.assertEqual(token, "second")
self.service.release_image_slot(token)
self.assertEqual(self.service._image_inflight, {})
def test_api_returns_upload_specific_429(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.ai import create_router
self.block("first")
self.block("second")
app = FastAPI()
app.include_router(create_router())
with patch.object(conversation, "account_service", self.service):
with TestClient(app) as client:
response = client.post(
"/v1/images/edits",
headers={"Authorization": f"Bearer {config.auth_key}"},
data={"model": "gpt-image-2", "prompt": "edit"},
files={"image": ("reference.png", b"reference", "image/png")},
)
self.assertEqual(response.status_code, 429, response.text)
self.assertEqual(response.json()["error"]["code"], "upload_limit_exceeded")
self.assertIn("预计恢复", response.json()["error"]["message"])
def test_busy_pool_obeys_deadline(self):
import time
with self.assertRaisesRegex(RuntimeError, "超时"):
self.service.get_available_access_token(requires_upload=True, deadline=time.monotonic() - 1)
self.assertEqual(self.service._image_inflight, {})
def run_request(self, stream, images=None, n=1):
request = conversation.ConversationRequest(model="gpt-image-2", prompt="edit", images=images, n=n,
progress_callback=MagicMock())
def backend(token=None, **kwargs):
return SimpleNamespace(access_token=kwargs["access_token"], close=MagicMock())
with (
patch.object(conversation, "account_service", self.service),
patch.object(conversation, "OpenAIBackendAPI", side_effect=backend),
patch.object(conversation, "stream_image_outputs", side_effect=stream),
):
outputs = list(conversation.stream_image_outputs_with_pool(request))
return outputs, request
def test_first_account_throttled_second_succeeds_and_inputs_preserved(self):
attempted = []
def stream(backend, request, index, total):
attempted.append(backend.access_token)
self.assertEqual(request.images, ["reference"])
self.assertEqual(request.prompt, "edit")
if backend.access_token == "first":
raise limited()
return iter([conversation.ImageOutput(kind="result", model=request.model, index=index, total=total)])
result, request = self.run_request(stream, ["reference"])
self.assertEqual(attempted, ["first", "second"])
self.assertEqual(len(result), 1)
self.assertEqual(self.service.get_account("first")["quota"], 3)
self.assertEqual(self.service.get_account("second")["quota"], 2)
self.assertIn("upload_blocked_until", self.service.get_account("first"))
self.assertEqual(self.service._image_inflight, {})
request.progress_callback.assert_any_call("switching_upload_account")
def test_all_accounts_fail_once_and_return_friendly_429(self):
calls = []
def stream(backend, *args):
calls.append(backend.access_token)
raise limited()
with self.assertRaises(conversation.ImageGenerationError) as caught:
self.run_request(stream, ["reference"])
self.assertEqual(set(calls), {"first", "second"})
self.assertEqual(len(calls), 2)
self.assertEqual(caught.exception.status_code, 429)
self.assertEqual(caught.exception.code, "upload_limit_exceeded")
self.assertNotIn("backend-api", str(caught.exception))
self.assertEqual(self.service._image_inflight, {})
def test_retry_cap_leaves_untried_accounts_for_later(self):
self.service.add_account_items([
{"access_token": f"extra-{i}", "status": "正常", "quota": 3} for i in range(5)
])
calls = []
def stream(backend, *args):
calls.append(backend.access_token)
raise limited()
with self.assertRaises(conversation.ImageGenerationError) as caught:
self.run_request(stream, ["reference"])
self.assertEqual(len(set(calls)), 5)
self.assertIn("自动切换已达到上限", str(caught.exception))
self.assertEqual(self.service._image_inflight, {})
def test_text_only_generation_still_uses_upload_limited_accounts(self):
self.block("first")
self.block("second")
def stream(backend, request, index, total):
return iter([conversation.ImageOutput(kind="result", model=request.model, index=index, total=total)])
result, _ = self.run_request(stream)
self.assertEqual(len(result), 1)
self.assertEqual(self.service._image_inflight, {})
def test_unrelated_429_does_not_switch_accounts(self):
calls = []
def stream(backend, *args):
calls.append(backend.access_token)
raise UpstreamHTTPError("/backend-api/conversation", 429, {"detail": {"code": "throttled"}})
with self.assertRaises(conversation.ImageGenerationError):
self.run_request(stream, ["reference"])
self.assertEqual(len(calls), 1)
self.assertNotIn("upload_blocked_until", self.service.get_account(calls[0]))
self.assertEqual(self.service._image_inflight, {})
def test_parallel_all_blocked_preserves_rate_limit_error(self):
self.block("first")
self.block("second")
with patch.dict(config.data, {"image_parallel_generation": True}):
with self.assertRaises(conversation.ImageGenerationError) as caught:
self.run_request(MagicMock(), ["reference"], n=2)
self.assertEqual(caught.exception.status_code, 429)
self.assertEqual(self.service._image_inflight, {})
def test_successful_reference_upload_clears_only_after_all_files_uploaded(self):
self.block(until=datetime.now(timezone.utc) - timedelta(seconds=1))
backend = object.__new__(OpenAIBackendAPI)
backend.access_token = "first"
backend._report_progress = MagicMock()
backend._upload_image = MagicMock(return_value={"file_id": "test"})
backend._bootstrap = MagicMock(side_effect=RuntimeError("stop after upload"))
with patch("services.openai_backend_api.account_service", self.service):
with self.assertRaisesRegex(RuntimeError, "stop after upload"):
list(backend._stream_picture_conversation("edit", "gpt-image-2", ["ref-1", "ref-2"]))
self.assertEqual(backend._upload_image.call_count, 2)
self.assertNotIn("upload_blocked_until", self.service.get_account("first"))
if __name__ == "__main__":
unittest.main()
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import json
import time
import unittest
import requests
from utils.helper import save_images_from_text
AUTH_KEY = "chatgpt2api"
BASE_URL = "http://localhost:8000"
class ChatCompletionsTests(unittest.TestCase):
def test_text_completion_http(self):
"""测试文本对话的非流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/chat/completions",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": "auto",
"messages": [
{"role": "user", "content": "你好。"},
{"role": "assistant", "content": "你好,我可以帮助你处理文本和图片相关请求。"},
{"role": "user", "content": "那你再简单介绍一下你自己。"},
],
},
timeout=300,
)
print("text non-stream status:")
print(response.status_code)
print("text non-stream result:")
print(json.dumps(response.json(), ensure_ascii=False, indent=2))
def test_text_completion_stream_http(self):
"""测试文本对话的流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/chat/completions",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": "auto",
"stream": True,
"messages": [
{"role": "user", "content": "你好。"},
{"role": "assistant", "content": "你好,我的名字是Claude。"},
{"role": "user", "content": "那你再简单介绍一下你自己,比如你的名字是什么。"},
],
},
stream=True,
timeout=300,
)
print("text stream status:")
print(response.status_code)
print("text stream result:")
for line in response.iter_lines():
if line:
print(line.decode("utf-8", errors="replace"))
def test_image_completion_http(self):
"""测试图片对话的非流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/chat/completions",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": "gpt-image-2",
"messages": [
{"role": "user", "content": "我想做一张南京城市宣传海报图。"},
],
"n": 1,
},
timeout=300,
)
payload = response.json()
content = str((((payload.get("choices") or [{}])[0].get("message") or {}).get("content") or ""))
saved_paths = save_images_from_text(content, "chat_completions_image_non_stream")
print("image non-stream status:")
print(response.status_code)
print("image non-stream saved files:")
for path in saved_paths:
print(path)
def test_image_completion_stream_http(self):
"""测试图片对话的流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/chat/completions",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": "gpt-image-2",
"stream": True,
"messages": [
{"role": "user", "content": "我想做一张南京城市宣传海报图。"},
],
"n": 1,
},
stream=True,
timeout=300,
)
parts: list[str] = []
started_at = time.time()
print("image stream status:")
print(response.status_code)
print("image stream chunks:")
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8", errors="replace")
print(f"{time.time() - started_at:6.2f}s {text}")
if not text.startswith("data:"):
continue
payload = text[5:].strip()
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except Exception:
continue
delta = ((chunk.get("choices") or [{}])[0].get("delta") or {})
content = str(delta.get("content") or "")
if content:
parts.append(content)
saved_paths = save_images_from_text("".join(parts), "chat_completions_image_stream")
print("image stream saved files:")
for path in saved_paths:
print(path)
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
import json
import time
import unittest
from pathlib import Path
import requests
from test.utils import save_image
from utils.log import logger
AUTH_KEY = "chatgpt2api"
BASE_URL = "http://localhost:8000"
ASSETS_DIR = Path(__file__).resolve().parents[1] / "assets"
def load_asset_bytes(name: str) -> bytes:
return (ASSETS_DIR / name).read_bytes()
def summarize_chunk(chunk: dict[str, object]) -> dict[str, object]:
data = chunk.get("data")
data_items = data if isinstance(data, list) else []
return {
"object": chunk.get("object"),
"index": chunk.get("index"),
"total": chunk.get("total"),
"created": chunk.get("created"),
"finish_reason": chunk.get("finish_reason"),
"progress_text": chunk.get("progress_text"),
"upstream_event_type": chunk.get("upstream_event_type"),
"data_count": len(data_items),
"has_b64_json": any(isinstance(item, dict) and bool(item.get("b64_json")) for item in data_items),
}
class ImageEditsTests(unittest.TestCase):
def test_image_edit_http(self):
"""测试图片编辑的非流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/images/edits",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
data={
"model": "gpt-image-2",
"prompt": "参考输入图片,保持人物主体和二次元插画风格不变,让女孩怀里抱着一只可爱的小猫,画面自然协调。",
"n": "1",
"response_format": "b64_json",
},
files={"image": ("chery_studio.png", load_asset_bytes("chery_studio.png"), "image/png")},
timeout=300,
)
self.assertEqual(response.status_code, 200, response.text)
payload = response.json()
saved_paths = []
for index, item in enumerate(payload.get("data") or [], start=1):
b64_json = str((item or {}).get("b64_json") or "")
if b64_json:
saved_paths.append(save_image(b64_json, f"images_edits_non_stream_{index}"))
self.assertGreater(len(saved_paths), 0, "非流式接口未输出图片。")
logger.info({
"event": "test_images_edits_non_stream_done",
"status_code": response.status_code,
"created": payload.get("created"),
"saved_paths": [str(path) for path in saved_paths],
"image_count": len(saved_paths),
})
def test_image_edit_stream_http(self):
"""测试图片编辑的流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/images/edits",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
data={
"model": "gpt-image-2",
"prompt": "请提取两张输入界面截图中的 6 个任务,并把这 6 个任务整合排版到同一张图里,做成一张清晰的中文任务总览海报,标题明确,六个任务分区展示,版面整洁。",
"n": "1",
"response_format": "b64_json",
"stream": "true",
},
files=[
("image", ("image.png", load_asset_bytes("image.png"), "image/png")),
("image", ("image_edit.png", load_asset_bytes("image_edit.png"), "image/png")),
],
stream=True,
timeout=300,
)
image_items: list[dict[str, object]] = []
stream_errors: list[dict[str, object]] = []
started_at = time.time()
self.assertEqual(response.status_code, 200, response.text)
self.assertTrue(
response.headers.get("content-type", "").startswith("text/event-stream"),
response.headers.get("content-type", ""),
)
logger.info({
"event": "test_images_edits_stream_start",
"status_code": response.status_code,
"content_type": response.headers.get("content-type"),
})
try:
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8", errors="replace")
if not text.startswith("data:"):
continue
payload = text[5:].strip()
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except Exception:
continue
elapsed = time.time() - started_at
if isinstance(chunk.get("error"), dict):
stream_errors.append(chunk["error"])
logger.info({
"event": "test_images_edits_stream_chunk",
"elapsed_seconds": round(elapsed, 2),
"chunk": summarize_chunk(chunk),
})
data = chunk.get("data")
if isinstance(data, list):
image_items.extend(item for item in data if isinstance(item, dict))
finally:
response.close()
saved_paths = []
for index, item in enumerate(image_items, start=1):
b64_json = str(item.get("b64_json") or "")
if b64_json:
saved_paths.append(save_image(b64_json, f"images_edits_stream_{index}"))
self.assertFalse(stream_errors, f"流式接口返回错误: {stream_errors}")
self.assertGreater(len(saved_paths), 0, "流式接口未输出图片。")
logger.info({
"event": "test_images_edits_stream_done",
"saved_paths": [str(path) for path in saved_paths],
"image_count": len(saved_paths),
})
if __name__ == "__main__":
unittest.main()
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import base64
import unittest
from unittest import mock
from fastapi import FastAPI
from fastapi.testclient import TestClient
import api.ai as ai_module
AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"}
PNG_BYTES = b"\x89PNG\r\n\x1a\n"
DATA_IMAGE_URL = f"data:image/png;base64,{base64.b64encode(PNG_BYTES).decode('ascii')}"
class ImagesEditsApiTests(unittest.TestCase):
def setUp(self):
self.handle_calls = []
def fake_handle(payload):
self.handle_calls.append(payload)
return {"created": 1, "data": [{"b64_json": base64.b64encode(b"out").decode("ascii")}]}
self.handler_patcher = mock.patch.object(ai_module.openai_v1_image_edit, "handle", fake_handle)
self.handler_patcher.start()
self.addCleanup(self.handler_patcher.stop)
app = FastAPI()
app.include_router(ai_module.create_router())
self.client = TestClient(app)
def test_edit_accepts_json_image_url(self):
"""测试图片编辑接口支持官方 JSON image_url 引用。"""
response = self.client.post(
"/v1/images/edits",
headers=AUTH_HEADERS,
json={
"model": "gpt-image-2",
"prompt": "edit",
"images": [{"image_url": DATA_IMAGE_URL}],
"n": 1,
"response_format": "b64_json",
},
)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(len(self.handle_calls), 1)
payload = self.handle_calls[0]
self.assertEqual(payload["prompt"], "edit")
self.assertEqual(payload["n"], 1)
self.assertEqual(payload["images"], [(PNG_BYTES, "image_url.png", "image/png")])
def test_edit_rejects_file_id_reference(self):
"""测试图片编辑接口对暂不支持的 file_id 返回明确错误。"""
response = self.client.post(
"/v1/images/edits",
headers=AUTH_HEADERS,
json={
"model": "gpt-image-2",
"prompt": "edit",
"images": [{"file_id": "file-abc123"}],
},
)
self.assertEqual(response.status_code, 400, response.text)
self.assertIn("file_id image references are not supported", response.text)
self.assertEqual(self.handle_calls, [])
if __name__ == "__main__":
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
import base64
import os
import unittest
from unittest import mock
os.environ.setdefault("CHATGPT2API_AUTH_KEY", "chatgpt2api")
from fastapi import FastAPI
from fastapi.testclient import TestClient
import api.ai as ai_module
AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"}
PNG_DATA_URL = "data:image/png;base64," + base64.b64encode(b"fake-png").decode("ascii")
JPEG_DATA_URL = "data:image/jpeg;base64," + base64.b64encode(b"fake-jpeg").decode("ascii")
class ImageEditsJsonApiTests(unittest.TestCase):
def setUp(self):
self.calls = []
def fake_handle(payload):
self.calls.append(payload)
return {"created": 1, "data": [{"b64_json": "ZmFrZQ=="}]}
self.handle_patcher = mock.patch.object(ai_module.openai_v1_image_edit, "handle", fake_handle)
self.filter_patcher = mock.patch.object(ai_module, "filter_or_log", mock.AsyncMock())
self.handle_patcher.start()
self.filter_patcher.start()
self.addCleanup(self.handle_patcher.stop)
self.addCleanup(self.filter_patcher.stop)
app = FastAPI()
app.include_router(ai_module.create_router())
self.client = TestClient(app)
def test_json_model_omitted_uses_existing_default_logic(self):
response = self.client.post("/v1/images/edits", headers=AUTH_HEADERS, json={"prompt": "未传 model", "image": PNG_DATA_URL})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(self.calls[0]["model"], "gpt-image-2")
def test_json_model_is_not_overwritten_when_provided(self):
response = self.client.post(
"/v1/images/edits",
headers=AUTH_HEADERS,
json={"model": "codex-gpt-image-2", "prompt": "保留 model", "image": PNG_DATA_URL},
)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(self.calls[0]["model"], "codex-gpt-image-2")
def test_image_edit_accepts_json_image_url(self):
response = self.client.post(
"/v1/images/edits",
headers=AUTH_HEADERS,
json={
"model": "gpt-image-2",
"prompt": "把图片改成夜景风格",
"n": 1,
"size": "1024x1536",
"response_format": "b64_json",
"images": [{"image_url": PNG_DATA_URL}],
},
)
self.assertEqual(response.status_code, 200, response.text)
payload = self.calls[0]
self.assertEqual(payload["images"], [(b"fake-png", "image_1.png", "image/png")])
self.assertEqual(payload["size"], "1024x1536")
def test_image_edit_accepts_json_multiple_images_and_b64_json(self):
response = self.client.post(
"/v1/images/edits",
headers=AUTH_HEADERS,
json={
"prompt": "把两张图合成海报",
"images": [
PNG_DATA_URL,
{"b64_json": base64.b64encode(b"raw-jpeg").decode("ascii"), "mime_type": "image/jpeg", "filename": "two.jpg"},
{"image_url": {"url": JPEG_DATA_URL}},
],
},
)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(self.calls[0]["images"], [
(b"fake-png", "image_1.png", "image/png"),
(b"raw-jpeg", "two.jpg", "image/jpeg"),
(b"fake-jpeg", "image_3.jpg", "image/jpeg"),
])
def test_image_edit_keeps_original_multipart_multiple_image_logic(self):
response = self.client.post(
"/v1/images/edits",
headers=AUTH_HEADERS,
data={"prompt": "multipart 多图仍然可用", "model": "gpt-image-2", "n": "1"},
files=[
("image", ("one.png", b"one", "image/png")),
("image", ("two.jpg", b"two", "image/jpeg")),
("image[]", ("three.webp", b"three", "image/webp")),
],
)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(self.calls[0]["images"], [
(b"one", "one.png", "image/png"),
(b"two", "two.jpg", "image/jpeg"),
(b"three", "three.webp", "image/webp"),
])
def test_image_edit_rejects_json_without_image(self):
response = self.client.post("/v1/images/edits", headers=AUTH_HEADERS, json={"prompt": "缺少图片"})
self.assertEqual(response.status_code, 400, response.text)
self.assertIn("image file is required", response.text)
def test_image_edit_rejects_remote_json_url(self):
response = self.client.post(
"/v1/images/edits",
headers=AUTH_HEADERS,
json={"prompt": "不允许远程拉图", "images": [{"image_url": "https://example.com/a.png"}]},
)
self.assertEqual(response.status_code, 400, response.text)
self.assertIn("remote image URLs are not supported", response.text)
def test_image_edit_rejects_json_n_out_of_range(self):
response = self.client.post("/v1/images/edits", headers=AUTH_HEADERS, json={"prompt": "n 越界", "n": 5, "image": PNG_DATA_URL})
self.assertEqual(response.status_code, 400, response.text)
self.assertFalse(self.calls)
if __name__ == "__main__":
unittest.main()
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import json
import time
import unittest
import requests
from test.utils import save_image
AUTH_KEY = "chatgpt2api"
BASE_URL = "http://localhost:8000"
class ImageGenerationsTests(unittest.TestCase):
def test_image_generation_http(self):
"""测试图片生成的非流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/images/generations",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": "gpt-image-2",
"prompt": "我想做一张南京城市宣传海报图。",
"n": 1,
"response_format": "b64_json",
},
timeout=300,
)
payload = response.json()
saved_paths = []
for index, item in enumerate(payload.get("data") or [], start=1):
b64_json = str((item or {}).get("b64_json") or "")
if b64_json:
saved_paths.append(save_image(b64_json, f"images_generations_non_stream_{index}"))
print("images generations non-stream status:")
print(response.status_code)
print("images generations non-stream created:")
print(payload.get("created"))
print("images generations non-stream saved files:")
for path in saved_paths:
print(path)
def test_image_generation_stream_http(self):
"""测试图片生成的流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/images/generations",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": "gpt-image-2",
"prompt": "我想做一张南京城市宣传海报图。",
"n": 1,
"response_format": "b64_json",
"stream": True,
},
stream=True,
timeout=300,
)
image_items: list[dict[str, object]] = []
started_at = time.time()
print("images generations stream status:")
print(response.status_code)
print("images generations stream chunks:")
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8", errors="replace")
print(f"{time.time() - started_at:6.2f}s {text}")
if not text.startswith("data:"):
continue
payload = text[5:].strip()
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except Exception:
continue
data = chunk.get("data")
if isinstance(data, list):
image_items.extend(item for item in data if isinstance(item, dict))
saved_paths = []
for index, item in enumerate(image_items, start=1):
b64_json = str(item.get("b64_json") or "")
if b64_json:
saved_paths.append(save_image(b64_json, f"images_generations_stream_{index}"))
print("images generations stream saved files:")
for path in saved_paths:
print(path)
if __name__ == "__main__":
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import json
import time
import unittest
import requests
AUTH_KEY = "chatgpt2api"
BASE_URL = "http://localhost:8000"
MODEL = "auto"
class AnthropicMessagesTests(unittest.TestCase):
@staticmethod
def _headers() -> dict[str, str]:
return {
"x-api-key": AUTH_KEY,
"anthropic-version": "2023-06-01",
}
def test_message_http(self):
"""测试 Anthropic Messages 的非流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/messages",
headers=self._headers(),
json={
"model": MODEL,
"messages": [
{"role": "user", "content": "你好,请简单介绍一下你自己。"},
],
},
timeout=300,
)
print("messages non-stream status:")
print(response.status_code)
print("messages non-stream result:")
try:
print(json.dumps(response.json(), ensure_ascii=False, indent=2))
except Exception:
print(response.text)
def test_message_stream_http(self):
"""测试 Anthropic Messages 的流式 HTTP 调用。"""
started_at = time.time()
response = requests.post(
f"{BASE_URL}/v1/messages",
headers=self._headers(),
json={
"model": MODEL,
"stream": True,
"messages": [
{"role": "user", "content": "你好,请简单介绍一下你自己。"},
],
},
stream=True,
timeout=300,
)
headers_at = time.time()
print("messages stream status:")
print(response.status_code)
print("messages stream content-type:")
print(response.headers.get("content-type", ""))
print("messages stream response headers:")
print(f"{headers_at - started_at:6.2f}s")
if response.status_code != 200:
print(response.text)
return
print("messages stream chunks:")
for line in response.iter_lines(chunk_size=1):
if not line:
continue
text = line.decode("utf-8", errors="replace")
print(f"{time.time() - started_at:6.2f}s {text}")
if not text.startswith("data:"):
continue
try:
payload = json.loads(text[5:].strip())
except Exception:
continue
if payload.get("type") == "message_stop":
break
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import json
import unittest
from unittest import mock
import requests
from services.protocol import openai_v1_models
AUTH_KEY = "chatgpt2api"
BASE_URL = "http://localhost:8000"
class ModelListTests(unittest.TestCase):
def test_list_models_only_returns_image_models_backed_by_account_types(self):
with (
mock.patch.object(
openai_v1_models.model_catalog_service,
"list_models",
return_value={"object": "list", "data": []},
),
mock.patch.object(
openai_v1_models.account_service,
"list_accounts",
return_value=[
{"access_token": "token-free", "type": "free"},
{"access_token": "token-web-team", "type": "Team", "source_type": "web"},
{"access_token": "token-codex-team", "type": "Team", "source_type": "codex"},
],
),
):
result = openai_v1_models.list_models()
ids = {item["id"] for item in result["data"]}
self.assertIn("gpt-image-2", ids)
self.assertIn("codex-gpt-image-2", ids)
self.assertIn("team-codex-gpt-image-2", ids)
self.assertNotIn("plus-codex-gpt-image-2", ids)
self.assertNotIn("pro-codex-gpt-image-2", ids)
def test_list_models_does_not_return_codex_models_for_web_plus_accounts(self):
with (
mock.patch.object(
openai_v1_models.model_catalog_service,
"list_models",
return_value={"object": "list", "data": []},
),
mock.patch.object(
openai_v1_models.account_service,
"list_accounts",
return_value=[
{"access_token": "token-web-plus", "type": "Plus", "source_type": "web"},
],
),
):
result = openai_v1_models.list_models()
ids = {item["id"] for item in result["data"]}
self.assertIn("gpt-image-2", ids)
self.assertNotIn("codex-gpt-image-2", ids)
self.assertNotIn("plus-codex-gpt-image-2", ids)
def test_list_models_function(self):
"""测试直接调用服务层获取模型列表。"""
result = openai_v1_models.list_models()
print("function result:")
print(json.dumps(result, ensure_ascii=False, indent=2))
def test_list_models_http(self):
"""测试通过 HTTP 接口获取模型列表。"""
response = requests.get(
f"{BASE_URL}/v1/models",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
timeout=30,
)
print("http status:")
print(response.status_code)
print("http result:")
print(json.dumps(response.json(), ensure_ascii=False, indent=2))
+294
View File
@@ -0,0 +1,294 @@
from __future__ import annotations
import json
import time
import unittest
import requests
from test.utils import save_image
AUTH_KEY = "chatgpt2api"
BASE_URL = "http://localhost:8000"
TEXT_MODEL = "auto"
IMAGE_MODEL = "gpt-image-2"
CODEX_IMAGE_MODEL = "codex-gpt-image-2"
class ResponsesTests(unittest.TestCase):
@staticmethod
def _iter_sse_payloads(response: requests.Response):
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8", errors="replace")
yield text
def test_text_response_http(self):
"""测试 Responses 文本的非流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/responses",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": TEXT_MODEL,
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "你好,请简单介绍一下你自己。"},
],
}
],
},
timeout=300,
)
self.assertEqual(response.status_code, 200, response.text)
print("responses text non-stream status:")
print(response.status_code)
print("responses text non-stream result:")
try:
payload = response.json()
print(json.dumps(payload, ensure_ascii=False, indent=2))
self.assertEqual(payload.get("object"), "response")
self.assertEqual(payload.get("status"), "completed")
self.assertTrue(isinstance(payload.get("output"), list) and payload.get("output"))
except Exception:
print(response.text)
raise
def test_text_response_stream_http(self):
"""测试 Responses 文本的流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/responses",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": TEXT_MODEL,
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "你好,请简单介绍一下你自己。"},
],
}
],
"stream": True,
},
stream=True,
timeout=300,
)
self.assertEqual(response.status_code, 200, response.text)
self.assertTrue(
response.headers.get("content-type", "").startswith("text/event-stream"),
response.headers.get("content-type", ""),
)
started_at = time.time()
print("responses text stream status:")
print(response.status_code)
print("responses text stream chunks:")
event_types = []
for text in self._iter_sse_payloads(response):
print(f"{time.time() - started_at:6.2f}s {text}")
if not text.startswith("data:"):
continue
payload_text = text[5:].strip()
if payload_text == "[DONE]":
break
try:
payload = json.loads(payload_text)
except Exception:
continue
event_type = str(payload.get("type") or "")
if event_type:
event_types.append(event_type)
self.assertIn("response.created", event_types)
self.assertIn("response.completed", event_types)
def test_image_response_http(self):
"""测试 Responses 画图的非流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/responses",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": IMAGE_MODEL,
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "我想做一张南京城市宣传海报图。"},
],
}
],
"tools": [{"type": "image_generation"}],
},
timeout=300,
)
self.assertEqual(response.status_code, 200, response.text)
saved_paths = []
try:
payload = response.json()
except Exception:
payload = {}
for index, item in enumerate(payload.get("output") or [], start=1):
if not isinstance(item, dict):
continue
image_b64 = str(item.get("result") or "")
if image_b64:
saved_paths.append(save_image(image_b64, f"responses_image_non_stream_{index}"))
print("responses image non-stream status:")
print(response.status_code)
print("responses image non-stream result:")
print(json.dumps(payload, ensure_ascii=False, indent=2))
print("responses image non-stream saved files:")
for path in saved_paths:
print(path)
def test_image_response_stream_http(self):
"""测试 Responses 画图的流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/responses",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": IMAGE_MODEL,
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "我想做一张南京城市宣传海报图。"},
],
}
],
"tools": [{"type": "image_generation"}],
"stream": True,
},
stream=True,
timeout=300,
)
self.assertEqual(response.status_code, 200, response.text)
self.assertTrue(
response.headers.get("content-type", "").startswith("text/event-stream"),
response.headers.get("content-type", ""),
)
started_at = time.time()
saved_paths = []
print("responses image stream status:")
print(response.status_code)
print("responses image stream chunks:")
for text in self._iter_sse_payloads(response):
print(f"{time.time() - started_at:6.2f}s {text}")
if not text.startswith("data:"):
continue
payload_text = text[5:].strip()
if payload_text == "[DONE]":
break
try:
payload = json.loads(payload_text)
except Exception:
continue
if payload.get("type") != "response.output_item.done":
continue
item = payload.get("item") or {}
if str(item.get("type") or "") != "image_generation_call":
continue
image_b64 = str(item.get("result") or "")
if image_b64:
saved_paths.append(save_image(image_b64, f"responses_image_stream_{len(saved_paths) + 1}"))
print("responses image stream saved files:")
for path in saved_paths:
print(path)
def test_codex_image_response_http(self):
"""测试 Responses 的 codex 画图非流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/responses",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": CODEX_IMAGE_MODEL,
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "我想做一张南京城市宣传海报图。"},
],
}
],
"tools": [{"type": "image_generation"}],
},
timeout=300,
)
self.assertEqual(response.status_code, 200, response.text)
saved_paths = []
try:
payload = response.json()
except Exception:
payload = {}
for index, item in enumerate(payload.get("output") or [], start=1):
if not isinstance(item, dict):
continue
image_b64 = str(item.get("result") or "")
if image_b64:
saved_paths.append(save_image(image_b64, f"responses_codex_image_non_stream_{index}"))
print("responses codex image non-stream status:")
print(response.status_code)
print("responses codex image non-stream result:")
print(json.dumps(payload, ensure_ascii=False, indent=2))
print("responses codex image non-stream saved files:")
for path in saved_paths:
print(path)
def test_codex_image_response_stream_http(self):
"""测试 Responses 的 codex 画图流式 HTTP 调用。"""
response = requests.post(
f"{BASE_URL}/v1/responses",
headers={"Authorization": f"Bearer {AUTH_KEY}"},
json={
"model": CODEX_IMAGE_MODEL,
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "我想做一张南京城市宣传海报图。"},
],
}
],
"tools": [{"type": "image_generation"}],
"stream": True,
},
stream=True,
timeout=300,
)
self.assertEqual(response.status_code, 200, response.text)
self.assertTrue(
response.headers.get("content-type", "").startswith("text/event-stream"),
response.headers.get("content-type", ""),
)
started_at = time.time()
saved_paths = []
print("responses codex image stream status:")
print(response.status_code)
print("responses codex image stream chunks:")
for text in self._iter_sse_payloads(response):
print(f"{time.time() - started_at:6.2f}s {text}")
if not text.startswith("data:"):
continue
payload_text = text[5:].strip()
if payload_text == "[DONE]":
break
try:
payload = json.loads(payload_text)
except Exception:
continue
if payload.get("type") != "response.output_item.done":
continue
item = payload.get("item") or {}
if str(item.get("type") or "") != "image_generation_call":
continue
image_b64 = str(item.get("result") or "")
if image_b64:
saved_paths.append(save_image(image_b64, f"responses_codex_image_stream_{len(saved_paths) + 1}"))
print("responses codex image stream saved files:")
for path in saved_paths:
print(path)
if __name__ == "__main__":
unittest.main()
+47
View File
@@ -0,0 +1,47 @@
import base64
import json
import sys
import time
import urllib.request
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[1]
OUTPUT_DIR = ROOT_DIR / "data" / "output"
BASE_URL = "http://127.0.0.1:8000"
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def load_auth_key() -> str:
return json.loads((ROOT_DIR / "config.json").read_text(encoding="utf-8"))["auth-key"]
def post_json(path: str, payload: dict) -> dict:
request = urllib.request.Request(
BASE_URL + path,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {load_auth_key()}"},
method="POST",
)
with urllib.request.urlopen(request) as response:
return json.loads(response.read().decode())
def detect_ext(image_bytes: bytes) -> str:
if image_bytes.startswith(b"\xff\xd8\xff"):
return ".jpg"
if image_bytes.startswith(b"RIFF") and image_bytes[8:12] == b"WEBP":
return ".webp"
if image_bytes.startswith((b"GIF87a", b"GIF89a")):
return ".gif"
return ".png"
def save_image(image_b64: str, name: str) -> Path:
image_bytes = base64.b64decode(image_b64)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
path = OUTPUT_DIR / f"{name}_{int(time.time())}{detect_ext(image_bytes)}"
path.write_bytes(image_bytes)
return path