diff --git a/docs/protocol.md b/docs/protocol.md index 0d4652c8..d5b3761c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 流尾状态结束,且这次生成没有输出正文、函数调用与媒体时(不输出给客户端的搜索、网址上下文、地图与文件检索步骤不计入,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`。 @@ -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/generate.go b/internal/aistudio/generate.go index c71dfeec..b8b18b6d 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 保存并发输入计数结果 @@ -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,8 +886,20 @@ 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) { + 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 } @@ -888,7 +913,7 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan } 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,11 +923,14 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan return } stopClose = closeOnCancel(response) - usage, finish = nil, nil + 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) @@ -944,18 +972,18 @@ 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 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) + return !committed } // countStopInput 并发计数请求输入,供本地命中 stop sequence 后补全用量 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 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_")