Skip to content

Commit 75ada28

Browse files
Merge upstream/main (auto-sync feat/copilot)
- b3d6d5d refactor: extract signature validation - 01a7cc4 fix(amp): restore response tool casing from request - 65e760a feat(usage): include cache tokens in total token calculation and add tests - 71c185f feat(usage): add service tier tracking and defaults in usage reporting - c65275f Merge pull request router-for-me#3591 from sususu98/feat/signature-check-extraction - df0176a feat(models): add Claude Opus 4.8 model to registry - f28258d Merge pull request router-for-me#3595 from Progress-infinitely/fix/anthropic-tool-name-reverse-map - c4ee063 feat(logging): add HomeAppLogForwarder for application log forwarding - 4ade13a Merge pull request router-for-me#3605 from router-for-me/log - 7d9980e fix(logging): log errors during file-backed source cleanup
2 parents 08da5d2 + 7d9980e commit 75ada28

31 files changed

Lines changed: 3346 additions & 438 deletions

internal/api/middleware/response_writer.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/gin-gonic/gin"
1313
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
1414
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
15+
log "github.com/sirupsen/logrus"
1516
)
1617

1718
const requestBodyOverrideContextKey = "REQUEST_BODY_OVERRIDE"
@@ -570,6 +571,8 @@ func cleanupFileBodySources(sources ...*logging.FileBodySource) {
570571
if source == nil {
571572
continue
572573
}
573-
_ = source.Cleanup()
574+
if errCleanup := source.Cleanup(); errCleanup != nil {
575+
log.WithError(errCleanup).Warn("failed to clean up log part files")
576+
}
574577
}
575578
}

internal/api/modules/amp/fallback_handlers.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
252252
// Log: Model was mapped to another model
253253
log.Debugf("amp model mapping: request %s -> %s", normalizedModel, resolvedModel)
254254
logAmpRouting(RouteTypeModelMapping, modelName, resolvedModel, providerName, requestPath)
255-
rewriter := NewResponseRewriter(c.Writer, modelName)
255+
rewriter := NewResponseRewriterForRequest(c.Writer, modelName, bodyBytes)
256256
rewriter.suppressThinking = true
257257
c.Writer = rewriter
258258
// Filter Anthropic-Beta header only for local handling paths
@@ -267,7 +267,7 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
267267
// Wrap with ResponseRewriter for local providers too, because upstream
268268
// proxies (e.g. NewAPI) may return a different model name and lack
269269
// Amp-required fields like thinking.signature.
270-
rewriter := NewResponseRewriter(c.Writer, modelName)
270+
rewriter := NewResponseRewriterForRequest(c.Writer, modelName, bodyBytes)
271271
rewriter.suppressThinking = providerName != "claude"
272272
c.Writer = rewriter
273273
// Filter Anthropic-Beta header only for local handling paths

internal/api/modules/amp/fallback_handlers_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,38 @@ import (
1313
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
1414
)
1515

