File size: 20,144 Bytes
3da2703
 
 
 
 
 
964549a
3da2703
 
c3ee9bf
e06b130
79693a5
 
3da2703
 
c3ee9bf
3da2703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f8ef60
 
 
 
3da2703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6862340
 
 
 
 
 
 
 
3da2703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f8ef60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79693a5
3da2703
 
 
79693a5
9f8ef60
3da2703
 
e06b130
 
211ca42
e06b130
 
964549a
 
 
 
 
 
 
f07669a
964549a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f07669a
 
 
 
 
 
964549a
0c9b62d
 
f4e1ae4
0c9b62d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4e1ae4
0c9b62d
 
e06b130
cccc148
 
 
 
 
 
 
 
 
e06b130
964549a
 
e06b130
 
 
 
964549a
 
e06b130
 
 
 
 
 
964549a
e06b130
964549a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e06b130
0c9b62d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35623b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
952727e
 
35623b6
 
 
 
 
 
 
 
 
 
 
952727e
 
35623b6
 
 
 
952727e
 
35623b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e06b130
cccc148
e06b130
 
35623b6
e06b130
 
 
211ca42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e06b130
211ca42
 
e06b130
3da2703
 
 
 
 
 
 
 
35623b6
3da2703
35623b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
952727e
 
 
 
 
 
 
 
 
 
 
c3ee9bf
 
35623b6
 
 
 
 
952727e
 
35623b6
c3ee9bf
35623b6
 
 
 
3da2703
 
 
964549a
3da2703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35623b6
3da2703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f755447
 
 
 
3da2703
 
 
f755447
 
3da2703
f4e1ae4
f755447
3da2703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
"""FastAPI app entrypoint for TalkingHeadBench OpenEnv server."""

from __future__ import annotations

import logging
import sys
from typing import Any, Literal
from pathlib import Path

from fastapi import HTTPException, Request, UploadFile
from fastapi.openapi.utils import get_openapi
from fastapi.responses import HTMLResponse
from openenv.core.env_server.http_server import create_app
from openenv.core.env_server.types import Action
from pydantic import BaseModel, Field
from starlette.datastructures import UploadFile as StarletteUploadFile

ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from models import TalkingHeadObservation
from server.artifact_ingest import (
    delete_ingested_bundle,
    get_ingested_bundle,
    ingest_artifacts_to_bundle,
    list_ingested_bundle_ids,
    store_ingested_bundle,
)
from server.llm_adapter import LLMAdapterError, analyze_ingested_bundle
from server.talking_head_environment import TalkingHeadEnvironment
from server.custom_ui import build_custom_ui
import os

os.environ["ENABLE_WEB_INTERFACE"] = "true"

log = logging.getLogger(__name__)

API_VERSION = "1.0"


class APIErrorDetail(BaseModel):
    code: str
    message: str
    retryable: bool = False


class NextStepHint(BaseModel):
    reset_payload: dict[str, Any]
    description: str


class IngestArtifactsResponse(BaseModel):
    api_version: str = Field(default=API_VERSION)
    ingestion_id: str
    bundle: dict[str, Any]
    next_step: NextStepHint


class ListIngestionsResponse(BaseModel):
    api_version: str = Field(default=API_VERSION)
    ingestion_ids: list[str]


class IngestionResponse(BaseModel):
    api_version: str = Field(default=API_VERSION)
    ingestion_id: str
    bundle: dict[str, Any]


class DeleteIngestionResponse(BaseModel):
    api_version: str = Field(default=API_VERSION)
    ingestion_id: str
    deleted: bool


class AnalyzeIngestionRequest(BaseModel):
    ingestion_id: str
    model_id: str | None = None
    api_key: str | None = None
    provider: Literal["auto", "openai", "anthropic", "huggingface", "local"] = "auto"
    task_tier: Literal[
        "image_audit",
        "clip_audit",
        "weight_audit",
        "easy",
        "medium",
        "hard",
    ] = "weight_audit"
    base_url: str | None = None
    max_tokens: int = Field(default=700, ge=64, le=4096)
    temperature: float = Field(default=0.2, ge=0.0, le=1.0)
    timeout_s: float = Field(default=45.0, ge=5.0, le=180.0)


