From b8f3dc243f288d1e63ba1f14a3fc99fe24ab7ced Mon Sep 17 00:00:00 2001 From: Indor <1217877330@qq.com> Date: Mon, 12 Oct 2026 23:39:12 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Gemini=20=E6=B5=81=E5=BC=8F=E5=80=99?= =?UTF-8?q?=E9=80=89=E8=A1=A5=E9=BD=90=20content=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E4=B8=8E=20groundingMetadata=20=E5=BC=95=E7=94=A8=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/gemini.go | 22 +++++++++++++++++++--- internal/api/gemini_output_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/internal/api/gemini.go b/internal/api/gemini.go index ce44430e..b07a2b38 100644 --- a/internal/api/gemini.go +++ b/internal/api/gemini.go @@ -1277,8 +1277,16 @@ func (s *server) streamGemini(w http.ResponseWriter, r *http.Request, request ai } finished := make([]any, 0, len(results)) for index, result := range results { - candidate := map[string]any{"index": index} + candidate := map[string]any{ + "index": index, + "content": map[string]any{"role": "model", "parts": []any{}}, + } setGeminiFinish(candidate, result.finishReason, result.finishMessage) + if result.grounding != nil { + candidate["groundingMetadata"] = geminiGroundingMetadata(*result.grounding) + } else if len(result.citations) > 0 { + candidate["citationMetadata"] = geminiCitationMetadata(result.citations) + } finished = append(finished, candidate) } final := map[string]any{ @@ -1322,12 +1330,20 @@ func geminiStreamCandidate(event aistudio.Event, index int) map[string]any { if event.Grounding == nil { return nil } - return map[string]any{"index": index, "groundingMetadata": geminiGroundingMetadata(*event.Grounding)} + return map[string]any{ + "index": index, + "content": map[string]any{"role": "model", "parts": []any{}}, + "groundingMetadata": geminiGroundingMetadata(*event.Grounding), + } case aistudio.EventCitation: if event.Citation == nil { return nil } - return map[string]any{"index": index, "citationMetadata": geminiCitationMetadata([]aistudio.Citation{*event.Citation})} + return map[string]any{ + "index": index, + "content": map[string]any{"role": "model", "parts": []any{}}, + "citationMetadata": geminiCitationMetadata([]aistudio.Citation{*event.Citation}), + } case aistudio.EventMedia: if event.Media == nil { return nil diff --git a/internal/api/gemini_output_test.go b/internal/api/gemini_output_test.go index 581a50ef..32d5ad40 100644 --- a/internal/api/gemini_output_test.go +++ b/internal/api/gemini_output_test.go @@ -217,3 +217,29 @@ func TestGeminiOutputParts_SignedAudio(t *testing.T) { t.Fatalf("signed audio=%+v", parts) } } + +// TestGeminiGroundingMetadataInResponse 验证候选包含 groundingMetadata +func TestGeminiGroundingMetadataInResponse(t *testing.T) { + grounding := aistudio.GroundingMetadata{ + WebSearchQueries: []string{"test query"}, + Chunks: []aistudio.GroundingChunk{ + {Source: "web", URI: "https://example.com", Title: "Example"}, + }, + } + result := generationResult{ + grounding: &grounding, + events: []aistudio.Event{ + {Kind: aistudio.EventText, Text: "Answer"}, + }, + } + resp := buildGeminiResponse(aistudio.GenerateRequest{ID: "req-1", Model: "gemini-3.8-flash"}, []generationResult{result}) + candidates, ok := resp["candidates"].([]any) + if !ok || len(candidates) != 1 { + t.Fatalf("expected 1 candidate, got %#v", resp["candidates"]) + } + cand := candidates[0].(map[string]any) + if cand["groundingMetadata"] == nil { + t.Fatalf("expected groundingMetadata in candidate, got %#v", cand) + } +} + From 58794ba8be5a9e6159df21117c405f971f36d1ba Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Wed, 14 Oct 2026 06:39:27 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20Gemini=20=E6=B5=81=E5=BC=8F=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E5=B9=B6=E5=85=A5=E7=BB=88=E6=AD=A2=E5=9D=97=EF=BC=8C?= =?UTF-8?q?=E5=90=84=E5=8D=8F=E8=AE=AE=E8=BE=93=E5=87=BA=E6=8C=89=E5=AE=98?= =?UTF-8?q?=E6=96=B9=E5=AF=B9=E9=BD=90=E6=80=9D=E8=80=83=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E3=80=81=E7=BB=93=E6=9D=9F=E5=8E=9F=E5=9B=A0=E3=80=81=E6=97=A7?= =?UTF-8?q?=E5=BC=8F=E5=87=BD=E6=95=B0=E8=B0=83=E7=94=A8=E4=B8=8E=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gemini 流式 grounding 与上游引用随最后一块输出,各候选带 content 与 finishReason,流式与非流式共用终止信息写入,非流式同时返回 groundingMetadata 与上游引用 - Gemini 按 thinkingConfig.includeThoughts 输出思考 Part,Anthropic 只在 thinking 为 enabled 或 adaptive 时输出 thinking 块,Responses 只在请求 reasoning.summary 时输出摘要,思考签名与思考用量照常保留 - Chat、Responses 与 Anthropic 只把内容策略原因与未知编号投影为 content_filter、refusal,其余终态按正常结束或函数调用结束 - Chat 支持旧式 functions、function_call 与 function 角色历史,带 functions 的请求以 function_call 返回调用,流式为 delta.function_call - Gemini 上游拒绝输入时返回 200 与只含 promptFeedback 的响应,流式作为唯一一帧发送 - 删除只覆盖非流式路径的 grounding 测试,协议文档同步流式分块、思考输出、终态映射、旧式函数与输入拦截,开发文档补充响应与官方输出一致的要求 --- docs/development.md | 2 +- docs/protocol.md | 23 +++--- internal/api/anthropic.go | 11 ++- internal/api/gemini.go | 120 ++++++++++++++++++----------- internal/api/gemini_output_test.go | 26 ------- internal/api/openai.go | 99 +++++++++++++++++++++--- internal/api/responses.go | 8 +- 7 files changed, 190 insertions(+), 99 deletions(-) diff --git a/docs/development.md b/docs/development.md index 02007cae..888201fd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -344,7 +344,7 @@ API 试用通过 `eventsource-parser` 读取 SSE,以各协议完成事件结 1. 在 `internal/aistudio` 定义规范请求、响应类型与模型能力 2. 编码 [protocol.md](protocol.md) 中对应的 JSON+protobuf 数组 3. 将网络增量解码为规范事件 -4. 由 `internal/api` 分别投影 OpenAI、Responses、Anthropic 和 Gemini 协议 +4. 由 `internal/api` 分别投影 OpenAI、Responses、Anthropic 和 Gemini 协议,响应的块、字段与顺序与各协议官方服务的输出一致 5. 将结构化状态与操作入口绑定到管理端 `internal/api` 消费规范请求与事件,`internal/aistudio` 管理账户文件、WAA runtime、原始上游数组和实时模型能力。资源型操作在创建时记录账户 ID,后续轮询、下载与提示引用使用该账户。 diff --git a/docs/protocol.md b/docs/protocol.md index c983046a..1e2be378 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -685,7 +685,7 @@ Anthropic 流式 `message_start` 写入即时输入估算,最终 `message_delt generation config、安全设置、工具与转写配置在选择账户前按各账户已载入的模型目录校验,任一账户的条目接受即通过。流式请求在写出响应头前等待首个事件:参数错误与首个事件之前的上游错误按非流式返回 HTTP 状态与错误对象;10 秒内没有事件时先写出响应头,此后的错误以各协议的流内错误事件返回。 -错误响应根形状为 `[null,[code,message,...]]`。输入被上游拒绝时帧为 `[null,[block_reason,null,message]]`,返回 400,错误消息包含拒绝原因与上游说明。协议核心保留 HTTP 状态、协议 code 与 message;公开适配器映射为 OpenAI、Anthropic 或 Gemini 错误对象。Chat、Responses、Anthropic Messages 与 Gemini GenerateContent 将媒体模型的普通文本作为文本结果输出;专用图片端点要求图片结果。HTTP/协议错误或缺失完成帧形成失败;上游 finish reason 作为正常终态保留并映射到各公开协议。 +错误响应根形状为 `[null,[code,message,...]]`。输入被上游拒绝时帧为 `[null,[block_reason,null,message]]`:Gemini GenerateContent 返回 200 与不含 `candidates` 的 `promptFeedback:{blockReason,blockReasonMessage}`,流式时作为唯一一帧发送;其他协议返回 400,错误消息包含拒绝原因与上游说明。协议核心保留 HTTP 状态、协议 code 与 message;公开适配器映射为 OpenAI、Anthropic 或 Gemini 错误对象。Chat、Responses、Anthropic Messages 与 Gemini GenerateContent 将媒体模型的普通文本作为文本结果输出;专用图片端点要求图片结果。HTTP/协议错误或缺失完成帧形成失败;上游 finish reason 作为正常终态保留并映射到各公开协议。 各协议的具体终态转换如下: @@ -694,9 +694,10 @@ generation config、安全设置、工具与转写配置在选择账户前按各 | `stop` 且包含函数调用 | `tool_calls` | `completed`,保留 function call item | `tool_use` | `STOP`,保留 functionCall Part | | `stop_sequence` | `stop` | `completed` | `stop_sequence` 与实际序列 | `STOP` | | `max_tokens` | `length` | `incomplete/max_output_tokens` | `max_tokens` | `MAX_TOKENS` | -| policy、refusal、签名缺失及其他异常终态 | `content_filter` | `incomplete/content_filter` | `refusal` | 对应 Gemini 枚举或 `OTHER` | +| safety、recitation、blocklist、prohibited_content、spii、图片对应原因与 `provider_*` | `content_filter` | `incomplete/content_filter` | `refusal` | 对应 Gemini 枚举或 `OTHER` | +| other、language、malformed_function_call、签名缺失等其他终态 | `stop`,含函数调用时 `tool_calls` | `completed` | `end_turn`,含函数调用时 `tool_use` | 对应 Gemini 枚举 | -异常终态优先于同一结果中的工具调用终态,已产生的正文、reasoning、工具事件和 usage 保持在响应中。`provider_*` 在 Chat choice、Responses response、Anthropic message 或 `message_delta` 的 `provider_finish_reason` 中保留原值;Gemini 使用 `finishMessage` 保留编号。`provider_19` 对应 AI Studio 页面的 `Content blocked`。 +内容拦截终态优先于同一结果中的工具调用终态,已产生的正文、reasoning、工具事件和 usage 保持在响应中。`provider_*` 在 Chat choice、Responses response、Anthropic message 或 `message_delta` 的 `provider_finish_reason` 中保留原值;Gemini 使用 `finishMessage` 保留编号。`provider_19` 对应 AI Studio 页面的 `Content blocked`。 ## 6. 函数、Google 工具、Drive 与媒体 @@ -1429,7 +1430,7 @@ OpenAI Chat 与 Anthropic 省略转换后没有 parts 的空历史消息;纯 - OpenAI Chat 与 Responses 的 `parallel_tool_calls` 省略或为 `true` 时允许并行;Responses 同时把该值写入响应元数据 - Responses `truncation:auto` 按上游权威计数移除最早的完整对话轮次,保留最新轮次与工具调用/结果配对;省略或 `disabled` 时保留完整输入 -- Anthropic `thinking.type:"disabled"` 同时隐藏思考正文,续接签名保持;未知 type 返回 `400 invalid_request_error` +- Anthropic 省略 `thinking` 或 `thinking.type:"disabled"` 时不返回思考正文,续接签名以 `redacted_thinking` 保持;未知 type 返回 `400 invalid_request_error` - Chat、Responses 与 Gemini 的协议专属参数见各协议小节 ### OpenAI Chat Completions @@ -1456,6 +1457,7 @@ OpenAI Chat 与 Anthropic 省略转换后没有 parts 的空历史消息;纯 | `reasoning_effort` | thinking effort | | `reasoning.effort` | nested thinking effort | | `seed` | 64 位整数 | +| `functions`、`function_call` | 旧式函数声明与函数选择 | - `allowed_tools` 的 mode 与工具列表映射为调用模式与允许的函数名,请求只发送列表中的函数与托管工具对应的 Google 工具 - `web_search_options` 的搜索深度与近似位置写入搜索指令 @@ -1463,8 +1465,9 @@ OpenAI Chat 与 Anthropic 省略转换后没有 parts 的空历史消息;纯 - `frequency_penalty`、`presence_penalty`、`logprobs`、`top_logprobs` 与 `logit_bias` 接受任意值;非流式响应的 `choices[].logprobs` 为 `null` - `stop` 中的空字符串从停止条件中移除 - `reasoning.effort` 与 `reasoning_effort` 映射到同一配置,两者同时提供时以 `reasoning.effort` 为准 +- `functions` 按 function 工具发送,`function_call` 的 `none`、`auto` 与 `{name}` 按 `tool_choice` 处理,每轮最多一个调用;带 `functions` 的请求以 assistant `function_call:{name,arguments}` 返回调用,`finish_reason` 为 `function_call`,流式为 `delta.function_call`。历史中的 assistant `function_call` 与 `function` 角色消息按函数调用与函数结果发送 -message 字段为 `role`、`content`、可选 `name`、`tool_call_id`、`tool_calls`。assistant tool call: +message 字段为 `role`、`content`、可选 `name`、`tool_call_id`、`tool_calls`、`function_call`。assistant tool call: ```json { @@ -1645,7 +1648,7 @@ output item 联合类型: | type | 字段 | | --- | --- | -| `reasoning` | `id`、`status`、`summary:[{type:"summary_text",text}]`、可选 `encrypted_content` | +| `reasoning` | `id`、`status`、`summary:[{type:"summary_text",text}]`、可选 `encrypted_content`;摘要文本只在请求 `reasoning.summary`(或 `generate_summary`)为 `auto`、`concise`、`detailed` 时返回 | | `message` | `id`、`status`、`role:"assistant"`、`content:[{type:"output_text",text,annotations}]` | | `function_call` | `id`、`status`、`call_id`、`name`、`arguments` | | `custom_tool_call` | `id`、`status`、`call_id`、`name`、`input`,可选 `namespace` | @@ -1885,10 +1888,12 @@ Content 字段为 `role` 与 `parts`。Part oneof: | structured output | `responseMimeType`、`responseSchema`、`responseJsonSchema` | | modalities | `responseModalities` | | image | `imageConfig:{aspectRatio,imageSize}` | -| thinking | `thinkingConfig:{thinkingBudget,thinkingLevel}` | +| thinking | `thinkingConfig:{thinkingBudget,thinkingLevel,includeThoughts}` | | transcription | `transcriptionConfig:{languageCodes,customVocabulary,wordTimestamps,speakerLabels,smartTranscription}` | | speech | `speechConfig` | +`thinkingConfig.includeThoughts` 为 `true` 时输出 `thought:true` 的思考 Part,省略或为 `false` 时不输出思考正文;思考签名照常随 Part 返回,`thoughtsTokenCount` 照常统计。 + `candidateCount` 取省略、`0` 或 `1` 至 `8`;大于 1 时每个候选单独生成,候选 0 沿用请求 ID,候选 i 的请求 ID 为 `-`,带 `seed` 时使用 `seed+i`;任一候选失败时整个请求返回该错误,`:streamGenerateContent` 在首个候选就绪后开始输出,其余候选的启动错误以流内错误返回;用量为全部候选之和。`frequencyPenalty`、`presencePenalty`、`responseLogprobs` 与 `logprobs` 接受任意值,不发送上游,响应不含 logprobs。 `responseModalities` 接受 `TEXT`、`IMAGE`、`AUDIO` 与 `MODALITY_UNSPECIFIED`,`MODALITY_UNSPECIFIED` 不发送,只含该值时按省略处理;请求的模态先与模型可生成的模态取交集:TTS 与音乐模型为 `AUDIO`,图像模型为 `IMAGE`、`TEXT`,其他模型为 `TEXT`;没有交集时保持原样,此时 `AUDIO` 与其他模态同时出现返回参数错误。图像模型省略模态时发送 `[IMAGE,TEXT]`,仅请求 `IMAGE` 时发送 `[IMAGE]`;`imageConfig` 保留显式宽高比与尺寸,支持输出分辨率的模型省略图片配置时使用 `1K`。 @@ -1959,7 +1964,7 @@ tool group 字段: } ``` -`finishMessage` 为上游给出的结束说明,例如上游过滤生成结果时的原因;没有上游说明时,`provider_*` 与缺少思维签名的结束原因写入固定说明。候选没有输出 Part 时省略 `content`。`candidateCount` 大于 1 时,`candidates` 按 `index` 0 到 n-1 排列,各自携带 `finishReason`。`:streamGenerateContent` 按到达顺序输出各候选的增量块,每块只含一个带 `index` 的候选;最后一块列出全部候选的 `finishReason` 与合计的 `usageMetadata`。 +`finishMessage` 为上游给出的结束说明,例如上游过滤生成结果时的原因;没有上游说明时,`provider_*` 与缺少思维签名的结束原因写入固定说明。候选没有输出 Part 时省略 `content`。`candidateCount` 大于 1 时,`candidates` 按 `index` 0 到 n-1 排列,各自携带 `finishReason`。`:streamGenerateContent` 按到达顺序输出各候选的增量块,每块只含一个带 `index` 的候选;最后一块列出全部候选,各自带 `content`、`finishReason` 与该候选的 `groundingMetadata`、`citationMetadata`,并带合计的 `usageMetadata`。非流式候选同时返回 `groundingMetadata` 与上游引用的 `citationMetadata`。 输出 Part 使用与输入相同的 `text`、`thought`、`thoughtSignature`、`inlineData`、`fileData`、`functionCall`、`executableCode`、`codeExecutionResult`。转录文本可以携带: @@ -1980,7 +1985,7 @@ tool group 字段: 非流式结果合并相邻、同类且无签名边界的正文或思考片段。工具、媒体与带转录元数据的 Part 保持独立;独立签名附着于前一未签名 Part,缺少可附着内容时用 `{"text":"","thoughtSignature":"..."}` 承载。 -`:streamGenerateContent` 使用 SSE。每个语义事件发送一个部分 `GenerateContentResponse`,包含 `responseId`、`modelVersion` 与一个 candidate Part、grounding 或 citation;最后一帧包含 candidate `finishReason`、可选 `finishMessage` 和 `usageMetadata`。响应头后的错误帧为 `data: {"error":{"code","message","status"}}`。 +`:streamGenerateContent` 使用 SSE。每个内容事件发送一个部分 `GenerateContentResponse`,包含 `responseId`、`modelVersion` 与一个带 `content` 的 candidate Part;最后一帧的每个候选包含 `content`、`finishReason`、可选 `finishMessage`、`groundingMetadata` 与 `citationMetadata`,并带 `usageMetadata`。响应头后的错误帧为 `data: {"error":{"code","message","status"}}`。 ### Files、Transcribe 与媒体 diff --git a/internal/api/anthropic.go b/internal/api/anthropic.go index 73c49d28..a04a3c17 100644 --- a/internal/api/anthropic.go +++ b/internal/api/anthropic.go @@ -221,9 +221,11 @@ func (request anthropicRequest) toGenerateRequest(id string) (aistudio.GenerateR if request.Thinking != nil && request.Thinking.Type == "enabled" { config.ThinkingBudget = request.Thinking.BudgetTokens } + if request.Thinking == nil || request.Thinking.Type == "disabled" { + config.HideThinking = true + } if request.Thinking != nil && request.Thinking.Type == "disabled" { config.ReasoningEffort = "none" - config.HideThinking = true } if request.OutputConfig != nil { config.ReasoningEffort = request.OutputConfig.Effort @@ -738,13 +740,14 @@ func anthropicStop(reason string, hasTools bool, stopSequence string) (string, * return "stop_sequence", nil case "pause_turn": return "pause_turn", nil - case "", "stop": + default: + if contentFilterFinishReason(normalized) { + return "refusal", nil + } if hasTools { return "tool_use", nil } return "end_turn", nil - default: - return "refusal", nil } } diff --git a/internal/api/gemini.go b/internal/api/gemini.go index b07a2b38..7e78d83f 100644 --- a/internal/api/gemini.go +++ b/internal/api/gemini.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -154,8 +155,9 @@ type geminiGenerationConfig struct { Seed *int64 `json:"seed"` MediaResolution string `json:"mediaResolution"` ThinkingConfig *struct { - ThinkingBudget *int64 `json:"thinkingBudget"` - ThinkingLevel string `json:"thinkingLevel"` + ThinkingBudget *int64 `json:"thinkingBudget"` + ThinkingLevel string `json:"thinkingLevel"` + IncludeThoughts bool `json:"includeThoughts"` } `json:"thinkingConfig"` } @@ -485,9 +487,11 @@ func (request geminiRequest) toGenerateRequest(id string, model string) (aistudi if len(request.GenerationConfig.ResponseJSONSchema) > 0 { config.ResponseSchema = request.GenerationConfig.ResponseJSONSchema } + config.HideThinking = true if request.GenerationConfig.ThinkingConfig != nil { config.ThinkingBudget = request.GenerationConfig.ThinkingConfig.ThinkingBudget config.ReasoningEffort = request.GenerationConfig.ThinkingConfig.ThinkingLevel + config.HideThinking = !request.GenerationConfig.ThinkingConfig.IncludeThoughts } safety := make([]aistudio.SafetySetting, 0, len(request.SafetySettings)) for _, setting := range request.SafetySettings { @@ -892,9 +896,7 @@ func (s *server) handleGeminiGenerate(w http.ResponseWriter, r *http.Request, re defer candidates.cancel() if err := candidates.err; err != nil { candidates.settle(r.Context()) - if shouldWriteRequestError(r, err) { - writeGeminiError(w, statusFromError(err), geminiErrorStatus(err), err.Error()) - } + writeGeminiFailure(w, r, request, err, stream) return } if stream { @@ -906,15 +908,48 @@ func (s *server) handleGeminiGenerate(w http.ResponseWriter, r *http.Request, re }) if err != nil { candidates.settle(r.Context()) - if shouldWriteRequestError(r, err) { - writeGeminiError(w, statusFromError(err), geminiErrorStatus(err), err.Error()) - } + writeGeminiFailure(w, r, request, err, false) return } candidates.record(r.Context(), results) writeJSON(w, http.StatusOK, buildGeminiResponse(request, results)) } +// writeGeminiFailure 写出响应头之前的生成失败,上游拒绝输入时按 Gemini API 返回 200 与 promptFeedback +func writeGeminiFailure(w http.ResponseWriter, r *http.Request, request aistudio.GenerateRequest, err error, stream bool) { + if !shouldWriteRequestError(r, err) { + return + } + response, ok := geminiPromptFeedback(request, err) + switch { + case !ok: + writeGeminiError(w, statusFromError(err), geminiErrorStatus(err), err.Error()) + case stream: + if streamHeaders(w) == nil { + _ = writeSSE(w, "", response) + } + default: + writeJSON(w, http.StatusOK, response) + } +} + +// geminiPromptFeedback 把上游输入拒绝投影为不含候选的 Gemini 响应 +func geminiPromptFeedback(request aistudio.GenerateRequest, err error) (map[string]any, bool) { + var feedback *aistudio.PromptFeedbackError + if !errors.As(err, &feedback) { + return nil, false + } + reason := strings.ToUpper(strings.TrimSpace(feedback.Reason)) + if reason == "" || reason == "UNSPECIFIED" { + reason = "BLOCK_REASON_UNSPECIFIED" + } + block := map[string]any{"blockReason": reason} + if feedback.Message != "" { + block["blockReasonMessage"] = feedback.Message + } + return map[string]any{"promptFeedback": block, "modelVersion": request.Model, "responseId": request.ID}, true +} + // buildGeminiResponse 按候选序号输出各候选内容与终止原因,用量为全部候选之和 func buildGeminiResponse(request aistudio.GenerateRequest, results []generationResult) map[string]any { candidates := make([]any, 0, len(results)) @@ -923,12 +958,7 @@ func buildGeminiResponse(request aistudio.GenerateRequest, results []generationR if parts := geminiOutputParts(result); len(parts) > 0 { candidate["content"] = map[string]any{"role": "model", "parts": parts} } - setGeminiFinish(candidate, result.finishReason, result.finishMessage) - if result.grounding != nil { - candidate["groundingMetadata"] = geminiGroundingMetadata(*result.grounding) - } else if len(result.citations) > 0 { - candidate["citationMetadata"] = geminiCitationMetadata(result.citations) - } + setGeminiCandidateEnd(candidate, result) candidates = append(candidates, candidate) } response := map[string]any{ @@ -1214,6 +1244,23 @@ func geminiFinishReason(reason string) string { } } +// setGeminiCandidateEnd 写入候选的终止原因、搜索来源与上游引用 +func setGeminiCandidateEnd(candidate map[string]any, result generationResult) { + setGeminiFinish(candidate, result.finishReason, result.finishMessage) + if result.grounding != nil { + candidate["groundingMetadata"] = geminiGroundingMetadata(*result.grounding) + } + citations := make([]aistudio.Citation, 0) + for _, event := range result.events { + if event.Kind == aistudio.EventCitation && event.Citation != nil { + citations = append(citations, *event.Citation) + } + } + if len(citations) > 0 { + candidate["citationMetadata"] = geminiCitationMetadata(citations) + } +} + func setGeminiFinish(candidate map[string]any, reason, message string) { candidate["finishReason"] = geminiFinishReason(reason) normalized := strings.ToLower(strings.TrimSpace(reason)) @@ -1236,7 +1283,7 @@ func geminiUsage(usage *aistudio.Usage) map[string]any { } } -// streamGemini 按到达顺序输出各候选的增量块,最后一块携带全部候选的终止原因与用量之和 +// streamGemini 按到达顺序输出各候选的增量块,最后一块携带全部候选的终止原因、来源与用量之和 func (s *server) streamGemini(w http.ResponseWriter, r *http.Request, request aistudio.GenerateRequest, candidates *chatChoices) { if err := streamHeaders(w); err != nil { return @@ -1263,11 +1310,16 @@ func (s *server) streamGemini(w http.ResponseWriter, r *http.Request, request ai }) if err != nil { candidates.settle(r.Context()) - if shouldWriteRequestError(r, err) { - _ = write(map[string]any{"error": map[string]any{ - "code": statusFromError(err), "message": err.Error(), "status": geminiErrorStatus(err), - }}) + if !shouldWriteRequestError(r, err) { + return } + if response, ok := geminiPromptFeedback(request, err); ok { + _ = write(response) + return + } + _ = write(map[string]any{"error": map[string]any{ + "code": statusFromError(err), "message": err.Error(), "status": geminiErrorStatus(err), + }}) return } candidates.record(r.Context(), results) @@ -1277,16 +1329,8 @@ func (s *server) streamGemini(w http.ResponseWriter, r *http.Request, request ai } finished := make([]any, 0, len(results)) for index, result := range results { - candidate := map[string]any{ - "index": index, - "content": map[string]any{"role": "model", "parts": []any{}}, - } - setGeminiFinish(candidate, result.finishReason, result.finishMessage) - if result.grounding != nil { - candidate["groundingMetadata"] = geminiGroundingMetadata(*result.grounding) - } else if len(result.citations) > 0 { - candidate["citationMetadata"] = geminiCitationMetadata(result.citations) - } + candidate := map[string]any{"index": index, "content": map[string]any{"role": "model", "parts": []any{}}} + setGeminiCandidateEnd(candidate, result) finished = append(finished, candidate) } final := map[string]any{ @@ -1326,24 +1370,6 @@ func geminiStreamCandidate(event aistudio.Event, index int) map[string]any { part = geminiSignedPart(map[string]any{ "codeExecutionResult": geminiCodeExecutionResult(*event.CodeExecutionResult), }, event.ThoughtSignature) - case aistudio.EventGrounding: - if event.Grounding == nil { - return nil - } - return map[string]any{ - "index": index, - "content": map[string]any{"role": "model", "parts": []any{}}, - "groundingMetadata": geminiGroundingMetadata(*event.Grounding), - } - case aistudio.EventCitation: - if event.Citation == nil { - return nil - } - return map[string]any{ - "index": index, - "content": map[string]any{"role": "model", "parts": []any{}}, - "citationMetadata": geminiCitationMetadata([]aistudio.Citation{*event.Citation}), - } case aistudio.EventMedia: if event.Media == nil { return nil diff --git a/internal/api/gemini_output_test.go b/internal/api/gemini_output_test.go index 32d5ad40..581a50ef 100644 --- a/internal/api/gemini_output_test.go +++ b/internal/api/gemini_output_test.go @@ -217,29 +217,3 @@ func TestGeminiOutputParts_SignedAudio(t *testing.T) { t.Fatalf("signed audio=%+v", parts) } } - -// TestGeminiGroundingMetadataInResponse 验证候选包含 groundingMetadata -func TestGeminiGroundingMetadataInResponse(t *testing.T) { - grounding := aistudio.GroundingMetadata{ - WebSearchQueries: []string{"test query"}, - Chunks: []aistudio.GroundingChunk{ - {Source: "web", URI: "https://example.com", Title: "Example"}, - }, - } - result := generationResult{ - grounding: &grounding, - events: []aistudio.Event{ - {Kind: aistudio.EventText, Text: "Answer"}, - }, - } - resp := buildGeminiResponse(aistudio.GenerateRequest{ID: "req-1", Model: "gemini-3.8-flash"}, []generationResult{result}) - candidates, ok := resp["candidates"].([]any) - if !ok || len(candidates) != 1 { - t.Fatalf("expected 1 candidate, got %#v", resp["candidates"]) - } - cand := candidates[0].(map[string]any) - if cand["groundingMetadata"] == nil { - t.Fatalf("expected groundingMetadata in candidate, got %#v", cand) - } -} - diff --git a/internal/api/openai.go b/internal/api/openai.go index cc0f1f44..043be1dd 100644 --- a/internal/api/openai.go +++ b/internal/api/openai.go @@ -36,6 +36,8 @@ type chatRequest struct { Reasoning json.RawMessage `json:"reasoning"` Seed *int64 `json:"seed"` WebSearchOptions json.RawMessage `json:"web_search_options"` + Functions []json.RawMessage `json:"functions"` + FunctionCall json.RawMessage `json:"function_call"` } type chatStreamOptions struct { @@ -43,11 +45,15 @@ type chatStreamOptions struct { } type chatMessage struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - Name string `json:"name"` - ToolCallID string `json:"tool_call_id"` - ToolCalls []openAIToolCall `json:"tool_calls"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Name string `json:"name"` + ToolCallID string `json:"tool_call_id"` + ToolCalls []openAIToolCall `json:"tool_calls"` + FunctionCall *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function_call"` } type openAIToolCall struct { @@ -81,6 +87,7 @@ type openAITool struct { Description string `json:"description"` Format json.RawMessage `json:"format"` } `json:"custom"` + legacy bool } var assistantImagePattern = regexp.MustCompile(`!\[[^\]]*\]\((data:image/[A-Za-z0-9.+-]+;base64,[A-Za-z0-9+/_=\r\n-]+)\)`) @@ -195,6 +202,10 @@ func (s *server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, http.StatusBadRequest, "invalid_request", "model is required") return } + if err := request.mergeLegacyFunctions(); err != nil { + writeOpenAIError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } requestID := newID("chatcmpl") generateRequest, err := request.toGenerateRequest(requestID) if err != nil { @@ -244,6 +255,31 @@ func (s *server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, buildChatCompletion(requestID, created, request.Model, results, request.Tools)) } +// mergeLegacyFunctions 把旧式 functions 与 function_call 并入 tools 与 tool_choice,旧式函数每轮最多一个调用 +func (request *chatRequest) mergeLegacyFunctions() error { + for _, raw := range request.Functions { + tool := openAITool{Type: "function", legacy: true} + if err := json.Unmarshal(raw, &tool.Function); err != nil { + return fmt.Errorf("functions: %w", err) + } + request.Tools = append(request.Tools, tool) + } + if len(request.ToolChoice) == 0 && len(request.FunctionCall) > 0 { + request.ToolChoice = request.FunctionCall + var call struct { + Name string `json:"name"` + } + if json.Unmarshal(request.FunctionCall, &call) == nil && call.Name != "" { + request.ToolChoice, _ = json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": call.Name}}) + } + } + if len(request.Functions) > 0 { + single := false + request.ParallelToolCalls = &single + } + return nil +} + func (request chatRequest) toGenerateRequest(id string) (aistudio.GenerateRequest, error) { var system []string contents := make([]aistudio.Content, 0, len(request.Messages)) @@ -340,6 +376,11 @@ func chatMessageContent(message chatMessage) (aistudio.Content, error) { } parts = append(parts, aistudio.Part{FunctionCall: &functionCall}) } + if message.FunctionCall != nil { + parts = append(parts, aistudio.Part{FunctionCall: &aistudio.FunctionCall{ + Name: message.FunctionCall.Name, Arguments: functionCallArguments(message.FunctionCall.Arguments), + }}) + } return aistudio.Content{Role: role, Parts: parts}, nil } @@ -882,7 +923,9 @@ func chatCompletionChoice(index int, result generationResult, tools []openAITool if result.reasoning.Len() > 0 { message["reasoning_content"] = result.reasoning.String() } - if len(result.toolCalls) > 0 { + if len(result.toolCalls) > 0 && legacyFunctionTools(tools) { + message["function_call"] = legacyFunctionCall(result.toolCalls[0]) + } else if len(result.toolCalls) > 0 { message["tool_calls"] = openAIToolCallOutput(result.toolCalls, tools) } if len(result.citations) > 0 { @@ -892,7 +935,7 @@ func chatCompletionChoice(index int, result generationResult, tools []openAITool "index": index, "message": message, "logprobs": nil, - "finish_reason": openAIFinishReason(result.finishReason, len(result.toolCalls) > 0), + "finish_reason": chatFinishReason(result, tools), } if providerReason := providerFinishReason(result.finishReason); providerReason != "" { choice["provider_finish_reason"] = providerReason @@ -929,7 +972,7 @@ func (s *server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re if len(result.citations) > 0 { _ = write(index, map[string]any{"annotations": openAICitations(result.citations)}, nil) } - finish := openAIFinishReason(result.finishReason, len(result.toolCalls) > 0) + finish := chatFinishReason(result, request.Tools) finalDelta := map[string]any{} if providerReason := providerFinishReason(result.finishReason); providerReason != "" { finalDelta["provider_finish_reason"] = providerReason @@ -992,6 +1035,9 @@ func (choice *chatStreamChoice) emit(event aistudio.Event) error { } call := event.ToolCall choice.server.thoughtSignatures.Remember([]aistudio.FunctionCall{*call}) + if legacyFunctionTools(choice.tools) { + return choice.write(choice.index, map[string]any{"function_call": legacyFunctionCall(*call)}, nil) + } toolCall := openAIToolCallItem(*call, choice.tools) toolCall["index"] = choice.toolIndex choice.toolIndex++ @@ -1052,16 +1098,47 @@ func openAIFinishReason(reason string, hasTools bool) string { return "length" case "stop_sequence": return "stop" - case "", "stop": + default: + if contentFilterFinishReason(reason) { + return "content_filter" + } if hasTools { return "tool_calls" } return "stop" - default: - return "content_filter" } } +// chatFinishReason 返回 choice 的 finish_reason,旧式函数调用为 function_call +func chatFinishReason(result generationResult, tools []openAITool) string { + finish := openAIFinishReason(result.finishReason, len(result.toolCalls) > 0) + if finish == "tool_calls" && legacyFunctionTools(tools) { + return "function_call" + } + return finish +} + +// legacyFunctionTools 判断工具是否来自旧式 functions +func legacyFunctionTools(tools []openAITool) bool { + return slices.ContainsFunc(tools, func(tool openAITool) bool { return tool.legacy }) +} + +// legacyFunctionCall 把函数调用投影为旧式 function_call +func legacyFunctionCall(call aistudio.FunctionCall) map[string]any { + return map[string]any{"name": call.Name, "arguments": string(call.Arguments)} +} + +// contentFilterFinishReason 判断上游结束原因是否表示内容策略拦截,未知编号按官网默认的 Content blocked 处理 +func contentFilterFinishReason(reason string) bool { + normalized := strings.ToLower(strings.TrimSpace(reason)) + switch normalized { + case "safety", "content_filter", "blocked", "recitation", "blocklist", "prohibited_content", "spii", + "image_safety", "image_prohibited_content", "image_recitation": + return true + } + return strings.HasPrefix(normalized, "provider_") +} + func openAIToolCallOutput(calls []aistudio.FunctionCall, tools []openAITool) []map[string]any { output := make([]map[string]any, 0, len(calls)) for _, call := range calls { diff --git a/internal/api/responses.go b/internal/api/responses.go index a3dd69b9..a003106a 100644 --- a/internal/api/responses.go +++ b/internal/api/responses.go @@ -1,6 +1,7 @@ package api import ( + "cmp" "context" "encoding/base64" "encoding/json" @@ -549,14 +550,19 @@ func (request responsesRequest) toGenerateRequest(id string) (aistudio.GenerateR TopP: request.TopP, MaxOutputTokens: request.MaxOutputTokens, } + config.HideThinking = true if len(request.Reasoning) > 0 && string(request.Reasoning) != "null" { var reasoning struct { - Effort string `json:"effort"` + Effort string `json:"effort"` + Summary string `json:"summary"` + GenerateSummary string `json:"generate_summary"` } if err := json.Unmarshal(request.Reasoning, &reasoning); err != nil { return aistudio.GenerateRequest{}, nil, fmt.Errorf("invalid reasoning: %w", err) } config.ReasoningEffort = reasoning.Effort + summary := cmp.Or(reasoning.Summary, reasoning.GenerateSummary) + config.HideThinking = summary == "" || summary == "none" } if len(request.Text) > 0 && string(request.Text) != "null" { var text struct {