16+
func TestFallbackHandler_RequestToolCasing_RewritesStreamingResponse(t *testing.T) {
17+
gin.SetMode(gin.TestMode)
18+
19+
reg := registry.GetGlobalRegistry()
20+
reg.RegisterClient("test-client-amp-tool-casing", "codex", []*registry.ModelInfo{
21+
{ID: "test/gpt-tool-casing", OwnedBy: "openai", Type: "codex"},
22+
})
23+
defer reg.UnregisterClient("test-client-amp-tool-casing")
24+
25+
fallback := NewFallbackHandlerWithMapper(func() *httputil.ReverseProxy { return nil }, nil, nil)
26+
handler := func(c *gin.Context) {
27+
c.Writer.Header().Set("Content-Type", "text/event-stream")
28+
_, _ = c.Writer.Write([]byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"name\":\"glob\",\"id\":\"toolu_01\",\"input\":{}}}\n\n"))
29+
}
30+
31+
r := gin.New()
32+
r.POST("/messages", fallback.WrapHandler(handler))
33+
34+
reqBody := []byte(`{"model":"test/gpt-tool-casing","tools":[{"name":"Glob","input_schema":{"type":"object"}}]}`)
35+
req := httptest.NewRequest(http.MethodPost, "/messages", bytes.NewReader(reqBody))
36+
req.Header.Set("Content-Type", "application/json")
37+
w := httptest.NewRecorder()
38+
r.ServeHTTP(w, req)
39+
40+
if w.Code != http.StatusOK {
41+
t.Fatalf("Expected status 200, got %d", w.Code)
42+
}
43+
if !bytes.Contains(w.Body.Bytes(), []byte(`"name":"Glob"`)) {
44+
t.Fatalf("expected streaming response to restore glob->Glob, got %s", w.Body.String())
45+
}
46+
}
47+
1648
func TestFallbackHandler_ModelMapping_PreservesThinkingSuffixAndRewritesResponse(t *testing.T) {
1749
gin.SetMode(gin.TestMode)
1850

internal/api/modules/amp/response_rewriter.go

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type ResponseRewriter struct {
2222
originalModel string
2323
isStreaming bool
2424
suppressThinking bool
25+
requestToolNames map[string]string
2526
}
2627

2728
// NewResponseRewriter creates a new response rewriter for model name substitution.
@@ -33,6 +34,12 @@ func NewResponseRewriter(w gin.ResponseWriter, originalModel string) *ResponseRe
3334
}
3435
}
3536

37+
func NewResponseRewriterForRequest(w gin.ResponseWriter, originalModel string, requestBody []byte) *ResponseRewriter {
38+
rw := NewResponseRewriter(w, originalModel)
39+
rw.requestToolNames = collectRequestToolNames(requestBody)
40+
return rw
41+
}
42+
3643
const maxBufferedResponseBytes = 2 * 1024 * 1024 // 2MB safety cap
3744

