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
12 changes: 12 additions & 0 deletions pkg/cassette/examples/py-example/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
.pytest_cache/

# Virtual environments
.venv

1 change: 1 addition & 0 deletions pkg/cassette/examples/py-example/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.14
17 changes: 17 additions & 0 deletions pkg/cassette/examples/py-example/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS build

WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project
COPY main.py ./

FROM python:3.14-slim-bookworm

WORKDIR /app
COPY --from=build /app/.venv /app/.venv
COPY --from=build /app/main.py ./
ENV PATH="/app/.venv/bin:$PATH" CASSETTE_LISTEN=0.0.0.0:9999
EXPOSE 9999
USER 65532:65532
ENTRYPOINT ["python", "main.py"]
35 changes: 35 additions & 0 deletions pkg/cassette/examples/py-example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Prompt cassette

A minimal [Tapes](https://github.com/papercomputeco/tapes) cassette that returns
a session's first captured user prompt and advertises it as an MCP tool.

## Run

```sh
docker compose up --build
curl http://127.0.0.1:9999/openapi
```

Compose starts PostgreSQL, Tapes (read API on `8081`, ingest on `8082`), and the
cassette on `9999`. Tapes loads the cassette from `http://prompt:9999/openapi`.

The operation-level `x-tapes-mcp` declaration publishes
`prompt.get_prompt` through Tapes' streamable HTTP MCP endpoint at
`http://127.0.0.1:8081/v1/mcp`.

The cassette also works directly over HTTP:

```sh
curl -X POST http://127.0.0.1:9999/api/prompt/get \
-H 'Content-Type: application/json' \
-d '{"session_id":"00000000-0000-0000-0000-000000000001"}'
```

## Local Python with `uv`

```sh
uv venv
source .venv/bin/activate
uv sync
python main.py
```
26 changes: 26 additions & 0 deletions pkg/cassette/examples/py-example/cassette.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
kind = "cassette/v1alpha1"

[cassette]
name = "prompt"
version = "0.1.0"
display_name = "Prompt"
description = "Returns the first captured user prompt for a tapes session."
license = "Apache-2.0"
homepage = "https://github.com/papercomputeco/prompt-cassette"
image = "tapes/prompt-cassette:0.1.0"
port = 9999

[depends]
core = "v1"
views = []

[api]
health = "/ping"
openapi = "/openapi"
prefix_path = "api"

[[config]]
key = "tapes_base_url"
type = "string"
default = "http://127.0.0.1:8081"
description = "Base URL of the tapes core API."
43 changes: 43 additions & 0 deletions pkg/cassette/examples/py-example/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: tapes-prompt-cassette

services:
postgres:
image: public.ecr.aws/g4e5l3z3/papercomputeco/postgres:17.7-pgduckdb-1.1.1
environment:
POSTGRES_DB: tapes
POSTGRES_USER: tapes
POSTGRES_PASSWORD: tapes
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U tapes -d tapes"]
interval: 1s
timeout: 3s
retries: 30

prompt:
build: .
environment:
CASSETTE_TAPES_BASE_URL: http://tapes:8081
ports:
- "127.0.0.1:9999:9999"

tapes:
image: public.ecr.aws/g4e5l3z3/papercomputeco/tapes:latest
command: >-
serve
--api-listen 0.0.0.0:8081
--ingest-listen 0.0.0.0:8082
--postgres postgres://tapes:tapes@postgres:5432/tapes?sslmode=disable
--cassettes http://prompt:9999/openapi
--cassette-refresh 10s
--embed-spans=false
ports:
- "127.0.0.1:8081:8081"
- "127.0.0.1:8082:8082"
depends_on:
postgres:
condition: service_healthy

volumes:
postgres-data:
201 changes: 201 additions & 0 deletions pkg/cassette/examples/py-example/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""A minimal tapes cassette that returns a session's first user prompt."""

from __future__ import annotations

import os
from typing import Any
from uuid import UUID

import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel

MANIFEST: dict[str, Any] = {
"kind": "cassette/v1alpha1",
"cassette": {
"name": "prompt",
"version": "0.1.0",
"display_name": "Prompt",
"description": "Returns the first captured user prompt for a tapes session.",
"license": "Apache-2.0",
"homepage": "https://github.com/papercomputeco/prompt-cassette",
"image": "tapes/prompt-cassette:0.1.0",
"port": 9999,
},
"depends": {"core": "v1", "views": []},
"api": {"health": "/ping", "openapi": "/openapi", "prefix_path": "api"},
"config": [
{
"key": "tapes_base_url",
"type": "string",
"default": "http://127.0.0.1:8081",
"description": "Base URL of the tapes core API.",
}
],
}


class PromptRequest(BaseModel):
session_id: UUID


def create_app(
tapes_base_url: str | None = None, client: httpx.Client | None = None
) -> FastAPI:
base_url = (
tapes_base_url
or os.getenv("CASSETTE_TAPES_BASE_URL")
or "http://127.0.0.1:8081"
).rstrip("/")
client = client or httpx.Client(timeout=10.0)
app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)

@app.get("/ping")
def ping() -> dict[str, str]:
return {"status": "ok", "cassette": "prompt"}

@app.get("/openapi")
def openapi() -> JSONResponse:
return JSONResponse(OPENAPI)

