diff --git a/docs/subsystems/gateway-routing.md b/docs/subsystems/gateway-routing.md index 36351101..1ea523ed 100644 --- a/docs/subsystems/gateway-routing.md +++ b/docs/subsystems/gateway-routing.md @@ -55,11 +55,12 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). - A translated Responses reply gives Codex reasoning items magpie made: an `rs_` id and a summary, nothing sealed. Codex hands them back on every turn. A request relayed as is to OpenAI's API or Azure OpenAI goes without them (`withoutBareReasoning`) unless `store` is true, because both look the id up among stored items and answer 400 "Item with id 'rs_…' not found" (#1008). Any other Responses upstream gets them, because a DeepSeek model wants its reasoning back (#388). When one answers 400 naming the input (`refusesInput`: `param` is `input`, or the error names an item by id), as a relay in front of OpenAI does with a message of its own, `passthrough` asks once more without them and, once that works, leaves them out for that provider and model from then on (`bareReasoningRefused`, #1044). Codex's compaction offers no tools, so it is relayed even when its turns were translated. When a provider answers its summary request 404, or 400 for an item it didn't find (`itemNotFound`, a relay in front of either), `codexCompact` asks once more with the conversation as text (`plainCompact`), then compacts locally (#866, #1008). - Group members are `provider/model[:effort]`. A member with a fixed effort is asked for that effort, bounded by the group's levels (#671). - An agent whose config can't carry an effort has one picked in magpie (`provider.AgentEffort`, settings' `agentEfforts`; Cursor Private Inference's row, #1003). It is asked for on that agent's requests to a model that reasons (`Provider.Thinks`), in place of the level the request sent, fitted to the model's levels and bounded by the group's. Order: a member's fixed effort, then a model's `:effort` suffix, then the turn's pick (a routing rule's), then the agent's pick, then the request's own. A model that doesn't reason is sent as asked; the default (none picked) leaves every request as it came. The pick beats the request's own because Cursor sends its own default as often as a real choice. Picking Default hands the choice back to Cursor. +- Gemini's OpenAI-compatible API (AI Studio's, or a proxy in front of Gemini on this computer or the LAN: `geminiCompat`) is asked for the model's thoughts in `extra_body.google.thinking_config`. A 400 naming it (`refusesThinkingConfig`) marks the provider unfit for the field (`thinking_config`), and it is asked again with `reasoning_effort` alone. Gemini 3 can't stop thinking, so reasoning turned off goes at `minimal`, or at the model's lowest level where its levels are known and have no `minimal` (`geminiOffLevel`). Google's 400 for `minimal` (`geminiMinimalRefused`; gemini-3.8-flash's "Thinking level is unsupported: THINKING_LEVEL_MINIMAL") names thinking too, but isn't the field turned away: the model is marked as refusing reasoning off (`offRefused`) and asked again at its lowest level, and so from then on, still asked for its thoughts. Vertex AI's OpenAI-compatible API marks each streamed chunk of thoughts in the delta's `extra_content.google.thought`, and such a chunk is read as thinking. ## Verification ```sh -go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted|TestCORS|TestForeignPagesRefused' +go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted|TestCORS|TestForeignPagesRefused|TestGeminiThinkingOffAtItsLowest|TestGeminiThoughtsMarkedInExtraContent' go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance|TestSkipsRedaction' ``` diff --git a/internal/gateway/aistudio_thinking_test.go b/internal/gateway/aistudio_thinking_test.go index e3886ba2..8626844c 100644 --- a/internal/gateway/aistudio_thinking_test.go +++ b/internal/gateway/aistudio_thinking_test.go @@ -5,6 +5,8 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "slices" "strconv" "strings" "testing" @@ -33,6 +35,7 @@ func TestAIStudioThinking(t *testing.T) { {"2.5 budget", aiStudioHost, "gemini-2.5-flash", Request{GeminiCompat: true, Thinking: true, Effort: "medium"}, want{"", `{"include_thoughts":true,"thinking_budget":8192}`}}, {"no effort", aiStudioHost, "gemini-3.8-flash", Request{GeminiCompat: true, Thinking: true}, want{"", `{"include_thoughts":true}`}}, {"off", aiStudioHost, "gemini-3.8-flash", Request{GeminiCompat: true, ThinkOff: true}, want{"minimal", ""}}, + {"off at its lowest", aiStudioHost, "gemini-3.8-flash", Request{GeminiCompat: true, ThinkOff: true, OffLevel: "low"}, want{"low", ""}}, {"effort unasked to show", aiStudioHost, "gemini-3.8-flash", Request{GeminiCompat: true, Effort: "medium"}, want{"medium", ""}}, {"other host", "api.deepseek.com", "deepseek-chat", Request{Thinking: true, Effort: "high"}, want{"high", ""}}, {"other host off", "api.deepseek.com", "deepseek-chat", Request{ThinkOff: true}, want{"", ""}}, @@ -246,3 +249,165 @@ func TestGeminiThoughtsThroughLocalProxy(t *testing.T) { }) } } + +// Gemini 3 can't stop thinking, and one without minimal turns it away: +// gemini-3.8-flash, asked with reasoning off as AI Studio's is, answered +// Vertex AI's OpenAI-compatible API with this 400. It names thinking, and +// was taken for thinking_config turned away, so the provider wasn't asked +// for Gemini's thoughts again, for any model, until magpie restarted. +// Reasoning off goes at the model's lowest level instead: at once where its +// levels are known, as models.dev's list for AI Studio gives them, and after +// the refusal where they aren't; the thoughts are still asked for. +func TestGeminiThinkingOffAtItsLowest(t *testing.T) { + const refusal = `[{ + "error": { + "code": 400, + "message": "Thinking level is unsupported: THINKING_LEVEL_MINIMAL", + "status": "INVALID_ARGUMENT" + } +} +] +` + thinkingConfig := func(b map[string]any) map[string]any { + extra, _ := b["extra_body"].(map[string]any) + google, _ := extra["google"].(map[string]any) + tc, _ := google["thinking_config"].(map[string]any) + return tc + } + for _, known := range []bool{false, true} { + name := "levels not known" + if known { + name = "levels known" + } + t.Run(name, func(t *testing.T) { + fresh(t) + var bodies []map[string]any + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var b map[string]any + json.NewDecoder(r.Body).Decode(&b) + bodies = append(bodies, b) + tc := thinkingConfig(b) + if b["reasoning_effort"] == "minimal" || tc["thinking_level"] == "minimal" { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + w.WriteHeader(400) + io.WriteString(w, refusal) + return + } + w.Header().Set("Content-Type", "text/event-stream") + content := "你好!" + if tc["include_thoughts"] == true { + content = "The user says hi.你好!" + } + for _, c := range []string{ + `{"id":"x","model":"gemini-3.8-flash","choices":[{"index":0,"delta":{"role":"assistant","content":` + strconv.Quote(content) + `}}]}`, + `{"id":"x","model":"gemini-3.8-flash","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3}}`, + } { + io.WriteString(w, "data: "+c+"\n\n") + } + io.WriteString(w, "data: [DONE]\n\n") + })) + t.Cleanup(up.Close) + p := provider.Provider{ID: "ai-studio", Name: "AI Studio", Key: "k", Models: []string{"gemini-3.8-flash"}, Chat: up.URL} + if err := provider.Save(p); err != nil { + t.Fatal(err) + } + if known { + if err := provider.SetModelEfforts("ai-studio/gemini-3.8-flash", []string{"low", "medium", "high"}); err != nil { + t.Fatal(err) + } + } + s := New() + ask := func(thinking string) string { + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(`{"model":"ai-studio/gemini-3.8-flash","max_tokens":32000,"stream":true, + `+thinking+`,"messages":[{"role":"user","content":"你好"}]}`)) + s.Handler().ServeHTTP(rec, req) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "你好!") { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + return rec.Body.String() + } + const off, think = `"thinking":{"type":"disabled"}`, `"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}` + ask(off) + var sent []any + for _, b := range bodies { + if b["extra_body"] != nil { + t.Fatalf("reasoning off asked for the thoughts: %v", b) + } + sent = append(sent, b["reasoning_effort"]) + } + if want := []any{"minimal", "low"}; known && !slices.Equal(sent, want[1:]) || !known && !slices.Equal(sent, want) { + t.Fatalf("reasoning off went at %v", sent) + } + n := len(bodies) + out := ask(think) + last := bodies[len(bodies)-1] + if tc := thinkingConfig(last); len(bodies) != n+1 || tc["include_thoughts"] != true || tc["thinking_level"] != "high" || last["reasoning_effort"] != nil { + t.Fatalf("the next turn, thinking, was sent %v (%d requests)", last, len(bodies)-n) + } + if !strings.Contains(out, `"thinking_delta"`) || !strings.Contains(out, "The user says hi.") { + t.Fatalf("no thoughts in the reply:\n%s", out) + } + ask(off) + if last := bodies[len(bodies)-1]; len(bodies) != n+2 || last["reasoning_effort"] != "low" { + t.Fatalf("reasoning off again went at %v (%d requests)", last["reasoning_effort"], len(bodies)-n-1) + } + }) + } +} + +// Asked for Gemini's thoughts, Vertex AI's OpenAI-compatible API gives each +// of them in its own chunk, marked in the delta's extra_content rather than +// with AI Studio's tags: these are its bytes, through a proxy on +// this machine. The thoughts are a thinking block, the rest the answer. +func TestGeminiThoughtsMarkedInExtraContent(t *testing.T) { + fresh(t) + sse, err := os.ReadFile("testdata/vertex_openai_thoughts.sse") + if err != nil { + t.Fatal(err) + } + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Write(sse) + })) + t.Cleanup(up.Close) + p := provider.Provider{ID: "vertex-proxy", Name: "Vertex proxy", Key: "k", Models: []string{"gemini-3.8-flash"}, Chat: up.URL} + if err := provider.Save(p); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(`{"model":"vertex-proxy/gemini-3.8-flash","max_tokens":32000,"stream":true, + "thinking":{"type":"adaptive"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"你好"}]}`)) + New().Handler().ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + var think, text string + var blocks []string + for _, line := range strings.Split(rec.Body.String(), "\n") { + var ev struct { + Type string `json:"type"` + ContentBlock struct { + Type string `json:"type"` + } `json:"content_block"` + Delta struct { + Thinking string `json:"thinking"` + Text string `json:"text"` + } `json:"delta"` + } + if json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev) != nil { + continue + } + if ev.Type == "content_block_start" { + blocks = append(blocks, ev.ContentBlock.Type) + } + think += ev.Delta.Thinking + text += ev.Delta.Text + } + if !strings.HasPrefix(think, "**Acknowledging Greeting**") || text != "你好!很高兴与你交流。请问有什么我可以帮你的吗?" { + t.Fatalf("thinking %q, text %q", think, text) + } + if len(blocks) != 2 || blocks[0] != "thinking" || blocks[1] != "text" { + t.Fatalf("blocks %v, want thinking then text", blocks) + } +} diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 76667887..0c08363f 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -8,6 +8,7 @@ import ( "mime" "net" "path" + "regexp" "sort" "strings" "time" @@ -411,8 +412,9 @@ func buildChat(r *Request, model, host string, rejectTemp bool) []byte { if tc := aiStudioThinking(r, model); tc != nil { out["extra_body"] = map[string]any{"google": map[string]any{"thinking_config": tc}} } else if r.ThinkOff { - // Gemini 3 can't stop thinking; it thinks least at minimal - out["reasoning_effort"] = "minimal" + // Gemini 3 can't stop thinking; it thinks least at minimal, + // or at its lowest level where it has no minimal + out["reasoning_effort"] = cmp.Or(r.OffLevel, "minimal") } else if r.Effort != "" { out["reasoning_effort"] = r.Effort } @@ -634,6 +636,15 @@ func thinkingEffort(body []byte) []byte { // provider that refused it. const thinkingConfigField = "thinking_config" +// geminiMinimalRefused is Gemini turning minimal away for a model that +// doesn't have it, which names thinking but isn't thinking_config turned +// away (refusesThinkingConfig): gemini-3.8-flash's 400 "Thinking level is +// unsupported: THINKING_LEVEL_MINIMAL", for reasoning_effort minimal and +// thinking_level minimal alike, at Vertex AI's OpenAI-compatible API; and +// gemini-3.1-pro-preview's "thinking_level MINIMAL is not supported by this +// model" at its generateContent. +var geminiMinimalRefused = regexp.MustCompile(`(?i)thinking[ _]level.*minimal`) + // refusesThinkingConfig recognizes an upstream turning a request away for // the thinking_config it was sent, by its error naming it. func refusesThinkingConfig(status int, body []byte) bool { @@ -814,6 +825,13 @@ func (d *chatDecoder) decode(data string, emit func(Event)) error { ReasoningContent string `json:"reasoning_content"` Reasoning string `json:"reasoning"` ToolCalls []cToolCall `json:"tool_calls"` + // Vertex AI's OpenAI-compatible API marks a chunk of + // Gemini's thoughts here, the thought its content + ExtraContent struct { + Google struct { + Thought bool `json:"thought"` + } `json:"google"` + } `json:"extra_content"` } `json:"delta"` FinishReason string `json:"finish_reason"` } `json:"choices"` @@ -859,6 +877,9 @@ func (d *chatDecoder) decode(data string, emit func(Event)) error { } else { json.Unmarshal(c.Delta.Content, &content) } + if c.Delta.ExtraContent.Google.Thought { + t, content = t+content, "" + } if t != "" { emit(Event{Kind: KThink, Text: t}) } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index cb3691a5..cea5ff9e 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -3302,6 +3302,12 @@ func (s *Server) forwardTranslated(ctx context.Context, p provider.Provider, to r := *req r.GeminiCompat, req = want, &r } + if req.GeminiCompat && req.ThinkOff { + if l := geminiOffLevel(p, model, s.fits(p.ID, offRefused(model), to)); l != req.OffLevel { + r := *req + r.OffLevel, req = l, &r + } + } body, err := build(to, req, model, p.Host(), p.RejectsTemperature(model)) if err != nil { return nil, to, err @@ -3347,6 +3353,13 @@ func (s *Server) forwardTranslated(ctx context.Context, p provider.Provider, to b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) res.Body.Close() res.Body = io.NopCloser(bytes.NewReader(b)) + if req.GeminiCompat && res.StatusCode == http.StatusBadRequest && geminiMinimalRefused.Match(b) && s.fits(p.ID, offRefused(model), to) { + // minimal turned away by a Gemini that hasn't it, reasoning + // off or asked for at minimal: the fields were taken, and the + // model is asked again at its lowest, and so from then on + s.markUnfit(p.ID, offRefused(model), to) + continue + } if req.GeminiCompat && refusesThinkingConfig(res.StatusCode, b) { // Gemini's own fields turned away (a proxy that isn't in front // of Google after all, or Google changing them): asked as @@ -4235,6 +4248,18 @@ func onEffort(p provider.Provider, model string) string { return fitEffort("low", levels) } +// geminiOffLevel is the level Gemini's OpenAI-compatible API is asked to +// think at for model with reasoning turned off: minimal, its least, where +// its levels have it or aren't known; its lowest where they haven't, or +// once it turned minimal away (fits false). Gemini 3 turns away a level it +// doesn't have, gemini-3.8-flash minimal with a 400. +func geminiOffLevel(p provider.Provider, model string, fits bool) string { + if !fits { + return onEffort(p, model) + } + return fitEffort("minimal", p.Efforts(model)) +} + // offRefused is how unfit remembers a provider refusing reasoning turned // off for model. func offRefused(model string) string { return "reasoning off\x00" + model } diff --git a/internal/gateway/ir.go b/internal/gateway/ir.go index 4778e0ba..2563e4a9 100644 --- a/internal/gateway/ir.go +++ b/internal/gateway/ir.go @@ -137,6 +137,11 @@ type Request struct { // (AI Studio's, or a proxy in front of it on this machine or the LAN), // which gives the model's thoughts only when asked in thinking_config. GeminiCompat bool + // OffLevel is the level such an API is asked to think at when the + // client turned reasoning off: Gemini 3 can't stop thinking, and thinks + // least at minimal, or at its lowest level where it has no minimal + // (geminiOffLevel). "" is minimal. + OffLevel string // Resume is set on a request built to go on with a reply the client // already has part of (continuation.go): its last message is that // part, an assistant message the model goes on from, not a turn diff --git a/internal/gateway/testdata/vertex_openai_thoughts.sse b/internal/gateway/testdata/vertex_openai_thoughts.sse new file mode 100644 index 00000000..a5327daa --- /dev/null +++ b/internal/gateway/testdata/vertex_openai_thoughts.sse @@ -0,0 +1,10 @@ +data: {"choices":[{"delta":{"content":"**Acknowledging Greeting**\n\nI'm recognizing a simple, friendly \"hello\" in Chinese. My processing confirms it's a standard greeting, initiating interaction with politeness. I'm ready to proceed based on this acknowledgment.\n\n","extra_content":{"google":{"thought":true}},"role":"assistant"},"index":0,"logprobs":null}],"created":1791318365,"id":"XVnFapHzGtiF998Po_-IwQ4","model":"google/gemini-3.8-flash","object":"chat.completion.chunk","system_fingerprint":""} + +data: {"choices":[{"delta":{"content":"\u4f60\u597d\uff01\u5f88\u9ad8","role":"assistant"},"index":0,"logprobs":null}],"created":1791318365,"id":"XVnFapHzGtiF998Po_-IwQ4","model":"google/gemini-3.8-flash","object":"chat.completion.chunk","system_fingerprint":""} + +data: {"choices":[{"delta":{"content":"\u5174\u4e0e\u4f60\u4ea4\u6d41\u3002\u8bf7\u95ee\u6709\u4ec0\u4e48\u6211\u53ef\u4ee5\u5e2e\u4f60\u7684\u5417\uff1f","role":"assistant"},"index":0,"logprobs":null}],"created":1791318365,"id":"XVnFapHzGtiF998Po_-IwQ4","model":"google/gemini-3.8-flash","object":"chat.completion.chunk","system_fingerprint":""} + +data: {"choices":[{"delta":{"content":"","extra_content":{"google":{"thought_signature":"AY89a19hMHL5rERAubRcDfLpzjHh1if4TijaMFTbbGfO9iNlC7bdKgKR3PDfH/f5eNhNNKirqU1kgjHZh5+zSGaTCiD2Y4WPIsoiKWX11+/TSMkrnENS1haCiMCeswc+3pO9uo/pyB/jacOY5V2MCGq0U1R/dZHGGK0X4bjVs6E9vgYbiP+8wWfVeltJjQE0ndI9iA7KdZSBPEqG0ZP2p8+gos/ySRP2diiSUic51ID0PkR04+BmCc9aMHvCI8iFHTQIZmmE0TAN5C3F1s7+wE0rqWMp5Sq5O911d0UWwsvNOG6m6KmHZnB0RF1BJXwZWNXmOICdYWzXKNAjAl0TIUoxSZ7asVIogGQZRkbmVN1yaZrV/LCm3tkEXA8glMFB36e70CHBmeOvjaXoVt/Ha7o1mc0aYU62Oy9MSP+OYPpJWjynyXawWXgYwsXByeOj/0vOlg8G837kdKqh2JRk9IeBVwCFRZBak9pQ4dD+y9Ys13QY6Fil7GDUCezJL54swfKOHp1x1paeVzrGu09KPtnn7+SH47zEweLAjsptS6gzU7KKnw2i6wZvILXGjhxtv0zTiKketyOvy/g6Fg1gRcotIlIxIpRlMkO06/o01hbjlFVAT843BB2tGyl4Q+DNs8VdSP0JNDY0C/Foz9gUuqNkB4X60xGiSwuuElrEnr/iCt3B6JXG3RuONj3c1cvDo5KNXLrgaNfCZbmINP2vTTFjMj9J4wqipVWm9d2X5WJ8x7WdSs4gdvi8yE6+Qj4Yzn2nX+Sbiu9G43VIi82ZiVMTCVcQTLf45+pAKirqwi0LIRjtnerN6rC3/2vHvKvgsD6oeXclqc6ikx32MUjCRU+G6Mj0poyuIp0EuMc8xpLEDXDCG5CqKGsStcv4+ndBU11sZrNHYaiKvMIqQdQ+FX3llR2C1JQLK9XSUaK2z9Ek7VsMCQ4WPLoW2oCxYpoNnk8f4GgQubKInXqQaRhq6TSN0ZZtt059Xr8lzOrLTUbUUawTv2slf2V0qKR/dxBdQk9551ZzmSb27DhFzETSj92YrPdVHEuAIb1bNwsELm5qIAGq/P/g41Np9Hw49ihPsoWu5qDGmhJca8ZCm6giNgkjIxT/A8poZMKs0G7r9Lp5n2MCL0K4ZueS95RF+PZgU+eOobrZ//XBLNcrgjXbB5iYoTFRTpoFhjnN5n8qwo7Cgygyr6fbt9L4PEtDNZ2RKXHR+gpA/1CO8ceOJu+DAVKOeViNRGiQgRxtJqWGcQhU0lz7d7C+ykxICaXBfCiSLS5ZHYBU3WlCuxrVoOY7jbdpJS27KIv3H00J0kXnRaItJn357R8SV+fkeyLKyabggse88zELZga69jElnqL22bQD1lntQp4Gcq6XX9qfD2DBLkCC7m+MUrKo0m7ycik7GoeRcEUDFvijCM4NON5BIJ8="}},"role":"assistant"},"finish_reason":"stop","index":0,"logprobs":null}],"created":1791318365,"id":"XVnFapHzGtiF998Po_-IwQ4","model":"google/gemini-3.8-flash","object":"chat.completion.chunk","system_fingerprint":"","usage":{"completion_tokens":16,"completion_tokens_details":{"reasoning_tokens":219},"extra_properties":{"google":{"traffic_type":"ON_DEMAND"}},"prompt_tokens":1,"total_tokens":236}} + +data: [DONE] +