gateway: a Gemini client's thinkingConfig no longer reaches a quiet group member as reasoning_effort (#1253)
withoutReasoningAsk (#950) took the reasoning ask off Chat, Responses and Anthropic bodies sent to a group member known not to think, and passed a Gemini-protocol body through as it was. parseGemini then read generationConfig.thinkingConfig into the request's effort, and the quiet member was sent reasoning_effort. It now drops generationConfig.thinkingConfig when it asks to think, by the same reading parseGemini uses (gThinking.effort: a level, a budget, -1 as medium), keeping the rest of generationConfig. A budget of 0 is no ask and stays. A member the user fixed at an effort isn't quiet and is sent the ask as before. TestGroupQuietMemberGeminiThinking sends generateContent through lanGuard(s.Handler()) to a group whose thinking member fails over to a quiet one, for thinkingLevel, a budget and -1. Without the change it fails: "the quiet member was sent map[... reasoning_effort:high ...]"; TestWithoutReasoningAsk's new Gemini cases fail too. With it both pass, and the thinking member and a fixed quiet member still get reasoning_effort. go test -tags nogui ./internal/gateway passes under a temp HOME with pinned caches; the three group tests pass with -race -count=5; go vet and GOOS=linux/windows go vet -tags nogui pass.
This commit is contained in:
1 parent
8c0ffcc640
commit
173ab71e8b
3 files changed
+136
-29
No files matched your search
+24
-13
@@ -78,14 +78,32 @@ type gRequest struct {
|
|||||||
ResponseMimeType string `json:"responseMimeType,omitempty"`
|
ResponseMimeType string `json:"responseMimeType,omitempty"`
|
||||||
ResponseSchema json.RawMessage `json:"responseSchema,omitempty"`
|
ResponseSchema json.RawMessage `json:"responseSchema,omitempty"`
|
||||||
ResponseJSONSchema json.RawMessage `json:"responseJsonSchema,omitempty"`
|
ResponseJSONSchema json.RawMessage `json:"responseJsonSchema,omitempty"`
|
||||||
ThinkingConfig *struct {
|
ThinkingConfig *gThinking `json:"thinkingConfig,omitempty"`
|
||||||
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
|
|
||||||
ThinkingLevel string `json:"thinkingLevel,omitempty"`
|
|
||||||
IncludeThoughts bool `json:"includeThoughts,omitempty"`
|
|
||||||
} `json:"thinkingConfig,omitempty"`
|
|
||||||
} `json:"generationConfig,omitempty"`
|
} `json:"generationConfig,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gThinking is a Gemini request's generationConfig.thinkingConfig.
|
||||||
|
type gThinking struct {
|
||||||
|
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
|
||||||
|
ThinkingLevel string `json:"thinkingLevel,omitempty"`
|
||||||
|
IncludeThoughts bool `json:"includeThoughts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// effort is the reasoning the thinkingConfig asks for: its level, else its
|
||||||
|
// budget as the level it is nearest, -1 (the model decides) as medium. ""
|
||||||
|
// when it asks for none: a budget of 0, or includeThoughts alone.
|
||||||
|
func (tc *gThinking) effort() string {
|
||||||
|
switch {
|
||||||
|
case tc.ThinkingLevel != "":
|
||||||
|
return effortOf(tc.ThinkingLevel)
|
||||||
|
case tc.ThinkingBudget != nil && *tc.ThinkingBudget < 0:
|
||||||
|
return "medium" // -1: let the model decide
|
||||||
|
case tc.ThinkingBudget != nil:
|
||||||
|
return effortOfBudget(*tc.ThinkingBudget)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// buildGemini is a generateContent body for an upstream that speaks Gemini
|
// buildGemini is a generateContent body for an upstream that speaks Gemini
|
||||||
// itself (Factory's /api/llm/g). The contents are the ones Code Assist
|
// itself (Factory's /api/llm/g). The contents are the ones Code Assist
|
||||||
// sends inside its envelope. systemInstruction has no role: droid sends
|
// sends inside its envelope. systemInstruction has no role: droid sends
|
||||||
@@ -193,14 +211,7 @@ func parseGemini(body []byte) (*Request, error) {
|
|||||||
if gc := g.GenerationConfig; gc != nil {
|
if gc := g.GenerationConfig; gc != nil {
|
||||||
r.MaxTokens, r.Temp, r.TopP, r.Stop = gc.MaxOutputTokens, gc.Temperature, gc.TopP, gc.StopSequences
|
r.MaxTokens, r.Temp, r.TopP, r.Stop = gc.MaxOutputTokens, gc.Temperature, gc.TopP, gc.StopSequences
|
||||||
if tc := gc.ThinkingConfig; tc != nil {
|
if tc := gc.ThinkingConfig; tc != nil {
|
||||||
switch {
|
r.Effort = tc.effort()
|
||||||
case tc.ThinkingLevel != "":
|
|
||||||
r.Effort = effortOf(tc.ThinkingLevel)
|
|
||||||
case tc.ThinkingBudget != nil && *tc.ThinkingBudget < 0:
|
|
||||||
r.Effort = "medium" // -1: let the model decide
|
|
||||||
case tc.ThinkingBudget != nil:
|
|
||||||
r.Effort = effortOfBudget(*tc.ThinkingBudget)
|
|
||||||
}
|
|
||||||
r.Thinking = r.Effort != ""
|
r.Thinking = r.Effort != ""
|
||||||
}
|
}
|
||||||
// structured output, asked for in the upstream's own field
|
// structured output, asked for in the upstream's own field
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import (
|
|||||||
|
|
||||||
// quietUp is a Chat vendor that answers as its key, and keeps what it was
|
// quietUp is a Chat vendor that answers as its key, and keeps what it was
|
||||||
// sent; fail makes it answer 500, so the group falls to the next member.
|
// sent; fail makes it answer 500, so the group falls to the next member.
|
||||||
// It speaks Responses too, for a request sent that way.
|
// It speaks Responses too, for a request sent that way, and streams a
|
||||||
|
// Chat request that asks to be.
|
||||||
type quietUp struct {
|
type quietUp struct {
|
||||||
key string
|
key string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -47,6 +48,16 @@ func (u *quietUp) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
))
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.Contains(string(b), `"stream":true`) {
|
||||||
|
// a translated request (a Gemini client's) is streamed
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
io.WriteString(w, sse(
|
||||||
|
`data: {"id":"x","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"from `+u.key+`"}}]}`,
|
||||||
|
`data: {"id":"x","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}`,
|
||||||
|
`data: [DONE]`,
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
io.WriteString(w, `{"id":"x","choices":[{"index":0,"message":{"role":"assistant","content":"from `+u.key+`"},"finish_reason":"stop"}],`+
|
io.WriteString(w, `{"id":"x","choices":[{"index":0,"message":{"role":"assistant","content":"from `+u.key+`"},"finish_reason":"stop"}],`+
|
||||||
`"usage":{"prompt_tokens":10,"completion_tokens":5}}`)
|
`"usage":{"prompt_tokens":10,"completion_tokens":5}}`)
|
||||||
@@ -60,14 +71,13 @@ func (u *quietUp) sent() map[string]any {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// A group reasons when any member does, so an agent asks it for reasoning
|
// quietGroup is the group Mix of a member that thinks (thinks/levelled)
|
||||||
// a member may not take: a member known not to think is sent none of it
|
// and one models.dev lists as not thinking (quiet/plain), ordered, each a
|
||||||
// (#950). The one that thinks is sent the effort the agent asked for,
|
// quietUp. reset begins a scenario as if the last hadn't run — no member
|
||||||
// fitted as before; the quiet one is sent the request without it — some
|
// resting, no conversation kept where it went (affinity, #63) — with the
|
||||||
// vendors turn it away with a 400 on a model that can't think — and the
|
// thinking member answering fail (0: as asked).
|
||||||
// group routes to it as it did.
|
func quietGroup(t *testing.T) (thinks, quiet *quietUp, reset func(fail int)) {
|
||||||
func TestGroupNonThinkingMemberNoEffort(t *testing.T) {
|
t.Helper()
|
||||||
fresh(t)
|
|
||||||
if err := os.MkdirAll(filepath.Dir(catalog.CachePath()), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(catalog.CachePath()), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -84,7 +94,7 @@ func TestGroupNonThinkingMemberNoEffort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
catalog.Reset()
|
catalog.Reset()
|
||||||
t.Cleanup(catalog.Reset)
|
t.Cleanup(catalog.Reset)
|
||||||
thinks, quiet := &quietUp{key: "kt"}, &quietUp{key: "kq"}
|
thinks, quiet = &quietUp{key: "kt"}, &quietUp{key: "kq"}
|
||||||
for _, x := range []struct {
|
for _, x := range []struct {
|
||||||
id string
|
id string
|
||||||
up *quietUp
|
up *quietUp
|
||||||
@@ -107,10 +117,7 @@ func TestGroupNonThinkingMemberNoEffort(t *testing.T) {
|
|||||||
if err := provider.SaveGroup(provider.Group{Name: "Mix", Members: []string{"thinks/levelled", "quiet/plain"}, Routing: provider.Ordered}); err != nil {
|
if err := provider.SaveGroup(provider.Group{Name: "Mix", Members: []string{"thinks/levelled", "quiet/plain"}, Routing: provider.Ordered}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s := New()
|
reset = func(fail int) {
|
||||||
// each scenario begins as if the last hadn't run: no member resting,
|
|
||||||
// no conversation kept where it went (affinity, #63)
|
|
||||||
reset := func(fail int) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
restingUntil.Lock()
|
restingUntil.Lock()
|
||||||
restingUntil.m = map[string]time.Time{}
|
restingUntil.m = map[string]time.Time{}
|
||||||
@@ -122,6 +129,19 @@ func TestGroupNonThinkingMemberNoEffort(t *testing.T) {
|
|||||||
thinks.fail = fail
|
thinks.fail = fail
|
||||||
thinks.mu.Unlock()
|
thinks.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
return thinks, quiet, reset
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group reasons when any member does, so an agent asks it for reasoning
|
||||||
|
// a member may not take: a member known not to think is sent none of it
|
||||||
|
// (#950). The one that thinks is sent the effort the agent asked for,
|
||||||
|
// fitted as before; the quiet one is sent the request without it — some
|
||||||
|
// vendors turn it away with a 400 on a model that can't think — and the
|
||||||
|
// group routes to it as it did.
|
||||||
|
func TestGroupNonThinkingMemberNoEffort(t *testing.T) {
|
||||||
|
fresh(t)
|
||||||
|
thinks, quiet, reset := quietGroup(t)
|
||||||
|
s := New()
|
||||||
ask := func(extra string) (string, Route) {
|
ask := func(extra string) (string, Route) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
code, out := postAs(t, s, "s1", `{"model":"group/mix"`+extra+`,"messages":[{"role":"user","content":"hi"}]}`)
|
code, out := postAs(t, s, "s1", `{"model":"group/mix"`+extra+`,"messages":[{"role":"user","content":"hi"}]}`)
|
||||||
@@ -211,10 +231,65 @@ func TestGroupNonThinkingMemberNoEffort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Gemini client asks for reasoning in generationConfig.thinkingConfig,
|
||||||
|
// which reaches a Chat member as reasoning_effort: a quiet member is sent
|
||||||
|
// none of it, as the Anthropic and OpenAI asks aren't (#1253), through the
|
||||||
|
// gateway as it serves requests (lanGuard). The member that thinks is
|
||||||
|
// still sent it, and so is a quiet member the user fixed at an effort.
|
||||||
|
func TestGroupQuietMemberGeminiThinking(t *testing.T) {
|
||||||
|
fresh(t)
|
||||||
|
thinks, quiet, reset := quietGroup(t)
|
||||||
|
h := lanGuard(New().Handler())
|
||||||
|
gemini := func(group, config string) string {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest("POST", "/v1beta/models/group/"+group+":generateContent",
|
||||||
|
strings.NewReader(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"temperature":0.2,"thinkingConfig":`+config+`}}`))
|
||||||
|
r.RemoteAddr = "127.0.0.1:5000"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("%s: %d %s", config, w.Code, w.Body)
|
||||||
|
}
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
for _, config := range []string{`{"thinkingLevel":"high","includeThoughts":true}`, `{"thinkingBudget":8192}`, `{"thinkingBudget":-1}`} {
|
||||||
|
reset(0)
|
||||||
|
if out := gemini("mix", config); !strings.Contains(out, "from kt") {
|
||||||
|
t.Fatalf("%s: %s", config, out)
|
||||||
|
}
|
||||||
|
if sent := thinks.sent(); sent["reasoning_effort"] == nil {
|
||||||
|
t.Fatalf("%s: the thinking member was sent %v", config, sent)
|
||||||
|
}
|
||||||
|
reset(500)
|
||||||
|
if out := gemini("mix", config); !strings.Contains(out, "from kq") {
|
||||||
|
t.Fatalf("%s: %s", config, out)
|
||||||
|
}
|
||||||
|
sent := quiet.sent()
|
||||||
|
if sent["reasoning_effort"] != nil || sent["reasoning"] != nil {
|
||||||
|
t.Fatalf("%s: the quiet member was sent %v", config, sent)
|
||||||
|
}
|
||||||
|
if sent["model"] != "plain" || sent["temperature"] != 0.2 {
|
||||||
|
t.Fatalf("%s: the quiet member lost the rest of the request: %v", config, sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// a quiet member the user fixed at an effort keeps the ask
|
||||||
|
if err := provider.SaveGroup(provider.Group{Name: "Fix", Members: []string{"thinks/levelled", "quiet/plain:high"}, Routing: provider.Ordered}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reset(500)
|
||||||
|
if out := gemini("fix", `{"thinkingLevel":"high"}`); !strings.Contains(out, "from kq") {
|
||||||
|
t.Fatalf("fixed: %s", out)
|
||||||
|
}
|
||||||
|
if sent := quiet.sent(); sent["reasoning_effort"] != "high" {
|
||||||
|
t.Fatalf("fixed: the fixed member was sent %v", sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The request the quiet member is sent keeps every other field: only what
|
// The request the quiet member is sent keeps every other field: only what
|
||||||
// asks the model to think goes, in the agent's own protocol. Anthropic's
|
// asks the model to think goes, in the agent's own protocol. Anthropic's
|
||||||
// thinking turned off is no ask and stays; output_config keeps its other
|
// thinking turned off is no ask and stays; output_config keeps its other
|
||||||
// keys.
|
// keys. Gemini's thinkingConfig goes when it asks to think; a budget of 0
|
||||||
|
// is no ask and stays.
|
||||||
func TestWithoutReasoningAsk(t *testing.T) {
|
func TestWithoutReasoningAsk(t *testing.T) {
|
||||||
for _, c := range []struct {
|
for _, c := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -229,6 +304,9 @@ func TestWithoutReasoningAsk(t *testing.T) {
|
|||||||
{"anthropic on", provider.Anthropic, `{"model":"m","thinking":{"type":"enabled","budget_tokens":4096}}`, `{"model":"m"}`},
|
{"anthropic on", provider.Anthropic, `{"model":"m","thinking":{"type":"enabled","budget_tokens":4096}}`, `{"model":"m"}`},
|
||||||
{"anthropic adaptive", provider.Anthropic, `{"model":"m","thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, `{"model":"m"}`},
|
{"anthropic adaptive", provider.Anthropic, `{"model":"m","thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, `{"model":"m"}`},
|
||||||
{"anthropic off", provider.Anthropic, `{"model":"m","thinking":{"type":"disabled"}}`, `{"model":"m","thinking":{"type":"disabled"}}`},
|
{"anthropic off", provider.Anthropic, `{"model":"m","thinking":{"type":"disabled"}}`, `{"model":"m","thinking":{"type":"disabled"}}`},
|
||||||
|
{"gemini level", provider.Gemini, `{"model":"m","generationConfig":{"temperature":0.2,"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}}}`, `{"generationConfig":{"temperature":0.2},"model":"m"}`},
|
||||||
|
{"gemini budget", provider.Gemini, `{"model":"m","generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, `{"model":"m"}`},
|
||||||
|
{"gemini off", provider.Gemini, `{"generationConfig":{"thinkingConfig":{"thinkingBudget":0}},"model":"m"}`, `{"generationConfig":{"thinkingConfig":{"thinkingBudget":0}},"model":"m"}`},
|
||||||
{"anthropic output", provider.Anthropic, `{"model":"m","output_config":{"effort":"high","format":{"type":"json"}}}`, `{"model":"m","output_config":{"format":{"type":"json"}}}`},
|
{"anthropic output", provider.Anthropic, `{"model":"m","output_config":{"effort":"high","format":{"type":"json"}}}`, `{"model":"m","output_config":{"format":{"type":"json"}}}`},
|
||||||
} {
|
} {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ func quietTo(pid, model string) bool {
|
|||||||
// Responses' reasoning (with reasoning_effort, which some relays take),
|
// Responses' reasoning (with reasoning_effort, which some relays take),
|
||||||
// Anthropic's thinking when it asks to think — thinking disabled is no
|
// Anthropic's thinking when it asks to think — thinking disabled is no
|
||||||
// ask and stays — and output_config's effort alone, the rest of
|
// ask and stays — and output_config's effort alone, the rest of
|
||||||
// output_config kept. The body goes as it is when it asked for nothing.
|
// output_config kept, and Gemini's generationConfig.thinkingConfig when it
|
||||||
|
// asks to think. The body goes as it is when it asked for nothing.
|
||||||
func withoutReasoningAsk(proto provider.Protocol, body []byte) []byte {
|
func withoutReasoningAsk(proto provider.Protocol, body []byte) []byte {
|
||||||
dec := json.NewDecoder(bytes.NewReader(body))
|
dec := json.NewDecoder(bytes.NewReader(body))
|
||||||
dec.UseNumber()
|
dec.UseNumber()
|
||||||
@@ -77,6 +78,23 @@ func withoutReasoningAsk(proto provider.Protocol, body []byte) []byte {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case provider.Gemini:
|
||||||
|
// generationConfig's thinkingConfig when it asks to think (as
|
||||||
|
// parseGemini reads it), the rest of generationConfig kept; a
|
||||||
|
// budget of 0 is no ask and stays. The body was spelled in
|
||||||
|
// camelCase on its way in (geminiCamel).
|
||||||
|
if gc, ok := m["generationConfig"].(map[string]any); ok {
|
||||||
|
if raw, err := json.Marshal(gc["thinkingConfig"]); err == nil {
|
||||||
|
var tc gThinking
|
||||||
|
if json.Unmarshal(raw, &tc) == nil && tc.effort() != "" {
|
||||||
|
delete(gc, "thinkingConfig")
|
||||||
|
changed = true
|
||||||
|
if len(gc) == 0 {
|
||||||
|
drop("generationConfig")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user