Streaming-first LLM runtime for real-time systems
Project description
owly-ai ๐ฆ
A lightweight, streaming-first LLM agent framework for Python.
owly-ai is a minimal Python framework for building real-time, streaming-first LLM applications. It provides an agent orchestration loop, provider-agnostic tool calling, session memory, and both async streaming and synchronous interfaces โ all without pulling in heavy dependencies.
โจ Features
- Streaming-first โ every interface yields tokens as they arrive
- Agent loop โ automatic tool detection, execution, and memory management
- Provider-agnostic tools โ define tools once, works on all providers
- OpenAI & Gemini โ built-in adapters, zero provider leakage in core logic
- Sync + Async โ
stream(),run(), andrun_sync()out of the box - Custom providers โ implement one method, plug in any LLM backend
- Custom memory โ bring your own persistent storage (Redis, SQLite, etc.)
- No heavy dependencies โ no Pydantic, no LangChain, no DAG framework
๐ฆ Installation
pip install owly-ai
For other projects, always reference the published package as owly-ai:
# pyproject.toml
dependencies = ["owly-ai"]
# requirements.txt
owly-ai
# Create a virtual environment
python -m venv .venv
# Activate it (Mac/Linux)
source .venv/bin/activate
# Install in editable mode
pip install -e .
Requirements: Python โฅ 3.11
owly-ai ships with adapters for OpenAI and Gemini. Install the providers you need:
pip install openai # for OpenAI
pip install google-generativeai # for Gemini
๐ API Keys
Set your provider key as an environment variable:
export OPENAI_API_KEY="sk-..."
export GEMINI_API_KEY="AIza..."
Or pass it directly at initialization:
llm = LLM(provider="openai", model="gpt-4o-mini", api_key="sk-...")
๐ Quickstart
Raw LLM Streaming
Stream tokens directly from any provider:
import asyncio
from owly_ai import LLM
from owly_ai.core.types import LLMRequest, Message
async def main():
llm = LLM(provider="openai", model="gpt-4o-mini")
request = LLMRequest(
messages=[Message(role="user", content="Explain async/await in Python.")],
temperature=0.3,
)
async for chunk in llm.stream(request):
print(chunk.text, end="", flush=True)
asyncio.run(main())
Synchronous API
No async boilerplate required:
from owly_ai import LLM
from owly_ai.core.types import LLMRequest, Message
llm = LLM(provider="gemini", model="gemini-1.5-flash")
msg = llm.generate_sync(LLMRequest(
messages=[Message(role="user", content="What is 2 + 2?")]
))
print(msg.content)
๐ค Agent Quickstart
Agent manages the full loop: memory โ LLM โ tool call โ result โ LLM.
import asyncio
from owly_ai import LLM, Agent, Tool
def get_weather(location: str) -> str:
"""Get the current weather for a location."""
data = {"london": "Rainy, 12ยฐC", "tokyo": "Clear, 22ยฐC"}
return data.get(location.lower(), "Sunny, 20ยฐC")
async def main():
llm = LLM(provider="openai", model="gpt-4o-mini")
agent = Agent(
llm=llm,
tools=[Tool.from_function(get_weather)],
system_prompt="You are a helpful weather assistant.",
)
# Multi-turn: memory is preserved automatically
async for chunk in agent.stream("What's the weather in Tokyo?"):
if hasattr(chunk, "text"):
print(chunk.text, end="", flush=True)
print()
async for chunk in agent.stream("And in London?"):
if hasattr(chunk, "text"):
print(chunk.text, end="", flush=True)
asyncio.run(main())
Synchronous Agent
Works from scripts, notebooks, and FastAPI handlers safely:
agent = Agent(llm=llm, tools=[Tool.from_function(get_weather)])
answer = agent.run_sync("What's the weather in Paris?")
print(answer)
๐ Defining Tools
Use Tool.from_function to wrap any Python function. owly-ai uses inspect to auto-generate the JSON schema from type hints and docstrings:
from owly_ai import Tool
def search_web(query: str, max_results: int = 5) -> str:
"""Search the web and return results."""
...
tool = Tool.from_function(search_web)
Supported types: str, int, float, bool (maps to JSON Schema types). Parameters without defaults are marked required.
Async tools are fully supported:
async def fetch_data(url: str) -> str:
"""Fetch content from a URL."""
...
agent = Agent(llm=llm, tools=[Tool.from_function(fetch_data)])
๐ง Memory
Agent uses InMemoryHistory by default. Bring your own by implementing the Memory protocol:
from owly_ai.memory import Memory
from owly_ai.core.types import Message
class RedisMemory:
def get_messages(self) -> tuple[Message, ...]:
# load from Redis
...
def add_message(self, message: Message) -> None:
# save to Redis
...
agent = Agent(llm=llm, memory=RedisMemory())
๐ Supported Providers
| Provider | String key | Default env variable |
|---|---|---|
| OpenAI | "openai" |
OPENAI_API_KEY |
| Google Gemini | "gemini" |
GEMINI_API_KEY |
Switching providers
llm = LLM(provider="gemini", model="gemini-1.5-flash")
No changes needed to agent or tool code.
๐งฉ Custom Providers
Implement BaseProvider to plug in any LLM backend:
from collections.abc import AsyncGenerator
from owly_ai.core.interfaces import BaseProvider
from owly_ai.core.types import ProviderChunk, ProviderRequest
class MyProvider(BaseProvider):
async def stream(self, request: ProviderRequest) -> AsyncGenerator[ProviderChunk, None]:
# Call your LLM API and yield ProviderChunk objects
yield ProviderChunk(text="Hello", is_final=False)
yield ProviderChunk(text=" world", is_final=False)
# Use it directly
llm = LLM(provider=MyProvider(), model="my-model")
๐ Architecture
owly_ai/
โโโ core/
โ โโโ types.py # Data contracts (Message, Chunk, ToolDefinition, ...)
โ โโโ interfaces.py # BaseProvider protocol
โ โโโ exceptions.py # ProviderError, ConfigurationError
โโโ providers/
โ โโโ openai.py # OpenAI adapter
โ โโโ gemini.py # Gemini adapter
โโโ runtime/
โ โโโ normalizer.py # Stream normalization & chunk sizing
โโโ infra/
โ โโโ config.py # StreamConfig (chunk sizing)
โโโ utils/
โ โโโ async_utils.py # Async helpers
โโโ agent.py # Agent orchestration loop
โโโ llm.py # LLM public interface
โโโ tools.py # Tool + ToolDefinition schema builder
โโโ memory.py # Memory protocol + InMemoryHistory
Data pipeline:
User prompt
โ
โผ
Agent.stream() โ appends to memory, loops on tool calls
โ
โผ
LLM.stream() โ builds ProviderRequest
โ
โผ
run_stream_pipeline() โ cancellable async wrapper
โ
โผ
ProviderAdapter.stream() โ OpenAI / Gemini / Custom
โ
โผ
normalize_stream() โ chunks buffered to target size, ToolCallChunks routed
โ
โผ
Chunk | ToolCallChunk โ yielded to caller
๐ API Reference
LLM
LLM(
provider: str | BaseProvider, # "openai", "gemini", or custom instance
model: str, # e.g. "gpt-4o-mini", "gemini-1.5-flash"
api_key: str | None = None, # overrides env variable
config: OwlyConfig | None = None,
)
| Method | Description |
|---|---|
stream(request) |
AsyncIterator[Chunk | ToolCallChunk] |
generate(request) |
async โ Message (full buffered response) |
generate_sync(request) |
Sync wrapper โ Message |
Agent
Agent(
llm: LLM,
tools: list[Tool] | None = None,
memory: Memory | None = None, # default: InMemoryHistory
system_prompt: str | None = None,
)
| Method | Description |
|---|---|
stream(prompt) |
AsyncIterator[Chunk | ToolCallChunk] |
run(prompt) |
async โ str (full buffered response) |
run_sync(prompt) |
Sync wrapper โ str |
Tool
Tool.from_function(
func: Callable,
name: str | None = None, # default: func.__name__
description: str | None = None, # default: func.__doc__
) -> Tool
Core Types
| Type | Description |
|---|---|
Message(role, content, tool_calls, tool_call_id, name) |
A single chat message |
LLMRequest(messages, temperature, max_tokens, tools, metadata) |
Request to LLM.stream |
Chunk(text, is_final) |
A streamed text chunk |
ToolCallChunk(tool_call_id, name, arguments, is_final) |
A streamed tool call event |
ToolDefinition(name, description, parameters, required) |
Provider-agnostic tool schema |
๐ Examples
# Run the weather agent (OpenAI)
OPENAI_API_KEY=sk-... python examples/agent_weather.py
# Run with Gemini
PROVIDER=gemini GEMINI_API_KEY=AIza... python examples/agent_weather.py
# Basic streaming
OPENAI_API_KEY=sk-... python examples/openai_stream.py
# Stream cancellation demo
OPENAI_API_KEY=sk-... python examples/cancel_stream.py
๐งช Running Tests
pip install pytest pytest-asyncio
pytest tests/
๐ค Contributing
Contributions are welcome! Please follow these steps:
- Fork the repository at github.com/amrawlabs/owly
- Create a branch for your feature or fix:
git checkout -b feat/my-feature - Write tests for any new behavior in
tests/ - Keep it minimal โ owly-ai's core principle is zero unnecessary abstraction
- Open a pull request with a clear description
Adding a Provider
Copy the structure of owly_ai/providers/openai.py:
- Implement
async def stream(self, request: ProviderRequest) -> AsyncGenerator[ProviderChunk, None] - Yield
ProviderChunkwithtext=for text andtool_call_id=,tool_name=,tool_arguments=for tool calls - Register it in
LLM._resolve_provider()
Design Principles
- No provider leakage โ provider-specific code lives only in
providers/ - Streaming-first โ every public interface is an async generator
- Minimal dependencies โ if it can be done with stdlib, do it with stdlib
- Typed contracts โ all data crosses module boundaries as frozen dataclasses
๐ License
Apache 2.0 โ see LICENSE.
Built with โค๏ธ by amrawlabs
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file owly_ai-0.1.0.tar.gz.
File metadata
- Download URL: owly_ai-0.1.0.tar.gz
- Upload date:
- Size: 21.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d63c44c5c6f8ca7ec365180f6ebe8e9a0c0a5dceae0bf0b1dc6786914e839639
|
|
| MD5 |
b6b43990af90825ef8f43dd295280c51
|
|
| BLAKE2b-256 |
efe5ed5a44809db5c6150bcc59738bb0f92a528131f9a512e67db763b377b877
|
File details
Details for the file owly_ai-0.1.0-py3-none-any.whl.
File metadata
- Download URL: owly_ai-0.1.0-py3-none-any.whl
- Upload date:
- Size: 27.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ac24527d76f89901bc8bc6a3731c332742c6082596ca48539d02c1bc6a914f13
|
|
| MD5 |
4ef1da93d343fe4431dab47ce52094b2
|
|
| BLAKE2b-256 |
cafe0e2bbfa11b2474be5277dda9f4cf3d3f51d42ba4ce34a3091d0788979508
|