返回提交历史
Modified
etc/unittest/mcp.py
+120
-0
Modified
g4f/api/__init__.py
+209
-2
Modified
g4f/mcp/__init__.py
+4
-0
Modified
g4f/mcp/pa_provider.py
+126
-0
XFEstudio/gpt4free
Add PaProviderRegistry, /pa/* API routes (providers list, chat/completions, backend-api/v2/conversation)
Agent-Logs-Url: https://github.com/xtekky/gpt4free/sessions/e0daf662-ee35-43ac-bdef-27dd570bc00d Co-authored-by: hlohaus <983577+hlohaus@users.noreply.github.com>
dd9230ef
代码差异
4 个文件
+459
-2
@@ -560,3 +560,123 @@ class TestSecurityHardening(unittest.IsolatedAsyncioTestCase):
560
560
"max_depth": MAX_RECURSION_DEPTH * 100,
561
561
})
562
562
self.assertTrue(result.get("success"))
563
564
class TestPaProviderRegistry(unittest.TestCase):
565
"""Tests for PaProviderRegistry — stable IDs without exposing filenames."""
566
567
def setUp(self):
568
"""Create a temporary .pa.py file in the workspace for testing."""
569
from g4f.mcp.pa_provider import get_workspace_dir, get_pa_registry, _pa_registry
570
self.workspace = get_workspace_dir()
571
# Force a fresh registry for each test
572
import g4f.mcp.pa_provider as _mod
573
_mod._pa_registry = None
574
575
self.pa_file = self.workspace / "registry_test.pa.py"
576
self.pa_file.write_text("""
577
class Provider:
578
label = "RegistryTestProvider"
579
working = True
580
models = ["rt-model-1", "rt-model-2"]
581
url = "https://test.example.com"
582
583
@classmethod
584
async def create_async_generator(cls, model, messages, **kwargs):
585
yield "hello from registry test"
586
""")
587
588
def tearDown(self):
589
if self.pa_file.exists():
590
self.pa_file.unlink()
591
import g4f.mcp.pa_provider as _mod
592
_mod._pa_registry = None
593
594
def test_list_providers_returns_list(self):
595
from g4f.mcp.pa_provider import get_pa_registry
596
reg = get_pa_registry()
597
reg.refresh()
598
result = reg.list_providers()
599
self.assertIsInstance(result, list)
600
self.assertGreaterEqual(len(result), 1)
601
602
def test_provider_has_required_fields(self):
603
from g4f.mcp.pa_provider import get_pa_registry
604
reg = get_pa_registry()
605
reg.refresh()
606
providers = reg.list_providers()
607
p = next((x for x in providers if x.get("label") == "RegistryTestProvider"), None)
608
self.assertIsNotNone(p, "Test provider not found in registry")
609
self.assertIn("id", p)
610
self.assertIn("label", p)
611
self.assertIn("models", p)
612
self.assertIn("working", p)
613
self.assertIn("url", p)
614
self.assertEqual(p["label"], "RegistryTestProvider")
615
self.assertIn("rt-model-1", p["models"])
616
self.assertTrue(p["working"])
617
618
def test_filename_not_exposed(self):
619
"""Provider IDs and info must NOT contain the filename or path."""
620
from g4f.mcp.pa_provider import get_pa_registry
621
import json
622
reg = get_pa_registry()
623
reg.refresh()
624
providers = reg.list_providers()
625
for p in providers:
626
serialized = json.dumps(p)
627
self.assertNotIn("registry_test", serialized, "Filename leaked in provider info")
628
self.assertNotIn(".pa.py", serialized, "Extension leaked in provider info")
629
self.assertNotIn(str(self.workspace), serialized, "Workspace path leaked")
630
631
def test_stable_id(self):
632
"""The same file gets the same ID across refreshes."""
633
from g4f.mcp.pa_provider import get_pa_registry
634
reg = get_pa_registry()
635
reg.refresh()
636
p1 = next(x for x in reg.list_providers() if x["label"] == "RegistryTestProvider")
637
reg.refresh()
638
p2 = next(x for x in reg.list_providers() if x["label"] == "RegistryTestProvider")
639
self.assertEqual(p1["id"], p2["id"])
640
641
def test_get_provider_class_returns_class(self):
642
from g4f.mcp.pa_provider import get_pa_registry
643
reg = get_pa_registry()
644
reg.refresh()
645
p = next(x for x in reg.list_providers() if x["label"] == "RegistryTestProvider")
646
cls = reg.get_provider_class(p["id"])
647
self.assertIsNotNone(cls)
648
self.assertTrue(hasattr(cls, "create_async_generator"))
649
650
def test_get_provider_class_missing_returns_none(self):
651
from g4f.mcp.pa_provider import get_pa_registry
652
reg = get_pa_registry()
653
self.assertIsNone(reg.get_provider_class("nonexistent00"))
654
655
def test_get_provider_info_returns_dict(self):
656
from g4f.mcp.pa_provider import get_pa_registry
657
reg = get_pa_registry()
658
reg.refresh()
659
p = next(x for x in reg.list_providers() if x["label"] == "RegistryTestProvider")
660
info = reg.get_provider_info(p["id"])
661
self.assertIsNotNone(info)
662
self.assertEqual(info["id"], p["id"])
663
self.assertEqual(info["label"], "RegistryTestProvider")
664
665
def test_get_provider_info_missing_returns_none(self):
666
from g4f.mcp.pa_provider import get_pa_registry
667
reg = get_pa_registry()
668
self.assertIsNone(reg.get_provider_info("nonexistent00"))
669
670
def test_id_length(self):
671
"""IDs should be 8 hex characters."""
672
from g4f.mcp.pa_provider import get_pa_registry
673
reg = get_pa_registry()
674
reg.refresh()
675
for p in reg.list_providers():
676
self.assertRegex(p["id"], r'^[0-9a-f]{8}$')
677
678
def test_registry_singleton(self):
679
from g4f.mcp.pa_provider import get_pa_registry
680
r1 = get_pa_registry()
681
r2 = get_pa_registry()
682
self.assertIs(r1, r2)
@@ -267,7 +267,7 @@ class Api:
267
267
else:
268
268
user = "admin"
269
269
path = request.url.path
270
if path.startswith("/v1") or path.startswith("/api/") or (AppConfig.demo and path == '/backend-api/v2/upload_cookies'):
270
if path.startswith("/v1") or path.startswith("/api/") or path.startswith("/pa/") or (AppConfig.demo and path == '/backend-api/v2/upload_cookies'):
271
271
if request.method != "OPTIONS" and not path.endswith("/models"):
272
272
if not user_g4f_api_key:
273
273
return ErrorResponse.from_message("G4F API key required", HTTP_401_UNAUTHORIZED)
@@ -636,7 +636,214 @@ class Api:
636
636
'params': [*provider.get_parameters()] if hasattr(provider, "get_parameters") else []
637
637
}
638
638
639
responses = {
639
# ------------------------------------------------------------------ #
640
# PA Provider routes #
641
# ------------------------------------------------------------------ #
642
643
@self.app.get("/pa/providers", responses={
644
HTTP_200_OK: {},
645
})
646
async def pa_providers_list():
647
"""List all PA providers loaded from the workspace.
648
649
Filenames are never exposed; each provider is identified by a
650
stable opaque ID (SHA-256 of the path, first 8 hex chars).
651
"""
652
from g4f.mcp.pa_provider import get_pa_registry
653
return get_pa_registry().list_providers()
654
655
@self.app.get("/pa/providers/{provider_id}", responses={
656
HTTP_200_OK: {},
657
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
658
})
659
async def pa_providers_detail(provider_id: str):
660
"""Get details for a single PA provider by its opaque ID."""
661
from g4f.mcp.pa_provider import get_pa_registry
662
info = get_pa_registry().get_provider_info(provider_id)
663
if info is None:
664
return ErrorResponse.from_message(
665
f"PA provider '{provider_id}' not found", HTTP_404_NOT_FOUND
666
)
667
return info
668
669
responses_pa = {
670
HTTP_200_OK: {"model": ChatCompletion},
671
HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
672
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
673
HTTP_422_UNPROCESSABLE_ENTITY: {"model": ErrorResponseModel},
674
HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponseModel},
675
}
676
677
@self.app.post("/pa/chat/completions", responses=responses_pa)
678
@self.app.post("/pa/{provider_id}/chat/completions", responses=responses_pa)
679
async def pa_chat_completions(
680
config: ChatCompletionsConfig,
681
credentials: Annotated[HTTPAuthorizationCredentials, Depends(Api.security)] = None,
682
provider_id: str = None,
683
):
684
"""OpenAI-compatible chat completions endpoint backed by PA providers.
685
686
The PA provider is identified by its opaque ID either from the URL
687
path (``/pa/{provider_id}/chat/completions``) or from the ``provider``
688
field in the JSON body. When both are absent the first available PA
689
provider is used.
690
"""
691
from g4f.mcp.pa_provider import get_pa_registry
692
693
registry = get_pa_registry()
694
pid = provider_id or config.provider
695
if pid is None:
696
listing = registry.list_providers()
697
if not listing:
698
return ErrorResponse.from_message(
699
"No PA providers found in workspace", HTTP_404_NOT_FOUND
700
)
701
pid = listing[0]["id"]
702
703
provider_cls = registry.get_provider_class(pid)
704
if provider_cls is None:
705
return ErrorResponse.from_message(
706
f"PA provider '{pid}' not found", HTTP_404_NOT_FOUND
707
)
708
709
try:
710
config.provider = None # pass the class directly below
711
if credentials is not None and credentials.credentials != "secret":
712
config.api_key = credentials.credentials
713
714
response = self.client.chat.completions.create(
715
**filter_none(
716
**(
717
config.model_dump(exclude_none=True)
718
if hasattr(config, "model_dump")
719
else config.dict(exclude_none=True)
720
),
721
**{
722
"conversation_id": None,
723
"provider": provider_cls,
724
},
725
),
726
)
727
728
if not config.stream:
729
return await response
730
731
async def streaming():
732
try:
733
async for chunk in response:
734
if not isinstance(chunk, BaseConversation):
735
yield (
736
f"data: "
737
f"{chunk.model_dump_json() if hasattr(chunk, 'model_dump_json') else chunk.json()}"
738
f"\n\n"
739
)
740
except GeneratorExit:
741
pass
742
except Exception as e:
743
logger.exception(e)
744
yield f"data: {format_exception(e, config)}\n\n"
745
yield "data: [DONE]\n\n"
746
747
return StreamingResponse(streaming(), media_type="text/event-stream")
748
749
except (ModelNotFoundError, ProviderNotFoundError) as e:
750
logger.exception(e)
751
return ErrorResponse.from_exception(e, config, HTTP_404_NOT_FOUND)
752
except (MissingAuthError, NoValidHarFileError) as e:
753
logger.exception(e)
754
return ErrorResponse.from_exception(e, config, HTTP_401_UNAUTHORIZED)
755
except Exception as e:
756
logger.exception(e)
757
return ErrorResponse.from_exception(e, config, HTTP_500_INTERNAL_SERVER_ERROR)
758
759
@self.app.post("/pa/backend-api/v2/conversation", responses={
760
HTTP_200_OK: {},
761
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
762
HTTP_422_UNPROCESSABLE_ENTITY: {"model": ErrorResponseModel},
763
HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponseModel},
764
})
765
async def pa_backend_conversation(request: Request):
766
"""GUI-compatible streaming conversation endpoint for PA providers.
767
768
Accepts the same JSON body as ``/backend-api/v2/conversation`` and
769
streams Server-Sent Events in the same format used by the gpt4free
770
web interface (``{"type": "content", "content": "..."}`` etc.).
771
772
The ``provider`` field should contain the opaque PA provider ID
773
returned by ``GET /pa/providers``. When omitted the first available
774
PA provider is used.
775
"""
776
from g4f.mcp.pa_provider import get_pa_registry
777
778
try:
779
body = await request.json()
780
except Exception:
781
return ErrorResponse.from_message(
782
"Invalid JSON body", HTTP_422_UNPROCESSABLE_ENTITY
783
)
784
785
registry = get_pa_registry()
786
pid = body.get("provider")
787
if pid:
788
provider_cls = registry.get_provider_class(pid)
789
if provider_cls is None:
790
return ErrorResponse.from_message(
791
f"PA provider '{pid}' not found", HTTP_404_NOT_FOUND
792
)
793
else:
794
listing = registry.list_providers()
795
if not listing:
796
return ErrorResponse.from_message(
797
"No PA providers found in workspace", HTTP_404_NOT_FOUND
798
)
799
provider_cls = registry.get_provider_class(listing[0]["id"])
800
801
provider_label = getattr(provider_cls, "label", provider_cls.__name__)
802
messages = body.get("messages") or []
803
model = body.get("model") or getattr(provider_cls, "default_model", "") or ""
804
805
async def gen_backend_stream():
806
yield (
807
"data: "
808
+ json.dumps({"type": "provider", "provider": provider_label, "model": model})
809
+ "\n\n"
810
)
811
try:
812
response = self.client.chat.completions.create(
813
messages=messages,
814
model=model,
815
provider=provider_cls,
816
stream=True,
817
)
818
async for chunk in response:
819
if isinstance(chunk, BaseConversation):
820
continue
821
text = ""
822
if hasattr(chunk, "choices") and chunk.choices:
823
delta = chunk.choices[0].delta
824
text = getattr(delta, "content", "") or ""
825
if text:
826
yield (
827
"data: "
828
+ json.dumps({"type": "content", "content": text})
829
+ "\n\n"
830
)
831
except GeneratorExit:
832
pass
833
except Exception as e:
834
logger.exception(e)
835
yield (
836
"data: "
837
+ json.dumps({"type": "error", "error": f"{type(e).__name__}: {e}"})
838
+ "\n\n"
839
)
840
yield (
841
"data: "
842
+ json.dumps({"type": "finish", "finish": "stop"})
843
+ "\n\n"
844
)
845
846
return StreamingResponse(gen_backend_stream(), media_type="text/event-stream")
640
847
HTTP_200_OK: {"model": TranscriptionResponseModel},
641
848
HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
642
849
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
@@ -28,8 +28,10 @@ from .pa_provider import (
28
28
load_pa_provider,
29
29
list_pa_providers,
30
30
get_workspace_dir,
31
get_pa_registry,
31
32
SAFE_MODULES,
32
33
SafeExecutionResult,
34
PaProviderRegistry,
33
35
)
34
36
35
37
__all__ = [
@@ -51,6 +53,8 @@ __all__ = [
51
53
'load_pa_provider',
52
54
'list_pa_providers',
53
55
'get_workspace_dir',
56
'get_pa_registry',
54
57
'SAFE_MODULES',
55
58
'SafeExecutionResult',
59
'PaProviderRegistry',
56
60
]
@@ -60,7 +60,9 @@ import io
60
60
import ast
61
61
import sys
62
62
import json
63
import hashlib
63
64
import threading
65
import time as _time_module
64
66
import traceback
65
67
import builtins as _builtins
66
68
from pathlib import Path
@@ -480,3 +482,127 @@ def list_pa_providers(directory: "Optional[str | Path]" = None) -> List[Path]:
480
482
if not directory.exists():
481
483
return []
482
484
return sorted(directory.rglob("*.pa.py"))
485
486
487
# ---------------------------------------------------------------------------
488
# PA Provider Registry
489
# ---------------------------------------------------------------------------
490
491
class PaProviderRegistry:
492
"""Singleton registry for PA providers loaded from the workspace.
493
494
Each provider is assigned a **stable opaque ID** derived from the SHA-256
495
hash of its canonical file path (truncated to 8 hex chars). The filename
496
is never exposed in any public-facing method.
497
498
The registry is automatically refreshed when the cache is older than
499
:attr:`TTL` seconds so hot-reloaded PA files are picked up without a
500
restart.
501
"""
502
503
#: How long (in seconds) the cached entries remain valid.
504
TTL: float = 5.0
505
506
def __init__(self) -> None:
507
# Each entry: (id, label, models, working, url, cls)
508
self._entries: List[tuple] = []
509
# Force a refresh on the first access.
510
self._loaded_at: float = -self.TTL
511
512
# ------------------------------------------------------------------
513
# Private helpers
514
# ------------------------------------------------------------------
515
516
@staticmethod
517
def _make_id(path: Path) -> str:
518
"""Return a stable 8-char hex ID for *path* (no path info exposed)."""
519
return hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:8]
520
521
def _ensure_fresh(self) -> None:
522
if _time_module.monotonic() - self._loaded_at >= self.TTL:
523
self.refresh()
524
525
# ------------------------------------------------------------------
526
# Public API
527
# ------------------------------------------------------------------
528
529
def refresh(self) -> None:
530
"""Re-scan the workspace and reload all ``.pa.py`` providers."""
531
entries: List[tuple] = []
532
for pa_path in list_pa_providers():
533
try:
534
cls = load_pa_provider(pa_path)
535
if cls is None:
536
continue
537
provider_id = self._make_id(pa_path)
538
models_list: List[str] = []
539
try:
540
if hasattr(cls, "get_models"):
541
raw = cls.get_models()
542
models_list = list(raw) if raw else []
543
elif hasattr(cls, "models"):
544
models_list = list(getattr(cls, "models") or [])
545
except Exception:
546
pass
547
entries.append((
548
provider_id,
549
getattr(cls, "label", cls.__name__),
550
models_list,
551
bool(getattr(cls, "working", True)),
552
getattr(cls, "url", None),
553
cls,
554
))
555
except Exception:
556
pass
557
self._entries = entries
558
self._loaded_at = _time_module.monotonic()
559
560
def list_providers(self) -> List[Dict[str, Any]]:
561
"""Return a list of provider info dicts (no filesystem paths)."""
562
self._ensure_fresh()
563
return [
564
{
565
"id": e[0],
566
"object": "pa_provider",
567
"label": e[1],
568
"models": e[2],
569
"working": e[3],
570
"url": e[4],
571
}
572
for e in self._entries
573
]
574
575
def get_provider_class(self, provider_id: str) -> Optional[Type]:
576
"""Return the provider class for *provider_id*, or ``None``."""
577
self._ensure_fresh()
578
for e in self._entries:
579
if e[0] == provider_id:
580
return e[5]
581
return None
582
583
def get_provider_info(self, provider_id: str) -> Optional[Dict[str, Any]]:
584
"""Return the info dict for *provider_id*, or ``None``."""
585
self._ensure_fresh()
586
for e in self._entries:
587
if e[0] == provider_id:
588
return {
589
"id": e[0],
590
"object": "pa_provider",
591
"label": e[1],
592
"models": e[2],
593
"working": e[3],
594
"url": e[4],
595
}
596
return None
597
598
599
#: Module-level singleton.
600
_pa_registry: Optional[PaProviderRegistry] = None
601
602
603
def get_pa_registry() -> PaProviderRegistry:
604
"""Return the singleton :class:`PaProviderRegistry`, creating it if needed."""
605
global _pa_registry
606
if _pa_registry is None:
607
_pa_registry = PaProviderRegistry()
608
return _pa_registry