class AnalyzeIngestionResponse(BaseModel):
    api_version: str = Field(default=API_VERSION)
    ingestion_id: str
    provider: str
    model_id: str
    report: str
    signal_digest: dict[str, Any]


class HealthResponse(BaseModel):
    api_version: str = Field(default=API_VERSION)
    status: Literal["ok"] = "ok"


def _raise_http_error(
    *,
    status_code: int,
    code: str,
    message: str,
    retryable: bool = False,
) -> None:
    raise HTTPException(
        status_code=status_code,
        detail=APIErrorDetail(code=code, message=message, retryable=retryable).model_dump(
            mode="json"
        ),
    )

# Monkeypatch to bypass OpenEnv's default "Playground" tab and enforce our custom theme
import gradio as gr
from server.custom_ui import build_custom_ui, custom_theme, custom_css

original_tabbed = gr.TabbedInterface
def skip_tabbed(interface_list, *args, **kwargs):
    if len(interface_list) == 2:
        return interface_list[1] # Only return standard custom_blocks
    return original_tabbed(interface_list, *args, **kwargs)
gr.TabbedInterface = skip_tabbed

original_mount = gr.mount_gradio_app
def override_mount_theme(fastapi_app, blocks, path, theme=None, css=None, **kwargs):
    return original_mount(fastapi_app, blocks, path, theme=custom_theme, css=custom_css, **kwargs)
gr.mount_gradio_app = override_mount_theme

app = create_app(
    TalkingHeadEnvironment,
    Action,
    TalkingHeadObservation,
    env_name="talking_head_bench",
    gradio_builder=build_custom_ui,
)

# ---------------------------------------------------------------------------
# Custom OpenAPI schema: ensure clips is typed as array-of-binary files and
# param_config_json has no spurious string default.
# ---------------------------------------------------------------------------

def _patch_ingest_artifacts_props(props: dict[str, Any]) -> None:
    if "clips" in props:
        props["clips"] = {
            "title": "Clips",
            "description": "One or more video clips (.mp4 / .mov / .avi / .mkv / .webm)",
            "type": "array",
            "items": {"type": "string", "format": "binary"},
            "default": [],
        }

    # Convert OpenAPI 3.1 contentMediaType encoding into Swagger-friendly binary format.
    for key in ("reference_image", "lora_weights", "tokenizer_config"):
        field = props.get(key)
        if not isinstance(field, dict):
            continue
        variants = field.get("anyOf")
        if not isinstance(variants, list):
            continue
        for item in variants:
            if isinstance(item, dict) and item.get("type") == "string":
                item.pop("contentMediaType", None)
                item["format"] = "binary"

    if "param_config_json" in props and isinstance(props["param_config_json"], dict):
        field = props["param_config_json"]
        field.pop("anyOf", None)
        field["type"] = "string"
        field["default"] = ""
        field["example"] = ""
        field.pop("nullable", None)


def _patch_analyze_ingestion_props(props: dict[str, Any]) -> None:
    allowed_keys = {"ingestion_id", "model_id", "api_key", "provider", "task_tier"}
    for key in list(props.keys()):
        if key not in allowed_keys:
            props.pop(key, None)


def _patch_analyze_ingestion_schema(schema_obj: dict[str, Any]) -> None:
    props = schema_obj.get("properties")
    if isinstance(props, dict):
        _patch_analyze_ingestion_props(props)

    required = schema_obj.get("required")
    if isinstance(required, list):
        schema_obj["required"] = [
            field_name
            for field_name in required
            if field_name in {"ingestion_id", "model_id", "api_key", "provider", "task_tier"}
        ]

