XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 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
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
提交于

代码差异

4 个文件 +459 -2
Modified etc/unittest/mcp.py +120 -0
@@ -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)
Modified g4f/api/__init__.py +209 -2
@@ -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},
Modified g4f/mcp/__init__.py +4 -0
@@ -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 ]
Modified g4f/mcp/pa_provider.py +126 -0
@@ -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