def find_prompt(session_id: str) -> dict[str, str]:
try:
response = client.get(
f"{base_url}/v1/traces", params={"session_id": session_id}
)
except httpx.HTTPError as error:
raise HTTPException(status_code=502, detail="tapes is unavailable") from error
if response.status_code == 404:
raise HTTPException(status_code=404, detail="session not found")
try:
response.raise_for_status()
items = response.json().get("items", [])
except (httpx.HTTPError, ValueError) as error:
raise HTTPException(status_code=502, detail="invalid response from tapes") from error
for item in items:
prompt = item.get("user_prompt", "")
if isinstance(prompt, str) and prompt:
return {"session_id": session_id, "prompt": prompt}
raise HTTPException(status_code=404, detail="prompt not found")
Comment on lines +71 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Validate upstream response shapes

A JSON-decodable 200 response such as null, {"items": null}, or an array containing a non-object raises an uncaught AttributeError or TypeError, returning an internal 500 instead of the documented 502 for an invalid Tapes response.

Suggested change
try:
response.raise_for_status()
items = response.json().get("items", [])
except (httpx.HTTPError, ValueError) as error:
raise HTTPException(status_code=502, detail="invalid response from tapes") from error
for item in items:
prompt = item.get("user_prompt", "")
if isinstance(prompt, str) and prompt:
return {"session_id": session_id, "prompt": prompt}
raise HTTPException(status_code=404, detail="prompt not found")
try:
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("response root is not an object")
items = payload.get("items", [])
if not isinstance(items, list):
raise ValueError("response items is not an array")
for item in items:
if not isinstance(item, dict):
raise ValueError("response item is not an object")
prompt = item.get("user_prompt", "")
if isinstance(prompt, str) and prompt:
return {"session_id": session_id, "prompt": prompt}
except (httpx.HTTPError, ValueError) as error:
raise HTTPException(status_code=502, detail="invalid response from tapes") from error
raise HTTPException(status_code=404, detail="prompt not found")

Knowledge Base Used: Session and trace API

Prompt To Fix With AI
This is a comment left during a code review.
Path: pkg/cassette/examples/py-example/main.py
Line: 71-80

Comment:
**Validate upstream response shapes**

A JSON-decodable 200 response such as `null`, `{"items": null}`, or an array containing a non-object raises an uncaught `AttributeError` or `TypeError`, returning an internal 500 instead of the documented 502 for an invalid Tapes response.

```suggestion
        try:
            response.raise_for_status()
            payload = response.json()
            if not isinstance(payload, dict):
                raise ValueError("response root is not an object")
            items = payload.get("items", [])
            if not isinstance(items, list):
                raise ValueError("response items is not an array")
            for item in items:
                if not isinstance(item, dict):
                    raise ValueError("response item is not an object")
                prompt = item.get("user_prompt", "")
                if isinstance(prompt, str) and prompt:
                    return {"session_id": session_id, "prompt": prompt}
        except (httpx.HTTPError, ValueError) as error:
            raise HTTPException(status_code=502, detail="invalid response from tapes") from error
        raise HTTPException(status_code=404, detail="prompt not found")
```

**Knowledge Base Used:** [Session and trace API](https://app.greptile.com/paper-compute/-/custom-context/knowledge-base/papercomputeco/tapes/-/docs/session-and-trace-api.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


@app.get("/api/prompt/{session_id}")
def get_prompt(session_id: UUID) -> dict[str, str]:
return find_prompt(str(session_id))

@app.post("/api/prompt/get")
def get_prompt_tool(request: PromptRequest) -> dict[str, str]:
return find_prompt(str(request.session_id))

return app


OPENAPI: dict[str, Any] = {
"openapi": "3.1.0",
"info": {
"title": "Prompt Cassette",
"description": MANIFEST["cassette"]["description"],
"version": MANIFEST["cassette"]["version"],
},
"x-tapes-cassette": MANIFEST,
"paths": {
"/api/prompt/get": {
"post": {
"operationId": "getPromptTool",
"summary": "Get a session's first user prompt",
"tags": ["prompt"],
"x-tapes-mcp": {
"name": "get_prompt",
"annotations": {
"readOnlyHint": True,
"idempotentHint": True,
"openWorldHint": False,
},
},
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": False,
"required": ["session_id"],
"properties": {
"session_id": {
"type": "string",
"format": "uuid",
}
},
}
}
},
},
"responses": {
"200": {
"description": "The prompt",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["session_id", "prompt"],
"properties": {
"session_id": {"type": "string"},
"prompt": {"type": "string"},
},
}
}
},
},
"404": {"description": "Session or prompt not found"},
"502": {"description": "Tapes is unavailable"},
},
}
},
"/api/prompt/{session_id}": {
"get": {
"operationId": "getPrompt",
"summary": "Get a session's first user prompt",
"tags": ["prompt"],
"parameters": [
{
"name": "session_id",
"in": "path",
"required": True,
"schema": {"type": "string", "format": "uuid"},
}
],
"responses": {
"200": {
"description": "The prompt",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["session_id", "prompt"],
"properties": {
"session_id": {"type": "string"},
"prompt": {"type": "string"},
},
}
}
},
},
"422": {"description": "Invalid session id"},
"404": {"description": "Session or prompt not found"},
"502": {"description": "Tapes is unavailable"},
},
}
}
},
}


def main() -> None:
host, _, port = os.getenv("CASSETTE_LISTEN", "127.0.0.1:9999").rpartition(":")
import uvicorn

uvicorn.run(create_app(), host=host or "127.0.0.1", port=int(port or 9999))


if __name__ == "__main__":
main()
17 changes: 17 additions & 0 deletions pkg/cassette/examples/py-example/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[project]
name = "prompt-cassette"
version = "0.1.0"
description = "Tapes cassette that returns a session's first user prompt"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"fastapi>=0.115",
"httpx>=0.27",
"uvicorn>=0.32",
]

[dependency-groups]
dev = ["pytest>=8.3"]

[tool.pytest.ini_options]
pythonpath = ["."]
Loading