pop-python 1.0.4__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.
- POP/Embedder.py +121 -119
- POP/__init__.py +34 -16
- POP/api_registry.py +148 -0
- POP/context.py +47 -0
- POP/env_api_keys.py +33 -0
- POP/models.py +20 -0
- POP/prompt_function.py +378 -0
- POP/prompts/__init__.py +8 -0
- POP/prompts/openai-json_schema_generator.md +12 -161
- POP/providers/__init__.py +33 -0
- POP/providers/deepseek_client.py +69 -0
- POP/providers/doubao_client.py +101 -0
- POP/providers/gemini_client.py +119 -0
- POP/providers/llm_client.py +60 -0
- POP/providers/local_client.py +45 -0
- POP/providers/ollama_client.py +129 -0
- POP/providers/openai_client.py +100 -0
- POP/stream.py +77 -0
- POP/utils/__init__.py +9 -0
- POP/utils/event_stream.py +43 -0
- POP/utils/http_proxy.py +16 -0
- POP/utils/json_parse.py +21 -0
- POP/utils/oauth/__init__.py +31 -0
- POP/utils/overflow.py +33 -0
- POP/utils/sanitize_unicode.py +18 -0
- POP/utils/validation.py +23 -0
- POP/utils/web_snapshot.py +108 -0
- {pop_python-1.0.4.dist-info → pop_python-1.1.0.dist-info}/METADATA +160 -57
- pop_python-1.1.0.dist-info/RECORD +42 -0
- {pop_python-1.0.4.dist-info → pop_python-1.1.0.dist-info}/WHEEL +1 -1
- pop_python-1.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +47 -0
- tests/test_api_registry.py +36 -0
- tests/test_context_utils.py +54 -0
- tests/test_embedder.py +64 -0
- tests/test_env_api_keys.py +15 -0
- tests/test_prompt_function.py +98 -0
- tests/test_web_snapshot.py +47 -0
- POP/LLMClient.py +0 -410
- POP/POP.py +0 -400
- POP/prompts/2024-11-19-content_finder.md +0 -46
- POP/prompts/2024-11-19-get_content.md +0 -71
- POP/prompts/2024-11-19-get_title_and_url.md +0 -62
- POP/prompts/CLI_AI_helper.md +0 -75
- POP/prompts/content_finder.md +0 -42
- POP/prompts/corpus_splitter.md +0 -28
- POP/prompts/function_code_generator.md +0 -51
- POP/prompts/function_description_generator.md +0 -45
- POP/prompts/get_content.md +0 -75
- POP/prompts/get_title_and_url.md +0 -62
- POP/prompts/openai-function_description_generator.md +0 -126
- POP/prompts/openai-prompt_generator.md +0 -49
- POP/schemas/biomedical_ner_extractor.json +0 -37
- POP/schemas/entity_extraction_per_sentence.json +0 -92
- pop_python-1.0.4.dist-info/RECORD +0 -26
- pop_python-1.0.4.dist-info/top_level.txt +0 -1
- {pop_python-1.0.4.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)
|