Skip to content

Commit a4e1fde

Browse files
committed
Preserve typed provider wire contracts across streaming responses
Replace public object sequences and finite object payloads with native JSON protocol values or concrete response DTOs across OpenAI, Anthropic, Cohere and Bedrock. Preserve event order, explicit nulls, omitted fields, citation shapes, argument bytes and terminal usage; keep transport encoding generic without changing runtime AWS envelope dispatch. Extend real HTTP and official SDK tool rounds, native citations, continuation replay, image/audio and primitive decision flows. Release the complete repair batch once as 1.0.4.
1 parent d2f04cf commit a4e1fde

25 files changed

Lines changed: 702 additions & 216 deletions

‎Directory.Build.props‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<AnalysisLevel>latest-recommended</AnalysisLevel>
1212
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
1313
<NoWarn>$(NoWarn);CS1591;CA1707;CA1848;CA1859;CA1861;CA1873;MEAI001</NoWarn>
14-
<Version>1.0.3</Version>
14+
<Version>1.0.4</Version>
1515
<PackageVersion>$(Version)</PackageVersion>
1616
</PropertyGroup>
1717

‎docs/Features/OpenAiNativeWebSearch.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,9 @@ fixtures expose `usage.server_tool_use.web_search_requests`; neither is converte
187187
Code interpreter fixtures preserve their configured container ID and native outputs. They do not invent
188188
per-response container sessions or billing duration: container/session accounting requires a separate
189189
lifecycle contract from the provider rather than a count of code calls.
190+
191+
## Native JSON wire mapping
192+
193+
`OpenAiWireMapper.ToResponseStreamEvents` returns a lazy `IEnumerable<JsonElement>`; the six same-contract Responses event helpers and Responses text annotations expose native protocol JSON as well. Their known payloads are constructed once through System.Text.Json at the final wire boundary. Event order, sequence numbers, IDs, explicit nulls, citations, terminal output and usage retain their existing protocol meaning.
194+
195+
The remaining four single-result helpers expose concrete models: model lists, image-stream events and the two transcription text events. Image completion usage and partial-image indexes retain their mutually exclusive fields; verbose transcription keeps a typed segment collection, currently emitted empty. Decision choices and probability values use native JSON scalars to preserve caller-authored strings and booleans and numeric score indexes. No custom converter or serializer configuration is added. Running-server HTTP/SDK regressions compare streamed terminal data with independent supported responses and retained conversation evidence where applicable.

‎src/ManagedCode.LlmTestServer.Anthropic/AnthropicWireMapper.NativeWebSearch.cs‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,36 +102,36 @@ private static AnthropicWebSearchCitation ToNativeCitation(LlmTestServerResponse
102102
};
103103
}
104104

105-
public static IEnumerable<object> ToOrderedContentStreamEvents(LlmTestServerChatResult result)
105+
public static IEnumerable<JsonElement> ToOrderedContentStreamEvents(LlmTestServerChatResult result)
106106
{
107107
var content = ToOrderedContent(result);
108108
for (var index = 0; index < content.Count; index++)
109109
{
110110
var block = content[index];
111111
if (block.Type is "server_tool_use" or "tool_use")
112112
{
113-
yield return new { type = "content_block_start", index, content_block = block with { Input = JsonSerializer.SerializeToElement(new { }) } };
114-
yield return new { type = "content_block_delta", index, delta = new { type = "input_json_delta", partial_json = block.Input!.Value.GetRawText() } };
113+
yield return JsonSerializer.SerializeToElement(new { type = "content_block_start", index, content_block = block with { Input = JsonSerializer.SerializeToElement(new { }) } });
114+
yield return JsonSerializer.SerializeToElement(new { type = "content_block_delta", index, delta = new { type = "input_json_delta", partial_json = block.Input!.Value.GetRawText() } });
115115
}
116116
else if (block.Type == "text")
117117
{
118-
yield return new { type = "content_block_start", index, content_block = block with { Text = string.Empty, Citations = block.Citations is null ? null : [] } };
118+
yield return JsonSerializer.SerializeToElement(new { type = "content_block_start", index, content_block = block with { Text = string.Empty, Citations = block.Citations is null ? null : [] } });
119119
var plan = (LlmTestServerResponseMessageItem)result.OutputItems[index];
120120
IReadOnlyList<string> chunks = plan.StreamChunks.Count > 0 ? plan.StreamChunks : [plan.Content];
121121
foreach (var chunk in chunks)
122122
{
123-
yield return new { type = "content_block_delta", index, delta = new { type = "text_delta", text = chunk } };
123+
yield return JsonSerializer.SerializeToElement(new { type = "content_block_delta", index, delta = new { type = "text_delta", text = chunk } });
124124
}
125125
foreach (var citation in block.Citations ?? [])
126126
{
127-
yield return new { type = "content_block_delta", index, delta = new { type = "citations_delta", citation } };
127+
yield return JsonSerializer.SerializeToElement(new { type = "content_block_delta", index, delta = new { type = "citations_delta", citation } });
128128
}
129129
}
130130
else
131131
{
132-
yield return new { type = "content_block_start", index, content_block = block };
132+
yield return JsonSerializer.SerializeToElement(new { type = "content_block_start", index, content_block = block });
133133
}
134-
yield return new { type = "content_block_stop", index };
134+
yield return JsonSerializer.SerializeToElement(new { type = "content_block_stop", index });
135135
}
136136
}
137137