3845
func looksLikeSSEChunk(data []byte) bool {
@@ -134,17 +141,70 @@ var ampCanonicalToolNames = map[string]string{
134141
"check": "Check",
135142
}
136143

144+
func collectRequestToolNames(data []byte) map[string]string {
145+
if len(data) == 0 {
146+
return nil
147+
}
148+
parsed := gjson.ParseBytes(data)
149+
names := map[string]string{}
150+
conflicts := map[string]bool{}
151+
record := func(name string) {
152+
if name == "" {
153+
return
154+
}
155+
key := strings.ToLower(name)
156+
if conflicts[key] {
157+
return
158+
}
159+
if existing, exists := names[key]; exists {
160+
if existing != name {
161+
names[key] = ""
162+
conflicts[key] = true
163+
}
164+
return
165+
}
166+
names[key] = name
167+
}
168+
169+
for _, tool := range parsed.Get("tools").Array() {
170+
record(tool.Get("name").String())
171+
}
172+
if parsed.Get("tool_choice.type").String() == "tool" {
173+
record(parsed.Get("tool_choice.name").String())
174+
}
175+
if len(names) == 0 {
176+
return nil
177+
}
178+
return names
179+
}
180+
181+
func canonicalAmpToolName(name string, requestToolNames map[string]string) (string, bool) {
182+
key := strings.ToLower(name)
183+
if canonical, ok := requestToolNames[key]; ok {
184+
if canonical == "" {
185+
return "", false
186+
}
187+
return canonical, true
188+
}
189+
canonical, ok := ampCanonicalToolNames[key]
190+
return canonical, ok
191+
}
192+
137193
// normalizeAmpToolNames fixes tool_use block names to match Amp's canonical casing.
138194
// Some upstream models return lowercase tool names (e.g. "bash" instead of "Bash")
139195
// which causes Amp's case-sensitive mode whitelist to reject them.
140196
func normalizeAmpToolNames(data []byte) []byte {
197+
return normalizeAmpToolNamesForRequest(data, nil)
198+
}
199+
200+
func normalizeAmpToolNamesForRequest(data []byte, requestToolNames map[string]string) []byte {
141201
// Non-streaming: content[].name in tool_use blocks
142202
for index, block := range gjson.GetBytes(data, "content").Array() {
143203
if block.Get("type").String() != "tool_use" {
144204
continue
145205
}
146206
name := block.Get("name").String()
147-
if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical {
207+
if canonical, ok := canonicalAmpToolName(name, requestToolNames); ok && name != canonical {
148208
path := fmt.Sprintf("content.%d.name", index)
149209
var err error
150210
data, err = sjson.SetBytes(data, path, canonical)
@@ -157,7 +217,7 @@ func normalizeAmpToolNames(data []byte) []byte {
157217
// Streaming: content_block.name in content_block_start events
158218
if gjson.GetBytes(data, "content_block.type").String() == "tool_use" {
159219
name := gjson.GetBytes(data, "content_block.name").String()
160-
if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical {
220+
if canonical, ok := canonicalAmpToolName(name, requestToolNames); ok && name != canonical {
161221
var err error
162222
data, err = sjson.SetBytes(data, "content_block.name", canonical)
163223
if err != nil {
@@ -169,6 +229,10 @@ func normalizeAmpToolNames(data []byte) []byte {
169229
return data
170230
}
171231

232+
func (rw *ResponseRewriter) normalizeToolNames(data []byte) []byte {
233+
return normalizeAmpToolNamesForRequest(data, rw.requestToolNames)
234+
}
235+
172236
// ensureAmpSignature injects empty signature fields into tool_use/thinking blocks
173237
// in API responses so that the Amp TUI does not crash on P.signature.length.
174238
func ensureAmpSignature(data []byte) []byte {
@@ -225,7 +289,7 @@ func (rw *ResponseRewriter) suppressAmpThinking(data []byte) []byte {
225289

226290
func (rw *ResponseRewriter) rewriteModelInResponse(data []byte) []byte {
227291
data = ensureAmpSignature(data)
228-
data = normalizeAmpToolNames(data)
292+
data = rw.normalizeToolNames(data)
229293
data = rw.suppressAmpThinking(data)
230294
if len(data) == 0 {
231295
return data
@@ -326,7 +390,7 @@ func (rw *ResponseRewriter) rewriteStreamEvent(data []byte) []byte {
326390
data = ensureAmpSignature(data)
327391

328392
// Normalize tool names to canonical casing
329-
data = normalizeAmpToolNames(data)
393+
data = rw.normalizeToolNames(data)
330394

331395
// Rewrite model name
332396
if rw.originalModel != "" {

internal/api/modules/amp/response_rewriter_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,96 @@ func TestNormalizeAmpToolNames_GlobPreserved(t *testing.T) {
217217
}
218218
}
219219

220+
func TestNormalizeAmpToolNames_RequestToolCasing_NonStreaming(t *testing.T) {
221+
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`)
222+
result := normalizeAmpToolNamesForRequest(input, map[string]string{"glob": "Glob"})
223+
224+
if !contains(result, []byte(`"name":"Glob"`)) {
225+
t.Errorf("expected glob->Glob when request advertised Glob, got %s", string(result))
226+
}
227+
}
228+
229+
func TestNormalizeAmpToolNames_RequestToolCasing_Streaming(t *testing.T) {
230+
input := []byte(`{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","name":"glob","id":"toolu_01","input":{}}}`)
231+
result := normalizeAmpToolNamesForRequest(input, map[string]string{"glob": "Glob"})
232+
233+
if !contains(result, []byte(`"name":"Glob"`)) {
234+
t.Errorf("expected glob->Glob in streaming when request advertised Glob, got %s", string(result))
235+
}
236+
}
237+
238+
func TestResponseRewriter_RequestToolCasingFromBody(t *testing.T) {
239+
requestBody := []byte(`{"tools":[{"name":"Glob","input_schema":{"type":"object"}}]}`)
240+
rw := &ResponseRewriter{requestToolNames: collectRequestToolNames(requestBody)}
241+
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`)
242+
243+
result := rw.rewriteModelInResponse(input)
244+
245+
if !contains(result, []byte(`"name":"Glob"`)) {
246+
t.Errorf("expected request body casing to restore glob->Glob, got %s", string(result))
247+
}
248+
}
249+
250+
func TestResponseRewriter_LowercaseNativeRequestPreserved(t *testing.T) {
251+
requestBody := []byte(`{"tools":[{"name":"glob","input_schema":{"type":"object"}}]}`)
252+
rw := &ResponseRewriter{requestToolNames: collectRequestToolNames(requestBody)}
253+
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`)
254+
255+
result := rw.rewriteModelInResponse(input)
256+
257+
if string(result) == string(input) {
258+
return
259+
}
260+
if !contains(result, []byte(`"name":"glob"`)) {
261+
t.Errorf("expected lowercase-native request to preserve glob, got %s", string(result))
262+
}
263+
}
264+
265+
func TestCollectRequestToolNames_CollisionIgnored(t *testing.T) {
266+
tests := []struct {
267+
requestBody []byte
268+
input []byte
269+
forbidden []byte
270+
}{
271+
{
272+
requestBody: []byte(`{"tools":[{"name":"Glob","input_schema":{"type":"object"}},{"name":"glob","input_schema":{"type":"object"}}]}`),
273+
input: []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`),
274+
forbidden: []byte(`"name":"Glob"`),
275+
},
276+
{
277+
requestBody: []byte(`{"tools":[{"name":"glob","input_schema":{"type":"object"}},{"name":"Glob","input_schema":{"type":"object"}}]}`),
278+
input: []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`),
279+
forbidden: []byte(`"name":"Glob"`),
280+
},
281+
{
282+
requestBody: []byte(`{"tools":[{"name":"Bash","input_schema":{"type":"object"}},{"name":"bash","input_schema":{"type":"object"}}]}`),
283+
input: []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"bash","input":{"cmd":"ls"}}]}`),
284+
forbidden: []byte(`"name":"Bash"`),
285+
},
286+
}
287+
288+
for _, tt := range tests {
289+
rw := &ResponseRewriter{requestToolNames: collectRequestToolNames(tt.requestBody)}
290+
result := rw.rewriteModelInResponse(tt.input)
291+
292+
if contains(result, tt.forbidden) {
293+
t.Errorf("expected conflicting tool casing not to force %s, got %s", string(tt.forbidden), string(result))
294+
}
295+
}
296+
}
297+
298+
func TestResponseRewriter_RequestToolCasingFromBody_Streaming(t *testing.T) {
299+
requestBody := []byte(`{"tools":[{"name":"Glob","input_schema":{"type":"object"}}]}`)
300+
rw := &ResponseRewriter{requestToolNames: collectRequestToolNames(requestBody)}
301+
input := []byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"name\":\"glob\",\"id\":\"toolu_01\",\"input\":{}}}\n\n")
302+
303+
result := rw.rewriteStreamChunk(input)
304+
305+
if !contains(result, []byte(`"name":"Glob"`)) {
306+
t.Errorf("expected streaming response to restore glob->Glob from request body, got %s", string(result))
307+
}
308+
}
309+
220310
func TestNormalizeAmpToolNames_UnknownToolUntouched(t *testing.T) {
221311
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"edit_file","input":{"path":"/tmp/x"}}]}`)
222312
result := normalizeAmpToolNames(input)

internal/home/client.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const (
2828
redisKeyModels = "models"
2929
redisKeyUsage = "usage"
3030
redisKeyRequestLog = "request-log"
31+
redisKeyAppLog = "app-log"
3132

3233
homeReconnectInterval = time.Second
3334
homeReconnectFailoverThreshold = 3
@@ -650,6 +651,17 @@ func (c *Client) RPushRequestLog(ctx context.Context, payload []byte) error {
650651
return cmd.RPush(ctx, redisKeyRequestLog, payload).Err()
651652
}
652653

654+
func (c *Client) RPushAppLog(ctx context.Context, payload []byte) error {
655+
cmd, errClient := c.commandClient()
656+
if errClient != nil {
657+
return errClient
658+
}
659+
if len(payload) == 0 {
660+
return nil
661+
}
662+
return cmd.RPush(ctx, redisKeyAppLog, payload).Err()
663+
}
664+
653665
func (c *Client) handleSubscriptionPayload(channel string, payload string, onConfig func([]byte) error) error {
654666
payload = strings.TrimSpace(payload)
655667
if payload == "" {

0 commit comments

Comments
 (0)