Skip to content

Commit 5e175b3

Browse files
committed
Add dump request assert
1 parent d23c88b commit 5e175b3

3 files changed

Lines changed: 549 additions & 107 deletions

File tree

src/BE.Tests/ChatServices/GoogleAI/GoogleAI2ChatServiceTest.cs

Lines changed: 102 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
using Chats.BE.Services.Models.Neutral;
1111
using System.Net;
1212
using System.Text;
13+
using System.Text.Json;
14+
using Chats.BE.Tests.ChatServices.Http;
1315

1416
namespace 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

Comments
 (0)