‎src/ManagedCode.LlmTestServer.Anthropic/AnthropicWireMapper.cs‎

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ public static AnthropicErrorResponse ToError(string type, string message, LlmTes
7070
};
7171
}
7272

73-
public static object ToMessageStartEvent(LlmTestServerChatResult result)
73+
public static JsonElement ToMessageStartEvent(LlmTestServerChatResult result)
7474
{
75-
return new
75+
return JsonSerializer.SerializeToElement(new
7676
{
7777
type = "message_start",
7878
message = new AnthropicMessageResponse
@@ -87,22 +87,22 @@ public static object ToMessageStartEvent(LlmTestServerChatResult result)
8787
) with
8888
{ ServerToolUse = NativeServerToolUsage(result) is null ? null : new AnthropicServerToolUsage() },
8989
},
90-
};
90+
});
9191
}
9292

93-
public static object ToContentBlockStartEvent()
93+
public static JsonElement ToContentBlockStartEvent()
9494
{
95-
return new
95+
return JsonSerializer.SerializeToElement(new
9696
{
9797
type = "content_block_start",
9898
index = 0,
9999
content_block = new AnthropicContentBlock(),
100-
};
100+
});
101101
}
102102

103-
public static object ToContentBlockDeltaEvent(string text)
103+
public static JsonElement ToContentBlockDeltaEvent(string text)
104104
{
105-
return new
105+
return JsonSerializer.SerializeToElement(new
106106
{
107107
type = "content_block_delta",
108108
index = 0,
@@ -111,30 +111,30 @@ public static object ToContentBlockDeltaEvent(string text)
111111
type = "text_delta",
112112
text,
113113
},
114-
};
114+
});
115115
}
116116

117-
public static object ToContentBlockStopEvent()
117+
public static JsonElement ToContentBlockStopEvent()
118118
{
119-
return new
119+
return JsonSerializer.SerializeToElement(new
120120
{
121121
type = "content_block_stop",
122122
index = 0,
123-
};
123+
});
124124
}
125125

126-
public static object ToMessageDeltaEvent(LlmTestServerChatResult result, bool cacheOnly = false)
126+
public static JsonElement ToMessageDeltaEvent(LlmTestServerChatResult result, bool cacheOnly = false)
127127
{
128128
if (!cacheOnly && NativeServerToolUsage(result) is { } nativeUsage)
129129
{
130-
return new
130+
return JsonSerializer.SerializeToElement(new
131131
{
132132
type = "message_delta",
133133
delta = new { stop_reason = result.ToolCalls.Count > 0 ? "tool_use" : "end_turn", stop_sequence = (string?)null },
134134
usage = new { output_tokens = result.Usage.OutputTokens, server_tool_use = nativeUsage },
135-
};
135+
});
136136
}
137-
return new
137+
return JsonSerializer.SerializeToElement(new
138138
{
139139
type = "message_delta",
140140
delta = new
@@ -145,15 +145,15 @@ public static object ToMessageDeltaEvent(LlmTestServerChatResult result, bool ca
145145
? new AnthropicRefusalStopDetails { Category = result.OutputDisposition.Category, Explanation = result.OutputDisposition.Explanation } : null,
146146
},
147147
usage = new { output_tokens = result.Usage.OutputTokens },
148-
};
148+
});
149149
}
150150

