contexara 0.1.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.
- contexara/__init__.py +46 -0
- contexara/_capture.py +370 -0
- contexara/_sse.py +110 -0
- contexara/async_client.py +375 -0
- contexara/client.py +585 -0
- contexara/context.py +159 -0
- contexara/errors.py +157 -0
- contexara/models.py +161 -0
- contexara/py.typed +0 -0
- contexara/resilience.py +187 -0
- contexara/testing.py +105 -0
- contexara-0.1.0.dist-info/METADATA +395 -0
- contexara-0.1.0.dist-info/RECORD +15 -0
- contexara-0.1.0.dist-info/WHEEL +4 -0
- contexara-0.1.0.dist-info/licenses/LICENSE +21 -0
contexara/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from .async_client import AsyncContexara
|
|
2
|
+
from .client import Contexara, MemoryAPI
|
|
3
|
+
from .context import format_context_block
|
|
4
|
+
from .errors import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
ConflictError,
|
|
7
|
+
ConnectionError_,
|
|
8
|
+
ContexaraAPIError,
|
|
9
|
+
ContexaraError,
|
|
10
|
+
ContexaraTimeoutError,
|
|
11
|
+
PermissionDeniedError,
|
|
12
|
+
QuotaExceededError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ResponseValidationError,
|
|
15
|
+
StreamError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from .models import AddResult, Entity, Memory, RetrieveResult, UploadEvent
|
|
19
|
+
from .resilience import ResiliencePolicy
|
|
20
|
+
from .testing import MockContexaraClient
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Contexara",
|
|
24
|
+
"AsyncContexara",
|
|
25
|
+
"MemoryAPI",
|
|
26
|
+
"Memory",
|
|
27
|
+
"Entity",
|
|
28
|
+
"RetrieveResult",
|
|
29
|
+
"AddResult",
|
|
30
|
+
"UploadEvent",
|
|
31
|
+
"ResiliencePolicy",
|
|
32
|
+
"MockContexaraClient",
|
|
33
|
+
"format_context_block",
|
|
34
|
+
"ContexaraError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"PermissionDeniedError",
|
|
37
|
+
"QuotaExceededError",
|
|
38
|
+
"RateLimitError",
|
|
39
|
+
"ValidationError",
|
|
40
|
+
"ConflictError",
|
|
41
|
+
"ConnectionError_",
|
|
42
|
+
"ContexaraTimeoutError",
|
|
43
|
+
"ResponseValidationError",
|
|
44
|
+
"StreamError",
|
|
45
|
+
"ContexaraAPIError",
|
|
46
|
+
]
|
contexara/_capture.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""_capture.py — builds the real CaptureEvent envelope /ingest expects,
|
|
2
|
+
and tracks session_id/turn_number the same way a real hook does (see
|
|
3
|
+
clients/installer/hook/platforms/cursor/hook.mjs's nextTurnNumber() and
|
|
4
|
+
resolveSessionId() — session_id is the caller's own stable conversation
|
|
5
|
+
identity, turn_number is a local, incrementing-per-session counter, not
|
|
6
|
+
server-assigned).
|
|
7
|
+
|
|
8
|
+
Two usage levels, per the build plan's section 7:
|
|
9
|
+
- Simple: memory.add(text) — the client auto-generates one default
|
|
10
|
+
session_id (constant for the life of the Contexara instance,
|
|
11
|
+
mirroring Cursor's "cursor-default" fallback) and allocates turn
|
|
12
|
+
numbers atomically from a local counter.
|
|
13
|
+
- Prepared/durable: memory.prepare_add(...) returns a fully-built,
|
|
14
|
+
serializable PreparedCaptureEvent an application can persist BEFORE
|
|
15
|
+
sending, and replay unchanged after a restart via memory.send(event)
|
|
16
|
+
— event_id/created_at/turn identity are frozen at prepare-time and
|
|
17
|
+
MUST NOT be regenerated on replay (see the contract's replay rule).
|
|
18
|
+
|
|
19
|
+
REAL, CONFIRMED GAP (contract review, 2026-09-28): reusing an event_id
|
|
20
|
+
with genuinely different content is NOT reliably caught server-side —
|
|
21
|
+
platform/api/app/worker.py's is_event_already_ingested() dedup check
|
|
22
|
+
returns before any content comparison ever runs. This module enforces
|
|
23
|
+
the replay rule by construction (a PreparedCaptureEvent's fields are
|
|
24
|
+
immutable once built; send() never mutates them) rather than relying on
|
|
25
|
+
the server to catch a violation.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import threading
|
|
30
|
+
import uuid
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
|
|
34
|
+
# Bounds the session registry for the life of one Contexara instance.
|
|
35
|
+
# Every distinct session_id ever allocated (automatic or explicit)
|
|
36
|
+
# counts against this once, permanently — see SessionTracker's own
|
|
37
|
+
# docstring for why entries are never evicted to make room. Sized
|
|
38
|
+
# generously (each entry is a small string key + a 2-tuple) so a real
|
|
39
|
+
# long-lived process handling far more than 100,000 distinct sessions
|
|
40
|
+
# over its lifetime is the only case that hits this limit.
|
|
41
|
+
MAX_BOOKKEEPING_SESSIONS = 100_000
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _iso_now() -> str:
|
|
45
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def derive_turn_id(turn_number: int) -> str:
|
|
49
|
+
"""MUST match platform/api/app/schemas.py:CaptureEvent.derive_turn_id
|
|
50
|
+
exactly — the server independently re-derives this and rejects a
|
|
51
|
+
mismatch (409/worker failure)."""
|
|
52
|
+
return f"turn_{turn_number:06d}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PreparedCaptureEvent:
|
|
57
|
+
"""A fully-built, serializable CaptureEvent, ready to send or persist.
|
|
58
|
+
Every field is frozen at construction time — send() must never
|
|
59
|
+
mutate any of these before transmitting, since a retry after an
|
|
60
|
+
uncertain outcome must resend the exact original payload unchanged
|
|
61
|
+
(the contract's replay rule)."""
|
|
62
|
+
|
|
63
|
+
event_id: str
|
|
64
|
+
turn_id: str
|
|
65
|
+
session_id: str
|
|
66
|
+
turn_number: int
|
|
67
|
+
provider: str
|
|
68
|
+
capture_version: str
|
|
69
|
+
created_at: str
|
|
70
|
+
user_content: str | None
|
|
71
|
+
assistant_content: str | None
|
|
72
|
+
project_id: str | None = None
|
|
73
|
+
workspace_id: str | None = None
|
|
74
|
+
|
|
75
|
+
def to_wire(self) -> dict:
|
|
76
|
+
"""The real CaptureEvent JSON body /ingest expects."""
|
|
77
|
+
return {
|
|
78
|
+
"event_id": self.event_id,
|
|
79
|
+
"event_type": "conversation_turn",
|
|
80
|
+
"event_version": "1.0",
|
|
81
|
+
"capture_version": self.capture_version,
|
|
82
|
+
"schema_version": "1.0",
|
|
83
|
+
"turn_id": self.turn_id,
|
|
84
|
+
"tenant_id": "unset",
|
|
85
|
+
"user_id": "unset",
|
|
86
|
+
"session_id": self.session_id,
|
|
87
|
+
"turn_number": self.turn_number,
|
|
88
|
+
"project_id": self.project_id,
|
|
89
|
+
"workspace_id": self.workspace_id,
|
|
90
|
+
"provider": self.provider,
|
|
91
|
+
"created_at": self.created_at,
|
|
92
|
+
"status": "complete",
|
|
93
|
+
"incomplete_reason": None,
|
|
94
|
+
"conversation": {
|
|
95
|
+
"user": {"timestamp": None, "content": self.user_content},
|
|
96
|
+
"assistant": {"timestamp": None, "content": self.assistant_content},
|
|
97
|
+
},
|
|
98
|
+
"metadata": {
|
|
99
|
+
"capture_client_version": self.capture_version,
|
|
100
|
+
"os": None,
|
|
101
|
+
"hook_event_names": [],
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def to_dict(self) -> dict:
|
|
106
|
+
"""Serializable representation for an application to persist
|
|
107
|
+
BEFORE sending, so it can replay this exact event after a
|
|
108
|
+
restart — a plain dict of this dataclass's own fields, not the
|
|
109
|
+
wire shape (to_wire() derives the wire shape from this, not the
|
|
110
|
+
other way around, so persisting to_dict()'s output and later
|
|
111
|
+
reconstructing via from_dict() round-trips exactly)."""
|
|
112
|
+
return {
|
|
113
|
+
"event_id": self.event_id,
|
|
114
|
+
"turn_id": self.turn_id,
|
|
115
|
+
"session_id": self.session_id,
|
|
116
|
+
"turn_number": self.turn_number,
|
|
117
|
+
"provider": self.provider,
|
|
118
|
+
"capture_version": self.capture_version,
|
|
119
|
+
"created_at": self.created_at,
|
|
120
|
+
"user_content": self.user_content,
|
|
121
|
+
"assistant_content": self.assistant_content,
|
|
122
|
+
"project_id": self.project_id,
|
|
123
|
+
"workspace_id": self.workspace_id,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_dict(cls, data: dict) -> "PreparedCaptureEvent":
|
|
128
|
+
return cls(**data)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class SessionTracker:
|
|
132
|
+
"""Owns default-session generation and per-session turn-number
|
|
133
|
+
allocation for ONE Contexara client instance. Not process-global —
|
|
134
|
+
two separate Contexara() instances get independent default sessions,
|
|
135
|
+
matching "one client instance = roughly one logical conversation
|
|
136
|
+
unless told otherwise", not "one process".
|
|
137
|
+
|
|
138
|
+
Thread-safe: allocate_turn() is the one method under concurrent-call
|
|
139
|
+
risk (two threads calling memory.add() on the same client at once),
|
|
140
|
+
guarded by a lock so two calls never observe/reuse the same
|
|
141
|
+
turn_number for the same session_id.
|
|
142
|
+
|
|
143
|
+
REAL BUG FIXED 2026-09-28 (Codex review, round 3, THIRD pass): the
|
|
144
|
+
previous two fixes both tried to patch the eviction/forgetting
|
|
145
|
+
design instead of replacing it, and both were reproduced broken by
|
|
146
|
+
Codex before this rewrite:
|
|
147
|
+
|
|
148
|
+
- Pass 1 (TTL + idle eviction): a bounded "recently evicted" set
|
|
149
|
+
rolled its oldest entry off under sustained churn, letting an
|
|
150
|
+
evicted session_id look brand-new again once enough OTHER
|
|
151
|
+
sessions had also been evicted.
|
|
152
|
+
- Pass 2 (unbounded evicted/explicit sets): fixed that, but (a)
|
|
153
|
+
broke explicit → explicit continuation (the check treated ANY
|
|
154
|
+
prior explicit allocation as a mode conflict, not just a prior
|
|
155
|
+
AUTOMATIC one — a session doing turn 1, 2, 3 all via
|
|
156
|
+
explicit_turn_number was incorrectly rejected on turn 2), and
|
|
157
|
+
(b) two separate unbounded sets meant total bookkeeping grew
|
|
158
|
+
without limit for the tracker's lifetime — not a configured
|
|
159
|
+
bound, regardless of how "non-adversarial" normal traffic is.
|
|
160
|
+
Reproduced directly by Codex: max_sessions=2, 100 distinct
|
|
161
|
+
explicitly-allocated session_ids → 100 retained forever.
|
|
162
|
+
|
|
163
|
+
This pass replaces BOTH bounded-forgetting and unbounded-growth
|
|
164
|
+
with the single design Codex specified: one bounded registry,
|
|
165
|
+
keyed by session_id, storing (mode, turn_number | None). Entries
|
|
166
|
+
are NEVER evicted for the life of this tracker instance — there is
|
|
167
|
+
no TTL, no idle sweep, no eviction of any kind. The only thing
|
|
168
|
+
max_sessions governs is whether *registering a brand-new*
|
|
169
|
+
session_id is allowed; every session_id already in the registry,
|
|
170
|
+
of either mode, continues working for as long as the process runs.
|
|
171
|
+
This is a real, fixed, configured bound (not "bounded in practice
|
|
172
|
+
by non-adversarial traffic" — an argument Codex correctly rejected,
|
|
173
|
+
since session_ids are caller-supplied and ordinary traffic alone
|
|
174
|
+
can exhaust an unbounded set). A caller who legitimately needs more
|
|
175
|
+
than max_sessions distinct sessions across one client's lifetime is
|
|
176
|
+
the resuming-in-another-process case this class's own
|
|
177
|
+
explicit_turn_number parameter already exists for — that path is
|
|
178
|
+
unaffected by capacity, since resuming callers manage their own
|
|
179
|
+
state and never need a REGISTRY entry to keep working correctly
|
|
180
|
+
(see allocate_turn's own docstring)."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, *, max_sessions: int | None = None) -> None:
|
|
183
|
+
self._lock = threading.Lock()
|
|
184
|
+
self._default_session_id: str | None = None
|
|
185
|
+
# session_id -> (mode, turn_number). mode is "automatic" or
|
|
186
|
+
# "explicit". For "explicit" entries, turn_number is None — an
|
|
187
|
+
# explicit caller manages their own numbers, this registry only
|
|
188
|
+
# needs to remember WHICH mode the session is locked to, not
|
|
189
|
+
# any particular number. Never evicted, never shrinks, for the
|
|
190
|
+
# life of this tracker instance — see this class's own
|
|
191
|
+
# docstring for why that is the actual fix, not a regression.
|
|
192
|
+
self._sessions: dict[str, tuple[str, int | None]] = {}
|
|
193
|
+
self._max_sessions = max_sessions if max_sessions is not None else MAX_BOOKKEEPING_SESSIONS
|
|
194
|
+
|
|
195
|
+
def default_session_id(self) -> str:
|
|
196
|
+
"""Lazily generates one default session_id, stable for the life
|
|
197
|
+
of this tracker instance — mirrors Cursor's own
|
|
198
|
+
resolveSessionId() fallback ("cursor-default"): a single,
|
|
199
|
+
consistent conversation identity used when the caller has no
|
|
200
|
+
more specific one to offer, not a fresh id manufactured per
|
|
201
|
+
call (which would make every add() its own isolated one-turn
|
|
202
|
+
session, defeating the point of a session concept at all)."""
|
|
203
|
+
with self._lock:
|
|
204
|
+
if self._default_session_id is None:
|
|
205
|
+
self._default_session_id = f"sdk-{uuid.uuid4().hex[:12]}"
|
|
206
|
+
return self._default_session_id
|
|
207
|
+
|
|
208
|
+
def allocate_turn(self, session_id: str, *, explicit_turn_number: int | None = None) -> int:
|
|
209
|
+
"""Returns the next turn_number for session_id, starting at 1,
|
|
210
|
+
incrementing per call — the same real allocation rule
|
|
211
|
+
nextTurnNumber() implements for hook clients. Atomic under
|
|
212
|
+
concurrent calls via the instance lock.
|
|
213
|
+
|
|
214
|
+
explicit_turn_number: caller-managed allocation, for a
|
|
215
|
+
distributed application that tracks its own durable
|
|
216
|
+
session/turn state externally (e.g. after a process restart,
|
|
217
|
+
or across multiple worker processes sharing one logical
|
|
218
|
+
session) — bypasses this tracker's local counter entirely.
|
|
219
|
+
A session_id is permanently locked to whichever mode
|
|
220
|
+
(automatic or explicit) its FIRST allocate_turn() call used,
|
|
221
|
+
for the life of this tracker instance:
|
|
222
|
+
|
|
223
|
+
- automatic → automatic: increments and returns the local
|
|
224
|
+
counter, as always.
|
|
225
|
+
- explicit → explicit: REAL BUG FIXED 2026-09-28 (Codex review,
|
|
226
|
+
round 3, third pass, reproduced directly): an earlier version
|
|
227
|
+
of this check rejected ANY session_id that had ever seen an
|
|
228
|
+
explicit call, including a second, third, etc. explicit call
|
|
229
|
+
for the SAME session_id — "explicit turn 1" then "explicit
|
|
230
|
+
turn 2" for one conversation incorrectly raised. Explicit
|
|
231
|
+
mode does not update this registry's own counter (the caller
|
|
232
|
+
owns their own numbers), so repeated explicit calls for an
|
|
233
|
+
explicit-mode session are always allowed; only a MODE SWITCH
|
|
234
|
+
(automatic session receiving an explicit call, or vice versa)
|
|
235
|
+
is rejected.
|
|
236
|
+
- either mode switching to the other: raises ValidationError —
|
|
237
|
+
silently mixing the two would desynchronize this tracker's
|
|
238
|
+
local counter from whatever the caller (or the server) is
|
|
239
|
+
separately tracking.
|
|
240
|
+
|
|
241
|
+
Raises ValidationError if registering session_id would exceed
|
|
242
|
+
max_sessions. This registry never evicts an entry once
|
|
243
|
+
created — see this class's own docstring for why "bounded, but
|
|
244
|
+
forgets old entries" was the actual defect being fixed across
|
|
245
|
+
the last two rounds, not a feature. A caller who legitimately
|
|
246
|
+
needs more than max_sessions distinct session_ids across one
|
|
247
|
+
client instance's lifetime should pass explicit_turn_number
|
|
248
|
+
(which never touches the registry once a session_id is already
|
|
249
|
+
registered in explicit mode) or construct a new client
|
|
250
|
+
instance."""
|
|
251
|
+
from .errors import ValidationError
|
|
252
|
+
|
|
253
|
+
with self._lock:
|
|
254
|
+
existing = self._sessions.get(session_id)
|
|
255
|
+
|
|
256
|
+
if explicit_turn_number is not None:
|
|
257
|
+
if existing is not None and existing[0] != "explicit":
|
|
258
|
+
raise ValidationError(
|
|
259
|
+
f"session_id {session_id!r} already has an active local turn counter "
|
|
260
|
+
f"from automatic allocation — passing an explicit_turn_number for it now "
|
|
261
|
+
f"would desynchronize the two. Use one allocation strategy per session "
|
|
262
|
+
f"consistently, not both."
|
|
263
|
+
)
|
|
264
|
+
if existing is None:
|
|
265
|
+
self._reserve_capacity(session_id)
|
|
266
|
+
self._sessions[session_id] = ("explicit", None)
|
|
267
|
+
return explicit_turn_number
|
|
268
|
+
|
|
269
|
+
if existing is not None and existing[0] == "explicit":
|
|
270
|
+
raise ValidationError(
|
|
271
|
+
f"session_id {session_id!r} was previously allocated an explicit_turn_number "
|
|
272
|
+
f"— switching to automatic allocation for it now would desynchronize the two. "
|
|
273
|
+
f"Use one allocation strategy per session consistently, not both."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if existing is not None:
|
|
277
|
+
next_turn = (existing[1] or 0) + 1
|
|
278
|
+
self._sessions[session_id] = ("automatic", next_turn)
|
|
279
|
+
return next_turn
|
|
280
|
+
|
|
281
|
+
self._reserve_capacity(session_id)
|
|
282
|
+
self._sessions[session_id] = ("automatic", 1)
|
|
283
|
+
return 1
|
|
284
|
+
|
|
285
|
+
def _reserve_capacity(self, session_id: str) -> None:
|
|
286
|
+
"""Raises if registering a brand-new session_id would exceed
|
|
287
|
+
max_sessions. Only called when session_id is NOT already in
|
|
288
|
+
the registry — an already-registered session_id (either mode)
|
|
289
|
+
never needs capacity, since it does not grow the registry."""
|
|
290
|
+
from .errors import ValidationError
|
|
291
|
+
|
|
292
|
+
if len(self._sessions) >= self._max_sessions:
|
|
293
|
+
raise ValidationError(
|
|
294
|
+
f"session_id {session_id!r} cannot be registered: this client is already "
|
|
295
|
+
f"tracking {self._max_sessions} other session_ids for its lifetime, and this "
|
|
296
|
+
f"registry never forgets a session_id once created (see SessionTracker's own "
|
|
297
|
+
f"docstring for why unbounded-with-forgetting was rejected as unsafe). Either "
|
|
298
|
+
f"reuse an already-registered session_id, pass an explicit_turn_number for a "
|
|
299
|
+
f"session_id whose turns you track yourself, or construct a new client instance."
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# Real, documented limits — platform/api/app/schemas.py:18,22
|
|
304
|
+
# (MAX_MESSAGE_CONTENT_CHARS, MAX_IDENTIFIER_CHARS). REAL GAP CLOSED
|
|
305
|
+
# 2026-09-28 (Codex review): prepare_add() previously sent blank
|
|
306
|
+
# content and over-limit content straight to the network, where the
|
|
307
|
+
# server would reject it with a 422 — a real, avoidable round trip for
|
|
308
|
+
# an error the SDK can and should catch immediately.
|
|
309
|
+
MAX_MESSAGE_CONTENT_CHARS = 100_000
|
|
310
|
+
MAX_IDENTIFIER_CHARS = 512
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def build_prepared_event(
|
|
314
|
+
tracker: SessionTracker,
|
|
315
|
+
*,
|
|
316
|
+
text: str,
|
|
317
|
+
assistant_message: str | None,
|
|
318
|
+
session_id: str | None,
|
|
319
|
+
project_id: str | None,
|
|
320
|
+
workspace_id: str | None,
|
|
321
|
+
provider: str,
|
|
322
|
+
capture_version: str,
|
|
323
|
+
turn_number: int | None = None,
|
|
324
|
+
) -> PreparedCaptureEvent:
|
|
325
|
+
from .errors import ValidationError
|
|
326
|
+
|
|
327
|
+
if not text or not text.strip():
|
|
328
|
+
raise ValidationError("text must not be blank")
|
|
329
|
+
if len(text) > MAX_MESSAGE_CONTENT_CHARS:
|
|
330
|
+
raise ValidationError(
|
|
331
|
+
f"text is {len(text)} chars, over the real {MAX_MESSAGE_CONTENT_CHARS}-char limit "
|
|
332
|
+
f"the server enforces per message."
|
|
333
|
+
)
|
|
334
|
+
if assistant_message is not None and len(assistant_message) > MAX_MESSAGE_CONTENT_CHARS:
|
|
335
|
+
raise ValidationError(
|
|
336
|
+
f"assistant_message is {len(assistant_message)} chars, over the real "
|
|
337
|
+
f"{MAX_MESSAGE_CONTENT_CHARS}-char limit the server enforces per message."
|
|
338
|
+
)
|
|
339
|
+
if session_id is not None and len(session_id) > MAX_IDENTIFIER_CHARS:
|
|
340
|
+
raise ValidationError(f"session_id is over the real {MAX_IDENTIFIER_CHARS}-char identifier limit.")
|
|
341
|
+
# REAL GAP CLOSED (schema reconciliation, release plan step 2):
|
|
342
|
+
# shared/schemas/capture_event.schema.json declares turn_number's
|
|
343
|
+
# real minimum as 1, and derive_turn_id's "turn_%06d" format
|
|
344
|
+
# produces a malformed turn_id (fails the schema's own
|
|
345
|
+
# ^turn_[0-9]{6}$ regex) for turn_number <= 0 — e.g. turn_number=-1
|
|
346
|
+
# formats as "turn_-00001", not six digits. Neither the backend
|
|
347
|
+
# Pydantic model NOR this SDK enforced turn_number >= 1 before this
|
|
348
|
+
# fix, making it reachable via this exact explicit_turn_number
|
|
349
|
+
# parameter with no server-side backstop. The Node SDK already
|
|
350
|
+
# enforced this (capture.ts's fromPersistable); this brings the
|
|
351
|
+
# Python SDK to parity and catches the error at prepare_add()/add()
|
|
352
|
+
# time instead of only via a confusing later ID-format failure.
|
|
353
|
+
if turn_number is not None and turn_number < 1:
|
|
354
|
+
raise ValidationError(f"turn_number must be >= 1, got {turn_number}.")
|
|
355
|
+
|
|
356
|
+
resolved_session_id = session_id or tracker.default_session_id()
|
|
357
|
+
turn_number = tracker.allocate_turn(resolved_session_id, explicit_turn_number=turn_number)
|
|
358
|
+
return PreparedCaptureEvent(
|
|
359
|
+
event_id=f"evt_{uuid.uuid4()}",
|
|
360
|
+
turn_id=derive_turn_id(turn_number),
|
|
361
|
+
session_id=resolved_session_id,
|
|
362
|
+
turn_number=turn_number,
|
|
363
|
+
provider=provider,
|
|
364
|
+
capture_version=capture_version,
|
|
365
|
+
created_at=_iso_now(),
|
|
366
|
+
user_content=text,
|
|
367
|
+
assistant_content=assistant_message,
|
|
368
|
+
project_id=project_id,
|
|
369
|
+
workspace_id=workspace_id,
|
|
370
|
+
)
|
contexara/_sse.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""_sse.py — parses the real /upload SSE protocol described in
|
|
2
|
+
platform/sdk/contracts/PUBLIC_API_CONTRACT.md's upload section and
|
|
3
|
+
exercised by fixtures/upload_sse_stream.json.
|
|
4
|
+
|
|
5
|
+
Real frame shape: `data: {json}\n\n` (or `\r\n\r\n`), terminated by a
|
|
6
|
+
literal `data: [DONE]\n\n` frame. An `{"error": ...}` frame may appear
|
|
7
|
+
before [DONE] — [DONE] following an error frame does NOT mean success;
|
|
8
|
+
callers must check for an error frame having been seen, not just for
|
|
9
|
+
stream completion.
|
|
10
|
+
|
|
11
|
+
This is a pure, transport-agnostic byte-accumulator: feed it raw bytes
|
|
12
|
+
in whatever chunks arrive from the network (which may split a frame
|
|
13
|
+
anywhere, including mid-multibyte-UTF-8-character), and it yields
|
|
14
|
+
complete, parsed UploadEvent-shaped dicts as they become available.
|
|
15
|
+
Kept separate from client.py so the same parser backs both the sync and
|
|
16
|
+
async upload() implementations without duplicating this logic.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
|
|
23
|
+
from .errors import StreamError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SSEFrameParser:
|
|
27
|
+
"""Stateful, incremental parser. Call feed(chunk) with each raw byte
|
|
28
|
+
chunk as it arrives; each call yields zero or more fully-parsed
|
|
29
|
+
frame dicts. Call finish() once the stream ends to detect a
|
|
30
|
+
connection drop with no [DONE] (a real protocol violation, distinct
|
|
31
|
+
from a clean error-then-done sequence)."""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._buffer = b""
|
|
35
|
+
self._decoder_leftover = b""
|
|
36
|
+
self._saw_done = False
|
|
37
|
+
self._error_frame: str | None = None
|
|
38
|
+
self._last_metadata: dict | None = None
|
|
39
|
+
|
|
40
|
+
def feed(self, chunk: bytes) -> Iterator[dict]:
|
|
41
|
+
self._buffer += chunk
|
|
42
|
+
# Normalize CRLF to LF once, on the accumulated buffer, so the
|
|
43
|
+
# frame-splitting logic below only needs to handle one line
|
|
44
|
+
# ending — real servers may use either per the contract.
|
|
45
|
+
normalized = self._buffer.replace(b"\r\n", b"\n")
|
|
46
|
+
# A complete frame is terminated by a blank line (\n\n). Split
|
|
47
|
+
# on that, keep the last (possibly incomplete) piece buffered
|
|
48
|
+
# for the next feed() call — this is what makes a frame split
|
|
49
|
+
# across two network reads reassemble correctly.
|
|
50
|
+
parts = normalized.split(b"\n\n")
|
|
51
|
+
self._buffer = parts[-1]
|
|
52
|
+
for raw_frame in parts[:-1]:
|
|
53
|
+
event = self._parse_frame(raw_frame)
|
|
54
|
+
if event is not None:
|
|
55
|
+
yield event
|
|
56
|
+
|
|
57
|
+
def _parse_frame(self, raw_frame: bytes) -> dict | None:
|
|
58
|
+
# A real frame is one or more `data: ...` lines. REAL BUG FIXED
|
|
59
|
+
# 2026-09-28 (Codex review): multiple data: lines were joined
|
|
60
|
+
# with NO separator (b"".join), which is not what the SSE spec
|
|
61
|
+
# requires — per the WHATWG spec, each data: line's value gets
|
|
62
|
+
# a "\n" appended before the next line's value, not
|
|
63
|
+
# concatenated directly. This protocol's real frames today are
|
|
64
|
+
# always single-line, so this had no observed practical effect
|
|
65
|
+
# yet, but a multiline frame would have silently corrupted the
|
|
66
|
+
# decoded JSON (e.g. two numbers on adjacent lines merging into
|
|
67
|
+
# one digit string with no separator).
|
|
68
|
+
lines = raw_frame.split(b"\n")
|
|
69
|
+
data_parts = [ln[len(b"data:"):].lstrip() for ln in lines if ln.startswith(b"data:")]
|
|
70
|
+
if not data_parts:
|
|
71
|
+
return None # a comment/keepalive line with no data: prefix
|
|
72
|
+
payload = b"\n".join(data_parts)
|
|
73
|
+
text = payload.decode("utf-8")
|
|
74
|
+
if text == "[DONE]":
|
|
75
|
+
self._saw_done = True
|
|
76
|
+
return {"done": True}
|
|
77
|
+
try:
|
|
78
|
+
obj = json.loads(text)
|
|
79
|
+
except json.JSONDecodeError as exc:
|
|
80
|
+
raise StreamError(f"malformed SSE data frame: {text!r}: {exc}") from None
|
|
81
|
+
if "error" in obj:
|
|
82
|
+
self._error_frame = obj["error"]
|
|
83
|
+
if "content_hash" in obj or "attachment_id" in obj:
|
|
84
|
+
self._last_metadata = obj
|
|
85
|
+
return obj
|
|
86
|
+
|
|
87
|
+
def finish(self) -> None:
|
|
88
|
+
"""Call once the underlying stream has ended (EOF). Raises
|
|
89
|
+
StreamError if the stream ended without a [DONE] frame (a
|
|
90
|
+
protocol violation — connection drop or server crash mid-
|
|
91
|
+
stream) or if an error frame was seen at any point (regardless
|
|
92
|
+
of whether [DONE] followed it — DONE's presence alone never
|
|
93
|
+
means success)."""
|
|
94
|
+
if self._error_frame is not None:
|
|
95
|
+
meta = self._last_metadata or {}
|
|
96
|
+
raise StreamError(
|
|
97
|
+
self._error_frame,
|
|
98
|
+
content_hash=meta.get("content_hash"),
|
|
99
|
+
duplicate=meta.get("duplicate"),
|
|
100
|
+
attachment_id=meta.get("attachment_id"),
|
|
101
|
+
)
|
|
102
|
+
if not self._saw_done:
|
|
103
|
+
meta = self._last_metadata or {}
|
|
104
|
+
raise StreamError(
|
|
105
|
+
"upload stream ended without a [DONE] frame (connection drop or "
|
|
106
|
+
"server error mid-stream)",
|
|
107
|
+
content_hash=meta.get("content_hash"),
|
|
108
|
+
duplicate=meta.get("duplicate"),
|
|
109
|
+
attachment_id=meta.get("attachment_id"),
|
|
110
|
+
)
|