1010using Chats . BE . Services . Models . Neutral ;
1111using System . Net ;
1212using System . Text ;
13+ using System . Text . Json ;
14+ using Chats . BE . Tests . ChatServices . Http ;
1315
1416namespace Chats . BE . Tests . ChatServices . GoogleAI ;
1517
@@ -23,11 +25,17 @@ public class GoogleAI2ChatServiceTest
2325 private static IHttpClientFactory CreateMockHttpClientFactory ( FiddlerHttpDumpParser . HttpDump dump )
2426 {
2527 var statusCode = ( HttpStatusCode ) dump . Response . StatusCode ;
26- return new FakeHttpClientFactory ( dump . Response . Chunks , statusCode ) ;
28+ return new FiddlerDumpHttpClientFactory ( dump . Response . Chunks , statusCode , dump . Request . Body ) ;
2729 }
2830
2931 private static ChatRequest CreateBaseChatRequest ( string modelDeploymentName , string prompt , Action < ChatConfig > ? configure = null )
3032 {
33+ bool isFlash = modelDeploymentName . Contains ( "gemini-2.5-flash" , StringComparison . OrdinalIgnoreCase ) &&
34+ ! modelDeploymentName . Contains ( "gemini-2.5-flash-image" , StringComparison . OrdinalIgnoreCase ) ;
35+ bool isFlashImage = modelDeploymentName . Contains ( "gemini-2.5-flash-image" , StringComparison . OrdinalIgnoreCase ) ;
36+ bool isImageGenerationExp = modelDeploymentName . Contains ( "gemini-2.0-flash-exp-image-generation" , StringComparison . OrdinalIgnoreCase ) ;
37+ bool isFlashExp = modelDeploymentName . Contains ( "gemini-2.0-flash-exp" , StringComparison . OrdinalIgnoreCase ) ;
38+
3139 var modelKey = new ModelKey
3240 {
3341 Id = 1 ,
@@ -50,17 +58,24 @@ private static ChatRequest CreateBaseChatRequest(string modelDeploymentName, str
5058 AllowCodeExecution = true ,
5159 AllowToolCall = true ,
5260 ContextWindow = 128000 ,
53- MaxResponseTokens = 8192 ,
61+ MaxResponseTokens = isImageGenerationExp ? 8192 : ( isFlashExp ? 8000 : ( isFlashImage ? 8192 : 0 ) ) ,
5462 MinTemperature = 0 ,
5563 MaxTemperature = 2 ,
5664 } ;
5765
66+ if ( isFlash )
67+ {
68+ model . ReasoningEffortOptions = "1" ;
69+ }
70+
5871 var chatConfig = new ChatConfig
5972 {
6073 Id = 1 ,
6174 ModelId = 1 ,
6275 Model = model ,
6376 Temperature = 1.0f ,
77+ ReasoningEffortId = isFlash ? ( byte ) DBReasoningEffort . Minimal : ( byte ) DBReasoningEffort . Default ,
78+ SystemPrompt = null ,
6479 } ;
6580
6681 configure ? . Invoke ( chatConfig ) ;
@@ -87,6 +102,7 @@ public async Task CodeExecute_ShouldReturnCodeExecutionResult()
87102 var request = CreateBaseChatRequest ( "gemini-2.5-flash" , "调用内置工具,计算1234/5432=?" , cfg =>
88103 {
89104 cfg . CodeExecutionEnabled = true ;
105+ cfg . SystemPrompt = GoogleAiDumpExtractors . TryGetSystemPrompt ( dump . Request . Body ) ;
90106 } ) ;
91107
92108 // Act
@@ -146,6 +162,7 @@ public async Task ToolCall_ShouldReturnFunctionCall()
146162 var request = CreateBaseChatRequest ( "gemini-2.5-flash" , "调用C#工具,计算1234/5432=?" , cfg =>
147163 {
148164 // 添加工具定义
165+ cfg . SystemPrompt = GoogleAiDumpExtractors . TryGetSystemPrompt ( dump . Request . Body ) ;
149166 } ) ;
150167 request = request with
151168 {
@@ -154,8 +171,8 @@ public async Task ToolCall_ShouldReturnFunctionCall()
154171 new FunctionTool
155172 {
156173 FunctionName = "run_code" ,
157- FunctionDescription = "执行C#代码" ,
158- FunctionParameters = """{"type":"object","properties":{"code":{"type":"string"}},"required":["code"]}"""
174+ FunctionDescription = GoogleAiDumpExtractors . TryGetFirstFunctionDescription ( dump . Request . Body ) ?? "执行C#代码" ,
175+ FunctionParameters = """{"type":"object","properties":{"code":{"type":"string"},"timeout":{"type":"integer"} },"required":["code"]}"""
159176 }
160177 ]
161178 } ;
@@ -197,6 +214,7 @@ public async Task WebSearch_ShouldReturnSearchResults()
197214 var request = CreateBaseChatRequest ( "gemini-2.5-flash" , "今天有什么新闻?" , cfg =>
198215 {
199216 cfg . WebSearchEnabled = true ;
217+ cfg . SystemPrompt = GoogleAiDumpExtractors . TryGetSystemPrompt ( dump . Request . Body ) ;
200218 } ) ;
201219
202220 // Act
@@ -238,8 +256,8 @@ public async Task ImageGenerate_ShouldReturnImage()
238256 var chatCompletionService = new ChatCompletionService ( httpClientFactory ) ;
239257 var service = new GoogleAI2ChatService ( chatCompletionService , httpClientFactory ) ;
240258
241- // 使用支持图片生成的模型名称
242- var request = CreateBaseChatRequest ( "gemini-2.0 -flash-exp- image-generation " , "1+1=? " ) ;
259+ // 使用与录制 Fiddler dump 一致的图片生成模型名称
260+ var request = CreateBaseChatRequest ( "gemini-2.5 -flash-image" , "生成一张小猫的图片 " ) ;
243261
244262 // Act
245263 var segments = new List < ChatSegment > ( ) ;
@@ -279,7 +297,18 @@ public async Task Error404_ShouldThrowRawChatServiceException()
279297 var chatCompletionService = new ChatCompletionService ( httpClientFactory ) ;
280298 var service = new GoogleAI2ChatService ( chatCompletionService , httpClientFactory ) ;
281299
282- var request = CreateBaseChatRequest ( "gemini-2.0-flash-exp-image-generation" , "生成一张小猫的图片" ) ;
300+ var request = CreateBaseChatRequest ( "gemini-2.0-flash-exp-image-generation" , "生成一张小猫的图片" ) with
301+ {
302+ Tools =
303+ [
304+ new FunctionTool
305+ {
306+ FunctionName = "run_code" ,
307+ FunctionDescription = GoogleAiDumpExtractors . TryGetFirstFunctionDescription ( dump . Request . Body ) ?? "执行C#代码" ,
308+ FunctionParameters = """{"type":"object","properties":{"code":{"type":"string"},"timeout":{"type":"integer"}},"required":["code"]}"""
309+ }
310+ ]
311+ } ;
283312
284313 // Act & Assert
285314 var exception = await Assert . ThrowsAsync < RawChatServiceException > ( async ( ) =>
@@ -306,7 +335,18 @@ public async Task Error429_ShouldThrowRawChatServiceException()
306335 var chatCompletionService = new ChatCompletionService ( httpClientFactory ) ;
307336 var service = new GoogleAI2ChatService ( chatCompletionService , httpClientFactory ) ;
308337
309- var request = CreateBaseChatRequest ( "gemini-2.0-flash-exp" , "生成一张小猫的图片" ) ;
338+ var request = CreateBaseChatRequest ( "gemini-2.0-flash-exp" , "生成一张小猫的图片" ) with
339+ {
340+ Tools =
341+ [
342+ new FunctionTool
343+ {
344+ FunctionName = "run_code" ,
345+ FunctionDescription = GoogleAiDumpExtractors . TryGetFirstFunctionDescription ( dump . Request . Body ) ?? "执行C#代码" ,
346+ FunctionParameters = """{"type":"object","properties":{"code":{"type":"string"},"timeout":{"type":"integer"}},"required":["code"]}"""
347+ }
348+ ]
349+ } ;
310350
311351 // Act & Assert
312352 var exception = await Assert . ThrowsAsync < RawChatServiceException > ( async ( ) =>
@@ -323,123 +363,78 @@ public async Task Error429_ShouldThrowRawChatServiceException()
323363 }
324364}
325365
326- /// <summary>
327- /// 模拟的 HttpClientFactory,基于 Fiddler dump 的 chunks 逐块返回响应
328- /// </summary>
329- public class FakeHttpClientFactory : IHttpClientFactory
366+ internal static class GoogleAiDumpExtractors
330367{
331- private readonly List < string > _chunks ;
332- private readonly HttpStatusCode _statusCode ;
333-
334- public FakeHttpClientFactory ( List < string > chunks , HttpStatusCode statusCode = HttpStatusCode . OK )
368+ public static string ? TryGetSystemPrompt ( string ? requestBodyJson )
335369 {
336- _chunks = chunks ;
337- _statusCode = statusCode ;
338- }
370+ if ( string . IsNullOrWhiteSpace ( requestBodyJson ) )
371+ {
372+ return null ;
373+ }
339374
340- public HttpClient CreateClient ( string name )
341- {
342- var handler = new FakeHttpMessageHandler ( _chunks , _statusCode ) ;
343- return new HttpClient ( handler ) ;
344- }
345- }
375+ try
376+ {
377+ using JsonDocument doc = JsonDocument . Parse ( requestBodyJson ) ;
378+ if ( ! doc . RootElement . TryGetProperty ( "systemInstruction" , out JsonElement sys ) )
379+ {
380+ return null ;
381+ }
346382
347- /// <summary>
348- /// 模拟的 HttpMessageHandler,逐块返回响应内容
349- /// </summary>
350- public class FakeHttpMessageHandler : HttpMessageHandler
351- {
352- private readonly List < string > _chunks ;
353- private readonly HttpStatusCode _statusCode ;
383+ if ( ! sys . TryGetProperty ( "parts" , out JsonElement parts ) || parts . ValueKind != JsonValueKind . Array || parts . GetArrayLength ( ) == 0 )
384+ {
385+ return null ;
386+ }
354387
355- public FakeHttpMessageHandler ( List < string > chunks , HttpStatusCode statusCode = HttpStatusCode . OK )
356- {
357- _chunks = chunks ;
358- _statusCode = statusCode ;
359- }
388+ JsonElement first = parts [ 0 ] ;
389+ if ( ! first . TryGetProperty ( "text" , out JsonElement text ) || text . ValueKind != JsonValueKind . String )
390+ {
391+ return null ;
392+ }
360393
361- protected override Task < HttpResponseMessage > SendAsync ( HttpRequestMessage request , CancellationToken cancellationToken )
362- {
363- var response = new HttpResponseMessage ( _statusCode )
364- {
365- Content = new StreamContent ( new ChunkedMemoryStream ( _chunks ) )
366- } ;
367- response . Content . Headers . ContentType = new System . Net . Http . Headers . MediaTypeHeaderValue ( "application/json" )
394+ return text . GetString ( ) ;
395+ }
396+ catch ( JsonException )
368397 {
369- CharSet = "UTF-8"
370- } ;
371- return Task . FromResult ( response ) ;
398+ return null ;
399+ }
372400 }
373- }
374-
375- /// <summary>
376- /// 模拟 chunked 流式响应的 Stream
377- /// 将 chunks 列表转换为可读取的流(跳过 chunk 大小行)
378- /// </summary>
379- public class ChunkedMemoryStream : Stream
380- {
381- private readonly MemoryStream _innerStream ;
382401
383- public ChunkedMemoryStream ( List < string > chunks )
402+ public static string ? TryGetFirstFunctionDescription ( string ? requestBodyJson )
384403 {
385- // HTTP chunked encoding 中,chunk 大小行只是协议分隔符
386- // 原始数据是连续的字节流,chunk 之间不需要添加任何换行符
387- var content = new StringBuilder ( ) ;
388-
389- foreach ( var line in chunks )
404+ if ( string . IsNullOrWhiteSpace ( requestBodyJson ) )
390405 {
391- if ( ! IsChunkSizeLine ( line ) )
406+ return null ;
407+ }
408+
409+ try
410+ {
411+ using JsonDocument doc = JsonDocument . Parse ( requestBodyJson ) ;
412+ if ( ! doc . RootElement . TryGetProperty ( "tools" , out JsonElement tools ) || tools . ValueKind != JsonValueKind . Array )
392413 {
393- content . Append ( line ) ;
414+ return null ;
394415 }
395- }
396-
397- var bytes = Encoding . UTF8 . GetBytes ( content . ToString ( ) ) ;
398- _innerStream = new MemoryStream ( bytes ) ;
399- }
400416
401- /// <summary>
402- /// 判断是否为 chunk 大小行
403- /// </summary>
404- private static bool IsChunkSizeLine ( string line )
405- {
406- if ( string . IsNullOrEmpty ( line ) ) return false ;
407-
408- // 如果行首有空白字符(有缩进),则不是 chunk 大小行
409- if ( char . IsWhiteSpace ( line [ 0 ] ) ) return false ;
410-
411- var trimmed = line . Trim ( ) ;
412- if ( string . IsNullOrEmpty ( trimmed ) ) return false ;
413- if ( trimmed . Length > 8 ) return false ;
414-
415- return trimmed . All ( c =>
416- ( c >= '0' && c <= '9' ) ||
417- ( c >= 'a' && c <= 'f' ) ||
418- ( c >= 'A' && c <= 'F' ) ) ;
419- }
417+ foreach ( JsonElement tool in tools . EnumerateArray ( ) )
418+ {
419+ if ( ! tool . TryGetProperty ( "functionDeclarations" , out JsonElement decls ) || decls . ValueKind != JsonValueKind . Array || decls . GetArrayLength ( ) == 0 )
420+ {
421+ continue ;
422+ }
420423
421- public override bool CanRead => true ;
422- public override bool CanSeek => _innerStream . CanSeek ;
423- public override bool CanWrite => false ;
424- public override long Length => _innerStream . Length ;
425- public override long Position
426- {
427- get => _innerStream . Position ;
428- set => _innerStream . Position = value ;
429- }
424+ JsonElement decl = decls [ 0 ] ;
425+ if ( ! decl . TryGetProperty ( "description" , out JsonElement desc ) || desc . ValueKind != JsonValueKind . String )
426+ {
427+ return null ;
428+ }
430429
431- public override void Flush ( ) => _innerStream . Flush ( ) ;
432- public override int Read ( byte [ ] buffer , int offset , int count ) => _innerStream . Read ( buffer , offset , count ) ;
433- public override long Seek ( long offset , SeekOrigin origin ) => _innerStream . Seek ( offset , origin ) ;
434- public override void SetLength ( long value ) => throw new NotSupportedException ( ) ;
435- public override void Write ( byte [ ] buffer , int offset , int count ) => throw new NotSupportedException ( ) ;
430+ return desc . GetString ( ) ;
431+ }
436432
437- protected override void Dispose ( bool disposing )
438- {
439- if ( disposing )
433+ return null ;
434+ }
435+ catch ( JsonException )
440436 {
441- _innerStream . Dispose ( ) ;
437+ return null ;
442438 }
443- base . Dispose ( disposing ) ;
444439 }
445440}
0 commit comments