151-
public static object ToMessageStopEvent()
151+
public static JsonElement ToMessageStopEvent()
152152
{
153-
return new
153+
return JsonSerializer.SerializeToElement(new
154154
{
155155
type = "message_stop",
156-
};
156+
});
157157
}
158158

159159
public static AnthropicContentBlock ToToolBlock(LlmTestServerToolCall call)
@@ -162,15 +162,15 @@ public static AnthropicContentBlock ToToolBlock(LlmTestServerToolCall call)
162162
{ Type = "tool_use", Caller = new(), Id = call.Id, Name = call.Name, Input = JsonSerializer.Deserialize<JsonElement>(call.ArgumentsJson), Text = null };
163163
}
164164

165-
public static IEnumerable<object> ToToolStreamEvents(LlmTestServerChatResult result, int offset = 0)
165+
public static IEnumerable<JsonElement> ToToolStreamEvents(LlmTestServerChatResult result, int offset = 0)
166166
{
167167
for (var position = 0; position < result.ToolCalls.Count; position++)
168168
{
169169
var index = position + offset;
170170
var call = result.ToolCalls[position];
171-
yield return new { type = "content_block_start", index, content_block = ToToolBlock(call) with { Input = JsonSerializer.SerializeToElement(new { }) } };
172-
yield return new { type = "content_block_delta", index, delta = new { type = "input_json_delta", partial_json = call.ArgumentsJson } };
173-
yield return new { type = "content_block_stop", index };
171+
yield return JsonSerializer.SerializeToElement(new { type = "content_block_start", index, content_block = ToToolBlock(call) with { Input = JsonSerializer.SerializeToElement(new { }) } });
172+
yield return JsonSerializer.SerializeToElement(new { type = "content_block_delta", index, delta = new { type = "input_json_delta", partial_json = call.ArgumentsJson } });
173+
yield return JsonSerializer.SerializeToElement(new { type = "content_block_stop", index });
174174
}
175175
}
176176

‎src/ManagedCode.LlmTestServer.Bedrock/BedrockEventStreamEncoder.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ namespace ManagedCode.LlmTestServer.Bedrock;
66
/// <summary>Encodes a Bedrock event using the official AWS event-stream framing implementation.</summary>
77
public static class BedrockEventStreamEncoder
88
{
9-
public static byte[] Encode(object streamEvent, JsonSerializerOptions? options = null)
9+
public static byte[] Encode<T>(T streamEvent, JsonSerializerOptions? options = null)
1010
{
11-
var envelope = JsonSerializer.SerializeToElement(streamEvent, options);
11+
var envelope = JsonSerializer.SerializeToElement(streamEvent, streamEvent?.GetType() ?? typeof(T), options);
1212
var properties = envelope.EnumerateObject();
1313
if (!properties.MoveNext())
1414
{

‎src/ManagedCode.LlmTestServer.Bedrock/BedrockWireMapper.cs‎

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,15 @@ private static IEnumerable<LlmTestServerMessage> ReadMessages(BedrockMessage mes
101101
yield return new() { Role = "tool", ToolCallId = block.ToolResult!.ToolUseId, Content = string.Concat(block.ToolResult.Content.Select(value => value.TryGetProperty("text", out var text) ? text.GetString() : value.GetRawText())) };
102102
}
103103
}
104-
public static IEnumerable<object> ToToolStreamEvents(LlmTestServerChatResult result, int offset = 0)
104+
public static IEnumerable<JsonElement> ToToolStreamEvents(LlmTestServerChatResult result, int offset = 0)
105105
{
106106
for (var position = 0; position < result.ToolCalls.Count; position++)
107107
{
108108
var contentBlockIndex = position + offset;
109109
var call = result.ToolCalls[position];
110-
yield return new { contentBlockStart = new { contentBlockIndex, start = new { toolUse = new { toolUseId = call.Id, name = call.Name } } } };
111-
yield return new { contentBlockDelta = new { contentBlockIndex, delta = new { toolUse = new { input = call.ArgumentsJson } } } };
112-
yield return new { contentBlockStop = new { contentBlockIndex } };
110+
yield return JsonSerializer.SerializeToElement(new { contentBlockStart = new { contentBlockIndex, start = new { toolUse = new { toolUseId = call.Id, name = call.Name } } } });
111+
yield return JsonSerializer.SerializeToElement(new { contentBlockDelta = new { contentBlockIndex, delta = new { toolUse = new { input = call.ArgumentsJson } } } });
112+
yield return JsonSerializer.SerializeToElement(new { contentBlockStop = new { contentBlockIndex } });
113113
}
114114
}
115115

@@ -157,44 +157,44 @@ public static BedrockImageResponse ToImageResponse(string dataUri)
157157
};
158158
}
159159

