diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..13b3197 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with dev dependencies + run: pip install -e ".[dev]" + + - name: Run tests + run: pytest tests/ -v + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install build tools + run: pip install build + + - name: Build package + run: python -m build + + - name: Verify wheel contents + run: | + pip install dist/*.whl + python -c "from c3_invoke import get_provider, list_providers; print('Import OK')" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..8908fa3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,47 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with dev dependencies + run: pip install -e ".[dev]" + + - name: Run tests + run: pytest tests/ -v + + publish: + needs: test + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install build tools + 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/.github/workflows/test-publish.yml b/.github/workflows/test-publish.yml new file mode 100644 index 0000000..eaacf85 --- /dev/null +++ b/.github/workflows/test-publish.yml @@ -0,0 +1,87 @@ +name: Test Publish to TestPyPI + +on: + workflow_dispatch: + inputs: + version_suffix: + description: "Version suffix (e.g. dev1, rc1). Leave empty to use pyproject.toml version as-is." + required: false + default: "" + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with dev dependencies + run: pip install -e ".[dev]" + + - name: Run tests + run: pytest tests/ -v + + build: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install build tools + run: pip install build + + - name: Append version suffix + if: ${{ inputs.version_suffix != '' }} + run: | + sed -i "s/^version = \"\\(.*\\)\"/version = \"\\1.${{ inputs.version_suffix }}\"/" pyproject.toml + echo "Updated version to:" + grep '^version' pyproject.toml + + - name: Build package + run: python -m build + + - name: Verify wheel contents + run: | + pip install dist/*.whl + python -c "from c3_invoke import get_provider, list_providers; print('Import OK')" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + - name: Verify installation from TestPyPI + run: | + sleep 15 + pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ c3-invoke + python -c "from c3_invoke import get_provider, list_providers; print('TestPyPI install OK')" diff --git a/README.md b/README.md index 21bfdc4..2a66ba7 100644 --- a/README.md +++ b/README.md @@ -1 +1,110 @@ -# c3-invoke \ No newline at end of file +# C3 Invoke + +Unified interface to AI CLIs (Gemini, Claude, Codex) from Python or HTTP. + +## Installation + +```bash +pip install c3-invoke # Core library (zero dependencies) +pip install c3-invoke[server] # With FastAPI HTTP server +``` + +## Quick Start + +```python +from c3_invoke import GeminiProvider, ClaudeProvider, parse_json_output +from c3_invoke.types import PromptRequest + +# Call any AI CLI with a unified interface +gemini = GeminiProvider() +response = gemini.run(PromptRequest(prompt="Explain Python decorators")) +print(response.raw_output) + +# Parse JSON from CLI output (handles markdown fences, embedded JSON) +data = parse_json_output(response.raw_output) + +# Run multiple prompts in parallel +from c3_invoke import run_batch +responses = run_batch(gemini, [ + PromptRequest(prompt="Explain decorators"), + PromptRequest(prompt="Explain generators"), +], max_workers=4) +``` + +## Providers + +| Provider | CLI Binary | Status | +|----------|-----------|--------| +| `GeminiProvider` | `gemini` | Fully implemented | +| `ClaudeProvider` | `claude` | Fully implemented | +| `CodexProvider` | `codex` | Fully implemented | + +```python +from c3_invoke import get_provider, list_providers + +# Get a specific provider +provider = get_provider("claude") + +# List all providers with availability status +for info in list_providers(): + print(f"{info.display_name}: {'available' if info.available else 'not found'}") +``` + +## HTTP Server + +```bash +pip install c3-invoke[server] +c3-invoke-server # Starts on port 8766 +``` + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Server status + available providers | +| `/providers` | GET | List all providers with availability | +| `/providers/{name}/test` | GET | Test specific provider | +| `/prompt` | POST | Send a single prompt to a provider | +| `/batch` | POST | Send multiple prompts in parallel | + +### Example + +```bash +# Single prompt +curl -X POST http://localhost:8766/prompt \ + -H "Content-Type: application/json" \ + -d '{"provider": "gemini", "prompt": "What is Python?"}' + +# Batch prompts +curl -X POST http://localhost:8766/batch \ + -H "Content-Type: application/json" \ + -d '{"provider": "gemini", "prompts": [{"prompt": "Q1"}, {"prompt": "Q2"}]}' +``` + +## Development + +```bash +pip install -e ".[dev]" +pytest tests/ -v +``` + +## Architecture + +``` +src/c3_invoke/ +├── __init__.py # Public API: get_provider(), list_providers() +├── types.py # PromptRequest, PromptResponse, ProviderInfo +├── output.py # parse_json_output() +├── pool.py # run_batch() — parallel execution +├── providers/ +│ ├── base.py # BaseProvider ABC +│ ├── gemini.py # GeminiProvider +│ ├── claude.py # ClaudeProvider +│ └── codex.py # CodexProvider +└── server/ # Optional FastAPI server + ├── app.py + ├── main.py + └── routers/ +``` + +## Part of C3 Ecosystem + +C3 Invoke is a standalone package in the [C3 organization](https://github.com/C3) ecosystem. See the [C3 Architecture Overview](https://github.com/C3/job-compass/blob/main/docs/C3-ORGANIZATION.md) for how it fits with Job Compass and C3 CLI Desktop. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..090b151 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "c3-invoke" +version = "0.1.0" +description = "Unified interface to AI CLIs (Gemini, Claude, Codex) from Python or HTTP" +requires-python = ">=3.11" +license = "MIT" +readme = "README.md" +authors = [{ name = "CodeCanvas Collective", email = "hello@codecanvascollective.com" }] +keywords = ["ai", "cli", "gemini", "claude", "codex", "llm"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [] + +[project.urls] +Homepage = "https://github.com/CodeCanvasCollective/c3-invoke" +Repository = "https://github.com/CodeCanvasCollective/c3-invoke" +Issues = "https://github.com/CodeCanvasCollective/c3-invoke/issues" + +[project.optional-dependencies] +server = ["fastapi>=0.115.0", "uvicorn[standard]>=0.30.0"] +dev = ["pytest>=8.0.0", "pytest-asyncio>=0.24.0", "httpx>=0.27.0"] +all = ["c3-invoke[server,dev]"] + +[project.scripts] +c3-invoke-server = "c3_invoke.server.main:main" + +[project.entry-points."c3.products"] +c3-invoke = "c3_invoke.server:product_info" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/c3_invoke"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/c3_invoke/__init__.py b/src/c3_invoke/__init__.py new file mode 100644 index 0000000..8eb4710 --- /dev/null +++ b/src/c3_invoke/__init__.py @@ -0,0 +1,45 @@ +"""C3 Invoke — Unified interface to AI CLIs (Gemini, Claude, Codex).""" + +from __future__ import annotations + +from .output import parse_json_output +from .pool import run_batch +from .providers.base import BaseProvider +from .providers.claude import ClaudeProvider +from .providers.codex import CodexProvider +from .providers.gemini import GeminiProvider +from .types import OutputFormat, PromptRequest, PromptResponse, ProviderInfo + +__all__ = [ + "BaseProvider", + "ClaudeProvider", + "CodexProvider", + "GeminiProvider", + "OutputFormat", + "PromptRequest", + "PromptResponse", + "ProviderInfo", + "get_provider", + "list_providers", + "parse_json_output", + "run_batch", +] + +_PROVIDERS: dict[str, BaseProvider] = { + "gemini": GeminiProvider(), + "claude": ClaudeProvider(), + "codex": CodexProvider(), +} + + +def get_provider(name: str) -> BaseProvider: + """Get a provider instance by name.""" + try: + return _PROVIDERS[name.lower()] + except KeyError: + raise KeyError(f"Unknown provider: {name!r}. Available: {list(_PROVIDERS)}") + + +def list_providers() -> list[ProviderInfo]: + """List all registered providers with their availability status.""" + return [p.info() for p in _PROVIDERS.values()] diff --git a/src/c3_invoke/output.py b/src/c3_invoke/output.py new file mode 100644 index 0000000..13887d8 --- /dev/null +++ b/src/c3_invoke/output.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import json +from typing import Any + + +def parse_json_output(output: str) -> Any: + """Parse JSON from AI CLI output, handling markdown fences and embedded JSON.""" + text = (output or "").strip() + if not text: + raise ValueError("CLI returned empty output.") + + if text.startswith("```json"): + text = text[7:].strip() + if text.endswith("```"): + text = text[:-3].strip() + elif text.startswith("```"): + text = text[3:].strip() + if text.endswith("```"): + text = text[:-3].strip() + + try: + return json.loads(text) + except json.JSONDecodeError: + decoder = json.JSONDecoder() + for index, char in enumerate(text): + if char not in "[{": + continue + try: + parsed, _ = decoder.raw_decode(text[index:]) + return parsed + except json.JSONDecodeError: + continue + + preview = text[:200].replace("\n", " ") + raise ValueError(f"CLI did not return valid JSON. Output preview: {preview}") diff --git a/src/c3_invoke/pool.py b/src/c3_invoke/pool.py new file mode 100644 index 0000000..4206484 --- /dev/null +++ b/src/c3_invoke/pool.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + from .providers.base import BaseProvider + from .types import PromptRequest, PromptResponse + + +def run_batch( + provider: BaseProvider, + requests: Sequence[PromptRequest], + max_workers: int = 4, +) -> list[PromptResponse]: + """Run multiple prompts in parallel using a thread pool.""" + if not requests: + return [] + + if len(requests) == 1: + try: + return [provider.run(requests[0])] + except Exception as exc: + from .types import PromptResponse as PR + + return [PR( + raw_output="", + provider=provider.info().name, + is_error=True, + error_message=str(exc), + )] + + results: list[tuple[int, PromptResponse]] = [] + with ThreadPoolExecutor(max_workers=max_workers) as pool: + futures = { + pool.submit(provider.run, req): idx + for idx, req in enumerate(requests) + } + for future in as_completed(futures): + idx = futures[future] + try: + response = future.result() + except Exception as exc: + from .types import PromptResponse as PR + + response = PR( + raw_output="", + provider=provider.info().name, + is_error=True, + error_message=str(exc), + ) + results.append((idx, response)) + + results.sort(key=lambda item: item[0]) + return [resp for _, resp in results] diff --git a/src/c3_invoke/providers/__init__.py b/src/c3_invoke/providers/__init__.py new file mode 100644 index 0000000..530ef03 --- /dev/null +++ b/src/c3_invoke/providers/__init__.py @@ -0,0 +1,6 @@ +from .base import BaseProvider +from .claude import ClaudeProvider +from .codex import CodexProvider +from .gemini import GeminiProvider + +__all__ = ["BaseProvider", "ClaudeProvider", "CodexProvider", "GeminiProvider"] diff --git a/src/c3_invoke/providers/base.py b/src/c3_invoke/providers/base.py new file mode 100644 index 0000000..b49fe25 --- /dev/null +++ b/src/c3_invoke/providers/base.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import shutil +import subprocess +import time +from abc import ABC, abstractmethod + +from ..types import PromptRequest, PromptResponse, ProviderInfo + + +class BaseProvider(ABC): + """Abstract base class for AI CLI providers.""" + + @abstractmethod + def info(self) -> ProviderInfo: + """Return provider metadata (name, binary path, availability).""" + + @abstractmethod + def build_command(self, request: PromptRequest) -> list[str]: + """Build the CLI command arguments for the given request.""" + + def is_available(self) -> bool: + """Check if the CLI binary is installed and accessible.""" + return self.info().available + + def run(self, request: PromptRequest) -> PromptResponse: + """Execute a prompt via the CLI subprocess.""" + info = self.info() + if not info.available: + return PromptResponse( + raw_output="", + provider=info.name, + is_error=True, + error_message=f"{info.display_name} not found in PATH.", + ) + + command = self.build_command(request) + start = time.monotonic() + try: + result = self._execute(command, request) + elapsed = time.monotonic() - start + output = (result.stdout or "").strip() + return PromptResponse( + raw_output=output, + provider=info.name, + elapsed_seconds=round(elapsed, 3), + ) + except subprocess.CalledProcessError as exc: + elapsed = time.monotonic() - start + fallback = self._handle_fallback(exc, request) + if fallback is not None: + total_elapsed = time.monotonic() - start + return PromptResponse( + raw_output=(fallback.stdout or "").strip(), + provider=info.name, + elapsed_seconds=round(total_elapsed, 3), + ) + stderr = (exc.stderr or "").strip() + error_msg = stderr[:300] if stderr else f"CLI exited with code {exc.returncode}." + return PromptResponse( + raw_output="", + provider=info.name, + elapsed_seconds=round(elapsed, 3), + is_error=True, + error_message=error_msg, + ) + except subprocess.TimeoutExpired: + elapsed = time.monotonic() - start + return PromptResponse( + raw_output="", + provider=info.name, + elapsed_seconds=round(elapsed, 3), + is_error=True, + error_message=f"CLI timed out after {request.timeout}s.", + ) + except Exception as exc: + elapsed = time.monotonic() - start + return PromptResponse( + raw_output="", + provider=info.name, + elapsed_seconds=round(elapsed, 3), + is_error=True, + error_message=str(exc), + ) + + def _execute( + self, command: list[str], request: PromptRequest + ) -> subprocess.CompletedProcess[str]: + """Run the subprocess command.""" + return subprocess.run( + command, + input=request.prompt, + capture_output=True, + text=True, + encoding="utf-8", + check=True, + cwd=request.cwd, + timeout=request.timeout, + ) + + def _handle_fallback( + self, + exc: subprocess.CalledProcessError, + request: PromptRequest, + ) -> subprocess.CompletedProcess[str] | None: + """Override to provide fallback behavior on specific errors. Return None to propagate.""" + return None + + def _resolve_binary(self, *names: str) -> str: + """Find the first available binary from the given names.""" + for name in names: + path = shutil.which(name) + if path: + return path + return "" diff --git a/src/c3_invoke/providers/claude.py b/src/c3_invoke/providers/claude.py new file mode 100644 index 0000000..e927180 --- /dev/null +++ b/src/c3_invoke/providers/claude.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from ..types import PromptRequest, ProviderInfo +from .base import BaseProvider + + +class ClaudeProvider(BaseProvider): + """Provider for the Claude CLI (Anthropic).""" + + def info(self) -> ProviderInfo: + binary = self._resolve_binary("claude") + return ProviderInfo( + name="claude", + display_name="Claude CLI", + binary=binary, + available=bool(binary), + ) + + def build_command(self, request: PromptRequest) -> list[str]: + binary = self.info().binary + if not binary: + binary = "claude" + cmd = [binary, "--print", "--output-format", "text"] + if request.model: + cmd.extend(["--model", request.model]) + cmd.extend(request.extra_flags) + return cmd diff --git a/src/c3_invoke/providers/codex.py b/src/c3_invoke/providers/codex.py new file mode 100644 index 0000000..17e358d --- /dev/null +++ b/src/c3_invoke/providers/codex.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from ..types import PromptRequest, ProviderInfo +from .base import BaseProvider + + +class CodexProvider(BaseProvider): + """Provider for the Codex CLI (OpenAI).""" + + def info(self) -> ProviderInfo: + binary = self._resolve_binary("codex") + return ProviderInfo( + name="codex", + display_name="Codex CLI", + binary=binary, + available=bool(binary), + ) + + def build_command(self, request: PromptRequest) -> list[str]: + binary = self.info().binary + if not binary: + binary = "codex" + cmd = [binary, "--quiet"] + if request.model: + cmd.extend(["--model", request.model]) + cmd.extend(request.extra_flags) + return cmd diff --git a/src/c3_invoke/providers/gemini.py b/src/c3_invoke/providers/gemini.py new file mode 100644 index 0000000..0722613 --- /dev/null +++ b/src/c3_invoke/providers/gemini.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import subprocess + +from ..types import PromptRequest, ProviderInfo +from .base import BaseProvider + + +class GeminiProvider(BaseProvider): + """Provider for the Gemini CLI (Google AI).""" + + def info(self) -> ProviderInfo: + binary = self._resolve_binary("gemini") + return ProviderInfo( + name="gemini", + display_name="Gemini CLI", + binary=binary, + available=bool(binary), + ) + + def build_command(self, request: PromptRequest) -> list[str]: + binary = self.info().binary + if not binary: + binary = "gemini" + cmd = [binary, "-p", "", "-o", "text", "--allowed-mcp-server-names", ""] + cmd.extend(request.extra_flags) + return cmd + + def _handle_fallback( + self, + exc: subprocess.CalledProcessError, + request: PromptRequest, + ) -> subprocess.CompletedProcess[str] | None: + """Fall back to command without --allowed-mcp-server-names if unsupported.""" + stderr = (exc.stderr or "").lower() + if any( + token in stderr + for token in ("unknown option", "unknown argument", "not recognized") + ): + binary = self.info().binary or "gemini" + fallback_cmd = [binary, "-p", "", "-o", "text"] + fallback_cmd.extend(request.extra_flags) + return subprocess.run( + fallback_cmd, + input=request.prompt, + capture_output=True, + text=True, + encoding="utf-8", + check=True, + cwd=request.cwd, + timeout=request.timeout, + ) + return None diff --git a/src/c3_invoke/server/__init__.py b/src/c3_invoke/server/__init__.py new file mode 100644 index 0000000..f8692ec --- /dev/null +++ b/src/c3_invoke/server/__init__.py @@ -0,0 +1,7 @@ +product_info = { + "name": "C3 Invoke", + "description": "Unified interface to AI CLIs (Gemini, Claude, Codex)", + "port": 8766, + "health_endpoint": "/health", + "entry_module": "c3_invoke.server.main", +} diff --git a/src/c3_invoke/server/app.py b/src/c3_invoke/server/app.py new file mode 100644 index 0000000..8b6f92a --- /dev/null +++ b/src/c3_invoke/server/app.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from fastapi import FastAPI + +from .routers import batch, health, prompt, providers + + +def create_app() -> FastAPI: + app = FastAPI( + title="C3 Invoke", + description="Unified HTTP API for AI CLI providers", + version="0.1.0", + ) + app.include_router(health.router) + app.include_router(providers.router) + app.include_router(prompt.router) + app.include_router(batch.router) + return app diff --git a/src/c3_invoke/server/main.py b/src/c3_invoke/server/main.py new file mode 100644 index 0000000..d681463 --- /dev/null +++ b/src/c3_invoke/server/main.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import sys + + +def main() -> None: + try: + import uvicorn + except ImportError: + print("Error: uvicorn not installed. Run: pip install c3-invoke[server]", file=sys.stderr) + sys.exit(1) + + uvicorn.run( + "c3_invoke.server.app:create_app", + factory=True, + host="0.0.0.0", + port=8766, + reload=False, + ) + + +if __name__ == "__main__": + main() diff --git a/src/c3_invoke/server/routers/__init__.py b/src/c3_invoke/server/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/c3_invoke/server/routers/batch.py b/src/c3_invoke/server/routers/batch.py new file mode 100644 index 0000000..4a414ac --- /dev/null +++ b/src/c3_invoke/server/routers/batch.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import asdict + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from ... import get_provider +from ...pool import run_batch +from ...types import OutputFormat, PromptRequest + +router = APIRouter(tags=["batch"]) + + +class BatchPromptItem(BaseModel): + prompt: str + timeout: int = Field(default=90, ge=1, le=600) + output_format: str = Field(default="text") + model: str | None = Field(default=None) + extra_flags: list[str] = Field(default_factory=list) + cwd: str | None = Field(default=None) + + +class BatchBody(BaseModel): + provider: str = Field(description="Provider name: gemini, claude, or codex") + prompts: list[BatchPromptItem] = Field(min_length=1, max_length=20) + max_workers: int = Field(default=4, ge=1, le=10) + + +@router.post("/batch") +def run_batch_prompts(body: BatchBody) -> list[dict]: + try: + provider = get_provider(body.provider) + except KeyError: + raise HTTPException(status_code=404, detail=f"Unknown provider: {body.provider}") + + requests = [] + for item in body.prompts: + try: + fmt = OutputFormat(item.output_format) + except ValueError: + fmt = OutputFormat.TEXT + requests.append( + PromptRequest( + prompt=item.prompt, + timeout=item.timeout, + output_format=fmt, + model=item.model, + extra_flags=item.extra_flags, + cwd=item.cwd, + ) + ) + + responses = run_batch(provider, requests, max_workers=body.max_workers) + return [asdict(r) for r in responses] diff --git a/src/c3_invoke/server/routers/health.py b/src/c3_invoke/server/routers/health.py new file mode 100644 index 0000000..7fd90db --- /dev/null +++ b/src/c3_invoke/server/routers/health.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from ...providers import GeminiProvider, ClaudeProvider, CodexProvider + +router = APIRouter() + +_PROVIDERS = [GeminiProvider(), ClaudeProvider(), CodexProvider()] + + +@router.get("/health") +def health() -> dict: + available = [p.info().name for p in _PROVIDERS if p.is_available()] + return { + "status": "ok", + "version": "0.1.0", + "providers_available": available, + } diff --git a/src/c3_invoke/server/routers/prompt.py b/src/c3_invoke/server/routers/prompt.py new file mode 100644 index 0000000..bd0ff5a --- /dev/null +++ b/src/c3_invoke/server/routers/prompt.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import asdict + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from ... import get_provider +from ...types import OutputFormat, PromptRequest + +router = APIRouter(tags=["prompt"]) + + +class PromptBody(BaseModel): + provider: str = Field(description="Provider name: gemini, claude, or codex") + prompt: str = Field(description="The prompt text to send") + timeout: int = Field(default=90, ge=1, le=600) + output_format: str = Field(default="text") + model: str | None = Field(default=None) + extra_flags: list[str] = Field(default_factory=list) + cwd: str | None = Field(default=None) + + +@router.post("/prompt") +def run_prompt(body: PromptBody) -> dict: + try: + provider = get_provider(body.provider) + except KeyError: + raise HTTPException(status_code=404, detail=f"Unknown provider: {body.provider}") + + try: + fmt = OutputFormat(body.output_format) + except ValueError: + fmt = OutputFormat.TEXT + + request = PromptRequest( + prompt=body.prompt, + timeout=body.timeout, + output_format=fmt, + model=body.model, + extra_flags=body.extra_flags, + cwd=body.cwd, + ) + response = provider.run(request) + return asdict(response) diff --git a/src/c3_invoke/server/routers/providers.py b/src/c3_invoke/server/routers/providers.py new file mode 100644 index 0000000..5cae21c --- /dev/null +++ b/src/c3_invoke/server/routers/providers.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import asdict + +from fastapi import APIRouter, HTTPException + +from ... import get_provider, list_providers + +router = APIRouter(prefix="/providers", tags=["providers"]) + + +@router.get("") +def get_providers() -> list[dict]: + return [asdict(info) for info in list_providers()] + + +@router.get("/{name}/test") +def test_provider(name: str) -> dict: + try: + provider = get_provider(name) + except KeyError: + raise HTTPException(status_code=404, detail=f"Unknown provider: {name}") + info = provider.info() + return { + "name": info.name, + "display_name": info.display_name, + "available": info.available, + "binary": info.binary, + "version": info.version, + } diff --git a/src/c3_invoke/types.py b/src/c3_invoke/types.py new file mode 100644 index 0000000..543aab8 --- /dev/null +++ b/src/c3_invoke/types.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class OutputFormat(Enum): + TEXT = "text" + JSON = "json" + MARKDOWN = "markdown" + + +@dataclass(frozen=True) +class PromptRequest: + """A request to send a prompt to an AI CLI provider.""" + + prompt: str + timeout: int = 90 + output_format: OutputFormat = OutputFormat.TEXT + model: str | None = None + extra_flags: list[str] = field(default_factory=list) + cwd: str | None = None + + +@dataclass(frozen=True) +class PromptResponse: + """The response from an AI CLI provider.""" + + raw_output: str + parsed: Any = None + provider: str = "" + model: str = "" + elapsed_seconds: float = 0.0 + is_error: bool = False + error_message: str = "" + + +@dataclass(frozen=True) +class ProviderInfo: + """Metadata about an AI CLI provider.""" + + name: str + display_name: str + binary: str + available: bool + version: str = "" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..53425a7 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,53 @@ +"""Tests for the public API surface.""" + +from unittest.mock import patch + +from c3_invoke import ( + GeminiProvider, + ClaudeProvider, + CodexProvider, + get_provider, + list_providers, + parse_json_output, + run_batch, +) + + +def test_get_provider_gemini(): + provider = get_provider("gemini") + assert isinstance(provider, GeminiProvider) + + +def test_get_provider_claude(): + provider = get_provider("claude") + assert isinstance(provider, ClaudeProvider) + + +def test_get_provider_codex(): + provider = get_provider("codex") + assert isinstance(provider, CodexProvider) + + +def test_get_provider_case_insensitive(): + provider = get_provider("GEMINI") + assert isinstance(provider, GeminiProvider) + + +def test_get_provider_unknown(): + import pytest + with pytest.raises(KeyError, match="Unknown provider"): + get_provider("unknown") + + +def test_list_providers(): + with patch("shutil.which", return_value=None): + infos = list_providers() + assert len(infos) == 3 + names = {info.name for info in infos} + assert names == {"gemini", "claude", "codex"} + + +def test_public_exports(): + """Verify all public names are importable.""" + assert parse_json_output is not None + assert run_batch is not None diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..2737c16 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,54 @@ +"""Tests for c3_invoke.output — JSON parsing from CLI output.""" + +import pytest + +from c3_invoke.output import parse_json_output + + +class TestParseJsonOutput: + def test_plain_json_array(self): + result = parse_json_output('["Python", "TypeScript"]') + assert result == ["Python", "TypeScript"] + + def test_plain_json_object(self): + result = parse_json_output('{"score": 85}') + assert result == {"score": 85} + + def test_json_in_markdown_fence(self): + text = '```json\n["React", "Node.js"]\n```' + assert parse_json_output(text) == ["React", "Node.js"] + + def test_json_in_generic_fence(self): + text = '```\n{"key": "value"}\n```' + assert parse_json_output(text) == {"key": "value"} + + def test_embedded_json_after_preamble(self): + text = 'Here are the skills:\n["Python", "Docker"]' + assert parse_json_output(text) == ["Python", "Docker"] + + def test_embedded_object_after_preamble(self): + text = "Based on my analysis:\n{\"score\": 42, \"decision\": \"Apply\"}" + result = parse_json_output(text) + assert result["score"] == 42 + assert result["decision"] == "Apply" + + def test_empty_input_raises(self): + with pytest.raises(ValueError, match="empty"): + parse_json_output("") + + def test_none_input_raises(self): + with pytest.raises(ValueError, match="empty"): + parse_json_output(None) + + def test_no_json_raises(self): + with pytest.raises(ValueError, match="valid JSON"): + parse_json_output("This is just plain text with no JSON.") + + def test_whitespace_around_json(self): + result = parse_json_output(" \n [1, 2, 3] \n ") + assert result == [1, 2, 3] + + def test_nested_json(self): + text = '{"items": [{"name": "Python"}, {"name": "Go"}]}' + result = parse_json_output(text) + assert len(result["items"]) == 2 diff --git a/tests/test_pool.py b/tests/test_pool.py new file mode 100644 index 0000000..4cc4c69 --- /dev/null +++ b/tests/test_pool.py @@ -0,0 +1,76 @@ +"""Tests for c3_invoke.pool — parallel prompt execution.""" + +from unittest.mock import MagicMock, patch + +from c3_invoke.pool import run_batch +from c3_invoke.types import PromptRequest, PromptResponse, ProviderInfo + + +class TestRunBatch: + def _make_provider(self, responses: list[str]): + """Create a mock provider that returns predefined responses.""" + provider = MagicMock() + provider.info.return_value = ProviderInfo( + name="mock", display_name="Mock", binary="/mock", available=True + ) + + call_idx = 0 + + def mock_run(request): + nonlocal call_idx + idx = call_idx + call_idx += 1 + return PromptResponse( + raw_output=responses[idx] if idx < len(responses) else "", + provider="mock", + ) + + provider.run.side_effect = mock_run + return provider + + def test_empty_requests(self): + provider = MagicMock() + result = run_batch(provider, []) + assert result == [] + + def test_single_request(self): + provider = self._make_provider(["response 1"]) + requests = [PromptRequest(prompt="test 1")] + results = run_batch(provider, requests) + assert len(results) == 1 + assert results[0].raw_output == "response 1" + + def test_multiple_requests_preserves_order(self): + responses = ["a", "b", "c"] + provider = MagicMock() + provider.info.return_value = ProviderInfo( + name="mock", display_name="Mock", binary="/mock", available=True + ) + + # Each call returns a unique response based on prompt content + def mock_run(request): + idx = int(request.prompt.split()[-1]) + return PromptResponse(raw_output=f"response {idx}", provider="mock") + + provider.run.side_effect = mock_run + + requests = [PromptRequest(prompt=f"test {i}") for i in range(3)] + results = run_batch(provider, requests, max_workers=2) + + assert len(results) == 3 + # Results should be in original order + for i, result in enumerate(results): + assert result.raw_output == f"response {i}" + + def test_error_handling(self): + provider = MagicMock() + provider.info.return_value = ProviderInfo( + name="mock", display_name="Mock", binary="/mock", available=True + ) + provider.run.side_effect = RuntimeError("CLI crashed") + + requests = [PromptRequest(prompt="test")] + results = run_batch(provider, requests) + assert len(results) == 1 + assert results[0].is_error is True + assert "CLI crashed" in results[0].error_message diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..dd89885 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,144 @@ +"""Tests for AI CLI providers.""" + +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from c3_invoke.providers.gemini import GeminiProvider +from c3_invoke.providers.claude import ClaudeProvider +from c3_invoke.providers.codex import CodexProvider +from c3_invoke.types import PromptRequest, PromptResponse + + +class TestGeminiProvider: + def test_info_when_available(self): + provider = GeminiProvider() + with patch("shutil.which", return_value="/usr/bin/gemini"): + info = provider.info() + assert info.name == "gemini" + assert info.display_name == "Gemini CLI" + assert info.available is True + assert info.binary == "/usr/bin/gemini" + + def test_info_when_not_available(self): + provider = GeminiProvider() + with patch("shutil.which", return_value=None): + info = provider.info() + assert info.available is False + assert info.binary == "" + + def test_build_command(self): + provider = GeminiProvider() + with patch("shutil.which", return_value="/usr/bin/gemini"): + cmd = provider.build_command(PromptRequest(prompt="test")) + assert cmd[0] == "/usr/bin/gemini" + assert "-p" in cmd + assert "-o" in cmd + assert "text" in cmd + assert "--allowed-mcp-server-names" in cmd + + def test_build_command_with_extra_flags(self): + provider = GeminiProvider() + with patch("shutil.which", return_value="/usr/bin/gemini"): + cmd = provider.build_command(PromptRequest(prompt="test", extra_flags=["--debug"])) + assert "--debug" in cmd + + def test_run_success(self): + provider = GeminiProvider() + mock_result = MagicMock() + mock_result.stdout = "Hello from Gemini" + mock_result.returncode = 0 + + with patch("shutil.which", return_value="/usr/bin/gemini"), \ + patch("subprocess.run", return_value=mock_result): + response = provider.run(PromptRequest(prompt="test")) + + assert response.raw_output == "Hello from Gemini" + assert response.provider == "gemini" + assert response.is_error is False + + def test_run_not_available(self): + provider = GeminiProvider() + with patch("shutil.which", return_value=None): + response = provider.run(PromptRequest(prompt="test")) + + assert response.is_error is True + assert "not found" in response.error_message + + def test_run_with_fallback(self): + provider = GeminiProvider() + + # First call raises with "unknown option", second call succeeds + error = subprocess.CalledProcessError(1, "gemini") + error.stderr = "unknown option: --allowed-mcp-server-names" + + mock_fallback = MagicMock() + mock_fallback.stdout = "Fallback response" + + call_count = 0 + + def mock_run(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise error + return mock_fallback + + with patch("shutil.which", return_value="/usr/bin/gemini"), \ + patch("subprocess.run", side_effect=mock_run): + response = provider.run(PromptRequest(prompt="test")) + + assert response.raw_output == "Fallback response" + assert response.is_error is False + + def test_run_timeout(self): + provider = GeminiProvider() + + with patch("shutil.which", return_value="/usr/bin/gemini"), \ + patch("subprocess.run", side_effect=subprocess.TimeoutExpired("gemini", 90)): + response = provider.run(PromptRequest(prompt="test", timeout=90)) + + assert response.is_error is True + assert "timed out" in response.error_message + + +class TestClaudeProvider: + def test_info(self): + provider = ClaudeProvider() + with patch("shutil.which", return_value="/usr/bin/claude"): + info = provider.info() + assert info.name == "claude" + assert info.display_name == "Claude CLI" + assert info.available is True + + def test_build_command(self): + provider = ClaudeProvider() + with patch("shutil.which", return_value="/usr/bin/claude"): + cmd = provider.build_command(PromptRequest(prompt="test")) + assert cmd[0] == "/usr/bin/claude" + assert "--print" in cmd + assert "--output-format" in cmd + + def test_build_command_with_model(self): + provider = ClaudeProvider() + with patch("shutil.which", return_value="/usr/bin/claude"): + cmd = provider.build_command(PromptRequest(prompt="test", model="sonnet")) + assert "--model" in cmd + assert "sonnet" in cmd + + +class TestCodexProvider: + def test_info(self): + provider = CodexProvider() + with patch("shutil.which", return_value="/usr/bin/codex"): + info = provider.info() + assert info.name == "codex" + assert info.display_name == "Codex CLI" + + def test_build_command(self): + provider = CodexProvider() + with patch("shutil.which", return_value="/usr/bin/codex"): + cmd = provider.build_command(PromptRequest(prompt="test")) + assert cmd[0] == "/usr/bin/codex" + assert "--quiet" in cmd