Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @edgee-cloud/edgeers
26 changes: 26 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Check
on:
push:
branches:
- main
pull_request:

jobs:
Check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
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
30 changes: 30 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
161 changes: 161 additions & 0 deletions edgee/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Edgee Gateway SDK for Python"""

import json
import os
from dataclasses import dataclass
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: str | None = None
parameters: dict | None = 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: 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: list[dict] | None = None
tool_choice: str | dict | None = None


@dataclass
class Choice:
index: int
message: dict
finish_reason: str | None


@dataclass
class Usage:
prompt_tokens: int
completion_tokens: int
total_tokens: int


@dataclass
class SendResponse:
choices: list[Choice]
usage: Usage | None = None


@dataclass
class EdgeeConfig:
api_key: str | None = None
base_url: str | None = None


class Edgee:
def __init__(
self,
config: str | EdgeeConfig | dict | None = 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", DEFAULT_BASE_URL)

def send(
self,
model: str,
input: 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}{API_ENDPOINT}",
data=json.dumps(body).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
Comment thread
CLEMENTINATOR marked this conversation as resolved.
},
method="POST",
)

try:
with urlopen(request) as response:
Comment on lines +225 to +226
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reliability: Missing HTTP Timeout

The urlopen call lacks a timeout, which could cause the request to hang indefinitely if the server doesn't respond. Adding a reasonable timeout improves reliability.

Code suggestion
Check the AI-generated fix before applying
Suggested change
try:
with urlopen(request) as response:
try:
with urlopen(request, timeout=30) as response:

Code Review Run #8e8543


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

data = json.loads(response.read().decode("utf-8"))
except HTTPError as e:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error Handling: Incomplete Exception Coverage

Currently, only HTTPError is caught, but network connection failures raise URLError. Catching both ensures all request failures are handled gracefully.

Code suggestion
Check the AI-generated fix before applying
 - from urllib.error import HTTPError
 + from urllib.error import HTTPError, URLError
 @@ -136,3 +136,7 @@
 -        except HTTPError as e:
 -            error_body = e.read().decode("utf-8")
 -            raise RuntimeError(f"API error {e.code}: {error_body}") from e
 +        except (HTTPError, URLError) as e:
 +            if isinstance(e, HTTPError):
 +                error_body = e.read().decode("utf-8")
 +                raise RuntimeError(f"API error {e.code}: {error_body}") from e
 +            else:
 +                raise RuntimeError(f"Network error: {e.reason}") from e

Code Review Run #8e8543


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

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)
Empty file added edgee/py.typed
Empty file.
63 changes: 63 additions & 0 deletions example/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""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')}")
64 changes: 64 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
[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", "ruff>=0.8.0"]

[build-system]
requires = ["hatchling"]
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",
]
Empty file added tests/__init__.py
Empty file.
Loading
Loading