diff --git a/docker-compose.yml b/docker-compose.yml index 3e6b605..e5e2ff1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ -services: - ds2api: - image: crpi-cnazxqmg4avmg4fq.cn-beijing.personal.cr.aliyuncs.com/ronghuaxueleng/ds2api:latest +services: + ds2api: + image: ghcr.io/cjackhwang/ds2api:latest container_name: ds2api restart: always ports: diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index 895605f..4dcadb9 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -3,6 +3,7 @@ package openai import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -315,6 +316,36 @@ func TestHandleStreamToolCallInterceptsWithoutRawContentLeak(t *testing.T) { } } +func TestHandleStreamToolCallLargeArgumentsStillIntercepted(t *testing.T) { + h := &Handler{} + large := strings.Repeat("a", 9000) + payload := fmt.Sprintf(`{"tool_calls":[{"name":"search","input":{"q":"%s"}}]}`, large) + splitAt := len(payload) / 2 + resp := makeSSEHTTPResponse( + fmt.Sprintf(`data: {"p":"response/content","v":%q}`, payload[:splitAt]), + fmt.Sprintf(`data: {"p":"response/content","v":%q}`, payload[splitAt:]), + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + h.handleStream(rec, req, resp, "cid3-large", "deepseek-chat", "prompt", false, false, []string{"search"}) + + frames, done := parseSSEDataFrames(t, rec.Body.String()) + if !done { + t.Fatalf("expected [DONE], body=%s", rec.Body.String()) + } + if !streamHasToolCallsDelta(frames) { + t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String()) + } + if streamHasRawToolJSONContent(frames) { + t.Fatalf("raw tool_calls JSON leaked in content delta: %s", rec.Body.String()) + } + if streamFinishReason(frames) != "tool_calls" { + t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String()) + } +} + func TestHandleStreamReasonerToolCallInterceptsWithoutRawContentLeak(t *testing.T) { h := &Handler{} resp := makeSSEHTTPResponse( diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index 5ed9b90..92ae8bb 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -26,15 +26,6 @@ func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames } prefix, calls, suffix, ready := consumeToolCapture(state, toolNames) if !ready { - if state.capture.Len() > toolSieveCaptureLimit { - content := state.capture.String() - state.capture.Reset() - state.capturing = false - state.resetIncrementalToolState() - state.noteText(content) - events = append(events, toolStreamEvent{Content: content}) - continue - } break } state.capture.Reset() diff --git a/internal/adapter/openai/tool_sieve_state.go b/internal/adapter/openai/tool_sieve_state.go index 04699e6..0b107b2 100644 --- a/internal/adapter/openai/tool_sieve_state.go +++ b/internal/adapter/openai/tool_sieve_state.go @@ -32,7 +32,6 @@ type toolCallDelta struct { Arguments string } -const toolSieveCaptureLimit = 8 * 1024 const toolSieveContextTailLimit = 256 func (s *toolStreamSieveState) resetIncrementalToolState() { diff --git a/internal/admin/handler_accounts_crud.go b/internal/admin/handler_accounts_crud.go index 768e59e..6536760 100644 --- a/internal/admin/handler_accounts_crud.go +++ b/internal/admin/handler_accounts_crud.go @@ -1,128 +1,133 @@ -package admin - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - - "github.com/go-chi/chi/v5" - - "ds2api/internal/config" -) - -func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) { - page := intFromQuery(r, "page", 1) - pageSize := intFromQuery(r, "page_size", 10) - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 1 - } - if pageSize > 100 { - pageSize = 100 - } - accounts := h.Store.Snapshot().Accounts - reverseAccounts(accounts) - q := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q"))) - if q != "" { - filtered := make([]config.Account, 0, len(accounts)) - for _, acc := range accounts { - id := strings.ToLower(acc.Identifier()) - if strings.Contains(id, q) || - strings.Contains(strings.ToLower(acc.Email), q) || - strings.Contains(strings.ToLower(acc.Mobile), q) { - filtered = append(filtered, acc) - } - } - accounts = filtered - } - total := len(accounts) - totalPages := 1 - if total > 0 { - totalPages = (total + pageSize - 1) / pageSize - } - start := (page - 1) * pageSize - if start > total { - start = total - } - end := start + pageSize - if end > total { - end = total - } - items := make([]map[string]any, 0, end-start) - for _, acc := range accounts[start:end] { - token := strings.TrimSpace(acc.Token) - preview := "" - if token != "" { - if len(token) > 20 { - preview = token[:20] + "..." - } else { - preview = token - } - } - items = append(items, map[string]any{ - "identifier": acc.Identifier(), - "email": acc.Email, - "mobile": acc.Mobile, - "has_password": acc.Password != "", - "has_token": token != "", - "token_preview": preview, - "test_status": acc.TestStatus, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages}) -} - -func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) { - var req map[string]any - _ = json.NewDecoder(r.Body).Decode(&req) - acc := toAccount(req) - if acc.Identifier() == "" { - writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要 email 或 mobile"}) - return - } - err := h.Store.Update(func(c *config.Config) error { - for _, a := range c.Accounts { - if acc.Email != "" && a.Email == acc.Email { - return fmt.Errorf("邮箱已存在") - } - if acc.Mobile != "" && a.Mobile == acc.Mobile { - return fmt.Errorf("手机号已存在") - } - } - c.Accounts = append(c.Accounts, acc) - return nil - }) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) - return - } - h.Pool.Reset() - writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)}) -} - -func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) { - identifier := chi.URLParam(r, "identifier") - err := h.Store.Update(func(c *config.Config) error { - idx := -1 - for i, a := range c.Accounts { - if accountMatchesIdentifier(a, identifier) { - idx = i - break - } - } - if idx < 0 { - return fmt.Errorf("账号不存在") - } - c.Accounts = append(c.Accounts[:idx], c.Accounts[idx+1:]...) - return nil - }) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()}) - return - } - h.Pool.Reset() - writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)}) -} +package admin + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/config" +) + +func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) { + page := intFromQuery(r, "page", 1) + pageSize := intFromQuery(r, "page_size", 10) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 1 + } + if pageSize > 100 { + pageSize = 100 + } + accounts := h.Store.Snapshot().Accounts + reverseAccounts(accounts) + q := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q"))) + if q != "" { + filtered := make([]config.Account, 0, len(accounts)) + for _, acc := range accounts { + id := strings.ToLower(acc.Identifier()) + if strings.Contains(id, q) || + strings.Contains(strings.ToLower(acc.Email), q) || + strings.Contains(strings.ToLower(acc.Mobile), q) { + filtered = append(filtered, acc) + } + } + accounts = filtered + } + total := len(accounts) + totalPages := 1 + if total > 0 { + totalPages = (total + pageSize - 1) / pageSize + } + start := (page - 1) * pageSize + if start > total { + start = total + } + end := start + pageSize + if end > total { + end = total + } + items := make([]map[string]any, 0, end-start) + for _, acc := range accounts[start:end] { + token := strings.TrimSpace(acc.Token) + preview := "" + if token != "" { + if len(token) > 20 { + preview = token[:20] + "..." + } else { + preview = token + } + } + items = append(items, map[string]any{ + "identifier": acc.Identifier(), + "email": acc.Email, + "mobile": acc.Mobile, + "has_password": acc.Password != "", + "has_token": token != "", + "token_preview": preview, + "test_status": acc.TestStatus, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages}) +} + +func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) { + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + acc := toAccount(req) + if acc.Identifier() == "" { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要 email 或 mobile"}) + return + } + err := h.Store.Update(func(c *config.Config) error { + mobileKey := config.CanonicalMobileKey(acc.Mobile) + for _, a := range c.Accounts { + if acc.Email != "" && a.Email == acc.Email { + return fmt.Errorf("邮箱已存在") + } + if mobileKey != "" && config.CanonicalMobileKey(a.Mobile) == mobileKey { + return fmt.Errorf("手机号已存在") + } + } + c.Accounts = append(c.Accounts, acc) + return nil + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) + return + } + h.Pool.Reset() + writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)}) +} + +func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) { + identifier := chi.URLParam(r, "identifier") + if decoded, err := url.PathUnescape(identifier); err == nil { + identifier = decoded + } + err := h.Store.Update(func(c *config.Config) error { + idx := -1 + for i, a := range c.Accounts { + if accountMatchesIdentifier(a, identifier) { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf("账号不存在") + } + c.Accounts = append(c.Accounts[:idx], c.Accounts[idx+1:]...) + return nil + }) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()}) + return + } + h.Pool.Reset() + writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)}) +} diff --git a/internal/admin/handler_accounts_identifier_test.go b/internal/admin/handler_accounts_identifier_test.go index 591d43a..b6f63ca 100644 --- a/internal/admin/handler_accounts_identifier_test.go +++ b/internal/admin/handler_accounts_identifier_test.go @@ -1,6 +1,7 @@ package admin import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -102,6 +103,45 @@ func TestDeleteAccountSupportsMobileAlias(t *testing.T) { } } +func TestDeleteAccountSupportsEncodedPlusMobile(t *testing.T) { + h := newAdminTestHandler(t, `{ + "accounts":[{"mobile":"+8613800138000","password":"pwd"}] + }`) + + r := chi.NewRouter() + r.Delete("/admin/accounts/{identifier}", h.deleteAccount) + req := httptest.NewRequest(http.MethodDelete, "/admin/accounts/"+url.PathEscape("+8613800138000"), nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } + if got := len(h.Store.Accounts()); got != 0 { + t.Fatalf("expected account removed, remaining=%d", got) + } +} + +func TestAddAccountRejectsCanonicalMobileDuplicate(t *testing.T) { + h := newAdminTestHandler(t, `{ + "accounts":[{"mobile":"+8613800138000","password":"pwd"}] + }`) + + r := chi.NewRouter() + r.Post("/admin/accounts", h.addAccount) + body := []byte(`{"mobile":"13800138000","password":"pwd2"}`) + req := httptest.NewRequest(http.MethodPost, "/admin/accounts", bytes.NewReader(body)) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } + if got := len(h.Store.Accounts()); got != 1 { + t.Fatalf("expected no duplicate insert, got=%d", got) + } +} + func TestFindAccountByIdentifierSupportsMobileAndTokenOnly(t *testing.T) { h := newAdminTestHandler(t, `{ "accounts":[ @@ -117,6 +157,13 @@ func TestFindAccountByIdentifierSupportsMobileAndTokenOnly(t *testing.T) { if accByMobile.Email != "u@example.com" { t.Fatalf("unexpected account by mobile: %#v", accByMobile) } + accByMobileWithCountryCode, ok := findAccountByIdentifier(h.Store, "+8613800138000") + if !ok { + t.Fatal("expected find by +86 mobile") + } + if accByMobileWithCountryCode.Email != "u@example.com" { + t.Fatalf("unexpected account by +86 mobile: %#v", accByMobileWithCountryCode) + } tokenOnlyID := "" for _, acc := range h.Store.Accounts() { diff --git a/internal/admin/handler_config_import.go b/internal/admin/handler_config_import.go index 674d8b2..2b88d45 100644 --- a/internal/admin/handler_config_import.go +++ b/internal/admin/handler_config_import.go @@ -49,6 +49,7 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { next := c.Clone() if mode == "replace" { next = incoming.Clone() + next.Accounts = normalizeAndDedupeAccounts(next.Accounts) next.VercelSyncHash = c.VercelSyncHash next.VercelSyncTime = c.VercelSyncTime importedKeys = len(next.Keys) @@ -73,17 +74,22 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { existingAccounts := map[string]struct{}{} for _, acc := range next.Accounts { - existingAccounts[acc.Identifier()] = struct{}{} + acc = normalizeAccountForStorage(acc) + key := accountDedupeKey(acc) + if key != "" { + existingAccounts[key] = struct{}{} + } } for _, acc := range incoming.Accounts { - id := acc.Identifier() - if id == "" { + acc = normalizeAccountForStorage(acc) + key := accountDedupeKey(acc) + if key == "" { continue } - if _, ok := existingAccounts[id]; ok { + if _, ok := existingAccounts[key]; ok { continue } - existingAccounts[id] = struct{}{} + existingAccounts[key] = struct{}{} next.Accounts = append(next.Accounts, acc) importedAccounts++ } diff --git a/internal/admin/handler_config_write.go b/internal/admin/handler_config_write.go index 792e696..e09edfe 100644 --- a/internal/admin/handler_config_write.go +++ b/internal/admin/handler_config_write.go @@ -25,17 +25,28 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { if accountsRaw, ok := req["accounts"].([]any); ok { existing := map[string]config.Account{} for _, a := range old.Accounts { - existing[a.Identifier()] = a + a = normalizeAccountForStorage(a) + key := accountDedupeKey(a) + if key != "" { + existing[key] = a + } } + seen := map[string]struct{}{} accounts := make([]config.Account, 0, len(accountsRaw)) for _, item := range accountsRaw { m, ok := item.(map[string]any) if !ok { continue } - acc := toAccount(m) - id := acc.Identifier() - if prev, ok := existing[id]; ok { + acc := normalizeAccountForStorage(toAccount(m)) + key := accountDedupeKey(acc) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + if prev, ok := existing[key]; ok { if strings.TrimSpace(acc.Password) == "" { acc.Password = prev.Password } @@ -43,6 +54,7 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { acc.Token = prev.Token } } + seen[key] = struct{}{} accounts = append(accounts, acc) } c.Accounts = accounts @@ -138,20 +150,24 @@ func (h *Handler) batchImport(w http.ResponseWriter, r *http.Request) { if accounts, ok := req["accounts"].([]any); ok { existing := map[string]bool{} for _, a := range c.Accounts { - existing[a.Identifier()] = true + a = normalizeAccountForStorage(a) + key := accountDedupeKey(a) + if key != "" { + existing[key] = true + } } for _, item := range accounts { m, ok := item.(map[string]any) if !ok { continue } - acc := toAccount(m) - id := acc.Identifier() - if id == "" || existing[id] { + acc := normalizeAccountForStorage(toAccount(m)) + key := accountDedupeKey(acc) + if key == "" || existing[key] { continue } c.Accounts = append(c.Accounts, acc) - existing[id] = true + existing[key] = true importedAccounts++ } } diff --git a/internal/admin/handler_settings_test.go b/internal/admin/handler_settings_test.go index 3eb5114..2a606fb 100644 --- a/internal/admin/handler_settings_test.go +++ b/internal/admin/handler_settings_test.go @@ -265,3 +265,57 @@ func TestConfigImportRejectsMergedRuntimeConflict(t *testing.T) { t.Fatalf("runtime should remain unchanged, runtime=%+v", snap.Runtime) } } + +func TestConfigImportMergeDedupesMobileAliases(t *testing.T) { + h := newAdminTestHandler(t, `{ + "keys":["k1"], + "accounts":[{"mobile":"+8613800138000","password":"p1"}] + }`) + + merge := map[string]any{ + "mode": "merge", + "config": map[string]any{ + "accounts": []any{ + map[string]any{"mobile": "13800138000", "password": "p2"}, + }, + }, + } + b, _ := json.Marshal(merge) + req := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=merge", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.configImport(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if got := len(h.Store.Accounts()); got != 1 { + t.Fatalf("expected merge dedupe by canonical mobile, got=%d", got) + } +} + +func TestUpdateConfigDedupesMobileAliases(t *testing.T) { + h := newAdminTestHandler(t, `{ + "keys":["k1"], + "accounts":[{"mobile":"+8613800138000","password":"old"}] + }`) + + reqBody := map[string]any{ + "accounts": []any{ + map[string]any{"mobile": "+8613800138000"}, + map[string]any{"mobile": "13800138000"}, + }, + } + b, _ := json.Marshal(reqBody) + req := httptest.NewRequest(http.MethodPost, "/admin/config", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + accounts := h.Store.Accounts() + if len(accounts) != 1 { + t.Fatalf("expected update dedupe by canonical mobile, got=%d", len(accounts)) + } + if accounts[0].Identifier() != "+8613800138000" { + t.Fatalf("unexpected identifier: %q", accounts[0].Identifier()) + } +} diff --git a/internal/admin/helpers.go b/internal/admin/helpers.go index 2e00323..af27676 100644 --- a/internal/admin/helpers.go +++ b/internal/admin/helpers.go @@ -59,9 +59,11 @@ func toStringSlice(v any) ([]string, bool) { } func toAccount(m map[string]any) config.Account { + email := fieldString(m, "email") + mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile")) return config.Account{ - Email: fieldString(m, "email"), - Mobile: fieldString(m, "mobile"), + Email: email, + Mobile: mobile, Password: fieldString(m, "password"), Token: fieldString(m, "token"), } @@ -90,12 +92,52 @@ func accountMatchesIdentifier(acc config.Account, identifier string) bool { if strings.TrimSpace(acc.Email) == id { return true } - if strings.TrimSpace(acc.Mobile) == id { + if mobileKey := config.CanonicalMobileKey(id); mobileKey != "" && mobileKey == config.CanonicalMobileKey(acc.Mobile) { return true } return acc.Identifier() == id } +func normalizeAccountForStorage(acc config.Account) config.Account { + acc.Email = strings.TrimSpace(acc.Email) + acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile) + return acc +} + +func accountDedupeKey(acc config.Account) string { + if email := strings.TrimSpace(acc.Email); email != "" { + return "email:" + email + } + if mobile := config.CanonicalMobileKey(acc.Mobile); mobile != "" { + return "mobile:" + mobile + } + if id := strings.TrimSpace(acc.Identifier()); id != "" { + return "id:" + id + } + return "" +} + +func normalizeAndDedupeAccounts(accounts []config.Account) []config.Account { + if len(accounts) == 0 { + return nil + } + out := make([]config.Account, 0, len(accounts)) + seen := make(map[string]struct{}, len(accounts)) + for _, acc := range accounts { + acc = normalizeAccountForStorage(acc) + key := accountDedupeKey(acc) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, acc) + } + return out +} + func findAccountByIdentifier(store ConfigStore, identifier string) (config.Account, bool) { id := strings.TrimSpace(identifier) if id == "" { diff --git a/internal/admin/helpers_edge_test.go b/internal/admin/helpers_edge_test.go index 2a0bf20..0b2a0ab 100644 --- a/internal/admin/helpers_edge_test.go +++ b/internal/admin/helpers_edge_test.go @@ -182,7 +182,7 @@ func TestToAccountAllFields(t *testing.T) { if acc.Email != "user@test.com" { t.Fatalf("unexpected email: %q", acc.Email) } - if acc.Mobile != "13800138000" { + if acc.Mobile != "+8613800138000" { t.Fatalf("unexpected mobile: %q", acc.Mobile) } if acc.Password != "secret" { diff --git a/internal/config/account.go b/internal/config/account.go index 29a4947..3d6fa7d 100644 --- a/internal/config/account.go +++ b/internal/config/account.go @@ -10,8 +10,8 @@ func (a Account) Identifier() string { if strings.TrimSpace(a.Email) != "" { return strings.TrimSpace(a.Email) } - if strings.TrimSpace(a.Mobile) != "" { - return strings.TrimSpace(a.Mobile) + if mobile := NormalizeMobileForStorage(a.Mobile); mobile != "" { + return mobile } // Backward compatibility: old configs may contain token-only accounts. // Use a stable non-sensitive synthetic id so they can still join the pool. diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index 1138867..8a969df 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -202,7 +202,7 @@ func TestConfigCloneNilMaps(t *testing.T) { func TestAccountIdentifierPreferenceMobileOverToken(t *testing.T) { acc := Account{Mobile: "13800138000", Token: "tok"} - if acc.Identifier() != "13800138000" { + if acc.Identifier() != "+8613800138000" { t.Fatalf("expected mobile identifier, got %q", acc.Identifier()) } } diff --git a/internal/config/mobile.go b/internal/config/mobile.go new file mode 100644 index 0000000..7e2158b --- /dev/null +++ b/internal/config/mobile.go @@ -0,0 +1,82 @@ +package config + +import "strings" + +// NormalizeMobileForStorage normalizes user input to a stable storage format. +// It keeps existing country codes and auto-prefixes mainland China numbers with +86. +func NormalizeMobileForStorage(raw string) string { + digits, hasPlus := extractMobileDigits(raw) + if digits == "" { + return "" + } + if hasPlus { + return "+" + digits + } + if isChinaMobileWithCountryCode(digits) { + return "+86" + digits[2:] + } + if isChinaMainlandMobileDigits(digits) { + return "+86" + digits + } + // For non-China numbers without a leading +, preserve semantics by adding it. + return "+" + digits +} + +// CanonicalMobileKey returns the comparison key used by dedupe/matching logic. +func CanonicalMobileKey(raw string) string { + return NormalizeMobileForStorage(raw) +} + +func extractMobileDigits(raw string) (digits string, hasPlus bool) { + s := strings.TrimSpace(raw) + if s == "" { + return "", false + } + + for _, r := range s { + switch { + case r >= '0' && r <= '9': + goto collect + case isMobileSeparator(r): + continue + case r == '+': + hasPlus = true + goto collect + default: + goto collect + } + } + +collect: + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String(), hasPlus +} + +func isChinaMainlandMobileDigits(digits string) bool { + if len(digits) != 11 || digits[0] != '1' { + return false + } + return digits[1] >= '3' && digits[1] <= '9' +} + +func isChinaMobileWithCountryCode(digits string) bool { + if len(digits) != 13 || !strings.HasPrefix(digits, "86") { + return false + } + return isChinaMainlandMobileDigits(digits[2:]) +} + +func isMobileSeparator(r rune) bool { + switch r { + case ' ', '\t', '\n', '\r', '-', '(', ')', '.', '/': + return true + default: + return false + } +} diff --git a/internal/config/mobile_test.go b/internal/config/mobile_test.go new file mode 100644 index 0000000..96a98b6 --- /dev/null +++ b/internal/config/mobile_test.go @@ -0,0 +1,36 @@ +package config + +import "testing" + +func TestNormalizeMobileForStorageChinaMainlandAddsPlus86(t *testing.T) { + if got := NormalizeMobileForStorage("13800138000"); got != "+8613800138000" { + t.Fatalf("got %q", got) + } +} + +func TestNormalizeMobileForStorageChinaWithCountryCode(t *testing.T) { + if got := NormalizeMobileForStorage("8613800138000"); got != "+8613800138000" { + t.Fatalf("got %q", got) + } +} + +func TestNormalizeMobileForStorageKeepsExistingCountryCode(t *testing.T) { + if got := NormalizeMobileForStorage(" +1 (415) 555-2671 "); got != "+14155552671" { + t.Fatalf("got %q", got) + } +} + +func TestCanonicalMobileKeyMatchesChinaAliases(t *testing.T) { + a := CanonicalMobileKey("+8613800138000") + b := CanonicalMobileKey("13800138000") + c := CanonicalMobileKey("86 13800138000") + if a == "" || a != b || b != c { + t.Fatalf("alias mismatch: a=%q b=%q c=%q", a, b, c) + } +} + +func TestCanonicalMobileKeyEmptyForInvalidInput(t *testing.T) { + if got := CanonicalMobileKey("() --"); got != "" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index 699c3a8..0abe507 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -1,7 +1,6 @@ 'use strict'; const { - TOOL_SIEVE_CAPTURE_LIMIT, resetIncrementalToolState, noteText, insideCodeFence, @@ -37,14 +36,6 @@ function processToolSieveChunk(state, chunk, toolNames) { } const consumed = consumeToolCapture(state, toolNames); if (!consumed.ready) { - if (state.capture.length > TOOL_SIEVE_CAPTURE_LIMIT) { - noteText(state, state.capture); - events.push({ type: 'text', text: state.capture }); - state.capture = ''; - state.capturing = false; - resetIncrementalToolState(state); - continue; - } break; } state.capture = ''; diff --git a/internal/js/helpers/stream-tool-sieve/state.js b/internal/js/helpers/stream-tool-sieve/state.js index a2d2b5c..ff588e2 100644 --- a/internal/js/helpers/stream-tool-sieve/state.js +++ b/internal/js/helpers/stream-tool-sieve/state.js @@ -1,6 +1,5 @@ 'use strict'; -const TOOL_SIEVE_CAPTURE_LIMIT = 8 * 1024; const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 256; function createToolSieveState() { @@ -78,7 +77,6 @@ function toStringSafe(v) { } module.exports = { - TOOL_SIEVE_CAPTURE_LIMIT, TOOL_SIEVE_CONTEXT_TAIL_LIMIT, createToolSieveState, resetIncrementalToolState, diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index f20cb11..ccbd160 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -141,6 +141,20 @@ test('sieve flushes incomplete captured tool json as text on stream finalize', ( assert.equal(leakedText.includes('{'), true); }); +test('sieve still intercepts large tool json payloads over previous capture limit', () => { + const large = 'a'.repeat(9000); + const payload = `{"tool_calls":[{"name":"read_file","input":{"path":"${large}"}}]}`; + const events = runSieve( + [payload.slice(0, 3000), payload.slice(3000, 7000), payload.slice(7000)], + ['read_file'], + ); + const leakedText = collectText(events); + const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0); + const hasToolDelta = events.some((evt) => evt.type === 'tool_call_deltas' && evt.deltas?.length > 0); + assert.equal(hasToolCall || hasToolDelta, true); + assert.equal(leakedText.toLowerCase().includes('tool_calls'), false); +}); + test('sieve keeps plain text intact in tool mode when no tool call appears', () => { const events = runSieve( ['你好,', '这是普通文本回复。', '请继续。'],