PROTHAM commited on
Commit
06b0d69
·
0 Parent(s):

Initial submission: API Debug Environment

Browse files
Files changed (47) hide show
  1. .dockerignore +9 -0
  2. .env.example +4 -0
  3. __init__.py +4 -0
  4. __pycache__/__init__.cpython-312.pyc +0 -0
  5. __pycache__/client.cpython-312.pyc +0 -0
  6. __pycache__/models.cpython-312.pyc +0 -0
  7. api_debug_env.egg-info/PKG-INFO +15 -0
  8. api_debug_env.egg-info/SOURCES.txt +17 -0
  9. api_debug_env.egg-info/dependency_links.txt +1 -0
  10. api_debug_env.egg-info/requires.txt +11 -0
  11. api_debug_env.egg-info/top_level.txt +3 -0
  12. client.py +12 -0
  13. graders/__init__.py +1 -0
  14. graders/__pycache__/__init__.cpython-312.pyc +0 -0
  15. graders/__pycache__/grader.cpython-312.pyc +0 -0
  16. graders/grader.py +40 -0
  17. inference.py +138 -0
  18. models.py +35 -0
  19. openenv.yaml +42 -0
  20. outputs/evals/.gitkeep +1 -0
  21. outputs/logs/.gitkeep +1 -0
  22. pyproject.toml +28 -0
  23. reset.json +1 -0
  24. server/Dockerfile +22 -0
  25. server/__init__.py +1 -0
  26. server/__pycache__/__init__.cpython-312.pyc +0 -0
  27. server/__pycache__/api_debug_environment.cpython-312.pyc +0 -0
  28. server/__pycache__/app.cpython-312.pyc +0 -0
  29. server/__pycache__/mock_api.cpython-312.pyc +0 -0
  30. server/api_debug_environment.py +102 -0
  31. server/app.py +16 -0
  32. server/mock_api.py +111 -0
  33. server/requirements.txt +7 -0
  34. step.json +1 -0
  35. tasks/__init__.py +1 -0
  36. tasks/__pycache__/__init__.cpython-312.pyc +0 -0
  37. tasks/__pycache__/easy.cpython-312.pyc +0 -0
  38. tasks/__pycache__/hard.cpython-312.pyc +0 -0
  39. tasks/__pycache__/medium.cpython-312.pyc +0 -0
  40. tasks/__pycache__/registry.cpython-312.pyc +0 -0
  41. tasks/easy.py +41 -0
  42. tasks/hard.py +41 -0
  43. tasks/medium.py +41 -0
  44. tasks/registry.py +9 -0
  45. test_imports.py +13 -0
  46. test_server.py +20 -0
  47. 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