Generation Endpoints
docsfy generation is asynchronous: POST /api/generate accepts a request, schedules background work, and returns immediately. You then poll status endpoints until the variant reaches ready, error, or aborted.
Note: Generation is scoped by owner + project name + provider + model. Two different users can generate the same repo/model combination without colliding.
Endpoint Summary
| Method | Path | Purpose |
|---|---|---|
POST |
/api/generate |
Start generation for a repo variant |
POST |
/api/projects/{name}/{provider}/{model}/abort |
Abort an active generation for one variant |
POST |
/api/projects/{name}/abort |
Legacy abort endpoint (name-only matching) |
GET |
/api/status |
List visible projects + known_models for UI suggestions |
GET |
/api/projects/{name}/{provider}/{model} |
Poll a single variant’s detailed status |
Auth and Write Permissions
All /api/* endpoints require authentication. Generation and abort endpoints also require write access (admin or user role).
```151:191:src/docsfy/main.py if not user and not is_admin: # Not authenticated if request.url.path.startswith("/api/"): return JSONResponse(status_code=401, content={"detail": "Unauthorized"}) return RedirectResponse(url="/login", status_code=302)
def _require_write_access(request: Request) -> None: """Raise 403 if user is a viewer (read-only).""" if request.state.role not in ("admin", "user"): raise HTTPException( status_code=403, detail="Write access required.", )
## `POST /api/generate`
### Request Schema
```10:64:src/docsfy/models.py
class GenerateRequest(BaseModel):
repo_url: str | None = Field(
default=None, description="Git repository URL (HTTPS or SSH)"
)
repo_path: str | None = Field(default=None, description="Local git repository path")
ai_provider: Literal["claude", "gemini", "cursor"] | None = None
ai_model: str | None = None
ai_cli_timeout: int | None = Field(default=None, gt=0)
force: bool = Field(
default=False, description="Force full regeneration, ignoring cache"
)
@model_validator(mode="after")
def validate_source(self) -> GenerateRequest:
if not self.repo_url and not self.repo_path:
msg = "Either 'repo_url' or 'repo_path' must be provided"
raise ValueError(msg)
if self.repo_url and self.repo_path:
msg = "Provide either 'repo_url' or 'repo_path', not both"
raise ValueError(msg)
return self
@field_validator("repo_url")
@classmethod
def validate_repo_url(cls, v: str | None) -> str | None:
if v is None:
return v
https_pattern = r"^https?://[\w.\-]+/[\w.\-]+/[\w.\-]+(\.git)?$"
ssh_pattern = r"^git@[\w.\-]+:[\w.\-]+/[\w.\-]+(\.git)?$"
if not re.match(https_pattern, v) and not re.match(ssh_pattern, v):
msg = f"Invalid git repository URL: '{v}'"
raise ValueError(msg)
return v
@field_validator("repo_path")
@classmethod
def validate_repo_path(cls, v: str | None) -> str | None:
if v is None:
return v
path = Path(v)
if not path.is_absolute():
msg = "repo_path must be an absolute path"
raise ValueError(msg)
return v
@property
def project_name(self) -> str:
if self.repo_url:
name = self.repo_url.rstrip("/").split("/")[-1]
if name.endswith(".git"):
name = name[:-4]
return name
if self.repo_path:
return Path(self.repo_path).resolve().name
return "unknown"
Field Behavior
| Field | Type | Required | Validation | Effective default |
|---|---|---|---|---|
repo_url |
string \| null |
One of repo_url or repo_path is required |
Must match HTTPS/HTTP or SSH git URL pattern | None |
repo_path |
string \| null |
One of repo_url or repo_path is required |
Must be absolute path; endpoint also checks path exists and has .git |
None |
ai_provider |
claude \| gemini \| cursor \| null |
Optional | Literal enum in schema + server-side runtime check | AI_PROVIDER |
ai_model |
string \| null |
Optional in body | Must be non-empty after fallback | AI_MODEL |
ai_cli_timeout |
int \| null |
Optional | > 0 |
AI_CLI_TIMEOUT |
force |
bool |
Optional | none | false |
Actual request body shape (dashboard client)
```2043:2056:src/docsfy/templates/dashboard.html var body = { repo_url: repoUrl, ai_provider: provider, force: force }; if (model) body.ai_model = model;
fetch('/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', redirect: 'manual', body: JSON.stringify(body) })
### Success response
```73:83:tests/test_main.py
async def test_generate_endpoint_starts_generation(client: AsyncClient) -> None:
with patch("docsfy.main.asyncio.create_task") as mock_task:
mock_task.side_effect = lambda coro: coro.close()
response = await client.post(
"/api/generate",
json={"repo_url": "https://github.com/org/repo.git"},
)
assert response.status_code == 202
body = response.json()
assert body["project"] == "repo"
assert body["status"] == "generating"
Response shape:
{
"project": "<derived project name>",
"status": "generating"
}
Provider/Model Validation
Provider and model are resolved from request first, then environment defaults:
```455:467:src/docsfy/main.py ai_provider = gen_request.ai_provider or settings.ai_provider ai_model = gen_request.ai_model or settings.ai_model project_name = gen_request.project_name owner = request.state.username
if ai_provider not in ("claude", "gemini", "cursor"): raise HTTPException( status_code=400, detail=f"Invalid AI provider: '{ai_provider}'. Must be claude, gemini, or cursor.", ) if not ai_model: raise HTTPException(status_code=400, detail="AI model must be specified.")
Supported providers are explicitly tested:
```14:17:tests/test_ai_client.py
assert "claude" in PROVIDERS
assert "gemini" in PROVIDERS
assert "cursor" in PROVIDERS
assert VALID_AI_PROVIDERS == frozenset({"claude", "gemini", "cursor"})
Note: Model names are not checked against a strict server-side allowlist at request time; any non-empty string can pass input validation. Real compatibility is verified later by AI CLI availability checks.
Conflict and Error Responses
POST /api/generate
| HTTP | Condition | Typical detail |
|---|---|---|
202 |
Accepted; generation queued | {"project":"...","status":"generating"} |
400 |
Runtime validation failure | Invalid provider, empty effective model, bad local repo path, SSRF-protected URL |
401 |
Missing/invalid auth for /api/* |
Unauthorized |
403 |
Viewer role or non-admin using repo_path |
Write access required. / Local repo path access requires admin privileges |
409 |
Same owner/name/provider/model already generating | Variant 'name/provider/model' is already being generated |
422 |
Pydantic schema validation failure | Invalid URL, both/neither repo_url and repo_path, relative repo_path, bad enum, timeout <= 0 |
Examples verified in tests:
```68:71:tests/test_main.py async def test_generate_endpoint_invalid_url(client: AsyncClient) -> None: response = await client.post("/api/generate", json={"repo_url": "not-a-url"}) assert response.status_code == 422
```129:145:tests/test_main.py
async def test_generate_duplicate_variant(client: AsyncClient) -> None:
"""Test that generating the same variant twice returns 409."""
from docsfy.main import _generating
# gen_key format now includes owner: "owner/name/provider/model"
_generating["admin/repo/claude/opus"] = asyncio.create_task(asyncio.sleep(100))
try:
response = await client.post(
"/api/generate",
json={
"repo_url": "https://github.com/org/repo.git",
"ai_provider": "claude",
"ai_model": "opus",
},
)
assert response.status_code == 409
```268:275:tests/test_main.py async def test_generate_rejects_private_url(client: AsyncClient) -> None: """Test that SSRF protection rejects private/localhost URLs.""" response = await client.post( "/api/generate", json={"repo_url": "https://localhost/org/repo.git"}, ) # Should be rejected by URL validation (either Pydantic or SSRF check) assert response.status_code in (400, 422)
### Abort Endpoints (`/api/projects/.../abort`)
```569:621:src/docsfy/main.py
@app.post("/api/projects/{name}/abort")
async def abort_generation(request: Request, name: str) -> dict[str, str]:
"""Abort generation for any variant of the given project name.
Kept for backward compatibility. Finds the first active generation
matching the project name.
"""
_require_write_access(request)
name = _validate_project_name(name)
# Find active generation keys matching this project name
matching_keys = [
key
for key in _generating
if len(key.split("/", 3)) == 4 and key.split("/", 3)[1] == name
]
if request.state.is_admin and len(matching_keys) > 1:
distinct_owners = {key.split("/", 3)[0] for key in matching_keys}
if len(distinct_owners) > 1:
raise HTTPException(
status_code=409,
detail="Multiple owners found for this variant, please specify owner",
)
...
except asyncio.TimeoutError as exc:
logger.warning(f"[{name}] Abort requested but cancellation still in progress")
raise HTTPException(
status_code=409,
detail=f"Abort still in progress for '{name}'. Please retry shortly.",
) from exc
```642:699:src/docsfy/main.py @app.post("/api/projects/{name}/{provider}/{model}/abort") async def abort_variant( request: Request, name: str, provider: str, model: str ) -> dict[str, str]: _require_write_access(request) ... if not task: ... if not task: raise HTTPException( status_code=404, detail="No active generation for this variant", ) ... except asyncio.TimeoutError as exc: logger.warning( f"[{gen_key}] Abort requested but cancellation still in progress" ) raise HTTPException( status_code=409, detail=f"Abort still in progress for '{gen_key}'. Please retry shortly.", ) from exc
> **Warning:** The name-only abort endpoint is legacy and can become ambiguous for admins when multiple owners have active generations for the same project name.
## Async Failures and Status Polling
`/api/generate` only validates/enqueues. Runtime failures are reflected later in project status.
```720:744:src/docsfy/main.py
async def _run_generation(
repo_url: str | None,
repo_path: str | None,
project_name: str,
ai_provider: str,
ai_model: str,
ai_cli_timeout: int,
force: bool = False,
owner: str = "",
) -> None:
gen_key = f"{owner}/{project_name}/{ai_provider}/{ai_model}"
try:
cli_flags = ["--trust"] if ai_provider == "cursor" else None
available, msg = await check_ai_cli_available(
ai_provider, ai_model, cli_flags=cli_flags
)
if not available:
await update_project_status(
project_name,
ai_provider,
ai_model,
status="error",
owner=owner,
error_message=msg,
)
return
```803:812:src/docsfy/main.py except Exception as exc: logger.error(f"Generation failed for {project_name}: {exc}") await update_project_status( project_name, ai_provider, ai_model, status="error", owner=owner, error_message=str(exc), )
```409:419:src/docsfy/main.py
@app.get("/api/status")
async def status(request: Request) -> dict[str, Any]:
if request.state.is_admin:
projects = await list_projects()
else:
accessible = await get_user_accessible_projects(request.state.username)
projects = await list_projects(
owner=request.state.username, accessible=accessible
)
known_models = await get_known_models()
return {"projects": projects, "known_models": known_models}
Status values used by generation records:
```17:17:src/docsfy/storage.py VALID_STATUSES = frozenset({"generating", "ready", "error", "aborted"})
> **Tip:** Use `GET /api/status` during polling and consume `known_models` to drive provider-specific model suggestions in clients.
## Configuration (Provider/Model/Timeout)
Environment defaults in `.env`:
```1:8:.env.example
# REQUIRED - Admin key for user management (minimum 16 characters)
ADMIN_KEY=your-secure-admin-key-here-min-16-chars
# AI Configuration
AI_PROVIDER=claude
# [1m] = 1 million token context window, this is a valid model identifier
AI_MODEL=claude-opus-4-6[1m]
AI_CLI_TIMEOUT=60
Application defaults when env vars are unset:
```16:22:src/docsfy/config.py admin_key: str = "" # Required — validated at startup ai_provider: str = "claude" ai_model: str = "claude-opus-4-6[1m]" # [1m] = 1 million token context window ai_cli_timeout: int = Field(default=60, gt=0) log_level: str = "INFO" data_dir: str = "/data" secure_cookies: bool = True # Set to False for local HTTP dev
`docker-compose` loads `.env` directly:
```1:8:docker-compose.yaml
services:
docsfy:
build: .
ports:
- "8000:8000"
env_file: .env
volumes:
- ./data:/data