From 0008880b82f91c1cff93c8fceb2798c35d0a8709 Mon Sep 17 00:00:00 2001 From: xiahuaaaa <130882695+xiahuaaaa@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:16:05 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(api):=20=E4=BF=AE=E6=AD=A3=20malformed?= =?UTF-8?q?=5Fresponse=20=E6=98=A0=E5=B0=84=E3=80=81=E6=97=A0=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E8=87=AA=E5=8A=A8=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=B8=8E=E9=BB=98=E8=AE=A4=E5=AE=89=E5=85=A8=E7=B1=BB=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/build.md | 5 ++-- docs/protocol.md | 8 +++--- internal/aistudio/compatibility_test.go | 34 +++++++++++++++++++++++++ internal/aistudio/generate.go | 10 +++++--- internal/aistudio/safety.go | 2 +- internal/api/openai.go | 2 +- 6 files changed, 49 insertions(+), 12 deletions(-) diff --git a/docs/build.md b/docs/build.md index 2d052998..61ae196f 100644 --- a/docs/build.md +++ b/docs/build.md @@ -264,7 +264,7 @@ Build 请求与 Playground 共用同一预处理:工具可用性校验、模 | `serviceTier` | `SERVICE_TIER_<请求值大写>` | | `store`、`labels` | 请求值 | -- `safetySettings` 默认为骚扰、仇恨、色情、危险四类 `OFF`,请求中的类别按名称覆盖或追加;图片路由只发送请求中的类别 +- `safetySettings` 默认为骚扰、仇恨、色情、危险、公民诚信五类 `OFF`,请求中的类别按名称覆盖或追加;图片路由只发送请求中的类别 ```json { @@ -279,7 +279,8 @@ Build 请求与 Playground 共用同一预处理:工具可用性校验、模 {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, - {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"} + {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"} ] } ``` diff --git a/docs/protocol.md b/docs/protocol.md index 0d4652c8..06a591cd 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -483,7 +483,7 @@ safety settings: ] ``` -每项为 `[null, null, 类别, 阈值]`,编号与 Gemini API 枚举相同:类别 `HARM_CATEGORY_HARASSMENT`=7、`HATE_SPEECH`=8、`SEXUALLY_EXPLICIT`=9、`DANGEROUS_CONTENT`=10、`CIVIC_INTEGRITY`=11;阈值 `BLOCK_LOW_AND_ABOVE`=1、`BLOCK_MEDIUM_AND_ABOVE`=2、`BLOCK_ONLY_HIGH`=3、`BLOCK_NONE`=4、`OFF`=5。Playground 没有 `HARM_CATEGORY_JAILBREAK` 的编号,含该类别的请求经 Build 通道发送。非图片模型默认发送上面 7–10 四类 `OFF`,Gemini 协议请求中的类别按名称覆盖或追加;图片模型只发送请求中的类别,没有时为 `null`。上游对 `HARM_CATEGORY_UNSPECIFIED`(0)与 `DEROGATORY` 至 `DANGEROUS`(1–6)返回 400,这些类别与 `HARM_CATEGORY_IMAGE_*` 不发送;其他未知名称在发送前返回 400 并列出可用类别,未知阈值同样在发送前返回 400。 +每项为 `[null, null, 类别, 阈值]`,编号与 Gemini API 枚举相同:类别 `HARM_CATEGORY_HARASSMENT`=7、`HATE_SPEECH`=8、`SEXUALLY_EXPLICIT`=9、`DANGEROUS_CONTENT`=10、`CIVIC_INTEGRITY`=11;阈值 `BLOCK_LOW_AND_ABOVE`=1、`BLOCK_MEDIUM_AND_ABOVE`=2、`BLOCK_ONLY_HIGH`=3、`BLOCK_NONE`=4、`OFF`=5。Playground 没有 `HARM_CATEGORY_JAILBREAK` 的编号,含该类别的请求经 Build 通道发送。非图片模型默认发送上面 7–11 五类 `OFF`,Gemini 协议请求中的类别按名称覆盖或追加;图片模型只发送请求中的类别,没有时为 `null`。上游对 `HARM_CATEGORY_UNSPECIFIED`(0)与 `DEROGATORY` 至 `DANGEROUS`(1–6)返回 400,这些类别与 `HARM_CATEGORY_IMAGE_*` 不发送;其他未知名称在发送前返回 400 并列出可用类别,未知阈值同样在发送前返回 400。 generation config 字段: @@ -752,7 +752,7 @@ ListModels 能力码 91(目录能力 `output_continuation`)的模型由官 | 18 | missing_thought_signature | 19 | malformed_response | | 22 | continuation | 其他整数 | `provider_` | -Build 通道的 Gemini API 终止原因去掉 `FINISH_REASON_` 前缀后转为上表的同名小写原因,上表没有的名称为 `provider_<小写名称>`。上游以 `malformed_function_call` 或 `unexpected_tool_call` 结束,或流在完成帧之前中断、以 5xx 流尾状态结束,且这次生成没有输出正文、函数调用与媒体时,服务在同一账户上以同一请求重新生成,最多再生成 3 次,之后按最后一次的终止原因或中断错误结束;每次重新生成从头匹配停止序列,上一次暂存的停止序列前缀不输出,重新生成的请求发送失败时本次生成以该错误结束。两个通道第一次带终止原因的帧中的内容事件标记为结束帧,自动续写中转入下一轮的帧不标记;Gemini 流式输出把结束帧的内容放进最后一块。 +Build 通道的 Gemini API 终止原因去掉 `FINISH_REASON_` 前缀后转为上表的同名小写原因,上表没有的名称为 `provider_<小写名称>`。上游以 `malformed_function_call`、`unexpected_tool_call` 或 `malformed_response` 结束,或流在完成帧之前中断、以 5xx 流尾状态结束,且这次生成没有输出正文、函数调用与媒体时,服务在同一账户上以同一请求重新生成,最多再生成 3 次,之后按最后一次的终止原因或中断错误结束;每次重新生成从头匹配停止序列,上一次暂存的停止序列前缀不输出,重新生成的请求发送失败时本次生成以该错误结束。两个通道第一次带终止原因的帧中的内容事件标记为结束帧,自动续写中转入下一轮的帧不标记;Gemini 流式输出把结束帧的内容放进最后一块。 候选 field 5(`$[0][frame][0][0][4]`)为安全评级 `[[null, null, 类别, 概率, blocked?], ...]`,类别编号与安全设置相同,概率 `NEGLIGIBLE`=1、`LOW`=2、`MEDIUM`=3、`HIGH`=4;输入拒绝帧 `[null,[block_reason,ratings?,message]]` 的 ratings 为同形数组。Build 通道读取候选与 `promptFeedback` 的 `safetyRatings`。 @@ -782,8 +782,8 @@ generation config、安全设置、工具与转写配置在选择账户前按各 | `stop_sequence` | `stop` | `completed` | `stop_sequence` 与实际序列 | `STOP` | | `max_tokens` | `length` | `incomplete/max_output_tokens` | `max_tokens` | `MAX_TOKENS` | | `continuation` | `length` | `incomplete/max_output_tokens` | `max_tokens` | `CONTINUATION` | -| safety、recitation、blocklist、prohibited_content、spii、malformed_response、图片对应原因与 `provider_*` | `content_filter` | `incomplete/content_filter` | `refusal` | 对应 Gemini 枚举或 `OTHER` | -| other、language、malformed_function_call、签名缺失等其他终态 | `stop`,含函数调用时 `tool_calls` | `completed` | `end_turn`,含函数调用时 `tool_use` | 对应 Gemini 枚举 | +| safety、recitation、blocklist、prohibited_content、spii、图片对应原因与 `provider_*` | `content_filter` | `incomplete/content_filter` | `refusal` | 对应 Gemini 枚举或 `OTHER` | +| other、language、malformed_function_call、malformed_response、签名缺失等其他终态 | `stop`,含函数调用时 `tool_calls` | `completed` | `end_turn`,含函数调用时 `tool_use` | 对应 Gemini 枚举 | 内容拦截终态优先于同一结果中的工具调用终态,已产生的正文、reasoning、工具事件和 usage 保持在响应中。`provider_*` 在 Chat choice、Responses response、Anthropic message 或 `message_delta` 的 `provider_finish_reason` 中保留原值;Gemini 对 Build 通道的 `provider_<名称>` 输出大写的原枚举名,对 Playground 的 `provider_<编号>` 输出 `OTHER` 并用 `finishMessage` 保留编号。`malformed_response`(Playground 编号 19)在 AI Studio 页面显示为 `Content blocked`。 diff --git a/internal/aistudio/compatibility_test.go b/internal/aistudio/compatibility_test.go index 8ce53140..d33f4e59 100644 --- a/internal/aistudio/compatibility_test.go +++ b/internal/aistudio/compatibility_test.go @@ -1,6 +1,7 @@ package aistudio import ( + "context" "encoding/json" "net/http" "strings" @@ -227,3 +228,36 @@ func TestFunctionResultNameInference(t *testing.T) { }) } } + +// TestResolveSafetySettingsCivicIntegrity 验证默认安全设置包含 civic integrity 类别 +func TestResolveSafetySettingsCivicIntegrity(t *testing.T) { + settings, err := resolveSafetySettings(nil, false) + if err != nil { + t.Fatal(err) + } + if len(settings) != 5 { + t.Fatalf("expected 5 safety settings, got %d: %#v", len(settings), settings) + } + found := false + for _, setting := range settings { + if setting.Category == "HARM_CATEGORY_CIVIC_INTEGRITY" && setting.Threshold == "OFF" { + found = true + break + } + } + if !found { + t.Fatalf("CIVIC_INTEGRITY not found in resolved safety settings: %#v", settings) + } +} + +// TestRegenerableMalformedResponse 验证无正文时的 malformed_response 触发重新生成 +func TestRegenerableMalformedResponse(t *testing.T) { + finish := &Event{Kind: EventFinish, FinishReason: "malformed_response"} + if !regenerable(context.Background(), nil, finish, Output{}) { + t.Fatal("expected malformed_response without output to be regenerable") + } + outputWithText := Output{Parts: []Part{{Text: "hello"}}} + if regenerable(context.Background(), nil, finish, outputWithText) { + t.Fatal("expected malformed_response with visible output NOT to be regenerable") + } +} diff --git a/internal/aistudio/generate.go b/internal/aistudio/generate.go index c71dfeec..0023ef18 100644 --- a/internal/aistudio/generate.go +++ b/internal/aistudio/generate.go @@ -19,7 +19,7 @@ var errStopSequenceMatched = errors.New("stop sequence matched") // continuationRoundTokens 为官网自动续写每轮增加的输出上限 const continuationRoundTokens = 32768 -// regenerations 是上游以函数调用格式错误结束、流中断或流尾 5xx 错误且客户端没有收到内容时重新生成的次数上限 +// regenerations 是上游以函数调用格式错误、意外的工具调用或响应格式异常结束、流中断或流尾 5xx 错误且客户端没有收到内容时重新生成的次数上限 const regenerations = 3 // tokenCountResult 保存并发输入计数结果 @@ -873,7 +873,6 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan finish = &value return nil default: - output.Add(event) if request.Config.HideThinking && event.Kind == EventReasoning || serverToolHidden(request, event) { if event.ThoughtSignature == "" { return nil @@ -883,6 +882,7 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan TurnToken: event.TurnToken, DebugInfo: event.DebugInfo, ResponseID: event.ResponseID, FrameUsage: event.FrameUsage, FinishFrame: event.FinishFrame, } } + output.Add(event) return send(event) } } @@ -899,6 +899,8 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan } stopClose = closeOnCancel(response) usage, finish = nil, nil + output = Output{} + matchedStopSequence = "" matcher = newStopSequenceMatcher(request.Config.StopSequences) emit = stopSequenceEmitter(matcher, emitEvent, &matchedStopSequence) err = decodeStream(observeStreamActivity(ctx, response.Body), emit) @@ -944,7 +946,7 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan return contract.forward(ctx, events), nil } -// regenerable 表示上游以函数调用格式错误或意外的工具调用结束、流在完成帧之前中断或流尾为 5xx 错误,且没有输出正文、函数调用与媒体 +// regenerable 表示上游以函数调用格式错误、意外的工具调用或响应格式异常结束、流在完成帧之前中断或流尾为 5xx 错误,且没有输出正文、函数调用与媒体 func regenerable(ctx context.Context, err error, finish *Event, output Output) bool { if err != nil { var interrupted *StreamInterruptedError @@ -952,7 +954,7 @@ func regenerable(ctx context.Context, err error, finish *Event, output Output) b if ctx.Err() != nil || !errors.As(err, &interrupted) && !(errors.As(err, &rpcError) && rpcError.StatusCode >= http.StatusInternalServerError) { return false } - } else if finish == nil || finish.FinishReason != "malformed_function_call" && finish.FinishReason != "unexpected_tool_call" { + } else if finish == nil || finish.FinishReason != "malformed_function_call" && finish.FinishReason != "unexpected_tool_call" && finish.FinishReason != "malformed_response" { return false } return !slices.ContainsFunc(output.Visible(), Part.payload) diff --git a/internal/aistudio/safety.go b/internal/aistudio/safety.go index 156ef8d6..1041f6d5 100644 --- a/internal/aistudio/safety.go +++ b/internal/aistudio/safety.go @@ -81,7 +81,7 @@ var harmThresholds = map[string]int64{ func resolveSafetySettings(settings []SafetySetting, imageRoute bool) ([]SafetySetting, error) { thresholds := map[string]string{} if !imageRoute { - for _, category := range harmCategories[:4] { + for _, category := range harmCategories { thresholds[category.name] = "OFF" } } diff --git a/internal/api/openai.go b/internal/api/openai.go index de74e20b..e2d0c87d 100644 --- a/internal/api/openai.go +++ b/internal/api/openai.go @@ -1363,7 +1363,7 @@ func legacyFunctionCall(call aistudio.FunctionCall) map[string]any { func contentFilterFinishReason(reason string) bool { switch reason { case "safety", "recitation", "blocklist", "prohibited_content", "spii", - "image_safety", "image_prohibited_content", "image_recitation", "malformed_response": + "image_safety", "image_prohibited_content", "image_recitation": return true } return strings.HasPrefix(reason, "provider_") From 43d8eb87e1d251a6e6ec0445e210597df4f68c66 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Tue, 3 Nov 2026 15:02:01 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E8=A1=A5=E4=BF=AE=20malformed=5Fres?= =?UTF-8?q?ponse=20=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90=E4=B8=8E=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E7=B1=BB=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 排除隐藏的只读服务端工具步骤,MCP 调用仍阻止重新生成,重新生成时保留已输出的思考与停止序列状态 - 丢弃被重新生成取代的那一次的网页来源与引用 - 撤销默认安全设置中的公民诚信类别,删除断言内部函数与类别数量的测试 - 补充协议文档的重新生成条件与来源处理,构建文档的安全类别恢复为四类 --- docs/build.md | 5 ++- docs/protocol.md | 4 +-- internal/aistudio/compatibility_test.go | 34 ------------------- internal/aistudio/generate.go | 44 ++++++++++++++++++++----- internal/aistudio/safety.go | 2 +- internal/aistudio/server_tools.go | 6 ++++ 6 files changed, 46 insertions(+), 49 deletions(-) diff --git a/docs/build.md b/docs/build.md index 61ae196f..2d052998 100644 --- a/docs/build.md +++ b/docs/build.md @@ -264,7 +264,7 @@ Build 请求与 Playground 共用同一预处理:工具可用性校验、模 | `serviceTier` | `SERVICE_TIER_<请求值大写>` | | `store`、`labels` | 请求值 | -- `safetySettings` 默认为骚扰、仇恨、色情、危险、公民诚信五类 `OFF`,请求中的类别按名称覆盖或追加;图片路由只发送请求中的类别 +- `safetySettings` 默认为骚扰、仇恨、色情、危险四类 `OFF`,请求中的类别按名称覆盖或追加;图片路由只发送请求中的类别 ```json { @@ -279,8 +279,7 @@ Build 请求与 Playground 共用同一预处理:工具可用性校验、模 {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, - {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, - {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"} + {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"} ] } ``` diff --git a/docs/protocol.md b/docs/protocol.md index 06a591cd..d5b3761c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -483,7 +483,7 @@ safety settings: ] ``` -每项为 `[null, null, 类别, 阈值]`,编号与 Gemini API 枚举相同:类别 `HARM_CATEGORY_HARASSMENT`=7、`HATE_SPEECH`=8、`SEXUALLY_EXPLICIT`=9、`DANGEROUS_CONTENT`=10、`CIVIC_INTEGRITY`=11;阈值 `BLOCK_LOW_AND_ABOVE`=1、`BLOCK_MEDIUM_AND_ABOVE`=2、`BLOCK_ONLY_HIGH`=3、`BLOCK_NONE`=4、`OFF`=5。Playground 没有 `HARM_CATEGORY_JAILBREAK` 的编号,含该类别的请求经 Build 通道发送。非图片模型默认发送上面 7–11 五类 `OFF`,Gemini 协议请求中的类别按名称覆盖或追加;图片模型只发送请求中的类别,没有时为 `null`。上游对 `HARM_CATEGORY_UNSPECIFIED`(0)与 `DEROGATORY` 至 `DANGEROUS`(1–6)返回 400,这些类别与 `HARM_CATEGORY_IMAGE_*` 不发送;其他未知名称在发送前返回 400 并列出可用类别,未知阈值同样在发送前返回 400。 +每项为 `[null, null, 类别, 阈值]`,编号与 Gemini API 枚举相同:类别 `HARM_CATEGORY_HARASSMENT`=7、`HATE_SPEECH`=8、`SEXUALLY_EXPLICIT`=9、`DANGEROUS_CONTENT`=10、`CIVIC_INTEGRITY`=11;阈值 `BLOCK_LOW_AND_ABOVE`=1、`BLOCK_MEDIUM_AND_ABOVE`=2、`BLOCK_ONLY_HIGH`=3、`BLOCK_NONE`=4、`OFF`=5。Playground 没有 `HARM_CATEGORY_JAILBREAK` 的编号,含该类别的请求经 Build 通道发送。非图片模型默认发送上面 7–10 四类 `OFF`,Gemini 协议请求中的类别按名称覆盖或追加;图片模型只发送请求中的类别,没有时为 `null`。上游对 `HARM_CATEGORY_UNSPECIFIED`(0)与 `DEROGATORY` 至 `DANGEROUS`(1–6)返回 400,这些类别与 `HARM_CATEGORY_IMAGE_*` 不发送;其他未知名称在发送前返回 400 并列出可用类别,未知阈值同样在发送前返回 400。 generation config 字段: @@ -752,7 +752,7 @@ ListModels 能力码 91(目录能力 `output_continuation`)的模型由官 | 18 | missing_thought_signature | 19 | malformed_response | | 22 | continuation | 其他整数 | `provider_` | -Build 通道的 Gemini API 终止原因去掉 `FINISH_REASON_` 前缀后转为上表的同名小写原因,上表没有的名称为 `provider_<小写名称>`。上游以 `malformed_function_call`、`unexpected_tool_call` 或 `malformed_response` 结束,或流在完成帧之前中断、以 5xx 流尾状态结束,且这次生成没有输出正文、函数调用与媒体时,服务在同一账户上以同一请求重新生成,最多再生成 3 次,之后按最后一次的终止原因或中断错误结束;每次重新生成从头匹配停止序列,上一次暂存的停止序列前缀不输出,重新生成的请求发送失败时本次生成以该错误结束。两个通道第一次带终止原因的帧中的内容事件标记为结束帧,自动续写中转入下一轮的帧不标记;Gemini 流式输出把结束帧的内容放进最后一块。 +Build 通道的 Gemini API 终止原因去掉 `FINISH_REASON_` 前缀后转为上表的同名小写原因,上表没有的名称为 `provider_<小写名称>`。上游以 `malformed_function_call`、`unexpected_tool_call` 或 `malformed_response` 结束,或流在完成帧之前中断、以 5xx 流尾状态结束,且这次生成没有输出正文、函数调用与媒体时(不输出给客户端的搜索、网址上下文、地图与文件检索步骤不计入,MCP 调用计入),服务在同一账户上以同一请求重新生成,最多再生成 3 次,之后按最后一次的终止原因或中断错误结束;被重新生成取代的那一次的网页来源与引用不输出,每次重新生成从头匹配停止序列,上一次暂存的停止序列前缀不输出,重新生成的请求发送失败时本次生成以该错误结束。两个通道第一次带终止原因的帧中的内容事件标记为结束帧,自动续写中转入下一轮的帧不标记;Gemini 流式输出把结束帧的内容放进最后一块。 候选 field 5(`$[0][frame][0][0][4]`)为安全评级 `[[null, null, 类别, 概率, blocked?], ...]`,类别编号与安全设置相同,概率 `NEGLIGIBLE`=1、`LOW`=2、`MEDIUM`=3、`HIGH`=4;输入拒绝帧 `[null,[block_reason,ratings?,message]]` 的 ratings 为同形数组。Build 通道读取候选与 `promptFeedback` 的 `safetyRatings`。 diff --git a/internal/aistudio/compatibility_test.go b/internal/aistudio/compatibility_test.go index d33f4e59..8ce53140 100644 --- a/internal/aistudio/compatibility_test.go +++ b/internal/aistudio/compatibility_test.go @@ -1,7 +1,6 @@ package aistudio import ( - "context" "encoding/json" "net/http" "strings" @@ -228,36 +227,3 @@ func TestFunctionResultNameInference(t *testing.T) { }) } } - -// TestResolveSafetySettingsCivicIntegrity 验证默认安全设置包含 civic integrity 类别 -func TestResolveSafetySettingsCivicIntegrity(t *testing.T) { - settings, err := resolveSafetySettings(nil, false) - if err != nil { - t.Fatal(err) - } - if len(settings) != 5 { - t.Fatalf("expected 5 safety settings, got %d: %#v", len(settings), settings) - } - found := false - for _, setting := range settings { - if setting.Category == "HARM_CATEGORY_CIVIC_INTEGRITY" && setting.Threshold == "OFF" { - found = true - break - } - } - if !found { - t.Fatalf("CIVIC_INTEGRITY not found in resolved safety settings: %#v", settings) - } -} - -// TestRegenerableMalformedResponse 验证无正文时的 malformed_response 触发重新生成 -func TestRegenerableMalformedResponse(t *testing.T) { - finish := &Event{Kind: EventFinish, FinishReason: "malformed_response"} - if !regenerable(context.Background(), nil, finish, Output{}) { - t.Fatal("expected malformed_response without output to be regenerable") - } - outputWithText := Output{Parts: []Part{{Text: "hello"}}} - if regenerable(context.Background(), nil, finish, outputWithText) { - t.Fatal("expected malformed_response with visible output NOT to be regenerable") - } -} diff --git a/internal/aistudio/generate.go b/internal/aistudio/generate.go index 0023ef18..b8b18b6d 100644 --- a/internal/aistudio/generate.go +++ b/internal/aistudio/generate.go @@ -861,6 +861,19 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan var usage *Usage var finish *Event var output Output + // committed 表示本次生成已有不能随重新生成丢弃的输出:客户端收到的正文、函数调用与媒体,或重新执行有外部副作用的服务端工具调用 + committed := false + // sources 暂存 committed 之前收到的网页来源与引用,重新生成时丢弃 + var sources []Event + releaseSources := func() error { + for _, source := range sources { + if err := send(source); err != nil { + return err + } + } + sources = nil + return nil + } matchedStopSequence := "" emitEvent := func(event Event) error { switch event.Kind { @@ -873,7 +886,20 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan finish = &value return nil default: - if request.Config.HideThinking && event.Kind == EventReasoning || serverToolHidden(request, event) { + hiddenTool := serverToolHidden(request, event) + if !hiddenTool || !serverToolReplayable(event) { + output.Add(event) + committed = committed || !event.Thought && event.payload() + } + if !committed { + if event.Kind == EventGrounding || event.Kind == EventCitation { + sources = append(sources, event) + return nil + } + } else if err := releaseSources(); err != nil { + return err + } + if request.Config.HideThinking && event.Kind == EventReasoning || hiddenTool { if event.ThoughtSignature == "" { return nil } @@ -882,13 +908,12 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan TurnToken: event.TurnToken, DebugInfo: event.DebugInfo, ResponseID: event.ResponseID, FrameUsage: event.FrameUsage, FinishFrame: event.FinishFrame, } } - output.Add(event) return send(event) } } emit := stopSequenceEmitter(matcher, emitEvent, &matchedStopSequence) err := decodeStream(observeStreamActivity(ctx, response.Body), emit) - for regenerated := 0; regenerated < regenerations && regenerable(ctx, err, finish, output); regenerated++ { + for regenerated := 0; regenerated < regenerations && regenerable(ctx, err, finish, committed); regenerated++ { stopClose() _ = response.Body.Close() if response, decodeStream, err = sendRequest(); err != nil { @@ -898,13 +923,14 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan return } stopClose = closeOnCancel(response) - usage, finish = nil, nil - output = Output{} - matchedStopSequence = "" + usage, finish, sources = nil, nil, nil matcher = newStopSequenceMatcher(request.Config.StopSequences) emit = stopSequenceEmitter(matcher, emitEvent, &matchedStopSequence) err = decodeStream(observeStreamActivity(ctx, response.Body), emit) } + if releaseSources() != nil { + return + } if errors.Is(err, errStopSequenceMatched) { _ = response.Body.Close() finishAtStopSequence(ctx, request, output, stopTokenCount, matchedStopSequence, send) @@ -946,8 +972,8 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan return contract.forward(ctx, events), nil } -// regenerable 表示上游以函数调用格式错误、意外的工具调用或响应格式异常结束、流在完成帧之前中断或流尾为 5xx 错误,且没有输出正文、函数调用与媒体 -func regenerable(ctx context.Context, err error, finish *Event, output Output) bool { +// regenerable 表示上游以函数调用格式错误、意外的工具调用或响应格式异常结束、流在完成帧之前中断或流尾为 5xx 错误,且本次生成还没有不能丢弃的输出 +func regenerable(ctx context.Context, err error, finish *Event, committed bool) bool { if err != nil { var interrupted *StreamInterruptedError var rpcError *RPCError @@ -957,7 +983,7 @@ func regenerable(ctx context.Context, err error, finish *Event, output Output) b } else if finish == nil || finish.FinishReason != "malformed_function_call" && finish.FinishReason != "unexpected_tool_call" && finish.FinishReason != "malformed_response" { return false } - return !slices.ContainsFunc(output.Visible(), Part.payload) + return !committed } // countStopInput 并发计数请求输入,供本地命中 stop sequence 后补全用量 diff --git a/internal/aistudio/safety.go b/internal/aistudio/safety.go index 1041f6d5..156ef8d6 100644 --- a/internal/aistudio/safety.go +++ b/internal/aistudio/safety.go @@ -81,7 +81,7 @@ var harmThresholds = map[string]int64{ func resolveSafetySettings(settings []SafetySetting, imageRoute bool) ([]SafetySetting, error) { thresholds := map[string]string{} if !imageRoute { - for _, category := range harmCategories { + for _, category := range harmCategories[:4] { thresholds[category.name] = "OFF" } } diff --git a/internal/aistudio/server_tools.go b/internal/aistudio/server_tools.go index 96496688..d9938efe 100644 --- a/internal/aistudio/server_tools.go +++ b/internal/aistudio/server_tools.go @@ -3,6 +3,7 @@ package aistudio import ( "encoding/json" "fmt" + "slices" ) // serverToolTypes 是 Playground ToolCall 与 ToolResponse field 1 的工具类型编号,MCP 调用为 CUSTOM,MCP 结果不带类型 @@ -26,6 +27,11 @@ func serverToolHidden(request GenerateRequest, event Event) bool { return step != nil && step.Type != "MEDIA_PROCESSING" && !request.Tools.ToolConfig.ServerSideInvocations } +// serverToolReplayable 表示事件随重新生成在上游再次执行没有外部副作用:网页与图片搜索、网址上下文、地图与文件检索的调用和全部工具结果,MCP 调用与其他类型的调用除外 +func serverToolReplayable(event Event) bool { + return event.ToolCall == nil || slices.Contains([]string{"GOOGLE_SEARCH_WEB", "GOOGLE_SEARCH_IMAGE", "URL_CONTEXT", "GOOGLE_MAPS", "FILE_SEARCH"}, event.ToolCall.Type) +} + // encodeServerToolStep 编码 ToolCall 或 ToolResponse:工具类型、名称、Struct 与调用 ID func encodeServerToolStep(step *ServerToolStep) ([]any, error) { stepType := step.Type