Initial project import
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user