def _patched_openapi() -> dict:
    if app.openapi_schema:
        return app.openapi_schema

    schema = get_openapi(
        title=app.title,
        version=app.version,
        description=app.description,
        routes=app.routes,
    )

    components = schema.get("components", {}).get("schemas", {})

    for path_item in schema.get("paths", {}).values():
        for operation in path_item.values():
            if not isinstance(operation, dict):
                continue

            request_schema = (
                operation
                .get("requestBody", {})
                .get("content", {})
                .get("multipart/form-data", {})
                .get("schema", {})
            )
            if not isinstance(request_schema, dict):
                continue

            props: dict[str, Any] | None = None
            if isinstance(request_schema.get("properties"), dict):
                props = request_schema["properties"]
            elif isinstance(request_schema.get("$ref"), str):
                ref_name = str(request_schema["$ref"]).rsplit("/", 1)[-1]
                ref_schema = components.get(ref_name)
                if isinstance(ref_schema, dict) and isinstance(ref_schema.get("properties"), dict):
                    props = ref_schema["properties"]

            if props:
                _patch_ingest_artifacts_props(props)

    # Safety net: patch ingest body component directly.
    for schema_name, schema_obj in components.items():
        if "ingest_artifacts" not in schema_name.lower():
            continue
        if isinstance(schema_obj, dict) and isinstance(schema_obj.get("properties"), dict):
            _patch_ingest_artifacts_props(schema_obj["properties"])

    analyze_request_schema = (
        schema
        .get("paths", {})
        .get("/analyze-ingestion", {})
        .get("post", {})
        .get("requestBody", {})
        .get("content", {})
        .get("application/json", {})
        .get("schema", {})
    )
    if isinstance(analyze_request_schema, dict):
        if isinstance(analyze_request_schema.get("properties"), dict):
            _patch_analyze_ingestion_schema(analyze_request_schema)
        elif isinstance(analyze_request_schema.get("$ref"), str):
            ref_name = str(analyze_request_schema["$ref"]).rsplit("/", 1)[-1]
            ref_schema = components.get(ref_name)
            if isinstance(ref_schema, dict):
                _patch_analyze_ingestion_schema(ref_schema)

    # Safety net: patch analyze request body component directly.
    for schema_name, schema_obj in components.items():
        if "analyzeingestionrequest" not in schema_name.lower():
            continue
        if isinstance(schema_obj, dict):
            _patch_analyze_ingestion_schema(schema_obj)

    # ---------------------------------------------------------------------------
    # /ingest-artifacts: the endpoint now takes a raw Request (to filter out the
    # empty-string placeholders Swagger sends for unfilled file fields), so FastAPI
    # no longer auto-generates its requestBody.  Inject it manually here.
    # ---------------------------------------------------------------------------
    _INGEST_REQUEST_BODY: dict[str, Any] = {
        "required": True,
        "content": {
            "multipart/form-data": {
                "schema": {
                    "type": "object",
                    "properties": {
                        "reference_image": {
                            "title": "Reference Image",
                            "description": "Reference portrait image (jpg / png / webp)",
                            "type": "string",
                            "format": "binary",
                        },
                        "clips": {
                            "title": "Clips",
                            "description": "One or more video clips (.mp4 / .mov / .avi / .mkv / .webm)",
                            "type": "array",
                            "items": {"type": "string", "format": "binary"},
                            "default": [],
                        },
                        "lora_weights": {
                            "title": "LoRA Weights",
                            "description": "LoRA weight file (.safetensors / .bin / .pt)",
                            "type": "string",
                            "format": "binary",
                        },
                        "tokenizer_config": {
                            "title": "Tokenizer Config",
                            "description": "Tokenizer config JSON file",
                            "type": "string",
                            "format": "binary",
                        },
                        "prompt": {
                            "title": "Prompt",
                            "description": "Text prompt describing the talking-head generation task",
                            "type": "string",
                            "default": "",
                        },
                        "param_config_json": {
                            "title": "Param Config JSON",
                            "description": "Optional JSON string with generation parameter overrides",
                            "type": "string",
                            "default": "",
                        },
                    },
                }
            }
        },
    }
    ingest_post = (
        schema.get("paths", {}).get("/ingest-artifacts", {}).get("post")
    )
    if isinstance(ingest_post, dict):
        ingest_post["requestBody"] = _INGEST_REQUEST_BODY

    app.openapi_schema = schema
    return app.openapi_schema



