@@ -48,8 +48,110 @@ class QueryStatus(Enum):
4848 CANCELLED = "cancelled"
4949
5050
51- _OUTPUT_DICT_TYPE = dict [str , str | list [str ]]
52- _OUTPUT_RESULT_TYPE = str | tuple [str , ...] | _OUTPUT_DICT_TYPE | None
51+ OUTPUT_ELEM_TYPE = str | tuple [str , ...]
52+ """Type for a single output or reasoning value: string (non-streaming) or tuple of strings (streaming)."""
53+
54+
55+ class TextModelOutput (
56+ msgspec .Struct ,
57+ tag = True ,
58+ kw_only = True ,
59+ frozen = True ,
60+ omit_defaults = True ,
61+ array_like = True ,
62+ gc = False ,
63+ ): # type: ignore[call-arg]
64+ """Structured output from a text model.
65+
66+ Supports main output and optional reasoning (e.g. chain-of-thought).
67+ Each field may be a string (non-streaming) or tuple of strings (streaming chunks).
68+
69+ Attributes:
70+ output: Main model output. Defaults to empty string.
71+ reasoning: Optional reasoning trace. Defaults to None.
72+ """
73+
74+ output : OUTPUT_ELEM_TYPE = ""
75+ reasoning : OUTPUT_ELEM_TYPE | None = None
76+
77+ def __post_init__ (self ):
78+ """Convert list to tuple for output and reasoning to preserve immutability."""
79+ if isinstance (self .output , list ):
80+ msgspec .structs .force_setattr (self , "output" , tuple (self .output ))
81+ if self .reasoning is not None and isinstance (self .reasoning , list ):
82+ msgspec .structs .force_setattr (self , "reasoning" , tuple (self .reasoning ))
83+
84+ def __str__ (self ) -> str :
85+ """Return the full output as a single string (joins tuple chunks if streaming)."""
86+ parts = []
87+ if self .reasoning :
88+ if isinstance (self .reasoning , str ):
89+ parts .append (self .reasoning )
90+ elif isinstance (self .reasoning , tuple ):
91+ parts .extend (self .reasoning )
92+
93+ if self .output :
94+ if isinstance (self .output , str ):
95+ parts .append (self .output )
96+ elif isinstance (self .output , tuple ):
97+ parts .extend (self .output )
98+
99+ return "" .join (parts )
100+
101+
102+ OUTPUT_TYPE = TextModelOutput
103+
104+
105+ class PromptData (
106+ msgspec .Struct ,
107+ tag = True ,
108+ kw_only = True ,
109+ frozen = True ,
110+ omit_defaults = True ,
111+ array_like = True ,
112+ gc = False ,
113+ ): # type: ignore[call-arg]
114+ """Prompt input data attached to ISSUED events for ISL computation.
115+
116+ Exactly one of ``text`` or ``token_ids`` should be set:
117+ - ``text``: raw prompt string (OpenAI path) — requires tokenization for ISL.
118+ - ``token_ids``: pre-tokenized token ID list (SGLang/Harmonize path) — ISL is len().
119+
120+ Attributes:
121+ text: Raw prompt string. Set when the adapter sends text prompts.
122+ token_ids: Pre-computed token IDs. Set when the adapter pre-tokenizes (e.g. SGLang).
123+ """
124+
125+ text : str | None = None
126+ token_ids : tuple [int , ...] | None = None
127+
128+
129+ class ErrorData (
130+ msgspec .Struct ,
131+ tag = True ,
132+ kw_only = True ,
133+ frozen = True ,
134+ omit_defaults = True ,
135+ array_like = True ,
136+ gc = False ,
137+ ): # type: ignore[call-arg]
138+ """Structured error information.
139+
140+ Attributes:
141+ error_type: Name of error. If possible, should be a qualified error type (e.g. "msgspec.DecodeError")..
142+ error_message: Optional human-readable message. Defaults to empty string.
143+ """
144+
145+ error_type : str
146+ error_message : str = ""
147+
148+ def __str__ (self ) -> str :
149+ """Human-readable string: 'type: message' if message present, else 'type'."""
150+ return (
151+ f"{ self .error_type } : { self .error_message } "
152+ if self .error_message
153+ else self .error_type
154+ )
53155
54156
55157class Query (
@@ -98,6 +200,7 @@ class Query(
98200 created_at : float = msgspec .field (default_factory = time .time )
99201
100202
203+ # gc=False: audit 2026-03: metadata dict is only ever read, never mutated after construction.
101204class QueryResult (
102205 msgspec .Struct ,
103206 tag = "query_result" ,
@@ -109,6 +212,10 @@ class QueryResult(
109212): # type: ignore[call-arg]
110213 """Result of a completed inference query.
111214
215+ AT-RISK (gc=False): Has mutable container field `metadata`. Any change that
216+ mutates `metadata` after construction or stores this struct in a container
217+ referenced by this struct must be audited; if so, remove gc=False.
218+
112219 Represents the outcome of processing a Query, including the response text,
113220 metadata, and any error information. The completed_at timestamp is
114221 automatically set to ensure accurate timing measurements.
@@ -118,14 +225,10 @@ class QueryResult(
118225
119226 Attributes:
120227 id: Query identifier (matches the originating Query.id).
121- response_output: Generated text response from the endpoint (None if error).
122- Can be a string, or a tuple of strings. If it is a string,
123- it is assumed to be a non-streaming response. If it is a
124- tuple of strings, it is assumed to be a streamed response,
125- where the first element is the first chunk, which will not
126- be included in the TPOT measurements.
228+ response_output: Generated response from the endpoint (None if error).
229+ Prefer TextModelOutput; str is supported but will be deprecated.
127230 metadata: Additional response metadata (token counts, model info, etc.).
128- error: Error message if query failed (None if successful).
231+ error: Structured error if query failed (None if successful).
129232 completed_at: High-resolution timestamp (nanoseconds, monotonic clock).
130233 Auto-set in __post_init__ to prevent tampering.
131234
@@ -144,9 +247,9 @@ class QueryResult(
144247 """
145248
146249 id : str = ""
147- response_output : _OUTPUT_RESULT_TYPE = None
250+ response_output : OUTPUT_TYPE | None = None
148251 metadata : dict [str , Any ] = msgspec .field (default_factory = dict )
149- error : str | None = None
252+ error : ErrorData | None = None
150253 completed_at : int | msgspec .UnsetType = msgspec .UNSET
151254
152255 def __post_init__ (self ):
@@ -166,22 +269,9 @@ def __post_init__(self):
166269 # due to how monotonic_ns works.
167270 msgspec .structs .force_setattr (self , "completed_at" , time .monotonic_ns ())
168271
169- # A list can be passed on, but we need to convert it to a tuple to maintain immutability,
170- # and for serialization to work properly.
171- if isinstance (self .response_output , list ):
172- msgspec .structs .force_setattr (
173- self , "response_output" , tuple (self .response_output )
174- )
175- elif isinstance (self .response_output , dict ):
176- for k , v in self .response_output .items ():
177- if isinstance (v , list ):
178- self .response_output [k ] = tuple (v )
179-
180272 def get_response_output_string (self ) -> str :
181273 """Get the response output as a string."""
182- if isinstance (self .response_output , tuple ):
183- return "" .join (self .response_output )
184- elif isinstance (self .response_output , dict ):
274+ if isinstance (self .response_output , TextModelOutput ):
185275 return str (self .response_output )
186276 elif isinstance (self .response_output , str ):
187277 return self .response_output
0 commit comments