pop-python 1.0.3__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. POP/Embedder.py +121 -119
  2. POP/__init__.py +34 -16
  3. POP/api_registry.py +148 -0
  4. POP/context.py +47 -0
  5. POP/env_api_keys.py +33 -0
  6. POP/models.py +20 -0
  7. POP/prompt_function.py +378 -0
  8. POP/prompts/__init__.py +8 -0
  9. POP/prompts/openai-json_schema_generator.md +12 -161
  10. POP/providers/__init__.py +33 -0
  11. POP/providers/deepseek_client.py +69 -0
  12. POP/providers/doubao_client.py +101 -0
  13. POP/providers/gemini_client.py +119 -0
  14. POP/providers/llm_client.py +60 -0
  15. POP/providers/local_client.py +45 -0
  16. POP/providers/ollama_client.py +129 -0
  17. POP/providers/openai_client.py +100 -0
  18. POP/stream.py +77 -0
  19. POP/utils/__init__.py +9 -0
  20. POP/utils/event_stream.py +43 -0
  21. POP/utils/http_proxy.py +16 -0
  22. POP/utils/json_parse.py +21 -0
  23. POP/utils/oauth/__init__.py +31 -0
  24. POP/utils/overflow.py +33 -0
  25. POP/utils/sanitize_unicode.py +18 -0
  26. POP/utils/validation.py +23 -0
  27. POP/utils/web_snapshot.py +108 -0
  28. {pop_python-1.0.3.dist-info → pop_python-1.1.0.dist-info}/METADATA +160 -57
  29. pop_python-1.1.0.dist-info/RECORD +42 -0
  30. {pop_python-1.0.3.dist-info → pop_python-1.1.0.dist-info}/WHEEL +1 -1
  31. pop_python-1.1.0.dist-info/top_level.txt +2 -0
  32. tests/__init__.py +0 -0
  33. tests/conftest.py +47 -0
  34. tests/test_api_registry.py +36 -0
  35. tests/test_context_utils.py +54 -0
  36. tests/test_embedder.py +64 -0
  37. tests/test_env_api_keys.py +15 -0
  38. tests/test_prompt_function.py +98 -0
  39. tests/test_web_snapshot.py +47 -0
  40. POP/LLMClient.py +0 -403
  41. POP/POP.py +0 -392
  42. POP/prompts/2024-11-19-content_finder.md +0 -46
  43. POP/prompts/2024-11-19-get_content.md +0 -71
  44. POP/prompts/2024-11-19-get_title_and_url.md +0 -62
  45. POP/prompts/CLI_AI_helper.md +0 -75
  46. POP/prompts/content_finder.md +0 -42
  47. POP/prompts/corpus_splitter.md +0 -28
  48. POP/prompts/function_code_generator.md +0 -51
  49. POP/prompts/function_description_generator.md +0 -45
  50. POP/prompts/get_content.md +0 -75
  51. POP/prompts/get_title_and_url.md +0 -62
  52. POP/prompts/openai-function_description_generator.md +0 -126
  53. POP/prompts/openai-prompt_generator.md +0 -49
  54. POP/schemas/biomedical_ner_extractor.json +0 -37
  55. POP/schemas/entity_extraction_per_sentence.json +0 -92
  56. pop_python-1.0.3.dist-info/RECORD +0 -26
  57. pop_python-1.0.3.dist-info/top_level.txt +0 -1
  58. {pop_python-1.0.3.dist-info → pop_python-1.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,101 @@
1
+ """Doubao (Volcengine Ark) API client implementation.
2
+
3
+ This client wraps the Doubao chat completion endpoint. It requires
4
+ the ``openai`` package because Doubao is compatible with the OpenAI
5
+ client library when configured with a custom base URL. If the
6
+ package is missing, instantiating :class:`DoubaoClient` will raise an
7
+ error.
8
+ """
9
+
10
+ from typing import List, Dict, Any
11
+ from os import getenv
12
+
13
+ from .llm_client import LLMClient
14
+
15
+
16
+ try:
17
+ from openai import OpenAI
18
+ except Exception:
19
+ OpenAI = None # type: ignore
20
+
21
+
22
+ class DoubaoClient(LLMClient):
23
+ """Client for Doubao (Volcengine Ark) LLMs."""
24
+
25
+ def __init__(self, model = None) -> None:
26
+ if OpenAI is None:
27
+ raise ImportError(
28
+ "openai package is not installed. Install it to use DoubaoClient."
29
+ )
30
+ # Use the custom base URL for Doubao. Requires DOUBAO_API_KEY.
31
+ self.client = OpenAI(
32
+ base_url="https://ark.cn-beijing.volces.com/api/v3",
33
+ api_key=getenv("DOUBAO_API_KEY"),
34
+ )
35
+ self.model_name = model
36
+
37
+ def chat_completion(
38
+ self,
39
+ messages: List[Dict[str, Any]],
40
+ model: str,
41
+ temperature: float = 0.0,
42
+ **kwargs: Any,
43
+ ) -> Any:
44
+ payload: Dict[str, Any] = {
45
+ "model": model,
46
+ "messages": [],
47
+ "temperature": temperature,
48
+ }
49
+
50
+ # Normalize images input into a list
51
+ images = kwargs.pop("images", None)
52
+ if images and not isinstance(images, list):
53
+ images = [images]
54
+
55
+ # Copy through common generation options supported by Doubao
56
+ passthrough = [
57
+ "top_p",
58
+ "max_tokens",
59
+ "stop",
60
+ "frequency_penalty",
61
+ "presence_penalty",
62
+ "logprobs",
63
+ "top_logprobs",
64
+ "logit_bias",
65
+ "service_tier",
66
+ "thinking",
67
+ "stream",
68
+ "stream_options",
69
+ ]
70
+ for key in passthrough:
71
+ if key in kwargs and kwargs[key] is not None:
72
+ payload[key] = kwargs[key]
73
+
74
+ # Build messages, attaching images to user messages
75
+ for msg in messages:
76
+ role = msg.get("role", "user")
77
+ content = msg.get("content", "")
78
+ if images and role == "user":
79
+ multi: List[Any] = [{"type": "text", "text": content}]
80
+ for img in images:
81
+ if isinstance(img, str) and img.startswith("http"):
82
+ multi.append({"type": "image_url", "image_url": {"url": img}})
83
+ else:
84
+ multi.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}})
85
+ content = multi
86
+ payload["messages"].append({"role": role, "content": content})
87
+
88
+ # Function tools
89
+ tools = kwargs.get("tools")
90
+ if tools:
91
+ payload["tools"] = tools
92
+ payload["tool_choice"] = kwargs.get("tool_choice", "auto")
93
+
94
+ try:
95
+ response = self.client.chat.completions.create(**payload)
96
+ except Exception as exc:
97
+ raise RuntimeError(f"Doubao chat_completion error: {exc}") from exc
98
+ return response
99
+
100
+
101
+ __all__ = ["DoubaoClient"]
@@ -0,0 +1,119 @@
1
+ """Gemini API client implementation.
2
+
3
+ This module provides a :class:`LLMClient` implementation for Google
4
+ Gemini models. It wraps the ``google.genai`` client so that
5
+ responses conform to the expected OpenAI‑like structure used by
6
+ :class:`pop.prompt_function.PromptFunction`.
7
+
8
+ If the ``google-genai`` library is unavailable, importing
9
+ this module will not error, but instantiating :class:`GeminiClient`
10
+ will raise an :class:`ImportError`.
11
+ """
12
+
13
+ from typing import List, Dict, Any, Optional
14
+ from os import getenv
15
+
16
+ from .llm_client import LLMClient
17
+
18
+
19
+ try:
20
+ from google import genai # type: ignore
21
+ from google.genai import types # type: ignore
22
+ except Exception:
23
+ genai = None # type: ignore
24
+ types = None # type: ignore
25
+
26
+
27
+ class GeminiClient(LLMClient):
28
+ """Client for Google's Gemini models."""
29
+
30
+ def __init__(self, model: str = "gemini-2.5-flash") -> None:
31
+ if genai is None or types is None:
32
+ raise ImportError(
33
+ "google-genai package is not installed. Install it to use GeminiClient."
34
+ )
35
+ # Authenticate with the API key
36
+ self.client = genai.Client(api_key=getenv("GEMINI_API_KEY"))
37
+ self.model_name = model
38
+
39
+ def chat_completion(
40
+ self,
41
+ messages: List[Dict[str, Any]],
42
+ model: Optional[str] = None,
43
+ temperature: float = 0.0,
44
+ **kwargs: Any,
45
+ ) -> Any:
46
+ model_name = model or self.model_name
47
+
48
+ # Extract system instruction and collate user/assistant content
49
+ system_instruction: Optional[str] = None
50
+ user_contents: List[str] = []
51
+ for msg in messages:
52
+ role = msg.get("role", "user")
53
+ content = msg.get("content", "")
54
+ if role == "system" and system_instruction is None:
55
+ system_instruction = content
56
+ else:
57
+ user_contents.append(content)
58
+
59
+ # Prepare multimodal contents. Gemini accepts a list of
60
+ # strings/images; embed images if passed via kwargs.
61
+ contents: List[Any] = []
62
+ images = kwargs.pop("images", None)
63
+ if images:
64
+ try:
65
+ from PIL import Image # type: ignore
66
+ import base64
67
+ from io import BytesIO
68
+ except Exception:
69
+ raise ImportError("PIL and base64 are required for image support in GeminiClient.")
70
+ for img in images:
71
+ # Accept PIL.Image, base64 string or URL
72
+ if isinstance(img, Image.Image):
73
+ contents.append(img)
74
+ elif isinstance(img, str):
75
+ try:
76
+ # Assume base64 encoded image
77
+ img_data = base64.b64decode(img)
78
+ image = Image.open(BytesIO(img_data)) # type: ignore
79
+ contents.append(image)
80
+ except Exception:
81
+ # Fallback to URL
82
+ contents.append(img)
83
+ # Add concatenated text as the last element
84
+ if user_contents:
85
+ contents.append("\n".join(user_contents))
86
+
87
+ # Configure generation
88
+ gen_config = types.GenerateContentConfig(
89
+ temperature=temperature,
90
+ system_instruction=system_instruction,
91
+ )
92
+
93
+ try:
94
+ response = self.client.models.generate_content(
95
+ model=model_name,
96
+ contents=contents,
97
+ config=gen_config,
98
+ )
99
+ except Exception as exc:
100
+ raise RuntimeError(f"Gemini chat_completion error: {exc}") from exc
101
+
102
+ # Wrap the Gemini response into an OpenAI‑like structure
103
+ class FakeMessage:
104
+ def __init__(self, content: str):
105
+ self.content = content
106
+ self.tool_calls = None
107
+
108
+ class FakeChoice:
109
+ def __init__(self, message: FakeMessage):
110
+ self.message = message
111
+
112
+ class FakeResponse:
113
+ def __init__(self, text: str):
114
+ self.choices = [FakeChoice(FakeMessage(text))]
115
+
116
+ return FakeResponse(response.text or "")
117
+
118
+
119
+ __all__ = ["GeminiClient"]
@@ -0,0 +1,60 @@
1
+ """Abstract interface for LLM clients.
2
+
3
+ This module defines the :class:`LLMClient` abstract base class used by
4
+ provider implementations. Each provider must implement a
5
+ ``chat_completion`` method that accepts a list of message dictionaries
6
+ ([{role: str, content: str}]), a ``model`` name, a ``temperature``, and
7
+ any additional keyword arguments. The method should return an object
8
+ with a ``choices`` attribute similar to OpenAI's response schema where
9
+ ``choices[0].message.content`` contains the generated text. If the
10
+ provider supports tool calling it should also populate
11
+ ``choices[0].message.tool_calls`` accordingly.
12
+
13
+ Original POP placed all provider implementations in a single file.
14
+ This module holds only the abstract base class; see the
15
+ ``pop/providers`` subpackage for concrete clients.
16
+ """
17
+
18
+ from abc import ABC, abstractmethod
19
+ from typing import List, Dict, Any
20
+
21
+
22
+ class LLMClient(ABC):
23
+ """Abstract base class for LLM clients."""
24
+
25
+ @abstractmethod
26
+ def chat_completion(
27
+ self,
28
+ messages: List[Dict[str, Any]],
29
+ model: str,
30
+ temperature: float = 0.0,
31
+ **kwargs: Any,
32
+ ) -> Any:
33
+ """Generate a chat completion.
34
+
35
+ Parameters
36
+ ----------
37
+ messages : list of dict
38
+ Messages in the conversation. Each dict must have a
39
+ ``role`` (e.g. ``"system"``, ``"user"``, ``"assistant"``) and
40
+ ``content`` (the text of the message). Implementations may
41
+ support additional keys (for example a list of
42
+ ``images``) but should document this behaviour.
43
+ model : str
44
+ Identifier for the model variant (e.g. ``"gpt-3.5-turbo"`` or
45
+ provider‑specific names).
46
+ temperature : float, optional
47
+ Controls the randomness of the output. The default of 0.0
48
+ yields deterministic output for many providers.
49
+ **kwargs : Any
50
+ Provider‑specific options such as ``tools``, ``response_format``
51
+ or image attachments.
52
+ Returns
53
+ -------
54
+ Any
55
+ A provider‑specific response object. Consumers should not
56
+ rely on the exact type; instead they should access
57
+ ``response.choices[0].message.content`` for the text and
58
+ ``response.choices[0].message.tool_calls`` if present.
59
+ """
60
+ raise NotImplementedError
@@ -0,0 +1,45 @@
1
+ """Local PyTorch LLM client.
2
+
3
+ This client provides a minimal placeholder implementation of an
4
+ LLM client that runs locally using PyTorch. Because actual model
5
+ weights are not included in this distribution, the client returns a
6
+ static response indicating that it is a stub. You can extend this
7
+ class to load and run a local model (for example via HuggingFace
8
+ transformers) if desired.
9
+ """
10
+
11
+ from typing import List, Dict, Any
12
+
13
+ from .llm_client import LLMClient
14
+
15
+
16
+ class LocalPyTorchClient(LLMClient):
17
+ """Placeholder client for local PyTorch models."""
18
+
19
+ def chat_completion(
20
+ self,
21
+ messages: List[Dict[str, Any]],
22
+ model: str,
23
+ temperature: float = 0.0,
24
+ **kwargs: Any,
25
+ ) -> Any:
26
+ # This stub simply returns a canned response. Real
27
+ # implementations should load a local model and generate a
28
+ # response based on the messages and parameters.
29
+ class FakeMessage:
30
+ def __init__(self, content: str):
31
+ self.content = content
32
+ self.tool_calls = None
33
+
34
+ class FakeChoice:
35
+ def __init__(self, message: FakeMessage):
36
+ self.message = message
37
+
38
+ class FakeResponse:
39
+ def __init__(self, text: str):
40
+ self.choices = [FakeChoice(FakeMessage(text))]
41
+
42
+ return FakeResponse("Local PyTorch LLM response (stub)")
43
+
44
+
45
+ __all__ = ["LocalPyTorchClient"]
@@ -0,0 +1,129 @@
1
+ """Ollama-compatible LLM client.
2
+
3
+ This client sends requests to an Ollama instance via its HTTP API. It
4
+ implements the :class:`LLMClient` interface by translating chat
5
+ messages into the prompt format expected by Ollama’s `/api/generate`
6
+ endpoint. The default model and base URL can be customised on
7
+ instantiation.
8
+ """
9
+
10
+ from typing import List, Dict, Any
11
+ from os import getenv
12
+ import requests
13
+
14
+ from .llm_client import LLMClient
15
+
16
+
17
+ class OllamaClient(LLMClient):
18
+ """Client for Ollama's generate endpoint."""
19
+
20
+ def __init__(
21
+ self,
22
+ model: str = "llama3:latest",
23
+ base_url: str = "http://localhost:11434",
24
+ default_options: Dict[str, Any] | None = None,
25
+ timeout: int = 300,
26
+ ) -> None:
27
+ self.model = model
28
+ self.model_name = model
29
+ self.base_url = base_url
30
+ # Reasonable defaults for extraction and summarisation tasks
31
+ self.default_options = default_options or {
32
+ "num_ctx": 8192,
33
+ "temperature": 0.02,
34
+ "top_p": 0.9,
35
+ "top_k": 40,
36
+ "repeat_penalty": 1.05,
37
+ "mirostat": 0,
38
+ }
39
+ self.timeout = timeout
40
+
41
+ def chat_completion(
42
+ self,
43
+ messages: List[Dict[str, Any]],
44
+ model: str | None = None,
45
+ temperature: float = 0.0,
46
+ **kwargs: Any,
47
+ ) -> Any:
48
+ # Build the system and prompt strings for Ollama
49
+ system_parts: List[str] = []
50
+ user_assistant_lines: List[str] = []
51
+ for msg in messages:
52
+ role = msg.get("role", "user")
53
+ content = msg.get("content", "")
54
+ if role == "system":
55
+ system_parts.append(content)
56
+ elif role == "assistant":
57
+ user_assistant_lines.append(f"[Assistant]: {content}")
58
+ else:
59
+ user_assistant_lines.append(f"[User]: {content}")
60
+ system = "\n".join(system_parts) if system_parts else None
61
+ prompt = "\n".join(user_assistant_lines)
62
+
63
+ # Merge caller options with defaults. Accept both a nested
64
+ # ``ollama_options`` dict and top-level parameters (e.g. max_tokens)
65
+ caller_opts = kwargs.pop("ollama_options", {}) or {}
66
+ options = {**self.default_options, **caller_opts}
67
+ # Keep ``temperature`` in sync with options if provided
68
+ if temperature is not None:
69
+ options["temperature"] = temperature
70
+
71
+ payload: Dict[str, Any] = {
72
+ "model": model or self.model,
73
+ "prompt": prompt,
74
+ "stream": False,
75
+ "num_predict": kwargs.get("max_tokens", 1024),
76
+ "options": options,
77
+ }
78
+ # Add system instruction separately
79
+ if system:
80
+ payload["system"] = system
81
+ # Handle JSON schema formatting
82
+ fmt = kwargs.get("response_format")
83
+ if fmt:
84
+ fmt = self._normalize_schema(fmt)
85
+ payload["format"] = fmt
86
+ # Optional stop sequences and keep-alive
87
+ if "stop" in kwargs and kwargs["stop"]:
88
+ payload["stop"] = kwargs["stop"]
89
+ if "keep_alive" in kwargs and kwargs["keep_alive"]:
90
+ payload["keep_alive"] = kwargs["keep_alive"]
91
+
92
+ try:
93
+ response = requests.post(f"{self.base_url}/api/generate", json=payload, timeout=self.timeout)
94
+ response.raise_for_status()
95
+ content = response.json().get("response", "")
96
+ except Exception as exc:
97
+ raise RuntimeError(f"OllamaClient error: {exc}") from exc
98
+ return self._wrap_response(content)
99
+
100
+ def _normalize_schema(self, fmt: Any) -> Any:
101
+ import json, os
102
+ if fmt is None:
103
+ return None
104
+ if isinstance(fmt, str):
105
+ # Interpret as a file path or JSON string
106
+ if os.path.exists(fmt):
107
+ return json.load(open(fmt, "r", encoding="utf-8"))
108
+ return json.loads(fmt)
109
+ if isinstance(fmt, dict) and "schema" in fmt and isinstance(fmt["schema"], dict):
110
+ return fmt["schema"] # unwrap OpenAI‑style wrapper
111
+ if isinstance(fmt, dict):
112
+ return fmt
113
+ raise TypeError("response_format must be a JSON schema dict, a JSON string, or a file path")
114
+
115
+ def _wrap_response(self, content: str) -> Any:
116
+ class Message:
117
+ def __init__(self, content: str) -> None:
118
+ self.content = content
119
+ self.tool_calls = None
120
+ class Choice:
121
+ def __init__(self, message: Message) -> None:
122
+ self.message = message
123
+ class Response:
124
+ def __init__(self, content: str) -> None:
125
+ self.choices = [Choice(Message(content))]
126
+ return Response(content)
127
+
128
+
129
+ __all__ = ["OllamaClient"]
@@ -0,0 +1,100 @@
1
+ """OpenAI client implementation.
2
+
3
+ This module provides a concrete :class:`LLMClient` for interacting with
4
+ OpenAI's chat completion endpoint. It mirrors the behaviour of the
5
+ original POP ``OpenAIClient`` but lives in its own file as part of a
6
+ modular provider architecture. If the ``openai`` package is not
7
+ installed in your environment this client will raise an
8
+ ImportError on instantiation.
9
+ """
10
+
11
+ from typing import List, Dict, Any, Optional
12
+ from os import getenv
13
+ from pydantic import BaseModel
14
+
15
+ from .llm_client import LLMClient
16
+
17
+
18
+ try:
19
+ from openai import OpenAI
20
+ except Exception:
21
+ OpenAI = None # type: ignore
22
+
23
+
24
+ class OpenAIClient(LLMClient):
25
+ """Client for the OpenAI Chat Completions API."""
26
+
27
+ def __init__(self, model: str = "gpt-5-nano") -> None:
28
+ if OpenAI is None:
29
+ raise ImportError(
30
+ "openai package is not installed. Install it to use OpenAIClient."
31
+ )
32
+ # The API key is pulled from the environment. You must set
33
+ # OPENAI_API_KEY before using this client.
34
+ self.client = OpenAI(api_key=getenv("OPENAI_API_KEY"))
35
+ self.model_name = model
36
+
37
+ def chat_completion(
38
+ self,
39
+ messages: List[Dict[str, Any]],
40
+ model: Optional[str] = None,
41
+ temperature: float = 1.0,
42
+ **kwargs: Any,
43
+ ) -> Any:
44
+ # Build the request payload according to OpenAI's API.
45
+ model_name = model or self.model_name
46
+ request_payload: Dict[str, Any] = {
47
+ "model": model_name,
48
+ "messages": [],
49
+ "temperature": temperature,
50
+ }
51
+
52
+ # Optional image attachments
53
+ images: Optional[List[str]] = kwargs.pop("images", None)
54
+
55
+ # Construct messages list. If images are present they are
56
+ # attached to the last user message (OpenAI multi‑content
57
+ # messages). Other roles are passed verbatim.
58
+ for msg in messages:
59
+ role = msg.get("role", "user")
60
+ content = msg.get("content", "")
61
+ if images and role == "user":
62
+ multi: List[Any] = [{"type": "text", "text": content}]
63
+ for img in images:
64
+ if isinstance(img, str) and img.startswith("http"):
65
+ multi.append({"type": "image_url", "image_url": {"url": img}})
66
+ else:
67
+ multi.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}})
68
+ content = multi
69
+ request_payload["messages"].append({"role": role, "content": content})
70
+
71
+ # Response format: can be a pydantic model or raw JSON schema
72
+ fmt: Any = kwargs.get("response_format")
73
+ if fmt:
74
+ if isinstance(fmt, BaseModel):
75
+ request_payload["response_format"] = fmt
76
+ else:
77
+ # wrap raw schema into OpenAI's wrapper
78
+ request_payload["response_format"] = {"type": "json_schema", "json_schema": fmt}
79
+
80
+ # Tools support: list of function descriptors
81
+ tools = kwargs.get("tools")
82
+ if tools:
83
+ request_payload["tools"] = tools
84
+ request_payload["tool_choice"] = kwargs.get("tool_choice", "auto")
85
+
86
+ # Temporary patch for models not supporting a system role (as in the
87
+ # original POP). Convert the first system message to a user
88
+ # message when using the "o1-mini" model.
89
+ if model_name == "o1-mini" and request_payload["messages"] and request_payload["messages"][0]["role"] == "system":
90
+ request_payload["messages"][0]["role"] = "user"
91
+
92
+ # Send the request. If something goes wrong, raise a runtime error.
93
+ try:
94
+ response = self.client.chat.completions.create(**request_payload)
95
+ except Exception as exc:
96
+ raise RuntimeError(f"OpenAI chat_completion error: {exc}") from exc
97
+ return response
98
+
99
+
100
+ __all__ = ["OpenAIClient"]
POP/stream.py ADDED
@@ -0,0 +1,77 @@
1
+ """Streaming support for LLM responses.
2
+
3
+ This module defines a simple generator function that yields events
4
+ during a call to an LLM client. Each event is represented by a
5
+ dictionary with an ``event`` key and optionally additional data. The
6
+ sequence of events loosely follows the pattern used by the TypeScript
7
+ ``pi‑ai`` project: ``start`` → ``text_start`` → ``text_delta`` →
8
+ ``text_end`` → ``done``.
9
+
10
+ The generator hides the blocking nature of LLM calls behind an
11
+ iterator interface. For providers that support true streaming
12
+ responses, you can extend this function to yield tokens as they
13
+ arrive.
14
+ """
15
+
16
+ from typing import Generator, Dict, Any, Optional
17
+
18
+ from .context import Context
19
+ from .api_registry import get_client, get_default_model
20
+
21
+
22
+ def stream(
23
+ provider: str,
24
+ context: Context,
25
+ model: Optional[str] = None,
26
+ **kwargs: Any,
27
+ ) -> Generator[Dict[str, Any], None, None]:
28
+ """Yield events representing a streamed LLM response.
29
+
30
+ Parameters
31
+ ----------
32
+ provider : str
33
+ Identifier of the provider to use (e.g. ``"openai"``).
34
+ context : Context
35
+ Conversation context containing messages and optional system prompt.
36
+ model : str, optional
37
+ Model name; if not provided the provider's default is used.
38
+ **kwargs : Any
39
+ Additional options passed directly to the provider's
40
+ ``chat_completion`` method (e.g. ``temperature``, ``tools``).
41
+ Yields
42
+ ------
43
+ dict
44
+ Streaming events with at least an ``event`` key. Events are:
45
+ ``start`` (begin request), ``text_start`` (before first token),
46
+ ``text_delta`` (with ``value`` containing the full text for
47
+ synchronous providers), ``text_end`` (after last token), and
48
+ ``done`` (request completed).
49
+ """
50
+ client = get_client(provider)
51
+ if client is None:
52
+ raise ValueError(f"Unknown provider: {provider}")
53
+ # Determine model: fallback to default models
54
+ if model is None:
55
+ model = get_default_model(provider)
56
+ yield {"event": "start"}
57
+ # Convert context into messages
58
+ messages = context.to_messages()
59
+ yield {"event": "text_start"}
60
+ # Call the provider synchronously. If providers support streaming,
61
+ # you could instead iterate over a streaming response here.
62
+ response = client.chat_completion(messages=messages, model=model, **kwargs)
63
+ # Extract the content; OpenAI‑style responses put the text in
64
+ # response.choices[0].message.content
65
+ text = ""
66
+ try:
67
+ text = response.choices[0].message.content
68
+ except Exception:
69
+ # If the response is a simple string or dict, fall back
70
+ text = str(response)
71
+ # For synchronous providers we send the entire text in one delta
72
+ yield {"event": "text_delta", "value": text}
73
+ yield {"event": "text_end"}
74
+ yield {"event": "done"}
75
+
76
+
77
+ __all__ = ["stream"]
POP/utils/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Utility subpackage for the restructured POP project.
3
+
4
+ This subpackage contains helper modules for event streaming, HTTP
5
+ proxy configuration, JSON parsing, overflow management, Unicode
6
+ sanitisation, basic validation, web snapshots, and OAuth configuration.
7
+ The layout mirrors the utilities present in the pi‑ai project to
8
+ provide similar functionality within POP.
9
+ """
@@ -0,0 +1,43 @@
1
+ """
2
+ Utility helpers for streaming events.
3
+
4
+ This module provides a simple event streaming interface. In POP's
5
+ rewritten architecture we mirror the ``event-stream`` helper from
6
+ the original pi-ai project but do not implement a full server-sent
7
+ events protocol. Instead, we define a ``EventStream`` class and
8
+ a helper function ``to_event_stream`` that can wrap a generator.
9
+ """
10
+
11
+ from typing import Iterable, Iterator, Any, Dict
12
+
13
+ class EventStream:
14
+ """Wrap a generator to provide an iterator of event dictionaries.
15
+
16
+ An event dictionary contains at least a ``type`` key (e.g.
17
+ ``start``, ``text_start``, ``text_delta``, ``text_end``, ``done``)
18
+ and a ``data`` key. Consumers of this class can send these events
19
+ to clients over websockets or Server‑Sent Events.
20
+ """
21
+ def __init__(self, generator: Iterable):
22
+ self._iterator = iter(generator)
23
+
24
+ def __iter__(self) -> Iterator[Dict[str, Any]]:
25
+ return self
26
+
27
+ def __next__(self) -> Dict[str, Any]:
28
+ return next(self._iterator)
29
+
30
+ def to_event_stream(generator: Iterable) -> EventStream:
31
+ """Convert a plain generator into an EventStream.
32
+
33
+ Parameters
34
+ ----------
35
+ generator:
36
+ A generator which yields dictionaries representing events.
37
+
38
+ Returns
39
+ -------
40
+ EventStream
41
+ An iterator wrapper around the generator.
42
+ """
43
+ return EventStream(generator)