app.openapi = _patched_openapi  # type: ignore[method-assign]


# Serve Scalar as the API reference UI (replaces the default Swagger UI).
_SCALAR_HTML = """
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>TalkingHeadBench API Reference</title>
    <style>body { margin: 0; }</style>
  </head>
  <body>
    <script
      id="api-reference"
      data-url="/openapi.json"
      data-configuration='{
        "theme": "purple",
        "layout": "modern",
        "defaultHttpClient": {"targetKey": "python", "clientKey": "requests"}
      }'
    ></script>
    <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
  </body>
</html>
"""


@app.get("/docs", include_in_schema=False)
async def scalar_ui(request: Request) -> HTMLResponse:  # noqa: ARG001
    return HTMLResponse(_SCALAR_HTML)


@app.get("/healthz", response_model=HealthResponse)
async def health() -> HealthResponse:
    """Basic health endpoint for deployment probes."""
    return HealthResponse()


@app.post("/ingest-artifacts", response_model=IngestArtifactsResponse)
async def ingest_artifacts(request: Request) -> IngestArtifactsResponse:
    """Upload artifacts, extract signals, and return a reusable ingestion id."""

    # Parse the raw multipart form ourselves so we can filter out the empty-string
    # placeholders that Swagger UI sends for optional file fields that aren't filled in.
    # FastAPI's automatic File() injection fails with "Expected UploadFile, received str"
    # whenever Swagger submits an empty string for clips / reference_image / etc.
    try:
        form = await request.form()
    except Exception as exc:  # noqa: BLE001
        _raise_http_error(
            status_code=400,
            code="invalid_multipart_form",
            message=f"Could not parse multipart form: {exc}",
        )

    def _as_upload(value: object) -> UploadFile | None:
        """Return value only if it is a real UploadFile.

        Starlette only produces UploadFile objects for multipart parts that
        carry a ``filename`` field in their Content-Disposition header, which
        is precisely what a browser/Scalar/Swagger sends when a user picks a
        real file.  Unfilled optional file fields are sent as plain strings
        (even empty strings like ``""``) — those will never be UploadFile
        instances, so the isinstance check alone is a safe, complete filter.
        Adding ``and value.filename`` would incorrectly discard uploads whose
        Content-Disposition says ``filename=""`` (falsy but still valid).
        """
        # request.form() returns Starlette UploadFile objects.
        return value if isinstance(value, StarletteUploadFile) else None

    reference_image: UploadFile | None = _as_upload(form.get("reference_image"))
    lora_weights: UploadFile | None = _as_upload(form.get("lora_weights"))
    tokenizer_config: UploadFile | None = _as_upload(form.get("tokenizer_config"))

    # clips may arrive as a single value or repeated field; filter out any
    # plain-string placeholders Swagger/Scalar sends for unfilled array items.
    raw_clips = form.getlist("clips")
    clips: list[UploadFile] = [f for f in raw_clips if isinstance(f, StarletteUploadFile)]

    prompt: str = str(form.get("prompt") or "")
    param_config_json: str = str(form.get("param_config_json") or "")

    try:
        bundle = await ingest_artifacts_to_bundle(
            reference_image=reference_image,
            clips=clips,
            lora_weights=lora_weights,
            tokenizer_config=tokenizer_config,
            prompt=prompt,
            param_config_json=param_config_json,
        )
    except ValueError as exc:
        _raise_http_error(
            status_code=400,
            code="invalid_ingestion_request",
            message=str(exc),
        )
    except Exception:  # noqa: BLE001
        log.exception("Artifact ingestion failed")
        _raise_http_error(
            status_code=500,
            code="internal_ingestion_error",
            message="Artifact ingestion failed.",
            retryable=True,
        )

    ingestion_id = store_ingested_bundle(bundle)
    stored_bundle = get_ingested_bundle(ingestion_id)
    if stored_bundle is None:
        _raise_http_error(
            status_code=500,
            code="ingestion_persistence_failed",
            message="Failed to persist ingested bundle.",
            retryable=True,
        )

    return IngestArtifactsResponse(
        ingestion_id=ingestion_id,
        bundle=stored_bundle,
        next_step=NextStepHint(
            reset_payload={"ingestion_id": ingestion_id},
            description="Pass this payload to env.reset(...) over OpenEnv WebSocket.",
        ),
    )



