Source code for novelai_image_mcp.nai.response

"""NovelAI HTTP, ZIP, and MessagePack response parsing."""

from __future__ import annotations

from dataclasses import dataclass
import io
import json
import struct
from typing import Any, cast
import zipfile

import msgpack

from .exceptions import (
    NovelAIAuthenticationError,
    NovelAIConcurrencyError,
    NovelAIInsufficientCreditsError,
    NovelAIProviderError,
    NovelAIResponseError,
    NovelAIValidationError,
)


[docs] @dataclass(frozen=True, slots=True) class NovelAIImage: filename: str data: bytes
[docs] @dataclass(frozen=True, slots=True) class GenerationEvent: event_type: str sample_index: int step_index: int generation_id: str sigma: float image: NovelAIImage
def _bounded_error(content: bytes) -> str: raw = content[:2_048] try: value = json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError): return raw.decode("utf-8", errors="replace") return json.dumps(value, ensure_ascii=False)
[docs] def check_status(status_code: int, content: bytes) -> None: """Raise the domain error associated with an HTTP status.""" if status_code < 400: return detail = _bounded_error(content) error_type: type[NovelAIProviderError | NovelAIValidationError] if status_code == 400: error_type = NovelAIValidationError elif status_code == 401: error_type = NovelAIAuthenticationError elif status_code == 402: error_type = NovelAIInsufficientCreditsError elif status_code == 429: error_type = NovelAIConcurrencyError else: error_type = NovelAIProviderError raise error_type(f"NovelAI HTTP {status_code}: {detail}")
[docs] def parse_zip_images(content: bytes) -> tuple[NovelAIImage, ...]: try: with zipfile.ZipFile(io.BytesIO(content)) as archive: return tuple( NovelAIImage(filename=name, data=archive.read(name)) for name in archive.namelist() if not name.endswith("/") ) except (OSError, zipfile.BadZipFile) as exc: raise NovelAIResponseError("NovelAI returned an invalid ZIP response") from exc
def _event(value: object) -> GenerationEvent | None: if not isinstance(value, dict): return None data = cast("dict[str, Any]", value) event_type = data.get("event_type") if event_type == "retry": return None if event_type == "error": raise NovelAIProviderError( f"NovelAI stream error {data.get('code')}: {data.get('message')}" ) image = data.get("image") if event_type not in {"intermediate", "final"} or not isinstance(image, bytes): return None sample_index = int(data.get("samp_ix", 0)) extension = "png" if image.startswith(b"\x89PNG") else "jpg" return GenerationEvent( event_type=event_type, sample_index=sample_index, step_index=int(data.get("step_ix", 0)), generation_id=str(data.get("gen_id", "")), sigma=float(data.get("sigma", 0)), image=NovelAIImage( filename=f"sample-{sample_index}-{event_type}.{extension}", data=image, ), )
[docs] class MessagePackStreamParser: """Incrementally parse NovelAI's big-endian length-prefixed MessagePack.""" def __init__(self) -> None: self._buffer = bytearray() self._expected: int | None = None
[docs] def feed(self, chunk: bytes) -> tuple[GenerationEvent, ...]: self._buffer.extend(chunk) events: list[GenerationEvent] = [] while True: if self._expected is None: if len(self._buffer) < 4: break frame_length = struct.unpack(">I", self._buffer[:4])[0] self._expected = frame_length del self._buffer[:4] if frame_length > 128 * 1024 * 1024: raise NovelAIResponseError("MessagePack frame is too large") expected = self._expected if expected is None: break if len(self._buffer) < expected: break payload = bytes(self._buffer[:expected]) del self._buffer[:expected] self._expected = None try: value = msgpack.unpackb(payload, raw=False) except (ValueError, msgpack.ExtraData) as exc: raise NovelAIResponseError("invalid MessagePack frame") from exc parsed = _event(value) if parsed is not None: events.append(parsed) return tuple(events)
[docs] def finish(self) -> None: if self._expected is not None or self._buffer: raise NovelAIResponseError("truncated MessagePack stream")
[docs] def parse_messagepack_events(content: bytes) -> tuple[GenerationEvent, ...]: parser = MessagePackStreamParser() events = parser.feed(content) parser.finish() return events
[docs] def parse_messagepack_images(content: bytes) -> tuple[NovelAIImage, ...]: return tuple( event.image for event in parse_messagepack_events(content) if event.event_type == "final" )