File size: 4,367 Bytes
afff449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a42bc3b
afff449
a42bc3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afff449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a42bc3b
 
 
 
 
 
 
 
afff449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a42bc3b
 
 
 
 
 
 
 
 
 
afff449
 
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
"""
ComputeProvider abstraction (Wave A — Batch 6 / HP-1).

Stops HomePilot from assuming ComfyUI / the LLM are always local. Two
implementations:

  * ``LocalComputeProvider``        — today's behaviour, wrapping the existing
                                       ``comfy.run_workflow`` and ``llm.chat``.
  * ``OllaBridgeCloudComputeProvider`` — routes generation to a paired GPU
                                       through OllaBridge Cloud's job API.

The default compute mode is ``local`` (config.HOMEPILOT_COMPUTE_MODE), so
introducing this seam changes no existing behaviour — every persona, gallery,
avatar, and Imagine/Animate path keeps working exactly as before until a user
opts into cloud or auto mode.
"""

from __future__ import annotations

import abc
import json
from dataclasses import dataclass, field
from typing import Any, AsyncIterator


async def iter_openai_sse(resp: Any) -> AsyncIterator[str]:
    """Parse an OpenAI-style ``/v1/chat/completions`` SSE stream, yielding the
    ``choices[0].delta.content`` text deltas. Tolerates keep-alive blanks and
    the terminal ``data: [DONE]``. ``resp`` is a streaming httpx response."""
    async for line in resp.aiter_lines():
        line = line.strip()
        if not line or not line.startswith("data:"):
            continue
        data = line[len("data:"):].strip()
        if data == "[DONE]":
            break
        try:
            obj = json.loads(data)
        except Exception:
            continue
        choices = obj.get("choices") or []
        if not choices:
            continue
        delta = (choices[0].get("delta") or {}).get("content")
        if delta:
            yield delta


@dataclass
class GeneratedMedia:
    """Normalised result of a generation call across providers."""
    images: list[str] = field(default_factory=list)   # served URLs
    videos: list[str] = field(default_factory=list)    # served URLs
    meta: dict[str, Any] = field(default_factory=dict)  # provider/job detail


class ComputeProvider(abc.ABC):
    """Interface every compute backend implements."""

    name: str = "base"

    @abc.abstractmethod
    async def generate_image(
        self,
        *,
        prompt: str,
        model: str | None = None,
        negative_prompt: str = "",
        width: int | None = None,
        height: int | None = None,
        steps: int | None = None,
        seed: int | None = None,
        **extra: Any,
    ) -> GeneratedMedia:
        ...

    @abc.abstractmethod
    async def available(self, modality: str | None = None) -> bool:
        """Can this provider serve a request right now?

        ``modality`` (``"chat"``/``"multimodal"``/``"image"``/``"video"``/
        ``"edit"``) lets a provider answer for the specific runtime a request
        needs — e.g. a healthy Ollama should report *available for chat* even
        when the ComfyUI image runtime is down. ``None`` means "any runtime".
        """
        ...

    # The following have sensible defaults so a provider only overrides what it
    # supports; callers can feature-detect via ``available()`` / ``describe()``.

    async def edit_image(
        self, *, prompt: str, image: str, model: str | None = None, **extra: Any
    ) -> GeneratedMedia:
        raise NotImplementedError(f"{self.name} does not support image editing")

    async def generate_video(
        self,
        *,
        prompt: str | None = None,
        image: str | None = None,
        model: str | None = None,
        **extra: Any,
    ) -> GeneratedMedia:
        raise NotImplementedError(f"{self.name} does not support video generation")

    async def chat(self, *, model: str, messages: list[dict], **extra: Any) -> dict:
        raise NotImplementedError(f"{self.name} does not support chat")

    async def chat_stream(
        self, *, model: str, messages: list[dict], **extra: Any
    ) -> AsyncIterator[str]:
        """Yield assistant text deltas. Default: adapt the non-streaming ``chat``
        into a single chunk, so every provider streams *something* uniformly."""
        result = await self.chat(model=model, messages=messages, **extra)
        text = ((result.get("choices") or [{}])[0].get("message", {}) or {}).get("content", "")
        if text:
            yield text

    def describe(self) -> dict[str, Any]:
        return {"provider": self.name}