@app.get("/ingestions", response_model=ListIngestionsResponse)
async def list_ingestions() -> ListIngestionsResponse:
    """List available ingestion ids currently held in memory."""
    return ListIngestionsResponse(ingestion_ids=list_ingested_bundle_ids())


@app.get("/ingestions/{ingestion_id}", response_model=IngestionResponse)
async def get_ingestion(ingestion_id: str) -> IngestionResponse:
    """Retrieve a previously ingested signal bundle by id."""
    bundle = get_ingested_bundle(ingestion_id)
    if bundle is None:
        _raise_http_error(
            status_code=404,
            code="unknown_ingestion_id",
            message=f"Unknown ingestion id: {ingestion_id}",
        )
    return IngestionResponse(ingestion_id=ingestion_id, bundle=bundle)


@app.delete("/ingestions/{ingestion_id}", response_model=DeleteIngestionResponse)
async def delete_ingestion(ingestion_id: str) -> DeleteIngestionResponse:
    """Delete an ingested bundle from in-memory storage."""
    removed = delete_ingested_bundle(ingestion_id)
    if not removed:
        _raise_http_error(
            status_code=404,
            code="unknown_ingestion_id",
            message=f"Unknown ingestion id: {ingestion_id}",
        )
    return DeleteIngestionResponse(ingestion_id=ingestion_id, deleted=True)


@app.post("/analyze-ingestion", response_model=AnalyzeIngestionResponse)
async def analyze_ingestion(request: AnalyzeIngestionRequest) -> AnalyzeIngestionResponse:
    """Generate an LLM report from a stored ingestion bundle."""
    bundle = get_ingested_bundle(request.ingestion_id)
    if bundle is None:
        _raise_http_error(
            status_code=404,
            code="unknown_ingestion_id",
            message=f"Unknown ingestion id: {request.ingestion_id}",
        )

    resolved_model_id = request.model_id or os.environ.get("MODEL_NAME")
    resolved_api_key = request.api_key or os.environ.get("HF_TOKEN")
    resolved_base_url = request.base_url or os.environ.get("API_BASE_URL")

    try:
        result = analyze_ingested_bundle(
            bundle,
            model_id=resolved_model_id,
            api_key=resolved_api_key,
            provider=request.provider,
            task_tier=request.task_tier,
            base_url=resolved_base_url,
            max_tokens=request.max_tokens,
            temperature=request.temperature,
            timeout_s=request.timeout_s,
        )
    except LLMAdapterError as exc:
        _raise_http_error(
            status_code=exc.status_code,
            code=exc.code,
            message=exc.message,
            retryable=exc.retryable,
        )
    except Exception:  # noqa: BLE001
        log.exception("Ingestion analysis failed")
        _raise_http_error(
            status_code=502,
            code="analysis_provider_error",
            message="Failed to generate analysis report.",
            retryable=True,
        )

    return AnalyzeIngestionResponse(
        ingestion_id=request.ingestion_id,
        provider=str(result["provider"]),
        model_id=str(result["model_id"]),
        report=str(result["report"]),
        signal_digest=dict(result.get("signal_digest") or {}),
    )


def main() -> None:
    """Run the TalkingHeadBench environment server."""
    import uvicorn

    uvicorn.run(app, host="0.0.0.0", port=8000)


if __name__ == "__main__":
    main()