Skip to content

Commit 87b374c

Browse files
committed
Merge upstream/main into remote-fix
2 parents fcc3fa2 + da6c599 commit 87b374c

43 files changed

Lines changed: 2021 additions & 247 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,9 @@ _bmad-output/*
5555
# macOS
5656
.DS_Store
5757
._*
58+
.gocache/
5859

5960
# Opencode
60-
.beads/
61-
.opencode/
6261
.cli-proxy-api/
6362
.venv/
6463
*.bak

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ VisionCoder is also offering our users a limited-time <a href="https://coder.vis
5353
- OpenAI Codex support (GPT models) via OAuth login
5454
- Claude Code support via OAuth login
5555
- Amp CLI and IDE extensions support with provider routing
56-
- Streaming and non-streaming responses
56+
- Streaming, non-streaming, and WebSocket responses where supported
5757
- Function calling/tools support
5858
- Multimodal input support (text and images)
5959
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude)

README_CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ VisionCoder 还为我们的用户提供 <a href="https://coder.visioncoder.cn" t
5353
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
5454
- 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
5555
- 新增 Claude Code 支持(OAuth 登录)
56-
- 支持流式与非流式响应
56+
- 支持流式、非流式响应,以及受支持场景下的 WebSocket 响应
5757
- 函数调用/工具支持
5858
- 多模态输入(文本、图片)
5959
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude)

README_JA.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB
5151
- OAuthログインによるOpenAI Codexサポート(GPTモデル)
5252
- OAuthログインによるClaude Codeサポート
5353
- プロバイダールーティングによるAmp CLIおよびIDE拡張機能のサポート
54-
- ストリーミングおよび非ストリーミングレスポンス
54+
- ストリーミング、非ストリーミング、および対応環境でのWebSocketレスポンス
5555
- 関数呼び出し/ツールのサポート
5656
- マルチモーダル入力サポート(テキストと画像)
5757
- ラウンドロビン負荷分散による複数アカウント対応(Gemini、OpenAI、Claude)

internal/api/handlers/management/auth_files.go

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3142,23 +3142,10 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage
31423142
finalProjectID := projectID
31433143
if responseProjectID != "" {
31443144
if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
3145-
// Check if this is a free user (gen-lang-client projects or free/legacy tier)
3146-
isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") ||
3147-
strings.EqualFold(tierID, "FREE") ||
3148-
strings.EqualFold(tierID, "LEGACY")
3149-
3150-
if isFreeUser {
3151-
// For free users, use backend project ID for preview model access
3152-
log.Infof("Gemini onboarding: frontend project %s maps to backend project %s", projectID, responseProjectID)
3153-
log.Infof("Using backend project ID: %s (recommended for preview model access)", responseProjectID)
3154-
finalProjectID = responseProjectID
3155-
} else {
3156-
// Pro users: keep requested project ID (original behavior)
3157-
log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
3158-
}
3159-
} else {
3160-
finalProjectID = responseProjectID
3145+
log.Infof("Gemini onboarding: requested project %s maps to backend project %s", projectID, responseProjectID)
3146+
log.Infof("Using backend project ID: %s", responseProjectID)
31613147
}
3148+
finalProjectID = responseProjectID
31623149
}
31633150

31643151
storage.ProjectID = strings.TrimSpace(finalProjectID)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package management
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"net/http"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/gin-gonic/gin"
11+
"github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue"
12+
)
13+
14+
type usageQueueRecord []byte
15+
16+
func (r usageQueueRecord) MarshalJSON() ([]byte, error) {
17+
if json.Valid(r) {
18+
return append([]byte(nil), r...), nil
19+
}
20+
return json.Marshal(string(r))
21+
}
22+
23+
// GetUsageQueue pops queued usage records from the usage queue.
24+
func (h *Handler) GetUsageQueue(c *gin.Context) {
25+
if h == nil {
26+
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
27+
return
28+
}
29+
30+
count, errCount := parseUsageQueueCount(c.Query("count"))
31+
if errCount != nil {
32+
c.JSON(http.StatusBadRequest, gin.H{"error": errCount.Error()})
33+
return
34+
}
35+
36+
items := redisqueue.PopOldest(count)
37+
records := make([]usageQueueRecord, 0, len(items))
38+
for _, item := range items {
39+
records = append(records, usageQueueRecord(append([]byte(nil), item...)))
40+
}
41+
42+
c.JSON(http.StatusOK, records)
43+
}
44+
45+
func parseUsageQueueCount(value string) (int, error) {
46+
value = strings.TrimSpace(value)
47+
if value == "" {
48+
return 1, nil
49+
}
50+
count, errCount := strconv.Atoi(value)
51+
if errCount != nil || count <= 0 {
52+
return 0, errors.New("count must be a positive integer")
53+
}
54+
return count, nil
55+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package management
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/gin-gonic/gin"
10+
"github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue"
11+
)
12+
13+
func TestGetUsageQueuePopsRequestedRecords(t *testing.T) {
14+
gin.SetMode(gin.TestMode)
15+
withManagementUsageQueue(t, func() {
16+
redisqueue.Enqueue([]byte(`{"id":1}`))
17+
redisqueue.Enqueue([]byte(`{"id":2}`))
18+
redisqueue.Enqueue([]byte(`{"id":3}`))
19+
20+
rec := httptest.NewRecorder()
21+
ginCtx, _ := gin.CreateTestContext(rec)
22+
ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=2", nil)
23+
24+
h := &Handler{}
25+
h.GetUsageQueue(ginCtx)
26+
27+
if rec.Code != http.StatusOK {
28+
t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String())
29+
}
30+
31+
var payload []json.RawMessage
32+
if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &payload); errUnmarshal != nil {
33+
t.Fatalf("unmarshal response: %v", errUnmarshal)
34+
}
35+
if len(payload) != 2 {
36+
t.Fatalf("response records = %d, want 2", len(payload))
37+
}
38+
requireRecordID(t, payload[0], 1)
39+
requireRecordID(t, payload[1], 2)
40+
41+
remaining := redisqueue.PopOldest(10)
42+
if len(remaining) != 1 || string(remaining[0]) != `{"id":3}` {
43+
t.Fatalf("remaining queue = %q, want third item only", remaining)
44+
}
45+
})
46+
}
47+
48+
func TestGetUsageQueueInvalidCountDoesNotPop(t *testing.T) {
49+
gin.SetMode(gin.TestMode)
50+
withManagementUsageQueue(t, func() {
51+
redisqueue.Enqueue([]byte(`{"id":1}`))
52+
53+
rec := httptest.NewRecorder()
54+
ginCtx, _ := gin.CreateTestContext(rec)
55+
ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=0", nil)
56+
57+
h := &Handler{}
58+
h.GetUsageQueue(ginCtx)
59+
60+
if rec.Code != http.StatusBadRequest {
61+
t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
62+
}
63+
64+
remaining := redisqueue.PopOldest(10)
65+
if len(remaining) != 1 || string(remaining[0]) != `{"id":1}` {
66+
t.Fatalf("remaining queue = %q, want original item", remaining)
67+
}
68+
})
69+
}
70+
71+
func withManagementUsageQueue(t *testing.T, fn func()) {
72+
t.Helper()
73+
74+
prevQueueEnabled := redisqueue.Enabled()
75+
redisqueue.SetEnabled(false)
76+
redisqueue.SetEnabled(true)
77+
78+
defer func() {
79+
redisqueue.SetEnabled(false)
80+
redisqueue.SetEnabled(prevQueueEnabled)
81+
}()
82+
83+
fn()
84+
}
85+
86+
func requireRecordID(t *testing.T, raw json.RawMessage, want int) {
87+
t.Helper()
88+
89+
var payload struct {
90+
ID int `json:"id"`
91+
}
92+
if errUnmarshal := json.Unmarshal(raw, &payload); errUnmarshal != nil {
93+
t.Fatalf("unmarshal record: %v", errUnmarshal)
94+
}
95+
if payload.ID != want {
96+
t.Fatalf("record id = %d, want %d", payload.ID, want)
97+
}
98+
}

internal/api/modules/amp/response_rewriter.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,52 @@ func (rw *ResponseRewriter) Flush() {
131131

132132
var modelFieldPaths = []string{"message.model", "model", "modelVersion", "response.model", "response.modelVersion"}
133133

134+
// ampCanonicalToolNames maps tool names to the exact casing expected by the
135+
// Amp mode tool whitelist (case-sensitive match).
136+
var ampCanonicalToolNames = map[string]string{
137+
"bash": "Bash",
138+
"read": "Read",
139+
"grep": "Grep",
140+
"glob": "glob",
141+
"task": "Task",
142+
"check": "Check",
143+
}
144+
145+
// normalizeAmpToolNames fixes tool_use block names to match Amp's canonical casing.
146+
// Some upstream models return lowercase tool names (e.g. "bash" instead of "Bash")
147+
// which causes Amp's case-sensitive mode whitelist to reject them.
148+
func normalizeAmpToolNames(data []byte) []byte {
149+
// Non-streaming: content[].name in tool_use blocks
150+
for index, block := range gjson.GetBytes(data, "content").Array() {
151+
if block.Get("type").String() != "tool_use" {
152+
continue
153+
}
154+
name := block.Get("name").String()
155+
if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical {
156+
path := fmt.Sprintf("content.%d.name", index)
157+
var err error
158+
data, err = sjson.SetBytes(data, path, canonical)
159+
if err != nil {
160+
log.Warnf("Amp ResponseRewriter: failed to normalize tool name %q to %q: %v", name, canonical, err)
161+
}
162+
}
163+
}
164+
165+
// Streaming: content_block.name in content_block_start events
166+
if gjson.GetBytes(data, "content_block.type").String() == "tool_use" {
167+
name := gjson.GetBytes(data, "content_block.name").String()
168+
if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical {
169+
var err error
170+
data, err = sjson.SetBytes(data, "content_block.name", canonical)
171+
if err != nil {
172+
log.Warnf("Amp ResponseRewriter: failed to normalize streaming tool name %q to %q: %v", name, canonical, err)
173+
}
174+
}
175+
}
176+
177+
return data
178+
}
179+
134180
// ensureAmpSignature injects empty signature fields into tool_use/thinking blocks
135181
// in API responses so that the Amp TUI does not crash on P.signature.length.
136182
func ensureAmpSignature(data []byte) []byte {
@@ -187,6 +233,7 @@ func (rw *ResponseRewriter) suppressAmpThinking(data []byte) []byte {
187233

188234
func (rw *ResponseRewriter) rewriteModelInResponse(data []byte) []byte {
189235
data = ensureAmpSignature(data)
236+
data = normalizeAmpToolNames(data)
190237
data = rw.suppressAmpThinking(data)
191238
if len(data) == 0 {
192239
return data
@@ -286,6 +333,9 @@ func (rw *ResponseRewriter) rewriteStreamEvent(data []byte) []byte {
286333
// Inject empty signature where needed
287334
data = ensureAmpSignature(data)
288335

336+
// Normalize tool names to canonical casing
337+
data = normalizeAmpToolNames(data)
338+
289339
// Rewrite model name
290340
if rw.originalModel != "" {
291341
for _, path := range modelFieldPaths {

internal/api/modules/amp/response_rewriter_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,57 @@ func TestSanitizeAmpRequestBody_MixedInvalidThinkingAndToolUseSignature(t *testi
175175
}
176176
}
177177

178+
func TestNormalizeAmpToolNames_NonStreaming(t *testing.T) {
179+
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"bash","input":{"cmd":"ls"}},{"type":"tool_use","id":"toolu_02","name":"read","input":{"path":"/tmp"}},{"type":"text","text":"hello"}]}`)
180+
result := normalizeAmpToolNames(input)
181+
182+
if !contains(result, []byte(`"name":"Bash"`)) {
183+
t.Errorf("expected bash->Bash, got %s", string(result))
184+
}
185+
if !contains(result, []byte(`"name":"Read"`)) {
186+
t.Errorf("expected read->Read, got %s", string(result))
187+
}
188+
if contains(result, []byte(`"name":"bash"`)) {
189+
t.Errorf("expected lowercase bash to be replaced, got %s", string(result))
190+
}
191+
}
192+
193+
func TestNormalizeAmpToolNames_Streaming(t *testing.T) {
194+
input := []byte(`{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","name":"grep","id":"toolu_01","input":{}}}`)
195+
result := normalizeAmpToolNames(input)
196+
197+
if !contains(result, []byte(`"name":"Grep"`)) {
198+
t.Errorf("expected grep->Grep in streaming, got %s", string(result))
199+
}
200+
}
201+
202+
func TestNormalizeAmpToolNames_AlreadyCorrect(t *testing.T) {
203+
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`)
204+
result := normalizeAmpToolNames(input)
205+
206+
if string(result) != string(input) {
207+
t.Errorf("expected no modification for correctly-cased tool, got %s", string(result))
208+
}
209+
}
210+
211+
func TestNormalizeAmpToolNames_GlobPreserved(t *testing.T) {
212+
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`)
213+
result := normalizeAmpToolNames(input)
214+
215+
if string(result) != string(input) {
216+
t.Errorf("expected glob to remain lowercase, got %s", string(result))
217+
}
218+
}
219+
220+
func TestNormalizeAmpToolNames_UnknownToolUntouched(t *testing.T) {
221+
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"edit_file","input":{"path":"/tmp/x"}}]}`)
222+
result := normalizeAmpToolNames(input)
223+
224+
if string(result) != string(input) {
225+
t.Errorf("expected no modification for unknown tool, got %s", string(result))
226+
}
227+
}
228+
178229
func contains(data, substr []byte) bool {
179230
for i := 0; i <= len(data)-len(substr); i++ {
180231
if string(data[i:i+len(substr)]) == string(substr) {

internal/api/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ func (s *Server) registerManagementRoutes() {
593593
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
594594
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
595595
mgmt.GET("/api-key-usage", s.mgmt.GetAPIKeyUsage)
596+
mgmt.GET("/usage-queue", s.mgmt.GetUsageQueue)
596597

597598
mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys)
598599
mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys)

0 commit comments

Comments
 (0)