From 770f03e2fc41ce8eb142615b3d7b821008c81bc5 Mon Sep 17 00:00:00 2001 From: daymade Date: Fri, 24 Apr 2026 19:52:24 +0800 Subject: [PATCH 1/3] feat(claude): support /v1/messages/count_tokens with local estimation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Anthropic's POST /v1/messages/count_tokens endpoint (https://docs.claude.com/en/api/messages-count-tokens) by estimating input_tokens locally instead of forwarding to any upstream channel. Why --- The Anthropic JS SDK and Claude CLI poll this endpoint before each chat to size the context window. When new-api returns 404 (which it does today, since the route is unregistered), the SDK falls back to sending fake max_tokens=1 messages with the entire tool schema attached. We observed bursts of 250 RPM of these probes from a single Claude Code Desktop session, exhausting the upstream provider's RPM quota and starving real traffic on the same channel for ~2 minutes. Returning a fast 200 here from new-api itself fixes the failure mode at the source: the SDK is satisfied, no upstream RPM is consumed, no billing entry is created. Closes #1694 Closes #2847 Closes #1979 Approach -------- - Mounted on relayV1Router directly, NOT under httpRouter — the former gives us TokenAuth + RouteTag + SystemPerformanceCheck + ModelRequestRateLimit; the latter would also pull in middleware.Distribute() which selects a channel and starts the PreConsume flow. Anthropic defines count_tokens as token-counting only with no quota impact, so the bypass is intentional. - Estimation reuses the project's existing pieces: * ClaudeRequest.GetTokenCountMeta() for the canonical flattening of system / messages (text, tool_use, tool_result) / tools * EstimateTokenByModel() for the Claude-tuned tokenizer that /v1/messages itself bills against This keeps the count consistent with what /v1/messages would report on the same body, so callers can reason about both numbers together. - One small helper, normalizeRequestTools(), converts raw map[string]any tool entries (what json.Unmarshal produces when the field is `any`) into the typed *dto.Tool / *dto.ClaudeWebSearchTool values that dto.ProcessTools requires. Without this every tool entry on a count_tokens request would be silently dropped on the `default: continue` arm of ProcessTools (dto/claude.go:439-442) and the tools text — usually 80%+ of a CLI probe body — would not be counted. - Body parsing uses common.UnmarshalBodyReusable so the request body remains available to any logging/observability that runs after this handler. - Image tokens are intentionally not added: getImageToken() needs a RelayInfo + http.Request context, which this route doesn't have. The CLI probe (the failure mode this PR exists to mitigate) never carries images. Documented in the estimator's godoc. How vs. PR #2384 ---------------- #2384 took the deeper path: a new RelayFormat enum value + adaptor changes + middleware.distributor changes + autoban bypass. That PR was closed without merge. This PR keeps the surface area small — one new route, one controller, one estimator, no enum/adaptor changes, no impact on any code path that doesn't hit the new route. Files ----- - router/relay-router.go + 9 lines (one route registration) - controller/claude_count_tokens.go new (~35 lines incl. doc) - service/claude_token_estimator.go new (~90 lines) - service/claude_token_estimator_test.go new (12 cases) Tests ----- $ go test ./service/ -run 'TestEstimateClaude|TestNormalizeRequestTools' -v === RUN TestEstimateClaudeInputTokens --- PASS (10 cases: empty / short user / system+user / CLI probe / system as array / CJK / tool_use / tool_result / web-search / pre-typed tools) === RUN TestEstimateClaudeInputTokens_NilSafety --- PASS (2 cases: nil request / zero-value request) === RUN TestNormalizeRequestTools --- PASS (3 cases: nil / non-array / malformed dropped) Manual verification of the failure mode (sample CLI probe body — trivial message + Bash tool schema, ~200 bytes): EstimateClaudeInputTokens = 43 input tokens Endpoint returns: 200 OK {"input_tokens": 43} Claude CLI accepts and stops sending fake max_tokens=1 fallback. --- controller/claude_count_tokens.go | 35 ++++++ router/relay-router.go | 10 ++ service/claude_token_estimator.go | 87 +++++++++++++++ service/claude_token_estimator_test.go | 144 +++++++++++++++++++++++++ 4 files changed, 276 insertions(+) create mode 100644 controller/claude_count_tokens.go create mode 100644 service/claude_token_estimator.go create mode 100644 service/claude_token_estimator_test.go diff --git a/controller/claude_count_tokens.go b/controller/claude_count_tokens.go new file mode 100644 index 000000000000..4162bb01519c --- /dev/null +++ b/controller/claude_count_tokens.go @@ -0,0 +1,35 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// ClaudeCountTokens implements POST /v1/messages/count_tokens. +// +// The estimate is computed locally — no upstream channel is selected +// and no quota is consumed. This is what makes the endpoint suitable +// for the SDK / CLI to poll before every chat. +func ClaudeCountTokens(c *gin.Context) { + var req dto.ClaudeRequest + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "type": "error", + "error": types.ClaudeError{ + Type: "invalid_request_error", + Message: "invalid JSON body: " + err.Error(), + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "input_tokens": service.EstimateClaudeInputTokens(&req), + }) +} diff --git a/router/relay-router.go b/router/relay-router.go index 17a13cad7fd6..23c66ac8aac1 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -71,6 +71,16 @@ func SetRelayRouter(router *gin.Engine) { relayV1Router.Use(middleware.SystemPerformanceCheck()) relayV1Router.Use(middleware.TokenAuth()) relayV1Router.Use(middleware.ModelRequestRateLimit()) + + // Anthropic's /v1/messages/count_tokens — local estimate only. Mounted + // directly on relayV1Router so it inherits TokenAuth + RouteTag + + // SystemPerformanceCheck + ModelRequestRateLimit, but NOT + // middleware.Distribute(). No channel is selected, no quota is consumed. + // Anthropic's spec defines this endpoint as token-counting only; the + // project's Distribute pipeline would otherwise pick a channel + run + // PreConsume on a request that never reaches an upstream. + relayV1Router.POST("/messages/count_tokens", controller.ClaudeCountTokens) + { // WebSocket 路由(统一到 Relay) wsRouter := relayV1Router.Group("") diff --git a/service/claude_token_estimator.go b/service/claude_token_estimator.go new file mode 100644 index 000000000000..c1af758490ff --- /dev/null +++ b/service/claude_token_estimator.go @@ -0,0 +1,87 @@ +package service + +import ( + "encoding/json" + + "github.com/QuantumNous/new-api/dto" +) + +// EstimateClaudeInputTokens returns a free, fast local estimate of +// input_tokens for an Anthropic /v1/messages/count_tokens request. +// +// Reuses ClaudeRequest.GetTokenCountMeta to do the canonical flattening +// of system / messages (text, tool_use, tool_result) / tools, and then +// the project's existing Claude-tuned tokenizer EstimateTokenByModel to +// turn that into a token count. This means the value matches whatever +// /v1/messages itself would report on the same body, so callers can +// reason about the two numbers consistently. +// +// Image tokens are intentionally NOT added: getImageToken requires a +// RelayInfo + http.Request context, which this route deliberately +// avoids by bypassing the channel pipeline. The Claude CLI context-bar +// probe (the failure mode this endpoint exists to mitigate) never +// carries images, so the gap doesn't matter for that case. Requests +// that do contain images will under-estimate, which is documented and +// acceptable for an estimate endpoint. +func EstimateClaudeInputTokens(req *dto.ClaudeRequest) int { + if req == nil { + return 0 + } + normalizeRequestTools(req) + meta := req.GetTokenCountMeta() + if meta == nil { + return 0 + } + return EstimateTokenByModel(req.Model, meta.CombineText) +} + +// normalizeRequestTools converts raw map[string]any entries (which is what +// json.Unmarshal of `tools` produces when the field is declared as `any`) +// into the typed *dto.Tool / *dto.ClaudeWebSearchTool values that +// dto.ProcessTools (called from GetTokenCountMeta) accepts. Without this, +// every tool entry on a count_tokens request is silently dropped on the +// `default: continue` arm of ProcessTools — see dto/claude.go:439-442. +// +// Mutates req.Tools in place. Safe because the request is parsed in this +// handler and not shared with anything else (count_tokens does not enter +// the channel pipeline). +// +// Web-search tools are distinguished from normal tools by the presence of +// a top-level "type" field, matching how the Anthropic SDK serializes them. +func normalizeRequestTools(req *dto.ClaudeRequest) { + if req == nil || req.Tools == nil { + return + } + rawTools, ok := req.Tools.([]any) + if !ok { + return + } + normalized := make([]any, 0, len(rawTools)) + for _, t := range rawTools { + switch t.(type) { + case *dto.Tool, dto.Tool, *dto.ClaudeWebSearchTool, dto.ClaudeWebSearchTool: + normalized = append(normalized, t) + continue + } + m, ok := t.(map[string]any) + if !ok { + continue + } + b, err := json.Marshal(m) + if err != nil { + continue + } + if _, isWebSearch := m["type"]; isWebSearch { + var ws dto.ClaudeWebSearchTool + if err := json.Unmarshal(b, &ws); err == nil && ws.Type != "" { + normalized = append(normalized, &ws) + } + continue + } + var tool dto.Tool + if err := json.Unmarshal(b, &tool); err == nil && tool.Name != "" { + normalized = append(normalized, &tool) + } + } + req.Tools = normalized +} diff --git a/service/claude_token_estimator_test.go b/service/claude_token_estimator_test.go new file mode 100644 index 000000000000..2c3309426b27 --- /dev/null +++ b/service/claude_token_estimator_test.go @@ -0,0 +1,144 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/QuantumNous/new-api/dto" +) + +func TestEstimateClaudeInputTokens(t *testing.T) { + cases := []struct { + name string + body string + want int + }{ + { + name: "empty messages", + body: `{"model":"claude-haiku-4-5","max_tokens":1024,"messages":[]}`, + want: 0, + }, + { + name: "single short user message", + body: `{"model":"claude-haiku-4-5","max_tokens":1024,"messages":[{"role":"user","content":"count"}]}`, + want: 4, + }, + { + name: "string system + user", + body: `{"model":"claude-haiku-4-5","max_tokens":1024,"system":"You are helpful.","messages":[{"role":"user","content":"hello"}]}`, + want: 9, + }, + { + name: "CLI-probe shape: trivial message + bash tool schema", + body: `{"model":"claude-haiku-4-5","max_tokens":1,"messages":[{"role":"user","content":"count"}],"tools":[{"name":"Bash","description":"Execute a shell command on the host","input_schema":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}]}`, + want: 43, + }, + { + name: "system as array of text blocks", + body: `{"model":"claude-haiku-4-5","max_tokens":1024,"system":[{"type":"text","text":"You are a helpful assistant."}],"messages":[{"role":"user","content":"hi"}]}`, + want: 12, + }, + { + name: "CJK content", + body: `{"model":"claude-haiku-4-5","max_tokens":1024,"messages":[{"role":"user","content":"你好世界,请用中文回答我的问题"}]}`, + want: 20, + }, + { + name: "tool_use block on assistant turn", + body: `{"model":"claude-haiku-4-5","max_tokens":1024,"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}]}`, + want: 10, + }, + { + name: "tool_result block on user turn", + body: `{"model":"claude-haiku-4-5","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"file1\nfile2"}]}]}`, + want: 9, + }, + { + name: "web-search tool (different schema, distinguished by type field)", + body: `{"model":"claude-haiku-4-5","max_tokens":1024,"messages":[{"role":"user","content":"news"}],"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":3}]}`, + want: 7, + }, + { + name: "tools array with already-typed entries (defensive: caller pre-normalized)", + // Synthesised in-test below via direct ClaudeRequest construction. + body: "", + want: 18, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var req dto.ClaudeRequest + if tc.body != "" { + if err := json.Unmarshal([]byte(tc.body), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + } else { + // pre-typed tool entry — exercises the early-return arm of + // normalizeRequestTools. + req = dto.ClaudeRequest{ + Model: "claude-haiku-4-5", + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "list"}, + }, + Tools: []any{ + &dto.Tool{ + Name: "ListFiles", + Description: "List directory contents", + InputSchema: map[string]any{"type": "object"}, + }, + }, + } + } + got := EstimateClaudeInputTokens(&req) + if got != tc.want { + t.Errorf("EstimateClaudeInputTokens() = %d, want %d", got, tc.want) + } + }) + } +} + +func TestEstimateClaudeInputTokens_NilSafety(t *testing.T) { + if got := EstimateClaudeInputTokens(nil); got != 0 { + t.Errorf("nil request should return 0, got %d", got) + } + + // Empty model string + no content — should not panic. + if got := EstimateClaudeInputTokens(&dto.ClaudeRequest{}); got != 0 { + t.Errorf("zero-value request should return 0, got %d", got) + } +} + +func TestNormalizeRequestTools(t *testing.T) { + t.Run("nil tools", func(t *testing.T) { + req := &dto.ClaudeRequest{} + normalizeRequestTools(req) + if req.Tools != nil { + t.Errorf("expected Tools to remain nil, got %v", req.Tools) + } + }) + + t.Run("non-array tools field is left alone", func(t *testing.T) { + req := &dto.ClaudeRequest{Tools: "not-an-array"} + normalizeRequestTools(req) + if got, ok := req.Tools.(string); !ok || got != "not-an-array" { + t.Errorf("expected non-array Tools to be preserved, got %v", req.Tools) + } + }) + + t.Run("malformed tool entry is dropped, others survive", func(t *testing.T) { + req := &dto.ClaudeRequest{Tools: []any{ + map[string]any{"name": "Good", "description": "ok"}, + map[string]any{"description": "missing name"}, // no Name → dropped + "a string entry", // wrong shape → dropped + }} + normalizeRequestTools(req) + got, ok := req.Tools.([]any) + if !ok { + t.Fatalf("expected []any after normalize, got %T", req.Tools) + } + if len(got) != 1 { + t.Errorf("expected 1 surviving tool, got %d (%v)", len(got), got) + } + }) +} From 2b9213da28b3a78fac5b9ff9ddd55258b38cbcc5 Mon Sep 17 00:00:00 2001 From: daymade Date: Sat, 25 Apr 2026 10:09:22 +0800 Subject: [PATCH 2/3] fix(claude): use common.Marshal/Unmarshal in estimator per project guideline CodeRabbit round 2 flagged service/claude_token_estimator.go + claude_token_estimator_test.go as still importing encoding/json after round 1. Replaces all three json.Marshal/Unmarshal calls in normalizeRequestTools + one json.Unmarshal call in the table-driven test body with the project's common.Marshal / common.Unmarshal wrappers. All 15 estimator/normalize test cases still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- service/claude_token_estimator.go | 9 ++++----- service/claude_token_estimator_test.go | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/service/claude_token_estimator.go b/service/claude_token_estimator.go index c1af758490ff..5bf4754969aa 100644 --- a/service/claude_token_estimator.go +++ b/service/claude_token_estimator.go @@ -1,8 +1,7 @@ package service import ( - "encoding/json" - + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" ) @@ -67,19 +66,19 @@ func normalizeRequestTools(req *dto.ClaudeRequest) { if !ok { continue } - b, err := json.Marshal(m) + b, err := common.Marshal(m) if err != nil { continue } if _, isWebSearch := m["type"]; isWebSearch { var ws dto.ClaudeWebSearchTool - if err := json.Unmarshal(b, &ws); err == nil && ws.Type != "" { + if err := common.Unmarshal(b, &ws); err == nil && ws.Type != "" { normalized = append(normalized, &ws) } continue } var tool dto.Tool - if err := json.Unmarshal(b, &tool); err == nil && tool.Name != "" { + if err := common.Unmarshal(b, &tool); err == nil && tool.Name != "" { normalized = append(normalized, &tool) } } diff --git a/service/claude_token_estimator_test.go b/service/claude_token_estimator_test.go index 2c3309426b27..50f3e37289aa 100644 --- a/service/claude_token_estimator_test.go +++ b/service/claude_token_estimator_test.go @@ -1,9 +1,9 @@ package service import ( - "encoding/json" "testing" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" ) @@ -70,7 +70,7 @@ func TestEstimateClaudeInputTokens(t *testing.T) { t.Run(tc.name, func(t *testing.T) { var req dto.ClaudeRequest if tc.body != "" { - if err := json.Unmarshal([]byte(tc.body), &req); err != nil { + if err := common.Unmarshal([]byte(tc.body), &req); err != nil { t.Fatalf("unmarshal: %v", err) } } else { From cb1d4c59e5c485714e7d22e1451d1eed3b5b238a Mon Sep 17 00:00:00 2001 From: daymade Date: Sat, 25 Apr 2026 10:21:03 +0800 Subject: [PATCH 3/3] fix(claude): narrow web-search tool routing to "web_search" type prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit round 3 flagged that normalizeRequestTools was routing any tool JSON with a top-level "type" field into ClaudeWebSearchTool. But Anthropic's other built-in server tools (computer_*, bash_*, text_editor_*, code_execution_*, mcp_*) also carry a top-level "type" — their shape is different from ClaudeWebSearchTool, so computer_use and friends were being unmarshalled into the web-search struct. After that, ProcessTools only saw Name + UserLocation and dropped the rest of the tool payload from the estimate. Fix: narrow the web-search branch to type values whose string begins with "web_search" (matches current web_search_20250305 and any future version suffix). Unknown "type" values fall through to the generic dto.Tool path, so ProcessTools still sees Name + Description + InputSchema. Caveat: dto.Tool does not model every server-tool-specific field (display_width_px on computer_use, etc.), so exotic server tools can still be undercounted. That is an upstream schema gap outside this PR's scope — this fix just stops the regression from misrouting them all into ClaudeWebSearchTool. Two new test cases lock in the behavior: - computer_20250124 routes to *dto.Tool (not *ClaudeWebSearchTool) - web_search_20250604 (future version suffix) still routes to *ClaudeWebSearchTool via the prefix rule Co-Authored-By: Claude Opus 4.7 (1M context) --- service/claude_token_estimator.go | 34 +++++++++++++++---- service/claude_token_estimator_test.go | 46 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/service/claude_token_estimator.go b/service/claude_token_estimator.go index 5bf4754969aa..6443713aa25b 100644 --- a/service/claude_token_estimator.go +++ b/service/claude_token_estimator.go @@ -1,6 +1,8 @@ package service import ( + "strings" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" ) @@ -45,8 +47,20 @@ func EstimateClaudeInputTokens(req *dto.ClaudeRequest) int { // handler and not shared with anything else (count_tokens does not enter // the channel pipeline). // -// Web-search tools are distinguished from normal tools by the presence of -// a top-level "type" field, matching how the Anthropic SDK serializes them. +// Web-search tools are distinguished from regular user-defined tools by a +// top-level "type" field whose value begins with "web_search" (e.g. +// "web_search_20250305", "web_search_20250604"). Other Anthropic server tools +// — computer_*, bash_*, text_editor_*, code_execution_*, mcp_* — also carry +// a top-level "type" but have a completely different shape from +// ClaudeWebSearchTool; if we unmarshalled them into that struct we would lose +// the rest of their schema from the estimate (ProcessTools would then only +// see Name + UserLocation). Prefix-matching the web-search family is a +// deliberate narrow allowlist: unknown "type" values fall through to the +// dto.Tool path so ProcessTools still sees Name / Description / InputSchema. +// (dto.Tool doesn't model every server-tool field, so undercount can still +// happen for exotic server tools — that's an upstream schema gap outside +// this PR's scope; the fallthrough at least stops the regression from +// misrouting.) func normalizeRequestTools(req *dto.ClaudeRequest) { if req == nil || req.Tools == nil { return @@ -70,12 +84,18 @@ func normalizeRequestTools(req *dto.ClaudeRequest) { if err != nil { continue } - if _, isWebSearch := m["type"]; isWebSearch { - var ws dto.ClaudeWebSearchTool - if err := common.Unmarshal(b, &ws); err == nil && ws.Type != "" { - normalized = append(normalized, &ws) + if typeVal, hasType := m["type"]; hasType { + if typeStr, ok := typeVal.(string); ok && strings.HasPrefix(typeStr, "web_search") { + var ws dto.ClaudeWebSearchTool + if err := common.Unmarshal(b, &ws); err == nil && ws.Type != "" { + normalized = append(normalized, &ws) + } + continue } - continue + // Non-web-search server tools (computer_*, bash_*, ...): fall + // through to the dto.Tool path below so ProcessTools still sees + // Name / Description / InputSchema instead of truncating the + // tool to just Name + UserLocation. } var tool dto.Tool if err := common.Unmarshal(b, &tool); err == nil && tool.Name != "" { diff --git a/service/claude_token_estimator_test.go b/service/claude_token_estimator_test.go index 50f3e37289aa..ec5674018868 100644 --- a/service/claude_token_estimator_test.go +++ b/service/claude_token_estimator_test.go @@ -141,4 +141,50 @@ func TestNormalizeRequestTools(t *testing.T) { t.Errorf("expected 1 surviving tool, got %d (%v)", len(got), got) } }) + + t.Run("non-web-search server tool routes to dto.Tool, not ClaudeWebSearchTool", func(t *testing.T) { + // Anthropic's built-in server tools (computer_*, bash_*, text_editor_*, + // code_execution_*, mcp_*) also carry a top-level "type" field, but + // their shape is different from ClaudeWebSearchTool. Previously they + // were routed into ClaudeWebSearchTool and ProcessTools then only saw + // Name + UserLocation, dropping the rest of the tool schema from the + // estimate. The fix narrows web-search routing to a "web_search" + // prefix allowlist; unknown types must fall through to dto.Tool. + req := &dto.ClaudeRequest{Tools: []any{ + map[string]any{ + "type": "computer_20250124", + "name": "computer", + "description": "Operate a virtual desktop", + "input_schema": map[string]any{"type": "object"}, + }, + }} + normalizeRequestTools(req) + got, ok := req.Tools.([]any) + if !ok || len(got) != 1 { + t.Fatalf("expected 1 tool, got %v (%T)", req.Tools, req.Tools) + } + if _, isWS := got[0].(*dto.ClaudeWebSearchTool); isWS { + t.Errorf("computer_20250124 must NOT be routed to *ClaudeWebSearchTool") + } + if _, isTool := got[0].(*dto.Tool); !isTool { + t.Errorf("expected *dto.Tool, got %T", got[0]) + } + }) + + t.Run("web_search variant (future version suffix) still routes to ClaudeWebSearchTool via prefix rule", func(t *testing.T) { + req := &dto.ClaudeRequest{Tools: []any{ + map[string]any{ + "type": "web_search_20250604", + "name": "web_search", + }, + }} + normalizeRequestTools(req) + got, ok := req.Tools.([]any) + if !ok || len(got) != 1 { + t.Fatalf("expected 1 tool, got %v (%T)", req.Tools, req.Tools) + } + if _, isWS := got[0].(*dto.ClaudeWebSearchTool); !isWS { + t.Errorf("web_search_20250604 should route to *ClaudeWebSearchTool, got %T", got[0]) + } + }) }