mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-29 16:54:41 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d109be159c | ||
|
|
eddf31e55b | ||
|
|
7e9d0db6aa | ||
|
|
2f1874ede5 | ||
|
|
6b83585b53 | ||
|
|
78ef04fcf1 | ||
|
|
b7e4f00c5f | ||
|
|
c20507c15e | ||
|
|
f7d0019df7 | ||
|
|
52364af5bf | ||
|
|
f410dd0440 | ||
|
|
eb5582c17c | ||
|
|
1c6cb2bec3 | ||
|
|
80b5e79e75 | ||
|
|
d182e893b6 | ||
|
|
2e8d49a641 | ||
|
|
6abd7d27d9 | ||
|
|
8fa12af403 | ||
|
|
77586ed7d3 | ||
|
|
394497fb2f | ||
|
|
fc7b6ef086 | ||
|
|
98edcad39d | ||
|
|
1187aa8222 | ||
|
|
a35d66443b | ||
|
|
40ad4a42ea | ||
|
|
dc9b4dd017 | ||
|
|
68cb81a258 | ||
|
|
16693053f5 | ||
|
|
4e3bad3907 | ||
|
|
c874f19f2a | ||
|
|
f5f26f0cbe | ||
|
|
e7e3ca1efb | ||
|
|
4b00312fef | ||
|
|
c5fd3db01e | ||
|
|
f870a9d2a7 | ||
|
|
706590c62a | ||
|
|
233be6272a | ||
|
|
47cb52385e | ||
|
|
a406ca2d5a |
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -77,6 +77,7 @@ func main() {
|
|||||||
var noBrowser bool
|
var noBrowser bool
|
||||||
var oauthCallbackPort int
|
var oauthCallbackPort int
|
||||||
var antigravityLogin bool
|
var antigravityLogin bool
|
||||||
|
var kimiLogin bool
|
||||||
var kiroLogin bool
|
var kiroLogin bool
|
||||||
var kiroGoogleLogin bool
|
var kiroGoogleLogin bool
|
||||||
var kiroAWSLogin bool
|
var kiroAWSLogin bool
|
||||||
@@ -102,6 +103,7 @@ func main() {
|
|||||||
flag.BoolVar(&useIncognito, "incognito", false, "Open browser in incognito/private mode for OAuth (useful for multiple accounts)")
|
flag.BoolVar(&useIncognito, "incognito", false, "Open browser in incognito/private mode for OAuth (useful for multiple accounts)")
|
||||||
flag.BoolVar(&noIncognito, "no-incognito", false, "Force disable incognito mode (uses existing browser session)")
|
flag.BoolVar(&noIncognito, "no-incognito", false, "Force disable incognito mode (uses existing browser session)")
|
||||||
flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
|
flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
|
||||||
|
flag.BoolVar(&kimiLogin, "kimi-login", false, "Login to Kimi using OAuth")
|
||||||
flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth")
|
flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth")
|
||||||
flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)")
|
flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)")
|
||||||
flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)")
|
flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)")
|
||||||
@@ -501,6 +503,8 @@ func main() {
|
|||||||
cmd.DoIFlowLogin(cfg, options)
|
cmd.DoIFlowLogin(cfg, options)
|
||||||
} else if iflowCookie {
|
} else if iflowCookie {
|
||||||
cmd.DoIFlowCookieAuth(cfg, options)
|
cmd.DoIFlowCookieAuth(cfg, options)
|
||||||
|
} else if kimiLogin {
|
||||||
|
cmd.DoKimiLogin(cfg, options)
|
||||||
} else if kiroLogin {
|
} else if kiroLogin {
|
||||||
// For Kiro auth, default to incognito mode for multi-account support
|
// For Kiro auth, default to incognito mode for multi-account support
|
||||||
// Users can explicitly override with --no-incognito
|
// Users can explicitly override with --no-incognito
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ nonstream-keepalive-interval: 0
|
|||||||
|
|
||||||
# Global OAuth model name aliases (per channel)
|
# Global OAuth model name aliases (per channel)
|
||||||
# These aliases rename model IDs for both model listing and request routing.
|
# These aliases rename model IDs for both model listing and request routing.
|
||||||
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot.
|
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, kimi.
|
||||||
# NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
|
# NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
|
||||||
# You can repeat the same name with different aliases to expose multiple client model names.
|
# You can repeat the same name with different aliases to expose multiple client model names.
|
||||||
#oauth-model-alias:
|
#oauth-model-alias:
|
||||||
@@ -280,6 +280,9 @@ nonstream-keepalive-interval: 0
|
|||||||
# iflow:
|
# iflow:
|
||||||
# - name: "glm-4.7"
|
# - name: "glm-4.7"
|
||||||
# alias: "glm-god"
|
# alias: "glm-god"
|
||||||
|
# kimi:
|
||||||
|
# - name: "kimi-k2.5"
|
||||||
|
# alias: "k2.5"
|
||||||
# kiro:
|
# kiro:
|
||||||
# - name: "kiro-claude-opus-4-5"
|
# - name: "kiro-claude-opus-4-5"
|
||||||
# alias: "op45"
|
# alias: "op45"
|
||||||
@@ -309,6 +312,8 @@ nonstream-keepalive-interval: 0
|
|||||||
# - "vision-model"
|
# - "vision-model"
|
||||||
# iflow:
|
# iflow:
|
||||||
# - "tstars2.0"
|
# - "tstars2.0"
|
||||||
|
# kimi:
|
||||||
|
# - "kimi-k2-thinking"
|
||||||
# kiro:
|
# kiro:
|
||||||
# - "kiro-claude-haiku-4-5"
|
# - "kiro-claude-haiku-4-5"
|
||||||
# github-copilot:
|
# github-copilot:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
|
||||||
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||||
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
|
||||||
kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
|
kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
@@ -1613,6 +1614,82 @@ func (h *Handler) RequestQwenToken(c *gin.Context) {
|
|||||||
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RequestKimiToken(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fmt.Println("Initializing Kimi authentication...")
|
||||||
|
|
||||||
|
state := fmt.Sprintf("kmi-%d", time.Now().UnixNano())
|
||||||
|
// Initialize Kimi auth service
|
||||||
|
kimiAuth := kimi.NewKimiAuth(h.cfg)
|
||||||
|
|
||||||
|
// Generate authorization URL
|
||||||
|
deviceFlow, errStartDeviceFlow := kimiAuth.StartDeviceFlow(ctx)
|
||||||
|
if errStartDeviceFlow != nil {
|
||||||
|
log.Errorf("Failed to generate authorization URL: %v", errStartDeviceFlow)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authURL := deviceFlow.VerificationURIComplete
|
||||||
|
if authURL == "" {
|
||||||
|
authURL = deviceFlow.VerificationURI
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterOAuthSession(state, "kimi")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
fmt.Println("Waiting for authentication...")
|
||||||
|
authBundle, errWaitForAuthorization := kimiAuth.WaitForAuthorization(ctx, deviceFlow)
|
||||||
|
if errWaitForAuthorization != nil {
|
||||||
|
SetOAuthSessionError(state, "Authentication failed")
|
||||||
|
fmt.Printf("Authentication failed: %v\n", errWaitForAuthorization)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token storage
|
||||||
|
tokenStorage := kimiAuth.CreateTokenStorage(authBundle)
|
||||||
|
|
||||||
|
metadata := map[string]any{
|
||||||
|
"type": "kimi",
|
||||||
|
"access_token": authBundle.TokenData.AccessToken,
|
||||||
|
"refresh_token": authBundle.TokenData.RefreshToken,
|
||||||
|
"token_type": authBundle.TokenData.TokenType,
|
||||||
|
"scope": authBundle.TokenData.Scope,
|
||||||
|
"timestamp": time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
if authBundle.TokenData.ExpiresAt > 0 {
|
||||||
|
expired := time.Unix(authBundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)
|
||||||
|
metadata["expired"] = expired
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(authBundle.DeviceID) != "" {
|
||||||
|
metadata["device_id"] = strings.TrimSpace(authBundle.DeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := fmt.Sprintf("kimi-%d.json", time.Now().UnixMilli())
|
||||||
|
record := &coreauth.Auth{
|
||||||
|
ID: fileName,
|
||||||
|
Provider: "kimi",
|
||||||
|
FileName: fileName,
|
||||||
|
Label: "Kimi User",
|
||||||
|
Storage: tokenStorage,
|
||||||
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||||
|
if errSave != nil {
|
||||||
|
log.Errorf("Failed to save authentication tokens: %v", errSave)
|
||||||
|
SetOAuthSessionError(state, "Failed to save authentication tokens")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
||||||
|
fmt.Println("You can now use Kimi services through this CLI")
|
||||||
|
CompleteOAuthSession(state)
|
||||||
|
CompleteOAuthSessionsByProvider("kimi")
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) RequestIFlowToken(c *gin.Context) {
|
func (h *Handler) RequestIFlowToken(c *gin.Context) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
|||||||
@@ -649,6 +649,7 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||||
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
|
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
|
||||||
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
||||||
|
mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken)
|
||||||
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
|
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
|
||||||
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
|
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
|
||||||
mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken)
|
mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken)
|
||||||
|
|||||||
396
internal/auth/kimi/kimi.go
Normal file
396
internal/auth/kimi/kimi.go
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
// Package kimi provides authentication and token management for Kimi (Moonshot AI) API.
|
||||||
|
// It handles the RFC 8628 OAuth2 Device Authorization Grant flow for secure authentication.
|
||||||
|
package kimi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// kimiClientID is Kimi Code's OAuth client ID.
|
||||||
|
kimiClientID = "17e5f671-d194-4dfb-9706-5516cb48c098"
|
||||||
|
// kimiOAuthHost is the OAuth server endpoint.
|
||||||
|
kimiOAuthHost = "https://auth.kimi.com"
|
||||||
|
// kimiDeviceCodeURL is the endpoint for requesting device codes.
|
||||||
|
kimiDeviceCodeURL = kimiOAuthHost + "/api/oauth/device_authorization"
|
||||||
|
// kimiTokenURL is the endpoint for exchanging device codes for tokens.
|
||||||
|
kimiTokenURL = kimiOAuthHost + "/api/oauth/token"
|
||||||
|
// KimiAPIBaseURL is the base URL for Kimi API requests.
|
||||||
|
KimiAPIBaseURL = "https://api.kimi.com/coding"
|
||||||
|
// defaultPollInterval is the default interval for polling token endpoint.
|
||||||
|
defaultPollInterval = 5 * time.Second
|
||||||
|
// maxPollDuration is the maximum time to wait for user authorization.
|
||||||
|
maxPollDuration = 15 * time.Minute
|
||||||
|
// refreshThresholdSeconds is when to refresh token before expiry (5 minutes).
|
||||||
|
refreshThresholdSeconds = 300
|
||||||
|
)
|
||||||
|
|
||||||
|
// KimiAuth handles Kimi authentication flow.
|
||||||
|
type KimiAuth struct {
|
||||||
|
deviceClient *DeviceFlowClient
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKimiAuth creates a new KimiAuth service instance.
|
||||||
|
func NewKimiAuth(cfg *config.Config) *KimiAuth {
|
||||||
|
return &KimiAuth{
|
||||||
|
deviceClient: NewDeviceFlowClient(cfg),
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartDeviceFlow initiates the device flow authentication.
|
||||||
|
func (k *KimiAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) {
|
||||||
|
return k.deviceClient.RequestDeviceCode(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForAuthorization polls for user authorization and returns the auth bundle.
|
||||||
|
func (k *KimiAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*KimiAuthBundle, error) {
|
||||||
|
tokenData, err := k.deviceClient.PollForToken(ctx, deviceCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &KimiAuthBundle{
|
||||||
|
TokenData: tokenData,
|
||||||
|
DeviceID: k.deviceClient.deviceID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTokenStorage creates a new KimiTokenStorage from auth bundle.
|
||||||
|
func (k *KimiAuth) CreateTokenStorage(bundle *KimiAuthBundle) *KimiTokenStorage {
|
||||||
|
expired := ""
|
||||||
|
if bundle.TokenData.ExpiresAt > 0 {
|
||||||
|
expired = time.Unix(bundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return &KimiTokenStorage{
|
||||||
|
AccessToken: bundle.TokenData.AccessToken,
|
||||||
|
RefreshToken: bundle.TokenData.RefreshToken,
|
||||||
|
TokenType: bundle.TokenData.TokenType,
|
||||||
|
Scope: bundle.TokenData.Scope,
|
||||||
|
DeviceID: strings.TrimSpace(bundle.DeviceID),
|
||||||
|
Expired: expired,
|
||||||
|
Type: "kimi",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceFlowClient handles the OAuth2 device flow for Kimi.
|
||||||
|
type DeviceFlowClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
cfg *config.Config
|
||||||
|
deviceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeviceFlowClient creates a new device flow client.
|
||||||
|
func NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient {
|
||||||
|
return NewDeviceFlowClientWithDeviceID(cfg, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeviceFlowClientWithDeviceID creates a new device flow client with the specified device ID.
|
||||||
|
func NewDeviceFlowClientWithDeviceID(cfg *config.Config, deviceID string) *DeviceFlowClient {
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
if cfg != nil {
|
||||||
|
client = util.SetProxy(&cfg.SDKConfig, client)
|
||||||
|
}
|
||||||
|
resolvedDeviceID := strings.TrimSpace(deviceID)
|
||||||
|
if resolvedDeviceID == "" {
|
||||||
|
resolvedDeviceID = getOrCreateDeviceID()
|
||||||
|
}
|
||||||
|
return &DeviceFlowClient{
|
||||||
|
httpClient: client,
|
||||||
|
cfg: cfg,
|
||||||
|
deviceID: resolvedDeviceID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOrCreateDeviceID returns an in-memory device ID for the current authentication flow.
|
||||||
|
func getOrCreateDeviceID() string {
|
||||||
|
return uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDeviceModel returns a device model string.
|
||||||
|
func getDeviceModel() string {
|
||||||
|
osName := runtime.GOOS
|
||||||
|
arch := runtime.GOARCH
|
||||||
|
|
||||||
|
switch osName {
|
||||||
|
case "darwin":
|
||||||
|
return fmt.Sprintf("macOS %s", arch)
|
||||||
|
case "windows":
|
||||||
|
return fmt.Sprintf("Windows %s", arch)
|
||||||
|
case "linux":
|
||||||
|
return fmt.Sprintf("Linux %s", arch)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%s %s", osName, arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHostname returns the machine hostname.
|
||||||
|
func getHostname() string {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
// commonHeaders returns headers required for Kimi API requests.
|
||||||
|
func (c *DeviceFlowClient) commonHeaders() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"X-Msh-Platform": "cli-proxy-api",
|
||||||
|
"X-Msh-Version": "1.0.0",
|
||||||
|
"X-Msh-Device-Name": getHostname(),
|
||||||
|
"X-Msh-Device-Model": getDeviceModel(),
|
||||||
|
"X-Msh-Device-Id": c.deviceID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestDeviceCode initiates the device flow by requesting a device code from Kimi.
|
||||||
|
func (c *DeviceFlowClient) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) {
|
||||||
|
data := url.Values{}
|
||||||
|
data.Set("client_id", kimiClientID)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiDeviceCodeURL, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: failed to create device code request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
for k, v := range c.commonHeaders() {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: device code request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("kimi device code: close body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: failed to read device code response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("kimi: device code request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var deviceCode DeviceCodeResponse
|
||||||
|
if err = json.Unmarshal(bodyBytes, &deviceCode); err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: failed to parse device code response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &deviceCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollForToken polls the token endpoint until the user authorizes or the device code expires.
|
||||||
|
func (c *DeviceFlowClient) PollForToken(ctx context.Context, deviceCode *DeviceCodeResponse) (*KimiTokenData, error) {
|
||||||
|
if deviceCode == nil {
|
||||||
|
return nil, fmt.Errorf("kimi: device code is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := time.Duration(deviceCode.Interval) * time.Second
|
||||||
|
if interval < defaultPollInterval {
|
||||||
|
interval = defaultPollInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(maxPollDuration)
|
||||||
|
if deviceCode.ExpiresIn > 0 {
|
||||||
|
codeDeadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
|
||||||
|
if codeDeadline.Before(deadline) {
|
||||||
|
deadline = codeDeadline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("kimi: context cancelled: %w", ctx.Err())
|
||||||
|
case <-ticker.C:
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return nil, fmt.Errorf("kimi: device code expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, pollErr, shouldContinue := c.exchangeDeviceCode(ctx, deviceCode.DeviceCode)
|
||||||
|
if token != nil {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
if !shouldContinue {
|
||||||
|
return nil, pollErr
|
||||||
|
}
|
||||||
|
// Continue polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchangeDeviceCode attempts to exchange the device code for an access token.
|
||||||
|
// Returns (token, error, shouldContinue).
|
||||||
|
func (c *DeviceFlowClient) exchangeDeviceCode(ctx context.Context, deviceCode string) (*KimiTokenData, error, bool) {
|
||||||
|
data := url.Values{}
|
||||||
|
data.Set("client_id", kimiClientID)
|
||||||
|
data.Set("device_code", deviceCode)
|
||||||
|
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiTokenURL, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: failed to create token request: %w", err), false
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
for k, v := range c.commonHeaders() {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: token request failed: %w", err), false
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("kimi token exchange: close body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: failed to read token response: %w", err), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response - Kimi returns 200 for both success and pending states
|
||||||
|
var oauthResp struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorDescription string `json:"error_description"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn float64 `json:"expires_in"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(bodyBytes, &oauthResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: failed to parse token response: %w", err), false
|
||||||
|
}
|
||||||
|
|
||||||
|
if oauthResp.Error != "" {
|
||||||
|
switch oauthResp.Error {
|
||||||
|
case "authorization_pending":
|
||||||
|
return nil, nil, true // Continue polling
|
||||||
|
case "slow_down":
|
||||||
|
return nil, nil, true // Continue polling (with increased interval handled by caller)
|
||||||
|
case "expired_token":
|
||||||
|
return nil, fmt.Errorf("kimi: device code expired"), false
|
||||||
|
case "access_denied":
|
||||||
|
return nil, fmt.Errorf("kimi: access denied by user"), false
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("kimi: OAuth error: %s - %s", oauthResp.Error, oauthResp.ErrorDescription), false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if oauthResp.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("kimi: empty access token in response"), false
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt int64
|
||||||
|
if oauthResp.ExpiresIn > 0 {
|
||||||
|
expiresAt = time.Now().Unix() + int64(oauthResp.ExpiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &KimiTokenData{
|
||||||
|
AccessToken: oauthResp.AccessToken,
|
||||||
|
RefreshToken: oauthResp.RefreshToken,
|
||||||
|
TokenType: oauthResp.TokenType,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Scope: oauthResp.Scope,
|
||||||
|
}, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken exchanges a refresh token for a new access token.
|
||||||
|
func (c *DeviceFlowClient) RefreshToken(ctx context.Context, refreshToken string) (*KimiTokenData, error) {
|
||||||
|
data := url.Values{}
|
||||||
|
data.Set("client_id", kimiClientID)
|
||||||
|
data.Set("grant_type", "refresh_token")
|
||||||
|
data.Set("refresh_token", refreshToken)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiTokenURL, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: failed to create refresh request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
for k, v := range c.commonHeaders() {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: refresh request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("kimi refresh token: close body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: failed to read refresh response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
return nil, fmt.Errorf("kimi: refresh token rejected (status %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("kimi: refresh failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn float64 `json:"expires_in"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(bodyBytes, &tokenResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: failed to parse refresh response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenResp.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("kimi: empty access token in refresh response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt int64
|
||||||
|
if tokenResp.ExpiresIn > 0 {
|
||||||
|
expiresAt = time.Now().Unix() + int64(tokenResp.ExpiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &KimiTokenData{
|
||||||
|
AccessToken: tokenResp.AccessToken,
|
||||||
|
RefreshToken: tokenResp.RefreshToken,
|
||||||
|
TokenType: tokenResp.TokenType,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Scope: tokenResp.Scope,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
116
internal/auth/kimi/token.go
Normal file
116
internal/auth/kimi/token.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// Package kimi provides authentication and token management functionality
|
||||||
|
// for Kimi (Moonshot AI) services. It handles OAuth2 device flow token storage,
|
||||||
|
// serialization, and retrieval for maintaining authenticated sessions with the Kimi API.
|
||||||
|
package kimi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KimiTokenStorage stores OAuth2 token information for Kimi API authentication.
|
||||||
|
type KimiTokenStorage struct {
|
||||||
|
// AccessToken is the OAuth2 access token used for authenticating API requests.
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
// RefreshToken is the OAuth2 refresh token used to obtain new access tokens.
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
// TokenType is the type of token, typically "Bearer".
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
// Scope is the OAuth2 scope granted to the token.
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
// DeviceID is the OAuth device flow identifier used for Kimi requests.
|
||||||
|
DeviceID string `json:"device_id,omitempty"`
|
||||||
|
// Expired is the RFC3339 timestamp when the access token expires.
|
||||||
|
Expired string `json:"expired,omitempty"`
|
||||||
|
// Type indicates the authentication provider type, always "kimi" for this storage.
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KimiTokenData holds the raw OAuth token response from Kimi.
|
||||||
|
type KimiTokenData struct {
|
||||||
|
// AccessToken is the OAuth2 access token.
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
// RefreshToken is the OAuth2 refresh token.
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
// TokenType is the type of token, typically "Bearer".
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
// ExpiresAt is the Unix timestamp when the token expires.
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
// Scope is the OAuth2 scope granted to the token.
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KimiAuthBundle bundles authentication data for storage.
|
||||||
|
type KimiAuthBundle struct {
|
||||||
|
// TokenData contains the OAuth token information.
|
||||||
|
TokenData *KimiTokenData
|
||||||
|
// DeviceID is the device identifier used during OAuth device flow.
|
||||||
|
DeviceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceCodeResponse represents Kimi's device code response.
|
||||||
|
type DeviceCodeResponse struct {
|
||||||
|
// DeviceCode is the device verification code.
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
// UserCode is the code the user must enter at the verification URI.
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
// VerificationURI is the URL where the user should enter the code.
|
||||||
|
VerificationURI string `json:"verification_uri,omitempty"`
|
||||||
|
// VerificationURIComplete is the URL with the code pre-filled.
|
||||||
|
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||||
|
// ExpiresIn is the number of seconds until the device code expires.
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
// Interval is the minimum number of seconds to wait between polling requests.
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTokenToFile serializes the Kimi token storage to a JSON file.
|
||||||
|
func (ts *KimiTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||||
|
misc.LogSavingCredentials(authFilePath)
|
||||||
|
ts.Type = "kimi"
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(authFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create token file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(f)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err = encoder.Encode(ts); err != nil {
|
||||||
|
return fmt.Errorf("failed to write token to file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpired checks if the token has expired.
|
||||||
|
func (ts *KimiTokenStorage) IsExpired() bool {
|
||||||
|
if ts.Expired == "" {
|
||||||
|
return false // No expiry set, assume valid
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, ts.Expired)
|
||||||
|
if err != nil {
|
||||||
|
return true // Has expiry string but can't parse
|
||||||
|
}
|
||||||
|
// Consider expired if within refresh threshold
|
||||||
|
return time.Now().Add(time.Duration(refreshThresholdSeconds) * time.Second).After(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsRefresh checks if the token should be refreshed.
|
||||||
|
func (ts *KimiTokenStorage) NeedsRefresh() bool {
|
||||||
|
if ts.RefreshToken == "" {
|
||||||
|
return false // Can't refresh without refresh token
|
||||||
|
}
|
||||||
|
return ts.IsExpired()
|
||||||
|
}
|
||||||
@@ -238,7 +238,7 @@ func (k *KiroAuth) ListAvailableModels(ctx context.Context, tokenData *KiroToken
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
RateMultiplier float64 `json:"rateMultiplier"`
|
RateMultiplier float64 `json:"rateMultiplier"`
|
||||||
RateUnit string `json:"rateUnit"`
|
RateUnit string `json:"rateUnit"`
|
||||||
TokenLimits struct {
|
TokenLimits *struct {
|
||||||
MaxInputTokens int `json:"maxInputTokens"`
|
MaxInputTokens int `json:"maxInputTokens"`
|
||||||
} `json:"tokenLimits"`
|
} `json:"tokenLimits"`
|
||||||
} `json:"models"`
|
} `json:"models"`
|
||||||
@@ -250,13 +250,17 @@ func (k *KiroAuth) ListAvailableModels(ctx context.Context, tokenData *KiroToken
|
|||||||
|
|
||||||
models := make([]*KiroModel, 0, len(result.Models))
|
models := make([]*KiroModel, 0, len(result.Models))
|
||||||
for _, m := range result.Models {
|
for _, m := range result.Models {
|
||||||
|
maxInputTokens := 0
|
||||||
|
if m.TokenLimits != nil {
|
||||||
|
maxInputTokens = m.TokenLimits.MaxInputTokens
|
||||||
|
}
|
||||||
models = append(models, &KiroModel{
|
models = append(models, &KiroModel{
|
||||||
ModelID: m.ModelID,
|
ModelID: m.ModelID,
|
||||||
ModelName: m.ModelName,
|
ModelName: m.ModelName,
|
||||||
Description: m.Description,
|
Description: m.Description,
|
||||||
RateMultiplier: m.RateMultiplier,
|
RateMultiplier: m.RateMultiplier,
|
||||||
RateUnit: m.RateUnit,
|
RateUnit: m.RateUnit,
|
||||||
MaxInputTokens: m.TokenLimits.MaxInputTokens,
|
MaxInputTokens: maxInputTokens,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func newAuthManager() *sdkAuth.Manager {
|
|||||||
sdkAuth.NewQwenAuthenticator(),
|
sdkAuth.NewQwenAuthenticator(),
|
||||||
sdkAuth.NewIFlowAuthenticator(),
|
sdkAuth.NewIFlowAuthenticator(),
|
||||||
sdkAuth.NewAntigravityAuthenticator(),
|
sdkAuth.NewAntigravityAuthenticator(),
|
||||||
|
sdkAuth.NewKimiAuthenticator(),
|
||||||
sdkAuth.NewKiroAuthenticator(),
|
sdkAuth.NewKiroAuthenticator(),
|
||||||
sdkAuth.NewGitHubCopilotAuthenticator(),
|
sdkAuth.NewGitHubCopilotAuthenticator(),
|
||||||
)
|
)
|
||||||
|
|||||||
44
internal/cmd/kimi_login.go
Normal file
44
internal/cmd/kimi_login.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DoKimiLogin triggers the OAuth device flow for Kimi (Moonshot AI) and saves tokens.
|
||||||
|
// It initiates the device flow authentication, displays the verification URL for the user,
|
||||||
|
// and waits for authorization before saving the tokens.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - cfg: The application configuration containing proxy and auth directory settings
|
||||||
|
// - options: Login options including browser behavior settings
|
||||||
|
func DoKimiLogin(cfg *config.Config, options *LoginOptions) {
|
||||||
|
if options == nil {
|
||||||
|
options = &LoginOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := newAuthManager()
|
||||||
|
authOpts := &sdkAuth.LoginOptions{
|
||||||
|
NoBrowser: options.NoBrowser,
|
||||||
|
Metadata: map[string]string{},
|
||||||
|
Prompt: options.Prompt,
|
||||||
|
}
|
||||||
|
|
||||||
|
record, savedPath, err := manager.Login(context.Background(), "kimi", cfg, authOpts)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Kimi authentication failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if savedPath != "" {
|
||||||
|
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||||
|
}
|
||||||
|
if record != nil && record.Label != "" {
|
||||||
|
fmt.Printf("Authenticated as %s\n", record.Label)
|
||||||
|
}
|
||||||
|
fmt.Println("Kimi authentication successful!")
|
||||||
|
}
|
||||||
@@ -535,14 +535,15 @@ func LoadConfig(configFile string) (*Config, error) {
|
|||||||
// If optional is true and the file is missing, it returns an empty Config.
|
// If optional is true and the file is missing, it returns an empty Config.
|
||||||
// If optional is true and the file is empty or invalid, it returns an empty Config.
|
// If optional is true and the file is empty or invalid, it returns an empty Config.
|
||||||
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
||||||
// Perform oauth-model-alias migration before loading config.
|
// NOTE: Startup oauth-model-alias migration is intentionally disabled.
|
||||||
// This migrates oauth-model-mappings to oauth-model-alias if needed.
|
// Reason: avoid mutating config.yaml during server startup.
|
||||||
if migrated, err := MigrateOAuthModelAlias(configFile); err != nil {
|
// Re-enable the block below if automatic startup migration is needed again.
|
||||||
// Log warning but don't fail - config loading should still work
|
// if migrated, err := MigrateOAuthModelAlias(configFile); err != nil {
|
||||||
fmt.Printf("Warning: oauth-model-alias migration failed: %v\n", err)
|
// // Log warning but don't fail - config loading should still work
|
||||||
} else if migrated {
|
// fmt.Printf("Warning: oauth-model-alias migration failed: %v\n", err)
|
||||||
fmt.Println("Migrated oauth-model-mappings to oauth-model-alias")
|
// } else if migrated {
|
||||||
}
|
// fmt.Println("Migrated oauth-model-mappings to oauth-model-alias")
|
||||||
|
// }
|
||||||
|
|
||||||
// Read the entire configuration file into memory.
|
// Read the entire configuration file into memory.
|
||||||
data, err := os.ReadFile(configFile)
|
data, err := os.ReadFile(configFile)
|
||||||
@@ -583,18 +584,21 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var legacy legacyConfigData
|
// NOTE: Startup legacy key migration is intentionally disabled.
|
||||||
if errLegacy := yaml.Unmarshal(data, &legacy); errLegacy == nil {
|
// Reason: avoid mutating config.yaml during server startup.
|
||||||
if cfg.migrateLegacyGeminiKeys(legacy.LegacyGeminiKeys) {
|
// Re-enable the block below if automatic startup migration is needed again.
|
||||||
cfg.legacyMigrationPending = true
|
// var legacy legacyConfigData
|
||||||
}
|
// if errLegacy := yaml.Unmarshal(data, &legacy); errLegacy == nil {
|
||||||
if cfg.migrateLegacyOpenAICompatibilityKeys(legacy.OpenAICompat) {
|
// if cfg.migrateLegacyGeminiKeys(legacy.LegacyGeminiKeys) {
|
||||||
cfg.legacyMigrationPending = true
|
// cfg.legacyMigrationPending = true
|
||||||
}
|
// }
|
||||||
if cfg.migrateLegacyAmpConfig(&legacy) {
|
// if cfg.migrateLegacyOpenAICompatibilityKeys(legacy.OpenAICompat) {
|
||||||
cfg.legacyMigrationPending = true
|
// cfg.legacyMigrationPending = true
|
||||||
}
|
// }
|
||||||
}
|
// if cfg.migrateLegacyAmpConfig(&legacy) {
|
||||||
|
// cfg.legacyMigrationPending = true
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
// Hash remote management key if plaintext is detected (nested)
|
// Hash remote management key if plaintext is detected (nested)
|
||||||
// We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix).
|
// We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix).
|
||||||
@@ -658,17 +662,20 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
// Validate raw payload rules and drop invalid entries.
|
// Validate raw payload rules and drop invalid entries.
|
||||||
cfg.SanitizePayloadRules()
|
cfg.SanitizePayloadRules()
|
||||||
|
|
||||||
if cfg.legacyMigrationPending {
|
// NOTE: Legacy migration persistence is intentionally disabled together with
|
||||||
fmt.Println("Detected legacy configuration keys, attempting to persist the normalized config...")
|
// startup legacy migration to keep startup read-only for config.yaml.
|
||||||
if !optional && configFile != "" {
|
// Re-enable the block below if automatic startup migration is needed again.
|
||||||
if err := SaveConfigPreserveComments(configFile, &cfg); err != nil {
|
// if cfg.legacyMigrationPending {
|
||||||
return nil, fmt.Errorf("failed to persist migrated legacy config: %w", err)
|
// fmt.Println("Detected legacy configuration keys, attempting to persist the normalized config...")
|
||||||
}
|
// if !optional && configFile != "" {
|
||||||
fmt.Println("Legacy configuration normalized and persisted.")
|
// if err := SaveConfigPreserveComments(configFile, &cfg); err != nil {
|
||||||
} else {
|
// return nil, fmt.Errorf("failed to persist migrated legacy config: %w", err)
|
||||||
fmt.Println("Legacy configuration normalized in memory; persistence skipped.")
|
// }
|
||||||
}
|
// fmt.Println("Legacy configuration normalized and persisted.")
|
||||||
}
|
// } else {
|
||||||
|
// fmt.Println("Legacy configuration normalized in memory; persistence skipped.")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
// Return the populated configuration struct.
|
// Return the populated configuration struct.
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func GetClaudeModels() []*ModelInfo {
|
|||||||
DisplayName: "Claude 4.5 Haiku",
|
DisplayName: "Claude 4.5 Haiku",
|
||||||
ContextLength: 200000,
|
ContextLength: 200000,
|
||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 64000,
|
||||||
// Thinking: not supported for Haiku models
|
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "claude-sonnet-4-5-20250929",
|
ID: "claude-sonnet-4-5-20250929",
|
||||||
@@ -29,15 +29,15 @@ func GetClaudeModels() []*ModelInfo {
|
|||||||
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
|
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "claude-opus-4-6-20260205",
|
ID: "claude-opus-4-6",
|
||||||
Object: "model",
|
Object: "model",
|
||||||
Created: 1770318000, // 2026-02-05
|
Created: 1770318000, // 2026-02-05
|
||||||
OwnedBy: "anthropic",
|
OwnedBy: "anthropic",
|
||||||
Type: "claude",
|
Type: "claude",
|
||||||
DisplayName: "Claude 4.6 Opus",
|
DisplayName: "Claude 4.6 Opus",
|
||||||
Description: "Premium model combining maximum intelligence with practical performance",
|
Description: "Premium model combining maximum intelligence with practical performance",
|
||||||
ContextLength: 200000,
|
ContextLength: 1000000,
|
||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 128000,
|
||||||
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
|
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -866,9 +866,50 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig {
|
|||||||
"gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}},
|
"gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}},
|
||||||
"claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
|
"claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
|
||||||
"claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
|
"claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
|
||||||
"claude-opus-4-6-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
|
"claude-opus-4-6-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 128000},
|
||||||
"claude-sonnet-4-5": {MaxCompletionTokens: 64000},
|
"claude-sonnet-4-5": {MaxCompletionTokens: 64000},
|
||||||
"gpt-oss-120b-medium": {},
|
"gpt-oss-120b-medium": {},
|
||||||
"tab_flash_lite_preview": {},
|
"tab_flash_lite_preview": {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetKimiModels returns the standard Kimi (Moonshot AI) model definitions
|
||||||
|
func GetKimiModels() []*ModelInfo {
|
||||||
|
return []*ModelInfo{
|
||||||
|
{
|
||||||
|
ID: "kimi-k2",
|
||||||
|
Object: "model",
|
||||||
|
Created: 1752192000, // 2025-07-11
|
||||||
|
OwnedBy: "moonshot",
|
||||||
|
Type: "kimi",
|
||||||
|
DisplayName: "Kimi K2",
|
||||||
|
Description: "Kimi K2 - Moonshot AI's flagship coding model",
|
||||||
|
ContextLength: 131072,
|
||||||
|
MaxCompletionTokens: 32768,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kimi-k2-thinking",
|
||||||
|
Object: "model",
|
||||||
|
Created: 1762387200, // 2025-11-06
|
||||||
|
OwnedBy: "moonshot",
|
||||||
|
Type: "kimi",
|
||||||
|
DisplayName: "Kimi K2 Thinking",
|
||||||
|
Description: "Kimi K2 Thinking - Extended reasoning model",
|
||||||
|
ContextLength: 131072,
|
||||||
|
MaxCompletionTokens: 32768,
|
||||||
|
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kimi-k2.5",
|
||||||
|
Object: "model",
|
||||||
|
Created: 1769472000, // 2026-01-26
|
||||||
|
OwnedBy: "moonshot",
|
||||||
|
Type: "kimi",
|
||||||
|
DisplayName: "Kimi K2.5",
|
||||||
|
Description: "Kimi K2.5 - Latest Moonshot AI coding model with improved capabilities",
|
||||||
|
ContextLength: 131072,
|
||||||
|
MaxCompletionTokens: 32768,
|
||||||
|
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
618
internal/runtime/executor/kimi_executor.go
Normal file
618
internal/runtime/executor/kimi_executor.go
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
kimiauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"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"
|
||||||
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KimiExecutor is a stateless executor for Kimi API using OpenAI-compatible chat completions.
|
||||||
|
type KimiExecutor struct {
|
||||||
|
ClaudeExecutor
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKimiExecutor creates a new Kimi executor.
|
||||||
|
func NewKimiExecutor(cfg *config.Config) *KimiExecutor { return &KimiExecutor{cfg: cfg} }
|
||||||
|
|
||||||
|
// Identifier returns the executor identifier.
|
||||||
|
func (e *KimiExecutor) Identifier() string { return "kimi" }
|
||||||
|
|
||||||
|
// PrepareRequest injects Kimi credentials into the outgoing HTTP request.
|
||||||
|
func (e *KimiExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||||
|
if req == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
token := kimiCreds(auth)
|
||||||
|
if strings.TrimSpace(token) != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HttpRequest injects Kimi credentials into the request and executes it.
|
||||||
|
func (e *KimiExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("kimi executor: request is nil")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = req.Context()
|
||||||
|
}
|
||||||
|
httpReq := req.WithContext(ctx)
|
||||||
|
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
return httpClient.Do(httpReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs a non-streaming chat completion request to Kimi.
|
||||||
|
func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
|
from := opts.SourceFormat
|
||||||
|
if from.String() == "claude" {
|
||||||
|
auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL
|
||||||
|
return e.ClaudeExecutor.Execute(ctx, auth, req, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
token := kimiCreds(auth)
|
||||||
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
|
to := sdktranslator.FromString("openai")
|
||||||
|
originalPayloadSource := req.Payload
|
||||||
|
if len(opts.OriginalRequest) > 0 {
|
||||||
|
originalPayloadSource = opts.OriginalRequest
|
||||||
|
}
|
||||||
|
originalPayload := bytes.Clone(originalPayloadSource)
|
||||||
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||||
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
|
|
||||||
|
// Strip kimi- prefix for upstream API
|
||||||
|
upstreamModel := stripKimiPrefix(baseModel)
|
||||||
|
body, err = sjson.SetBytes(body, "model", upstreamModel)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("kimi executor: failed to set model in payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err = thinking.ApplyThinking(body, req.Model, from.String(), "kimi", e.Identifier())
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
||||||
|
body, err = normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions"
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
applyKimiHeadersWithAuth(httpReq, token, false, auth)
|
||||||
|
var authID, authLabel, authType, authValue string
|
||||||
|
if auth != nil {
|
||||||
|
authID = auth.ID
|
||||||
|
authLabel = auth.Label
|
||||||
|
authType, authValue = auth.AccountInfo()
|
||||||
|
}
|
||||||
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
|
URL: url,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Headers: httpReq.Header.Clone(),
|
||||||
|
Body: body,
|
||||||
|
Provider: e.Identifier(),
|
||||||
|
AuthID: authID,
|
||||||
|
AuthLabel: authLabel,
|
||||||
|
AuthType: authType,
|
||||||
|
AuthValue: authValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, err)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("kimi executor: close response body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
||||||
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(httpResp.Body)
|
||||||
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
||||||
|
logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
||||||
|
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(httpResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, err)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
appendAPIResponseChunk(ctx, e.cfg, data)
|
||||||
|
reporter.publish(ctx, parseOpenAIUsage(data))
|
||||||
|
var param any
|
||||||
|
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
|
||||||
|
// the original model name in the response for client compatibility.
|
||||||
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
||||||
|
resp = cliproxyexecutor.Response{Payload: []byte(out)}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteStream performs a streaming chat completion request to Kimi.
|
||||||
|
func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
||||||
|
from := opts.SourceFormat
|
||||||
|
if from.String() == "claude" {
|
||||||
|
auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL
|
||||||
|
return e.ClaudeExecutor.ExecuteStream(ctx, auth, req, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
token := kimiCreds(auth)
|
||||||
|
|
||||||
|
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
|
to := sdktranslator.FromString("openai")
|
||||||
|
originalPayloadSource := req.Payload
|
||||||
|
if len(opts.OriginalRequest) > 0 {
|
||||||
|
originalPayloadSource = opts.OriginalRequest
|
||||||
|
}
|
||||||
|
originalPayload := bytes.Clone(originalPayloadSource)
|
||||||
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
|
|
||||||
|
// Strip kimi- prefix for upstream API
|
||||||
|
upstreamModel := stripKimiPrefix(baseModel)
|
||||||
|
body, err = sjson.SetBytes(body, "model", upstreamModel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi executor: failed to set model in payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err = thinking.ApplyThinking(body, req.Model, from.String(), "kimi", e.Identifier())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err = sjson.SetBytes(body, "stream_options.include_usage", true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi executor: failed to set stream_options in payload: %w", err)
|
||||||
|
}
|
||||||
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
||||||
|
body, err = normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions"
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
applyKimiHeadersWithAuth(httpReq, token, true, auth)
|
||||||
|
var authID, authLabel, authType, authValue string
|
||||||
|
if auth != nil {
|
||||||
|
authID = auth.ID
|
||||||
|
authLabel = auth.Label
|
||||||
|
authType, authValue = auth.AccountInfo()
|
||||||
|
}
|
||||||
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
|
URL: url,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Headers: httpReq.Header.Clone(),
|
||||||
|
Body: body,
|
||||||
|
Provider: e.Identifier(),
|
||||||
|
AuthID: authID,
|
||||||
|
AuthLabel: authLabel,
|
||||||
|
AuthType: authType,
|
||||||
|
AuthValue: authValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
||||||
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(httpResp.Body)
|
||||||
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
||||||
|
logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
||||||
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("kimi executor: close response body error: %v", errClose)
|
||||||
|
}
|
||||||
|
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(chan cliproxyexecutor.StreamChunk)
|
||||||
|
stream = out
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
defer func() {
|
||||||
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("kimi executor: close response body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
scanner := bufio.NewScanner(httpResp.Body)
|
||||||
|
scanner.Buffer(nil, 1_048_576) // 1MB
|
||||||
|
var param any
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
appendAPIResponseChunk(ctx, e.cfg, line)
|
||||||
|
if detail, ok := parseOpenAIStreamUsage(line); ok {
|
||||||
|
reporter.publish(ctx, detail)
|
||||||
|
}
|
||||||
|
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
||||||
|
for i := range chunks {
|
||||||
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
||||||
|
for i := range doneChunks {
|
||||||
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(doneChunks[i])}
|
||||||
|
}
|
||||||
|
if errScan := scanner.Err(); errScan != nil {
|
||||||
|
recordAPIResponseError(ctx, e.cfg, errScan)
|
||||||
|
reporter.publishFailure(ctx)
|
||||||
|
out <- cliproxyexecutor.StreamChunk{Err: errScan}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return stream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountTokens estimates token count for Kimi requests.
|
||||||
|
func (e *KimiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
|
auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL
|
||||||
|
return e.ClaudeExecutor.CountTokens(ctx, auth, req, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := gjson.GetBytes(body, "messages")
|
||||||
|
if !messages.Exists() || !messages.IsArray() {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := body
|
||||||
|
pending := make([]string, 0)
|
||||||
|
patched := 0
|
||||||
|
patchedReasoning := 0
|
||||||
|
ambiguous := 0
|
||||||
|
latestReasoning := ""
|
||||||
|
hasLatestReasoning := false
|
||||||
|
|
||||||
|
removePending := func(id string) {
|
||||||
|
for idx := range pending {
|
||||||
|
if pending[idx] != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending = append(pending[:idx], pending[idx+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs := messages.Array()
|
||||||
|
for msgIdx := range msgs {
|
||||||
|
msg := msgs[msgIdx]
|
||||||
|
role := strings.TrimSpace(msg.Get("role").String())
|
||||||
|
switch role {
|
||||||
|
case "assistant":
|
||||||
|
reasoning := msg.Get("reasoning_content")
|
||||||
|
if reasoning.Exists() {
|
||||||
|
reasoningText := reasoning.String()
|
||||||
|
if strings.TrimSpace(reasoningText) != "" {
|
||||||
|
latestReasoning = reasoningText
|
||||||
|
hasLatestReasoning = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls := msg.Get("tool_calls")
|
||||||
|
if !toolCalls.Exists() || !toolCalls.IsArray() || len(toolCalls.Array()) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reasoning.Exists() || strings.TrimSpace(reasoning.String()) == "" {
|
||||||
|
reasoningText := fallbackAssistantReasoning(msg, hasLatestReasoning, latestReasoning)
|
||||||
|
path := fmt.Sprintf("messages.%d.reasoning_content", msgIdx)
|
||||||
|
next, err := sjson.SetBytes(out, path, reasoningText)
|
||||||
|
if err != nil {
|
||||||
|
return body, fmt.Errorf("kimi executor: failed to set assistant reasoning_content: %w", err)
|
||||||
|
}
|
||||||
|
out = next
|
||||||
|
patchedReasoning++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range toolCalls.Array() {
|
||||||
|
id := strings.TrimSpace(tc.Get("id").String())
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending = append(pending, id)
|
||||||
|
}
|
||||||
|
case "tool":
|
||||||
|
toolCallID := strings.TrimSpace(msg.Get("tool_call_id").String())
|
||||||
|
if toolCallID == "" {
|
||||||
|
toolCallID = strings.TrimSpace(msg.Get("call_id").String())
|
||||||
|
if toolCallID != "" {
|
||||||
|
path := fmt.Sprintf("messages.%d.tool_call_id", msgIdx)
|
||||||
|
next, err := sjson.SetBytes(out, path, toolCallID)
|
||||||
|
if err != nil {
|
||||||
|
return body, fmt.Errorf("kimi executor: failed to set tool_call_id from call_id: %w", err)
|
||||||
|
}
|
||||||
|
out = next
|
||||||
|
patched++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if toolCallID == "" {
|
||||||
|
if len(pending) == 1 {
|
||||||
|
toolCallID = pending[0]
|
||||||
|
path := fmt.Sprintf("messages.%d.tool_call_id", msgIdx)
|
||||||
|
next, err := sjson.SetBytes(out, path, toolCallID)
|
||||||
|
if err != nil {
|
||||||
|
return body, fmt.Errorf("kimi executor: failed to infer tool_call_id: %w", err)
|
||||||
|
}
|
||||||
|
out = next
|
||||||
|
patched++
|
||||||
|
} else if len(pending) > 1 {
|
||||||
|
ambiguous++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if toolCallID != "" {
|
||||||
|
removePending(toolCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if patched > 0 || patchedReasoning > 0 {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"patched_tool_messages": patched,
|
||||||
|
"patched_reasoning_messages": patchedReasoning,
|
||||||
|
}).Debug("kimi executor: normalized tool message fields")
|
||||||
|
}
|
||||||
|
if ambiguous > 0 {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"ambiguous_tool_messages": ambiguous,
|
||||||
|
"pending_tool_calls": len(pending),
|
||||||
|
}).Warn("kimi executor: tool messages missing tool_call_id with ambiguous candidates")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string) string {
|
||||||
|
if hasLatest && strings.TrimSpace(latest) != "" {
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
content := msg.Get("content")
|
||||||
|
if content.Type == gjson.String {
|
||||||
|
if text := strings.TrimSpace(content.String()); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content.IsArray() {
|
||||||
|
parts := make([]string, 0, len(content.Array()))
|
||||||
|
for _, item := range content.Array() {
|
||||||
|
text := strings.TrimSpace(item.Get("text").String())
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "[reasoning unavailable]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh refreshes the Kimi token using the refresh token.
|
||||||
|
func (e *KimiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
||||||
|
log.Debugf("kimi executor: refresh called")
|
||||||
|
if auth == nil {
|
||||||
|
return nil, fmt.Errorf("kimi executor: auth is nil")
|
||||||
|
}
|
||||||
|
// Expect refresh_token in metadata for OAuth-based accounts
|
||||||
|
var refreshToken string
|
||||||
|
if auth.Metadata != nil {
|
||||||
|
if v, ok := auth.Metadata["refresh_token"].(string); ok && strings.TrimSpace(v) != "" {
|
||||||
|
refreshToken = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(refreshToken) == "" {
|
||||||
|
// Nothing to refresh
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client := kimiauth.NewDeviceFlowClientWithDeviceID(e.cfg, resolveKimiDeviceID(auth))
|
||||||
|
td, err := client.RefreshToken(ctx, refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if auth.Metadata == nil {
|
||||||
|
auth.Metadata = make(map[string]any)
|
||||||
|
}
|
||||||
|
auth.Metadata["access_token"] = td.AccessToken
|
||||||
|
if td.RefreshToken != "" {
|
||||||
|
auth.Metadata["refresh_token"] = td.RefreshToken
|
||||||
|
}
|
||||||
|
if td.ExpiresAt > 0 {
|
||||||
|
exp := time.Unix(td.ExpiresAt, 0).UTC().Format(time.RFC3339)
|
||||||
|
auth.Metadata["expired"] = exp
|
||||||
|
}
|
||||||
|
auth.Metadata["type"] = "kimi"
|
||||||
|
now := time.Now().Format(time.RFC3339)
|
||||||
|
auth.Metadata["last_refresh"] = now
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyKimiHeaders sets required headers for Kimi API requests.
|
||||||
|
// Headers match kimi-cli client for compatibility.
|
||||||
|
func applyKimiHeaders(r *http.Request, token string, stream bool) {
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
// Match kimi-cli headers exactly
|
||||||
|
r.Header.Set("User-Agent", "KimiCLI/1.10.6")
|
||||||
|
r.Header.Set("X-Msh-Platform", "kimi_cli")
|
||||||
|
r.Header.Set("X-Msh-Version", "1.10.6")
|
||||||
|
r.Header.Set("X-Msh-Device-Name", getKimiHostname())
|
||||||
|
r.Header.Set("X-Msh-Device-Model", getKimiDeviceModel())
|
||||||
|
r.Header.Set("X-Msh-Device-Id", getKimiDeviceID())
|
||||||
|
if stream {
|
||||||
|
r.Header.Set("Accept", "text/event-stream")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Header.Set("Accept", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveKimiDeviceIDFromAuth(auth *cliproxyauth.Auth) string {
|
||||||
|
if auth == nil || auth.Metadata == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceIDRaw, ok := auth.Metadata["device_id"]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID, ok := deviceIDRaw.(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveKimiDeviceIDFromStorage(auth *cliproxyauth.Auth) string {
|
||||||
|
if auth == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
storage, ok := auth.Storage.(*kimiauth.KimiTokenStorage)
|
||||||
|
if !ok || storage == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(storage.DeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveKimiDeviceID(auth *cliproxyauth.Auth) string {
|
||||||
|
deviceID := resolveKimiDeviceIDFromAuth(auth)
|
||||||
|
if deviceID != "" {
|
||||||
|
return deviceID
|
||||||
|
}
|
||||||
|
return resolveKimiDeviceIDFromStorage(auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyKimiHeadersWithAuth(r *http.Request, token string, stream bool, auth *cliproxyauth.Auth) {
|
||||||
|
applyKimiHeaders(r, token, stream)
|
||||||
|
|
||||||
|
if deviceID := resolveKimiDeviceID(auth); deviceID != "" {
|
||||||
|
r.Header.Set("X-Msh-Device-Id", deviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getKimiHostname returns the machine hostname.
|
||||||
|
func getKimiHostname() string {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
// getKimiDeviceModel returns a device model string matching kimi-cli format.
|
||||||
|
func getKimiDeviceModel() string {
|
||||||
|
return fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getKimiDeviceID returns a stable device ID, matching kimi-cli storage location.
|
||||||
|
func getKimiDeviceID() string {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "cli-proxy-api-device"
|
||||||
|
}
|
||||||
|
// Check kimi-cli's device_id location first (platform-specific)
|
||||||
|
var kimiShareDir string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
kimiShareDir = filepath.Join(homeDir, "Library", "Application Support", "kimi")
|
||||||
|
case "windows":
|
||||||
|
appData := os.Getenv("APPDATA")
|
||||||
|
if appData == "" {
|
||||||
|
appData = filepath.Join(homeDir, "AppData", "Roaming")
|
||||||
|
}
|
||||||
|
kimiShareDir = filepath.Join(appData, "kimi")
|
||||||
|
default: // linux and other unix-like
|
||||||
|
kimiShareDir = filepath.Join(homeDir, ".local", "share", "kimi")
|
||||||
|
}
|
||||||
|
deviceIDPath := filepath.Join(kimiShareDir, "device_id")
|
||||||
|
if data, err := os.ReadFile(deviceIDPath); err == nil {
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
return "cli-proxy-api-device"
|
||||||
|
}
|
||||||
|
|
||||||
|
// kimiCreds extracts the access token from auth.
|
||||||
|
func kimiCreds(a *cliproxyauth.Auth) (token string) {
|
||||||
|
if a == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Check metadata first (OAuth flow stores tokens here)
|
||||||
|
if a.Metadata != nil {
|
||||||
|
if v, ok := a.Metadata["access_token"].(string); ok && strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to attributes (API key style)
|
||||||
|
if a.Attributes != nil {
|
||||||
|
if v := a.Attributes["access_token"]; v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := a.Attributes["api_key"]; v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripKimiPrefix removes the "kimi-" prefix from model names for the upstream API.
|
||||||
|
func stripKimiPrefix(model string) string {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if strings.HasPrefix(strings.ToLower(model), "kimi-") {
|
||||||
|
return model[5:]
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
}
|
||||||
205
internal/runtime/executor/kimi_executor_test.go
Normal file
205
internal/runtime/executor/kimi_executor_test.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_UsesCallIDFallback(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"list_directory:1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]},
|
||||||
|
{"role":"tool","call_id":"list_directory:1","content":"[]"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
||||||
|
if got != "list_directory:1" {
|
||||||
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "list_directory:1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_InferSinglePendingID(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_123","type":"function","function":{"name":"read_file","arguments":"{}"}}]},
|
||||||
|
{"role":"tool","content":"file-content"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
||||||
|
if got != "call_123" {
|
||||||
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_AmbiguousMissingIDIsNotInferred(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[
|
||||||
|
{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}},
|
||||||
|
{"id":"call_2","type":"function","function":{"name":"read_file","arguments":"{}"}}
|
||||||
|
]},
|
||||||
|
{"role":"tool","content":"result-without-id"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gjson.GetBytes(out, "messages.1.tool_call_id").Exists() {
|
||||||
|
t.Fatalf("messages.1.tool_call_id should be absent for ambiguous case, got %q", gjson.GetBytes(out, "messages.1.tool_call_id").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_PreservesExistingToolCallID(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]},
|
||||||
|
{"role":"tool","tool_call_id":"call_1","call_id":"different-id","content":"result"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
||||||
|
if got != "call_1" {
|
||||||
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_InheritsPreviousReasoningForAssistantToolCalls(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","content":"plan","reasoning_content":"previous reasoning"},
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.1.reasoning_content").String()
|
||||||
|
if got != "previous reasoning" {
|
||||||
|
t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "previous reasoning")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_InsertsFallbackReasoningWhenMissing(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reasoning := gjson.GetBytes(out, "messages.0.reasoning_content")
|
||||||
|
if !reasoning.Exists() {
|
||||||
|
t.Fatalf("messages.0.reasoning_content should exist")
|
||||||
|
}
|
||||||
|
if reasoning.String() != "[reasoning unavailable]" {
|
||||||
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", reasoning.String(), "[reasoning unavailable]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_UsesContentAsReasoningFallback(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","content":[{"type":"text","text":"first line"},{"type":"text","text":"second line"}],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
|
||||||
|
if got != "first line\nsecond line" {
|
||||||
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "first line\nsecond line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_ReplacesEmptyReasoningContent(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","content":"assistant summary","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":""}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
|
||||||
|
if got != "assistant summary" {
|
||||||
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "assistant summary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_PreservesExistingAssistantReasoning(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":"keep me"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
|
||||||
|
if got != "keep me" {
|
||||||
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "keep me")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_RepairsIDsAndReasoningTogether(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":"r1"},
|
||||||
|
{"role":"tool","call_id":"call_1","content":"[]"},
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_2","type":"function","function":{"name":"read_file","arguments":"{}"}}]},
|
||||||
|
{"role":"tool","call_id":"call_2","content":"file"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "call_1" {
|
||||||
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_1")
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(out, "messages.3.tool_call_id").String(); got != "call_2" {
|
||||||
|
t.Fatalf("messages.3.tool_call_id = %q, want %q", got, "call_2")
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(out, "messages.2.reasoning_content").String(); got != "r1" {
|
||||||
|
t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "r1")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2102,6 +2102,22 @@ func (e *KiroExecutor) parseEventStream(body io.Reader) (string, []kiroclaude.Ki
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "contextUsageEvent":
|
||||||
|
// Handle context usage events from Kiro API
|
||||||
|
// Format: {"contextUsageEvent": {"contextUsagePercentage": 0.53}}
|
||||||
|
if ctxUsage, ok := event["contextUsageEvent"].(map[string]interface{}); ok {
|
||||||
|
if ctxPct, ok := ctxUsage["contextUsagePercentage"].(float64); ok {
|
||||||
|
upstreamContextPercentage = ctxPct
|
||||||
|
log.Debugf("kiro: parseEventStream received contextUsageEvent: %.2f%%", ctxPct*100)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try direct field (fallback)
|
||||||
|
if ctxPct, ok := event["contextUsagePercentage"].(float64); ok {
|
||||||
|
upstreamContextPercentage = ctxPct
|
||||||
|
log.Debugf("kiro: parseEventStream received contextUsagePercentage (direct): %.2f%%", ctxPct*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "error", "exception", "internalServerException", "invalidStateEvent":
|
case "error", "exception", "internalServerException", "invalidStateEvent":
|
||||||
// Handle error events from Kiro API stream
|
// Handle error events from Kiro API stream
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
@@ -2705,6 +2721,22 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "contextUsageEvent":
|
||||||
|
// Handle context usage events from Kiro API
|
||||||
|
// Format: {"contextUsageEvent": {"contextUsagePercentage": 0.53}}
|
||||||
|
if ctxUsage, ok := event["contextUsageEvent"].(map[string]interface{}); ok {
|
||||||
|
if ctxPct, ok := ctxUsage["contextUsagePercentage"].(float64); ok {
|
||||||
|
upstreamContextPercentage = ctxPct
|
||||||
|
log.Debugf("kiro: streamToChannel received contextUsageEvent: %.2f%%", ctxPct*100)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try direct field (fallback)
|
||||||
|
if ctxPct, ok := event["contextUsagePercentage"].(float64); ok {
|
||||||
|
upstreamContextPercentage = ctxPct
|
||||||
|
log.Debugf("kiro: streamToChannel received contextUsagePercentage (direct): %.2f%%", ctxPct*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "error", "exception", "internalServerException":
|
case "error", "exception", "internalServerException":
|
||||||
// Handle error events from Kiro API stream
|
// Handle error events from Kiro API stream
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ import (
|
|||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/iflow"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/iflow"
|
||||||
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/kimi"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import (
|
|||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// gcInterval defines minimum time between garbage collection runs.
|
||||||
|
const gcInterval = 5 * time.Minute
|
||||||
|
|
||||||
// GitTokenStore persists token records and auth metadata using git as the backing storage.
|
// GitTokenStore persists token records and auth metadata using git as the backing storage.
|
||||||
type GitTokenStore struct {
|
type GitTokenStore struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -31,6 +34,7 @@ type GitTokenStore struct {
|
|||||||
remote string
|
remote string
|
||||||
username string
|
username string
|
||||||
password string
|
password string
|
||||||
|
lastGC time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGitTokenStore creates a token store that saves credentials to disk through the
|
// NewGitTokenStore creates a token store that saves credentials to disk through the
|
||||||
@@ -613,6 +617,7 @@ func (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string)
|
|||||||
} else if errRewrite := s.rewriteHeadAsSingleCommit(repo, headRef.Name(), commitHash, message, signature); errRewrite != nil {
|
} else if errRewrite := s.rewriteHeadAsSingleCommit(repo, headRef.Name(), commitHash, message, signature); errRewrite != nil {
|
||||||
return errRewrite
|
return errRewrite
|
||||||
}
|
}
|
||||||
|
s.maybeRunGC(repo)
|
||||||
if err = repo.Push(&git.PushOptions{Auth: s.gitAuth(), Force: true}); err != nil {
|
if err = repo.Push(&git.PushOptions{Auth: s.gitAuth(), Force: true}); err != nil {
|
||||||
if errors.Is(err, git.NoErrAlreadyUpToDate) {
|
if errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
return nil
|
return nil
|
||||||
@@ -652,6 +657,23 @@ func (s *GitTokenStore) rewriteHeadAsSingleCommit(repo *git.Repository, branch p
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *GitTokenStore) maybeRunGC(repo *git.Repository) {
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(s.lastGC) < gcInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.lastGC = now
|
||||||
|
|
||||||
|
pruneOpts := git.PruneOptions{
|
||||||
|
OnlyObjectsOlderThan: now,
|
||||||
|
Handler: repo.DeleteObject,
|
||||||
|
}
|
||||||
|
if err := repo.Prune(pruneOpts); err != nil && !errors.Is(err, git.ErrLooseObjectsNotSupported) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = repo.RepackObjects(&git.RepackConfig{})
|
||||||
|
}
|
||||||
|
|
||||||
// PersistConfig commits and pushes configuration changes to git.
|
// PersistConfig commits and pushes configuration changes to git.
|
||||||
func (s *GitTokenStore) PersistConfig(_ context.Context) error {
|
func (s *GitTokenStore) PersistConfig(_ context.Context) error {
|
||||||
if err := s.EnsureRepository(); err != nil {
|
if err := s.EnsureRepository(); err != nil {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ var providerAppliers = map[string]ProviderApplier{
|
|||||||
"codex": nil,
|
"codex": nil,
|
||||||
"iflow": nil,
|
"iflow": nil,
|
||||||
"antigravity": nil,
|
"antigravity": nil,
|
||||||
|
"kimi": nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProviderApplier returns the ProviderApplier for the given provider name.
|
// GetProviderApplier returns the ProviderApplier for the given provider name.
|
||||||
@@ -326,6 +327,9 @@ func extractThinkingConfig(body []byte, provider string) ThinkingConfig {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
return extractOpenAIConfig(body)
|
return extractOpenAIConfig(body)
|
||||||
|
case "kimi":
|
||||||
|
// Kimi uses OpenAI-compatible reasoning_effort format
|
||||||
|
return extractOpenAIConfig(body)
|
||||||
default:
|
default:
|
||||||
return ThinkingConfig{}
|
return ThinkingConfig{}
|
||||||
}
|
}
|
||||||
|
|||||||
126
internal/thinking/provider/kimi/apply.go
Normal file
126
internal/thinking/provider/kimi/apply.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// Package kimi implements thinking configuration for Kimi (Moonshot AI) models.
|
||||||
|
//
|
||||||
|
// Kimi models use the OpenAI-compatible reasoning_effort format with discrete levels
|
||||||
|
// (low/medium/high). The provider strips any existing thinking config and applies
|
||||||
|
// the unified ThinkingConfig in OpenAI format.
|
||||||
|
package kimi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Applier implements thinking.ProviderApplier for Kimi models.
|
||||||
|
//
|
||||||
|
// Kimi-specific behavior:
|
||||||
|
// - Output format: reasoning_effort (string: low/medium/high)
|
||||||
|
// - Uses OpenAI-compatible format
|
||||||
|
// - Supports budget-to-level conversion
|
||||||
|
type Applier struct{}
|
||||||
|
|
||||||
|
var _ thinking.ProviderApplier = (*Applier)(nil)
|
||||||
|
|
||||||
|
// NewApplier creates a new Kimi thinking applier.
|
||||||
|
func NewApplier() *Applier {
|
||||||
|
return &Applier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
thinking.RegisterProvider("kimi", NewApplier())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies thinking configuration to Kimi request body.
|
||||||
|
//
|
||||||
|
// Expected output format:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "reasoning_effort": "high"
|
||||||
|
// }
|
||||||
|
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||||
|
if thinking.IsUserDefinedModel(modelInfo) {
|
||||||
|
return applyCompatibleKimi(body, config)
|
||||||
|
}
|
||||||
|
if modelInfo.Thinking == nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
var effort string
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeLevel:
|
||||||
|
if config.Level == "" {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
effort = string(config.Level)
|
||||||
|
case thinking.ModeNone:
|
||||||
|
// Kimi uses "none" to disable thinking
|
||||||
|
effort = string(thinking.LevelNone)
|
||||||
|
case thinking.ModeBudget:
|
||||||
|
// Convert budget to level using threshold mapping
|
||||||
|
level, ok := thinking.ConvertBudgetToLevel(config.Budget)
|
||||||
|
if !ok {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
effort = level
|
||||||
|
case thinking.ModeAuto:
|
||||||
|
// Auto mode maps to "auto" effort
|
||||||
|
effort = string(thinking.LevelAuto)
|
||||||
|
default:
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if effort == "" {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := sjson.SetBytes(body, "reasoning_effort", effort)
|
||||||
|
if err != nil {
|
||||||
|
return body, fmt.Errorf("kimi thinking: failed to set reasoning_effort: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyCompatibleKimi applies thinking config for user-defined Kimi models.
|
||||||
|
func applyCompatibleKimi(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
body = []byte(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
var effort string
|
||||||
|
switch config.Mode {
|
||||||
|
case thinking.ModeLevel:
|
||||||
|
if config.Level == "" {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
effort = string(config.Level)
|
||||||
|
case thinking.ModeNone:
|
||||||
|
effort = string(thinking.LevelNone)
|
||||||
|
if config.Level != "" {
|
||||||
|
effort = string(config.Level)
|
||||||
|
}
|
||||||
|
case thinking.ModeAuto:
|
||||||
|
effort = string(thinking.LevelAuto)
|
||||||
|
case thinking.ModeBudget:
|
||||||
|
// Convert budget to level
|
||||||
|
level, ok := thinking.ConvertBudgetToLevel(config.Budget)
|
||||||
|
if !ok {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
effort = level
|
||||||
|
default:
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := sjson.SetBytes(body, "reasoning_effort", effort)
|
||||||
|
if err != nil {
|
||||||
|
return body, fmt.Errorf("kimi thinking: failed to set reasoning_effort: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -113,10 +113,10 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
|||||||
template = `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`
|
template = `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`
|
||||||
p := (*param).(*ConvertCodexResponseToClaudeParams).HasToolCall
|
p := (*param).(*ConvertCodexResponseToClaudeParams).HasToolCall
|
||||||
stopReason := rootResult.Get("response.stop_reason").String()
|
stopReason := rootResult.Get("response.stop_reason").String()
|
||||||
if stopReason != "" {
|
if p {
|
||||||
template, _ = sjson.Set(template, "delta.stop_reason", stopReason)
|
|
||||||
} else if p {
|
|
||||||
template, _ = sjson.Set(template, "delta.stop_reason", "tool_use")
|
template, _ = sjson.Set(template, "delta.stop_reason", "tool_use")
|
||||||
|
} else if stopReason == "max_tokens" || stopReason == "stop" {
|
||||||
|
template, _ = sjson.Set(template, "delta.stop_reason", stopReason)
|
||||||
} else {
|
} else {
|
||||||
template, _ = sjson.Set(template, "delta.stop_reason", "end_turn")
|
template, _ = sjson.Set(template, "delta.stop_reason", "end_turn")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
. "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions"
|
. "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -77,14 +78,20 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
|||||||
template, _ = sjson.Set(template, "id", responseIDResult.String())
|
template, _ = sjson.Set(template, "id", responseIDResult.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract and set the finish reason.
|
finishReason := ""
|
||||||
if finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
|
if stopReasonResult := gjson.GetBytes(rawJSON, "response.stop_reason"); stopReasonResult.Exists() {
|
||||||
template, _ = sjson.Set(template, "choices.0.finish_reason", strings.ToLower(finishReasonResult.String()))
|
finishReason = stopReasonResult.String()
|
||||||
template, _ = sjson.Set(template, "choices.0.native_finish_reason", strings.ToLower(finishReasonResult.String()))
|
|
||||||
}
|
}
|
||||||
|
if finishReason == "" {
|
||||||
|
if finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
|
||||||
|
finishReason = finishReasonResult.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishReason = strings.ToLower(finishReason)
|
||||||
|
|
||||||
// Extract and set usage metadata (token counts).
|
// Extract and set usage metadata (token counts).
|
||||||
if usageResult := gjson.GetBytes(rawJSON, "response.usageMetadata"); usageResult.Exists() {
|
if usageResult := gjson.GetBytes(rawJSON, "response.usageMetadata"); usageResult.Exists() {
|
||||||
|
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
|
||||||
if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() {
|
if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() {
|
||||||
template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
|
template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
|
||||||
}
|
}
|
||||||
@@ -97,6 +104,14 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
|||||||
if thoughtsTokenCount > 0 {
|
if thoughtsTokenCount > 0 {
|
||||||
template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
|
template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
|
||||||
}
|
}
|
||||||
|
// Include cached token count if present (indicates prompt caching is working)
|
||||||
|
if cachedTokenCount > 0 {
|
||||||
|
var err error
|
||||||
|
template, err = sjson.Set(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("gemini-cli openai response: failed to set cached_tokens: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the main content part of the response.
|
// Process the main content part of the response.
|
||||||
@@ -187,6 +202,12 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
|||||||
if hasFunctionCall {
|
if hasFunctionCall {
|
||||||
template, _ = sjson.Set(template, "choices.0.finish_reason", "tool_calls")
|
template, _ = sjson.Set(template, "choices.0.finish_reason", "tool_calls")
|
||||||
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
|
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
|
||||||
|
} else if finishReason != "" && (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex == 0 {
|
||||||
|
// Only pass through specific finish reasons
|
||||||
|
if finishReason == "max_tokens" || finishReason == "stop" {
|
||||||
|
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReason)
|
||||||
|
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return []string{template}
|
return []string{template}
|
||||||
|
|||||||
@@ -129,11 +129,16 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
|
|||||||
candidateIndex := int(candidate.Get("index").Int())
|
candidateIndex := int(candidate.Get("index").Int())
|
||||||
template, _ = sjson.Set(template, "choices.0.index", candidateIndex)
|
template, _ = sjson.Set(template, "choices.0.index", candidateIndex)
|
||||||
|
|
||||||
// Extract and set the finish reason.
|
finishReason := ""
|
||||||
if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() {
|
if stopReasonResult := gjson.GetBytes(rawJSON, "stop_reason"); stopReasonResult.Exists() {
|
||||||
template, _ = sjson.Set(template, "choices.0.finish_reason", strings.ToLower(finishReasonResult.String()))
|
finishReason = stopReasonResult.String()
|
||||||
template, _ = sjson.Set(template, "choices.0.native_finish_reason", strings.ToLower(finishReasonResult.String()))
|
|
||||||
}
|
}
|
||||||
|
if finishReason == "" {
|
||||||
|
if finishReasonResult := gjson.GetBytes(rawJSON, "candidates.0.finishReason"); finishReasonResult.Exists() {
|
||||||
|
finishReason = finishReasonResult.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishReason = strings.ToLower(finishReason)
|
||||||
|
|
||||||
partsResult := candidate.Get("content.parts")
|
partsResult := candidate.Get("content.parts")
|
||||||
hasFunctionCall := false
|
hasFunctionCall := false
|
||||||
@@ -225,6 +230,12 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
|
|||||||
if hasFunctionCall {
|
if hasFunctionCall {
|
||||||
template, _ = sjson.Set(template, "choices.0.finish_reason", "tool_calls")
|
template, _ = sjson.Set(template, "choices.0.finish_reason", "tool_calls")
|
||||||
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
|
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
|
||||||
|
} else if finishReason != "" {
|
||||||
|
// Only pass through specific finish reasons
|
||||||
|
if finishReason == "max_tokens" || finishReason == "stop" {
|
||||||
|
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReason)
|
||||||
|
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
responseStrings = append(responseStrings, template)
|
responseStrings = append(responseStrings, template)
|
||||||
|
|||||||
@@ -608,18 +608,22 @@ func processMessages(messages gjson.Result, modelID, origin string) ([]KiroHisto
|
|||||||
|
|
||||||
if role == "user" {
|
if role == "user" {
|
||||||
userMsg, toolResults := BuildUserMessageStruct(msg, modelID, origin)
|
userMsg, toolResults := BuildUserMessageStruct(msg, modelID, origin)
|
||||||
|
// CRITICAL: Kiro API requires content to be non-empty for ALL user messages
|
||||||
|
// This includes both history messages and the current message.
|
||||||
|
// When user message contains only tool_result (no text), content will be empty.
|
||||||
|
// This commonly happens in compaction requests from OpenCode.
|
||||||
|
if strings.TrimSpace(userMsg.Content) == "" {
|
||||||
|
if len(toolResults) > 0 {
|
||||||
|
userMsg.Content = kirocommon.DefaultUserContentWithToolResults
|
||||||
|
} else {
|
||||||
|
userMsg.Content = kirocommon.DefaultUserContent
|
||||||
|
}
|
||||||
|
log.Debugf("kiro: user content was empty, using default: %s", userMsg.Content)
|
||||||
|
}
|
||||||
if isLastMessage {
|
if isLastMessage {
|
||||||
currentUserMsg = &userMsg
|
currentUserMsg = &userMsg
|
||||||
currentToolResults = toolResults
|
currentToolResults = toolResults
|
||||||
} else {
|
} else {
|
||||||
// CRITICAL: Kiro API requires content to be non-empty for history messages too
|
|
||||||
if strings.TrimSpace(userMsg.Content) == "" {
|
|
||||||
if len(toolResults) > 0 {
|
|
||||||
userMsg.Content = "Tool results provided."
|
|
||||||
} else {
|
|
||||||
userMsg.Content = "Continue"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For history messages, embed tool results in context
|
// For history messages, embed tool results in context
|
||||||
if len(toolResults) > 0 {
|
if len(toolResults) > 0 {
|
||||||
userMsg.UserInputMessageContext = &KiroUserInputMessageContext{
|
userMsg.UserInputMessageContext = &KiroUserInputMessageContext{
|
||||||
|
|||||||
@@ -31,11 +31,23 @@ const (
|
|||||||
|
|
||||||
// DefaultAssistantContentWithTools is the fallback content for assistant messages
|
// DefaultAssistantContentWithTools is the fallback content for assistant messages
|
||||||
// that have tool_use but no text content. Kiro API requires non-empty content.
|
// that have tool_use but no text content. Kiro API requires non-empty content.
|
||||||
DefaultAssistantContentWithTools = "I'll help you with that."
|
// IMPORTANT: Use a minimal neutral string that the model won't mimic in responses.
|
||||||
|
// Previously "I'll help you with that." which caused the model to parrot it back.
|
||||||
|
DefaultAssistantContentWithTools = "."
|
||||||
|
|
||||||
// DefaultAssistantContent is the fallback content for assistant messages
|
// DefaultAssistantContent is the fallback content for assistant messages
|
||||||
// that have no content at all. Kiro API requires non-empty content.
|
// that have no content at all. Kiro API requires non-empty content.
|
||||||
DefaultAssistantContent = "I understand."
|
// IMPORTANT: Use a minimal neutral string that the model won't mimic in responses.
|
||||||
|
// Previously "I understand." which could leak into model behavior.
|
||||||
|
DefaultAssistantContent = "."
|
||||||
|
|
||||||
|
// DefaultUserContentWithToolResults is the fallback content for user messages
|
||||||
|
// that have only tool_result (no text). Kiro API requires non-empty content.
|
||||||
|
DefaultUserContentWithToolResults = "Tool results provided."
|
||||||
|
|
||||||
|
// DefaultUserContent is the fallback content for user messages
|
||||||
|
// that have no content at all. Kiro API requires non-empty content.
|
||||||
|
DefaultUserContent = "Continue"
|
||||||
|
|
||||||
// KiroAgenticSystemPrompt is injected only for -agentic models to prevent timeouts on large writes.
|
// KiroAgenticSystemPrompt is injected only for -agentic models to prevent timeouts on large writes.
|
||||||
// AWS Kiro API has a 2-3 minute timeout for large file write operations.
|
// AWS Kiro API has a 2-3 minute timeout for large file write operations.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type ManagementTokenRequester interface {
|
|||||||
RequestCodexToken(*gin.Context)
|
RequestCodexToken(*gin.Context)
|
||||||
RequestAntigravityToken(*gin.Context)
|
RequestAntigravityToken(*gin.Context)
|
||||||
RequestQwenToken(*gin.Context)
|
RequestQwenToken(*gin.Context)
|
||||||
|
RequestKimiToken(*gin.Context)
|
||||||
RequestIFlowToken(*gin.Context)
|
RequestIFlowToken(*gin.Context)
|
||||||
RequestIFlowCookieToken(*gin.Context)
|
RequestIFlowCookieToken(*gin.Context)
|
||||||
GetAuthStatus(c *gin.Context)
|
GetAuthStatus(c *gin.Context)
|
||||||
@@ -55,6 +56,10 @@ func (m *managementTokenRequester) RequestQwenToken(c *gin.Context) {
|
|||||||
m.handler.RequestQwenToken(c)
|
m.handler.RequestQwenToken(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *managementTokenRequester) RequestKimiToken(c *gin.Context) {
|
||||||
|
m.handler.RequestKimiToken(c)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *managementTokenRequester) RequestIFlowToken(c *gin.Context) {
|
func (m *managementTokenRequester) RequestIFlowToken(c *gin.Context) {
|
||||||
m.handler.RequestIFlowToken(c)
|
m.handler.RequestIFlowToken(c)
|
||||||
}
|
}
|
||||||
|
|||||||
123
sdk/auth/kimi.go
Normal file
123
sdk/auth/kimi.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kimiRefreshLead is the duration before token expiry when refresh should occur.
|
||||||
|
var kimiRefreshLead = 5 * time.Minute
|
||||||
|
|
||||||
|
// KimiAuthenticator implements the OAuth device flow login for Kimi (Moonshot AI).
|
||||||
|
type KimiAuthenticator struct{}
|
||||||
|
|
||||||
|
// NewKimiAuthenticator constructs a new Kimi authenticator.
|
||||||
|
func NewKimiAuthenticator() Authenticator {
|
||||||
|
return &KimiAuthenticator{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider returns the provider key for kimi.
|
||||||
|
func (KimiAuthenticator) Provider() string {
|
||||||
|
return "kimi"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshLead returns the duration before token expiry when refresh should occur.
|
||||||
|
// Kimi tokens expire and need to be refreshed before expiry.
|
||||||
|
func (KimiAuthenticator) RefreshLead() *time.Duration {
|
||||||
|
return &kimiRefreshLead
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login initiates the Kimi device flow authentication.
|
||||||
|
func (a KimiAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||||
|
}
|
||||||
|
if opts == nil {
|
||||||
|
opts = &LoginOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
authSvc := kimi.NewKimiAuth(cfg)
|
||||||
|
|
||||||
|
// Start the device flow
|
||||||
|
fmt.Println("Starting Kimi authentication...")
|
||||||
|
deviceCode, err := authSvc.StartDeviceFlow(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: failed to start device flow: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the verification URL
|
||||||
|
verificationURL := deviceCode.VerificationURIComplete
|
||||||
|
if verificationURL == "" {
|
||||||
|
verificationURL = deviceCode.VerificationURI
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nTo authenticate, please visit:\n%s\n\n", verificationURL)
|
||||||
|
if deviceCode.UserCode != "" {
|
||||||
|
fmt.Printf("User code: %s\n\n", deviceCode.UserCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to open the browser automatically
|
||||||
|
if !opts.NoBrowser {
|
||||||
|
if browser.IsAvailable() {
|
||||||
|
if errOpen := browser.OpenURL(verificationURL); errOpen != nil {
|
||||||
|
log.Warnf("Failed to open browser automatically: %v", errOpen)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Browser opened automatically.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Waiting for authorization...")
|
||||||
|
if deviceCode.ExpiresIn > 0 {
|
||||||
|
fmt.Printf("(This will timeout in %d seconds if not authorized)\n", deviceCode.ExpiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for user authorization
|
||||||
|
authBundle, err := authSvc.WaitForAuthorization(ctx, deviceCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kimi: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the token storage
|
||||||
|
tokenStorage := authSvc.CreateTokenStorage(authBundle)
|
||||||
|
|
||||||
|
// Build metadata with token information
|
||||||
|
metadata := map[string]any{
|
||||||
|
"type": "kimi",
|
||||||
|
"access_token": authBundle.TokenData.AccessToken,
|
||||||
|
"refresh_token": authBundle.TokenData.RefreshToken,
|
||||||
|
"token_type": authBundle.TokenData.TokenType,
|
||||||
|
"scope": authBundle.TokenData.Scope,
|
||||||
|
"timestamp": time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if authBundle.TokenData.ExpiresAt > 0 {
|
||||||
|
exp := time.Unix(authBundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)
|
||||||
|
metadata["expired"] = exp
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(authBundle.DeviceID) != "" {
|
||||||
|
metadata["device_id"] = strings.TrimSpace(authBundle.DeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique filename
|
||||||
|
fileName := fmt.Sprintf("kimi-%d.json", time.Now().UnixMilli())
|
||||||
|
|
||||||
|
fmt.Println("\nKimi authentication successful!")
|
||||||
|
|
||||||
|
return &coreauth.Auth{
|
||||||
|
ID: fileName,
|
||||||
|
Provider: a.Provider(),
|
||||||
|
FileName: fileName,
|
||||||
|
Label: "Kimi User",
|
||||||
|
Storage: tokenStorage,
|
||||||
|
Metadata: metadata,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ func init() {
|
|||||||
registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })
|
registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })
|
||||||
registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
|
registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
|
||||||
registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
|
registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
|
||||||
|
registerRefreshLead("kimi", func() Authenticator { return NewKimiAuthenticator() })
|
||||||
registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() })
|
registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() })
|
||||||
registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() })
|
registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -607,6 +607,9 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
|
|||||||
result.RetryAfter = ra
|
result.RetryAfter = ra
|
||||||
}
|
}
|
||||||
m.MarkResult(execCtx, result)
|
m.MarkResult(execCtx, result)
|
||||||
|
if isRequestInvalidError(errExec) {
|
||||||
|
return cliproxyexecutor.Response{}, errExec
|
||||||
|
}
|
||||||
lastErr = errExec
|
lastErr = errExec
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -660,6 +663,9 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
|
|||||||
result.RetryAfter = ra
|
result.RetryAfter = ra
|
||||||
}
|
}
|
||||||
m.MarkResult(execCtx, result)
|
m.MarkResult(execCtx, result)
|
||||||
|
if isRequestInvalidError(errExec) {
|
||||||
|
return cliproxyexecutor.Response{}, errExec
|
||||||
|
}
|
||||||
lastErr = errExec
|
lastErr = errExec
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -711,6 +717,9 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
|
|||||||
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: rerr}
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: rerr}
|
||||||
result.RetryAfter = retryAfterFromError(errStream)
|
result.RetryAfter = retryAfterFromError(errStream)
|
||||||
m.MarkResult(execCtx, result)
|
m.MarkResult(execCtx, result)
|
||||||
|
if isRequestInvalidError(errStream) {
|
||||||
|
return nil, errStream
|
||||||
|
}
|
||||||
lastErr = errStream
|
lastErr = errStream
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1110,6 +1119,9 @@ func (m *Manager) shouldRetryAfterError(err error, attempt int, providers []stri
|
|||||||
if status := statusCodeFromError(err); status == http.StatusOK {
|
if status := statusCodeFromError(err); status == http.StatusOK {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
if isRequestInvalidError(err) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
wait, found := m.closestCooldownWait(providers, model, attempt)
|
wait, found := m.closestCooldownWait(providers, model, attempt)
|
||||||
if !found || wait > maxWait {
|
if !found || wait > maxWait {
|
||||||
return 0, false
|
return 0, false
|
||||||
@@ -1299,7 +1311,7 @@ func updateAggregatedAvailability(auth *Auth, now time.Time) {
|
|||||||
stateUnavailable = true
|
stateUnavailable = true
|
||||||
} else if state.Unavailable {
|
} else if state.Unavailable {
|
||||||
if state.NextRetryAfter.IsZero() {
|
if state.NextRetryAfter.IsZero() {
|
||||||
stateUnavailable = true
|
stateUnavailable = false
|
||||||
} else if state.NextRetryAfter.After(now) {
|
} else if state.NextRetryAfter.After(now) {
|
||||||
stateUnavailable = true
|
stateUnavailable = true
|
||||||
if earliestRetry.IsZero() || state.NextRetryAfter.Before(earliestRetry) {
|
if earliestRetry.IsZero() || state.NextRetryAfter.Before(earliestRetry) {
|
||||||
@@ -1430,6 +1442,21 @@ func statusCodeFromResult(err *Error) int {
|
|||||||
return err.StatusCode()
|
return err.StatusCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isRequestInvalidError returns true if the error represents a client request
|
||||||
|
// error that should not be retried. Specifically, it checks for 400 Bad Request
|
||||||
|
// with "invalid_request_error" in the message, indicating the request itself is
|
||||||
|
// malformed and switching to a different auth will not help.
|
||||||
|
func isRequestInvalidError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
status := statusCodeFromError(err)
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(err.Error(), "invalid_request_error")
|
||||||
|
}
|
||||||
|
|
||||||
func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Duration, now time.Time) {
|
func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Duration, now time.Time) {
|
||||||
if auth == nil {
|
if auth == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
61
sdk/cliproxy/auth/conductor_availability_test.go
Normal file
61
sdk/cliproxy/auth/conductor_availability_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateAggregatedAvailability_UnavailableWithoutNextRetryDoesNotBlockAuth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
model := "test-model"
|
||||||
|
auth := &Auth{
|
||||||
|
ID: "a",
|
||||||
|
ModelStates: map[string]*ModelState{
|
||||||
|
model: {
|
||||||
|
Status: StatusError,
|
||||||
|
Unavailable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAggregatedAvailability(auth, now)
|
||||||
|
|
||||||
|
if auth.Unavailable {
|
||||||
|
t.Fatalf("auth.Unavailable = true, want false")
|
||||||
|
}
|
||||||
|
if !auth.NextRetryAfter.IsZero() {
|
||||||
|
t.Fatalf("auth.NextRetryAfter = %v, want zero", auth.NextRetryAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAggregatedAvailability_FutureNextRetryBlocksAuth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
model := "test-model"
|
||||||
|
next := now.Add(5 * time.Minute)
|
||||||
|
auth := &Auth{
|
||||||
|
ID: "a",
|
||||||
|
ModelStates: map[string]*ModelState{
|
||||||
|
model: {
|
||||||
|
Status: StatusError,
|
||||||
|
Unavailable: true,
|
||||||
|
NextRetryAfter: next,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAggregatedAvailability(auth, now)
|
||||||
|
|
||||||
|
if !auth.Unavailable {
|
||||||
|
t.Fatalf("auth.Unavailable = false, want true")
|
||||||
|
}
|
||||||
|
if auth.NextRetryAfter.IsZero() {
|
||||||
|
t.Fatalf("auth.NextRetryAfter = zero, want %v", next)
|
||||||
|
}
|
||||||
|
if auth.NextRetryAfter.Sub(next) > time.Second || next.Sub(auth.NextRetryAfter) > time.Second {
|
||||||
|
t.Fatalf("auth.NextRetryAfter = %v, want %v", auth.NextRetryAfter, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -221,7 +221,7 @@ func modelAliasChannel(auth *Auth) string {
|
|||||||
// and auth kind. Returns empty string if the provider/authKind combination doesn't support
|
// and auth kind. Returns empty string if the provider/authKind combination doesn't support
|
||||||
// OAuth model alias (e.g., API key authentication).
|
// OAuth model alias (e.g., API key authentication).
|
||||||
//
|
//
|
||||||
// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot.
|
// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, kimi.
|
||||||
func OAuthModelAliasChannel(provider, authKind string) string {
|
func OAuthModelAliasChannel(provider, authKind string) string {
|
||||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
authKind = strings.ToLower(strings.TrimSpace(authKind))
|
authKind = strings.ToLower(strings.TrimSpace(authKind))
|
||||||
@@ -245,7 +245,7 @@ func OAuthModelAliasChannel(provider, authKind string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return "codex"
|
return "codex"
|
||||||
case "gemini-cli", "aistudio", "antigravity", "qwen", "iflow", "kiro", "github-copilot":
|
case "gemini-cli", "aistudio", "antigravity", "qwen", "iflow", "kiro", "github-copilot", "kimi":
|
||||||
return provider
|
return provider
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ func TestResolveOAuthUpstreamModel_SuffixPreservation(t *testing.T) {
|
|||||||
input: "gemini-2.5-pro(none)",
|
input: "gemini-2.5-pro(none)",
|
||||||
want: "gemini-2.5-pro-exp-03-25(none)",
|
want: "gemini-2.5-pro-exp-03-25(none)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "kimi suffix preserved",
|
||||||
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
|
"kimi": {{Name: "kimi-k2.5", Alias: "k2.5"}},
|
||||||
|
},
|
||||||
|
channel: "kimi",
|
||||||
|
input: "k2.5(high)",
|
||||||
|
want: "kimi-k2.5(high)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "case insensitive alias lookup with suffix",
|
name: "case insensitive alias lookup with suffix",
|
||||||
aliases: map[string][]internalconfig.OAuthModelAlias{
|
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||||
@@ -161,6 +170,8 @@ func createAuthForChannel(channel string) *Auth {
|
|||||||
return &Auth{Provider: "qwen"}
|
return &Auth{Provider: "qwen"}
|
||||||
case "iflow":
|
case "iflow":
|
||||||
return &Auth{Provider: "iflow"}
|
return &Auth{Provider: "iflow"}
|
||||||
|
case "kimi":
|
||||||
|
return &Auth{Provider: "kimi"}
|
||||||
case "kiro":
|
case "kiro":
|
||||||
return &Auth{Provider: "kiro"}
|
return &Auth{Provider: "kiro"}
|
||||||
default:
|
default:
|
||||||
@@ -168,6 +179,14 @@ func createAuthForChannel(channel string) *Auth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOAuthModelAliasChannel_Kimi(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := OAuthModelAliasChannel("kimi", "oauth"); got != "kimi" {
|
||||||
|
t.Fatalf("OAuthModelAliasChannel() = %q, want %q", got, "kimi")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyOAuthModelAlias_SuffixPreservation(t *testing.T) {
|
func TestApplyOAuthModelAlias_SuffixPreservation(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
type RoundRobinSelector struct {
|
type RoundRobinSelector struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cursors map[string]int
|
cursors map[string]int
|
||||||
|
maxKeys int
|
||||||
}
|
}
|
||||||
|
|
||||||
// FillFirstSelector selects the first available credential (deterministic ordering).
|
// FillFirstSelector selects the first available credential (deterministic ordering).
|
||||||
@@ -119,6 +121,19 @@ func authPriority(auth *Auth) int {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canonicalModelKey(model string) string {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed := thinking.ParseSuffix(model)
|
||||||
|
modelName := strings.TrimSpace(parsed.ModelName)
|
||||||
|
if modelName == "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return modelName
|
||||||
|
}
|
||||||
|
|
||||||
func collectAvailableByPriority(auths []*Auth, model string, now time.Time) (available map[int][]*Auth, cooldownCount int, earliest time.Time) {
|
func collectAvailableByPriority(auths []*Auth, model string, now time.Time) (available map[int][]*Auth, cooldownCount int, earliest time.Time) {
|
||||||
available = make(map[int][]*Auth)
|
available = make(map[int][]*Auth)
|
||||||
for i := 0; i < len(auths); i++ {
|
for i := 0; i < len(auths); i++ {
|
||||||
@@ -185,11 +200,18 @@ func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, o
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
key := provider + ":" + model
|
key := provider + ":" + canonicalModelKey(model)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.cursors == nil {
|
if s.cursors == nil {
|
||||||
s.cursors = make(map[string]int)
|
s.cursors = make(map[string]int)
|
||||||
}
|
}
|
||||||
|
limit := s.maxKeys
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 4096
|
||||||
|
}
|
||||||
|
if _, ok := s.cursors[key]; !ok && len(s.cursors) >= limit {
|
||||||
|
s.cursors = make(map[string]int)
|
||||||
|
}
|
||||||
index := s.cursors[key]
|
index := s.cursors[key]
|
||||||
|
|
||||||
if index >= 2_147_483_640 {
|
if index >= 2_147_483_640 {
|
||||||
@@ -223,7 +245,14 @@ func isAuthBlockedForModel(auth *Auth, model string, now time.Time) (bool, block
|
|||||||
}
|
}
|
||||||
if model != "" {
|
if model != "" {
|
||||||
if len(auth.ModelStates) > 0 {
|
if len(auth.ModelStates) > 0 {
|
||||||
if state, ok := auth.ModelStates[model]; ok && state != nil {
|
state, ok := auth.ModelStates[model]
|
||||||
|
if (!ok || state == nil) && model != "" {
|
||||||
|
baseModel := canonicalModelKey(model)
|
||||||
|
if baseModel != "" && baseModel != model {
|
||||||
|
state, ok = auth.ModelStates[baseModel]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok && state != nil {
|
||||||
if state.Status == StatusDisabled {
|
if state.Status == StatusDisabled {
|
||||||
return true, blockReasonDisabled, time.Time{}
|
return true, blockReasonDisabled, time.Time{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -175,3 +177,228 @@ func TestRoundRobinSelectorPick_Concurrent(t *testing.T) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelectorPick_AllCooldownReturnsModelCooldownError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := "test-model"
|
||||||
|
now := time.Now()
|
||||||
|
next := now.Add(60 * time.Second)
|
||||||
|
auths := []*Auth{
|
||||||
|
{
|
||||||
|
ID: "a",
|
||||||
|
ModelStates: map[string]*ModelState{
|
||||||
|
model: {
|
||||||
|
Status: StatusActive,
|
||||||
|
Unavailable: true,
|
||||||
|
NextRetryAfter: next,
|
||||||
|
Quota: QuotaState{
|
||||||
|
Exceeded: true,
|
||||||
|
NextRecoverAt: next,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "b",
|
||||||
|
ModelStates: map[string]*ModelState{
|
||||||
|
model: {
|
||||||
|
Status: StatusActive,
|
||||||
|
Unavailable: true,
|
||||||
|
NextRetryAfter: next,
|
||||||
|
Quota: QuotaState{
|
||||||
|
Exceeded: true,
|
||||||
|
NextRecoverAt: next,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("mixed provider redacts provider field", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
selector := &FillFirstSelector{}
|
||||||
|
_, err := selector.Pick(context.Background(), "mixed", model, cliproxyexecutor.Options{}, auths)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Pick() error = nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var mce *modelCooldownError
|
||||||
|
if !errors.As(err, &mce) {
|
||||||
|
t.Fatalf("Pick() error = %T, want *modelCooldownError", err)
|
||||||
|
}
|
||||||
|
if mce.StatusCode() != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("StatusCode() = %d, want %d", mce.StatusCode(), http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := mce.Headers()
|
||||||
|
if got := headers.Get("Retry-After"); got == "" {
|
||||||
|
t.Fatalf("Headers().Get(Retry-After) = empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(mce.Error()), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal(Error()) error = %v", err)
|
||||||
|
}
|
||||||
|
rawErr, ok := payload["error"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Error() payload missing error object: %v", payload)
|
||||||
|
}
|
||||||
|
if got, _ := rawErr["code"].(string); got != "model_cooldown" {
|
||||||
|
t.Fatalf("Error().error.code = %q, want %q", got, "model_cooldown")
|
||||||
|
}
|
||||||
|
if _, ok := rawErr["provider"]; ok {
|
||||||
|
t.Fatalf("Error().error.provider exists for mixed provider: %v", rawErr["provider"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-mixed provider includes provider field", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
selector := &FillFirstSelector{}
|
||||||
|
_, err := selector.Pick(context.Background(), "gemini", model, cliproxyexecutor.Options{}, auths)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Pick() error = nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var mce *modelCooldownError
|
||||||
|
if !errors.As(err, &mce) {
|
||||||
|
t.Fatalf("Pick() error = %T, want *modelCooldownError", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(mce.Error()), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal(Error()) error = %v", err)
|
||||||
|
}
|
||||||
|
rawErr, ok := payload["error"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Error() payload missing error object: %v", payload)
|
||||||
|
}
|
||||||
|
if got, _ := rawErr["provider"].(string); got != "gemini" {
|
||||||
|
t.Fatalf("Error().error.provider = %q, want %q", got, "gemini")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAuthBlockedForModel_UnavailableWithoutNextRetryIsNotBlocked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
model := "test-model"
|
||||||
|
auth := &Auth{
|
||||||
|
ID: "a",
|
||||||
|
ModelStates: map[string]*ModelState{
|
||||||
|
model: {
|
||||||
|
Status: StatusActive,
|
||||||
|
Unavailable: true,
|
||||||
|
Quota: QuotaState{
|
||||||
|
Exceeded: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked, reason, next := isAuthBlockedForModel(auth, model, now)
|
||||||
|
if blocked {
|
||||||
|
t.Fatalf("blocked = true, want false")
|
||||||
|
}
|
||||||
|
if reason != blockReasonNone {
|
||||||
|
t.Fatalf("reason = %v, want %v", reason, blockReasonNone)
|
||||||
|
}
|
||||||
|
if !next.IsZero() {
|
||||||
|
t.Fatalf("next = %v, want zero", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFillFirstSelectorPick_ThinkingSuffixFallsBackToBaseModelState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
selector := &FillFirstSelector{}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
baseModel := "test-model"
|
||||||
|
requestedModel := "test-model(high)"
|
||||||
|
|
||||||
|
high := &Auth{
|
||||||
|
ID: "high",
|
||||||
|
Attributes: map[string]string{"priority": "10"},
|
||||||
|
ModelStates: map[string]*ModelState{
|
||||||
|
baseModel: {
|
||||||
|
Status: StatusActive,
|
||||||
|
Unavailable: true,
|
||||||
|
NextRetryAfter: now.Add(30 * time.Minute),
|
||||||
|
Quota: QuotaState{
|
||||||
|
Exceeded: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
low := &Auth{
|
||||||
|
ID: "low",
|
||||||
|
Attributes: map[string]string{"priority": "0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := selector.Pick(context.Background(), "mixed", requestedModel, cliproxyexecutor.Options{}, []*Auth{high, low})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Pick() error = %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("Pick() auth = nil")
|
||||||
|
}
|
||||||
|
if got.ID != "low" {
|
||||||
|
t.Fatalf("Pick() auth.ID = %q, want %q", got.ID, "low")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundRobinSelectorPick_ThinkingSuffixSharesCursor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
selector := &RoundRobinSelector{}
|
||||||
|
auths := []*Auth{
|
||||||
|
{ID: "b"},
|
||||||
|
{ID: "a"},
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := selector.Pick(context.Background(), "gemini", "test-model(high)", cliproxyexecutor.Options{}, auths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Pick() first error = %v", err)
|
||||||
|
}
|
||||||
|
second, err := selector.Pick(context.Background(), "gemini", "test-model(low)", cliproxyexecutor.Options{}, auths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Pick() second error = %v", err)
|
||||||
|
}
|
||||||
|
if first == nil || second == nil {
|
||||||
|
t.Fatalf("Pick() returned nil auth")
|
||||||
|
}
|
||||||
|
if first.ID != "a" {
|
||||||
|
t.Fatalf("Pick() first auth.ID = %q, want %q", first.ID, "a")
|
||||||
|
}
|
||||||
|
if second.ID != "b" {
|
||||||
|
t.Fatalf("Pick() second auth.ID = %q, want %q", second.ID, "b")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundRobinSelectorPick_CursorKeyCap(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
selector := &RoundRobinSelector{maxKeys: 2}
|
||||||
|
auths := []*Auth{{ID: "a"}}
|
||||||
|
|
||||||
|
_, _ = selector.Pick(context.Background(), "gemini", "m1", cliproxyexecutor.Options{}, auths)
|
||||||
|
_, _ = selector.Pick(context.Background(), "gemini", "m2", cliproxyexecutor.Options{}, auths)
|
||||||
|
_, _ = selector.Pick(context.Background(), "gemini", "m3", cliproxyexecutor.Options{}, auths)
|
||||||
|
|
||||||
|
selector.mu.Lock()
|
||||||
|
defer selector.mu.Unlock()
|
||||||
|
|
||||||
|
if selector.cursors == nil {
|
||||||
|
t.Fatalf("selector.cursors = nil")
|
||||||
|
}
|
||||||
|
if len(selector.cursors) != 1 {
|
||||||
|
t.Fatalf("len(selector.cursors) = %d, want %d", len(selector.cursors), 1)
|
||||||
|
}
|
||||||
|
if _, ok := selector.cursors["gemini:m3"]; !ok {
|
||||||
|
t.Fatalf("selector.cursors missing key %q", "gemini:m3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -409,6 +409,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
|
|||||||
s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg))
|
s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg))
|
||||||
case "iflow":
|
case "iflow":
|
||||||
s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))
|
s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))
|
||||||
|
case "kimi":
|
||||||
|
s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg))
|
||||||
case "kiro":
|
case "kiro":
|
||||||
s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg))
|
s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg))
|
||||||
case "github-copilot":
|
case "github-copilot":
|
||||||
@@ -826,6 +828,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
case "iflow":
|
case "iflow":
|
||||||
models = registry.GetIFlowModels()
|
models = registry.GetIFlowModels()
|
||||||
models = applyExcludedModels(models, excluded)
|
models = applyExcludedModels(models, excluded)
|
||||||
|
case "kimi":
|
||||||
|
models = registry.GetKimiModels()
|
||||||
|
models = applyExcludedModels(models, excluded)
|
||||||
case "github-copilot":
|
case "github-copilot":
|
||||||
models = registry.GetGitHubCopilotModels()
|
models = registry.GetGitHubCopilotModels()
|
||||||
models = applyExcludedModels(models, excluded)
|
models = applyExcludedModels(models, excluded)
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
package test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLegacyConfigMigration(t *testing.T) {
|
|
||||||
t.Run("onlyLegacyFields", func(t *testing.T) {
|
|
||||||
path := writeConfig(t, `
|
|
||||||
port: 8080
|
|
||||||
generative-language-api-key:
|
|
||||||
- "legacy-gemini-1"
|
|
||||||
openai-compatibility:
|
|
||||||
- name: "legacy-provider"
|
|
||||||
base-url: "https://example.com"
|
|
||||||
api-keys:
|
|
||||||
- "legacy-openai-1"
|
|
||||||
amp-upstream-url: "https://amp.example.com"
|
|
||||||
amp-upstream-api-key: "amp-legacy-key"
|
|
||||||
amp-restrict-management-to-localhost: false
|
|
||||||
amp-model-mappings:
|
|
||||||
- from: "old-model"
|
|
||||||
to: "new-model"
|
|
||||||
`)
|
|
||||||
cfg, err := config.LoadConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load legacy config: %v", err)
|
|
||||||
}
|
|
||||||
if got := len(cfg.GeminiKey); got != 1 || cfg.GeminiKey[0].APIKey != "legacy-gemini-1" {
|
|
||||||
t.Fatalf("gemini migration mismatch: %+v", cfg.GeminiKey)
|
|
||||||
}
|
|
||||||
if got := len(cfg.OpenAICompatibility); got != 1 {
|
|
||||||
t.Fatalf("expected 1 openai-compat provider, got %d", got)
|
|
||||||
}
|
|
||||||
if entries := cfg.OpenAICompatibility[0].APIKeyEntries; len(entries) != 1 || entries[0].APIKey != "legacy-openai-1" {
|
|
||||||
t.Fatalf("openai-compat migration mismatch: %+v", entries)
|
|
||||||
}
|
|
||||||
if cfg.AmpCode.UpstreamURL != "https://amp.example.com" || cfg.AmpCode.UpstreamAPIKey != "amp-legacy-key" {
|
|
||||||
t.Fatalf("amp migration failed: %+v", cfg.AmpCode)
|
|
||||||
}
|
|
||||||
if cfg.AmpCode.RestrictManagementToLocalhost {
|
|
||||||
t.Fatalf("expected amp restriction to be false after migration")
|
|
||||||
}
|
|
||||||
if got := len(cfg.AmpCode.ModelMappings); got != 1 || cfg.AmpCode.ModelMappings[0].From != "old-model" {
|
|
||||||
t.Fatalf("amp mappings migration mismatch: %+v", cfg.AmpCode.ModelMappings)
|
|
||||||
}
|
|
||||||
updated := readFile(t, path)
|
|
||||||
if strings.Contains(updated, "generative-language-api-key") {
|
|
||||||
t.Fatalf("legacy gemini key still present:\n%s", updated)
|
|
||||||
}
|
|
||||||
if strings.Contains(updated, "amp-upstream-url") || strings.Contains(updated, "amp-restrict-management-to-localhost") {
|
|
||||||
t.Fatalf("legacy amp keys still present:\n%s", updated)
|
|
||||||
}
|
|
||||||
if strings.Contains(updated, "\n api-keys:") {
|
|
||||||
t.Fatalf("legacy openai compat keys still present:\n%s", updated)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("mixedLegacyAndNewFields", func(t *testing.T) {
|
|
||||||
path := writeConfig(t, `
|
|
||||||
gemini-api-key:
|
|
||||||
- api-key: "new-gemini"
|
|
||||||
generative-language-api-key:
|
|
||||||
- "new-gemini"
|
|
||||||
- "legacy-gemini-only"
|
|
||||||
openai-compatibility:
|
|
||||||
- name: "mixed-provider"
|
|
||||||
base-url: "https://mixed.example.com"
|
|
||||||
api-key-entries:
|
|
||||||
- api-key: "new-entry"
|
|
||||||
api-keys:
|
|
||||||
- "legacy-entry"
|
|
||||||
- "new-entry"
|
|
||||||
`)
|
|
||||||
cfg, err := config.LoadConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load mixed config: %v", err)
|
|
||||||
}
|
|
||||||
if got := len(cfg.GeminiKey); got != 2 {
|
|
||||||
t.Fatalf("expected 2 gemini entries, got %d: %+v", got, cfg.GeminiKey)
|
|
||||||
}
|
|
||||||
seen := make(map[string]struct{}, len(cfg.GeminiKey))
|
|
||||||
for _, entry := range cfg.GeminiKey {
|
|
||||||
if _, exists := seen[entry.APIKey]; exists {
|
|
||||||
t.Fatalf("duplicate gemini key %q after migration", entry.APIKey)
|
|
||||||
}
|
|
||||||
seen[entry.APIKey] = struct{}{}
|
|
||||||
}
|
|
||||||
provider := cfg.OpenAICompatibility[0]
|
|
||||||
if got := len(provider.APIKeyEntries); got != 2 {
|
|
||||||
t.Fatalf("expected 2 openai entries, got %d: %+v", got, provider.APIKeyEntries)
|
|
||||||
}
|
|
||||||
entrySeen := make(map[string]struct{}, len(provider.APIKeyEntries))
|
|
||||||
for _, entry := range provider.APIKeyEntries {
|
|
||||||
if _, ok := entrySeen[entry.APIKey]; ok {
|
|
||||||
t.Fatalf("duplicate openai key %q after migration", entry.APIKey)
|
|
||||||
}
|
|
||||||
entrySeen[entry.APIKey] = struct{}{}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("onlyNewFields", func(t *testing.T) {
|
|
||||||
path := writeConfig(t, `
|
|
||||||
gemini-api-key:
|
|
||||||
- api-key: "new-only"
|
|
||||||
openai-compatibility:
|
|
||||||
- name: "new-only-provider"
|
|
||||||
base-url: "https://new-only.example.com"
|
|
||||||
api-key-entries:
|
|
||||||
- api-key: "new-only-entry"
|
|
||||||
ampcode:
|
|
||||||
upstream-url: "https://amp.new"
|
|
||||||
upstream-api-key: "new-amp-key"
|
|
||||||
restrict-management-to-localhost: true
|
|
||||||
model-mappings:
|
|
||||||
- from: "a"
|
|
||||||
to: "b"
|
|
||||||
`)
|
|
||||||
cfg, err := config.LoadConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load new config: %v", err)
|
|
||||||
}
|
|
||||||
if len(cfg.GeminiKey) != 1 || cfg.GeminiKey[0].APIKey != "new-only" {
|
|
||||||
t.Fatalf("unexpected gemini entries: %+v", cfg.GeminiKey)
|
|
||||||
}
|
|
||||||
if len(cfg.OpenAICompatibility) != 1 || len(cfg.OpenAICompatibility[0].APIKeyEntries) != 1 {
|
|
||||||
t.Fatalf("unexpected openai compat entries: %+v", cfg.OpenAICompatibility)
|
|
||||||
}
|
|
||||||
if cfg.AmpCode.UpstreamURL != "https://amp.new" || cfg.AmpCode.UpstreamAPIKey != "new-amp-key" {
|
|
||||||
t.Fatalf("unexpected amp config: %+v", cfg.AmpCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("duplicateNamesDifferentBase", func(t *testing.T) {
|
|
||||||
path := writeConfig(t, `
|
|
||||||
openai-compatibility:
|
|
||||||
- name: "dup-provider"
|
|
||||||
base-url: "https://provider-a"
|
|
||||||
api-keys:
|
|
||||||
- "key-a"
|
|
||||||
- name: "dup-provider"
|
|
||||||
base-url: "https://provider-b"
|
|
||||||
api-keys:
|
|
||||||
- "key-b"
|
|
||||||
`)
|
|
||||||
cfg, err := config.LoadConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load duplicate config: %v", err)
|
|
||||||
}
|
|
||||||
if len(cfg.OpenAICompatibility) != 2 {
|
|
||||||
t.Fatalf("expected 2 providers, got %d", len(cfg.OpenAICompatibility))
|
|
||||||
}
|
|
||||||
for _, entry := range cfg.OpenAICompatibility {
|
|
||||||
if len(entry.APIKeyEntries) != 1 {
|
|
||||||
t.Fatalf("expected 1 key entry per provider: %+v", entry)
|
|
||||||
}
|
|
||||||
switch entry.BaseURL {
|
|
||||||
case "https://provider-a":
|
|
||||||
if entry.APIKeyEntries[0].APIKey != "key-a" {
|
|
||||||
t.Fatalf("provider-a key mismatch: %+v", entry.APIKeyEntries)
|
|
||||||
}
|
|
||||||
case "https://provider-b":
|
|
||||||
if entry.APIKeyEntries[0].APIKey != "key-b" {
|
|
||||||
t.Fatalf("provider-b key mismatch: %+v", entry.APIKeyEntries)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected provider base url: %s", entry.BaseURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeConfig(t *testing.T, content string) string {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
if err := os.WriteFile(path, []byte(strings.TrimSpace(content)+"\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("write temp config: %v", err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFile(t *testing.T, path string) string {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read temp config: %v", err)
|
|
||||||
}
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/iflow"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/iflow"
|
||||||
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/kimi"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
|||||||
Reference in New Issue
Block a user