feat: stream provider responses

This commit is contained in:
2026-07-30 19:38:53 +02:00
parent 9148193806
commit 00a5a6ce8c
12 changed files with 850 additions and 41 deletions
+44 -1
View File
@@ -2,10 +2,15 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from javis.memory.sqlite_store import ChatSession, SQLiteSessionStore
from javis.providers.base import ChatMessage, LocalModelProvider
from javis.providers.base import (
ChatMessage,
InvalidProviderResponseError,
LocalModelProvider,
)
class SessionProviderMismatchError(RuntimeError):
@@ -59,5 +64,43 @@ class ChatService:
self.store.append_exchange(session_id, normalized, response)
return response
def stream_send(self, session_id: str, text: str) -> Iterator[str]:
normalized = text.strip()
if not normalized:
raise ValueError("Eine leere Nachricht wird nicht gesendet.")
loaded = self.load_session(session_id)
messages = [*loaded.messages, ChatMessage("user", normalized)]
stream_method = getattr(self.provider, "stream_chat", None)
stream = (
stream_method(messages)
if callable(stream_method)
else iter((self.provider.chat(messages),))
)
chunks: list[str] = []
completed = False
try:
for chunk in stream:
if not isinstance(chunk, str):
raise InvalidProviderResponseError(
"Der Provider lieferte einen ungültigen Streaming-Abschnitt."
)
if not chunk:
continue
chunks.append(chunk)
yield chunk
completed = True
finally:
if not completed:
close_stream = getattr(stream, "close", None)
if callable(close_stream):
close_stream()
response = "".join(chunks).strip()
if not response:
raise InvalidProviderResponseError(
"Der Provider lieferte keine verwendbare Streaming-Antwort."
)
self.store.append_exchange(session_id, normalized, response)
def clear_session(self, session_id: str) -> None:
self.store.clear_messages(session_id)
+259 -12
View File
@@ -2,17 +2,19 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError
from javis.providers.base import (
ChatMessage,
InvalidApiKeyError,
InvalidProviderResponseError,
LocalModelProvider,
MissingApiKeyError,
ProviderError,
ProviderUsage,
ResponseAbortedError,
)
from javis.security.privacy import CloudPolicy, PrivacyDecision, PrivacyRouter
@@ -165,6 +167,245 @@ class HybridProvider:
)
return response
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
current_text = messages[-1].content if messages else ""
decision = self._privacy_router.classify(
current_text,
cloud_requested=self.mode == "gemini",
)
if self.mode == "local":
yield from self._stream_local(
messages,
decision,
"lokaler Modus",
fallback=False,
cloud_suppressed_by_local_mode=True,
)
return
if decision.policy is CloudPolicy.NEVER:
yield from self._stream_local(
messages,
decision,
decision.reason,
fallback=True,
)
return
approved = decision.policy is CloudPolicy.ALLOWED
if decision.policy is CloudPolicy.ASK:
approved = self._approval_callback(decision)
if not approved:
yield from self._stream_local(
messages,
decision,
"Cloudfreigabe abgelehnt",
fallback=True,
)
return
unavailable_reason = self._cloud_unavailable_reason()
if unavailable_reason:
yield from self._stream_local(
messages,
decision,
unavailable_reason,
fallback=True,
)
return
if len(current_text) > self._max_cloud_input_chars:
yield from self._stream_local(
messages,
decision,
"Nachricht überschreitet das lokale Cloud-Größenlimit",
fallback=True,
)
return
cloud_messages = self._privacy_router.minimal_context(
messages,
current_policy=decision.policy,
approved=approved,
max_chars=self._max_cloud_input_chars,
max_messages=self._max_cloud_context_messages,
)
if not cloud_messages:
yield from self._stream_local(
messages,
decision,
"kein freigegebener Cloudkontext",
fallback=True,
)
return
emitted = False
try:
cloud_provider = self._cloud_provider_factory()
for chunk in self._provider_chunks(cloud_provider, cloud_messages):
emitted = True
yield chunk
except InvalidApiKeyError as exc:
self._cloud_session_disabled = True
if emitted:
self._raise_interrupted_cloud(decision, exc)
yield from self._stream_cloud_failure(
messages,
decision,
exc,
disable_notice=True,
)
return
except (MissingApiKeyError, ProviderError) as exc:
if emitted:
self._raise_interrupted_cloud(decision, exc)
yield from self._stream_cloud_failure(messages, decision, exc)
return
if not emitted:
error = InvalidProviderResponseError(
"Gemini hat keine verwendbare Streaming-Antwort geliefert."
)
yield from self._stream_cloud_failure(messages, decision, error)
return
usage = getattr(cloud_provider, "last_usage", ProviderUsage())
self._record(
ProviderEvent(
provider=cloud_provider.name,
model=cloud_provider.model,
success=True,
error_category=None,
fallback=False,
privacy_policy=decision.policy.value,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
)
)
self.last_route = RouteStatus(
provider=cloud_provider.name,
privacy_policy=decision.policy,
fallback=False,
cloud_suppressed_by_local_mode=False,
technical_fallback=False,
reason="Cloudstream erfolgreich",
)
@staticmethod
def _provider_chunks(
provider: LocalModelProvider,
messages: list[ChatMessage],
) -> Iterator[str]:
stream_method = getattr(provider, "stream_chat", None)
chunks = (
stream_method(messages) if callable(stream_method) else iter((provider.chat(messages),))
)
completed = False
try:
for chunk in chunks:
if not isinstance(chunk, str):
raise InvalidProviderResponseError(
"Der Provider lieferte einen ungültigen Streaming-Abschnitt."
)
if chunk:
yield chunk
completed = True
finally:
if not completed:
close = getattr(chunks, "close", None)
if callable(close):
close()
def _stream_cloud_failure(
self,
messages: list[ChatMessage],
decision: PrivacyDecision,
error: ProviderError,
*,
disable_notice: bool = False,
) -> Iterator[str]:
self._record_cloud_error(decision, error)
reason = "Gemini nicht verfügbar"
if disable_notice:
reason = "Gemini-Schlüssel abgelehnt; Cloud für diese Sitzung deaktiviert"
yield from self._stream_local(
messages,
decision,
reason,
fallback=True,
technical_fallback=True,
)
def _raise_interrupted_cloud(
self,
decision: PrivacyDecision,
error: ProviderError,
) -> None:
self._record_cloud_error(decision, error)
self.last_route = RouteStatus(
provider="gemini",
privacy_policy=decision.policy,
fallback=False,
cloud_suppressed_by_local_mode=False,
technical_fallback=True,
reason="Cloudstream nach Teilausgabe abgebrochen",
)
raise ResponseAbortedError(
"Der Gemini-Stream wurde nach einer Teilausgabe abgebrochen; "
"es wurde kein lokaler Ersatz angehängt."
) from error
def _stream_local(
self,
messages: list[ChatMessage],
decision: PrivacyDecision,
reason: str,
*,
fallback: bool,
cloud_suppressed_by_local_mode: bool = False,
technical_fallback: bool = False,
) -> Iterator[str]:
if fallback:
self._notice_callback(f"{reason} lokale Antwort mit Ollama.")
emitted = False
try:
for chunk in self._provider_chunks(self.local_provider, messages):
emitted = True
yield chunk
except ProviderError as exc:
self._record(
ProviderEvent(
provider=self.local_provider.name,
model=self.local_provider.model,
success=False,
error_category=type(exc).__name__,
fallback=fallback,
privacy_policy=decision.policy.value,
)
)
raise
if not emitted:
raise InvalidProviderResponseError(
"Ollama hat keine verwendbare Streaming-Antwort geliefert."
)
self._record(
ProviderEvent(
provider=self.local_provider.name,
model=self.local_provider.model,
success=True,
error_category=None,
fallback=fallback,
privacy_policy=decision.policy.value,
)
)
self.last_route = RouteStatus(
provider=self.local_provider.name,
privacy_policy=decision.policy,
fallback=fallback,
cloud_suppressed_by_local_mode=cloud_suppressed_by_local_mode,
technical_fallback=technical_fallback,
reason=reason,
)
def _cloud_unavailable_reason(self) -> str | None:
if not self._cloud_enabled:
return "Gemini ist lokal nicht aktiviert"
@@ -188,17 +429,7 @@ class HybridProvider:
*,
disable_notice: bool = False,
) -> str:
error_category = type(error).__name__
self._record(
ProviderEvent(
provider="gemini",
model=self._cloud_model,
success=False,
error_category=error_category,
fallback=False,
privacy_policy=decision.policy.value,
)
)
self._record_cloud_error(decision, error)
reason = "Gemini nicht verfügbar"
if disable_notice:
reason = "Gemini-Schlüssel abgelehnt; Cloud für diese Sitzung deaktiviert"
@@ -210,6 +441,22 @@ class HybridProvider:
technical_fallback=True,
)
def _record_cloud_error(
self,
decision: PrivacyDecision,
error: ProviderError,
) -> None:
self._record(
ProviderEvent(
provider="gemini",
model=self._cloud_model,
success=False,
error_category=type(error).__name__,
fallback=False,
privacy_policy=decision.policy.value,
)
)
def _local(
self,
messages: list[ChatMessage],
+69 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import argparse
import getpass
import re
import sys
from collections.abc import Callable, Sequence
from pathlib import Path
@@ -27,6 +28,7 @@ from javis.security.secrets import SecretProvider, SecretStoreError
InputFunction = Callable[[str], str]
OutputFunction = Callable[[str], None]
StreamOutputFunction = Callable[[str], None]
StatusFunction = Callable[[], list[str]]
HELP_TEXT = """Befehle:
@@ -144,12 +146,48 @@ def _show_sessions(sessions: list[ChatSession], output: OutputFunction) -> None:
)
class TerminalMarkdownRenderer:
"""Turn common Markdown markers into readable incremental terminal text."""
_tail_size = 3
def __init__(self) -> None:
self._pending = ""
@staticmethod
def _clean(text: str) -> str:
text = re.sub(r"(?m)^[ \t]{0,3}#{1,6}[ \t]+", "", text)
for marker in ("```", "**", "__", "~~", "`"):
text = text.replace(marker, "")
return text
def feed(self, chunk: str) -> str:
combined = self._pending + chunk
if len(combined) <= self._tail_size:
self._pending = combined
return ""
visible = combined[: -self._tail_size]
self._pending = combined[-self._tail_size :]
return self._clean(visible)
def finish(self) -> str:
visible = self._clean(self._pending)
self._pending = ""
return visible
def _terminal_write(text: str) -> None:
sys.stdout.write(text)
sys.stdout.flush()
def run_chat(
service: ChatService,
*,
session_id: str | None = None,
input_fn: InputFunction = input,
output: OutputFunction = print,
stream_output: StreamOutputFunction | None = None,
status_fn: StatusFunction | None = None,
) -> int:
try:
@@ -262,12 +300,41 @@ def run_chat(
output("Unbekannter Befehl. /help zeigt die verfügbaren Befehle.")
continue
stream = service.stream_send(active_id, entered)
writer = stream_output or (_terminal_write if output is print else output)
renderer = TerminalMarkdownRenderer()
started = False
try:
response = service.send(active_id, entered)
output(f"Javis: {response}")
for chunk in stream:
visible = renderer.feed(chunk)
if not visible:
continue
if not started:
writer("Javis: ")
started = True
writer(visible)
remaining = renderer.finish()
if remaining:
if not started:
writer("Javis: ")
started = True
writer(remaining)
if started:
writer("\n")
except KeyboardInterrupt:
close = getattr(stream, "close", None)
if callable(close):
close()
if started:
writer("\n")
output("Generierung abgebrochen. Die unvollständige Antwort wurde nicht gespeichert.")
except (ProviderError, SessionStoreError, SessionProviderMismatchError) as exc:
if started:
writer("\n")
output(f"Fehler: {exc}")
except ValueError as exc:
if started:
writer("\n")
output(f"Fehler: {exc}")
+11
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
@@ -70,3 +71,13 @@ class LocalModelProvider(Protocol):
def chat(self, messages: list[ChatMessage]) -> str:
"""Return one complete assistant response."""
...
@runtime_checkable
class StreamingModelProvider(Protocol):
name: str
model: str
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
"""Yield only visible assistant text chunks."""
...
+69 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterator
from typing import Any
import httpx
@@ -49,6 +49,7 @@ class GeminiProvider:
self._config = types.GenerateContentConfig(
candidate_count=1,
max_output_tokens=max_output_tokens,
thinking_config=types.ThinkingConfig(include_thoughts=False),
)
self._client = client_factory(
api_key=api_key,
@@ -104,6 +105,73 @@ class GeminiProvider:
)
return text.strip()
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
contents = [
types.Content(
role="model" if message.role == "assistant" else "user",
parts=[types.Part.from_text(text=message.content)],
)
for message in messages
]
self.last_usage = ProviderUsage()
visible_text_received = False
try:
responses = self._client.models.generate_content_stream(
model=self.model,
contents=contents,
config=self._config,
)
for response in responses:
usage = getattr(response, "usage_metadata", None)
if usage is not None:
self.last_usage = ProviderUsage(
input_tokens=self._token_count(usage, "prompt_token_count"),
output_tokens=self._token_count(
usage,
"candidates_token_count",
),
)
for text in self._visible_text_parts(response):
visible_text_received = True
yield text
except errors.APIError as exc:
self._raise_api_error(exc)
except httpx.TimeoutException as exc:
raise ProviderTimeoutError("Gemini hat nicht rechtzeitig geantwortet.") from exc
except httpx.NetworkError as exc:
raise CloudNetworkError(
"Gemini ist wegen eines Netzwerkfehlers nicht erreichbar."
) from exc
except (OSError, ConnectionError) as exc:
raise CloudNetworkError(
"Gemini ist wegen eines Netzwerkfehlers nicht erreichbar."
) from exc
if not visible_text_received:
raise InvalidProviderResponseError(
"Gemini hat keine verwendbare Streaming-Antwort geliefert."
)
@staticmethod
def _visible_text_parts(response: object) -> Iterator[str]:
candidates = getattr(response, "candidates", None)
if isinstance(candidates, list) and candidates:
for candidate in candidates:
content = getattr(candidate, "content", None)
parts = getattr(content, "parts", None)
if not isinstance(parts, list):
continue
for part in parts:
if getattr(part, "thought", False):
continue
text = getattr(part, "text", None)
if isinstance(text, str) and text:
yield text
return
text = getattr(response, "text", None)
if isinstance(text, str) and text:
yield text
@staticmethod
def _token_count(usage: object, name: str) -> int | None:
value = getattr(usage, name, None)
+74
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import socket
from collections.abc import Iterator
from dataclasses import dataclass
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
@@ -107,3 +108,76 @@ class OllamaProvider:
"Ollama hat keine verwendbare Textantwort geliefert."
)
return content.strip()
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
payload = {
"model": self.model,
"messages": [
{"role": message.role, "content": message.content} for message in messages
],
"stream": True,
"think": False,
}
request = Request(
self._endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
completed = False
visible_text_received = False
try:
with urlopen(request, timeout=self._timeout_seconds) as response:
for raw_line in response:
if not raw_line.strip():
continue
try:
result = json.loads(raw_line)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise InvalidProviderResponseError(
"Ollama hat einen ungültigen Streaming-Abschnitt geliefert."
) from exc
error = result.get("error")
if isinstance(error, str) and error:
if "not found" in error.lower():
raise ModelNotInstalledError(
f"Das lokale Modell '{self.model}' ist nicht installiert."
)
raise ProviderUnavailableError("Ollama hat den Stream abgelehnt.")
content = result.get("message", {}).get("content")
if content is not None and not isinstance(content, str):
raise InvalidProviderResponseError(
"Ollama hat ungültigen sichtbaren Text geliefert."
)
if content:
visible_text_received = True
yield content
if result.get("done") is True:
completed = True
break
except HTTPError as exc:
details = exc.read().decode("utf-8", errors="replace")
if exc.code == 404 or "not found" in details.lower():
raise ModelNotInstalledError(
f"Das lokale Modell '{self.model}' ist nicht installiert."
) from exc
raise ProviderUnavailableError(f"Ollama meldet HTTP-Fehler {exc.code}.") from exc
except TimeoutError as exc:
raise ProviderTimeoutError(
"Die Modellantwort hat das Zeitlimit überschritten."
) from exc
except URLError as exc:
if isinstance(exc.reason, (TimeoutError, socket.timeout)):
raise ProviderTimeoutError(
"Die Modellantwort hat das Zeitlimit überschritten."
) from exc
raise ProviderUnavailableError(
"Ollama ist unter der konfigurierten lokalen Adresse nicht erreichbar."
) from exc
if not completed:
raise ResponseAbortedError("Der Ollama-Stream wurde vorzeitig beendet.")
if not visible_text_received:
raise InvalidProviderResponseError(
"Ollama hat keine verwendbare Streaming-Antwort geliefert."
)