From 4658947730e191357dc22b7dc9007a51c3efffbd Mon Sep 17 00:00:00 2001 From: Nicolas Girardot Date: Mon, 5 Jan 2026 16:03:24 +0100 Subject: [PATCH 01/10] feat: first implementation --- .github/workflows/release.yml | 30 ++++ edgee/__init__.py | 157 +++++++++++++++++ edgee/py.typed | 0 example/test.py | 64 +++++++ pyproject.toml | 36 ++++ tests/__init__.py | 0 tests/test_edgee.py | 309 ++++++++++++++++++++++++++++++++++ 7 files changed, 596 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 edgee/__init__.py create mode 100644 edgee/py.typed create mode 100644 example/test.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_edgee.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d2e9fed --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release to PyPI + +on: + push: + tags: + - "v*" + +jobs: + release: + runs-on: ubuntu-latest + permissions: + id-token: write # Required for trusted publishing + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build dependencies + run: pip install build + + - name: Build package + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + diff --git a/edgee/__init__.py b/edgee/__init__.py new file mode 100644 index 0000000..3f15f94 --- /dev/null +++ b/edgee/__init__.py @@ -0,0 +1,157 @@ +"""Edgee Gateway SDK for Python""" + +import os +import json +from typing import Optional, Union +from dataclasses import dataclass +from urllib.request import Request, urlopen +from urllib.error import HTTPError + + +@dataclass +class FunctionDefinition: + name: str + description: Optional[str] = None + parameters: Optional[dict] = None + + +@dataclass +class Tool: + type: str # "function" + function: FunctionDefinition + + +@dataclass +class ToolCall: + id: str + type: str + function: dict # {"name": str, "arguments": str} + + +@dataclass +class Message: + role: str # "system" | "user" | "assistant" | "tool" + content: Optional[str] = None + name: Optional[str] = None + tool_calls: Optional[list[ToolCall]] = None + tool_call_id: Optional[str] = None + + +@dataclass +class InputObject: + messages: list[dict] + tools: Optional[list[dict]] = None + tool_choice: Optional[Union[str, dict]] = None + + +@dataclass +class Choice: + index: int + message: dict + finish_reason: Optional[str] + + +@dataclass +class Usage: + prompt_tokens: int + completion_tokens: int + total_tokens: int + + +@dataclass +class SendResponse: + choices: list[Choice] + usage: Optional[Usage] = None + + +@dataclass +class EdgeeConfig: + api_key: Optional[str] = None + base_url: Optional[str] = None + + +class Edgee: + def __init__( + self, + config: Optional[Union[str, EdgeeConfig, dict]] = None, + ): + api_key: Optional[str] = None + base_url: Optional[str] = None + + if isinstance(config, str): + # Backward compatibility: accept api_key as string + api_key = config + elif isinstance(config, EdgeeConfig): + api_key = config.api_key + base_url = config.base_url + elif isinstance(config, dict): + api_key = config.get("api_key") + base_url = config.get("base_url") + + self.api_key = api_key or os.environ.get("EDGEE_API_KEY", "") + if not self.api_key: + raise ValueError("EDGEE_API_KEY is not set") + + self.base_url = base_url or os.environ.get("EDGEE_BASE_URL", "https://api.edgee.ai") + + def send( + self, + model: str, + input: Union[str, InputObject, dict], + ) -> SendResponse: + """Send a completion request to the Edgee AI Gateway.""" + + if isinstance(input, str): + messages = [{"role": "user", "content": input}] + tools = None + tool_choice = None + elif isinstance(input, InputObject): + messages = input.messages + tools = input.tools + tool_choice = input.tool_choice + else: + messages = input.get("messages", []) + tools = input.get("tools") + tool_choice = input.get("tool_choice") + + body: dict = {"model": model, "messages": messages} + if tools: + body["tools"] = tools + if tool_choice: + body["tool_choice"] = tool_choice + + request = Request( + f"{self.base_url}/v1/chat/completions", + data=json.dumps(body).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + }, + method="POST", + ) + + try: + with urlopen(request) as response: + data = json.loads(response.read().decode("utf-8")) + except HTTPError as e: + error_body = e.read().decode("utf-8") + raise RuntimeError(f"API error {e.code}: {error_body}") from e + + choices = [ + Choice( + index=c["index"], + message=c["message"], + finish_reason=c.get("finish_reason"), + ) + for c in data["choices"] + ] + + usage = None + if "usage" in data: + usage = Usage( + prompt_tokens=data["usage"]["prompt_tokens"], + completion_tokens=data["usage"]["completion_tokens"], + total_tokens=data["usage"]["total_tokens"], + ) + + return SendResponse(choices=choices, usage=usage) diff --git a/edgee/py.typed b/edgee/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/example/test.py b/example/test.py new file mode 100644 index 0000000..f009972 --- /dev/null +++ b/example/test.py @@ -0,0 +1,64 @@ +"""Example usage of Edgee Gateway SDK""" + +import os +import sys + +# Add parent directory to path for local testing +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from edgee import Edgee + +edgee = Edgee(os.environ.get("EDGEE_API_KEY", "test-key")) + +# Test 1: Simple string input +print("Test 1: Simple string input") +response1 = edgee.send( + model="gpt-4o", + input="What is the capital of France?", +) +print(f"Content: {response1.choices[0].message['content']}") +print(f"Usage: {response1.usage}") +print() + +# Test 2: Full input object with messages +print("Test 2: Full input object with messages") +response2 = edgee.send( + model="gpt-4o", + input={ + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Say hello!"}, + ], + }, +) +print(f"Content: {response2.choices[0].message['content']}") +print() + +# Test 3: With tools +print("Test 3: With tools") +response3 = edgee.send( + model="gpt-4o", + input={ + "messages": [{"role": "user", "content": "What is the weather in Paris?"}], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "City name"}, + }, + "required": ["location"], + }, + }, + }, + ], + "tool_choice": "auto", + }, +) +print(f"Content: {response3.choices[0].message.get('content')}") +print(f"Tool calls: {response3.choices[0].message.get('tool_calls')}") + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d6ed460 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "edgee" +version = "0.1.1" +description = "Lightweight Python SDK for Edgee AI Gateway" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +dependencies = [] +authors = [ + { name = "Edgee", email = "support@edgee.cloud" } +] +keywords = ["ai", "llm", "gateway", "openai", "anthropic"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +[project.urls] +Homepage = "https://github.com/edgee-cloud/python-sdk" +Repository = "https://github.com/edgee-cloud/python-sdk" + +[project.optional-dependencies] +dev = ["pytest>=8.0.0"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_edgee.py b/tests/test_edgee.py new file mode 100644 index 0000000..09455ba --- /dev/null +++ b/tests/test_edgee.py @@ -0,0 +1,309 @@ +"""Tests for Edgee SDK""" + +import json +import os +import pytest +from unittest.mock import patch, MagicMock +from io import BytesIO + +from edgee import Edgee, EdgeeConfig, SendResponse, Choice, Usage + + +class TestEdgeeConstructor: + """Test Edgee constructor""" + + def setup_method(self): + # Clear environment variables before each test + os.environ.pop("EDGEE_API_KEY", None) + os.environ.pop("EDGEE_BASE_URL", None) + + def test_with_string_api_key(self): + """Should use provided API key (backward compatibility)""" + client = Edgee("test-api-key") + assert isinstance(client, Edgee) + + def test_with_empty_string_raises_error(self): + """Should throw error when empty string is provided as API key""" + with pytest.raises(ValueError, match="EDGEE_API_KEY is not set"): + Edgee("") + + def test_with_config_dict(self): + """Should use provided API key and base_url from dict""" + client = Edgee({"api_key": "test-key", "base_url": "https://custom.example.com"}) + assert isinstance(client, Edgee) + + def test_with_config_object(self): + """Should use provided API key and base_url from EdgeeConfig""" + config = EdgeeConfig(api_key="test-key", base_url="https://custom.example.com") + client = Edgee(config) + assert isinstance(client, Edgee) + + def test_with_env_api_key(self): + """Should use EDGEE_API_KEY environment variable""" + os.environ["EDGEE_API_KEY"] = "env-api-key" + client = Edgee() + assert isinstance(client, Edgee) + + def test_with_env_base_url(self): + """Should use EDGEE_BASE_URL environment variable""" + os.environ["EDGEE_API_KEY"] = "env-api-key" + os.environ["EDGEE_BASE_URL"] = "https://env-base-url.example.com" + client = Edgee() + assert isinstance(client, Edgee) + + def test_no_api_key_raises_error(self): + """Should throw error when no API key provided""" + with pytest.raises(ValueError, match="EDGEE_API_KEY is not set"): + Edgee() + + def test_empty_config_with_env(self): + """Should use environment variables when config is empty dict""" + os.environ["EDGEE_API_KEY"] = "env-api-key" + client = Edgee({}) + assert isinstance(client, Edgee) + + +class TestEdgeeSend: + """Test Edgee.send method""" + + def setup_method(self): + os.environ.pop("EDGEE_API_KEY", None) + os.environ.pop("EDGEE_BASE_URL", None) + + def _mock_response(self, data: dict): + """Create a mock response""" + mock = MagicMock() + mock.read.return_value = json.dumps(data).encode("utf-8") + mock.__enter__ = MagicMock(return_value=mock) + mock.__exit__ = MagicMock(return_value=False) + return mock + + @patch("edgee.urlopen") + def test_send_with_string_input(self, mock_urlopen): + """Should send request with string input""" + mock_response_data = { + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello, world!"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + mock_urlopen.return_value = self._mock_response(mock_response_data) + + client = Edgee("test-api-key") + result = client.send(model="gpt-4", input="Hello") + + assert len(result.choices) == 1 + assert result.choices[0].message["content"] == "Hello, world!" + assert result.usage.total_tokens == 15 + + # Verify the request + call_args = mock_urlopen.call_args[0][0] + assert call_args.full_url == "https://api.edgee.ai/v1/chat/completions" + body = json.loads(call_args.data.decode("utf-8")) + assert body["model"] == "gpt-4" + assert body["messages"] == [{"role": "user", "content": "Hello"}] + + @patch("edgee.urlopen") + def test_send_with_input_object(self, mock_urlopen): + """Should send request with InputObject (dict)""" + mock_response_data = { + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Response"}, + "finish_reason": "stop", + } + ], + } + mock_urlopen.return_value = self._mock_response(mock_response_data) + + client = Edgee("test-api-key") + result = client.send( + model="gpt-4", + input={ + "messages": [ + {"role": "system", "content": "You are a helpful assistant"}, + {"role": "user", "content": "Hello"}, + ], + }, + ) + + call_args = mock_urlopen.call_args[0][0] + body = json.loads(call_args.data.decode("utf-8")) + assert body["messages"] == [ + {"role": "system", "content": "You are a helpful assistant"}, + {"role": "user", "content": "Hello"}, + ] + + @patch("edgee.urlopen") + def test_send_with_tools(self, mock_urlopen): + """Should include tools when provided""" + mock_response_data = { + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "San Francisco"}', + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + } + mock_urlopen.return_value = self._mock_response(mock_response_data) + + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the weather for a location", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + }, + }, + } + ] + + client = Edgee("test-api-key") + result = client.send( + model="gpt-4", + input={ + "messages": [{"role": "user", "content": "What is the weather?"}], + "tools": tools, + "tool_choice": "auto", + }, + ) + + call_args = mock_urlopen.call_args[0][0] + body = json.loads(call_args.data.decode("utf-8")) + assert body["tools"] == tools + assert body["tool_choice"] == "auto" + assert result.choices[0].message.get("tool_calls") is not None + + @patch("edgee.urlopen") + def test_send_without_usage(self, mock_urlopen): + """Should handle response without usage field""" + mock_response_data = { + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Response"}, + "finish_reason": "stop", + } + ], + } + mock_urlopen.return_value = self._mock_response(mock_response_data) + + client = Edgee("test-api-key") + result = client.send(model="gpt-4", input="Test") + + assert result.usage is None + assert len(result.choices) == 1 + + @patch("edgee.urlopen") + def test_send_with_multiple_choices(self, mock_urlopen): + """Should handle multiple choices in response""" + mock_response_data = { + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "First response"}, + "finish_reason": "stop", + }, + { + "index": 1, + "message": {"role": "assistant", "content": "Second response"}, + "finish_reason": "stop", + }, + ], + } + mock_urlopen.return_value = self._mock_response(mock_response_data) + + client = Edgee("test-api-key") + result = client.send(model="gpt-4", input="Test") + + assert len(result.choices) == 2 + assert result.choices[0].message["content"] == "First response" + assert result.choices[1].message["content"] == "Second response" + + @patch("edgee.urlopen") + def test_send_with_custom_base_url(self, mock_urlopen): + """Should use custom base_url when provided""" + mock_response_data = { + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Response"}, + "finish_reason": "stop", + } + ], + } + mock_urlopen.return_value = self._mock_response(mock_response_data) + + custom_base_url = "https://custom-api.example.com" + client = Edgee({"api_key": "test-key", "base_url": custom_base_url}) + client.send(model="gpt-4", input="Test") + + call_args = mock_urlopen.call_args[0][0] + assert call_args.full_url == f"{custom_base_url}/v1/chat/completions" + + @patch("edgee.urlopen") + def test_send_with_env_base_url(self, mock_urlopen): + """Should use EDGEE_BASE_URL environment variable""" + mock_response_data = { + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Response"}, + "finish_reason": "stop", + } + ], + } + mock_urlopen.return_value = self._mock_response(mock_response_data) + + env_base_url = "https://env-base-url.example.com" + os.environ["EDGEE_BASE_URL"] = env_base_url + client = Edgee("test-key") + client.send(model="gpt-4", input="Test") + + call_args = mock_urlopen.call_args[0][0] + assert call_args.full_url == f"{env_base_url}/v1/chat/completions" + + @patch("edgee.urlopen") + def test_config_base_url_overrides_env(self, mock_urlopen): + """Should prioritize config base_url over environment variable""" + mock_response_data = { + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Response"}, + "finish_reason": "stop", + } + ], + } + mock_urlopen.return_value = self._mock_response(mock_response_data) + + config_base_url = "https://config-base-url.example.com" + os.environ["EDGEE_BASE_URL"] = "https://env-base-url.example.com" + client = Edgee({"api_key": "test-key", "base_url": config_base_url}) + client.send(model="gpt-4", input="Test") + + call_args = mock_urlopen.call_args[0][0] + assert call_args.full_url == f"{config_base_url}/v1/chat/completions" + From 79a332e96676290dc1a9ed6d00a7c776043fb31b Mon Sep 17 00:00:00 2001 From: Nicolas Girardot Date: Mon, 5 Jan 2026 16:04:53 +0100 Subject: [PATCH 02/10] feat: add contributors --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..541175b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @edgee-cloud/edgeers From 392e473dfe967ff711fad053051bb0534d449d91 Mon Sep 17 00:00:00 2001 From: SachaMorard <2254275+SachaMorard@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:43:51 +0100 Subject: [PATCH 03/10] little change --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cf7873e..935d477 100644 --- a/README.md +++ b/README.md @@ -91,3 +91,5 @@ class Usage: completion_tokens: int total_tokens: int ``` + +To learn more about this SDK, please refer to the [dedicated documentation](https://www.edgee.cloud/docs/sdk/python). \ No newline at end of file From ac7db186e0f01db9e8210084b474a67f18e6ab96 Mon Sep 17 00:00:00 2001 From: SachaMorard <2254275+SachaMorard@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:47:21 +0100 Subject: [PATCH 04/10] check --- .github/workflows/check.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/check.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..f5dd5f5 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,21 @@ +name: Check +on: + push: + branches: + - main + pull_request: + +jobs: + Check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install + run: pip install -r requirements.txt + - name: Lint + run: pylint edgee + - name: Test + run: python -m unittest discover -s tests -p 'test_*.py' \ No newline at end of file From 22c1d863dfe6cdb6e219e073b992b7dd56fa2104 Mon Sep 17 00:00:00 2001 From: SachaMorard <2254275+SachaMorard@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:49:02 +0100 Subject: [PATCH 05/10] fix: check --- .github/workflows/check.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f5dd5f5..89b485f 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -13,9 +13,9 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Install - run: pip install -r requirements.txt - - name: Lint - run: pylint edgee - - name: Test - run: python -m unittest discover -s tests -p 'test_*.py' \ No newline at end of file + # - name: Install + # run: pip install -r requirements.txt + # - name: Lint + # run: pylint edgee + # - name: Test + # run: python -m unittest discover -s tests -p 'test_*.py' \ No newline at end of file From 480270f0e8c789f71c69f2f7b4afe9880691dc50 Mon Sep 17 00:00:00 2001 From: Clement Bouvet Date: Wed, 7 Jan 2026 14:18:59 +0100 Subject: [PATCH 06/10] pythonize --- .github/workflows/check.yml | 21 ++-- edgee/__init__.py | 48 ++++----- example/test.py | 1 - pyproject.toml | 30 +++++- tests/test_edgee.py | 9 +- uv.lock | 192 ++++++++++++++++++++++++++++++++++++ 6 files changed, 264 insertions(+), 37 deletions(-) create mode 100644 uv.lock diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 89b485f..20a8d7c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -10,12 +10,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - python-version: "3.12" - # - name: Install - # run: pip install -r requirements.txt - # - name: Lint - # run: pylint edgee - # - name: Test - # run: python -m unittest discover -s tests -p 'test_*.py' \ No newline at end of file + enable-cache: true + - name: Set up Python + run: uv python install 3.12 + - name: Install dependencies + run: uv sync --all-extras + - name: Ruff format check + run: uv run ruff format --check . + - name: Ruff lint + run: uv run ruff check . + - name: Run tests + run: uv run pytest \ No newline at end of file diff --git a/edgee/__init__.py b/edgee/__init__.py index 3f15f94..0ea6258 100644 --- a/edgee/__init__.py +++ b/edgee/__init__.py @@ -1,18 +1,21 @@ """Edgee Gateway SDK for Python""" -import os import json -from typing import Optional, Union +import os from dataclasses import dataclass -from urllib.request import Request, urlopen from urllib.error import HTTPError +from urllib.request import Request, urlopen + +# API Configuration +DEFAULT_BASE_URL = "https://api.edgee.ai" +API_ENDPOINT = "/v1/chat/completions" @dataclass class FunctionDefinition: name: str - description: Optional[str] = None - parameters: Optional[dict] = None + description: str | None = None + parameters: dict | None = None @dataclass @@ -31,24 +34,24 @@ class ToolCall: @dataclass class Message: role: str # "system" | "user" | "assistant" | "tool" - content: Optional[str] = None - name: Optional[str] = None - tool_calls: Optional[list[ToolCall]] = None - tool_call_id: Optional[str] = None + content: str | None = None + name: str | None = None + tool_calls: list[ToolCall] | None = None + tool_call_id: str | None = None @dataclass class InputObject: messages: list[dict] - tools: Optional[list[dict]] = None - tool_choice: Optional[Union[str, dict]] = None + tools: list[dict] | None = None + tool_choice: str | dict | None = None @dataclass class Choice: index: int message: dict - finish_reason: Optional[str] + finish_reason: str | None @dataclass @@ -61,43 +64,44 @@ class Usage: @dataclass class SendResponse: choices: list[Choice] - usage: Optional[Usage] = None + usage: Usage | None = None @dataclass class EdgeeConfig: - api_key: Optional[str] = None - base_url: Optional[str] = None + api_key: str | None = None + base_url: str | None = None class Edgee: def __init__( self, - config: Optional[Union[str, EdgeeConfig, dict]] = None, + config: str | EdgeeConfig | dict | None = None, ): - api_key: Optional[str] = None - base_url: Optional[str] = None - if isinstance(config, str): # Backward compatibility: accept api_key as string api_key = config + base_url = None elif isinstance(config, EdgeeConfig): api_key = config.api_key base_url = config.base_url elif isinstance(config, dict): api_key = config.get("api_key") base_url = config.get("base_url") + else: + api_key = None + base_url = None self.api_key = api_key or os.environ.get("EDGEE_API_KEY", "") if not self.api_key: raise ValueError("EDGEE_API_KEY is not set") - self.base_url = base_url or os.environ.get("EDGEE_BASE_URL", "https://api.edgee.ai") + self.base_url = base_url or os.environ.get("EDGEE_BASE_URL", DEFAULT_BASE_URL) def send( self, model: str, - input: Union[str, InputObject, dict], + input: str | InputObject | dict, ) -> SendResponse: """Send a completion request to the Edgee AI Gateway.""" @@ -121,7 +125,7 @@ def send( body["tool_choice"] = tool_choice request = Request( - f"{self.base_url}/v1/chat/completions", + f"{self.base_url}{API_ENDPOINT}", data=json.dumps(body).encode("utf-8"), headers={ "Content-Type": "application/json", diff --git a/example/test.py b/example/test.py index f009972..caa6fbf 100644 --- a/example/test.py +++ b/example/test.py @@ -61,4 +61,3 @@ ) print(f"Content: {response3.choices[0].message.get('content')}") print(f"Tool calls: {response3.choices[0].message.get('tool_calls')}") - diff --git a/pyproject.toml b/pyproject.toml index d6ed460..94b6ff0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ Homepage = "https://github.com/edgee-cloud/python-sdk" Repository = "https://github.com/edgee-cloud/python-sdk" [project.optional-dependencies] -dev = ["pytest>=8.0.0"] +dev = ["pytest>=8.0.0", "ruff>=0.8.0"] [build-system] requires = ["hatchling"] @@ -34,3 +34,31 @@ build-backend = "hatchling.build" [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "ruff>=0.8.0", +] diff --git a/tests/test_edgee.py b/tests/test_edgee.py index 09455ba..048a6b7 100644 --- a/tests/test_edgee.py +++ b/tests/test_edgee.py @@ -2,11 +2,11 @@ import json import os +from unittest.mock import MagicMock, patch + import pytest -from unittest.mock import patch, MagicMock -from io import BytesIO -from edgee import Edgee, EdgeeConfig, SendResponse, Choice, Usage +from edgee import Edgee, EdgeeConfig class TestEdgeeConstructor: @@ -122,7 +122,7 @@ def test_send_with_input_object(self, mock_urlopen): mock_urlopen.return_value = self._mock_response(mock_response_data) client = Edgee("test-api-key") - result = client.send( + client.send( model="gpt-4", input={ "messages": [ @@ -306,4 +306,3 @@ def test_config_base_url_overrides_env(self, mock_urlopen): call_args = mock_urlopen.call_args[0][0] assert call_args.full_url == f"{config_base_url}/v1/chat/completions" - diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ccc443e --- /dev/null +++ b/uv.lock @@ -0,0 +1,192 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "edgee" +version = "0.1.1" +source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0.0" }, + { name = "ruff", specifier = ">=0.8.0" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] From b9b3384de29be3d5a3ffc8b59c9a4bee1ff4f342 Mon Sep 17 00:00:00 2001 From: Clement Bouvet Date: Wed, 7 Jan 2026 14:30:45 +0100 Subject: [PATCH 07/10] add stream --- README.md | 48 +++++++++++++++++++++ edgee/__init__.py | 103 ++++++++++++++++++++++++++++++++++++++++++++++ example/test.py | 81 +++++++++++++++++++++++++----------- 3 files changed, 207 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 935d477..a424394 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,34 @@ if response.choices[0].message.get("tool_calls"): print(response.choices[0].message["tool_calls"]) ``` +### Streaming + +```python +for chunk in edgee.stream( + model="gpt-4o", + input="Tell me a story", +): + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="", flush=True) +``` + +### Streaming with Messages + +```python +for chunk in edgee.stream( + model="gpt-4o", + input={ + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello!"}, + ], + }, +): + delta = chunk.choices[0].delta + if delta.content: + print(delta.content, end="", flush=True) +``` + ## Response ```python @@ -92,4 +120,24 @@ class Usage: total_tokens: int ``` +### Streaming Response + +```python +@dataclass +class StreamChunk: + choices: list[StreamChoice] + +@dataclass +class StreamChoice: + index: int + delta: StreamDelta + finish_reason: str | None + +@dataclass +class StreamDelta: + role: str | None # Only present in first chunk + content: str | None + tool_calls: list[dict] | None +``` + To learn more about this SDK, please refer to the [dedicated documentation](https://www.edgee.cloud/docs/sdk/python). \ No newline at end of file diff --git a/edgee/__init__.py b/edgee/__init__.py index 0ea6258..6a9dfd2 100644 --- a/edgee/__init__.py +++ b/edgee/__init__.py @@ -67,6 +67,25 @@ class SendResponse: usage: Usage | None = None +@dataclass +class StreamDelta: + role: str | None = None + content: str | None = None + tool_calls: list[dict] | None = None + + +@dataclass +class StreamChoice: + index: int + delta: StreamDelta + finish_reason: str | None = None + + +@dataclass +class StreamChunk: + choices: list[StreamChoice] + + @dataclass class EdgeeConfig: api_key: str | None = None @@ -159,3 +178,87 @@ def send( ) return SendResponse(choices=choices, usage=usage) + + def stream( + self, + model: str, + input: str | InputObject | dict, + ): + """Stream a completion request from the Edgee AI Gateway. + + Yields StreamChunk objects as they arrive from the API. + """ + + if isinstance(input, str): + messages = [{"role": "user", "content": input}] + tools = None + tool_choice = None + elif isinstance(input, InputObject): + messages = input.messages + tools = input.tools + tool_choice = input.tool_choice + else: + messages = input.get("messages", []) + tools = input.get("tools") + tool_choice = input.get("tool_choice") + + body: dict = {"model": model, "messages": messages, "stream": True} + if tools: + body["tools"] = tools + if tool_choice: + body["tool_choice"] = tool_choice + + request = Request( + f"{self.base_url}{API_ENDPOINT}", + data=json.dumps(body).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + }, + method="POST", + ) + + try: + with urlopen(request) as response: + # Read and parse SSE stream + buffer = "" + for line in response: + decoded_line = line.decode("utf-8") + + if decoded_line.strip() == "": + continue + + if decoded_line.startswith("data: "): + data_str = decoded_line[6:].strip() + + # Check for stream end signal + if data_str == "[DONE]": + break + + try: + data = json.loads(data_str) + + # Parse choices + choices = [] + for c in data.get("choices", []): + delta_data = c.get("delta", {}) + delta = StreamDelta( + role=delta_data.get("role"), + content=delta_data.get("content"), + tool_calls=delta_data.get("tool_calls"), + ) + choice = StreamChoice( + index=c["index"], + delta=delta, + finish_reason=c.get("finish_reason"), + ) + choices.append(choice) + + yield StreamChunk(choices=choices) + except json.JSONDecodeError: + # Skip malformed JSON + continue + + except HTTPError as e: + error_body = e.read().decode("utf-8") + raise RuntimeError(f"API error {e.code}: {error_body}") from e diff --git a/example/test.py b/example/test.py index caa6fbf..de30e66 100644 --- a/example/test.py +++ b/example/test.py @@ -13,7 +13,7 @@ # Test 1: Simple string input print("Test 1: Simple string input") response1 = edgee.send( - model="gpt-4o", + model="mistral/mistral-small-latest", input="What is the capital of France?", ) print(f"Content: {response1.choices[0].message['content']}") @@ -23,7 +23,7 @@ # Test 2: Full input object with messages print("Test 2: Full input object with messages") response2 = edgee.send( - model="gpt-4o", + model="mistral/mistral-small-latest", input={ "messages": [ {"role": "system", "content": "You are a helpful assistant."}, @@ -35,29 +35,60 @@ print() # Test 3: With tools -print("Test 3: With tools") -response3 = edgee.send( - model="gpt-4o", +#print("Test 3: With tools") +#response3 = edgee.send( +# model="gpt-4o", +# input={ +# "messages": [{"role": "user", "content": "What is the weather in Paris?"}], +# "tools": [ +# { +# "type": "function", +# "function": { +# "name": "get_weather", +# "description": "Get the current weather for a location", +# "parameters": { +# "type": "object", +# "properties": { +# "location": {"type": "string", "description": "City name"}, +# }, +# "required": ["location"], +# }, +# }, +# }, +# ], +# "tool_choice": "auto", +# }, +#) +#print(f"Content: {response3.choices[0].message.get('content')}") +#print(f"Tool calls: {response3.choices[0].message.get('tool_calls')}") +#print() + +# Test 4: Streaming +print("Test 4: Streaming") +for chunk in edgee.stream( + model="mistral/mistral-small-latest", + input="Tell me a short story about a robot", +): + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="", flush=True) +print("\n") + +# Test 5: Streaming with messages +print("Test 5: Streaming with system message") +for chunk in edgee.stream( + model="mistral/mistral-small-latest", input={ - "messages": [{"role": "user", "content": "What is the weather in Paris?"}], - "tools": [ - { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the current weather for a location", - "parameters": { - "type": "object", - "properties": { - "location": {"type": "string", "description": "City name"}, - }, - "required": ["location"], - }, - }, - }, + "messages": [ + {"role": "system", "content": "You are a poetic assistant. Respond in rhyme."}, + {"role": "user", "content": "What is Python?"}, ], - "tool_choice": "auto", }, -) -print(f"Content: {response3.choices[0].message.get('content')}") -print(f"Tool calls: {response3.choices[0].message.get('tool_calls')}") +): + delta = chunk.choices[0].delta + if delta.role: + print(f"[Role: {delta.role}]") + if delta.content: + print(delta.content, end="", flush=True) + if chunk.choices[0].finish_reason: + print(f"\n[Finished: {chunk.choices[0].finish_reason}]") +print() From 3a784f3b94e36428658b1e88307165cd515e0cfd Mon Sep 17 00:00:00 2001 From: Clement Bouvet Date: Wed, 7 Jan 2026 16:38:43 +0100 Subject: [PATCH 08/10] streaming update --- README.md | 19 +++++++++++- edgee/__init__.py | 78 ++++++++++++++++++++++------------------------- example/test.py | 7 +++-- 3 files changed, 58 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index a424394..6485c97 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,22 @@ if response.choices[0].message.get("tool_calls"): ### Streaming +You can enable streaming by setting `stream=True` in the `send()` method, or by using the convenience `stream()` method. + +#### Using send(stream=True) + +```python +for chunk in edgee.send( + model="gpt-4o", + input="Tell me a story", + stream=True, +): + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="", flush=True) +``` + +#### Using stream() method + ```python for chunk in edgee.stream( model="gpt-4o", @@ -85,7 +101,7 @@ for chunk in edgee.stream( ### Streaming with Messages ```python -for chunk in edgee.stream( +for chunk in edgee.send( model="gpt-4o", input={ "messages": [ @@ -93,6 +109,7 @@ for chunk in edgee.stream( {"role": "user", "content": "Hello!"}, ], }, + stream=True, ): delta = chunk.choices[0].delta if delta.content: diff --git a/edgee/__init__.py b/edgee/__init__.py index 6a9dfd2..2e264fb 100644 --- a/edgee/__init__.py +++ b/edgee/__init__.py @@ -121,8 +121,19 @@ def send( self, model: str, input: str | InputObject | dict, - ) -> SendResponse: - """Send a completion request to the Edgee AI Gateway.""" + stream: bool = False, + ): + """Send a completion request to the Edgee AI Gateway. + + Args: + model: The model to use for completion + input: The input (string, dict, or InputObject) + stream: If True, returns a generator yielding StreamChunk objects. + If False, returns a SendResponse object. + + Returns: + SendResponse if stream=False, or a generator yielding StreamChunk objects if stream=True. + """ if isinstance(input, str): messages = [{"role": "user", "content": input}] @@ -138,6 +149,8 @@ def send( tool_choice = input.get("tool_choice") body: dict = {"model": model, "messages": messages} + if stream: + body["stream"] = True if tools: body["tools"] = tools if tool_choice: @@ -153,6 +166,13 @@ def send( method="POST", ) + if stream: + return self._handle_streaming_response(request) + else: + return self._handle_non_streaming_response(request) + + def _handle_non_streaming_response(self, request: Request) -> SendResponse: + """Handle non-streaming response.""" try: with urlopen(request) as response: data = json.loads(response.read().decode("utf-8")) @@ -179,49 +199,11 @@ def send( return SendResponse(choices=choices, usage=usage) - def stream( - self, - model: str, - input: str | InputObject | dict, - ): - """Stream a completion request from the Edgee AI Gateway. - - Yields StreamChunk objects as they arrive from the API. - """ - - if isinstance(input, str): - messages = [{"role": "user", "content": input}] - tools = None - tool_choice = None - elif isinstance(input, InputObject): - messages = input.messages - tools = input.tools - tool_choice = input.tool_choice - else: - messages = input.get("messages", []) - tools = input.get("tools") - tool_choice = input.get("tool_choice") - - body: dict = {"model": model, "messages": messages, "stream": True} - if tools: - body["tools"] = tools - if tool_choice: - body["tool_choice"] = tool_choice - - request = Request( - f"{self.base_url}{API_ENDPOINT}", - data=json.dumps(body).encode("utf-8"), - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - }, - method="POST", - ) - + def _handle_streaming_response(self, request: Request): + """Handle streaming response, yielding StreamChunk objects.""" try: with urlopen(request) as response: # Read and parse SSE stream - buffer = "" for line in response: decoded_line = line.decode("utf-8") @@ -262,3 +244,15 @@ def stream( except HTTPError as e: error_body = e.read().decode("utf-8") raise RuntimeError(f"API error {e.code}: {error_body}") from e + + def stream( + self, + model: str, + input: str | InputObject | dict, + ): + """Stream a completion request from the Edgee AI Gateway. + + Convenience method that calls send(stream=True). + Yields StreamChunk objects as they arrive from the API. + """ + return self.send(model=model, input=input, stream=True) diff --git a/example/test.py b/example/test.py index de30e66..fd1684e 100644 --- a/example/test.py +++ b/example/test.py @@ -63,11 +63,12 @@ #print(f"Tool calls: {response3.choices[0].message.get('tool_calls')}") #print() -# Test 4: Streaming -print("Test 4: Streaming") -for chunk in edgee.stream( +# Test 4: Streaming with send(stream=True) +print("Test 4: Streaming with send(stream=True)") +for chunk in edgee.send( model="mistral/mistral-small-latest", input="Tell me a short story about a robot", + stream=True, ): if chunk.choices[0].delta.content: print(chunk.choices[0].delta.content, end="", flush=True) From 66f57393e09ecb644bee96dc2e3f3b37a86aa4b4 Mon Sep 17 00:00:00 2001 From: Clement Bouvet Date: Wed, 7 Jan 2026 16:45:34 +0100 Subject: [PATCH 09/10] add helper properties --- README.md | 75 ++++++++++++++++++++---------------- edgee/__init__.py | 66 ++++++++++++++++++++++++++++++++ example/test.py | 96 ++++++++++++++++++++--------------------------- 3 files changed, 148 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 6485c97..1d9157e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ response = edgee.send( input="What is the capital of France?", ) -print(response.choices[0].message["content"]) +print(response.text) ``` ### Full Input with Messages @@ -67,53 +67,51 @@ response = edgee.send( }, ) -if response.choices[0].message.get("tool_calls"): - print(response.choices[0].message["tool_calls"]) +if response.tool_calls: + print(response.tool_calls) ``` ### Streaming -You can enable streaming by setting `stream=True` in the `send()` method, or by using the convenience `stream()` method. +#### Simple Text Streaming -#### Using send(stream=True) +The simplest way to stream text responses: ```python -for chunk in edgee.send( - model="gpt-4o", - input="Tell me a story", - stream=True, -): - if chunk.choices[0].delta.content: - print(chunk.choices[0].delta.content, end="", flush=True) +for text in edgee.stream_text(model="gpt-4o", input="Tell me a story"): + print(text, end="", flush=True) ``` -#### Using stream() method +#### Streaming with More Control + +Access chunk properties when you need more control: ```python -for chunk in edgee.stream( - model="gpt-4o", - input="Tell me a story", -): - if chunk.choices[0].delta.content: - print(chunk.choices[0].delta.content, end="", flush=True) +for chunk in edgee.stream(model="gpt-4o", input="Tell me a story"): + if chunk.text: + print(chunk.text, end="", flush=True) ``` -### Streaming with Messages +#### Alternative: Using send(stream=True) ```python -for chunk in edgee.send( - model="gpt-4o", - input={ - "messages": [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Hello!"}, - ], - }, - stream=True, -): - delta = chunk.choices[0].delta - if delta.content: - print(delta.content, end="", flush=True) +for chunk in edgee.send(model="gpt-4o", input="Tell me a story", stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) +``` + +#### Accessing Full Chunk Data + +When you need complete access to the streaming response: + +```python +for chunk in edgee.stream(model="gpt-4o", input="Hello"): + if chunk.role: + print(f"Role: {chunk.role}") + if chunk.text: + print(chunk.text, end="", flush=True) + if chunk.finish_reason: + print(f"\nFinish: {chunk.finish_reason}") ``` ## Response @@ -124,6 +122,12 @@ class SendResponse: choices: list[Choice] usage: Optional[Usage] + # Convenience properties for easy access + text: str | None # Shortcut for choices[0].message["content"] + message: dict | None # Shortcut for choices[0].message + finish_reason: str | None # Shortcut for choices[0].finish_reason + tool_calls: list | None # Shortcut for choices[0].message["tool_calls"] + @dataclass class Choice: index: int @@ -144,6 +148,11 @@ class Usage: class StreamChunk: choices: list[StreamChoice] + # Convenience properties for easy access + text: str | None # Shortcut for choices[0].delta.content + role: str | None # Shortcut for choices[0].delta.role + finish_reason: str | None # Shortcut for choices[0].finish_reason + @dataclass class StreamChoice: index: int diff --git a/edgee/__init__.py b/edgee/__init__.py index 2e264fb..eff27e2 100644 --- a/edgee/__init__.py +++ b/edgee/__init__.py @@ -66,6 +66,34 @@ class SendResponse: choices: list[Choice] usage: Usage | None = None + @property + def text(self) -> str | None: + """Convenience property to get text content from the first choice.""" + if self.choices and self.choices[0].message.get("content"): + return self.choices[0].message["content"] + return None + + @property + def message(self) -> dict | None: + """Convenience property to get the message from the first choice.""" + if self.choices: + return self.choices[0].message + return None + + @property + def finish_reason(self) -> str | None: + """Convenience property to get finish_reason from the first choice.""" + if self.choices and self.choices[0].finish_reason: + return self.choices[0].finish_reason + return None + + @property + def tool_calls(self) -> list | None: + """Convenience property to get tool_calls from the first choice.""" + if self.choices and self.choices[0].message.get("tool_calls"): + return self.choices[0].message["tool_calls"] + return None + @dataclass class StreamDelta: @@ -85,6 +113,27 @@ class StreamChoice: class StreamChunk: choices: list[StreamChoice] + @property + def text(self) -> str | None: + """Convenience property to get text content from the first choice.""" + if self.choices and self.choices[0].delta.content: + return self.choices[0].delta.content + return None + + @property + def role(self) -> str | None: + """Convenience property to get role from the first choice.""" + if self.choices and self.choices[0].delta.role: + return self.choices[0].delta.role + return None + + @property + def finish_reason(self) -> str | None: + """Convenience property to get finish_reason from the first choice.""" + if self.choices and self.choices[0].finish_reason: + return self.choices[0].finish_reason + return None + @dataclass class EdgeeConfig: @@ -256,3 +305,20 @@ def stream( Yields StreamChunk objects as they arrive from the API. """ return self.send(model=model, input=input, stream=True) + + def stream_text( + self, + model: str, + input: str | InputObject | dict, + ): + """Stream only the text content from the completion. + + Convenience method that yields only non-empty text strings, + filtering out chunks without content. + + Yields: + str: Text content from each chunk + """ + for chunk in self.stream(model=model, input=input): + if chunk.text: + yield chunk.text diff --git a/example/test.py b/example/test.py index fd1684e..38f48c4 100644 --- a/example/test.py +++ b/example/test.py @@ -16,7 +16,7 @@ model="mistral/mistral-small-latest", input="What is the capital of France?", ) -print(f"Content: {response1.choices[0].message['content']}") +print(f"Content: {response1.text}") print(f"Usage: {response1.usage}") print() @@ -31,65 +31,49 @@ ], }, ) -print(f"Content: {response2.choices[0].message['content']}") +print(f"Content: {response2.text}") print() # Test 3: With tools -#print("Test 3: With tools") -#response3 = edgee.send( -# model="gpt-4o", -# input={ -# "messages": [{"role": "user", "content": "What is the weather in Paris?"}], -# "tools": [ -# { -# "type": "function", -# "function": { -# "name": "get_weather", -# "description": "Get the current weather for a location", -# "parameters": { -# "type": "object", -# "properties": { -# "location": {"type": "string", "description": "City name"}, -# }, -# "required": ["location"], -# }, -# }, -# }, -# ], -# "tool_choice": "auto", -# }, -#) -#print(f"Content: {response3.choices[0].message.get('content')}") -#print(f"Tool calls: {response3.choices[0].message.get('tool_calls')}") -#print() - -# Test 4: Streaming with send(stream=True) -print("Test 4: Streaming with send(stream=True)") -for chunk in edgee.send( - model="mistral/mistral-small-latest", - input="Tell me a short story about a robot", - stream=True, -): - if chunk.choices[0].delta.content: - print(chunk.choices[0].delta.content, end="", flush=True) -print("\n") - -# Test 5: Streaming with messages -print("Test 5: Streaming with system message") -for chunk in edgee.stream( - model="mistral/mistral-small-latest", +print("Test 3: With tools") +response3 = edgee.send( + model="gpt-4o", input={ - "messages": [ - {"role": "system", "content": "You are a poetic assistant. Respond in rhyme."}, - {"role": "user", "content": "What is Python?"}, + "messages": [{"role": "user", "content": "What is the weather in Paris?"}], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "City name"}, + }, + "required": ["location"], + }, + }, + }, ], + "tool_choice": "auto", }, -): - delta = chunk.choices[0].delta - if delta.role: - print(f"[Role: {delta.role}]") - if delta.content: - print(delta.content, end="", flush=True) - if chunk.choices[0].finish_reason: - print(f"\n[Finished: {chunk.choices[0].finish_reason}]") +) +print(f"Content: {response3.text}") +print(f"Tool calls: {response3.tool_calls}") print() + +# Test 4: Streaming (simplest way) +print("Test 4: Streaming (simplest way)") +for text in edgee.stream_text( + model="mistral/mistral-small-latest", input="Tell me a short story about a robot" +): + print(text, end="", flush=True) +print("\n") + +# Test 5: Streaming with more control +print("Test 5: Streaming with more control") +for chunk in edgee.stream(model="mistral/mistral-small-latest", input="What is Python?"): + if chunk.text: + print(chunk.text, end="", flush=True) +print("\n") From 010dfc9c8602153cc94a0337bc0f13a151c549de Mon Sep 17 00:00:00 2001 From: Clement Bouvet Date: Fri, 9 Jan 2026 09:00:36 +0100 Subject: [PATCH 10/10] remove stream_text --- README.md | 13 +------------ edgee/__init__.py | 17 ----------------- example/test.py | 12 ++---------- 3 files changed, 3 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 1d9157e..80b77aa 100644 --- a/README.md +++ b/README.md @@ -73,18 +73,7 @@ if response.tool_calls: ### Streaming -#### Simple Text Streaming - -The simplest way to stream text responses: - -```python -for text in edgee.stream_text(model="gpt-4o", input="Tell me a story"): - print(text, end="", flush=True) -``` - -#### Streaming with More Control - -Access chunk properties when you need more control: +Access chunk properties for streaming: ```python for chunk in edgee.stream(model="gpt-4o", input="Tell me a story"): diff --git a/edgee/__init__.py b/edgee/__init__.py index eff27e2..5229fd3 100644 --- a/edgee/__init__.py +++ b/edgee/__init__.py @@ -305,20 +305,3 @@ def stream( Yields StreamChunk objects as they arrive from the API. """ return self.send(model=model, input=input, stream=True) - - def stream_text( - self, - model: str, - input: str | InputObject | dict, - ): - """Stream only the text content from the completion. - - Convenience method that yields only non-empty text strings, - filtering out chunks without content. - - Yields: - str: Text content from each chunk - """ - for chunk in self.stream(model=model, input=input): - if chunk.text: - yield chunk.text diff --git a/example/test.py b/example/test.py index 38f48c4..2351002 100644 --- a/example/test.py +++ b/example/test.py @@ -63,16 +63,8 @@ print(f"Tool calls: {response3.tool_calls}") print() -# Test 4: Streaming (simplest way) -print("Test 4: Streaming (simplest way)") -for text in edgee.stream_text( - model="mistral/mistral-small-latest", input="Tell me a short story about a robot" -): - print(text, end="", flush=True) -print("\n") - -# Test 5: Streaming with more control -print("Test 5: Streaming with more control") +# Test 4: Streaming +print("Test 4: Streaming") for chunk in edgee.stream(model="mistral/mistral-small-latest", input="What is Python?"): if chunk.text: print(chunk.text, end="", flush=True)