Spaces:
Sleeping
Sleeping
PROTHAM commited on
Commit ·
06b0d69
0
Parent(s):
Initial submission: API Debug Environment
Browse files- .dockerignore +9 -0
- .env.example +4 -0
- __init__.py +4 -0
- __pycache__/__init__.cpython-312.pyc +0 -0
- __pycache__/client.cpython-312.pyc +0 -0
- __pycache__/models.cpython-312.pyc +0 -0
- api_debug_env.egg-info/PKG-INFO +15 -0
- api_debug_env.egg-info/SOURCES.txt +17 -0
- api_debug_env.egg-info/dependency_links.txt +1 -0
- api_debug_env.egg-info/requires.txt +11 -0
- api_debug_env.egg-info/top_level.txt +3 -0
- client.py +12 -0
- graders/__init__.py +1 -0
- graders/__pycache__/__init__.cpython-312.pyc +0 -0
- graders/__pycache__/grader.cpython-312.pyc +0 -0
- graders/grader.py +40 -0
- inference.py +138 -0
- models.py +35 -0
- openenv.yaml +42 -0
- outputs/evals/.gitkeep +1 -0
- outputs/logs/.gitkeep +1 -0
- pyproject.toml +28 -0
- reset.json +1 -0
- server/Dockerfile +22 -0
- server/__init__.py +1 -0
- server/__pycache__/__init__.cpython-312.pyc +0 -0
- server/__pycache__/api_debug_environment.cpython-312.pyc +0 -0
- server/__pycache__/app.cpython-312.pyc +0 -0
- server/__pycache__/mock_api.cpython-312.pyc +0 -0
- server/api_debug_environment.py +102 -0
- server/app.py +16 -0
- server/mock_api.py +111 -0
- server/requirements.txt +7 -0
- step.json +1 -0
- tasks/__init__.py +1 -0
- tasks/__pycache__/__init__.cpython-312.pyc +0 -0
- tasks/__pycache__/easy.cpython-312.pyc +0 -0
- tasks/__pycache__/hard.cpython-312.pyc +0 -0
- tasks/__pycache__/medium.cpython-312.pyc +0 -0
- tasks/__pycache__/registry.cpython-312.pyc +0 -0
- tasks/easy.py +41 -0
- tasks/hard.py +41 -0
- tasks/medium.py +41 -0
- tasks/registry.py +9 -0
- test_imports.py +13 -0
- test_server.py +20 -0
- uv.lock +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.env
|
| 3 |
+
__pycache__
|
| 4 |
+
*.pyc
|
| 5 |
+
*.pyo
|
| 6 |
+
outputs/
|
| 7 |
+
.venv
|
| 8 |
+
uv.lock
|
| 9 |
+
*.egg-info
|
.env.example
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
API_BASE_URL=https://api-inference.huggingface.co/v1
|
| 2 |
+
MODEL_NAME=mistralai/Mistral-7B-Instruct-v0.3
|
| 3 |
+
HF_TOKEN=hf_your_token_here
|
| 4 |
+
ENV_URL=https://YOUR-USERNAME-api-debug-env.hf.space
|
__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .models import APIAction, APIObservation, APIState
|
| 2 |
+
from .client import APIDebugEnv
|
| 3 |
+
|
| 4 |
+
__all__ = ["APIAction", "APIObservation", "APIState", "APIDebugEnv"]
|
__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (317 Bytes). View file
|
|
|
__pycache__/client.cpython-312.pyc
ADDED
|
Binary file (851 Bytes). View file
|
|
|
__pycache__/models.cpython-312.pyc
ADDED
|
Binary file (2.35 kB). View file
|
|
|
api_debug_env.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: api-debug-env
|
| 3 |
+
Version: 1.0.0
|
| 4 |
+
Summary: OpenEnv environment for AI-powered HTTP API debugging
|
| 5 |
+
Requires-Python: >=3.10
|
| 6 |
+
Requires-Dist: openenv-core>=0.2.3
|
| 7 |
+
Requires-Dist: fastapi>=0.104.0
|
| 8 |
+
Requires-Dist: uvicorn>=0.24.0
|
| 9 |
+
Requires-Dist: pydantic>=2.0.0
|
| 10 |
+
Requires-Dist: httpx>=0.27.0
|
| 11 |
+
Requires-Dist: python-dotenv>=1.0.0
|
| 12 |
+
Requires-Dist: openai>=1.0.0
|
| 13 |
+
Provides-Extra: dev
|
| 14 |
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
| 15 |
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
api_debug_env.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pyproject.toml
|
| 2 |
+
api_debug_env.egg-info/PKG-INFO
|
| 3 |
+
api_debug_env.egg-info/SOURCES.txt
|
| 4 |
+
api_debug_env.egg-info/dependency_links.txt
|
| 5 |
+
api_debug_env.egg-info/requires.txt
|
| 6 |
+
api_debug_env.egg-info/top_level.txt
|
| 7 |
+
graders/__init__.py
|
| 8 |
+
graders/grader.py
|
| 9 |
+
server/__init__.py
|
| 10 |
+
server/api_debug_environment.py
|
| 11 |
+
server/app.py
|
| 12 |
+
server/mock_api.py
|
| 13 |
+
tasks/__init__.py
|
| 14 |
+
tasks/easy.py
|
| 15 |
+
tasks/hard.py
|
| 16 |
+
tasks/medium.py
|
| 17 |
+
tasks/registry.py
|
api_debug_env.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
api_debug_env.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core>=0.2.3
|
| 2 |
+
fastapi>=0.104.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
pydantic>=2.0.0
|
| 5 |
+
httpx>=0.27.0
|
| 6 |
+
python-dotenv>=1.0.0
|
| 7 |
+
openai>=1.0.0
|
| 8 |
+
|
| 9 |
+
[dev]
|
| 10 |
+
pytest>=7.0
|
| 11 |
+
pytest-asyncio>=0.21
|
api_debug_env.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
graders
|
| 2 |
+
server
|
| 3 |
+
tasks
|
client.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from openenv.core.client import EnvClient
|
| 2 |
+
from models import APIAction, APIObservation, APIState
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class APIDebugEnv(EnvClient[APIAction, APIObservation, APIState]):
|
| 6 |
+
|
| 7 |
+
def __init__(self, base_url: str = "http://localhost:8000"):
|
| 8 |
+
super().__init__(
|
| 9 |
+
base_url=base_url,
|
| 10 |
+
action_type=APIAction,
|
| 11 |
+
observation_type=APIObservation,
|
| 12 |
+
)
|
graders/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# empty
|
graders/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (148 Bytes). View file
|
|
|
graders/__pycache__/grader.cpython-312.pyc
ADDED
|
Binary file (2.97 kB). View file
|
|
|
graders/grader.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class APIGrader:
|
| 5 |
+
|
| 6 |
+
def grade(self, response_status, response_body, expected_status, expected_schema, attempt, max_steps):
|
| 7 |
+
if response_status != expected_status:
|
| 8 |
+
return self._partial(response_status)
|
| 9 |
+
try:
|
| 10 |
+
body = json.loads(response_body)
|
| 11 |
+
except Exception:
|
| 12 |
+
return 0.1
|
| 13 |
+
schema_score = self._schema_match(body, expected_schema)
|
| 14 |
+
attempt_bonus = max(0.0, 1.0 - (attempt / max_steps) * 0.15)
|
| 15 |
+
return round(min(1.0, max(0.0, schema_score * attempt_bonus)), 4)
|
| 16 |
+
|
| 17 |
+
def _partial(self, status):
|
| 18 |
+
mapping = {0: 0.0, 401: 0.05, 403: 0.05, 404: 0.05, 405: 0.10, 415: 0.10, 422: 0.15, 429: 0.20, 200: 0.70}
|
| 19 |
+
return mapping.get(status, 0.05)
|
| 20 |
+
|
| 21 |
+
def _schema_match(self, body, schema):
|
| 22 |
+
if not schema:
|
| 23 |
+
return 1.0
|
| 24 |
+
matched = sum(1 for k, v in schema.items() if k in body and type(body[k]) == type(v))
|
| 25 |
+
return matched / len(schema)
|
| 26 |
+
|
| 27 |
+
def get_feedback(self, status, expected_status):
|
| 28 |
+
if status == expected_status:
|
| 29 |
+
return "Correct status code. Verify response schema matches expected."
|
| 30 |
+
hints = {
|
| 31 |
+
401: "Authentication failed. Check Authorization header format and token value.",
|
| 32 |
+
403: "Forbidden. Token valid but lacks permission.",
|
| 33 |
+
404: "Endpoint not found. Check the URL path.",
|
| 34 |
+
405: "Wrong HTTP method. Check if endpoint needs GET POST PUT or DELETE.",
|
| 35 |
+
415: "Wrong Content-Type. Send application/json.",
|
| 36 |
+
422: "Body validation failed. Check field names types and required fields.",
|
| 37 |
+
429: "Rate limited. Add header X-Retry-After: 2 on next request.",
|
| 38 |
+
500: "Server error. Your payload caused an exception.",
|
| 39 |
+
}
|
| 40 |
+
return hints.get(status, f"Got {status} expected {expected_status}. Re-read task description carefully.")
|
inference.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import asyncio
|
| 4 |
+
from openai import OpenAI
|
| 5 |
+
from client import APIDebugEnv
|
| 6 |
+
from models import APIAction
|
| 7 |
+
|
| 8 |
+
API_BASE_URL = os.environ["API_BASE_URL"]
|
| 9 |
+
MODEL_NAME = os.environ["MODEL_NAME"]
|
| 10 |
+
HF_TOKEN = os.environ["HF_TOKEN"]
|
| 11 |
+
ENV_URL = os.environ.get("ENV_URL", "http://localhost:8000")
|
| 12 |
+
|
| 13 |
+
llm = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 14 |
+
|
| 15 |
+
SYSTEM_PROMPT = """You are an HTTP API debugger. You receive a broken request and must fix it to get HTTP 200.
|
| 16 |
+
Respond ONLY in valid JSON with exactly these fields:
|
| 17 |
+
{
|
| 18 |
+
"method": "GET",
|
| 19 |
+
"url": "/mock_api/...",
|
| 20 |
+
"headers": {},
|
| 21 |
+
"body": null,
|
| 22 |
+
"query_params": {}
|
| 23 |
+
}
|
| 24 |
+
Rules:
|
| 25 |
+
- method must be uppercase
|
| 26 |
+
- url must start with /mock_api/
|
| 27 |
+
- body must be null for GET requests
|
| 28 |
+
- always include Content-Type: application/json when body is not null
|
| 29 |
+
- do not add any text outside the JSON"""
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def call_llm(task_description, broken_request, last_status, last_body, feedback):
|
| 33 |
+
user_msg = (
|
| 34 |
+
f"Task: {task_description}\n\n"
|
| 35 |
+
f"Broken request:\n{json.dumps(broken_request, indent=2)}\n\n"
|
| 36 |
+
f"Last response status: {last_status}\n"
|
| 37 |
+
f"Last response body: {last_body[:300]}\n\n"
|
| 38 |
+
f"Feedback: {feedback}\n\n"
|
| 39 |
+
f"Return the fixed request as JSON:"
|
| 40 |
+
)
|
| 41 |
+
resp = llm.chat.completions.create(
|
| 42 |
+
model=MODEL_NAME,
|
| 43 |
+
messages=[
|
| 44 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 45 |
+
{"role": "user", "content": user_msg},
|
| 46 |
+
],
|
| 47 |
+
max_tokens=300,
|
| 48 |
+
temperature=0.1,
|
| 49 |
+
)
|
| 50 |
+
raw = resp.choices[0].message.content.strip()
|
| 51 |
+
raw = raw.replace("```json", "").replace("```", "").strip()
|
| 52 |
+
try:
|
| 53 |
+
return json.loads(raw)
|
| 54 |
+
except json.JSONDecodeError:
|
| 55 |
+
return broken_request
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def run_task(env, task_id):
|
| 59 |
+
obs = await env.reset(task_id=task_id)
|
| 60 |
+
state = await env.state()
|
| 61 |
+
|
| 62 |
+
print(json.dumps({
|
| 63 |
+
"type": "[START]",
|
| 64 |
+
"task_id": task_id,
|
| 65 |
+
"episode_id": state.episode_id,
|
| 66 |
+
"task_description": obs.task_description,
|
| 67 |
+
"broken_request": obs.broken_request,
|
| 68 |
+
}))
|
| 69 |
+
|
| 70 |
+
final_reward = 0.0
|
| 71 |
+
step_num = 0
|
| 72 |
+
|
| 73 |
+
for step_num in range(state.max_steps):
|
| 74 |
+
fixed = call_llm(
|
| 75 |
+
obs.task_description,
|
| 76 |
+
obs.broken_request,
|
| 77 |
+
obs.last_status_code,
|
| 78 |
+
obs.last_response_body,
|
| 79 |
+
obs.step_feedback,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
action = APIAction(
|
| 83 |
+
method=str(fixed.get("method", "GET")),
|
| 84 |
+
url=str(fixed.get("url", obs.broken_request.get("url", "/mock_api/users"))),
|
| 85 |
+
headers=dict(fixed.get("headers") or {}),
|
| 86 |
+
body=fixed.get("body"),
|
| 87 |
+
query_params=dict(fixed.get("query_params") or {}),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
result = await env.step(action)
|
| 91 |
+
final_reward = result.reward
|
| 92 |
+
obs = result.observation
|
| 93 |
+
|
| 94 |
+
print(json.dumps({
|
| 95 |
+
"type": "[STEP]",
|
| 96 |
+
"task_id": task_id,
|
| 97 |
+
"step": step_num + 1,
|
| 98 |
+
"action": {
|
| 99 |
+
"method": action.method,
|
| 100 |
+
"url": action.url,
|
| 101 |
+
"headers": action.headers,
|
| 102 |
+
"body": action.body,
|
| 103 |
+
"query_params": action.query_params,
|
| 104 |
+
},
|
| 105 |
+
"status_code": obs.last_status_code,
|
| 106 |
+
"reward": result.reward,
|
| 107 |
+
"done": result.done,
|
| 108 |
+
"feedback": obs.step_feedback,
|
| 109 |
+
}))
|
| 110 |
+
|
| 111 |
+
if result.done:
|
| 112 |
+
break
|
| 113 |
+
|
| 114 |
+
print(json.dumps({
|
| 115 |
+
"type": "[END]",
|
| 116 |
+
"task_id": task_id,
|
| 117 |
+
"final_reward": final_reward,
|
| 118 |
+
"steps_taken": step_num + 1,
|
| 119 |
+
}))
|
| 120 |
+
|
| 121 |
+
return final_reward
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
async def main():
|
| 125 |
+
async with APIDebugEnv(base_url=ENV_URL) as env:
|
| 126 |
+
scores = {}
|
| 127 |
+
for task_id in ["easy", "medium", "hard"]:
|
| 128 |
+
scores[task_id] = await run_task(env, task_id)
|
| 129 |
+
|
| 130 |
+
print(json.dumps({
|
| 131 |
+
"type": "[SUMMARY]",
|
| 132 |
+
"scores": scores,
|
| 133 |
+
"average": round(sum(scores.values()) / len(scores), 4),
|
| 134 |
+
}))
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
asyncio.run(main())
|
models.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from openenv.core.env_server import Environment
|
| 2 |
+
try:
|
| 3 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 4 |
+
except ImportError:
|
| 5 |
+
from openenv.core.models import Action, Observation, State
|
| 6 |
+
from pydantic import Field
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class APIAction(Action):
|
| 11 |
+
method: str = Field(default="GET")
|
| 12 |
+
url: str = Field(default="/mock_api/users")
|
| 13 |
+
headers: dict = Field(default_factory=dict)
|
| 14 |
+
body: Optional[dict] = Field(default=None)
|
| 15 |
+
query_params: dict = Field(default_factory=dict)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class APIObservation(Observation):
|
| 19 |
+
task_id: str = Field(default="easy")
|
| 20 |
+
task_description: str = Field(default="")
|
| 21 |
+
broken_request: dict = Field(default_factory=dict)
|
| 22 |
+
last_status_code: int = Field(default=0)
|
| 23 |
+
last_response_headers: dict = Field(default_factory=dict)
|
| 24 |
+
last_response_body: str = Field(default="")
|
| 25 |
+
step_feedback: str = Field(default="")
|
| 26 |
+
current_score: float = Field(default=0.0)
|
| 27 |
+
attempt: int = Field(default=0)
|
| 28 |
+
reward: float = Field(default=0.0)
|
| 29 |
+
done: bool = Field(default=False)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class APIState(State):
|
| 33 |
+
task_id: str = Field(default="easy")
|
| 34 |
+
max_steps: int = Field(default=5)
|
| 35 |
+
solved: bool = Field(default=False)
|
openenv.yaml
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: api-debug-env
|
| 3 |
+
version: "1.0.0"
|
| 4 |
+
type: environment
|
| 5 |
+
runtime: docker
|
| 6 |
+
app: server/app.py
|
| 7 |
+
port: 8000
|
| 8 |
+
description: OpenEnv environment where AI agents debug broken HTTP API requests by reading real error responses and iteratively fixing headers, methods, bodies, and auth flows.
|
| 9 |
+
tasks:
|
| 10 |
+
- id: easy
|
| 11 |
+
name: Auth and Header Errors
|
| 12 |
+
difficulty: easy
|
| 13 |
+
max_steps: 5
|
| 14 |
+
reward_range: [0.0, 1.0]
|
| 15 |
+
- id: medium
|
| 16 |
+
name: Method and Body Errors
|
| 17 |
+
difficulty: medium
|
| 18 |
+
max_steps: 5
|
| 19 |
+
reward_range: [0.0, 1.0]
|
| 20 |
+
- id: hard
|
| 21 |
+
name: Rate Limiting and Multi-step Auth
|
| 22 |
+
difficulty: hard
|
| 23 |
+
max_steps: 5
|
| 24 |
+
reward_range: [0.0, 1.0]
|
| 25 |
+
action_space:
|
| 26 |
+
type: structured
|
| 27 |
+
fields:
|
| 28 |
+
- method: string
|
| 29 |
+
- url: string
|
| 30 |
+
- headers: dict
|
| 31 |
+
- body: dict
|
| 32 |
+
- query_params: dict
|
| 33 |
+
observation_space:
|
| 34 |
+
type: structured
|
| 35 |
+
fields:
|
| 36 |
+
- task_id: string
|
| 37 |
+
- task_description: string
|
| 38 |
+
- broken_request: dict
|
| 39 |
+
- last_status_code: int
|
| 40 |
+
- last_response_body: string
|
| 41 |
+
- step_feedback: string
|
| 42 |
+
- current_score: float
|
outputs/evals/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# empty file
|
outputs/logs/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# empty file
|
pyproject.toml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68.0", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "api-debug-env"
|
| 7 |
+
version = "1.0.0"
|
| 8 |
+
description = "OpenEnv environment for AI-powered HTTP API debugging"
|
| 9 |
+
requires-python = ">=3.10"
|
| 10 |
+
dependencies = [
|
| 11 |
+
"openenv-core>=0.2.3",
|
| 12 |
+
"fastapi>=0.104.0",
|
| 13 |
+
"uvicorn>=0.24.0",
|
| 14 |
+
"pydantic>=2.0.0",
|
| 15 |
+
"httpx>=0.27.0",
|
| 16 |
+
"python-dotenv>=1.0.0",
|
| 17 |
+
"openai>=1.0.0",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[project.optional-dependencies]
|
| 21 |
+
dev = ["pytest>=7.0", "pytest-asyncio>=0.21"]
|
| 22 |
+
|
| 23 |
+
[tool.setuptools.packages.find]
|
| 24 |
+
where = ["."]
|
| 25 |
+
include = ["server*", "tasks*", "graders*", "models*", "client*"]
|
| 26 |
+
|
| 27 |
+
[tool.uv.scripts]
|
| 28 |
+
server = "uvicorn server.app:app --host 0.0.0.0 --port 8000"
|
reset.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"task_id": "easy"}
|
server/Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
RUN useradd -m -u 1000 envuser
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
RUN apt-get update && apt-get install -y gcc curl && rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
COPY server/requirements.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
+
|
| 11 |
+
COPY . .
|
| 12 |
+
RUN pip install --no-cache-dir -e .
|
| 13 |
+
|
| 14 |
+
ENV PYTHONPATH=/app
|
| 15 |
+
|
| 16 |
+
USER envuser
|
| 17 |
+
EXPOSE 8000
|
| 18 |
+
|
| 19 |
+
HEALTHCHECK --interval=20s --timeout=8s --start-period=40s --retries=3 \
|
| 20 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 21 |
+
|
| 22 |
+
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# empty
|
server/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (147 Bytes). View file
|
|
|
server/__pycache__/api_debug_environment.cpython-312.pyc
ADDED
|
Binary file (4.62 kB). View file
|
|
|
server/__pycache__/app.cpython-312.pyc
ADDED
|
Binary file (834 Bytes). View file
|
|
|
server/__pycache__/mock_api.cpython-312.pyc
ADDED
|
Binary file (7.12 kB). View file
|
|
|
server/api_debug_environment.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import httpx
|
| 3 |
+
|
| 4 |
+
try:
|
| 5 |
+
from openenv.core.env_server import Environment
|
| 6 |
+
except ImportError:
|
| 7 |
+
from openenv.core.env_server.interfaces import Environment
|
| 8 |
+
|
| 9 |
+
from models import APIAction, APIObservation, APIState
|
| 10 |
+
from tasks.registry import TASK_REGISTRY
|
| 11 |
+
from graders.grader import APIGrader
|
| 12 |
+
|
| 13 |
+
GRADER = APIGrader()
|
| 14 |
+
MOCK_BASE = "http://localhost:8000"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class APIDebugEnvironment(Environment):
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self._state = APIState(episode_id="init", step_count=0, task_id="easy", max_steps=5, solved=False)
|
| 21 |
+
self._current_task = None
|
| 22 |
+
|
| 23 |
+
def reset(self, task_id: str = "easy", **kwargs) -> APIObservation:
|
| 24 |
+
if task_id not in TASK_REGISTRY:
|
| 25 |
+
task_id = "easy"
|
| 26 |
+
self._current_task = random.choice(TASK_REGISTRY[task_id])
|
| 27 |
+
self._state = APIState(
|
| 28 |
+
episode_id=f"ep_{random.randint(10000, 99999)}",
|
| 29 |
+
step_count=0,
|
| 30 |
+
task_id=task_id,
|
| 31 |
+
max_steps=5,
|
| 32 |
+
solved=False,
|
| 33 |
+
)
|
| 34 |
+
return APIObservation(
|
| 35 |
+
task_id=task_id,
|
| 36 |
+
task_description=self._current_task["description"],
|
| 37 |
+
broken_request=self._current_task["broken_request"],
|
| 38 |
+
last_status_code=0,
|
| 39 |
+
last_response_headers={},
|
| 40 |
+
last_response_body="",
|
| 41 |
+
step_feedback="Episode started. Read the task description and fix the broken request.",
|
| 42 |
+
current_score=0.0,
|
| 43 |
+
attempt=0,
|
| 44 |
+
reward=0.0,
|
| 45 |
+
done=False,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
def step(self, action: APIAction, **kwargs) -> APIObservation:
|
| 49 |
+
if self._current_task is None:
|
| 50 |
+
self.reset(task_id="easy")
|
| 51 |
+
|
| 52 |
+
self._state.step_count += 1
|
| 53 |
+
task = self._current_task
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
with httpx.Client(base_url=MOCK_BASE, timeout=5.0) as http:
|
| 57 |
+
resp = http.request(
|
| 58 |
+
method=action.method.upper(),
|
| 59 |
+
url=action.url,
|
| 60 |
+
headers=action.headers,
|
| 61 |
+
json=action.body if action.body else None,
|
| 62 |
+
params=action.query_params,
|
| 63 |
+
)
|
| 64 |
+
status = resp.status_code
|
| 65 |
+
resp_headers = dict(resp.headers)
|
| 66 |
+
resp_body = resp.text
|
| 67 |
+
except Exception as e:
|
| 68 |
+
status = 0
|
| 69 |
+
resp_headers = {}
|
| 70 |
+
resp_body = f"Request error: {str(e)}"
|
| 71 |
+
|
| 72 |
+
reward = GRADER.grade(
|
| 73 |
+
response_status=status,
|
| 74 |
+
response_body=resp_body,
|
| 75 |
+
expected_status=task["expected_status"],
|
| 76 |
+
expected_schema=task["expected_schema"],
|
| 77 |
+
attempt=self._state.step_count,
|
| 78 |
+
max_steps=self._state.max_steps,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
feedback = GRADER.get_feedback(status, task["expected_status"])
|
| 82 |
+
solved = status == task["expected_status"] and reward >= 0.8
|
| 83 |
+
self._state.solved = solved
|
| 84 |
+
done = solved or self._state.step_count >= self._state.max_steps
|
| 85 |
+
|
| 86 |
+
return APIObservation(
|
| 87 |
+
task_id=self._state.task_id,
|
| 88 |
+
task_description=task["description"],
|
| 89 |
+
broken_request=task["broken_request"],
|
| 90 |
+
last_status_code=status,
|
| 91 |
+
last_response_headers=resp_headers,
|
| 92 |
+
last_response_body=resp_body,
|
| 93 |
+
step_feedback=feedback,
|
| 94 |
+
current_score=reward,
|
| 95 |
+
attempt=self._state.step_count,
|
| 96 |
+
reward=reward,
|
| 97 |
+
done=done,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def state(self) -> APIState:
|
| 102 |
+
return self._state
|
server/app.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from openenv.core.env_server import create_app
|
| 3 |
+
|
| 4 |
+
from models import APIAction, APIObservation
|
| 5 |
+
from server.api_debug_environment import APIDebugEnvironment
|
| 6 |
+
from server.mock_api import router as mock_router
|
| 7 |
+
|
| 8 |
+
# Pass the CLASS not an instance
|
| 9 |
+
app = create_app(APIDebugEnvironment, APIAction, APIObservation, env_name="api_debug_env")
|
| 10 |
+
|
| 11 |
+
app.include_router(mock_router)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@app.get("/health")
|
| 15 |
+
async def health():
|
| 16 |
+
return {"status": "ok", "environment": "api-debug-env"}
|
server/mock_api.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from fastapi import APIRouter, Header, HTTPException, Request, Query
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
router = APIRouter(prefix="/mock_api", tags=["mock"])
|
| 7 |
+
|
| 8 |
+
_issued_tokens = set()
|
| 9 |
+
_request_log = {}
|
| 10 |
+
|
| 11 |
+
LOG_PAGES = {
|
| 12 |
+
None: {"items": list(range(10)), "next_cursor": "cur_abc", "has_more": True},
|
| 13 |
+
"cur_abc": {"items": list(range(10, 20)), "next_cursor": "cur_def", "has_more": True},
|
| 14 |
+
"cur_def": {"items": list(range(20, 25)), "next_cursor": None, "has_more": False},
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TokenRequest(BaseModel):
|
| 19 |
+
client_id: str
|
| 20 |
+
client_secret: str
|
| 21 |
+
|
| 22 |
+
@router.post("/auth/token")
|
| 23 |
+
async def get_token(body: TokenRequest):
|
| 24 |
+
if body.client_id == "abc" and body.client_secret == "xyz":
|
| 25 |
+
token = f"tok_{int(time.time())}"
|
| 26 |
+
_issued_tokens.add(token)
|
| 27 |
+
return {"access_token": token, "expires_in": 60}
|
| 28 |
+
raise HTTPException(status_code=401, detail="Invalid client credentials")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.get("/users")
|
| 32 |
+
async def get_users(authorization: Optional[str] = Header(default=None)):
|
| 33 |
+
if not authorization or not authorization.startswith("Bearer "):
|
| 34 |
+
raise HTTPException(status_code=401, detail="Authorization header missing or malformed")
|
| 35 |
+
token = authorization.split(" ")[1]
|
| 36 |
+
if token not in _issued_tokens and token != "demo_token_123":
|
| 37 |
+
raise HTTPException(status_code=401, detail="Invalid token")
|
| 38 |
+
return {"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.post("/items")
|
| 42 |
+
async def create_item(request: Request):
|
| 43 |
+
ct = request.headers.get("content-type", "")
|
| 44 |
+
if "application/json" not in ct:
|
| 45 |
+
raise HTTPException(status_code=415, detail="Content-Type must be application/json")
|
| 46 |
+
body = await request.json()
|
| 47 |
+
if "name" not in body:
|
| 48 |
+
raise HTTPException(status_code=422, detail=[{"loc": ["body", "name"], "msg": "field required"}])
|
| 49 |
+
return {"item_id": 42, "name": body["name"], "created": True}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.get("/search")
|
| 53 |
+
async def search(q: Optional[str] = Query(default=None)):
|
| 54 |
+
if not q:
|
| 55 |
+
raise HTTPException(status_code=422, detail=[{"loc": ["query", "q"], "msg": "field required"}])
|
| 56 |
+
return {"results": [{"title": f"Result for {q}", "score": 0.95}], "total": 1}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class OrderBody(BaseModel):
|
| 60 |
+
product_id: int
|
| 61 |
+
qty: int
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.post("/orders")
|
| 65 |
+
async def create_order(order: OrderBody):
|
| 66 |
+
return {"order_id": 999, "product_id": order.product_id, "qty": order.qty, "status": "confirmed"}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class AddressModel(BaseModel):
|
| 70 |
+
street: str
|
| 71 |
+
city: str
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class ProfileBody(BaseModel):
|
| 75 |
+
name: str
|
| 76 |
+
address: AddressModel
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.post("/profile")
|
| 80 |
+
async def update_profile(profile: ProfileBody):
|
| 81 |
+
return {"name": profile.name, "address": profile.address.model_dump(), "updated": True}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.get("/protected")
|
| 85 |
+
async def protected(authorization: Optional[str] = Header(default=None)):
|
| 86 |
+
if not authorization or not authorization.startswith("Bearer "):
|
| 87 |
+
raise HTTPException(status_code=401, detail="Authorization header missing")
|
| 88 |
+
token = authorization.split(" ")[1]
|
| 89 |
+
if token not in _issued_tokens:
|
| 90 |
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
| 91 |
+
return {"data": "secret_payload", "accessed_at": int(time.time())}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@router.get("/rate_limited")
|
| 95 |
+
async def rate_limited(request: Request, x_retry_after: Optional[str] = Header(default=None)):
|
| 96 |
+
client_id = request.client.host if request.client else "default"
|
| 97 |
+
now = time.time()
|
| 98 |
+
window = [t for t in _request_log.get(client_id, []) if now - t < 5]
|
| 99 |
+
if len(window) >= 3 and not x_retry_after:
|
| 100 |
+
_request_log[client_id] = window
|
| 101 |
+
raise HTTPException(status_code=429, detail="Rate limit exceeded", headers={"Retry-After": "2"})
|
| 102 |
+
window.append(now)
|
| 103 |
+
_request_log[client_id] = window
|
| 104 |
+
return {"data": "rate_limited_resource", "requests_in_window": len(window)}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@router.get("/logs")
|
| 108 |
+
async def get_logs(cursor: Optional[str] = Query(default=None)):
|
| 109 |
+
if cursor not in LOG_PAGES:
|
| 110 |
+
raise HTTPException(status_code=400, detail=f"Invalid cursor: {cursor}")
|
| 111 |
+
return LOG_PAGES[cursor]
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core>=0.2.3
|
| 2 |
+
fastapi>=0.104.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
pydantic>=2.0.0
|
| 5 |
+
httpx>=0.27.0
|
| 6 |
+
python-dotenv>=1.0.0
|
| 7 |
+
openai>=1.0.0
|
step.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"action": {"method":"GET","url":"/mock_api/users","headers":{"Authorization":"Bearer demo_token_123"},"body":null,"query_params":{}}}
|
tasks/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# empty
|
tasks/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (146 Bytes). View file
|
|
|
tasks/__pycache__/easy.cpython-312.pyc
ADDED
|
Binary file (1.17 kB). View file
|
|
|
tasks/__pycache__/hard.cpython-312.pyc
ADDED
|
Binary file (1.31 kB). View file
|
|
|
tasks/__pycache__/medium.cpython-312.pyc
ADDED
|
Binary file (1.24 kB). View file
|
|
|
tasks/__pycache__/registry.cpython-312.pyc
ADDED
|
Binary file (357 Bytes). View file
|
|
|
tasks/easy.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
EASY_TASKS = [
|
| 2 |
+
{
|
| 3 |
+
"id": "easy_auth",
|
| 4 |
+
"description": "The request is missing an Authorization header. The API requires: Authorization: Bearer demo_token_123. Fix the request to GET /mock_api/users successfully.",
|
| 5 |
+
"broken_request": {
|
| 6 |
+
"method": "GET",
|
| 7 |
+
"url": "/mock_api/users",
|
| 8 |
+
"headers": {},
|
| 9 |
+
"body": None,
|
| 10 |
+
"query_params": {},
|
| 11 |
+
},
|
| 12 |
+
"expected_status": 200,
|
| 13 |
+
"expected_schema": {"users": []},
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"id": "easy_content_type",
|
| 17 |
+
"description": "The request uses Content-Type: text/plain but the API requires application/json. Fix the header and send body with a name field to POST /mock_api/items.",
|
| 18 |
+
"broken_request": {
|
| 19 |
+
"method": "POST",
|
| 20 |
+
"url": "/mock_api/items",
|
| 21 |
+
"headers": {"Content-Type": "text/plain"},
|
| 22 |
+
"body": None,
|
| 23 |
+
"query_params": {},
|
| 24 |
+
},
|
| 25 |
+
"expected_status": 200,
|
| 26 |
+
"expected_schema": {"item_id": 0, "name": "", "created": True},
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"id": "easy_query_param",
|
| 30 |
+
"description": "The search endpoint requires a query parameter q. The request omits it. Add q=python to the query params for GET /mock_api/search.",
|
| 31 |
+
"broken_request": {
|
| 32 |
+
"method": "GET",
|
| 33 |
+
"url": "/mock_api/search",
|
| 34 |
+
"headers": {},
|
| 35 |
+
"body": None,
|
| 36 |
+
"query_params": {},
|
| 37 |
+
},
|
| 38 |
+
"expected_status": 200,
|
| 39 |
+
"expected_schema": {"results": [], "total": 0},
|
| 40 |
+
},
|
| 41 |
+
]
|
tasks/hard.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
HARD_TASKS = [
|
| 2 |
+
{
|
| 3 |
+
"id": "hard_token_exchange",
|
| 4 |
+
"description": "The protected endpoint needs a fresh token. First POST to /mock_api/auth/token with body client_id abc and client_secret xyz to get an access_token. Then use it as Authorization: Bearer <token> to GET /mock_api/protected. The token in the broken request is expired.",
|
| 5 |
+
"broken_request": {
|
| 6 |
+
"method": "GET",
|
| 7 |
+
"url": "/mock_api/protected",
|
| 8 |
+
"headers": {"Authorization": "Bearer stale_token_000"},
|
| 9 |
+
"body": None,
|
| 10 |
+
"query_params": {},
|
| 11 |
+
},
|
| 12 |
+
"expected_status": 200,
|
| 13 |
+
"expected_schema": {"data": ""},
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"id": "hard_rate_limit",
|
| 17 |
+
"description": "The rate_limited endpoint returns 429 if you send more than 3 requests without the X-Retry-After header. After receiving a 429 include header X-Retry-After with value 2.",
|
| 18 |
+
"broken_request": {
|
| 19 |
+
"method": "GET",
|
| 20 |
+
"url": "/mock_api/rate_limited",
|
| 21 |
+
"headers": {},
|
| 22 |
+
"body": None,
|
| 23 |
+
"query_params": {},
|
| 24 |
+
},
|
| 25 |
+
"expected_status": 200,
|
| 26 |
+
"expected_schema": {"data": ""},
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"id": "hard_pagination",
|
| 30 |
+
"description": "GET /mock_api/logs returns only the first page with a next_cursor field. Follow the cursor chain by passing cursor as a query param until has_more is false.",
|
| 31 |
+
"broken_request": {
|
| 32 |
+
"method": "GET",
|
| 33 |
+
"url": "/mock_api/logs",
|
| 34 |
+
"headers": {},
|
| 35 |
+
"body": None,
|
| 36 |
+
"query_params": {},
|
| 37 |
+
},
|
| 38 |
+
"expected_status": 200,
|
| 39 |
+
"expected_schema": {"items": [], "has_more": False},
|
| 40 |
+
},
|
| 41 |
+
]
|
tasks/medium.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MEDIUM_TASKS = [
|
| 2 |
+
{
|
| 3 |
+
"id": "medium_wrong_method",
|
| 4 |
+
"description": "Creating an order requires POST not GET. Fix the HTTP method and send body with product_id 5 and qty 2 to /mock_api/orders.",
|
| 5 |
+
"broken_request": {
|
| 6 |
+
"method": "GET",
|
| 7 |
+
"url": "/mock_api/orders",
|
| 8 |
+
"headers": {"Content-Type": "application/json"},
|
| 9 |
+
"body": {"product_id": 5, "qty": 2},
|
| 10 |
+
"query_params": {},
|
| 11 |
+
},
|
| 12 |
+
"expected_status": 200,
|
| 13 |
+
"expected_schema": {"order_id": 0, "status": ""},
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"id": "medium_type_mismatch",
|
| 17 |
+
"description": "The API expects product_id as an integer but the request sends the string five. Fix the type to the integer 5 in the body.",
|
| 18 |
+
"broken_request": {
|
| 19 |
+
"method": "POST",
|
| 20 |
+
"url": "/mock_api/orders",
|
| 21 |
+
"headers": {"Content-Type": "application/json"},
|
| 22 |
+
"body": {"product_id": "five", "qty": 2},
|
| 23 |
+
"query_params": {},
|
| 24 |
+
},
|
| 25 |
+
"expected_status": 200,
|
| 26 |
+
"expected_schema": {"order_id": 0, "product_id": 0},
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"id": "medium_nested_field",
|
| 30 |
+
"description": "The profile endpoint expects address to be a dict like street and city keys, not a plain string. Fix the body structure.",
|
| 31 |
+
"broken_request": {
|
| 32 |
+
"method": "POST",
|
| 33 |
+
"url": "/mock_api/profile",
|
| 34 |
+
"headers": {"Content-Type": "application/json"},
|
| 35 |
+
"body": {"name": "Alice", "address": "123 Main St"},
|
| 36 |
+
"query_params": {},
|
| 37 |
+
},
|
| 38 |
+
"expected_status": 200,
|
| 39 |
+
"expected_schema": {"name": "", "address": {}, "updated": True},
|
| 40 |
+
},
|
| 41 |
+
]
|
tasks/registry.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from tasks.easy import EASY_TASKS
|
| 2 |
+
from tasks.medium import MEDIUM_TASKS
|
| 3 |
+
from tasks.hard import HARD_TASKS
|
| 4 |
+
|
| 5 |
+
TASK_REGISTRY = {
|
| 6 |
+
"easy": EASY_TASKS,
|
| 7 |
+
"medium": MEDIUM_TASKS,
|
| 8 |
+
"hard": HARD_TASKS,
|
| 9 |
+
}
|
test_imports.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
try:
|
| 2 |
+
from models import APIAction, APIObservation, APIState
|
| 3 |
+
print('models OK')
|
| 4 |
+
from client import APIDebugEnv
|
| 5 |
+
print('client OK')
|
| 6 |
+
from tasks.registry import TASK_REGISTRY
|
| 7 |
+
print('tasks OK')
|
| 8 |
+
from graders.grader import APIGrader
|
| 9 |
+
print('grader OK')
|
| 10 |
+
print('ALL IMPORTS PASS')
|
| 11 |
+
except Exception as e:
|
| 12 |
+
import traceback
|
| 13 |
+
traceback.print_exc()
|
test_server.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
from server.app import app
|
| 3 |
+
|
| 4 |
+
client = TestClient(app)
|
| 5 |
+
|
| 6 |
+
def test_health():
|
| 7 |
+
response = client.get("/health")
|
| 8 |
+
assert response.status_code == 200
|
| 9 |
+
print("health OK")
|
| 10 |
+
|
| 11 |
+
def test_reset():
|
| 12 |
+
response = client.post("/reset", json={"task_id": "easy"})
|
| 13 |
+
assert response.status_code == 200
|
| 14 |
+
assert "task_description" in response.json()
|
| 15 |
+
print("easy reset OK")
|
| 16 |
+
|
| 17 |
+
if __name__ == "__main__":
|
| 18 |
+
test_health()
|
| 19 |
+
test_reset()
|
| 20 |
+
print("All tests passed.")
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|