160-
public static object ToConverseMessageStartEvent()
160+
public static JsonElement ToConverseMessageStartEvent()
161161
{
162-
return new { messageStart = new { role = "assistant" } };
162+
return JsonSerializer.SerializeToElement(new { messageStart = new { role = "assistant" } });
163163
}
164164

165-
public static object ToConverseContentBlockStartEvent()
165+
public static JsonElement ToConverseContentBlockStartEvent()
166166
{
167-
return new
167+
return JsonSerializer.SerializeToElement(new
168168
{
169169
contentBlockStart = new { contentBlockIndex = 0, start = new { } },
170-
};
170+
});
171171
}
172172

173-
public static object ToConverseContentBlockDeltaEvent(string text)
173+
public static JsonElement ToConverseContentBlockDeltaEvent(string text)
174174
{
175-
return new
175+
return JsonSerializer.SerializeToElement(new
176176
{
177177
contentBlockDelta = new
178178
{
179179
contentBlockIndex = 0,
180180
delta = new { text },
181181
},
182-
};
182+
});
183183
}
184184

185-
public static object ToConverseContentBlockStopEvent()
185+
public static JsonElement ToConverseContentBlockStopEvent()
186186
{
187-
return new { contentBlockStop = new { contentBlockIndex = 0 } };
187+
return JsonSerializer.SerializeToElement(new { contentBlockStop = new { contentBlockIndex = 0 } });
188188
}
189189

190-
public static object ToConverseMessageStopEvent(bool toolUse = false)
190+
public static JsonElement ToConverseMessageStopEvent(bool toolUse = false)
191191
{
192-
return new { messageStop = new { stopReason = toolUse ? "tool_use" : "end_turn" } };
192+
return JsonSerializer.SerializeToElement(new { messageStop = new { stopReason = toolUse ? "tool_use" : "end_turn" } });
193193
}
194194

195-
public static object ToConverseMetadataEvent(LlmTestServerChatResult result)
195+
public static JsonElement ToConverseMetadataEvent(LlmTestServerChatResult result)
196196
{
197-
return new
197+
return JsonSerializer.SerializeToElement(new
198198
{
199199
metadata = new
200200
{
@@ -209,19 +209,19 @@ public static object ToConverseMetadataEvent(LlmTestServerChatResult result)
209209
},
210210
metrics = new { latencyMs = 0 },
211211
},
212-
};
212+
});
213213
}
214214

215-
public static object ToInvokeStreamChunk(string outputText)
215+
public static JsonElement ToInvokeStreamChunk(string outputText)
216216
{
217217
var payload = JsonSerializer.Serialize(new { outputText });
218-
return new
218+
return JsonSerializer.SerializeToElement(new
219219
{
220220
chunk = new
221221
{
222222
bytes = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)),
223223
},
224-
};
224+
});
225225
}
226226

227227
public static BedrockErrorResponse ToError(string message)

0 commit comments

Comments
 (0)