Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

jobs:
test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --all-groups

- name: Run tests
run: uv run pytest tests/ -v --tb=short
58 changes: 58 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Publish to PyPI

on:
release:
types: [published]

permissions:
contents: read
id-token: write # required for Trusted Publishing (OIDC)

jobs:
build:
name: Build distribution
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.12

- name: Install dependencies
run: uv sync --all-groups

- name: Run tests
run: uv run pytest tests/ -v --tb=short

- name: Build package
run: uv build

- name: Upload distribution artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/open-batch-llm

steps:
- name: Download distribution artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
65 changes: 64 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,65 @@
# open-batch-llm
A project to manage batch llm calls

A CLI tool to manage and run batch LLM calls.

## Installation

```bash
pip install open-batch-llm
```

Or with [uv](https://docs.astral.sh/uv/):

```bash
uv tool install open-batch-llm
```

## Usage

### Validate a batch request file

```bash
open-batch-llm validate requests.json
```

### Run batch requests (dry-run)

```bash
open-batch-llm run --dry-run requests.json
```

### Run batch requests

```bash
open-batch-llm run requests.json --provider openai --model gpt-4o-mini --output results.json
```

### Input file format

The input file must be a JSON array of request objects. Each object must contain a `prompt` key:

```json
[
{"id": "req-1", "prompt": "What is 2 + 2?"},
{"id": "req-2", "prompt": "Name the planets of the solar system."}
]
```

## Development

This project uses [uv](https://docs.astral.sh/uv/) as the package manager and build system.

```bash
# Install dependencies
uv sync --all-groups

# Run tests
uv run pytest

# Build the package
uv build
```

## Publishing

Releases are published to [PyPI](https://pypi.org/project/open-batch-llm/) automatically via GitHub Actions when a new GitHub Release is created. The workflow uses [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/) — no API token is needed.
33 changes: 33 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "open-batch-llm"
version = "0.1.0"
description = "A CLI tool to manage batch LLM calls"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
dependencies = [
"click>=8.0",
"httpx>=0.27",
"rich>=13.0",
]

[project.scripts]
open-batch-llm = "open_batch_llm.cli:main"

[project.urls]
Homepage = "https://github.com/dev-ankit/open-batch-llm"
Repository = "https://github.com/dev-ankit/open-batch-llm"
Issues = "https://github.com/dev-ankit/open-batch-llm/issues"

[tool.hatch.build.targets.wheel]
packages = ["src/open_batch_llm"]

[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
]
3 changes: 3 additions & 0 deletions src/open_batch_llm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""open-batch-llm: A CLI tool to manage batch LLM calls."""

__version__ = "0.1.0"
150 changes: 150 additions & 0 deletions src/open_batch_llm/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""CLI entry point for open-batch-llm."""

from __future__ import annotations

import json
import sys
from pathlib import Path

import click
from rich.console import Console
from rich.table import Table

console = Console()


@click.group()
@click.version_option()
def main() -> None:
"""open-batch-llm: Manage and run batch LLM calls."""


@main.command()
@click.argument("input_file", type=click.Path(exists=True, path_type=Path))
@click.option(
"--provider",
default="openai",
show_default=True,
help="LLM provider to use (e.g. openai, anthropic).",
)
@click.option(
"--model",
default="gpt-4o-mini",
show_default=True,
help="Model name to use for completions.",
)
@click.option(
"--output",
"-o",
type=click.Path(path_type=Path),
default=None,
help="Output file path (JSON). Defaults to stdout.",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="Validate the input file without making any API calls.",
)
def run(
input_file: Path,
provider: str,
model: str,
output: Path | None,
dry_run: bool,
) -> None:
"""Run batch LLM calls from INPUT_FILE.

INPUT_FILE must be a JSON file containing a list of request objects.
Each object should have a \"prompt\" key with the text to send.

\b
Example INPUT_FILE:
[
{"id": "req-1", "prompt": "What is 2 + 2?"},
{"id": "req-2", "prompt": "Name the planets of the solar system."}
]
"""
try:
requests = json.loads(input_file.read_text())
except json.JSONDecodeError as exc:
console.print(f"[red]Error:[/red] Invalid JSON in {input_file}: {exc}")
sys.exit(1)

if not isinstance(requests, list):
console.print("[red]Error:[/red] Input file must contain a JSON array of request objects.")
sys.exit(1)

console.print(
f"[green]Loaded[/green] {len(requests)} request(s) from [bold]{input_file}[/bold]"
)
console.print(f"Provider: [cyan]{provider}[/cyan] Model: [cyan]{model}[/cyan]")

if dry_run:
console.print("[yellow]Dry-run mode — no API calls will be made.[/yellow]")
_print_requests_table(requests)
return

console.print(
"[dim]Note: API calls are not yet implemented. "
"Use --dry-run to inspect the request list.[/dim]"
)
_print_requests_table(requests)

results = [
{"id": req.get("id", i), "prompt": req.get("prompt", ""), "response": None}
for i, req in enumerate(requests)
]

if output:
output.write_text(json.dumps(results, indent=2))
console.print(f"[green]Results written to[/green] {output}")
else:
click.echo(json.dumps(results, indent=2))


@main.command()
@click.argument("input_file", type=click.Path(exists=True, path_type=Path))
def validate(input_file: Path) -> None:
"""Validate the structure of INPUT_FILE without making any API calls."""
try:
requests = json.loads(input_file.read_text())
except json.JSONDecodeError as exc:
console.print(f"[red]Error:[/red] Invalid JSON in {input_file}: {exc}")
sys.exit(1)

if not isinstance(requests, list):
console.print("[red]Error:[/red] Input file must contain a JSON array.")
sys.exit(1)

issues: list[str] = []
for i, req in enumerate(requests):
if not isinstance(req, dict):
issues.append(f"Item {i} is not an object.")
elif "prompt" not in req:
issues.append(f"Item {i} is missing required key 'prompt'.")

if issues:
console.print("[red]Validation failed:[/red]")
for issue in issues:
console.print(f" • {issue}")
sys.exit(1)

console.print(
f"[green]✓ Valid[/green] — {len(requests)} request(s) in [bold]{input_file}[/bold]"
)


def _print_requests_table(requests: list) -> None:
table = Table(title="Batch Requests", show_lines=True)
table.add_column("#", style="dim", width=4)
table.add_column("ID")
table.add_column("Prompt")

for i, req in enumerate(requests):
prompt = str(req.get("prompt", ""))
if len(prompt) > 80:
prompt = prompt[:77] + "..."
table.add_row(str(i), str(req.get("id", i)), prompt)

console.print(table)
Empty file added tests/__init__.py
Empty file.
Loading
Loading