Skip to content

Commit 36228c2

Browse files
Merge branch 'router-for-me:main' into main
2 parents 7daa13c + 26fc611 commit 36228c2

24 files changed

Lines changed: 1258 additions & 229 deletions

internal/api/handlers/management/auth_files.go

Lines changed: 66 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -192,17 +192,6 @@ func startCallbackForwarder(port int, provider, targetBase string) (*callbackFor
192192
return forwarder, nil
193193
}
194194

195-
func stopCallbackForwarder(port int) {
196-
callbackForwardersMu.Lock()
197-
forwarder := callbackForwarders[port]
198-
if forwarder != nil {
199-
delete(callbackForwarders, port)
200-
}
201-
callbackForwardersMu.Unlock()
202-
203-
stopForwarderInstance(port, forwarder)
204-
}
205-
206195
func stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) {
207196
if forwarder == nil {
208197
return
@@ -644,28 +633,66 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
644633
c.JSON(400, gin.H{"error": "invalid name"})
645634
return
646635
}
647-
full := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
648-
if !filepath.IsAbs(full) {
649-
if abs, errAbs := filepath.Abs(full); errAbs == nil {
650-
full = abs
636+
637+
targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
638+
targetID := ""
639+
if targetAuth := h.findAuthForDelete(name); targetAuth != nil {
640+
targetID = strings.TrimSpace(targetAuth.ID)
641+
if path := strings.TrimSpace(authAttribute(targetAuth, "path")); path != "" {
642+
targetPath = path
643+
}
644+
}
645+
if !filepath.IsAbs(targetPath) {
646+
if abs, errAbs := filepath.Abs(targetPath); errAbs == nil {
647+
targetPath = abs
651648
}
652649
}
653-
if err := os.Remove(full); err != nil {
654-
if os.IsNotExist(err) {
650+
if errRemove := os.Remove(targetPath); errRemove != nil {
651+
if os.IsNotExist(errRemove) {
655652
c.JSON(404, gin.H{"error": "file not found"})
656653
} else {
657-
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)})
654+
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", errRemove)})
658655
}
659656
return
660657
}
661-
if err := h.deleteTokenRecord(ctx, full); err != nil {
662-
c.JSON(500, gin.H{"error": err.Error()})
658+
if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil {
659+
c.JSON(500, gin.H{"error": errDeleteRecord.Error()})
663660
return
664661
}
665-
h.disableAuth(ctx, full)
662+
if targetID != "" {
663+
h.disableAuth(ctx, targetID)
664+
} else {
665+
h.disableAuth(ctx, targetPath)
666+
}
666667
c.JSON(200, gin.H{"status": "ok"})
667668
}
668669

670+
func (h *Handler) findAuthForDelete(name string) *coreauth.Auth {
671+
if h == nil || h.authManager == nil {
672+
return nil
673+
}
674+
name = strings.TrimSpace(name)
675+
if name == "" {
676+
return nil
677+
}
678+
if auth, ok := h.authManager.GetByID(name); ok {
679+
return auth
680+
}
681+
auths := h.authManager.List()
682+
for _, auth := range auths {
683+
if auth == nil {
684+
continue
685+
}
686+
if strings.TrimSpace(auth.FileName) == name {
687+
return auth
688+
}
689+
if filepath.Base(strings.TrimSpace(authAttribute(auth, "path"))) == name {
690+
return auth
691+
}
692+
}
693+
return nil
694+
}
695+
669696
func (h *Handler) authIDForPath(path string) string {
670697
path = strings.TrimSpace(path)
671698
if path == "" {
@@ -899,10 +926,19 @@ func (h *Handler) disableAuth(ctx context.Context, id string) {
899926
if h == nil || h.authManager == nil {
900927
return
901928
}
902-
authID := h.authIDForPath(id)
903-
if authID == "" {
904-
authID = strings.TrimSpace(id)
929+
id = strings.TrimSpace(id)
930+
if id == "" {
931+
return
905932
}
933+
if auth, ok := h.authManager.GetByID(id); ok {
934+
auth.Disabled = true
935+
auth.Status = coreauth.StatusDisabled
936+
auth.StatusMessage = "removed via management API"
937+
auth.UpdatedAt = time.Now()
938+
_, _ = h.authManager.Update(ctx, auth)
939+
return
940+
}
941+
authID := h.authIDForPath(id)
906942
if authID == "" {
907943
return
908944
}
@@ -2549,6 +2585,7 @@ func PopulateAuthContext(ctx context.Context, c *gin.Context) context.Context {
25492585
}
25502586
return coreauth.WithRequestInfo(ctx, info)
25512587
}
2588+
25522589
const kiroCallbackPort = 9876
25532590

25542591
func (h *Handler) RequestKiroToken(c *gin.Context) {
@@ -2685,14 +2722,16 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
26852722
}
26862723

26872724
isWebUI := isWebUIRequest(c)
2725+
var forwarder *callbackForwarder
26882726
if isWebUI {
26892727
targetURL, errTarget := h.managementCallbackURL("/kiro/callback")
26902728
if errTarget != nil {
26912729
log.WithError(errTarget).Error("failed to compute kiro callback target")
26922730
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
26932731
return
26942732
}
2695-
if _, errStart := startCallbackForwarder(kiroCallbackPort, "kiro", targetURL); errStart != nil {
2733+
var errStart error
2734+
if forwarder, errStart = startCallbackForwarder(kiroCallbackPort, "kiro", targetURL); errStart != nil {
26962735
log.WithError(errStart).Error("failed to start kiro callback forwarder")
26972736
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
26982737
return
@@ -2701,7 +2740,7 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
27012740

27022741
go func() {
27032742
if isWebUI {
2704-
defer stopCallbackForwarder(kiroCallbackPort)
2743+
defer stopCallbackForwarderInstance(kiroCallbackPort, forwarder)
27052744
}
27062745

27072746
socialClient := kiroauth.NewSocialAuthClient(h.cfg)
@@ -2904,7 +2943,7 @@ func (h *Handler) RequestKiloToken(c *gin.Context) {
29042943
Metadata: map[string]any{
29052944
"email": status.UserEmail,
29062945
"organization_id": orgID,
2907-
"model": defaults.Model,
2946+
"model": defaults.Model,
29082947
},
29092948
}
29102949

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package management
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"os"
10+
"path/filepath"
11+
"testing"
12+
13+
"github.com/gin-gonic/gin"
14+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
15+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
16+
)
17+
18+
func TestDeleteAuthFile_UsesAuthPathFromManager(t *testing.T) {
19+
t.Setenv("MANAGEMENT_PASSWORD", "")
20+
gin.SetMode(gin.TestMode)
21+
22+
tempDir := t.TempDir()
23+
authDir := filepath.Join(tempDir, "auth")
24+
externalDir := filepath.Join(tempDir, "external")
25+
if errMkdirAuth := os.MkdirAll(authDir, 0o700); errMkdirAuth != nil {
26+
t.Fatalf("failed to create auth dir: %v", errMkdirAuth)
27+
}
28+
if errMkdirExternal := os.MkdirAll(externalDir, 0o700); errMkdirExternal != nil {
29+
t.Fatalf("failed to create external dir: %v", errMkdirExternal)
30+
}
31+
32+
fileName := "codex-user@example.com-plus.json"
33+
shadowPath := filepath.Join(authDir, fileName)
34+
realPath := filepath.Join(externalDir, fileName)
35+
if errWriteShadow := os.WriteFile(shadowPath, []byte(`{"type":"codex","email":"shadow@example.com"}`), 0o600); errWriteShadow != nil {
36+
t.Fatalf("failed to write shadow file: %v", errWriteShadow)
37+
}
38+
if errWriteReal := os.WriteFile(realPath, []byte(`{"type":"codex","email":"real@example.com"}`), 0o600); errWriteReal != nil {
39+
t.Fatalf("failed to write real file: %v", errWriteReal)
40+
}
41+
42+
manager := coreauth.NewManager(nil, nil, nil)
43+
record := &coreauth.Auth{
44+
ID: "legacy/" + fileName,
45+
FileName: fileName,
46+
Provider: "codex",
47+
Status: coreauth.StatusError,
48+
Unavailable: true,
49+
Attributes: map[string]string{
50+
"path": realPath,
51+
},
52+
Metadata: map[string]any{
53+
"type": "codex",
54+
"email": "real@example.com",
55+
},
56+
}
57+
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
58+
t.Fatalf("failed to register auth record: %v", errRegister)
59+
}
60+
61+
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
62+
h.tokenStore = &memoryAuthStore{}
63+
64+
deleteRec := httptest.NewRecorder()
65+
deleteCtx, _ := gin.CreateTestContext(deleteRec)
66+
deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
67+
deleteCtx.Request = deleteReq
68+
h.DeleteAuthFile(deleteCtx)
69+
70+
if deleteRec.Code != http.StatusOK {
71+
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
72+
}
73+
if _, errStatReal := os.Stat(realPath); !os.IsNotExist(errStatReal) {
74+
t.Fatalf("expected managed auth file to be removed, stat err: %v", errStatReal)
75+
}
76+
if _, errStatShadow := os.Stat(shadowPath); errStatShadow != nil {
77+
t.Fatalf("expected shadow auth file to remain, stat err: %v", errStatShadow)
78+
}
79+
80+
listRec := httptest.NewRecorder()
81+
listCtx, _ := gin.CreateTestContext(listRec)
82+
listReq := httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil)
83+
listCtx.Request = listReq
84+
h.ListAuthFiles(listCtx)
85+
86+
if listRec.Code != http.StatusOK {
87+
t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, listRec.Code, listRec.Body.String())
88+
}
89+
var listPayload map[string]any
90+
if errUnmarshal := json.Unmarshal(listRec.Body.Bytes(), &listPayload); errUnmarshal != nil {
91+
t.Fatalf("failed to decode list payload: %v", errUnmarshal)
92+
}
93+
filesRaw, ok := listPayload["files"].([]any)
94+
if !ok {
95+
t.Fatalf("expected files array, payload: %#v", listPayload)
96+
}
97+
if len(filesRaw) != 0 {
98+
t.Fatalf("expected removed auth to be hidden from list, got %d entries", len(filesRaw))
99+
}
100+
}
101+
102+
func TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) {
103+
t.Setenv("MANAGEMENT_PASSWORD", "")
104+
gin.SetMode(gin.TestMode)
105+
106+
authDir := t.TempDir()
107+
fileName := "fallback-user.json"
108+
filePath := filepath.Join(authDir, fileName)
109+
if errWrite := os.WriteFile(filePath, []byte(`{"type":"codex"}`), 0o600); errWrite != nil {
110+
t.Fatalf("failed to write auth file: %v", errWrite)
111+
}
112+
113+
manager := coreauth.NewManager(nil, nil, nil)
114+
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
115+
h.tokenStore = &memoryAuthStore{}
116+
117+
deleteRec := httptest.NewRecorder()
118+
deleteCtx, _ := gin.CreateTestContext(deleteRec)
119+
deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
120+
deleteCtx.Request = deleteReq
121+
h.DeleteAuthFile(deleteCtx)
122+
123+
if deleteRec.Code != http.StatusOK {
124+
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
125+
}
126+
if _, errStat := os.Stat(filePath); !os.IsNotExist(errStat) {
127+
t.Fatalf("expected auth file to be removed from auth dir, stat err: %v", errStat)
128+
}
129+
}

internal/registry/model_definitions_static_data.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func GetClaudeModels() []*ModelInfo {
3737
DisplayName: "Claude 4.6 Sonnet",
3838
ContextLength: 200000,
3939
MaxCompletionTokens: 64000,
40-
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
40+
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high"}},
4141
},
4242
{
4343
ID: "claude-opus-4-6",
@@ -49,7 +49,7 @@ func GetClaudeModels() []*ModelInfo {
4949
Description: "Premium model combining maximum intelligence with practical performance",
5050
ContextLength: 1000000,
5151
MaxCompletionTokens: 128000,
52-
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
52+
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high", "max"}},
5353
},
5454
{
5555
ID: "claude-sonnet-4-6",

internal/runtime/executor/claude_executor.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,12 @@ func disableThinkingIfToolChoiceForced(body []byte) []byte {
634634
if toolChoiceType == "any" || toolChoiceType == "tool" {
635635
// Remove thinking configuration entirely to avoid API error
636636
body, _ = sjson.DeleteBytes(body, "thinking")
637+
// Adaptive thinking may also set output_config.effort; remove it to avoid
638+
// leaking thinking controls when tool_choice forces tool use.
639+
body, _ = sjson.DeleteBytes(body, "output_config.effort")
640+
if oc := gjson.GetBytes(body, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
641+
body, _ = sjson.DeleteBytes(body, "output_config")
642+
}
637643
}
638644
return body
639645
}

internal/runtime/executor/github_copilot_executor.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -490,18 +490,46 @@ func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string, b
490490
r.Header.Set("X-Request-Id", uuid.NewString())
491491

492492
initiator := "user"
493-
if len(body) > 0 {
494-
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
495-
for _, msg := range messages.Array() {
496-
role := msg.Get("role").String()
497-
if role == "assistant" || role == "tool" {
498-
initiator = "agent"
499-
break
500-
}
493+
if role := detectLastConversationRole(body); role == "assistant" || role == "tool" {
494+
initiator = "agent"
495+
}
496+
r.Header.Set("X-Initiator", initiator)
497+
}
498+
499+
func detectLastConversationRole(body []byte) string {
500+
if len(body) == 0 {
501+
return ""
502+
}
503+
504+
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
505+
arr := messages.Array()
506+
for i := len(arr) - 1; i >= 0; i-- {
507+
if role := arr[i].Get("role").String(); role != "" {
508+
return role
501509
}
502510
}
503511
}
504-
r.Header.Set("X-Initiator", initiator)
512+
513+
if inputs := gjson.GetBytes(body, "input"); inputs.Exists() && inputs.IsArray() {
514+
arr := inputs.Array()
515+
for i := len(arr) - 1; i >= 0; i-- {
516+
item := arr[i]
517+
518+
// Most Responses input items carry a top-level role.
519+
if role := item.Get("role").String(); role != "" {
520+
return role
521+
}
522+
523+
switch item.Get("type").String() {
524+
case "function_call", "function_call_arguments":
525+
return "assistant"
526+
case "function_call_output", "function_call_response", "tool_result":
527+
return "tool"
528+
}
529+
}
530+
}
531+
532+
return ""
505533
}
506534

507535
// detectVisionContent checks if the request body contains vision/image content.

0 commit comments

Comments
 (0)