Skip to main content

Streaming-first LLM runtime for real-time systems

Project description

owly-ai ๐Ÿฆ‰

A lightweight, streaming-first LLM agent framework for Python.

PyPI version Python License

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(), and run_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:

  1. Fork the repository at github.com/amrawlabs/owly
  2. Create a branch for your feature or fix: git checkout -b feat/my-feature
  3. Write tests for any new behavior in tests/
  4. Keep it minimal โ€” owly-ai's core principle is zero unnecessary abstraction
  5. 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 ProviderChunk with text= for text and tool_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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

owly_ai-0.1.0.tar.gz (21.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

owly_ai-0.1.0-py3-none-any.whl (27.1 kB view details)

Uploaded Python 3

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

Hashes for owly_ai-0.1.0.tar.gz
Algorithm Hash digest
SHA256 d63c44c5c6f8ca7ec365180f6ebe8e9a0c0a5dceae0bf0b1dc6786914e839639
MD5 b6b43990af90825ef8f43dd295280c51
BLAKE2b-256 efe5ed5a44809db5c6150bcc59738bb0f92a528131f9a512e67db763b377b877

See more details on using hashes here.

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

Hashes for owly_ai-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ac24527d76f89901bc8bc6a3731c332742c6082596ca48539d02c1bc6a914f13
MD5 4ef1da93d343fe4431dab47ce52094b2
BLAKE2b-256 cafe0e2bbfa11b2474be5277dda9f4cf3d3f51d42ba4ce34a3091d0788979508

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page