From bd676668ce1d10041cb7d59ff7019e38d48e297b Mon Sep 17 00:00:00 2001 From: deref3046 Date: Wed, 30 Sep 2026 11:10:59 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(api):=20=E5=90=88=E5=B9=B6=E9=9D=9E?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=20Gemini=20=E5=93=8D=E5=BA=94=E7=A2=8E?= =?UTF-8?q?=E7=89=87=E5=B9=B6=E8=A7=84=E8=8C=83=E6=8C=82=E8=BD=BD=E6=80=9D?= =?UTF-8?q?=E8=80=83=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在非流式响应中合并相邻的文本片段与思考片段,对齐官方原生接口表现,避免在酒馆等前端中出现异常换行 - 规范化挂载 thoughtSignature 至对应的 Content Part,消除多余的独立空签名 Part - 补充 geminiOutputParts 单元测试,覆盖相邻合并、思考签名附着及回退场景 --- internal/api/gemini.go | 66 +++++++++++--- internal/api/gemini_output_test.go | 138 +++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 internal/api/gemini_output_test.go diff --git a/internal/api/gemini.go b/internal/api/gemini.go index d8ef7c8..ba57573 100644 --- a/internal/api/gemini.go +++ b/internal/api/gemini.go @@ -828,46 +828,90 @@ func buildGeminiResponse(request aistudio.GenerateRequest, result generationResu func geminiOutputParts(result generationResult) []map[string]any { parts := make([]map[string]any, 0) + var pendingSignature string + + attachSignature := func(part map[string]any, sig string) map[string]any { + if sig != "" { + part["thoughtSignature"] = sig + } else if pendingSignature != "" { + part["thoughtSignature"] = pendingSignature + pendingSignature = "" + } + return part + } + for _, event := range result.events { switch event.Kind { case aistudio.EventText: - parts = append(parts, geminiSignedPart(geminiTextPart(event), event.ThoughtSignature)) + if len(parts) > 0 { + last := parts[len(parts)-1] + if lastText, ok := last["text"].(string); ok && last["thought"] != true && last["transcriptionMetadata"] == nil && event.Transcript == nil { + last["text"] = lastText + event.Text + if event.ThoughtSignature != "" { + last["thoughtSignature"] = event.ThoughtSignature + } + continue + } + } + parts = append(parts, attachSignature(geminiTextPart(event), event.ThoughtSignature)) case aistudio.EventReasoning: - parts = append(parts, geminiSignedPart(map[string]any{"text": event.Text, "thought": true}, event.ThoughtSignature)) + if len(parts) > 0 { + last := parts[len(parts)-1] + if lastText, ok := last["text"].(string); ok && last["thought"] == true { + last["text"] = lastText + event.Text + if event.ThoughtSignature != "" { + last["thoughtSignature"] = event.ThoughtSignature + } + continue + } + } + parts = append(parts, attachSignature(map[string]any{"text": event.Text, "thought": true}, event.ThoughtSignature)) case aistudio.EventToolCall: if event.ToolCall != nil { - parts = append(parts, geminiSignedPart(geminiFunctionCallPart(*event.ToolCall), event.ThoughtSignature)) + parts = append(parts, attachSignature(geminiFunctionCallPart(*event.ToolCall), event.ThoughtSignature)) } case aistudio.EventExecutableCode: if event.ExecutableCode != nil { - parts = append(parts, geminiSignedPart(map[string]any{"executableCode": map[string]any{ + parts = append(parts, attachSignature(map[string]any{"executableCode": map[string]any{ "language": event.ExecutableCode.Language, "code": event.ExecutableCode.Code, }}, event.ThoughtSignature)) } case aistudio.EventCodeExecutionResult: if event.CodeExecutionResult != nil { - parts = append(parts, geminiSignedPart(map[string]any{ + parts = append(parts, attachSignature(map[string]any{ "codeExecutionResult": geminiCodeExecutionResult(*event.CodeExecutionResult), }, event.ThoughtSignature)) } case aistudio.EventMedia: if event.Media != nil { + var part map[string]any if len(event.Media.Data) > 0 { - parts = append(parts, geminiSignedPart(map[string]any{"inlineData": map[string]any{ + part = map[string]any{"inlineData": map[string]any{ "mimeType": event.Media.MIME, "data": base64.StdEncoding.EncodeToString(event.Media.Data), - }}, event.ThoughtSignature)) + }} } else if event.Media.URL != "" { - parts = append(parts, geminiSignedPart(map[string]any{"fileData": map[string]any{ + part = map[string]any{"fileData": map[string]any{ "mimeType": event.Media.MIME, "fileUri": event.Media.URL, "displayName": event.Media.Name, - }}, event.ThoughtSignature)) + }} + } + if part != nil { + parts = append(parts, attachSignature(part, event.ThoughtSignature)) } } case aistudio.EventThoughtSignature: - if event.ThoughtSignature != "" { - parts = append(parts, map[string]any{"thoughtSignature": event.ThoughtSignature}) + if event.ThoughtSignature == "" { + continue + } + if len(parts) > 0 { + parts[len(parts)-1]["thoughtSignature"] = event.ThoughtSignature + } else { + pendingSignature = event.ThoughtSignature } } } + if len(parts) == 0 && pendingSignature != "" { + parts = append(parts, map[string]any{"thoughtSignature": pendingSignature}) + } return parts } diff --git a/internal/api/gemini_output_test.go b/internal/api/gemini_output_test.go new file mode 100644 index 0000000..28b41ae --- /dev/null +++ b/internal/api/gemini_output_test.go @@ -0,0 +1,138 @@ +package api + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/Mag1cFall/AIStudio2API/internal/aistudio" +) + +func TestGeminiOutputParts_MergeTextAndReasoning(t *testing.T) { + result := generationResult{ + events: []aistudio.Event{ + {Kind: aistudio.EventReasoning, Text: "I think "}, + {Kind: aistudio.EventReasoning, Text: "therefore "}, + {Kind: aistudio.EventReasoning, Text: "I am."}, + {Kind: aistudio.EventThoughtSignature, ThoughtSignature: "sig_abc123"}, + {Kind: aistudio.EventText, Text: "Hello "}, + {Kind: aistudio.EventText, Text: "world!"}, + }, + } + + parts := geminiOutputParts(result) + if len(parts) != 2 { + t.Fatalf("expected 2 parts, got %d: %+v", len(parts), parts) + } + + // First part: merged thought + expectedThought := map[string]any{ + "thought": true, + "text": "I think therefore I am.", + "thoughtSignature": "sig_abc123", + } + if !reflect.DeepEqual(parts[0], expectedThought) { + t.Errorf("parts[0] = %+v, want %+v", parts[0], expectedThought) + } + + // Second part: merged text + expectedText := map[string]any{ + "text": "Hello world!", + } + if !reflect.DeepEqual(parts[1], expectedText) { + t.Errorf("parts[1] = %+v, want %+v", parts[1], expectedText) + } +} + +func TestGeminiOutputParts_TextOnly(t *testing.T) { + result := generationResult{ + events: []aistudio.Event{ + {Kind: aistudio.EventText, Text: "Chunk 1 "}, + {Kind: aistudio.EventText, Text: "Chunk 2 "}, + {Kind: aistudio.EventText, Text: "Chunk 3"}, + }, + } + + parts := geminiOutputParts(result) + if len(parts) != 1 { + t.Fatalf("expected 1 part, got %d: %+v", len(parts), parts) + } + + expectedText := map[string]any{ + "text": "Chunk 1 Chunk 2 Chunk 3", + } + if !reflect.DeepEqual(parts[0], expectedText) { + t.Errorf("parts[0] = %+v, want %+v", parts[0], expectedText) + } +} + +func TestGeminiOutputParts_LeadingThoughtSignature(t *testing.T) { + result := generationResult{ + events: []aistudio.Event{ + {Kind: aistudio.EventThoughtSignature, ThoughtSignature: "sig_pre"}, + {Kind: aistudio.EventReasoning, Text: "Thinking step."}, + {Kind: aistudio.EventText, Text: "Final text."}, + }, + } + + parts := geminiOutputParts(result) + if len(parts) != 2 { + t.Fatalf("expected 2 parts, got %d: %+v", len(parts), parts) + } + + if parts[0]["thoughtSignature"] != "sig_pre" { + t.Errorf("expected thoughtSignature 'sig_pre' on parts[0], got %v", parts[0]["thoughtSignature"]) + } + if parts[0]["text"] != "Thinking step." { + t.Errorf("expected text 'Thinking step.', got %v", parts[0]["text"]) + } + if parts[1]["text"] != "Final text." { + t.Errorf("expected text 'Final text.', got %v", parts[1]["text"]) + } +} + +func TestGeminiOutputParts_StandaloneThoughtSignatureFallback(t *testing.T) { + result := generationResult{ + events: []aistudio.Event{ + {Kind: aistudio.EventThoughtSignature, ThoughtSignature: "sig_only"}, + }, + } + + parts := geminiOutputParts(result) + if len(parts) != 1 { + t.Fatalf("expected 1 part, got %d: %+v", len(parts), parts) + } + if parts[0]["thoughtSignature"] != "sig_only" { + t.Errorf("expected thoughtSignature 'sig_only', got %v", parts[0]["thoughtSignature"]) + } +} + +func TestGeminiOutputParts_WithToolCall(t *testing.T) { + call := aistudio.FunctionCall{ + ID: "call_1", + Name: "get_weather", + Arguments: json.RawMessage(`{"location":"Tokyo"}`), + } + result := generationResult{ + events: []aistudio.Event{ + {Kind: aistudio.EventReasoning, Text: "Need weather for Tokyo."}, + {Kind: aistudio.EventThoughtSignature, ThoughtSignature: "sig_call"}, + {Kind: aistudio.EventToolCall, ToolCall: &call}, + }, + } + + parts := geminiOutputParts(result) + if len(parts) != 2 { + t.Fatalf("expected 2 parts, got %d: %+v", len(parts), parts) + } + + if parts[0]["thought"] != true || parts[0]["text"] != "Need weather for Tokyo." { + t.Errorf("unexpected parts[0]: %+v", parts[0]) + } + if parts[0]["thoughtSignature"] != "sig_call" { + t.Errorf("expected sig_call on parts[0], got %v", parts[0]["thoughtSignature"]) + } + if parts[1]["functionCall"] == nil { + t.Errorf("expected functionCall on parts[1], got %+v", parts[1]) + } +} From 9c821b078cacb17a40edf78ff19c8399cb8b2715 Mon Sep 17 00:00:00 2001 From: deref3046 Date: Wed, 30 Sep 2026 11:11:54 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(aistudio):=20=E6=94=AF=E6=8C=81=20Buil?= =?UTF-8?q?d=20=E5=8E=9F=E7=94=9F=E5=8D=95=E6=AC=A1=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E5=B9=B6=E9=BB=98=E8=AE=A4=E9=9D=9E=E6=B5=81=E5=BC=8F=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E8=B5=B0=20Build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 解锁 Build 通道对免费模型的 Unary 支持,直接调用 ProxyUnaryCall 原生单次接口 - 非流式请求(!stream)统一标记 Unary 并默认路由至 Build 通道 - 适配 Gemini、OpenAI、Anthropic、Responses 全协议端点 --- internal/aistudio/accounts.go | 1 + internal/aistudio/build.go | 2 +- internal/aistudio/channel.go | 3 +++ internal/aistudio/service.go | 5 +++++ internal/aistudio/types.go | 4 ++++ internal/api/anthropic.go | 1 + internal/api/gemini.go | 1 + internal/api/openai.go | 1 + internal/api/responses.go | 1 + 9 files changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index 76e5e69..f17f845 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -214,6 +214,7 @@ type AccountSelection struct { ResourceID string AllowedAccountIDs []string PlaygroundOnly bool + Channel Channel } const preferredBootstrapModelID = "gemini-flash-latest" diff --git a/internal/aistudio/build.go b/internal/aistudio/build.go index 912aae9..1ef9364 100644 --- a/internal/aistudio/build.go +++ b/internal/aistudio/build.go @@ -779,7 +779,7 @@ func buildTrailerError(raw json.RawMessage) error { // sendBuild 编码并发送 Build 代理请求,返回响应与含 finishReason 校验的解码 func (c *Client) sendBuild(ctx context.Context, request GenerateRequest, entry modelEntry) (*RPCResponse, func(io.Reader, func(Event) error) error, error) { - unary := buildUsesUnary(entry.model) + unary := request.Unary || buildUsesUnary(entry.model) path, body, err := EncodeBuildGenerateRequest(request, entry.defaults, request.ImageRoute, unary) if err != nil { return nil, nil, fmt.Errorf("%w: %v", ErrInvalidArgument, err) diff --git a/internal/aistudio/channel.go b/internal/aistudio/channel.go index 5231b9a..b321b81 100644 --- a/internal/aistudio/channel.go +++ b/internal/aistudio/channel.go @@ -96,6 +96,9 @@ func generationChannelSelection(selection AccountSelection) bool { // selectionChannelsLocked 返回选择可使用的通道顺序;非生成请求只使用 Playground RPC func (p *AccountPool) selectionChannelsLocked(selection AccountSelection) []Channel { + if selection.Channel != "" && p.channelEnabledLocked(selection.Channel) { + return []Channel{selection.Channel} + } if !generationChannelSelection(selection) { return []Channel{ChannelPlayground} } diff --git a/internal/aistudio/service.go b/internal/aistudio/service.go index 3ee1bf4..05322a1 100644 --- a/internal/aistudio/service.go +++ b/internal/aistudio/service.go @@ -550,11 +550,16 @@ func (s *PooledService) Generate(ctx context.Context, request GenerateRequest) ( if err != nil { return nil, err } + channel := request.Channel + if request.Unary && channel == "" { + channel = ChannelBuild + } selection := AccountSelection{ ModelID: modelID, Method: "generateContent", AccountID: strings.TrimSpace(request.AccountID), ResourceID: resourceID, + Channel: channel, } pinned := selection.AccountID != "" || selection.ResourceID != "" if _, ok := AccountLeaseFromContext(ctx); ok { diff --git a/internal/aistudio/types.go b/internal/aistudio/types.go index 0afd98e..be8045b 100644 --- a/internal/aistudio/types.go +++ b/internal/aistudio/types.go @@ -235,6 +235,10 @@ type GenerateRequest struct { AccountID string `json:"account_id,omitempty"` // ImageRoute 内部标记:图像生成模型(由模型目录能力推导) ImageRoute bool `json:"-"` + // Unary 标记单次非流式请求(在 Build 代理中使用 ProxyUnaryCall) + Unary bool `json:"-"` + // Channel 指定请求优先或强制使用的上游通道(为空时按 AccountPool 规则调度) + Channel Channel `json:"-"` } // TokenCountRequest 表示计数请求 diff --git a/internal/api/anthropic.go b/internal/api/anthropic.go index c7f83d9..14af2d4 100644 --- a/internal/api/anthropic.go +++ b/internal/api/anthropic.go @@ -100,6 +100,7 @@ func (s *server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request) writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error()) return } + generateRequest.Unary = !request.Stream if request.Stream { if err := streamHeaders(w); err != nil { return diff --git a/internal/api/gemini.go b/internal/api/gemini.go index ba57573..84b2d67 100644 --- a/internal/api/gemini.go +++ b/internal/api/gemini.go @@ -780,6 +780,7 @@ func (s *server) handleGeminiCountTokens(w http.ResponseWriter, r *http.Request, } func (s *server) handleGeminiGenerate(w http.ResponseWriter, r *http.Request, request aistudio.GenerateRequest, stream bool) { + request.Unary = !stream events, err := s.service.Generate(r.Context(), request) if err != nil { if shouldWriteRequestError(r, err) { diff --git a/internal/api/openai.go b/internal/api/openai.go index 10b632d..15cc6f5 100644 --- a/internal/api/openai.go +++ b/internal/api/openai.go @@ -135,6 +135,7 @@ func (s *server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } + generateRequest.Unary = !request.Stream s.thoughtSignatures.Restore(generateRequest.Contents) events, err := s.service.Generate(r.Context(), generateRequest) if err != nil { diff --git a/internal/api/responses.go b/internal/api/responses.go index 664a3c7..02a0770 100644 --- a/internal/api/responses.go +++ b/internal/api/responses.go @@ -164,6 +164,7 @@ func (s *server) handleResponses(w http.ResponseWriter, r *http.Request) { instructions = append(instructions, inlineInstructions...) generateRequest.System = strings.Join(instructions, "\n") } + generateRequest.Unary = !request.Stream events, err := s.service.Generate(r.Context(), generateRequest) if err != nil { if shouldWriteRequestError(r, err) {