python-casahome 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- casahome/__init__.py +30 -0
- casahome/_base_http.py +21 -0
- casahome/api.py +1135 -0
- casahome/auth.py +199 -0
- casahome/client.py +168 -0
- casahome/const.py +34 -0
- casahome/exceptions.py +52 -0
- casahome/models/__init__.py +477 -0
- casahome/models/_base.py +25 -0
- casahome/models/ai.py +58 -0
- casahome/models/assets.py +123 -0
- casahome/models/availability.py +39 -0
- casahome/models/bootstrap.py +134 -0
- casahome/models/calendar.py +33 -0
- casahome/models/catalogs.py +73 -0
- casahome/models/credits.py +29 -0
- casahome/models/documents.py +127 -0
- casahome/models/enums.py +183 -0
- casahome/models/feed.py +30 -0
- casahome/models/help_center.py +45 -0
- casahome/models/home.py +175 -0
- casahome/models/images.py +48 -0
- casahome/models/mailbox.py +160 -0
- casahome/models/maintenance.py +200 -0
- casahome/models/membership.py +65 -0
- casahome/models/paint.py +56 -0
- casahome/models/preferences.py +104 -0
- casahome/models/purchases.py +106 -0
- casahome/models/read_todos.py +22 -0
- casahome/models/referral.py +21 -0
- casahome/models/requests.py +149 -0
- casahome/models/tasks.py +77 -0
- casahome/models/taxes.py +47 -0
- casahome/models/todos.py +52 -0
- casahome/models/visits.py +158 -0
- casahome/models/write_contracts.py +147 -0
- casahome/py.typed +0 -0
- casahome/streaming.py +278 -0
- python_casahome-0.2.0.dist-info/METADATA +272 -0
- python_casahome-0.2.0.dist-info/RECORD +42 -0
- python_casahome-0.2.0.dist-info/WHEEL +4 -0
- python_casahome-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Provisional visit and conversation reads, with no call grants or controls.
|
|
2
|
+
|
|
3
|
+
Fields inferred from the app. Envelopes haven't been observed live.
|
|
4
|
+
Task-item and conversation-part unions stay opaque JSON, not guessed tags.
|
|
5
|
+
The older requests.VendorVisit stays unchanged for compatibility.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pydantic import JsonValue
|
|
9
|
+
|
|
10
|
+
from casahome.models._base import CasaModel
|
|
11
|
+
from casahome.models.images import MediaAttachment
|
|
12
|
+
from casahome.models.purchases import RequestPurchase
|
|
13
|
+
from casahome.models.requests import VendorVisitCalendarEvent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemberInvoicePreview(CasaModel):
|
|
17
|
+
id: str
|
|
18
|
+
vendor_name: str
|
|
19
|
+
amount_display: str
|
|
20
|
+
user_collection_status: str
|
|
21
|
+
status: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class VendorVisitMessagePreview(CasaModel):
|
|
25
|
+
sender_name: str
|
|
26
|
+
sent_at: int
|
|
27
|
+
message: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class VendorVisitRequestSummary(CasaModel):
|
|
31
|
+
id: str
|
|
32
|
+
type: str
|
|
33
|
+
status: str
|
|
34
|
+
title: str
|
|
35
|
+
emoji: str
|
|
36
|
+
has_unread_messages: bool
|
|
37
|
+
message_preview: VendorVisitMessagePreview | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class VisitDetailVendor(CasaModel):
|
|
41
|
+
id: str
|
|
42
|
+
display_name: str
|
|
43
|
+
compact_display_name: str
|
|
44
|
+
type: str
|
|
45
|
+
image_url: str | None = None
|
|
46
|
+
image_cache_key: str | None = None
|
|
47
|
+
active_work_started_at: int | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VisitConversationWindow(CasaModel):
|
|
51
|
+
state: str
|
|
52
|
+
opens_at: int | None = None
|
|
53
|
+
closes_at: int | None = None
|
|
54
|
+
closed_reason: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class VisitConversationLiveCall(CasaModel):
|
|
58
|
+
call_id: str
|
|
59
|
+
started_at: int
|
|
60
|
+
caption: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class VisitConversationCounterparty(CasaModel):
|
|
64
|
+
user_id: str
|
|
65
|
+
role: str
|
|
66
|
+
vendor_id: str | None = None
|
|
67
|
+
display_name: str
|
|
68
|
+
avatar_url: str | None = None
|
|
69
|
+
avatar_url_cache_key: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class VisitConversationParticipant(VisitConversationCounterparty):
|
|
73
|
+
is_me: bool
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class VisitConversationLatestMessage(CasaModel):
|
|
77
|
+
sender_display_name: str
|
|
78
|
+
sent_at: int
|
|
79
|
+
message: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class VisitConversationSummary(CasaModel):
|
|
83
|
+
thread_id: str | None = None
|
|
84
|
+
window: VisitConversationWindow
|
|
85
|
+
counterparties: list[VisitConversationCounterparty]
|
|
86
|
+
live_call: VisitConversationLiveCall | None = None
|
|
87
|
+
calls_enabled: bool | None = None
|
|
88
|
+
unread_count: int
|
|
89
|
+
latest_message: VisitConversationLatestMessage | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class VisitConversationMessage(CasaModel):
|
|
93
|
+
id: str
|
|
94
|
+
thread_id: str
|
|
95
|
+
created_at: int
|
|
96
|
+
sender_role: str
|
|
97
|
+
sender_user_id: str
|
|
98
|
+
sender_display_name: str
|
|
99
|
+
sender_avatar_url: str | None = None
|
|
100
|
+
sender_avatar_url_cache_key: str | None = None
|
|
101
|
+
is_mine: bool
|
|
102
|
+
content: str | None = None
|
|
103
|
+
attachments: list[MediaAttachment]
|
|
104
|
+
parts: list[JsonValue]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class VisitConversation(CasaModel):
|
|
108
|
+
visit_id: str
|
|
109
|
+
thread_id: str | None = None
|
|
110
|
+
window: VisitConversationWindow
|
|
111
|
+
calls_enabled: bool | None = None
|
|
112
|
+
live_call: VisitConversationLiveCall | None = None
|
|
113
|
+
unread_count: int
|
|
114
|
+
participants: list[VisitConversationParticipant]
|
|
115
|
+
messages: list[VisitConversationMessage]
|
|
116
|
+
event_cursor: str | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class VisitCallParticipant(CasaModel):
|
|
120
|
+
user_id: str
|
|
121
|
+
role: str
|
|
122
|
+
rang_at: int | None = None
|
|
123
|
+
joined_at: int | None = None
|
|
124
|
+
left_at: int | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class VisitCall(CasaModel):
|
|
128
|
+
id: str
|
|
129
|
+
vendor_visit_id: str
|
|
130
|
+
status: str
|
|
131
|
+
started_by_user_id: str
|
|
132
|
+
started_by_role: str
|
|
133
|
+
started_at: int
|
|
134
|
+
answered_at: int | None = None
|
|
135
|
+
ended_at: int | None = None
|
|
136
|
+
duration_seconds: int | None = None
|
|
137
|
+
recorded: bool
|
|
138
|
+
participants: list[VisitCallParticipant]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class VisitCallsResponse(CasaModel):
|
|
142
|
+
calls: list[VisitCall]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class VendorVisitDetails(CasaModel):
|
|
146
|
+
id: str
|
|
147
|
+
type: str
|
|
148
|
+
status: str
|
|
149
|
+
request: VendorVisitRequestSummary
|
|
150
|
+
calendar_event: VendorVisitCalendarEvent | None = None
|
|
151
|
+
vendors: list[VisitDetailVendor]
|
|
152
|
+
task_items: list[JsonValue]
|
|
153
|
+
invoices: list[MemberInvoicePreview]
|
|
154
|
+
purchases: list[RequestPurchase]
|
|
155
|
+
can_add_remove_items: bool
|
|
156
|
+
can_cancel: bool
|
|
157
|
+
feedback: JsonValue = None
|
|
158
|
+
conversation: VisitConversationSummary | None = None
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Static-only write codecs. Backend acceptance and business semantics are UNVERIFIED.
|
|
2
|
+
|
|
3
|
+
Request encoders, coding keys and response shapes were inferred from the Casa
|
|
4
|
+
iOS app. The reconstructed wire contracts are LIKELY, not confirmed live.
|
|
5
|
+
These models neither authorize nor execute operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated, Any, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, ValidationError
|
|
13
|
+
|
|
14
|
+
from casahome.exceptions import CasaRequestError
|
|
15
|
+
from casahome.models._base import CasaModel
|
|
16
|
+
from casahome.models.requests import RequestMessage
|
|
17
|
+
from casahome.models.todos import AttachMediaPayload as LegacyAttachMediaPayload
|
|
18
|
+
from casahome.models.todos import CreateTodoBody as LegacyCreateTodoBody
|
|
19
|
+
from casahome.models.todos import TodoDetail
|
|
20
|
+
|
|
21
|
+
WRITE_TEXT_MAX_CHARS = 10_000
|
|
22
|
+
"""Cap on free-text write fields (to-do content, message content, property values).
|
|
23
|
+
|
|
24
|
+
Not a server limit, which is UNKNOWN. It bounds what a caller, or an agent
|
|
25
|
+
driving one, can send in a single write. Real to-dos and messages are short.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_WriteText = Annotated[str, Field(max_length=WRITE_TEXT_MAX_CHARS)]
|
|
29
|
+
|
|
30
|
+
_WRITE_CONFIG = ConfigDict(
|
|
31
|
+
extra="forbid", strict=True, validate_assignment=True, revalidate_instances="always"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _WriteModel(BaseModel):
|
|
36
|
+
"""Reject coercion and unknown write fields, including nested fields."""
|
|
37
|
+
|
|
38
|
+
model_config = _WRITE_CONFIG
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AttachMediaPayload(LegacyAttachMediaPayload):
|
|
42
|
+
"""Existing upload IDs only. No upload, URL fetch or lifecycle inference.
|
|
43
|
+
|
|
44
|
+
The recovered codec always encodes upload_ids, including an empty list.
|
|
45
|
+
This strict public companion requires callers to supply that list explicitly.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
model_config = _WRITE_CONFIG
|
|
49
|
+
upload_ids: list[str] = Field(...)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CreateTodoBody(LegacyCreateTodoBody):
|
|
53
|
+
"""Strict public companion to the legacy todo body, with the same wire keys."""
|
|
54
|
+
|
|
55
|
+
model_config = _WRITE_CONFIG
|
|
56
|
+
content: _WriteText
|
|
57
|
+
attachments: AttachMediaPayload | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CreateTodoResponse(CasaModel):
|
|
61
|
+
"""Required app envelope. A missing flag must never become false.
|
|
62
|
+
|
|
63
|
+
TodoDetail is the existing model. Its nested opaque fields aren't re-audited.
|
|
64
|
+
A true flag isn't permission to perform any support confirmation.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
todo: TodoDetail
|
|
68
|
+
needs_support_confirmation: StrictBool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SendRequestMessageRequest(_WriteModel):
|
|
72
|
+
"""Optional fields use omission, not null. Only member role is public input.
|
|
73
|
+
|
|
74
|
+
The app codec also represents privileged roles. Their authorization is
|
|
75
|
+
UNVERIFIED, so they aren't accepted here. No content requirement is inferred.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
content: _WriteText | None = None
|
|
79
|
+
attachments: AttachMediaPayload | None = None
|
|
80
|
+
sender_role: Literal["member"] | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SendRequestMessageResponse(CasaModel):
|
|
84
|
+
"""Required message envelope, reusing RequestMessage without new media variants."""
|
|
85
|
+
|
|
86
|
+
message: RequestMessage
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class UpdateMultiEnumProperty(_WriteModel):
|
|
90
|
+
"""Both selection states are encoded. Server deselection semantics are unknown."""
|
|
91
|
+
|
|
92
|
+
id: str
|
|
93
|
+
is_selected: StrictBool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class UpdateEntityProperty(_WriteModel):
|
|
97
|
+
"""String-valued write property, not an arbitrary read-model scalar.
|
|
98
|
+
|
|
99
|
+
The codec doesn't enforce mutual exclusion among the optional value fields.
|
|
100
|
+
Server acceptance of combinations, duplicates and empty values is UNVERIFIED.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
definition_id: str
|
|
104
|
+
value: _WriteText | None = None
|
|
105
|
+
enum_id: str | None = None
|
|
106
|
+
multi_enums: list[UpdateMultiEnumProperty] | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class UpdateEntityPropertiesRequest(_WriteModel):
|
|
110
|
+
"""Required props list with optional make/model strings. No null clear operation."""
|
|
111
|
+
|
|
112
|
+
make_id: str | None = None
|
|
113
|
+
model_id: str | None = None
|
|
114
|
+
props: list[UpdateEntityProperty]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class BasicMessageResponse(CasaModel):
|
|
118
|
+
"""Required status string. The PATCH doesn't return an updated entity."""
|
|
119
|
+
|
|
120
|
+
message: StrictStr
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _write_payload(body: BaseModel, schema: type[BaseModel]) -> dict[str, Any]:
|
|
124
|
+
"""Revalidate at the I/O boundary, including copied or mutated nested models.
|
|
125
|
+
|
|
126
|
+
Accept valid legacy todo bodies without accepting their permissive extras.
|
|
127
|
+
Validate strict instances directly so model_copy's unknown fields can't hide
|
|
128
|
+
behind serialization. Python-mode dumping adapts valid legacy bodies without
|
|
129
|
+
coercing their values. Only the validated result is JSON-encoded, recursively
|
|
130
|
+
omitting None but preserving empty values.
|
|
131
|
+
"""
|
|
132
|
+
if not isinstance(body, BaseModel):
|
|
133
|
+
raise TypeError("write body must be a Pydantic request model")
|
|
134
|
+
source = (
|
|
135
|
+
body
|
|
136
|
+
if isinstance(body, schema)
|
|
137
|
+
else body.model_dump(mode="python", round_trip=True, warnings=False)
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
validated = schema.model_validate(source)
|
|
141
|
+
except ValidationError as err:
|
|
142
|
+
message = f"Invalid {schema.__name__} write body: {err.error_count()} validation error(s)"
|
|
143
|
+
else:
|
|
144
|
+
return validated.model_dump(mode="json", exclude_none=True)
|
|
145
|
+
# Raise outside the handler, like _parse. Pydantic's error echoes input
|
|
146
|
+
# values, and an implicit __context__ would leak them in tracebacks.
|
|
147
|
+
raise CasaRequestError(message)
|
casahome/py.typed
ADDED
|
File without changes
|
casahome/streaming.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Opt-in CasaAI SSE support. Payload and replay behavior remain UNVERIFIED.
|
|
2
|
+
|
|
3
|
+
The app uses GET, after plus Last-Event-ID, thread.update JSON, and a
|
|
4
|
+
terminal resync_required event. Change discriminator values aren't known.
|
|
5
|
+
Nothing in an event is executed, fetched, or treated as an instruction.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterator
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Literal, TypeVar, cast
|
|
16
|
+
|
|
17
|
+
import aiohttp
|
|
18
|
+
from pydantic import JsonValue
|
|
19
|
+
|
|
20
|
+
from .client import CasaClient
|
|
21
|
+
from .const import CASA_APP, CASA_APP_VERSION, USER_AGENT
|
|
22
|
+
from .exceptions import CasaApiError, CasaAuthError, CasaConnectionError, CasaResponseError
|
|
23
|
+
|
|
24
|
+
MAX_EVENT_BYTES = 256 * 1024
|
|
25
|
+
MAX_LINE_BYTES = 64 * 1024
|
|
26
|
+
MAX_CURSOR_LENGTH = 2048
|
|
27
|
+
STREAM_READ_TIMEOUT = 60.0
|
|
28
|
+
_CHUNK_BYTES = 16 * 1024
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ServerSentEvent:
|
|
33
|
+
"""SSE fields. An absent or empty event field becomes the standard message type.
|
|
34
|
+
|
|
35
|
+
id persists across dispatches. data is joined text, including unknown events.
|
|
36
|
+
These values can contain member content. Don't log them indiscriminately.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
event: str
|
|
40
|
+
id: str | None
|
|
41
|
+
data: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class CasaAIThreadUpdateEvent(ServerSentEvent):
|
|
46
|
+
"""thread.update with JSON preserved without guessing nested wire variants."""
|
|
47
|
+
|
|
48
|
+
event: Literal["thread.update"]
|
|
49
|
+
payload: JsonValue
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class CasaAIResyncRequiredEvent(ServerSentEvent):
|
|
54
|
+
"""Terminal resync signal. The caller decides how to reload and resume."""
|
|
55
|
+
|
|
56
|
+
event: Literal["resync_required"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
CasaAIEvent = CasaAIThreadUpdateEvent | CasaAIResyncRequiredEvent | ServerSentEvent
|
|
60
|
+
_F = TypeVar("_F", bound=Callable[..., AsyncIterator[CasaAIEvent]])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def unverified_stream(func: _F) -> _F:
|
|
64
|
+
"""Mark an async generator without changing its identity or coroutine markers."""
|
|
65
|
+
func.__casa_unverified__ = True # type: ignore[attr-defined]
|
|
66
|
+
func.__doc__ = (
|
|
67
|
+
"UNVERIFIED: payload decoding and replay behavior aren't established against live data. "
|
|
68
|
+
"Live traffic (2026-10) confirmed GET/SSE transport only.\n\n" + (func.__doc__ or "")
|
|
69
|
+
).rstrip()
|
|
70
|
+
return func
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _validate_cursor(after: str | None) -> None:
|
|
74
|
+
"""Keep cursors opaque, but safe for the matching query and HTTP header."""
|
|
75
|
+
if after is not None and (
|
|
76
|
+
not isinstance(after, str)
|
|
77
|
+
or len(after) > MAX_CURSOR_LENGTH
|
|
78
|
+
or any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in after)
|
|
79
|
+
or any(0xD800 <= ord(c) <= 0xDFFF for c in after)
|
|
80
|
+
):
|
|
81
|
+
raise ValueError("after must be a cursor of at most 2048 characters without controls")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SSEParser:
|
|
85
|
+
"""Incremental bounded UTF-8 SSE parser. EOF discards an unfinished event.
|
|
86
|
+
|
|
87
|
+
Supports LF, CRLF, and CR, comments, multiline data, a leading BOM, persistent
|
|
88
|
+
IDs, and the standard empty ID reset. NUL IDs are ignored. Unknown fields,
|
|
89
|
+
including retry, are ignored. Bounds include comments and unknown fields in
|
|
90
|
+
each blank-line-delimited block. CRLF counts as one line-ending byte.
|
|
91
|
+
feed is lazy, so stopping at a terminal event doesn't parse later bytes.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
last_event_id: str | None = None,
|
|
98
|
+
max_event_bytes: int = MAX_EVENT_BYTES,
|
|
99
|
+
max_line_bytes: int = MAX_LINE_BYTES,
|
|
100
|
+
) -> None:
|
|
101
|
+
for value in (max_event_bytes, max_line_bytes):
|
|
102
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
103
|
+
raise ValueError("SSE byte limits must be positive integers")
|
|
104
|
+
self._max_event_bytes = max_event_bytes
|
|
105
|
+
self._max_line_bytes = max_line_bytes
|
|
106
|
+
self._line = bytearray()
|
|
107
|
+
self._block_bytes = 0
|
|
108
|
+
self._skip_lf = False
|
|
109
|
+
self._first_line = True
|
|
110
|
+
self._event = ""
|
|
111
|
+
self._id = last_event_id
|
|
112
|
+
self._data: list[str] = []
|
|
113
|
+
|
|
114
|
+
def feed(self, chunk: bytes) -> Iterator[ServerSentEvent]:
|
|
115
|
+
"""Consume a bounded chunk and yield only blank-line-terminated events."""
|
|
116
|
+
for byte in chunk:
|
|
117
|
+
if self._skip_lf:
|
|
118
|
+
self._skip_lf = False
|
|
119
|
+
if byte == 10:
|
|
120
|
+
continue
|
|
121
|
+
self._block_bytes += 1
|
|
122
|
+
if self._block_bytes > self._max_event_bytes:
|
|
123
|
+
raise CasaResponseError("SSE event exceeds byte limit")
|
|
124
|
+
if byte in (10, 13):
|
|
125
|
+
self._skip_lf = byte == 13
|
|
126
|
+
event = self._consume_line()
|
|
127
|
+
self._line.clear()
|
|
128
|
+
if event is not None:
|
|
129
|
+
yield event
|
|
130
|
+
else:
|
|
131
|
+
if len(self._line) >= self._max_line_bytes:
|
|
132
|
+
raise CasaResponseError("SSE line exceeds byte limit")
|
|
133
|
+
self._line.append(byte)
|
|
134
|
+
|
|
135
|
+
def _consume_line(self) -> ServerSentEvent | None:
|
|
136
|
+
line = self._line.decode("utf-8", errors="replace")
|
|
137
|
+
if self._first_line:
|
|
138
|
+
line = line.removeprefix("\ufeff")
|
|
139
|
+
self._first_line = False
|
|
140
|
+
if not line:
|
|
141
|
+
event = (
|
|
142
|
+
ServerSentEvent(self._event or "message", self._id, "\n".join(self._data))
|
|
143
|
+
if self._data
|
|
144
|
+
else None
|
|
145
|
+
)
|
|
146
|
+
self._event = ""
|
|
147
|
+
self._data.clear()
|
|
148
|
+
self._block_bytes = 0
|
|
149
|
+
return event
|
|
150
|
+
if line.startswith(":"):
|
|
151
|
+
return None
|
|
152
|
+
field, separator, value = line.partition(":")
|
|
153
|
+
if separator:
|
|
154
|
+
value = value.removeprefix(" ")
|
|
155
|
+
if field == "event":
|
|
156
|
+
self._event = value
|
|
157
|
+
elif field == "id" and "\x00" not in value:
|
|
158
|
+
self._id = value
|
|
159
|
+
elif field == "data":
|
|
160
|
+
self._data.append(value)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _reject_constant(value: str) -> None:
|
|
165
|
+
raise ValueError("Non-JSON numeric constant")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _finite_float(value: str) -> float:
|
|
169
|
+
number = float(value)
|
|
170
|
+
if not math.isfinite(number):
|
|
171
|
+
raise ValueError("Nonfinite JSON number")
|
|
172
|
+
return number
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _typed_event(event: ServerSentEvent) -> CasaAIEvent:
|
|
176
|
+
if event.event == "resync_required":
|
|
177
|
+
return CasaAIResyncRequiredEvent("resync_required", event.id, event.data)
|
|
178
|
+
if event.event == "thread.update":
|
|
179
|
+
# Don't retain a decoding exception as cause/context. It can hold raw data.
|
|
180
|
+
try:
|
|
181
|
+
payload = cast(
|
|
182
|
+
JsonValue,
|
|
183
|
+
json.loads(event.data, parse_constant=_reject_constant, parse_float=_finite_float),
|
|
184
|
+
)
|
|
185
|
+
valid = True
|
|
186
|
+
except (ValueError, RecursionError):
|
|
187
|
+
payload = None
|
|
188
|
+
valid = False
|
|
189
|
+
if not valid:
|
|
190
|
+
raise CasaResponseError("Malformed thread.update JSON")
|
|
191
|
+
return CasaAIThreadUpdateEvent("thread.update", event.id, event.data, payload)
|
|
192
|
+
return event
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@asynccontextmanager
|
|
196
|
+
async def _safe_stream_errors() -> AsyncIterator[None]:
|
|
197
|
+
"""Keep transport errors free of URLs, tokens, and remote body snippets."""
|
|
198
|
+
error: CasaConnectionError | CasaAuthError | None = None
|
|
199
|
+
try:
|
|
200
|
+
yield
|
|
201
|
+
except TimeoutError:
|
|
202
|
+
error = CasaConnectionError("SSE request timed out")
|
|
203
|
+
except (aiohttp.ClientError, CasaConnectionError):
|
|
204
|
+
error = CasaConnectionError("SSE connection failed")
|
|
205
|
+
except CasaAuthError:
|
|
206
|
+
error = CasaAuthError("SSE authentication failed")
|
|
207
|
+
if error is not None:
|
|
208
|
+
# athrow() resumes this context manager under the caller's exception.
|
|
209
|
+
# Clear that implicit context too, not only the displayed traceback.
|
|
210
|
+
try:
|
|
211
|
+
raise error from None
|
|
212
|
+
finally:
|
|
213
|
+
error.__context__ = None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _stream_events(
|
|
217
|
+
client: CasaClient, path: str, after: str | None
|
|
218
|
+
) -> AsyncGenerator[CasaAIEvent, None]:
|
|
219
|
+
"""Internal fixed-route transport. The API validates the path and cursor.
|
|
220
|
+
|
|
221
|
+
CasaClient has no public session/base/timeout accessors. Read those existing
|
|
222
|
+
fields here only, without changing ownership or bypassing FirebaseAuth's
|
|
223
|
+
cache and token-specific invalidation. JSON request() can't stream and its
|
|
224
|
+
error-body helper exposes raw snippets, so neither is used for SSE bodies.
|
|
225
|
+
"""
|
|
226
|
+
timeout = aiohttp.ClientTimeout(
|
|
227
|
+
total=None,
|
|
228
|
+
connect=min(client._timeout.total or STREAM_READ_TIMEOUT, STREAM_READ_TIMEOUT),
|
|
229
|
+
sock_read=STREAM_READ_TIMEOUT,
|
|
230
|
+
)
|
|
231
|
+
async with _safe_stream_errors():
|
|
232
|
+
for attempt in range(2):
|
|
233
|
+
token = await client.auth.get_id_token()
|
|
234
|
+
headers = {
|
|
235
|
+
"Authorization": f"Bearer {token}",
|
|
236
|
+
"X-Casa-App": CASA_APP,
|
|
237
|
+
"X-Casa-App-Version": CASA_APP_VERSION,
|
|
238
|
+
"User-Agent": USER_AGENT,
|
|
239
|
+
"Accept": "text/event-stream",
|
|
240
|
+
}
|
|
241
|
+
params = None
|
|
242
|
+
if after is not None:
|
|
243
|
+
headers["Last-Event-ID"] = after
|
|
244
|
+
params = {"after": after}
|
|
245
|
+
async with client._session.get(
|
|
246
|
+
client._base_url + path,
|
|
247
|
+
headers=headers,
|
|
248
|
+
params=params,
|
|
249
|
+
timeout=timeout,
|
|
250
|
+
allow_redirects=False,
|
|
251
|
+
raise_for_status=False,
|
|
252
|
+
) as response:
|
|
253
|
+
try:
|
|
254
|
+
if response.status == 401:
|
|
255
|
+
response.close()
|
|
256
|
+
if attempt:
|
|
257
|
+
raise CasaAuthError("Casa rejected the refreshed token (HTTP 401)")
|
|
258
|
+
client.auth.invalidate(token)
|
|
259
|
+
continue
|
|
260
|
+
if response.status == 204:
|
|
261
|
+
return
|
|
262
|
+
if response.status != 200:
|
|
263
|
+
# No error-body read, even for JSON. Status is safe metadata.
|
|
264
|
+
raise CasaApiError(response.status, "stream_http_error")
|
|
265
|
+
if response.content_type != "text/event-stream":
|
|
266
|
+
raise CasaResponseError("Expected text/event-stream response")
|
|
267
|
+
parser = SSEParser(last_event_id=after)
|
|
268
|
+
while chunk := await response.content.read(_CHUNK_BYTES):
|
|
269
|
+
for raw in parser.feed(chunk):
|
|
270
|
+
event = _typed_event(raw)
|
|
271
|
+
if isinstance(event, CasaAIResyncRequiredEvent):
|
|
272
|
+
response.close()
|
|
273
|
+
yield event
|
|
274
|
+
return
|
|
275
|
+
yield event
|
|
276
|
+
return
|
|
277
|
+
finally:
|
|
278
|
+
response.close()
|