mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-27 14:17:37 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25680a8259 | ||
|
|
13c93e8cfd | ||
|
|
88aa1b9fd1 | ||
|
|
352cb98ff0 | ||
|
|
7fe1d102cb | ||
|
|
5850492a93 | ||
|
|
fdbd4041ca | ||
|
|
ebef1fae2a | ||
|
|
c51851689b | ||
|
|
4bbeb92e9a | ||
|
|
b436dad8bc | ||
|
|
6ae15d6c44 | ||
|
|
0468bde0d6 | ||
|
|
1d7329e797 | ||
|
|
48ffc4dee7 | ||
|
|
7ebd8f0c44 | ||
|
|
b680c146c1 | ||
|
|
7d6660d181 | ||
|
|
d8e3d4e2b6 | ||
|
|
d26ad8224d | ||
|
|
5c84d69d42 | ||
|
|
527e4b7f26 | ||
|
|
b48485b42b | ||
|
|
79009bb3d4 | ||
|
|
26fc611f86 | ||
|
|
b43743d4f1 | ||
|
|
179e5434b1 | ||
|
|
9f95b31158 | ||
|
|
5da07eae4c | ||
|
|
835ae178d4 | ||
|
|
c80ab8bf0d | ||
|
|
ce87714ef1 | ||
|
|
0452b869e8 | ||
|
|
d2e5857b82 | ||
|
|
f9b005f21f | ||
|
|
532107b4fa | ||
|
|
c44793789b | ||
|
|
4e99525279 | ||
|
|
7547d1d0b3 | ||
|
|
2615f489d6 |
@@ -10,7 +10,7 @@ The Plus release stays in lockstep with the mainline features.
|
||||
|
||||
## Differences from the Mainline
|
||||
|
||||
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||
|
||||
## New Features (Plus Enhanced)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## 与主线版本版本差异
|
||||
|
||||
[](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
||||
[](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
||||
|
||||
## 新增功能 (Plus 增强版)
|
||||
|
||||
|
||||
@@ -233,6 +233,9 @@ nonstream-keepalive-interval: 0
|
||||
# alias: "vertex-flash" # client-visible alias
|
||||
# - name: "gemini-2.5-pro"
|
||||
# alias: "vertex-pro"
|
||||
# excluded-models: # optional: models to exclude from listing
|
||||
# - "imagen-3.0-generate-002"
|
||||
# - "imagen-*"
|
||||
|
||||
# Amp Integration
|
||||
# ampcode:
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -192,17 +193,6 @@ func startCallbackForwarder(port int, provider, targetBase string) (*callbackFor
|
||||
return forwarder, nil
|
||||
}
|
||||
|
||||
func stopCallbackForwarder(port int) {
|
||||
callbackForwardersMu.Lock()
|
||||
forwarder := callbackForwarders[port]
|
||||
if forwarder != nil {
|
||||
delete(callbackForwarders, port)
|
||||
}
|
||||
callbackForwardersMu.Unlock()
|
||||
|
||||
stopForwarderInstance(port, forwarder)
|
||||
}
|
||||
|
||||
func stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) {
|
||||
if forwarder == nil {
|
||||
return
|
||||
@@ -644,44 +634,85 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "invalid name"})
|
||||
return
|
||||
}
|
||||
full := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
||||
if !filepath.IsAbs(full) {
|
||||
if abs, errAbs := filepath.Abs(full); errAbs == nil {
|
||||
full = abs
|
||||
|
||||
targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
||||
targetID := ""
|
||||
if targetAuth := h.findAuthForDelete(name); targetAuth != nil {
|
||||
targetID = strings.TrimSpace(targetAuth.ID)
|
||||
if path := strings.TrimSpace(authAttribute(targetAuth, "path")); path != "" {
|
||||
targetPath = path
|
||||
}
|
||||
}
|
||||
if err := os.Remove(full); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if !filepath.IsAbs(targetPath) {
|
||||
if abs, errAbs := filepath.Abs(targetPath); errAbs == nil {
|
||||
targetPath = abs
|
||||
}
|
||||
}
|
||||
if errRemove := os.Remove(targetPath); errRemove != nil {
|
||||
if os.IsNotExist(errRemove) {
|
||||
c.JSON(404, gin.H{"error": "file not found"})
|
||||
} else {
|
||||
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)})
|
||||
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", errRemove)})
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := h.deleteTokenRecord(ctx, full); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil {
|
||||
c.JSON(500, gin.H{"error": errDeleteRecord.Error()})
|
||||
return
|
||||
}
|
||||
h.disableAuth(ctx, full)
|
||||
if targetID != "" {
|
||||
h.disableAuth(ctx, targetID)
|
||||
} else {
|
||||
h.disableAuth(ctx, targetPath)
|
||||
}
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *Handler) findAuthForDelete(name string) *coreauth.Auth {
|
||||
if h == nil || h.authManager == nil {
|
||||
return nil
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if auth, ok := h.authManager.GetByID(name); ok {
|
||||
return auth
|
||||
}
|
||||
auths := h.authManager.List()
|
||||
for _, auth := range auths {
|
||||
if auth == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(auth.FileName) == name {
|
||||
return auth
|
||||
}
|
||||
if filepath.Base(strings.TrimSpace(authAttribute(auth, "path"))) == name {
|
||||
return auth
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) authIDForPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if h == nil || h.cfg == nil {
|
||||
return path
|
||||
id := path
|
||||
if h != nil && h.cfg != nil {
|
||||
authDir := strings.TrimSpace(h.cfg.AuthDir)
|
||||
if authDir != "" {
|
||||
if rel, errRel := filepath.Rel(authDir, path); errRel == nil && rel != "" {
|
||||
id = rel
|
||||
}
|
||||
}
|
||||
}
|
||||
authDir := strings.TrimSpace(h.cfg.AuthDir)
|
||||
if authDir == "" {
|
||||
return path
|
||||
// On Windows, normalize ID casing to avoid duplicate auth entries caused by case-insensitive paths.
|
||||
if runtime.GOOS == "windows" {
|
||||
id = strings.ToLower(id)
|
||||
}
|
||||
if rel, err := filepath.Rel(authDir, path); err == nil && rel != "" {
|
||||
return rel
|
||||
}
|
||||
return path
|
||||
return id
|
||||
}
|
||||
|
||||
func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error {
|
||||
@@ -899,10 +930,19 @@ func (h *Handler) disableAuth(ctx context.Context, id string) {
|
||||
if h == nil || h.authManager == nil {
|
||||
return
|
||||
}
|
||||
authID := h.authIDForPath(id)
|
||||
if authID == "" {
|
||||
authID = strings.TrimSpace(id)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
if auth, ok := h.authManager.GetByID(id); ok {
|
||||
auth.Disabled = true
|
||||
auth.Status = coreauth.StatusDisabled
|
||||
auth.StatusMessage = "removed via management API"
|
||||
auth.UpdatedAt = time.Now()
|
||||
_, _ = h.authManager.Update(ctx, auth)
|
||||
return
|
||||
}
|
||||
authID := h.authIDForPath(id)
|
||||
if authID == "" {
|
||||
return
|
||||
}
|
||||
@@ -2549,6 +2589,7 @@ func PopulateAuthContext(ctx context.Context, c *gin.Context) context.Context {
|
||||
}
|
||||
return coreauth.WithRequestInfo(ctx, info)
|
||||
}
|
||||
|
||||
const kiroCallbackPort = 9876
|
||||
|
||||
func (h *Handler) RequestKiroToken(c *gin.Context) {
|
||||
@@ -2685,6 +2726,7 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
|
||||
}
|
||||
|
||||
isWebUI := isWebUIRequest(c)
|
||||
var forwarder *callbackForwarder
|
||||
if isWebUI {
|
||||
targetURL, errTarget := h.managementCallbackURL("/kiro/callback")
|
||||
if errTarget != nil {
|
||||
@@ -2692,7 +2734,8 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
|
||||
return
|
||||
}
|
||||
if _, errStart := startCallbackForwarder(kiroCallbackPort, "kiro", targetURL); errStart != nil {
|
||||
var errStart error
|
||||
if forwarder, errStart = startCallbackForwarder(kiroCallbackPort, "kiro", targetURL); errStart != nil {
|
||||
log.WithError(errStart).Error("failed to start kiro callback forwarder")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
|
||||
return
|
||||
@@ -2701,7 +2744,7 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
|
||||
|
||||
go func() {
|
||||
if isWebUI {
|
||||
defer stopCallbackForwarder(kiroCallbackPort)
|
||||
defer stopCallbackForwarderInstance(kiroCallbackPort, forwarder)
|
||||
}
|
||||
|
||||
socialClient := kiroauth.NewSocialAuthClient(h.cfg)
|
||||
@@ -2904,7 +2947,7 @@ func (h *Handler) RequestKiloToken(c *gin.Context) {
|
||||
Metadata: map[string]any{
|
||||
"email": status.UserEmail,
|
||||
"organization_id": orgID,
|
||||
"model": defaults.Model,
|
||||
"model": defaults.Model,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
129
internal/api/handlers/management/auth_files_delete_test.go
Normal file
129
internal/api/handlers/management/auth_files_delete_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func TestDeleteAuthFile_UsesAuthPathFromManager(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
authDir := filepath.Join(tempDir, "auth")
|
||||
externalDir := filepath.Join(tempDir, "external")
|
||||
if errMkdirAuth := os.MkdirAll(authDir, 0o700); errMkdirAuth != nil {
|
||||
t.Fatalf("failed to create auth dir: %v", errMkdirAuth)
|
||||
}
|
||||
if errMkdirExternal := os.MkdirAll(externalDir, 0o700); errMkdirExternal != nil {
|
||||
t.Fatalf("failed to create external dir: %v", errMkdirExternal)
|
||||
}
|
||||
|
||||
fileName := "codex-user@example.com-plus.json"
|
||||
shadowPath := filepath.Join(authDir, fileName)
|
||||
realPath := filepath.Join(externalDir, fileName)
|
||||
if errWriteShadow := os.WriteFile(shadowPath, []byte(`{"type":"codex","email":"shadow@example.com"}`), 0o600); errWriteShadow != nil {
|
||||
t.Fatalf("failed to write shadow file: %v", errWriteShadow)
|
||||
}
|
||||
if errWriteReal := os.WriteFile(realPath, []byte(`{"type":"codex","email":"real@example.com"}`), 0o600); errWriteReal != nil {
|
||||
t.Fatalf("failed to write real file: %v", errWriteReal)
|
||||
}
|
||||
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
record := &coreauth.Auth{
|
||||
ID: "legacy/" + fileName,
|
||||
FileName: fileName,
|
||||
Provider: "codex",
|
||||
Status: coreauth.StatusError,
|
||||
Unavailable: true,
|
||||
Attributes: map[string]string{
|
||||
"path": realPath,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"type": "codex",
|
||||
"email": "real@example.com",
|
||||
},
|
||||
}
|
||||
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
|
||||
t.Fatalf("failed to register auth record: %v", errRegister)
|
||||
}
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||
h.tokenStore = &memoryAuthStore{}
|
||||
|
||||
deleteRec := httptest.NewRecorder()
|
||||
deleteCtx, _ := gin.CreateTestContext(deleteRec)
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
|
||||
deleteCtx.Request = deleteReq
|
||||
h.DeleteAuthFile(deleteCtx)
|
||||
|
||||
if deleteRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
if _, errStatReal := os.Stat(realPath); !os.IsNotExist(errStatReal) {
|
||||
t.Fatalf("expected managed auth file to be removed, stat err: %v", errStatReal)
|
||||
}
|
||||
if _, errStatShadow := os.Stat(shadowPath); errStatShadow != nil {
|
||||
t.Fatalf("expected shadow auth file to remain, stat err: %v", errStatShadow)
|
||||
}
|
||||
|
||||
listRec := httptest.NewRecorder()
|
||||
listCtx, _ := gin.CreateTestContext(listRec)
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil)
|
||||
listCtx.Request = listReq
|
||||
h.ListAuthFiles(listCtx)
|
||||
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, listRec.Code, listRec.Body.String())
|
||||
}
|
||||
var listPayload map[string]any
|
||||
if errUnmarshal := json.Unmarshal(listRec.Body.Bytes(), &listPayload); errUnmarshal != nil {
|
||||
t.Fatalf("failed to decode list payload: %v", errUnmarshal)
|
||||
}
|
||||
filesRaw, ok := listPayload["files"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected files array, payload: %#v", listPayload)
|
||||
}
|
||||
if len(filesRaw) != 0 {
|
||||
t.Fatalf("expected removed auth to be hidden from list, got %d entries", len(filesRaw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
authDir := t.TempDir()
|
||||
fileName := "fallback-user.json"
|
||||
filePath := filepath.Join(authDir, fileName)
|
||||
if errWrite := os.WriteFile(filePath, []byte(`{"type":"codex"}`), 0o600); errWrite != nil {
|
||||
t.Fatalf("failed to write auth file: %v", errWrite)
|
||||
}
|
||||
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||
h.tokenStore = &memoryAuthStore{}
|
||||
|
||||
deleteRec := httptest.NewRecorder()
|
||||
deleteCtx, _ := gin.CreateTestContext(deleteRec)
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
|
||||
deleteCtx.Request = deleteReq
|
||||
h.DeleteAuthFile(deleteCtx)
|
||||
|
||||
if deleteRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
if _, errStat := os.Stat(filePath); !os.IsNotExist(errStat) {
|
||||
t.Fatalf("expected auth file to be removed from auth dir, stat err: %v", errStat)
|
||||
}
|
||||
}
|
||||
@@ -516,12 +516,13 @@ func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
|
||||
}
|
||||
func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
||||
type vertexCompatPatch struct {
|
||||
APIKey *string `json:"api-key"`
|
||||
Prefix *string `json:"prefix"`
|
||||
BaseURL *string `json:"base-url"`
|
||||
ProxyURL *string `json:"proxy-url"`
|
||||
Headers *map[string]string `json:"headers"`
|
||||
Models *[]config.VertexCompatModel `json:"models"`
|
||||
APIKey *string `json:"api-key"`
|
||||
Prefix *string `json:"prefix"`
|
||||
BaseURL *string `json:"base-url"`
|
||||
ProxyURL *string `json:"proxy-url"`
|
||||
Headers *map[string]string `json:"headers"`
|
||||
Models *[]config.VertexCompatModel `json:"models"`
|
||||
ExcludedModels *[]string `json:"excluded-models"`
|
||||
}
|
||||
var body struct {
|
||||
Index *int `json:"index"`
|
||||
@@ -585,6 +586,9 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
||||
if body.Value.Models != nil {
|
||||
entry.Models = append([]config.VertexCompatModel(nil), (*body.Value.Models)...)
|
||||
}
|
||||
if body.Value.ExcludedModels != nil {
|
||||
entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
|
||||
}
|
||||
normalizeVertexCompatKey(&entry)
|
||||
h.cfg.VertexCompatAPIKey[targetIndex] = entry
|
||||
h.cfg.SanitizeVertexCompatKeys()
|
||||
@@ -1029,6 +1033,7 @@ func normalizeVertexCompatKey(entry *config.VertexCompatKey) {
|
||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
||||
entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
|
||||
if len(entry.Models) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
@@ -222,6 +224,97 @@ func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// CopilotModelEntry represents a single model entry returned by the Copilot /models API.
|
||||
type CopilotModelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Capabilities map[string]any `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// CopilotModelsResponse represents the response from the Copilot /models endpoint.
|
||||
type CopilotModelsResponse struct {
|
||||
Data []CopilotModelEntry `json:"data"`
|
||||
Object string `json:"object"`
|
||||
}
|
||||
|
||||
// maxModelsResponseSize is the maximum allowed response size from the /models endpoint (2 MB).
|
||||
const maxModelsResponseSize = 2 * 1024 * 1024
|
||||
|
||||
// allowedCopilotAPIHosts is the set of hosts that are considered safe for Copilot API requests.
|
||||
var allowedCopilotAPIHosts = map[string]bool{
|
||||
"api.githubcopilot.com": true,
|
||||
"api.individual.githubcopilot.com": true,
|
||||
"api.business.githubcopilot.com": true,
|
||||
"copilot-proxy.githubusercontent.com": true,
|
||||
}
|
||||
|
||||
// ListModels fetches the list of available models from the Copilot API.
|
||||
// It requires a valid Copilot API token (not the GitHub access token).
|
||||
func (c *CopilotAuth) ListModels(ctx context.Context, apiToken *CopilotAPIToken) ([]CopilotModelEntry, error) {
|
||||
if apiToken == nil || apiToken.Token == "" {
|
||||
return nil, fmt.Errorf("copilot: api token is required for listing models")
|
||||
}
|
||||
|
||||
// Build models URL, validating the endpoint host to prevent SSRF.
|
||||
modelsURL := copilotAPIEndpoint + "/models"
|
||||
if ep := strings.TrimRight(apiToken.Endpoints.API, "/"); ep != "" {
|
||||
parsed, err := url.Parse(ep)
|
||||
if err == nil && parsed.Scheme == "https" && allowedCopilotAPIHosts[parsed.Host] {
|
||||
modelsURL = ep + "/models"
|
||||
} else {
|
||||
log.Warnf("copilot: ignoring untrusted API endpoint %q, using default", ep)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := c.MakeAuthenticatedRequest(ctx, http.MethodGet, modelsURL, nil, apiToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copilot: failed to create models request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copilot: models request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("copilot list models: close body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
// Limit response body to prevent memory exhaustion.
|
||||
limitedReader := io.LimitReader(resp.Body, maxModelsResponseSize)
|
||||
bodyBytes, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copilot: failed to read models response: %w", err)
|
||||
}
|
||||
|
||||
if !isHTTPSuccess(resp.StatusCode) {
|
||||
return nil, fmt.Errorf("copilot: list models failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var modelsResp CopilotModelsResponse
|
||||
if err = json.Unmarshal(bodyBytes, &modelsResp); err != nil {
|
||||
return nil, fmt.Errorf("copilot: failed to parse models response: %w", err)
|
||||
}
|
||||
|
||||
return modelsResp.Data, nil
|
||||
}
|
||||
|
||||
// ListModelsWithGitHubToken is a convenience method that exchanges a GitHub access token
|
||||
// for a Copilot API token and then fetches the available models.
|
||||
func (c *CopilotAuth) ListModelsWithGitHubToken(ctx context.Context, githubAccessToken string) ([]CopilotModelEntry, error) {
|
||||
apiToken, err := c.GetCopilotAPIToken(ctx, githubAccessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copilot: failed to get API token for model listing: %w", err)
|
||||
}
|
||||
|
||||
return c.ListModels(ctx, apiToken)
|
||||
}
|
||||
|
||||
// buildChatCompletionURL builds the URL for chat completions API.
|
||||
func buildChatCompletionURL() string {
|
||||
return copilotAPIEndpoint + "/chat/completions"
|
||||
|
||||
61
internal/config/oauth_model_alias_defaults.go
Normal file
61
internal/config/oauth_model_alias_defaults.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// defaultKiroAliases returns default oauth-model-alias entries for Kiro.
|
||||
// These aliases expose standard Claude IDs for Kiro-prefixed upstream models.
|
||||
func defaultKiroAliases() []OAuthModelAlias {
|
||||
return []OAuthModelAlias{
|
||||
// Sonnet 4.6
|
||||
{Name: "kiro-claude-sonnet-4-6", Alias: "claude-sonnet-4-6", Fork: true},
|
||||
// Sonnet 4.5
|
||||
{Name: "kiro-claude-sonnet-4-5", Alias: "claude-sonnet-4-5-20250929", Fork: true},
|
||||
{Name: "kiro-claude-sonnet-4-5", Alias: "claude-sonnet-4-5", Fork: true},
|
||||
// Sonnet 4
|
||||
{Name: "kiro-claude-sonnet-4", Alias: "claude-sonnet-4-20250514", Fork: true},
|
||||
{Name: "kiro-claude-sonnet-4", Alias: "claude-sonnet-4", Fork: true},
|
||||
// Opus 4.6
|
||||
{Name: "kiro-claude-opus-4-6", Alias: "claude-opus-4-6", Fork: true},
|
||||
// Opus 4.5
|
||||
{Name: "kiro-claude-opus-4-5", Alias: "claude-opus-4-5-20251101", Fork: true},
|
||||
{Name: "kiro-claude-opus-4-5", Alias: "claude-opus-4-5", Fork: true},
|
||||
// Haiku 4.5
|
||||
{Name: "kiro-claude-haiku-4-5", Alias: "claude-haiku-4-5-20251001", Fork: true},
|
||||
{Name: "kiro-claude-haiku-4-5", Alias: "claude-haiku-4-5", Fork: true},
|
||||
}
|
||||
}
|
||||
|
||||
// defaultGitHubCopilotAliases returns default oauth-model-alias entries for
|
||||
// GitHub Copilot Claude models. It exposes hyphen-style IDs used by clients.
|
||||
func defaultGitHubCopilotAliases() []OAuthModelAlias {
|
||||
return []OAuthModelAlias{
|
||||
{Name: "claude-haiku-4.5", Alias: "claude-haiku-4-5", Fork: true},
|
||||
{Name: "claude-opus-4.1", Alias: "claude-opus-4-1", Fork: true},
|
||||
{Name: "claude-opus-4.5", Alias: "claude-opus-4-5", Fork: true},
|
||||
{Name: "claude-opus-4.6", Alias: "claude-opus-4-6", Fork: true},
|
||||
{Name: "claude-sonnet-4.5", Alias: "claude-sonnet-4-5", Fork: true},
|
||||
{Name: "claude-sonnet-4.6", Alias: "claude-sonnet-4-6", Fork: true},
|
||||
}
|
||||
}
|
||||
|
||||
// GitHubCopilotAliasesFromModels generates oauth-model-alias entries from a dynamic
|
||||
// list of model IDs fetched from the Copilot API. It auto-creates aliases for
|
||||
// models whose ID contains a dot (e.g. "claude-opus-4.6" → "claude-opus-4-6"),
|
||||
// which is the pattern used by Claude models on Copilot.
|
||||
func GitHubCopilotAliasesFromModels(modelIDs []string) []OAuthModelAlias {
|
||||
var aliases []OAuthModelAlias
|
||||
seen := make(map[string]struct{})
|
||||
for _, id := range modelIDs {
|
||||
if !strings.Contains(id, ".") {
|
||||
continue
|
||||
}
|
||||
hyphenID := strings.ReplaceAll(id, ".", "-")
|
||||
key := id + "→" + hyphenID
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
aliases = append(aliases, OAuthModelAlias{Name: id, Alias: hyphenID, Fork: true})
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
@@ -34,6 +34,9 @@ type VertexCompatKey struct {
|
||||
|
||||
// Models defines the model configurations including aliases for routing.
|
||||
Models []VertexCompatModel `yaml:"models,omitempty" json:"models,omitempty"`
|
||||
|
||||
// ExcludedModels lists model IDs that should be excluded for this provider.
|
||||
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
|
||||
}
|
||||
|
||||
func (k VertexCompatKey) GetAPIKey() string { return k.APIKey }
|
||||
@@ -74,6 +77,7 @@ func (cfg *Config) SanitizeVertexCompatKeys() {
|
||||
}
|
||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||
entry.Headers = NormalizeHeaders(entry.Headers)
|
||||
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
|
||||
|
||||
// Sanitize models: remove entries without valid alias
|
||||
sanitizedModels := make([]VertexCompatModel, 0, len(entry.Models))
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
// - kiro
|
||||
// - kilo
|
||||
// - github-copilot
|
||||
// - kiro
|
||||
// - amazonq
|
||||
// - antigravity (returns static overrides only)
|
||||
func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
|
||||
|
||||
@@ -37,7 +37,7 @@ func GetClaudeModels() []*ModelInfo {
|
||||
DisplayName: "Claude 4.6 Sonnet",
|
||||
ContextLength: 200000,
|
||||
MaxCompletionTokens: 64000,
|
||||
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
|
||||
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high"}},
|
||||
},
|
||||
{
|
||||
ID: "claude-opus-4-6",
|
||||
@@ -49,7 +49,7 @@ func GetClaudeModels() []*ModelInfo {
|
||||
Description: "Premium model combining maximum intelligence with practical performance",
|
||||
ContextLength: 1000000,
|
||||
MaxCompletionTokens: 128000,
|
||||
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
|
||||
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high", "max"}},
|
||||
},
|
||||
{
|
||||
ID: "claude-sonnet-4-6",
|
||||
|
||||
@@ -47,6 +47,8 @@ type ModelInfo struct {
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
// SupportedParameters lists supported parameters
|
||||
SupportedParameters []string `json:"supported_parameters,omitempty"`
|
||||
// SupportedEndpoints lists supported API endpoints (e.g., "/chat/completions", "/responses").
|
||||
SupportedEndpoints []string `json:"supported_endpoints,omitempty"`
|
||||
// SupportedInputModalities lists supported input modalities (e.g., TEXT, IMAGE, VIDEO, AUDIO)
|
||||
SupportedInputModalities []string `json:"supportedInputModalities,omitempty"`
|
||||
// SupportedOutputModalities lists supported output modalities (e.g., TEXT, IMAGE)
|
||||
|
||||
@@ -59,6 +59,7 @@ func buildRequestBodyFromPayload(t *testing.T, modelName string) map[string]any
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"deprecated": true,
|
||||
"enum": ["a", "b"],
|
||||
"enumTitles": ["A", "B"]
|
||||
}
|
||||
@@ -156,4 +157,7 @@ func assertSchemaSanitizedAndPropertyPreserved(t *testing.T, params map[string]a
|
||||
if _, ok := mode["enumTitles"]; ok {
|
||||
t.Fatalf("enumTitles should be removed from nested schema")
|
||||
}
|
||||
if _, ok := mode["deprecated"]; ok {
|
||||
t.Fatalf("deprecated should be removed from nested schema")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,6 +634,12 @@ func disableThinkingIfToolChoiceForced(body []byte) []byte {
|
||||
if toolChoiceType == "any" || toolChoiceType == "tool" {
|
||||
// Remove thinking configuration entirely to avoid API error
|
||||
body, _ = sjson.DeleteBytes(body, "thinking")
|
||||
// Adaptive thinking may also set output_config.effort; remove it to avoid
|
||||
// leaking thinking controls when tool_choice forces tool use.
|
||||
body, _ = sjson.DeleteBytes(body, "output_config.effort")
|
||||
if oc := gjson.GetBytes(body, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
body, _ = sjson.DeleteBytes(body, "output_config")
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
@@ -490,18 +491,46 @@ func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string, b
|
||||
r.Header.Set("X-Request-Id", uuid.NewString())
|
||||
|
||||
initiator := "user"
|
||||
if len(body) > 0 {
|
||||
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
||||
for _, msg := range messages.Array() {
|
||||
role := msg.Get("role").String()
|
||||
if role == "assistant" || role == "tool" {
|
||||
initiator = "agent"
|
||||
break
|
||||
}
|
||||
if role := detectLastConversationRole(body); role == "assistant" || role == "tool" {
|
||||
initiator = "agent"
|
||||
}
|
||||
r.Header.Set("X-Initiator", initiator)
|
||||
}
|
||||
|
||||
func detectLastConversationRole(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
||||
arr := messages.Array()
|
||||
for i := len(arr) - 1; i >= 0; i-- {
|
||||
if role := arr[i].Get("role").String(); role != "" {
|
||||
return role
|
||||
}
|
||||
}
|
||||
}
|
||||
r.Header.Set("X-Initiator", initiator)
|
||||
|
||||
if inputs := gjson.GetBytes(body, "input"); inputs.Exists() && inputs.IsArray() {
|
||||
arr := inputs.Array()
|
||||
for i := len(arr) - 1; i >= 0; i-- {
|
||||
item := arr[i]
|
||||
|
||||
// Most Responses input items carry a top-level role.
|
||||
if role := item.Get("role").String(); role != "" {
|
||||
return role
|
||||
}
|
||||
|
||||
switch item.Get("type").String() {
|
||||
case "function_call", "function_call_arguments":
|
||||
return "assistant"
|
||||
case "function_call_output", "function_call_response", "tool_result":
|
||||
return "tool"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectVisionContent checks if the request body contains vision/image content.
|
||||
@@ -1236,3 +1265,99 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
||||
func isHTTPSuccess(statusCode int) bool {
|
||||
return statusCode >= 200 && statusCode < 300
|
||||
}
|
||||
|
||||
const (
|
||||
// defaultCopilotContextLength is the default context window for unknown Copilot models.
|
||||
defaultCopilotContextLength = 128000
|
||||
// defaultCopilotMaxCompletionTokens is the default max output tokens for unknown Copilot models.
|
||||
defaultCopilotMaxCompletionTokens = 16384
|
||||
)
|
||||
|
||||
// FetchGitHubCopilotModels dynamically fetches available models from the GitHub Copilot API.
|
||||
// It exchanges the GitHub access token stored in auth.Metadata for a Copilot API token,
|
||||
// then queries the /models endpoint. Falls back to the static registry on any failure.
|
||||
func FetchGitHubCopilotModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo {
|
||||
if auth == nil {
|
||||
log.Debug("github-copilot: auth is nil, using static models")
|
||||
return registry.GetGitHubCopilotModels()
|
||||
}
|
||||
|
||||
accessToken := metaStringValue(auth.Metadata, "access_token")
|
||||
if accessToken == "" {
|
||||
log.Debug("github-copilot: no access_token in auth metadata, using static models")
|
||||
return registry.GetGitHubCopilotModels()
|
||||
}
|
||||
|
||||
copilotAuth := copilotauth.NewCopilotAuth(cfg)
|
||||
|
||||
entries, err := copilotAuth.ListModelsWithGitHubToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
log.Warnf("github-copilot: failed to fetch dynamic models: %v, using static models", err)
|
||||
return registry.GetGitHubCopilotModels()
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
log.Debug("github-copilot: API returned no models, using static models")
|
||||
return registry.GetGitHubCopilotModels()
|
||||
}
|
||||
|
||||
// Build a lookup from the static definitions so we can enrich dynamic entries
|
||||
// with known context lengths, thinking support, etc.
|
||||
staticMap := make(map[string]*registry.ModelInfo)
|
||||
for _, m := range registry.GetGitHubCopilotModels() {
|
||||
staticMap[m.ID] = m
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
models := make([]*registry.ModelInfo, 0, len(entries))
|
||||
seen := make(map[string]struct{}, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.ID == "" {
|
||||
continue
|
||||
}
|
||||
// Deduplicate model IDs to avoid incorrect reference counting.
|
||||
if _, dup := seen[entry.ID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[entry.ID] = struct{}{}
|
||||
|
||||
m := ®istry.ModelInfo{
|
||||
ID: entry.ID,
|
||||
Object: "model",
|
||||
Created: now,
|
||||
OwnedBy: "github-copilot",
|
||||
Type: "github-copilot",
|
||||
}
|
||||
|
||||
if entry.Created > 0 {
|
||||
m.Created = entry.Created
|
||||
}
|
||||
if entry.Name != "" {
|
||||
m.DisplayName = entry.Name
|
||||
} else {
|
||||
m.DisplayName = entry.ID
|
||||
}
|
||||
|
||||
// Merge known metadata from the static fallback list
|
||||
if static, ok := staticMap[entry.ID]; ok {
|
||||
if m.DisplayName == entry.ID && static.DisplayName != "" {
|
||||
m.DisplayName = static.DisplayName
|
||||
}
|
||||
m.Description = static.Description
|
||||
m.ContextLength = static.ContextLength
|
||||
m.MaxCompletionTokens = static.MaxCompletionTokens
|
||||
m.SupportedEndpoints = static.SupportedEndpoints
|
||||
m.Thinking = static.Thinking
|
||||
} else {
|
||||
// Sensible defaults for models not in the static list
|
||||
m.Description = entry.ID + " via GitHub Copilot"
|
||||
m.ContextLength = defaultCopilotContextLength
|
||||
m.MaxCompletionTokens = defaultCopilotMaxCompletionTokens
|
||||
}
|
||||
|
||||
models = append(models, m)
|
||||
}
|
||||
|
||||
log.Infof("github-copilot: fetched %d models from API", len(models))
|
||||
return models
|
||||
}
|
||||
|
||||
@@ -262,15 +262,15 @@ func TestApplyHeaders_XInitiator_UserOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHeaders_XInitiator_AgentWithAssistantAndUserToolResult(t *testing.T) {
|
||||
func TestApplyHeaders_XInitiator_UserWhenLastRoleIsUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := &GitHubCopilotExecutor{}
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)
|
||||
// Claude Code typical flow: last message is user (tool result), but has assistant in history
|
||||
// Last role governs the initiator decision.
|
||||
body := []byte(`{"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"I will read the file"},{"role":"user","content":"tool result here"}]}`)
|
||||
e.applyHeaders(req, "token", body)
|
||||
if got := req.Header.Get("X-Initiator"); got != "agent" {
|
||||
t.Fatalf("X-Initiator = %q, want agent (assistant exists in messages)", got)
|
||||
if got := req.Header.Get("X-Initiator"); got != "user" {
|
||||
t.Fatalf("X-Initiator = %q, want user (last role is user)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,39 @@ func TestApplyHeaders_XInitiator_AgentWithToolRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHeaders_XInitiator_InputArrayLastAssistantMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := &GitHubCopilotExecutor{}
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)
|
||||
body := []byte(`{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Hi"}]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello"}]}]}`)
|
||||
e.applyHeaders(req, "token", body)
|
||||
if got := req.Header.Get("X-Initiator"); got != "agent" {
|
||||
t.Fatalf("X-Initiator = %q, want agent (last role is assistant)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHeaders_XInitiator_InputArrayLastUserMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := &GitHubCopilotExecutor{}
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)
|
||||
body := []byte(`{"input":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I can help"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"Do X"}]}]}`)
|
||||
e.applyHeaders(req, "token", body)
|
||||
if got := req.Header.Get("X-Initiator"); got != "user" {
|
||||
t.Fatalf("X-Initiator = %q, want user (last role is user)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHeaders_XInitiator_InputArrayLastFunctionCallOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := &GitHubCopilotExecutor{}
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)
|
||||
body := []byte(`{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Use tool"}]},{"type":"function_call","call_id":"c1","name":"Read","arguments":"{}"},{"type":"function_call_output","call_id":"c1","output":"ok"}]}`)
|
||||
e.applyHeaders(req, "token", body)
|
||||
if got := req.Header.Get("X-Initiator"); got != "agent" {
|
||||
t.Fatalf("X-Initiator = %q, want agent (last item maps to tool role)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for x-github-api-version header (Problem M) ---
|
||||
|
||||
func TestApplyHeaders_GitHubAPIVersion(t *testing.T) {
|
||||
|
||||
@@ -2458,7 +2458,6 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
||||
reader := bufio.NewReaderSize(body, 20*1024*1024) // 20MB buffer to match other providers
|
||||
var totalUsage usage.Detail
|
||||
var hasToolUses bool // Track if any tool uses were emitted
|
||||
var hasTruncatedTools bool // Track if any tool uses were truncated
|
||||
var upstreamStopReason string // Track stop_reason from upstream events
|
||||
|
||||
// Tool use state tracking for input buffering and deduplication
|
||||
@@ -3286,59 +3285,9 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
||||
|
||||
// Emit completed tool uses
|
||||
for _, tu := range completedToolUses {
|
||||
// Check if this tool was truncated - emit with SOFT_LIMIT_REACHED marker
|
||||
// Skip truncated tools - don't emit fake marker tool_use
|
||||
if tu.IsTruncated {
|
||||
hasTruncatedTools = true
|
||||
log.Infof("kiro: streamToChannel emitting truncated tool with SOFT_LIMIT_REACHED: %s (ID: %s)", tu.Name, tu.ToolUseID)
|
||||
|
||||
// Close text block if open
|
||||
if isTextBlockOpen && contentBlockIndex >= 0 {
|
||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||
for _, chunk := range sseData {
|
||||
if chunk != "" {
|
||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
||||
}
|
||||
}
|
||||
isTextBlockOpen = false
|
||||
}
|
||||
|
||||
contentBlockIndex++
|
||||
|
||||
// Emit tool_use with SOFT_LIMIT_REACHED marker input
|
||||
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, tu.Name)
|
||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
||||
for _, chunk := range sseData {
|
||||
if chunk != "" {
|
||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
||||
}
|
||||
}
|
||||
|
||||
// Build SOFT_LIMIT_REACHED marker input
|
||||
markerInput := map[string]interface{}{
|
||||
"_status": "SOFT_LIMIT_REACHED",
|
||||
"_message": "Tool output was truncated. Split content into smaller chunks (max 300 lines). Due to potential model hallucination, you MUST re-fetch the current working directory and generate the correct file_path.",
|
||||
}
|
||||
|
||||
markerJSON, _ := json.Marshal(markerInput)
|
||||
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(markerJSON), contentBlockIndex)
|
||||
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
|
||||
for _, chunk := range sseData {
|
||||
if chunk != "" {
|
||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
||||
}
|
||||
}
|
||||
|
||||
// Close tool_use block
|
||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
||||
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
||||
for _, chunk := range sseData {
|
||||
if chunk != "" {
|
||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
||||
}
|
||||
}
|
||||
|
||||
hasToolUses = true // Keep this so stop_reason = tool_use
|
||||
log.Warnf("kiro: streamToChannel skipping truncated tool: %s (ID: %s)", tu.Name, tu.ToolUseID)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -3640,12 +3589,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
||||
}
|
||||
|
||||
// Determine stop reason: prefer upstream, then detect tool_use, default to end_turn
|
||||
// SOFT_LIMIT_REACHED: Keep stop_reason = "tool_use" so Claude continues the loop
|
||||
stopReason := upstreamStopReason
|
||||
if hasTruncatedTools {
|
||||
// Log that we're using SOFT_LIMIT_REACHED approach
|
||||
log.Infof("kiro: streamToChannel using SOFT_LIMIT_REACHED - keeping stop_reason=tool_use for truncated tools")
|
||||
}
|
||||
if stopReason == "" {
|
||||
if hasToolUses {
|
||||
stopReason = "tool_use"
|
||||
|
||||
@@ -293,7 +293,7 @@ func normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat stri
|
||||
if config.Mode != ModeLevel {
|
||||
return config
|
||||
}
|
||||
if !isBudgetBasedProvider(toFormat) || !isLevelBasedProvider(fromFormat) {
|
||||
if !isBudgetCapableProvider(toFormat) {
|
||||
return config
|
||||
}
|
||||
budget, ok := ConvertLevelToBudget(string(config.Level))
|
||||
@@ -353,6 +353,26 @@ func extractClaudeConfig(body []byte) ThinkingConfig {
|
||||
if thinkingType == "disabled" {
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
}
|
||||
if thinkingType == "adaptive" || thinkingType == "auto" {
|
||||
// Claude adaptive thinking uses output_config.effort (low/medium/high/max).
|
||||
// We only treat it as a thinking config when effort is explicitly present;
|
||||
// otherwise we passthrough and let upstream defaults apply.
|
||||
if effort := gjson.GetBytes(body, "output_config.effort"); effort.Exists() && effort.Type == gjson.String {
|
||||
value := strings.ToLower(strings.TrimSpace(effort.String()))
|
||||
if value == "" {
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
switch value {
|
||||
case "none":
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
case "auto":
|
||||
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||
default:
|
||||
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
|
||||
}
|
||||
}
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
|
||||
// Check budget_tokens
|
||||
if budget := gjson.GetBytes(body, "thinking.budget_tokens"); budget.Exists() {
|
||||
|
||||
@@ -16,6 +16,9 @@ var levelToBudgetMap = map[string]int{
|
||||
"medium": 8192,
|
||||
"high": 24576,
|
||||
"xhigh": 32768,
|
||||
// "max" is used by Claude adaptive thinking effort. We map it to a large budget
|
||||
// and rely on per-model clamping when converting to budget-only providers.
|
||||
"max": 128000,
|
||||
}
|
||||
|
||||
// ConvertLevelToBudget converts a thinking level to a budget value.
|
||||
@@ -31,6 +34,7 @@ var levelToBudgetMap = map[string]int{
|
||||
// - medium → 8192
|
||||
// - high → 24576
|
||||
// - xhigh → 32768
|
||||
// - max → 128000
|
||||
//
|
||||
// Returns:
|
||||
// - budget: The converted budget value
|
||||
@@ -92,6 +96,43 @@ func ConvertBudgetToLevel(budget int) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// HasLevel reports whether the given target level exists in the levels slice.
|
||||
// Matching is case-insensitive with leading/trailing whitespace trimmed.
|
||||
func HasLevel(levels []string, target string) bool {
|
||||
for _, level := range levels {
|
||||
if strings.EqualFold(strings.TrimSpace(level), target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MapToClaudeEffort maps a generic thinking level string to a Claude adaptive
|
||||
// thinking effort value (low/medium/high/max).
|
||||
//
|
||||
// supportsMax indicates whether the target model supports "max" effort.
|
||||
// Returns the mapped effort and true if the level is valid, or ("", false) otherwise.
|
||||
func MapToClaudeEffort(level string, supportsMax bool) (string, bool) {
|
||||
level = strings.ToLower(strings.TrimSpace(level))
|
||||
switch level {
|
||||
case "":
|
||||
return "", false
|
||||
case "minimal":
|
||||
return "low", true
|
||||
case "low", "medium", "high":
|
||||
return level, true
|
||||
case "xhigh", "max":
|
||||
if supportsMax {
|
||||
return "max", true
|
||||
}
|
||||
return "high", true
|
||||
case "auto":
|
||||
return "high", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// ModelCapability describes the thinking format support of a model.
|
||||
type ModelCapability int
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Package claude implements thinking configuration scaffolding for Claude models.
|
||||
//
|
||||
// Claude models use the thinking.budget_tokens format with values in the range
|
||||
// 1024-128000. Some Claude models support ZeroAllowed (sonnet-4-5, opus-4-5),
|
||||
// while older models do not.
|
||||
// Claude models support two thinking control styles:
|
||||
// - Manual thinking: thinking.type="enabled" with thinking.budget_tokens (token budget)
|
||||
// - Adaptive thinking (Claude 4.6): thinking.type="adaptive" with output_config.effort (low/medium/high/max)
|
||||
//
|
||||
// Some Claude models support ZeroAllowed (sonnet-4-5, opus-4-5), while older models do not.
|
||||
// See: _bmad-output/planning-artifacts/architecture.md#Epic-6
|
||||
package claude
|
||||
|
||||
@@ -34,7 +36,11 @@ func init() {
|
||||
// - Budget clamping to model range
|
||||
// - ZeroAllowed constraint enforcement
|
||||
//
|
||||
// Apply only processes ModeBudget and ModeNone; other modes are passed through unchanged.
|
||||
// Apply processes:
|
||||
// - ModeBudget: manual thinking budget_tokens
|
||||
// - ModeLevel: adaptive thinking effort (Claude 4.6)
|
||||
// - ModeAuto: provider default adaptive/manual behavior
|
||||
// - ModeNone: disabled
|
||||
//
|
||||
// Expected output format when enabled:
|
||||
//
|
||||
@@ -45,6 +51,17 @@ func init() {
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected output format for adaptive:
|
||||
//
|
||||
// {
|
||||
// "thinking": {
|
||||
// "type": "adaptive"
|
||||
// },
|
||||
// "output_config": {
|
||||
// "effort": "high"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected output format when disabled:
|
||||
//
|
||||
// {
|
||||
@@ -60,30 +77,91 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Only process ModeBudget and ModeNone; other modes pass through
|
||||
// (caller should use ValidateConfig first to normalize modes)
|
||||
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
// Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced)
|
||||
// Decide enabled/disabled based on budget value
|
||||
if config.Budget == 0 {
|
||||
supportsAdaptive := modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0
|
||||
|
||||
switch config.Mode {
|
||||
case thinking.ModeNone:
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
|
||||
case thinking.ModeLevel:
|
||||
// Adaptive thinking effort is only valid when the model advertises discrete levels.
|
||||
// (Claude 4.6 uses output_config.effort.)
|
||||
if supportsAdaptive && config.Level != "" {
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Fallback for non-adaptive Claude models: convert level to budget_tokens.
|
||||
if budget, ok := thinking.ConvertLevelToBudget(string(config.Level)); ok {
|
||||
config.Mode = thinking.ModeBudget
|
||||
config.Budget = budget
|
||||
config.Level = ""
|
||||
} else {
|
||||
return body, nil
|
||||
}
|
||||
fallthrough
|
||||
|
||||
case thinking.ModeBudget:
|
||||
// Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced).
|
||||
// Decide enabled/disabled based on budget value.
|
||||
if config.Budget == 0 {
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||
result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
|
||||
// Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint).
|
||||
result = a.normalizeClaudeBudget(result, config.Budget, modelInfo)
|
||||
return result, nil
|
||||
|
||||
case thinking.ModeAuto:
|
||||
// For Claude 4.6 models, auto maps to adaptive thinking with upstream defaults.
|
||||
if supportsAdaptive {
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
// Explicit effort is optional for adaptive thinking; omit it to allow upstream default.
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Legacy fallback: enable thinking without specifying budget_tokens.
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||
result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
|
||||
|
||||
// Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint)
|
||||
result = a.normalizeClaudeBudget(result, config.Budget, modelInfo)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// normalizeClaudeBudget applies Claude-specific constraints to ensure max_tokens > budget_tokens.
|
||||
@@ -141,7 +219,7 @@ func (a *Applier) effectiveMaxTokens(body []byte, modelInfo *registry.ModelInfo)
|
||||
}
|
||||
|
||||
func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto && config.Mode != thinking.ModeLevel {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
@@ -153,14 +231,36 @@ func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte,
|
||||
case thinking.ModeNone:
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
case thinking.ModeAuto:
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
case thinking.ModeLevel:
|
||||
// For user-defined models, interpret ModeLevel as Claude adaptive thinking effort.
|
||||
// Upstream is responsible for validating whether the target model supports it.
|
||||
if config.Level == "" {
|
||||
return body, nil
|
||||
}
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level))
|
||||
return result, nil
|
||||
default:
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||
result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
package codex
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -68,7 +66,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
|
||||
effort := ""
|
||||
support := modelInfo.Thinking
|
||||
if config.Budget == 0 {
|
||||
if support.ZeroAllowed || hasLevel(support.Levels, string(thinking.LevelNone)) {
|
||||
if support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) {
|
||||
effort = string(thinking.LevelNone)
|
||||
}
|
||||
}
|
||||
@@ -120,12 +118,3 @@ func applyCompatibleCodex(body []byte, config thinking.ThinkingConfig) ([]byte,
|
||||
result, _ := sjson.SetBytes(body, "reasoning.effort", effort)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hasLevel(levels []string, target string) bool {
|
||||
for _, level := range levels {
|
||||
if strings.EqualFold(strings.TrimSpace(level), target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -65,7 +63,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
|
||||
effort := ""
|
||||
support := modelInfo.Thinking
|
||||
if config.Budget == 0 {
|
||||
if support.ZeroAllowed || hasLevel(support.Levels, string(thinking.LevelNone)) {
|
||||
if support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) {
|
||||
effort = string(thinking.LevelNone)
|
||||
}
|
||||
}
|
||||
@@ -117,12 +115,3 @@ func applyCompatibleOpenAI(body []byte, config thinking.ThinkingConfig) ([]byte,
|
||||
result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hasLevel(levels []string, target string) bool {
|
||||
for _, level := range levels {
|
||||
if strings.EqualFold(strings.TrimSpace(level), target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func StripThinkingConfig(body []byte, provider string) []byte {
|
||||
var paths []string
|
||||
switch provider {
|
||||
case "claude":
|
||||
paths = []string{"thinking"}
|
||||
paths = []string{"thinking", "output_config.effort"}
|
||||
case "gemini":
|
||||
paths = []string{"generationConfig.thinkingConfig"}
|
||||
case "gemini-cli", "antigravity":
|
||||
@@ -59,5 +59,12 @@ func StripThinkingConfig(body []byte, provider string) []byte {
|
||||
for _, path := range paths {
|
||||
result, _ = sjson.DeleteBytes(result, path)
|
||||
}
|
||||
|
||||
// Avoid leaving an empty output_config object for Claude when effort was the only field.
|
||||
if provider == "claude" {
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ func ParseSpecialSuffix(rawSuffix string) (mode ThinkingMode, ok bool) {
|
||||
// ParseLevelSuffix attempts to parse a raw suffix as a discrete thinking level.
|
||||
//
|
||||
// This function parses the raw suffix content (from ParseSuffix.RawSuffix) as a level.
|
||||
// Only discrete effort levels are valid: minimal, low, medium, high, xhigh.
|
||||
// Only discrete effort levels are valid: minimal, low, medium, high, xhigh, max.
|
||||
// Level matching is case-insensitive.
|
||||
//
|
||||
// Special values (none, auto) are NOT handled by this function; use ParseSpecialSuffix
|
||||
@@ -140,6 +140,8 @@ func ParseLevelSuffix(rawSuffix string) (level ThinkingLevel, ok bool) {
|
||||
return LevelHigh, true
|
||||
case "xhigh":
|
||||
return LevelXHigh, true
|
||||
case "max":
|
||||
return LevelMax, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ const (
|
||||
LevelHigh ThinkingLevel = "high"
|
||||
// LevelXHigh sets extra-high thinking effort
|
||||
LevelXHigh ThinkingLevel = "xhigh"
|
||||
// LevelMax sets maximum thinking effort.
|
||||
// This is currently used by Claude 4.6 adaptive thinking (opus supports "max").
|
||||
LevelMax ThinkingLevel = "max"
|
||||
)
|
||||
|
||||
// ThinkingConfig represents a unified thinking configuration.
|
||||
|
||||
@@ -53,7 +53,17 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
allowClampUnsupported := isBudgetBasedProvider(fromFormat) && isLevelBasedProvider(toFormat)
|
||||
// allowClampUnsupported determines whether to clamp unsupported levels instead of returning an error.
|
||||
// This applies when crossing provider families (e.g., openai→gemini, claude→gemini) and the target
|
||||
// model supports discrete levels. Same-family conversions require strict validation.
|
||||
toCapability := detectModelCapability(modelInfo)
|
||||
toHasLevelSupport := toCapability == CapabilityLevelOnly || toCapability == CapabilityHybrid
|
||||
allowClampUnsupported := toHasLevelSupport && !isSameProviderFamily(fromFormat, toFormat)
|
||||
|
||||
// strictBudget determines whether to enforce strict budget range validation.
|
||||
// This applies when: (1) config comes from request body (not suffix), (2) source format is known,
|
||||
// and (3) source and target are in the same provider family. Cross-family or suffix-based configs
|
||||
// are clamped instead of rejected to improve interoperability.
|
||||
strictBudget := !fromSuffix && fromFormat != "" && isSameProviderFamily(fromFormat, toFormat)
|
||||
budgetDerivedFromLevel := false
|
||||
|
||||
@@ -201,7 +211,7 @@ func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupp
|
||||
}
|
||||
|
||||
// standardLevelOrder defines the canonical ordering of thinking levels from lowest to highest.
|
||||
var standardLevelOrder = []ThinkingLevel{LevelMinimal, LevelLow, LevelMedium, LevelHigh, LevelXHigh}
|
||||
var standardLevelOrder = []ThinkingLevel{LevelMinimal, LevelLow, LevelMedium, LevelHigh, LevelXHigh, LevelMax}
|
||||
|
||||
// clampLevel clamps the given level to the nearest supported level.
|
||||
// On tie, prefers the lower level.
|
||||
@@ -325,7 +335,9 @@ func normalizeLevels(levels []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func isBudgetBasedProvider(provider string) bool {
|
||||
// isBudgetCapableProvider returns true if the provider supports budget-based thinking.
|
||||
// These providers may also support level-based thinking (hybrid models).
|
||||
func isBudgetCapableProvider(provider string) bool {
|
||||
switch provider {
|
||||
case "gemini", "gemini-cli", "antigravity", "claude":
|
||||
return true
|
||||
@@ -334,15 +346,6 @@ func isBudgetBasedProvider(provider string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isLevelBasedProvider(provider string) bool {
|
||||
switch provider {
|
||||
case "openai", "openai-response", "codex":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isGeminiFamily(provider string) bool {
|
||||
switch provider {
|
||||
case "gemini", "gemini-cli", "antigravity":
|
||||
@@ -352,11 +355,21 @@ func isGeminiFamily(provider string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isOpenAIFamily(provider string) bool {
|
||||
switch provider {
|
||||
case "openai", "openai-response", "codex":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isSameProviderFamily(from, to string) bool {
|
||||
if from == to {
|
||||
return true
|
||||
}
|
||||
return isGeminiFamily(from) && isGeminiFamily(to)
|
||||
return (isGeminiFamily(from) && isGeminiFamily(to)) ||
|
||||
(isOpenAIFamily(from) && isOpenAIFamily(to))
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
|
||||
@@ -431,6 +431,33 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
out, _ = sjson.SetRaw(out, "request.tools", toolsJSON)
|
||||
}
|
||||
|
||||
// tool_choice
|
||||
toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice")
|
||||
if toolChoiceResult.Exists() {
|
||||
toolChoiceType := ""
|
||||
toolChoiceName := ""
|
||||
if toolChoiceResult.IsObject() {
|
||||
toolChoiceType = toolChoiceResult.Get("type").String()
|
||||
toolChoiceName = toolChoiceResult.Get("name").String()
|
||||
} else if toolChoiceResult.Type == gjson.String {
|
||||
toolChoiceType = toolChoiceResult.String()
|
||||
}
|
||||
|
||||
switch toolChoiceType {
|
||||
case "auto":
|
||||
out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "AUTO")
|
||||
case "none":
|
||||
out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "NONE")
|
||||
case "any":
|
||||
out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "ANY")
|
||||
case "tool":
|
||||
out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "ANY")
|
||||
if toolChoiceName != "" {
|
||||
out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{toolChoiceName})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
|
||||
if t := gjson.GetBytes(rawJSON, "thinking"); enableThoughtTranslate && t.Exists() && t.IsObject() {
|
||||
switch t.Get("type").String() {
|
||||
@@ -441,9 +468,22 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
||||
}
|
||||
case "adaptive", "auto":
|
||||
// Keep adaptive/auto as a high level sentinel; ApplyThinking resolves it
|
||||
// to model-specific max capability.
|
||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high")
|
||||
// For adaptive thinking:
|
||||
// - If output_config.effort is explicitly present, pass through as thinkingLevel.
|
||||
// - Otherwise, treat it as "enabled with target-model maximum" and emit high.
|
||||
// ApplyThinking handles clamping to target model's supported levels.
|
||||
effort := ""
|
||||
if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String {
|
||||
effort = strings.ToLower(strings.TrimSpace(v.String()))
|
||||
}
|
||||
if effort != "" {
|
||||
if effort == "max" {
|
||||
effort = "high"
|
||||
}
|
||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort)
|
||||
} else {
|
||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high")
|
||||
}
|
||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,42 @@ func TestConvertClaudeRequestToAntigravity_ToolDeclarations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToAntigravity_ToolChoice_SpecificTool(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hi"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "json",
|
||||
"description": "A JSON tool",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": {"type": "tool", "name": "json"}
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToAntigravity("gemini-3-flash-preview", inputJSON, false)
|
||||
outputStr := string(output)
|
||||
|
||||
if got := gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
|
||||
t.Fatalf("Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got)
|
||||
}
|
||||
allowed := gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Array()
|
||||
if len(allowed) != 1 || allowed[0].String() != "json" {
|
||||
t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-3-5-sonnet-20240620",
|
||||
@@ -1199,3 +1235,64 @@ func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *t
|
||||
t.Errorf("Interleaved thinking hint should be in created systemInstruction, got: %v", sysInstruction.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToAntigravity_AdaptiveThinking_EffortLevels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
effort string
|
||||
expected string
|
||||
}{
|
||||
{"low", "low", "low"},
|
||||
{"medium", "medium", "medium"},
|
||||
{"high", "high", "high"},
|
||||
{"max", "max", "high"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-opus-4-6-thinking",
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
||||
"thinking": {"type": "adaptive"},
|
||||
"output_config": {"effort": "` + tt.effort + `"}
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
|
||||
outputStr := string(output)
|
||||
|
||||
thinkingConfig := gjson.Get(outputStr, "request.generationConfig.thinkingConfig")
|
||||
if !thinkingConfig.Exists() {
|
||||
t.Fatal("thinkingConfig should exist for adaptive thinking")
|
||||
}
|
||||
if thinkingConfig.Get("thinkingLevel").String() != tt.expected {
|
||||
t.Errorf("Expected thinkingLevel %q, got %q", tt.expected, thinkingConfig.Get("thinkingLevel").String())
|
||||
}
|
||||
if !thinkingConfig.Get("includeThoughts").Bool() {
|
||||
t.Error("includeThoughts should be true")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToAntigravity_AdaptiveThinking_NoEffort(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-opus-4-6-thinking",
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
||||
"thinking": {"type": "adaptive"}
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
|
||||
outputStr := string(output)
|
||||
|
||||
thinkingConfig := gjson.Get(outputStr, "request.generationConfig.thinkingConfig")
|
||||
if !thinkingConfig.Exists() {
|
||||
t.Fatal("thinkingConfig should exist for adaptive thinking without effort")
|
||||
}
|
||||
if thinkingConfig.Get("thinkingLevel").String() != "high" {
|
||||
t.Errorf("Expected default thinkingLevel \"high\", got %q", thinkingConfig.Get("thinkingLevel").String())
|
||||
}
|
||||
if !thinkingConfig.Get("includeThoughts").Bool() {
|
||||
t.Error("includeThoughts should be true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -115,24 +116,47 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
|
||||
// Include thoughts configuration for reasoning process visibility
|
||||
// Translator only does format conversion, ApplyThinking handles model capability validation.
|
||||
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||
mi := registry.LookupModelInfo(modelName, "claude")
|
||||
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
|
||||
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
|
||||
|
||||
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
|
||||
// validation errors since validate treats same-provider unsupported levels as errors.
|
||||
thinkingLevel := thinkingConfig.Get("thinkingLevel")
|
||||
if !thinkingLevel.Exists() {
|
||||
thinkingLevel = thinkingConfig.Get("thinking_level")
|
||||
}
|
||||
if thinkingLevel.Exists() {
|
||||
level := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
|
||||
switch level {
|
||||
case "":
|
||||
case "none":
|
||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
case "auto":
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
default:
|
||||
if budget, ok := thinking.ConvertLevelToBudget(level); ok {
|
||||
if supportsAdaptive {
|
||||
switch level {
|
||||
case "":
|
||||
case "none":
|
||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.Delete(out, "output_config.effort")
|
||||
default:
|
||||
if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok {
|
||||
level = mapped
|
||||
}
|
||||
out, _ = sjson.Set(out, "thinking.type", "adaptive")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.Set(out, "output_config.effort", level)
|
||||
}
|
||||
} else {
|
||||
switch level {
|
||||
case "":
|
||||
case "none":
|
||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
case "auto":
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
default:
|
||||
if budget, ok := thinking.ConvertLevelToBudget(level); ok {
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -142,16 +166,35 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
|
||||
}
|
||||
if thinkingBudget.Exists() {
|
||||
budget := int(thinkingBudget.Int())
|
||||
switch budget {
|
||||
case 0:
|
||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
case -1:
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
default:
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||
if supportsAdaptive {
|
||||
switch budget {
|
||||
case 0:
|
||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.Delete(out, "output_config.effort")
|
||||
default:
|
||||
level, ok := thinking.ConvertBudgetToLevel(budget)
|
||||
if ok {
|
||||
if mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM {
|
||||
level = mapped
|
||||
}
|
||||
out, _ = sjson.Set(out, "thinking.type", "adaptive")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.Set(out, "output_config.effort", level)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch budget {
|
||||
case 0:
|
||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
case -1:
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
default:
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||
}
|
||||
}
|
||||
} else if includeThoughts := thinkingConfig.Get("includeThoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -68,17 +69,45 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
||||
if v := root.Get("reasoning_effort"); v.Exists() {
|
||||
effort := strings.ToLower(strings.TrimSpace(v.String()))
|
||||
if effort != "" {
|
||||
budget, ok := thinking.ConvertLevelToBudget(effort)
|
||||
if ok {
|
||||
switch budget {
|
||||
case 0:
|
||||
mi := registry.LookupModelInfo(modelName, "claude")
|
||||
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
|
||||
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
|
||||
|
||||
// Claude 4.6 supports adaptive thinking with output_config.effort.
|
||||
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
|
||||
// validation errors since validate treats same-provider unsupported levels as errors.
|
||||
if supportsAdaptive {
|
||||
switch effort {
|
||||
case "none":
|
||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||
case -1:
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.Delete(out, "output_config.effort")
|
||||
case "auto":
|
||||
out, _ = sjson.Set(out, "thinking.type", "adaptive")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.Delete(out, "output_config.effort")
|
||||
default:
|
||||
if budget > 0 {
|
||||
if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
|
||||
effort = mapped
|
||||
}
|
||||
out, _ = sjson.Set(out, "thinking.type", "adaptive")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.Set(out, "output_config.effort", effort)
|
||||
}
|
||||
} else {
|
||||
// Legacy/manual thinking (budget_tokens).
|
||||
budget, ok := thinking.ConvertLevelToBudget(effort)
|
||||
if ok {
|
||||
switch budget {
|
||||
case 0:
|
||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||
case -1:
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||
default:
|
||||
if budget > 0 {
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -56,17 +57,45 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
||||
if v := root.Get("reasoning.effort"); v.Exists() {
|
||||
effort := strings.ToLower(strings.TrimSpace(v.String()))
|
||||
if effort != "" {
|
||||
budget, ok := thinking.ConvertLevelToBudget(effort)
|
||||
if ok {
|
||||
switch budget {
|
||||
case 0:
|
||||
mi := registry.LookupModelInfo(modelName, "claude")
|
||||
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
|
||||
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
|
||||
|
||||
// Claude 4.6 supports adaptive thinking with output_config.effort.
|
||||
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
|
||||
// validation errors since validate treats same-provider unsupported levels as errors.
|
||||
if supportsAdaptive {
|
||||
switch effort {
|
||||
case "none":
|
||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||
case -1:
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.Delete(out, "output_config.effort")
|
||||
case "auto":
|
||||
out, _ = sjson.Set(out, "thinking.type", "adaptive")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.Delete(out, "output_config.effort")
|
||||
default:
|
||||
if budget > 0 {
|
||||
if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
|
||||
effort = mapped
|
||||
}
|
||||
out, _ = sjson.Set(out, "thinking.type", "adaptive")
|
||||
out, _ = sjson.Delete(out, "thinking.budget_tokens")
|
||||
out, _ = sjson.Set(out, "output_config.effort", effort)
|
||||
}
|
||||
} else {
|
||||
// Legacy/manual thinking (budget_tokens).
|
||||
budget, ok := thinking.ConvertLevelToBudget(effort)
|
||||
if ok {
|
||||
switch budget {
|
||||
case 0:
|
||||
out, _ = sjson.Set(out, "thinking.type", "disabled")
|
||||
case -1:
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||
default:
|
||||
if budget > 0 {
|
||||
out, _ = sjson.Set(out, "thinking.type", "enabled")
|
||||
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,51 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
flushMessage()
|
||||
functionCallOutputMessage := `{"type":"function_call_output"}`
|
||||
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "call_id", messageContentResult.Get("tool_use_id").String())
|
||||
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
|
||||
|
||||
contentResult := messageContentResult.Get("content")
|
||||
if contentResult.IsArray() {
|
||||
toolResultContentIndex := 0
|
||||
toolResultContent := `[]`
|
||||
contentResults := contentResult.Array()
|
||||
for k := 0; k < len(contentResults); k++ {
|
||||
toolResultContentType := contentResults[k].Get("type").String()
|
||||
if toolResultContentType == "image" {
|
||||
sourceResult := contentResults[k].Get("source")
|
||||
if sourceResult.Exists() {
|
||||
data := sourceResult.Get("data").String()
|
||||
if data == "" {
|
||||
data = sourceResult.Get("base64").String()
|
||||
}
|
||||
if data != "" {
|
||||
mediaType := sourceResult.Get("media_type").String()
|
||||
if mediaType == "" {
|
||||
mediaType = sourceResult.Get("mime_type").String()
|
||||
}
|
||||
if mediaType == "" {
|
||||
mediaType = "application/octet-stream"
|
||||
}
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, data)
|
||||
|
||||
toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.type", toolResultContentIndex), "input_image")
|
||||
toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.image_url", toolResultContentIndex), dataURL)
|
||||
toolResultContentIndex++
|
||||
}
|
||||
}
|
||||
} else if toolResultContentType == "text" {
|
||||
toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.type", toolResultContentIndex), "input_text")
|
||||
toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.text", toolResultContentIndex), contentResults[k].Get("text").String())
|
||||
toolResultContentIndex++
|
||||
}
|
||||
}
|
||||
if toolResultContent != `[]` {
|
||||
functionCallOutputMessage, _ = sjson.SetRaw(functionCallOutputMessage, "output", toolResultContent)
|
||||
} else {
|
||||
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
|
||||
}
|
||||
} else {
|
||||
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
|
||||
}
|
||||
|
||||
template, _ = sjson.SetRaw(template, "input.-1", functionCallOutputMessage)
|
||||
}
|
||||
}
|
||||
@@ -211,6 +255,8 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
tool, _ = sjson.SetRaw(tool, "parameters", normalizeToolParameters(toolResult.Get("input_schema").Raw))
|
||||
tool, _ = sjson.Delete(tool, "input_schema")
|
||||
tool, _ = sjson.Delete(tool, "parameters.$schema")
|
||||
tool, _ = sjson.Delete(tool, "cache_control")
|
||||
tool, _ = sjson.Delete(tool, "defer_loading")
|
||||
tool, _ = sjson.Set(tool, "strict", false)
|
||||
template, _ = sjson.SetRaw(template, "tools.-1", tool)
|
||||
}
|
||||
@@ -231,9 +277,17 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
}
|
||||
}
|
||||
case "adaptive", "auto":
|
||||
// Claude adaptive/auto means "enable with max capacity"; keep it as highest level
|
||||
// and let ApplyThinking normalize per target model capability.
|
||||
reasoningEffort = string(thinking.LevelXHigh)
|
||||
// Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6).
|
||||
// Pass through directly; ApplyThinking handles clamping to target model's levels.
|
||||
effort := ""
|
||||
if v := rootResult.Get("output_config.effort"); v.Exists() && v.Type == gjson.String {
|
||||
effort = strings.ToLower(strings.TrimSpace(v.String()))
|
||||
}
|
||||
if effort != "" {
|
||||
reasoningEffort = effort
|
||||
} else {
|
||||
reasoningEffort = string(thinking.LevelXHigh)
|
||||
}
|
||||
case "disabled":
|
||||
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
|
||||
reasoningEffort = effort
|
||||
|
||||
@@ -74,8 +74,13 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
|
||||
}
|
||||
|
||||
// Extract and set the model version.
|
||||
cachedModel := (*param).(*ConvertCliToOpenAIParams).Model
|
||||
if modelResult := gjson.GetBytes(rawJSON, "model"); modelResult.Exists() {
|
||||
template, _ = sjson.Set(template, "model", modelResult.String())
|
||||
} else if cachedModel != "" {
|
||||
template, _ = sjson.Set(template, "model", cachedModel)
|
||||
} else if modelName != "" {
|
||||
template, _ = sjson.Set(template, "model", modelName)
|
||||
}
|
||||
|
||||
template, _ = sjson.Set(template, "created", (*param).(*ConvertCliToOpenAIParams).CreatedAt)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package chat_completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_StreamSetsModelFromResponseCreated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
modelName := "gpt-5.3-codex"
|
||||
|
||||
out := ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {"type":"response.created","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.3-codex"}}`), ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected no output for response.created, got %d chunks", len(out))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {"type":"response.output_text.delta","delta":"hello"}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
gotModel := gjson.Get(out[0], "model").String()
|
||||
if gotModel != modelName {
|
||||
t.Fatalf("expected model %q, got %q", modelName, gotModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToOpenAI_FirstChunkUsesRequestModelName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
modelName := "gpt-5.3-codex"
|
||||
|
||||
out := ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {"type":"response.output_text.delta","delta":"hello"}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
gotModel := gjson.Get(out[0], "model").String()
|
||||
if gotModel != modelName {
|
||||
t.Fatalf("expected model %q, got %q", modelName, gotModel)
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
|
||||
tool, _ = sjson.Delete(tool, "input_examples")
|
||||
tool, _ = sjson.Delete(tool, "type")
|
||||
tool, _ = sjson.Delete(tool, "cache_control")
|
||||
tool, _ = sjson.Delete(tool, "defer_loading")
|
||||
if gjson.Valid(tool) && gjson.Parse(tool).IsObject() {
|
||||
if !hasTools {
|
||||
out, _ = sjson.SetRaw(out, "request.tools", `[{"functionDeclarations":[]}]`)
|
||||
@@ -171,7 +172,35 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
|
||||
}
|
||||
}
|
||||
|
||||
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
|
||||
// tool_choice
|
||||
toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice")
|
||||
if toolChoiceResult.Exists() {
|
||||
toolChoiceType := ""
|
||||
toolChoiceName := ""
|
||||
if toolChoiceResult.IsObject() {
|
||||
toolChoiceType = toolChoiceResult.Get("type").String()
|
||||
toolChoiceName = toolChoiceResult.Get("name").String()
|
||||
} else if toolChoiceResult.Type == gjson.String {
|
||||
toolChoiceType = toolChoiceResult.String()
|
||||
}
|
||||
|
||||
switch toolChoiceType {
|
||||
case "auto":
|
||||
out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "AUTO")
|
||||
case "none":
|
||||
out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "NONE")
|
||||
case "any":
|
||||
out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "ANY")
|
||||
case "tool":
|
||||
out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "ANY")
|
||||
if toolChoiceName != "" {
|
||||
out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{toolChoiceName})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map Anthropic thinking -> Gemini CLI thinkingConfig when enabled
|
||||
// Translator only does format conversion, ApplyThinking handles model capability validation.
|
||||
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
|
||||
switch t.Get("type").String() {
|
||||
case "enabled":
|
||||
@@ -181,9 +210,19 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
|
||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
||||
}
|
||||
case "adaptive", "auto":
|
||||
// Keep adaptive/auto as a high level sentinel; ApplyThinking resolves it
|
||||
// to model-specific max capability.
|
||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high")
|
||||
// For adaptive thinking:
|
||||
// - If output_config.effort is explicitly present, pass through as thinkingLevel.
|
||||
// - Otherwise, treat it as "enabled with target-model maximum" and emit high.
|
||||
// ApplyThinking handles clamping to target model's supported levels.
|
||||
effort := ""
|
||||
if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String {
|
||||
effort = strings.ToLower(strings.TrimSpace(v.String()))
|
||||
}
|
||||
if effort != "" {
|
||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort)
|
||||
} else {
|
||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high")
|
||||
}
|
||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertClaudeRequestToCLI_ToolChoice_SpecificTool(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hi"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "json",
|
||||
"description": "A JSON tool",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": {"type": "tool", "name": "json"}
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToCLI("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
if got := gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
|
||||
t.Fatalf("Expected request.toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got)
|
||||
}
|
||||
allowed := gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Array()
|
||||
if len(allowed) != 1 || allowed[0].String() != "json" {
|
||||
t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Raw)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -136,6 +137,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
||||
tool, _ = sjson.Delete(tool, "input_examples")
|
||||
tool, _ = sjson.Delete(tool, "type")
|
||||
tool, _ = sjson.Delete(tool, "cache_control")
|
||||
tool, _ = sjson.Delete(tool, "defer_loading")
|
||||
if gjson.Valid(tool) && gjson.Parse(tool).IsObject() {
|
||||
if !hasTools {
|
||||
out, _ = sjson.SetRaw(out, "tools", `[{"functionDeclarations":[]}]`)
|
||||
@@ -151,7 +153,34 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
||||
}
|
||||
}
|
||||
|
||||
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled
|
||||
// tool_choice
|
||||
toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice")
|
||||
if toolChoiceResult.Exists() {
|
||||
toolChoiceType := ""
|
||||
toolChoiceName := ""
|
||||
if toolChoiceResult.IsObject() {
|
||||
toolChoiceType = toolChoiceResult.Get("type").String()
|
||||
toolChoiceName = toolChoiceResult.Get("name").String()
|
||||
} else if toolChoiceResult.Type == gjson.String {
|
||||
toolChoiceType = toolChoiceResult.String()
|
||||
}
|
||||
|
||||
switch toolChoiceType {
|
||||
case "auto":
|
||||
out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.mode", "AUTO")
|
||||
case "none":
|
||||
out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.mode", "NONE")
|
||||
case "any":
|
||||
out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.mode", "ANY")
|
||||
case "tool":
|
||||
out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.mode", "ANY")
|
||||
if toolChoiceName != "" {
|
||||
out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.allowedFunctionNames", []string{toolChoiceName})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map Anthropic thinking -> Gemini thinking config when enabled
|
||||
// Translator only does format conversion, ApplyThinking handles model capability validation.
|
||||
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
|
||||
switch t.Get("type").String() {
|
||||
@@ -162,9 +191,27 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true)
|
||||
}
|
||||
case "adaptive", "auto":
|
||||
// Keep adaptive/auto as a high level sentinel; ApplyThinking resolves it
|
||||
// to model-specific max capability.
|
||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", "high")
|
||||
// For adaptive thinking:
|
||||
// - If output_config.effort is explicitly present, pass through as thinkingLevel.
|
||||
// - Otherwise, treat it as "enabled with target-model maximum" and emit thinkingBudget=max.
|
||||
// ApplyThinking handles clamping to target model's supported levels.
|
||||
effort := ""
|
||||
if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String {
|
||||
effort = strings.ToLower(strings.TrimSpace(v.String()))
|
||||
}
|
||||
if effort != "" {
|
||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", effort)
|
||||
} else {
|
||||
maxBudget := 0
|
||||
if mi := registry.LookupModelInfo(modelName, "gemini"); mi != nil && mi.Thinking != nil {
|
||||
maxBudget = mi.Thinking.Max
|
||||
}
|
||||
if maxBudget > 0 {
|
||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", maxBudget)
|
||||
} else {
|
||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", "high")
|
||||
}
|
||||
}
|
||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertClaudeRequestToGemini_ToolChoice_SpecificTool(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hi"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "json",
|
||||
"description": "A JSON tool",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": {"type": "tool", "name": "json"}
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
if got := gjson.GetBytes(output, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
|
||||
t.Fatalf("Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got)
|
||||
}
|
||||
allowed := gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
|
||||
if len(allowed) != 1 || allowed[0].String() != "json" {
|
||||
t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Raw)
|
||||
}
|
||||
}
|
||||
@@ -354,22 +354,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
|
||||
funcDecl, _ = sjson.Set(funcDecl, "description", desc.String())
|
||||
}
|
||||
if params := tool.Get("parameters"); params.Exists() {
|
||||
// Convert parameter types from OpenAI format to Gemini format
|
||||
cleaned := params.Raw
|
||||
// Convert type values to uppercase for Gemini
|
||||
paramsResult := gjson.Parse(cleaned)
|
||||
if properties := paramsResult.Get("properties"); properties.Exists() {
|
||||
properties.ForEach(func(key, value gjson.Result) bool {
|
||||
if propType := value.Get("type"); propType.Exists() {
|
||||
upperType := strings.ToUpper(propType.String())
|
||||
cleaned, _ = sjson.Set(cleaned, "properties."+key.String()+".type", upperType)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
// Set the overall type to OBJECT
|
||||
cleaned, _ = sjson.Set(cleaned, "type", "OBJECT")
|
||||
funcDecl, _ = sjson.SetRaw(funcDecl, "parametersJsonSchema", cleaned)
|
||||
funcDecl, _ = sjson.SetRaw(funcDecl, "parametersJsonSchema", params.Raw)
|
||||
}
|
||||
|
||||
geminiTools, _ = sjson.SetRaw(geminiTools, "0.functionDeclarations.-1", funcDecl)
|
||||
|
||||
@@ -605,10 +605,6 @@ func convertClaudeToolsToKiro(tools gjson.Result) []KiroToolWrapper {
|
||||
})
|
||||
}
|
||||
|
||||
// Apply dynamic compression if total tools size exceeds threshold
|
||||
// This prevents 500 errors when Claude Code sends too many tools
|
||||
kiroTools = compressToolsIfNeeded(kiroTools)
|
||||
|
||||
return kiroTools
|
||||
}
|
||||
|
||||
@@ -858,34 +854,7 @@ func BuildUserMessageStruct(msg gjson.Result, modelID, origin string) (KiroUserI
|
||||
|
||||
var textContents []KiroTextContent
|
||||
|
||||
// Check if this tool_result contains error from our SOFT_LIMIT_REACHED tool_use
|
||||
// The client will return an error when trying to execute a tool with marker input
|
||||
resultStr := resultContent.String()
|
||||
isSoftLimitError := strings.Contains(resultStr, "SOFT_LIMIT_REACHED") ||
|
||||
strings.Contains(resultStr, "_status") ||
|
||||
strings.Contains(resultStr, "truncated") ||
|
||||
strings.Contains(resultStr, "missing required") ||
|
||||
strings.Contains(resultStr, "invalid input") ||
|
||||
strings.Contains(resultStr, "Error writing file")
|
||||
|
||||
if isError && isSoftLimitError {
|
||||
// Replace error content with SOFT_LIMIT_REACHED guidance
|
||||
log.Infof("kiro: detected SOFT_LIMIT_REACHED in tool_result for %s, replacing with guidance", toolUseID)
|
||||
softLimitMsg := `SOFT_LIMIT_REACHED
|
||||
|
||||
Your previous tool call was incomplete due to API output size limits.
|
||||
The content was PARTIALLY transmitted but NOT executed.
|
||||
|
||||
REQUIRED ACTION:
|
||||
1. Split your content into smaller chunks (max 300 lines per call)
|
||||
2. For file writes: Create file with first chunk, then use append for remaining
|
||||
3. Do NOT regenerate content you already attempted - continue from where you stopped
|
||||
|
||||
STATUS: This is NOT an error. Continue with smaller chunks.`
|
||||
textContents = append(textContents, KiroTextContent{Text: softLimitMsg})
|
||||
// Mark as SUCCESS so Claude doesn't treat it as a failure
|
||||
isError = false
|
||||
} else if resultContent.IsArray() {
|
||||
if resultContent.IsArray() {
|
||||
for _, item := range resultContent.Array() {
|
||||
if item.Get("type").String() == "text" {
|
||||
textContents = append(textContents, KiroTextContent{Text: item.Get("text").String()})
|
||||
|
||||
@@ -55,39 +55,18 @@ func BuildClaudeResponse(content string, toolUses []KiroToolUse, model string, u
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool_use blocks - emit truncated tools with SOFT_LIMIT_REACHED marker
|
||||
hasTruncatedTools := false
|
||||
// Add tool_use blocks - skip truncated tools and log warning
|
||||
for _, toolUse := range toolUses {
|
||||
if toolUse.IsTruncated && toolUse.TruncationInfo != nil {
|
||||
// Emit tool_use with SOFT_LIMIT_REACHED marker input
|
||||
hasTruncatedTools = true
|
||||
log.Infof("kiro: buildClaudeResponse emitting truncated tool with SOFT_LIMIT_REACHED: %s (ID: %s)", toolUse.Name, toolUse.ToolUseID)
|
||||
|
||||
markerInput := map[string]interface{}{
|
||||
"_status": "SOFT_LIMIT_REACHED",
|
||||
"_message": "Tool output was truncated. Split content into smaller chunks (max 300 lines). Due to potential model hallucination, you MUST re-fetch the current working directory and generate the correct file_path.",
|
||||
}
|
||||
|
||||
contentBlocks = append(contentBlocks, map[string]interface{}{
|
||||
"type": "tool_use",
|
||||
"id": toolUse.ToolUseID,
|
||||
"name": toolUse.Name,
|
||||
"input": markerInput,
|
||||
})
|
||||
} else {
|
||||
// Normal tool use
|
||||
contentBlocks = append(contentBlocks, map[string]interface{}{
|
||||
"type": "tool_use",
|
||||
"id": toolUse.ToolUseID,
|
||||
"name": toolUse.Name,
|
||||
"input": toolUse.Input,
|
||||
})
|
||||
log.Warnf("kiro: buildClaudeResponse skipping truncated tool: %s (ID: %s)", toolUse.Name, toolUse.ToolUseID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Log if we used SOFT_LIMIT_REACHED
|
||||
if hasTruncatedTools {
|
||||
log.Infof("kiro: buildClaudeResponse using SOFT_LIMIT_REACHED - keeping stop_reason=tool_use")
|
||||
contentBlocks = append(contentBlocks, map[string]interface{}{
|
||||
"type": "tool_use",
|
||||
"id": toolUse.ToolUseID,
|
||||
"name": toolUse.Name,
|
||||
"input": toolUse.Input,
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure at least one content block (Claude API requires non-empty content)
|
||||
|
||||
@@ -192,8 +192,8 @@ func AnalyzeBufferedStream(chunks [][]byte) BufferedStreamResult {
|
||||
if idx, ok := event["index"].(float64); ok {
|
||||
currentToolIndex = int(idx)
|
||||
}
|
||||
// Capture tool use ID for toolResults handshake
|
||||
if id, ok := cb["id"].(string); ok {
|
||||
// Capture tool use ID only for web_search toolResults handshake
|
||||
if id, ok := cb["id"].(string); ok && (currentToolName == "web_search" || currentToolName == "remote_web_search") {
|
||||
result.WebSearchToolUseId = id
|
||||
}
|
||||
toolInputBuilder.Reset()
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
// Package claude provides tool compression functionality for Kiro translator.
|
||||
// This file implements dynamic tool compression to reduce tool payload size
|
||||
// when it exceeds the target threshold, preventing 500 errors from Kiro API.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"unicode/utf8"
|
||||
|
||||
kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// calculateToolsSize calculates the JSON serialized size of the tools list.
|
||||
// Returns the size in bytes.
|
||||
func calculateToolsSize(tools []KiroToolWrapper) int {
|
||||
if len(tools) == 0 {
|
||||
return 0
|
||||
}
|
||||
data, err := json.Marshal(tools)
|
||||
if err != nil {
|
||||
log.Warnf("kiro: failed to marshal tools for size calculation: %v", err)
|
||||
return 0
|
||||
}
|
||||
return len(data)
|
||||
}
|
||||
|
||||
// simplifyInputSchema simplifies the input_schema by keeping only essential fields:
|
||||
// type, enum, required. Recursively processes nested properties.
|
||||
func simplifyInputSchema(schema interface{}) interface{} {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
schemaMap, ok := schema.(map[string]interface{})
|
||||
if !ok {
|
||||
return schema
|
||||
}
|
||||
|
||||
simplified := make(map[string]interface{})
|
||||
|
||||
// Keep essential fields
|
||||
if t, ok := schemaMap["type"]; ok {
|
||||
simplified["type"] = t
|
||||
}
|
||||
if enum, ok := schemaMap["enum"]; ok {
|
||||
simplified["enum"] = enum
|
||||
}
|
||||
if required, ok := schemaMap["required"]; ok {
|
||||
simplified["required"] = required
|
||||
}
|
||||
|
||||
// Recursively process properties
|
||||
if properties, ok := schemaMap["properties"].(map[string]interface{}); ok {
|
||||
simplifiedProps := make(map[string]interface{})
|
||||
for key, value := range properties {
|
||||
simplifiedProps[key] = simplifyInputSchema(value)
|
||||
}
|
||||
simplified["properties"] = simplifiedProps
|
||||
}
|
||||
|
||||
// Process items for array types
|
||||
if items, ok := schemaMap["items"]; ok {
|
||||
simplified["items"] = simplifyInputSchema(items)
|
||||
}
|
||||
|
||||
// Process additionalProperties if present
|
||||
if additionalProps, ok := schemaMap["additionalProperties"]; ok {
|
||||
simplified["additionalProperties"] = simplifyInputSchema(additionalProps)
|
||||
}
|
||||
|
||||
// Process anyOf, oneOf, allOf
|
||||
for _, key := range []string{"anyOf", "oneOf", "allOf"} {
|
||||
if arr, ok := schemaMap[key].([]interface{}); ok {
|
||||
simplifiedArr := make([]interface{}, len(arr))
|
||||
for i, item := range arr {
|
||||
simplifiedArr[i] = simplifyInputSchema(item)
|
||||
}
|
||||
simplified[key] = simplifiedArr
|
||||
}
|
||||
}
|
||||
|
||||
return simplified
|
||||
}
|
||||
|
||||
// compressToolDescription compresses a description to the target length.
|
||||
// Ensures the result is at least MinToolDescriptionLength characters.
|
||||
// Uses UTF-8 safe truncation.
|
||||
func compressToolDescription(description string, targetLength int) string {
|
||||
if targetLength < kirocommon.MinToolDescriptionLength {
|
||||
targetLength = kirocommon.MinToolDescriptionLength
|
||||
}
|
||||
|
||||
if len(description) <= targetLength {
|
||||
return description
|
||||
}
|
||||
|
||||
// Find a safe truncation point (UTF-8 boundary)
|
||||
truncLen := targetLength - 3 // Leave room for "..."
|
||||
|
||||
// Ensure we don't cut in the middle of a UTF-8 character
|
||||
for truncLen > 0 && !utf8.RuneStart(description[truncLen]) {
|
||||
truncLen--
|
||||
}
|
||||
|
||||
if truncLen <= 0 {
|
||||
return description[:kirocommon.MinToolDescriptionLength]
|
||||
}
|
||||
|
||||
return description[:truncLen] + "..."
|
||||
}
|
||||
|
||||
// compressToolsIfNeeded compresses tools if their total size exceeds the target threshold.
|
||||
// Compression strategy:
|
||||
// 1. First, check if compression is needed (size > ToolCompressionTargetSize)
|
||||
// 2. Step 1: Simplify input_schema (keep only type/enum/required)
|
||||
// 3. Step 2: Proportionally compress descriptions (minimum MinToolDescriptionLength chars)
|
||||
// Returns the compressed tools list.
|
||||
func compressToolsIfNeeded(tools []KiroToolWrapper) []KiroToolWrapper {
|
||||
if len(tools) == 0 {
|
||||
return tools
|
||||
}
|
||||
|
||||
originalSize := calculateToolsSize(tools)
|
||||
if originalSize <= kirocommon.ToolCompressionTargetSize {
|
||||
log.Debugf("kiro: tools size %d bytes is within target %d bytes, no compression needed",
|
||||
originalSize, kirocommon.ToolCompressionTargetSize)
|
||||
return tools
|
||||
}
|
||||
|
||||
log.Infof("kiro: tools size %d bytes exceeds target %d bytes, starting compression",
|
||||
originalSize, kirocommon.ToolCompressionTargetSize)
|
||||
|
||||
// Create a copy of tools to avoid modifying the original
|
||||
compressedTools := make([]KiroToolWrapper, len(tools))
|
||||
for i, tool := range tools {
|
||||
compressedTools[i] = KiroToolWrapper{
|
||||
ToolSpecification: KiroToolSpecification{
|
||||
Name: tool.ToolSpecification.Name,
|
||||
Description: tool.ToolSpecification.Description,
|
||||
InputSchema: KiroInputSchema{JSON: tool.ToolSpecification.InputSchema.JSON},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Simplify input_schema
|
||||
for i := range compressedTools {
|
||||
compressedTools[i].ToolSpecification.InputSchema.JSON =
|
||||
simplifyInputSchema(compressedTools[i].ToolSpecification.InputSchema.JSON)
|
||||
}
|
||||
|
||||
sizeAfterSchemaSimplification := calculateToolsSize(compressedTools)
|
||||
log.Debugf("kiro: size after schema simplification: %d bytes (reduced by %d bytes)",
|
||||
sizeAfterSchemaSimplification, originalSize-sizeAfterSchemaSimplification)
|
||||
|
||||
// Check if we're within target after schema simplification
|
||||
if sizeAfterSchemaSimplification <= kirocommon.ToolCompressionTargetSize {
|
||||
log.Infof("kiro: compression complete after schema simplification, final size: %d bytes",
|
||||
sizeAfterSchemaSimplification)
|
||||
return compressedTools
|
||||
}
|
||||
|
||||
// Step 2: Compress descriptions proportionally
|
||||
sizeToReduce := float64(sizeAfterSchemaSimplification - kirocommon.ToolCompressionTargetSize)
|
||||
var totalDescLen float64
|
||||
for _, tool := range compressedTools {
|
||||
totalDescLen += float64(len(tool.ToolSpecification.Description))
|
||||
}
|
||||
|
||||
if totalDescLen > 0 {
|
||||
// Assume size reduction comes primarily from descriptions.
|
||||
keepRatio := 1.0 - (sizeToReduce / totalDescLen)
|
||||
if keepRatio > 1.0 {
|
||||
keepRatio = 1.0
|
||||
} else if keepRatio < 0 {
|
||||
keepRatio = 0
|
||||
}
|
||||
|
||||
for i := range compressedTools {
|
||||
desc := compressedTools[i].ToolSpecification.Description
|
||||
targetLen := int(float64(len(desc)) * keepRatio)
|
||||
compressedTools[i].ToolSpecification.Description = compressToolDescription(desc, targetLen)
|
||||
}
|
||||
}
|
||||
|
||||
finalSize := calculateToolsSize(compressedTools)
|
||||
log.Infof("kiro: compression complete, original: %d bytes, final: %d bytes (%.1f%% reduction)",
|
||||
originalSize, finalSize, float64(originalSize-finalSize)/float64(originalSize)*100)
|
||||
|
||||
return compressedTools
|
||||
}
|
||||
@@ -84,13 +84,18 @@ func DetectTruncation(toolName, toolUseID, rawInput string, parsedInput map[stri
|
||||
ParsedFields: make(map[string]string),
|
||||
}
|
||||
|
||||
// Scenario 1: Empty input buffer - no data received at all
|
||||
// Scenario 1: Empty input buffer - only flag as truncation if tool has required fields
|
||||
// Many tools (e.g. TaskList, TaskGet) have no required params, so empty input is valid
|
||||
if strings.TrimSpace(rawInput) == "" {
|
||||
info.IsTruncated = true
|
||||
info.TruncationType = TruncationTypeEmptyInput
|
||||
info.ErrorMessage = "Tool input was completely empty - API response may have been truncated before tool parameters were transmitted"
|
||||
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): empty input buffer",
|
||||
info.TruncationType, toolName, toolUseID)
|
||||
if _, hasRequirements := RequiredFieldsByTool[toolName]; hasRequirements {
|
||||
info.IsTruncated = true
|
||||
info.TruncationType = TruncationTypeEmptyInput
|
||||
info.ErrorMessage = "Tool input was completely empty - API response may have been truncated before tool parameters were transmitted"
|
||||
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): empty input buffer",
|
||||
info.TruncationType, toolName, toolUseID)
|
||||
return info
|
||||
}
|
||||
log.Debugf("kiro: empty input for tool %s (ID: %s) - no required fields, treating as valid", toolName, toolUseID)
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -342,7 +347,7 @@ func buildTruncationErrorMessage(toolName, truncationType string, parsedFields m
|
||||
}
|
||||
|
||||
sb.WriteString(" Received ")
|
||||
sb.WriteString(string(rune(len(rawInput))))
|
||||
sb.WriteString(formatInt(len(rawInput)))
|
||||
sb.WriteString(" bytes. Please retry with smaller content chunks.")
|
||||
|
||||
return sb.String()
|
||||
|
||||
@@ -6,14 +6,6 @@ const (
|
||||
// Kiro API limit is 10240 bytes, leave room for "..."
|
||||
KiroMaxToolDescLen = 10237
|
||||
|
||||
// ToolCompressionTargetSize is the target total size for compressed tools (20KB).
|
||||
// If tools exceed this size, compression will be applied.
|
||||
ToolCompressionTargetSize = 20 * 1024 // 20KB
|
||||
|
||||
// MinToolDescriptionLength is the minimum description length after compression.
|
||||
// Descriptions will not be shortened below this length.
|
||||
MinToolDescriptionLength = 50
|
||||
|
||||
// ThinkingStartTag is the start tag for thinking blocks in responses.
|
||||
ThinkingStartTag = "<thinking>"
|
||||
|
||||
|
||||
@@ -76,9 +76,17 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
||||
}
|
||||
}
|
||||
case "adaptive", "auto":
|
||||
// Claude adaptive/auto means "enable with max capacity"; keep it as highest level
|
||||
// and let ApplyThinking normalize per target model capability.
|
||||
out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh))
|
||||
// Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6).
|
||||
// Pass through directly; ApplyThinking handles clamping to target model's levels.
|
||||
effort := ""
|
||||
if v := root.Get("output_config.effort"); v.Exists() && v.Type == gjson.String {
|
||||
effort = strings.ToLower(strings.TrimSpace(v.String()))
|
||||
}
|
||||
if effort != "" {
|
||||
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
||||
} else {
|
||||
out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh))
|
||||
}
|
||||
case "disabled":
|
||||
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
|
||||
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
||||
|
||||
@@ -430,7 +430,7 @@ func removeUnsupportedKeywords(jsonStr string) string {
|
||||
keywords := append(unsupportedConstraints,
|
||||
"$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties",
|
||||
"propertyNames", "patternProperties", // Gemini doesn't support these schema keywords
|
||||
"enumTitles", "prefill", // Claude/OpenCode schema metadata fields unsupported by Gemini
|
||||
"enumTitles", "prefill", "deprecated", // Schema metadata fields unsupported by Gemini
|
||||
)
|
||||
|
||||
deletePaths := make([]string, 0)
|
||||
|
||||
@@ -304,6 +304,11 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
||||
if oldModels.hash != newModels.hash {
|
||||
changes = append(changes, fmt.Sprintf("vertex[%d].models: updated (%d -> %d entries)", i, oldModels.count, newModels.count))
|
||||
}
|
||||
oldExcluded := SummarizeExcludedModels(o.ExcludedModels)
|
||||
newExcluded := SummarizeExcludedModels(n.ExcludedModels)
|
||||
if oldExcluded.hash != newExcluded.hash {
|
||||
changes = append(changes, fmt.Sprintf("vertex[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count))
|
||||
}
|
||||
if !equalStringMap(o.Headers, n.Headers) {
|
||||
changes = append(changes, fmt.Sprintf("vertex[%d].headers: updated", i))
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
ApplyAuthExcludedModelsMeta(a, cfg, nil, "apikey")
|
||||
ApplyAuthExcludedModelsMeta(a, cfg, compat.ExcludedModels, "apikey")
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -72,6 +73,10 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e
|
||||
if rel, errRel := filepath.Rel(ctx.AuthDir, full); errRel == nil && rel != "" {
|
||||
id = rel
|
||||
}
|
||||
// On Windows, normalize ID casing to avoid duplicate auth entries caused by case-insensitive paths.
|
||||
if runtime.GOOS == "windows" {
|
||||
id = strings.ToLower(id)
|
||||
}
|
||||
|
||||
proxyURL := ""
|
||||
if p, ok := metadata["proxy_url"].(string); ok {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -266,14 +267,17 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth,
|
||||
}
|
||||
|
||||
func (s *FileTokenStore) idFor(path, baseDir string) string {
|
||||
if baseDir == "" {
|
||||
return path
|
||||
id := path
|
||||
if baseDir != "" {
|
||||
if rel, errRel := filepath.Rel(baseDir, path); errRel == nil && rel != "" {
|
||||
id = rel
|
||||
}
|
||||
}
|
||||
rel, err := filepath.Rel(baseDir, path)
|
||||
if err != nil {
|
||||
return path
|
||||
// On Windows, normalize ID casing to avoid duplicate auth entries caused by case-insensitive paths.
|
||||
if runtime.GOOS == "windows" {
|
||||
id = strings.ToLower(id)
|
||||
}
|
||||
return rel
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *FileTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
|
||||
|
||||
@@ -463,9 +463,14 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
|
||||
return nil, nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
if existing, ok := m.auths[auth.ID]; ok && existing != nil && !auth.indexAssigned && auth.Index == "" {
|
||||
auth.Index = existing.Index
|
||||
auth.indexAssigned = existing.indexAssigned
|
||||
if existing, ok := m.auths[auth.ID]; ok && existing != nil {
|
||||
if !auth.indexAssigned && auth.Index == "" {
|
||||
auth.Index = existing.Index
|
||||
auth.indexAssigned = existing.indexAssigned
|
||||
}
|
||||
if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 {
|
||||
auth.ModelStates = existing.ModelStates
|
||||
}
|
||||
}
|
||||
auth.EnsureIndex()
|
||||
m.auths[auth.ID] = auth.Clone()
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
)
|
||||
|
||||
@@ -115,8 +117,19 @@ func newCredentialRetryLimitTestManager(t *testing.T, maxRetryCredentials int) (
|
||||
executor := &credentialRetryLimitExecutor{id: "claude"}
|
||||
m.RegisterExecutor(executor)
|
||||
|
||||
auth1 := &Auth{ID: "auth-1", Provider: "claude"}
|
||||
auth2 := &Auth{ID: "auth-2", Provider: "claude"}
|
||||
baseID := uuid.NewString()
|
||||
auth1 := &Auth{ID: baseID + "-auth-1", Provider: "claude"}
|
||||
auth2 := &Auth{ID: baseID + "-auth-2", Provider: "claude"}
|
||||
|
||||
// Auth selection requires that the global model registry knows each credential supports the model.
|
||||
reg := registry.GetGlobalRegistry()
|
||||
reg.RegisterClient(auth1.ID, "claude", []*registry.ModelInfo{{ID: "test-model"}})
|
||||
reg.RegisterClient(auth2.ID, "claude", []*registry.ModelInfo{{ID: "test-model"}})
|
||||
t.Cleanup(func() {
|
||||
reg.UnregisterClient(auth1.ID)
|
||||
reg.UnregisterClient(auth2.ID)
|
||||
})
|
||||
|
||||
if _, errRegister := m.Register(context.Background(), auth1); errRegister != nil {
|
||||
t.Fatalf("register auth1: %v", errRegister)
|
||||
}
|
||||
|
||||
49
sdk/cliproxy/auth/conductor_update_test.go
Normal file
49
sdk/cliproxy/auth/conductor_update_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManager_Update_PreservesModelStates(t *testing.T) {
|
||||
m := NewManager(nil, nil, nil)
|
||||
|
||||
model := "test-model"
|
||||
backoffLevel := 7
|
||||
|
||||
if _, errRegister := m.Register(context.Background(), &Auth{
|
||||
ID: "auth-1",
|
||||
Provider: "claude",
|
||||
Metadata: map[string]any{"k": "v"},
|
||||
ModelStates: map[string]*ModelState{
|
||||
model: {
|
||||
Quota: QuotaState{BackoffLevel: backoffLevel},
|
||||
},
|
||||
},
|
||||
}); errRegister != nil {
|
||||
t.Fatalf("register auth: %v", errRegister)
|
||||
}
|
||||
|
||||
if _, errUpdate := m.Update(context.Background(), &Auth{
|
||||
ID: "auth-1",
|
||||
Provider: "claude",
|
||||
Metadata: map[string]any{"k": "v2"},
|
||||
}); errUpdate != nil {
|
||||
t.Fatalf("update auth: %v", errUpdate)
|
||||
}
|
||||
|
||||
updated, ok := m.GetByID("auth-1")
|
||||
if !ok || updated == nil {
|
||||
t.Fatalf("expected auth to be present")
|
||||
}
|
||||
if len(updated.ModelStates) == 0 {
|
||||
t.Fatalf("expected ModelStates to be preserved")
|
||||
}
|
||||
state := updated.ModelStates[model]
|
||||
if state == nil {
|
||||
t.Fatalf("expected model state to be present")
|
||||
}
|
||||
if state.Quota.BackoffLevel != backoffLevel {
|
||||
t.Fatalf("expected BackoffLevel to be %d, got %d", backoffLevel, state.Quota.BackoffLevel)
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,9 @@ func (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.A
|
||||
auth.CreatedAt = existing.CreatedAt
|
||||
auth.LastRefreshedAt = existing.LastRefreshedAt
|
||||
auth.NextRefreshAfter = existing.NextRefreshAfter
|
||||
if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 {
|
||||
auth.ModelStates = existing.ModelStates
|
||||
}
|
||||
op = "update"
|
||||
_, err = s.coreManager.Update(ctx, auth)
|
||||
} else {
|
||||
@@ -817,10 +820,13 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
||||
case "vertex":
|
||||
// Vertex AI Gemini supports the same model identifiers as Gemini.
|
||||
models = registry.GetGeminiVertexModels()
|
||||
if authKind == "apikey" {
|
||||
if entry := s.resolveConfigVertexCompatKey(a); entry != nil && len(entry.Models) > 0 {
|
||||
if entry := s.resolveConfigVertexCompatKey(a); entry != nil {
|
||||
if len(entry.Models) > 0 {
|
||||
models = buildVertexCompatConfigModels(entry)
|
||||
}
|
||||
if authKind == "apikey" {
|
||||
excluded = entry.ExcludedModels
|
||||
}
|
||||
}
|
||||
models = applyExcludedModels(models, excluded)
|
||||
case "gemini-cli":
|
||||
@@ -866,7 +872,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
||||
models = registry.GetKimiModels()
|
||||
models = applyExcludedModels(models, excluded)
|
||||
case "github-copilot":
|
||||
models = registry.GetGitHubCopilotModels()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
models = executor.FetchGitHubCopilotModels(ctx, a, s.cfg)
|
||||
models = applyExcludedModels(models, excluded)
|
||||
case "kiro":
|
||||
models = s.fetchKiroModels(a)
|
||||
|
||||
@@ -34,6 +34,8 @@ type thinkingTestCase struct {
|
||||
inputJSON string
|
||||
expectField string
|
||||
expectValue string
|
||||
expectField2 string
|
||||
expectValue2 string
|
||||
includeThoughts string
|
||||
expectErr bool
|
||||
}
|
||||
@@ -384,15 +386,17 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
|
||||
includeThoughts: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
// Case 30: Effort xhigh → not in low/high → error
|
||||
// Case 30: Effort xhigh → clamped to high
|
||||
{
|
||||
name: "30",
|
||||
from: "openai",
|
||||
to: "gemini",
|
||||
model: "gemini-mixed-model(xhigh)",
|
||||
inputJSON: `{"model":"gemini-mixed-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`,
|
||||
expectField: "",
|
||||
expectErr: true,
|
||||
name: "30",
|
||||
from: "openai",
|
||||
to: "gemini",
|
||||
model: "gemini-mixed-model(xhigh)",
|
||||
inputJSON: `{"model":"gemini-mixed-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`,
|
||||
expectField: "generationConfig.thinkingConfig.thinkingLevel",
|
||||
expectValue: "high",
|
||||
includeThoughts: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
// Case 31: Effort none → clamped to low (min supported) → includeThoughts=false
|
||||
{
|
||||
@@ -1782,15 +1786,17 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
|
||||
includeThoughts: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
// Case 30: reasoning_effort=xhigh → error (not in low/high)
|
||||
// Case 30: reasoning_effort=xhigh → clamped to high
|
||||
{
|
||||
name: "30",
|
||||
from: "openai",
|
||||
to: "gemini",
|
||||
model: "gemini-mixed-model",
|
||||
inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`,
|
||||
expectField: "",
|
||||
expectErr: true,
|
||||
name: "30",
|
||||
from: "openai",
|
||||
to: "gemini",
|
||||
model: "gemini-mixed-model",
|
||||
inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`,
|
||||
expectField: "generationConfig.thinkingConfig.thinkingLevel",
|
||||
expectValue: "high",
|
||||
includeThoughts: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
// Case 31: reasoning_effort=none → clamped to low → includeThoughts=false
|
||||
{
|
||||
@@ -2822,9 +2828,8 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
|
||||
runThinkingTests(t, cases)
|
||||
}
|
||||
|
||||
// TestThinkingE2EClaudeAdaptive_Body tests Claude thinking.type=adaptive extended body-only cases.
|
||||
// These cases validate that adaptive means "thinking enabled without explicit budget", and
|
||||
// cross-protocol conversion should resolve to target-model maximum thinking capability.
|
||||
// TestThinkingE2EClaudeAdaptive_Body covers Group 3 cases in docs/thinking-e2e-test-cases.md.
|
||||
// It focuses on Claude 4.6 adaptive thinking and effort/level cross-protocol semantics (body-only).
|
||||
func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {
|
||||
reg := registry.GetGlobalRegistry()
|
||||
uid := fmt.Sprintf("thinking-e2e-claude-adaptive-%d", time.Now().UnixNano())
|
||||
@@ -2833,32 +2838,347 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {
|
||||
defer reg.UnregisterClient(uid)
|
||||
|
||||
cases := []thinkingTestCase{
|
||||
// A1: Claude adaptive to OpenAI level model -> highest supported level
|
||||
// A subgroup: OpenAI -> Claude (reasoning_effort -> output_config.effort)
|
||||
{
|
||||
name: "A1",
|
||||
from: "openai",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"minimal"}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "low",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "A2",
|
||||
from: "openai",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"low"}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "low",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "A3",
|
||||
from: "openai",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "medium",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "A4",
|
||||
from: "openai",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high"}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "A5",
|
||||
from: "openai",
|
||||
to: "claude",
|
||||
model: "claude-opus-4-6-model",
|
||||
inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "max",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "A6",
|
||||
from: "openai",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "A7",
|
||||
from: "openai",
|
||||
to: "claude",
|
||||
model: "claude-opus-4-6-model",
|
||||
inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"max"}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "max",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "A8",
|
||||
from: "openai",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"max"}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
|
||||
// B subgroup: Gemini -> Claude (thinkingLevel/thinkingBudget -> output_config.effort)
|
||||
{
|
||||
name: "B1",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"minimal"}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "low",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B2",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"low"}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "low",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B3",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"medium"}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "medium",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B4",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B5",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-opus-4-6-model",
|
||||
inputJSON: `{"model":"claude-opus-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"xhigh"}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "max",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B6",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"xhigh"}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B7",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":512}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "low",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B8",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":1024}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "low",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B9",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "medium",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B10",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":24576}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B11",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-opus-4-6-model",
|
||||
inputJSON: `{"model":"claude-opus-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":32768}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "max",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B12",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":32768}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B13",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`,
|
||||
expectField: "thinking.type",
|
||||
expectValue: "disabled",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "B14",
|
||||
from: "gemini",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`,
|
||||
expectField: "output_config.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
|
||||
// C subgroup: Claude adaptive + effort cross-protocol conversion
|
||||
{
|
||||
name: "C1",
|
||||
from: "claude",
|
||||
to: "openai",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"minimal"}}`,
|
||||
expectField: "reasoning_effort",
|
||||
expectValue: "minimal",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C2",
|
||||
from: "claude",
|
||||
to: "openai",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`,
|
||||
expectField: "reasoning_effort",
|
||||
expectValue: "low",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C3",
|
||||
from: "claude",
|
||||
to: "openai",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`,
|
||||
expectField: "reasoning_effort",
|
||||
expectValue: "medium",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C4",
|
||||
from: "claude",
|
||||
to: "openai",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
|
||||
expectField: "reasoning_effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
// A2: Claude adaptive to Gemini level subset model -> highest supported level
|
||||
{
|
||||
name: "A2",
|
||||
name: "C5",
|
||||
from: "claude",
|
||||
to: "openai",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
|
||||
expectField: "reasoning_effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C6",
|
||||
from: "claude",
|
||||
to: "openai",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`,
|
||||
expectField: "reasoning_effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C7",
|
||||
from: "claude",
|
||||
to: "openai",
|
||||
model: "no-thinking-model",
|
||||
inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
|
||||
expectField: "",
|
||||
expectErr: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "C8",
|
||||
from: "claude",
|
||||
to: "gemini",
|
||||
model: "level-subset-model",
|
||||
inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
|
||||
inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
|
||||
expectField: "generationConfig.thinkingConfig.thinkingLevel",
|
||||
expectValue: "high",
|
||||
includeThoughts: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
// A3: Claude adaptive to Gemini budget model -> max budget
|
||||
{
|
||||
name: "A3",
|
||||
name: "C9",
|
||||
from: "claude",
|
||||
to: "gemini",
|
||||
model: "gemini-budget-model",
|
||||
inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`,
|
||||
expectField: "generationConfig.thinkingConfig.thinkingBudget",
|
||||
expectValue: "1024",
|
||||
includeThoughts: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C10",
|
||||
from: "claude",
|
||||
to: "gemini",
|
||||
model: "gemini-budget-model",
|
||||
inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`,
|
||||
expectField: "generationConfig.thinkingConfig.thinkingBudget",
|
||||
expectValue: "8192",
|
||||
includeThoughts: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C11",
|
||||
from: "claude",
|
||||
to: "gemini",
|
||||
model: "gemini-budget-model",
|
||||
inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
|
||||
expectField: "generationConfig.thinkingConfig.thinkingBudget",
|
||||
expectValue: "20000",
|
||||
includeThoughts: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C12",
|
||||
from: "claude",
|
||||
to: "gemini",
|
||||
model: "gemini-budget-model",
|
||||
@@ -2868,32 +3188,91 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {
|
||||
includeThoughts: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
// A4: Claude adaptive to Gemini mixed model -> highest supported level
|
||||
{
|
||||
name: "A4",
|
||||
name: "C13",
|
||||
from: "claude",
|
||||
to: "gemini",
|
||||
model: "gemini-mixed-model",
|
||||
inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
|
||||
inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
|
||||
expectField: "generationConfig.thinkingConfig.thinkingLevel",
|
||||
expectValue: "high",
|
||||
includeThoughts: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
// A5: Claude adaptive passthrough for same protocol
|
||||
|
||||
{
|
||||
name: "A5",
|
||||
name: "C14",
|
||||
from: "claude",
|
||||
to: "claude",
|
||||
model: "claude-budget-model",
|
||||
inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
|
||||
expectField: "thinking.type",
|
||||
expectValue: "adaptive",
|
||||
to: "codex",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"minimal"}}`,
|
||||
expectField: "reasoning.effort",
|
||||
expectValue: "minimal",
|
||||
expectErr: false,
|
||||
},
|
||||
// A6: Claude adaptive to Antigravity budget model -> max budget
|
||||
{
|
||||
name: "A6",
|
||||
name: "C15",
|
||||
from: "claude",
|
||||
to: "codex",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`,
|
||||
expectField: "reasoning.effort",
|
||||
expectValue: "low",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C16",
|
||||
from: "claude",
|
||||
to: "codex",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
|
||||
expectField: "reasoning.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C17",
|
||||
from: "claude",
|
||||
to: "codex",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
|
||||
expectField: "reasoning.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C18",
|
||||
from: "claude",
|
||||
to: "codex",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`,
|
||||
expectField: "reasoning.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "C19",
|
||||
from: "claude",
|
||||
to: "iflow",
|
||||
model: "glm-test",
|
||||
inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"minimal"}}`,
|
||||
expectField: "chat_template_kwargs.enable_thinking",
|
||||
expectValue: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C20",
|
||||
from: "claude",
|
||||
to: "iflow",
|
||||
model: "minimax-test",
|
||||
inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
|
||||
expectField: "reasoning_split",
|
||||
expectValue: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C21",
|
||||
from: "claude",
|
||||
to: "antigravity",
|
||||
model: "antigravity-budget-model",
|
||||
@@ -2903,48 +3282,66 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {
|
||||
includeThoughts: "true",
|
||||
expectErr: false,
|
||||
},
|
||||
// A7: Claude adaptive to iFlow GLM -> enabled boolean
|
||||
|
||||
{
|
||||
name: "A7",
|
||||
from: "claude",
|
||||
to: "iflow",
|
||||
model: "glm-test",
|
||||
inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
|
||||
expectField: "chat_template_kwargs.enable_thinking",
|
||||
expectValue: "true",
|
||||
expectErr: false,
|
||||
name: "C22",
|
||||
from: "claude",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`,
|
||||
expectField: "thinking.type",
|
||||
expectValue: "adaptive",
|
||||
expectField2: "output_config.effort",
|
||||
expectValue2: "medium",
|
||||
expectErr: false,
|
||||
},
|
||||
// A8: Claude adaptive to iFlow MiniMax -> enabled boolean
|
||||
{
|
||||
name: "A8",
|
||||
from: "claude",
|
||||
to: "iflow",
|
||||
model: "minimax-test",
|
||||
inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
|
||||
expectField: "reasoning_split",
|
||||
expectValue: "true",
|
||||
expectErr: false,
|
||||
name: "C23",
|
||||
from: "claude",
|
||||
to: "claude",
|
||||
model: "claude-opus-4-6-model",
|
||||
inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`,
|
||||
expectField: "thinking.type",
|
||||
expectValue: "adaptive",
|
||||
expectField2: "output_config.effort",
|
||||
expectValue2: "max",
|
||||
expectErr: false,
|
||||
},
|
||||
// A9: Claude adaptive to Codex level model -> highest supported level
|
||||
{
|
||||
name: "A9",
|
||||
from: "claude",
|
||||
to: "codex",
|
||||
model: "level-model",
|
||||
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
|
||||
expectField: "reasoning.effort",
|
||||
expectValue: "high",
|
||||
expectErr: false,
|
||||
name: "C24",
|
||||
from: "claude",
|
||||
to: "claude",
|
||||
model: "claude-opus-4-6-model",
|
||||
inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
|
||||
expectErr: true,
|
||||
},
|
||||
// A10: Claude adaptive on non-thinking model should still be stripped
|
||||
{
|
||||
name: "A10",
|
||||
from: "claude",
|
||||
to: "openai",
|
||||
model: "no-thinking-model",
|
||||
inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
|
||||
expectField: "",
|
||||
expectErr: false,
|
||||
name: "C25",
|
||||
from: "claude",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
|
||||
expectField: "thinking.type",
|
||||
expectValue: "adaptive",
|
||||
expectField2: "output_config.effort",
|
||||
expectValue2: "high",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "C26",
|
||||
from: "claude",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "C27",
|
||||
from: "claude",
|
||||
to: "claude",
|
||||
model: "claude-sonnet-4-6-model",
|
||||
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2999,6 +3396,29 @@ func getTestModels() []*registry.ModelInfo {
|
||||
DisplayName: "Claude Budget Model",
|
||||
Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
|
||||
},
|
||||
{
|
||||
ID: "claude-sonnet-4-6-model",
|
||||
Object: "model",
|
||||
Created: 1771372800, // 2026-02-17
|
||||
OwnedBy: "anthropic",
|
||||
Type: "claude",
|
||||
DisplayName: "Claude 4.6 Sonnet",
|
||||
ContextLength: 200000,
|
||||
MaxCompletionTokens: 64000,
|
||||
Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high"}},
|
||||
},
|
||||
{
|
||||
ID: "claude-opus-4-6-model",
|
||||
Object: "model",
|
||||
Created: 1770318000, // 2026-02-05
|
||||
OwnedBy: "anthropic",
|
||||
Type: "claude",
|
||||
DisplayName: "Claude 4.6 Opus",
|
||||
Description: "Premium model combining maximum intelligence with practical performance",
|
||||
ContextLength: 1000000,
|
||||
MaxCompletionTokens: 128000,
|
||||
Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high", "max"}},
|
||||
},
|
||||
{
|
||||
ID: "antigravity-budget-model",
|
||||
Object: "model",
|
||||
@@ -3165,17 +3585,23 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) {
|
||||
return
|
||||
}
|
||||
|
||||
val := gjson.GetBytes(body, tc.expectField)
|
||||
if !val.Exists() {
|
||||
t.Fatalf("expected field %s not found, body=%s", tc.expectField, string(body))
|
||||
assertField := func(fieldPath, expected string) {
|
||||
val := gjson.GetBytes(body, fieldPath)
|
||||
if !val.Exists() {
|
||||
t.Fatalf("expected field %s not found, body=%s", fieldPath, string(body))
|
||||
}
|
||||
actualValue := val.String()
|
||||
if val.Type == gjson.Number {
|
||||
actualValue = fmt.Sprintf("%d", val.Int())
|
||||
}
|
||||
if actualValue != expected {
|
||||
t.Fatalf("field %s: expected %q, got %q, body=%s", fieldPath, expected, actualValue, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
actualValue := val.String()
|
||||
if val.Type == gjson.Number {
|
||||
actualValue = fmt.Sprintf("%d", val.Int())
|
||||
}
|
||||
if actualValue != tc.expectValue {
|
||||
t.Fatalf("field %s: expected %q, got %q, body=%s", tc.expectField, tc.expectValue, actualValue, string(body))
|
||||
assertField(tc.expectField, tc.expectValue)
|
||||
if tc.expectField2 != "" {
|
||||
assertField(tc.expectField2, tc.expectValue2)
|
||||
}
|
||||
|
||||
if tc.includeThoughts != "" && (tc.to == "gemini" || tc.to == "gemini-cli" || tc.to == "antigravity") {
|
||||
|
||||
Reference in New Issue
Block a user