Skip to content

Commit 7e02001

Browse files
committed
feat(copilot/gemini): add Gemini 3 Pro reasoning support
- Capture reasoning_text from SSE model parts - Handle reasoning_opaque signature for tool calls - Re-inject reasoning context into subsequent requests - Credit: Reverse engineering adapted from github.com/aadishv/vscre
1 parent 8e9ba84 commit 7e02001

File tree

1 file changed

+193
-0
lines changed

1 file changed

+193
-0
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package executor
2+
3+
import (
4+
"fmt"
5+
"sync"
6+
"time"
7+
8+
log "github.com/sirupsen/logrus"
9+
"github.com/tidwall/gjson"
10+
"github.com/tidwall/sjson"
11+
)
12+
13+
type geminiReasoningCache struct {
14+
mu sync.RWMutex
15+
cache map[string]*geminiReasoning
16+
}
17+
18+
type geminiReasoning struct {
19+
Opaque string
20+
Text string
21+
createdAt time.Time
22+
}
23+
24+
const geminiReasoningTTL = 30 * time.Minute
25+
26+
var (
27+
sharedGeminiReasoningMu sync.Mutex
28+
sharedGeminiReasoning = make(map[string]*geminiReasoningCache)
29+
)
30+
31+
func newGeminiReasoningCache() *geminiReasoningCache {
32+
return &geminiReasoningCache{
33+
cache: make(map[string]*geminiReasoning),
34+
}
35+
}
36+
37+
// getSharedGeminiReasoningCache returns a cache keyed by authID to preserve
38+
// reasoning data across executor re-creations (e.g., after reauth).
39+
func getSharedGeminiReasoningCache(authID string) *geminiReasoningCache {
40+
if authID == "" {
41+
return newGeminiReasoningCache()
42+
}
43+
sharedGeminiReasoningMu.Lock()
44+
defer sharedGeminiReasoningMu.Unlock()
45+
if cache, ok := sharedGeminiReasoning[authID]; ok && cache != nil {
46+
return cache
47+
}
48+
cache := newGeminiReasoningCache()
49+
sharedGeminiReasoning[authID] = cache
50+
return cache
51+
}
52+
53+
// EvictCopilotGeminiReasoningCache removes the shared cache for an auth ID when the auth is removed.
54+
func EvictCopilotGeminiReasoningCache(authID string) {
55+
if authID == "" {
56+
return
57+
}
58+
sharedGeminiReasoningMu.Lock()
59+
delete(sharedGeminiReasoning, authID)
60+
sharedGeminiReasoningMu.Unlock()
61+
}
62+
63+
// InjectReasoning inserts cached reasoning fields back into assistant messages
64+
// for tool calls (required by Gemini 3 models).
65+
func (c *geminiReasoningCache) InjectReasoning(body []byte) []byte {
66+
// Find assistant messages with tool_calls that are missing reasoning fields
67+
messages := gjson.GetBytes(body, "messages")
68+
if !messages.Exists() || !messages.IsArray() {
69+
return body
70+
}
71+
72+
c.mu.RLock()
73+
defer c.mu.RUnlock()
74+
75+
if len(c.cache) == 0 {
76+
log.Debug("copilot executor: no cached Gemini reasoning available")
77+
return body
78+
}
79+
80+
var modified bool
81+
var msgIdx int
82+
messages.ForEach(func(_, msg gjson.Result) bool {
83+
defer func() { msgIdx++ }()
84+
if msg.Get("role").String() != "assistant" {
85+
return true
86+
}
87+
toolCalls := msg.Get("tool_calls")
88+
if !toolCalls.Exists() || !toolCalls.IsArray() {
89+
return true
90+
}
91+
// Check if reasoning fields are missing
92+
if msg.Get("reasoning_opaque").Exists() || msg.Get("reasoning_text").Exists() {
93+
return true
94+
}
95+
96+
// Look up reasoning by the first tool_call's id
97+
var callID string
98+
toolCalls.ForEach(func(_, tc gjson.Result) bool {
99+
if id := tc.Get("id").String(); id != "" {
100+
callID = id
101+
return false // stop after first
102+
}
103+
return true
104+
})
105+
106+
if callID == "" {
107+
return true
108+
}
109+
110+
reasoning := c.cache[callID]
111+
if reasoning == nil || (reasoning.Opaque == "" && reasoning.Text == "") {
112+
log.Debugf("copilot executor: no cached reasoning for call_id %s", callID)
113+
return true
114+
}
115+
116+
// Check TTL
117+
if time.Since(reasoning.createdAt) > geminiReasoningTTL {
118+
log.Debugf("copilot executor: cached reasoning for call_id %s expired", callID)
119+
return true
120+
}
121+
122+
log.Debugf("copilot executor: injecting reasoning for call_id %s (opaque=%d chars, text=%d chars)", callID, len(reasoning.Opaque), len(reasoning.Text))
123+
124+
msgPath := fmt.Sprintf("messages.%d", msgIdx)
125+
if reasoning.Opaque != "" {
126+
body, _ = sjson.SetBytes(body, msgPath+".reasoning_opaque", reasoning.Opaque)
127+
modified = true
128+
}
129+
if reasoning.Text != "" {
130+
body, _ = sjson.SetBytes(body, msgPath+".reasoning_text", reasoning.Text)
131+
modified = true
132+
}
133+
return true
134+
})
135+
136+
if modified {
137+
log.Debug("copilot executor: injected cached Gemini reasoning into request")
138+
}
139+
return body
140+
}
141+
142+
// CacheReasoning captures reasoning fields from streaming deltas.
143+
func (c *geminiReasoningCache) CacheReasoning(data []byte) {
144+
delta := gjson.GetBytes(data, "choices.0.delta")
145+
if !delta.Exists() {
146+
return
147+
}
148+
149+
// Get the call_id from the first tool_call in the delta
150+
callID := gjson.GetBytes(data, "choices.0.delta.tool_calls.0.id").String()
151+
152+
opaque := delta.Get("reasoning_opaque").String()
153+
text := delta.Get("reasoning_text").String()
154+
155+
if opaque == "" && text == "" {
156+
return
157+
}
158+
159+
c.mu.Lock()
160+
defer c.mu.Unlock()
161+
162+
// Lazy eviction: simple random cleanup if cache gets too big
163+
if len(c.cache) > 1000 {
164+
now := time.Now()
165+
for k, v := range c.cache {
166+
if now.Sub(v.createdAt) > geminiReasoningTTL {
167+
delete(c.cache, k)
168+
}
169+
}
170+
}
171+
172+
if callID == "" {
173+
return
174+
}
175+
176+
log.Debugf("copilot executor: caching Gemini reasoning for call_id %s (opaque=%d chars, text=%d chars)", callID, len(opaque), len(text))
177+
178+
if c.cache[callID] == nil {
179+
c.cache[callID] = &geminiReasoning{
180+
createdAt: time.Now(),
181+
}
182+
}
183+
184+
// Only update if we got new values
185+
if opaque != "" {
186+
c.cache[callID].Opaque = opaque
187+
}
188+
if text != "" {
189+
// Append text since it comes in chunks
190+
c.cache[callID].Text += text
191+
}
192+
c.cache[callID].createdAt = time.Now()
193+
}

0 commit comments

Comments
 (0)