diff --git a/docs/planning/reports/issue-wave-gh-next32-merge-2026-02-23.md b/docs/planning/reports/issue-wave-gh-next32-merge-2026-02-23.md new file mode 100644 index 0000000000..ea33898729 --- /dev/null +++ b/docs/planning/reports/issue-wave-gh-next32-merge-2026-02-23.md @@ -0,0 +1,37 @@ +# Issue Wave GH Next32 Merge Report (2026-02-23) + +## Scope +- Parallel lane checkpoint pass: 6 lanes, first shippable issue per lane. +- Base: `origin/main` @ `37d8a39b`. + +## Merged Commits +- `6f302a42` - `fix(kiro): add IDC extension headers on refresh token requests (#246)` +- `18855252` - `fix(kiro): remove duplicate IDC refresh grantType field for cline (#245)` +- `5ef7e982` - `feat(amp): support kilocode provider alias model routing (#213)` +- `b2f9fbaa` - `fix(management): tolerate read-only config writes for put yaml (#201)` +- `ed3f9142` - `fix(metrics): include kiro and cursor in provider dashboard metrics (#183)` +- `e6dbe638` - `fix(gemini): strip thought_signature from Claude tool args (#178)` +- `296cc7ca` - `fix(management): remove redeclare in auth file registration path` + +## Issue -> Commit Mapping +- `#246` -> `6f302a42` +- `#245` -> `18855252` +- `#213` -> `5ef7e982` +- `#201` -> `b2f9fbaa`, `296cc7ca` +- `#183` -> `ed3f9142` +- `#178` -> `e6dbe638` + +## Validation +- Focused package tests: + - `go test ./pkg/llmproxy/auth/kiro -count=1` + - `go test ./pkg/llmproxy/translator/gemini/claude -count=1` + - `go test ./pkg/llmproxy/translator/gemini-cli/claude -count=1` + - `go test ./pkg/llmproxy/usage -count=1` +- Compile verification for remaining touched packages: + - `go test ./pkg/llmproxy/api/modules/amp -run '^$' -count=1` + - `go test ./pkg/llmproxy/registry -run '^$' -count=1` + - `go test ./pkg/llmproxy/api/handlers/management -run '^$' -count=1` + +## Notes +- Some broad `management` suite tests are long-running in this repository; compile-level verification was used for checkpoint merge safety. +- Remaining assigned issues from lanes are still open for next pass (second item per lane). diff --git a/pkg/llmproxy/api/handlers/management/auth_files.go b/pkg/llmproxy/api/handlers/management/auth_files.go index d9be804d14..f90478519f 100644 --- a/pkg/llmproxy/api/handlers/management/auth_files.go +++ b/pkg/llmproxy/api/handlers/management/auth_files.go @@ -840,10 +840,10 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data [] auth.ModelStates = existing.ModelStates } auth.Runtime = existing.Runtime - _, err := h.authManager.Update(ctx, auth) + _, err = h.authManager.Update(ctx, auth) return err } - _, err := h.authManager.Register(ctx, auth) + _, err = h.authManager.Register(ctx, auth) return err } diff --git a/pkg/llmproxy/api/handlers/management/config_basic.go b/pkg/llmproxy/api/handlers/management/config_basic.go index 5b419a06cf..7222570dcf 100644 --- a/pkg/llmproxy/api/handlers/management/config_basic.go +++ b/pkg/llmproxy/api/handlers/management/config_basic.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "os" - "path/filepath" "strings" "time" @@ -23,6 +22,8 @@ const ( latestReleaseUserAgent = "cliproxyapi++" ) +var writeConfigFile = WriteConfig + func (h *Handler) GetConfig(c *gin.Context) { if h == nil || h.cfg == nil { c.JSON(200, gin.H{}) @@ -119,9 +120,9 @@ func (h *Handler) PutConfigYAML(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": err.Error()}) return } - // Validate config using LoadConfigOptional with optional=false to enforce parsing - tmpDir := filepath.Dir(h.configFilePath) - tmpFile, err := os.CreateTemp(tmpDir, "config-validate-*.yaml") + // Validate config using LoadConfigOptional with optional=false to enforce parsing. + // Use the system temp dir so validation remains available even when config dir is read-only. + tmpFile, err := os.CreateTemp("", "config-validate-*.yaml") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": err.Error()}) return @@ -141,24 +142,28 @@ func (h *Handler) PutConfigYAML(c *gin.Context) { defer func() { _ = os.Remove(tempFile) }() - _, err = config.LoadConfigOptional(tempFile, false) + validatedCfg, err := config.LoadConfigOptional(tempFile, false) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid_config", "message": err.Error()}) return } h.mu.Lock() defer h.mu.Unlock() - if WriteConfig(h.configFilePath, body) != nil { + if errWrite := writeConfigFile(h.configFilePath, body); errWrite != nil { + if isReadOnlyConfigWriteError(errWrite) { + h.cfg = validatedCfg + c.JSON(http.StatusOK, gin.H{ + "ok": true, + "changed": []string{"config"}, + "persisted": false, + "warning": "config filesystem is read-only; runtime changes applied but not persisted", + }) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": "failed to write config"}) return } - // Reload into handler to keep memory in sync - newCfg, err := config.LoadConfig(h.configFilePath) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "reload_failed", "message": err.Error()}) - return - } - h.cfg = newCfg + h.cfg = validatedCfg c.JSON(http.StatusOK, gin.H{"ok": true, "changed": []string{"config"}}) } diff --git a/pkg/llmproxy/api/handlers/management/management_extra_test.go b/pkg/llmproxy/api/handlers/management/management_extra_test.go index 9dc4861240..cf339623f7 100644 --- a/pkg/llmproxy/api/handlers/management/management_extra_test.go +++ b/pkg/llmproxy/api/handlers/management/management_extra_test.go @@ -294,6 +294,39 @@ func TestPutConfigYAML(t *testing.T) { } } +func TestPutConfigYAMLReadOnlyWriteAppliesRuntimeConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config.yaml") + if err := os.WriteFile(tmpFile, []byte("debug: false"), 0o644); err != nil { + t.Fatalf("write initial config: %v", err) + } + + origWriteConfigFile := writeConfigFile + writeConfigFile = func(path string, data []byte) error { + return &os.PathError{Op: "open", Path: path, Err: syscall.EROFS} + } + t.Cleanup(func() { writeConfigFile = origWriteConfigFile }) + + h := &Handler{configFilePath: tmpFile, cfg: &config.Config{}} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("PUT", "/", strings.NewReader("debug: true")) + + h.PutConfigYAML(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"persisted":false`) { + t.Fatalf("expected persisted=false in response body, got %s", w.Body.String()) + } + if h.cfg == nil || !h.cfg.Debug { + t.Fatalf("expected runtime config to be applied despite read-only write") + } +} + func TestGetLogs(t *testing.T) { gin.SetMode(gin.TestMode) tmpDir, _ := os.MkdirTemp("", "logtest") diff --git a/pkg/llmproxy/api/modules/amp/routes.go b/pkg/llmproxy/api/modules/amp/routes.go index 9df77ea29c..0020293789 100644 --- a/pkg/llmproxy/api/modules/amp/routes.go +++ b/pkg/llmproxy/api/modules/amp/routes.go @@ -290,14 +290,18 @@ func (m *AmpModule) registerProviderAliases(engine *gin.Engine, baseHandler *han // Dynamic models handler - routes to appropriate provider based on path parameter ampModelsHandler := func(c *gin.Context) { providerName := strings.ToLower(c.Param("provider")) + channelName := providerName + if providerName == "kilocode" { + channelName = "kilo" + } switch providerName { case "anthropic": claudeCodeHandlers.ClaudeModels(c) case "google": geminiHandlers.GeminiModels(c) - case "kiro", "cursor", "kilo", "kimi": - models := registry.GetStaticModelDefinitionsByChannel(providerName) + case "kiro", "cursor", "kilo", "kilocode", "kimi": + models := registry.GetStaticModelDefinitionsByChannel(channelName) if models == nil { openaiHandlers.OpenAIModels(c) return diff --git a/pkg/llmproxy/api/modules/amp/routes_test.go b/pkg/llmproxy/api/modules/amp/routes_test.go index 37f49c985c..60319a92c7 100644 --- a/pkg/llmproxy/api/modules/amp/routes_test.go +++ b/pkg/llmproxy/api/modules/amp/routes_test.go @@ -179,6 +179,7 @@ func TestRegisterProviderAliases_DedicatedProviderModels(t *testing.T) { expectedOwner string }{ {provider: "kiro", expectedModel: "kiro-claude-opus-4-6", expectedOwner: "aws"}, + {provider: "kilocode", expectedModel: "kilo/auto", expectedOwner: "kilo"}, {provider: "cursor", expectedModel: "default", expectedOwner: "cursor"}, } for _, tc := range tests { @@ -242,7 +243,7 @@ func TestRegisterProviderAliases_DedicatedProviderModelsV1(t *testing.T) { m := &AmpModule{} m.registerProviderAliases(r, base, nil) - tests := []string{"kiro", "cursor"} + tests := []string{"kiro", "kilocode", "cursor"} for _, provider := range tests { t.Run(provider, func(t *testing.T) { path := "/api/provider/" + provider + "/v1/models" diff --git a/pkg/llmproxy/auth/kiro/sso_oidc.go b/pkg/llmproxy/auth/kiro/sso_oidc.go index f449bbca07..1507749154 100644 --- a/pkg/llmproxy/auth/kiro/sso_oidc.go +++ b/pkg/llmproxy/auth/kiro/sso_oidc.go @@ -47,6 +47,9 @@ const ( // IDC token refresh headers (matching Kiro IDE behavior) idcAmzUserAgent = "aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE" + idcPlatform = "darwin" + idcClientType = "extension" + idcDefaultVer = "0.0.0" ) // Sentinel errors for OIDC token polling @@ -124,7 +127,6 @@ func buildIDCRefreshPayload(clientID, clientSecret, refreshToken string) map[str "clientId": clientID, "clientSecret": clientSecret, "refreshToken": refreshToken, - "grantType": "refresh_token", "client_id": clientID, "client_secret": clientSecret, "refresh_token": refreshToken, @@ -145,6 +147,12 @@ func applyIDCRefreshHeaders(req *http.Request, region string) { req.Header.Set("sec-fetch-mode", "cors") req.Header.Set("User-Agent", "node") req.Header.Set("Accept-Encoding", "br, gzip, deflate") + req.Header.Set("X-PLATFORM", idcPlatform) + req.Header.Set("X-PLATFORM-VERSION", idcDefaultVer) + req.Header.Set("X-CLIENT-VERSION", idcDefaultVer) + req.Header.Set("X-CLIENT-TYPE", idcClientType) + req.Header.Set("X-CORE-VERSION", idcDefaultVer) + req.Header.Set("X-IS-MULTIROOT", "false") } // promptInput prompts the user for input with an optional default value. diff --git a/pkg/llmproxy/auth/kiro/sso_oidc_test.go b/pkg/llmproxy/auth/kiro/sso_oidc_test.go index 5979457216..8112fbddd3 100644 --- a/pkg/llmproxy/auth/kiro/sso_oidc_test.go +++ b/pkg/llmproxy/auth/kiro/sso_oidc_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -func TestRefreshToken_IncludesGrantTypeAndExtensionHeaders(t *testing.T) { +func TestRefreshToken_UsesSingleGrantTypeFieldAndExtensionHeaders(t *testing.T) { t.Parallel() client := &SSOOIDCClient{ @@ -20,7 +20,6 @@ func TestRefreshToken_IncludesGrantTypeAndExtensionHeaders(t *testing.T) { } bodyStr := string(body) for _, token := range []string{ - `"grantType":"refresh_token"`, `"grant_type":"refresh_token"`, `"refreshToken":"rt-1"`, `"refresh_token":"rt-1"`, @@ -29,14 +28,23 @@ func TestRefreshToken_IncludesGrantTypeAndExtensionHeaders(t *testing.T) { t.Fatalf("expected payload to contain %s, got %s", token, bodyStr) } } + if strings.Contains(bodyStr, `"grantType":"refresh_token"`) { + t.Fatalf("did not expect duplicate grantType field in payload, got %s", bodyStr) + } for key, want := range map[string]string{ - "Content-Type": "application/json", - "x-amz-user-agent": idcAmzUserAgent, - "User-Agent": "node", - "Connection": "keep-alive", - "Accept-Language": "*", - "sec-fetch-mode": "cors", + "Content-Type": "application/json", + "x-amz-user-agent": idcAmzUserAgent, + "User-Agent": "node", + "Connection": "keep-alive", + "Accept-Language": "*", + "sec-fetch-mode": "cors", + "X-PLATFORM": idcPlatform, + "X-PLATFORM-VERSION": idcDefaultVer, + "X-CLIENT-VERSION": idcDefaultVer, + "X-CLIENT-TYPE": idcClientType, + "X-CORE-VERSION": idcDefaultVer, + "X-IS-MULTIROOT": "false", } { if got := req.Header.Get(key); got != want { t.Fatalf("header %s = %q, want %q", key, got, want) @@ -61,7 +69,7 @@ func TestRefreshToken_IncludesGrantTypeAndExtensionHeaders(t *testing.T) { } } -func TestRefreshTokenWithRegion_UsesRegionHostAndGrantType(t *testing.T) { +func TestRefreshTokenWithRegion_UsesRegionHostAndSingleGrantType(t *testing.T) { t.Parallel() client := &SSOOIDCClient{ @@ -72,16 +80,22 @@ func TestRefreshTokenWithRegion_UsesRegionHostAndGrantType(t *testing.T) { t.Fatalf("read body: %v", err) } bodyStr := string(body) - if !strings.Contains(bodyStr, `"grantType":"refresh_token"`) { - t.Fatalf("expected grantType in payload, got %s", bodyStr) - } if !strings.Contains(bodyStr, `"grant_type":"refresh_token"`) { t.Fatalf("expected grant_type in payload, got %s", bodyStr) } + if strings.Contains(bodyStr, `"grantType":"refresh_token"`) { + t.Fatalf("did not expect duplicate grantType field in payload, got %s", bodyStr) + } if got := req.Header.Get("Host"); got != "oidc.eu-west-1.amazonaws.com" { t.Fatalf("Host header = %q, want oidc.eu-west-1.amazonaws.com", got) } + if got := req.Header.Get("X-PLATFORM"); got != idcPlatform { + t.Fatalf("X-PLATFORM = %q, want %q", got, idcPlatform) + } + if got := req.Header.Get("X-CLIENT-TYPE"); got != idcClientType { + t.Fatalf("X-CLIENT-TYPE = %q, want %q", got, idcClientType) + } return &http.Response{ StatusCode: http.StatusOK, diff --git a/pkg/llmproxy/registry/model_definitions.go b/pkg/llmproxy/registry/model_definitions.go index ce695b133f..af4a5a25d9 100644 --- a/pkg/llmproxy/registry/model_definitions.go +++ b/pkg/llmproxy/registry/model_definitions.go @@ -66,6 +66,8 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo { return GetRooModels() case "kilo": return GetKiloModels() + case "kilocode": + return GetKiloModels() case "deepseek": return GetDeepSeekModels() case "groq": diff --git a/pkg/llmproxy/registry/model_definitions_test.go b/pkg/llmproxy/registry/model_definitions_test.go index adfc5e7a73..7c7154eb44 100644 --- a/pkg/llmproxy/registry/model_definitions_test.go +++ b/pkg/llmproxy/registry/model_definitions_test.go @@ -8,7 +8,7 @@ func TestGetStaticModelDefinitionsByChannel(t *testing.T) { channels := []string{ "claude", "gemini", "vertex", "gemini-cli", "aistudio", "codex", "qwen", "iflow", "github-copilot", "kiro", "amazonq", "cursor", - "minimax", "roo", "kilo", "deepseek", "groq", "mistral", + "minimax", "roo", "kilo", "kilocode", "deepseek", "groq", "mistral", "siliconflow", "openrouter", "together", "fireworks", "novita", "antigravity", } diff --git a/pkg/llmproxy/translator/gemini-cli/claude/gemini-cli_claude_request.go b/pkg/llmproxy/translator/gemini-cli/claude/gemini-cli_claude_request.go index 52ab764f01..00d62ddc10 100644 --- a/pkg/llmproxy/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/pkg/llmproxy/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -94,10 +94,16 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] functionArgs := contentResult.Get("input").String() argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() && gjson.Valid(functionArgs) { + // Claude may include thought_signature in tool args; Gemini treats this as + // a base64 thought signature and can reject malformed values. + sanitizedArgs, err := sjson.Delete(functionArgs, "thought_signature") + if err != nil { + sanitizedArgs = functionArgs + } part := `{"thoughtSignature":"","functionCall":{"name":"","args":{}}}` part, _ = sjson.Set(part, "thoughtSignature", geminiCLIClaudeThoughtSignature) part, _ = sjson.Set(part, "functionCall.name", functionName) - part, _ = sjson.SetRaw(part, "functionCall.args", functionArgs) + part, _ = sjson.SetRaw(part, "functionCall.args", sanitizedArgs) contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) } diff --git a/pkg/llmproxy/translator/gemini-cli/claude/gemini-cli_claude_request_test.go b/pkg/llmproxy/translator/gemini-cli/claude/gemini-cli_claude_request_test.go index d3ccc245a4..d3042b330b 100644 --- a/pkg/llmproxy/translator/gemini-cli/claude/gemini-cli_claude_request_test.go +++ b/pkg/llmproxy/translator/gemini-cli/claude/gemini-cli_claude_request_test.go @@ -55,3 +55,35 @@ func TestConvertClaudeRequestToCLI_SanitizesToolUseThoughtSignature(t *testing.T t.Fatalf("expected thoughtSignature %q, got %q", geminiCLIClaudeThoughtSignature, part.Get("thoughtSignature").String()) } } + +func TestConvertClaudeRequestToCLI_StripsThoughtSignatureFromToolArgs(t *testing.T) { + input := []byte(`{ + "messages":[ + { + "role":"assistant", + "content":[ + { + "type":"tool_use", + "id":"toolu_01", + "name":"lookup", + "input":{"q":"hello","thought_signature":"not-base64"} + } + ] + } + ] + }`) + + got := ConvertClaudeRequestToCLI("gemini-2.5-pro", input, false) + res := gjson.ParseBytes(got) + + args := res.Get("request.contents.0.parts.0.functionCall.args") + if !args.Exists() { + t.Fatalf("expected functionCall args to exist") + } + if args.Get("q").String() != "hello" { + t.Fatalf("expected q arg to be preserved, got %q", args.Get("q").String()) + } + if args.Get("thought_signature").Exists() { + t.Fatalf("expected thought_signature to be stripped from tool args") + } +} diff --git a/pkg/llmproxy/translator/gemini/claude/gemini_claude_request.go b/pkg/llmproxy/translator/gemini/claude/gemini_claude_request.go index c2aaa73436..0849971aa6 100644 --- a/pkg/llmproxy/translator/gemini/claude/gemini_claude_request.go +++ b/pkg/llmproxy/translator/gemini/claude/gemini_claude_request.go @@ -93,10 +93,16 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) functionArgs := contentResult.Get("input").String() argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() && gjson.Valid(functionArgs) { + // Claude may include thought_signature in tool args; Gemini treats this as + // a base64 thought signature and can reject malformed values. + sanitizedArgs, err := sjson.Delete(functionArgs, "thought_signature") + if err != nil { + sanitizedArgs = functionArgs + } part := `{"thoughtSignature":"","functionCall":{"name":"","args":{}}}` part, _ = sjson.Set(part, "thoughtSignature", geminiClaudeThoughtSignature) part, _ = sjson.Set(part, "functionCall.name", functionName) - part, _ = sjson.SetRaw(part, "functionCall.args", functionArgs) + part, _ = sjson.SetRaw(part, "functionCall.args", sanitizedArgs) contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) } diff --git a/pkg/llmproxy/translator/gemini/claude/gemini_claude_request_test.go b/pkg/llmproxy/translator/gemini/claude/gemini_claude_request_test.go index 648fd63387..936938819a 100644 --- a/pkg/llmproxy/translator/gemini/claude/gemini_claude_request_test.go +++ b/pkg/llmproxy/translator/gemini/claude/gemini_claude_request_test.go @@ -107,3 +107,35 @@ func TestConvertClaudeRequestToGemini_SanitizesToolUseThoughtSignature(t *testin t.Fatalf("expected thoughtSignature %q, got %q", geminiClaudeThoughtSignature, part.Get("thoughtSignature").String()) } } + +func TestConvertClaudeRequestToGemini_StripsThoughtSignatureFromToolArgs(t *testing.T) { + input := []byte(`{ + "messages":[ + { + "role":"assistant", + "content":[ + { + "type":"tool_use", + "id":"toolu_01", + "name":"lookup", + "input":{"q":"hello","thought_signature":"not-base64"} + } + ] + } + ] + }`) + + got := ConvertClaudeRequestToGemini("gemini-2.5-pro", input, false) + res := gjson.ParseBytes(got) + + args := res.Get("contents.0.parts.0.functionCall.args") + if !args.Exists() { + t.Fatalf("expected functionCall args to exist") + } + if args.Get("q").String() != "hello" { + t.Fatalf("expected q arg to be preserved, got %q", args.Get("q").String()) + } + if args.Get("thought_signature").Exists() { + t.Fatalf("expected thought_signature to be stripped from tool args") + } +} diff --git a/pkg/llmproxy/usage/metrics.go b/pkg/llmproxy/usage/metrics.go index 8ab9e19b33..f333549ef2 100644 --- a/pkg/llmproxy/usage/metrics.go +++ b/pkg/llmproxy/usage/metrics.go @@ -31,6 +31,7 @@ type ProviderMetrics struct { var knownProviders = map[string]struct{}{ "nim": {}, "kilo": {}, "minimax": {}, "glm": {}, "openrouter": {}, "antigravity": {}, "claude": {}, "codex": {}, "gemini": {}, "roo": {}, + "kiro": {}, "cursor": {}, } // Fallback cost per 1k tokens (USD) when no usage data. Align with thegent _GLM_OFFER_COST. diff --git a/pkg/llmproxy/usage/metrics_test.go b/pkg/llmproxy/usage/metrics_test.go index 337d161f85..bee78a16ff 100644 --- a/pkg/llmproxy/usage/metrics_test.go +++ b/pkg/llmproxy/usage/metrics_test.go @@ -77,6 +77,36 @@ func TestGetProviderMetrics_FiltersKnownProviders(t *testing.T) { } } +func TestGetProviderMetrics_IncludesKiroAndCursor(t *testing.T) { + stats := GetRequestStatistics() + ctx := context.Background() + + stats.Record(ctx, coreusage.Record{ + Provider: "kiro", + APIKey: "kiro-main", + Model: "kiro/claude-sonnet-4.6", + Detail: coreusage.Detail{ + TotalTokens: 42, + }, + }) + stats.Record(ctx, coreusage.Record{ + Provider: "cursor", + APIKey: "cursor-primary", + Model: "cursor/default", + Detail: coreusage.Detail{ + TotalTokens: 21, + }, + }) + + metrics := GetProviderMetrics() + if _, ok := metrics["kiro"]; !ok { + t.Fatal("expected kiro in provider metrics") + } + if _, ok := metrics["cursor"]; !ok { + t.Fatal("expected cursor in provider metrics") + } +} + func TestGetProviderMetrics_StableRateBounds(t *testing.T) { metrics := GetProviderMetrics() for provider, stat := range metrics {