Skip to content
37 changes: 37 additions & 0 deletions docs/planning/reports/issue-wave-gh-next32-merge-2026-02-23.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 2 additions & 2 deletions pkg/llmproxy/api/handlers/management/auth_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
31 changes: 18 additions & 13 deletions pkg/llmproxy/api/handlers/management/config_basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"

Expand All @@ -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{})
Expand Down Expand Up @@ -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
Expand All @@ -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"}})
}

Expand Down
33 changes: 33 additions & 0 deletions pkg/llmproxy/api/handlers/management/management_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 6 additions & 2 deletions pkg/llmproxy/api/modules/amp/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion pkg/llmproxy/api/modules/amp/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
10 changes: 9 additions & 1 deletion pkg/llmproxy/auth/kiro/sso_oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
38 changes: 26 additions & 12 deletions pkg/llmproxy/auth/kiro/sso_oidc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"testing"
)

func TestRefreshToken_IncludesGrantTypeAndExtensionHeaders(t *testing.T) {
func TestRefreshToken_UsesSingleGrantTypeFieldAndExtensionHeaders(t *testing.T) {
t.Parallel()

client := &SSOOIDCClient{
Expand All @@ -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"`,
Expand All @@ -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)
Expand All @@ -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{
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions pkg/llmproxy/registry/model_definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
2 changes: 1 addition & 1 deletion pkg/llmproxy/registry/model_definitions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading
Loading