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