mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-21 07:02:05 +00:00
Merge branch 'main' into plus
This commit is contained in:
@@ -62,7 +62,7 @@ const (
|
||||
refreshCheckInterval = 5 * time.Second
|
||||
refreshMaxConcurrency = 16
|
||||
refreshPendingBackoff = time.Minute
|
||||
refreshFailureBackoff = 5 * time.Minute
|
||||
refreshFailureBackoff = 1 * time.Minute
|
||||
quotaBackoffBase = time.Second
|
||||
quotaBackoffMax = 30 * time.Minute
|
||||
)
|
||||
@@ -2662,7 +2662,9 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) {
|
||||
updated.Runtime = auth.Runtime
|
||||
}
|
||||
updated.LastRefreshedAt = now
|
||||
updated.NextRefreshAfter = time.Time{}
|
||||
// Preserve NextRefreshAfter set by the Authenticator
|
||||
// If the Authenticator set a reasonable refresh time, it should not be overwritten
|
||||
// If the Authenticator did not set it (zero value), shouldRefresh will use default logic
|
||||
updated.LastError = nil
|
||||
updated.UpdatedAt = now
|
||||
_, _ = m.Update(ctx, updated)
|
||||
|
||||
@@ -265,7 +265,7 @@ func modelAliasChannel(auth *Auth) string {
|
||||
// and auth kind. Returns empty string if the provider/authKind combination doesn't support
|
||||
// OAuth model alias (e.g., API key authentication).
|
||||
//
|
||||
// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kimi.
|
||||
// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, kimi.
|
||||
func OAuthModelAliasChannel(provider, authKind string) string {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
authKind = strings.ToLower(strings.TrimSpace(authKind))
|
||||
@@ -289,7 +289,7 @@ func OAuthModelAliasChannel(provider, authKind string) string {
|
||||
return ""
|
||||
}
|
||||
return "codex"
|
||||
case "gemini-cli", "aistudio", "antigravity", "qwen", "iflow", "kimi":
|
||||
case "gemini-cli", "aistudio", "antigravity", "qwen", "iflow", "kiro", "github-copilot", "kimi":
|
||||
return provider
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -43,6 +43,15 @@ func TestResolveOAuthUpstreamModel_SuffixPreservation(t *testing.T) {
|
||||
input: "gemini-2.5-pro",
|
||||
want: "gemini-2.5-pro-exp-03-25",
|
||||
},
|
||||
{
|
||||
name: "kiro alias resolves",
|
||||
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||
"kiro": {{Name: "kiro-claude-sonnet-4-5", Alias: "sonnet"}},
|
||||
},
|
||||
channel: "kiro",
|
||||
input: "sonnet",
|
||||
want: "kiro-claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
name: "config suffix takes priority",
|
||||
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||
@@ -70,6 +79,24 @@ func TestResolveOAuthUpstreamModel_SuffixPreservation(t *testing.T) {
|
||||
input: "gemini-2.5-pro(none)",
|
||||
want: "gemini-2.5-pro-exp-03-25(none)",
|
||||
},
|
||||
{
|
||||
name: "github-copilot suffix preserved",
|
||||
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||
"github-copilot": {{Name: "claude-opus-4.6", Alias: "opus"}},
|
||||
},
|
||||
channel: "github-copilot",
|
||||
input: "opus(medium)",
|
||||
want: "claude-opus-4.6(medium)",
|
||||
},
|
||||
{
|
||||
name: "github-copilot no suffix",
|
||||
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||
"github-copilot": {{Name: "claude-opus-4.6", Alias: "opus"}},
|
||||
},
|
||||
channel: "github-copilot",
|
||||
input: "opus",
|
||||
want: "claude-opus-4.6",
|
||||
},
|
||||
{
|
||||
name: "kimi suffix preserved",
|
||||
aliases: map[string][]internalconfig.OAuthModelAlias{
|
||||
@@ -163,6 +190,10 @@ func createAuthForChannel(channel string) *Auth {
|
||||
return &Auth{Provider: "iflow"}
|
||||
case "kimi":
|
||||
return &Auth{Provider: "kimi"}
|
||||
case "kiro":
|
||||
return &Auth{Provider: "kiro"}
|
||||
case "github-copilot":
|
||||
return &Auth{Provider: "github-copilot"}
|
||||
default:
|
||||
return &Auth{Provider: channel}
|
||||
}
|
||||
@@ -176,6 +207,22 @@ func TestOAuthModelAliasChannel_Kimi(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthModelAliasChannel_GitHubCopilot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := OAuthModelAliasChannel("github-copilot", ""); got != "github-copilot" {
|
||||
t.Fatalf("OAuthModelAliasChannel() = %q, want %q", got, "github-copilot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthModelAliasChannel_Kiro(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := OAuthModelAliasChannel("kiro", ""); got != "kiro" {
|
||||
t.Fatalf("OAuthModelAliasChannel() = %q, want %q", got, "kiro")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOAuthModelAlias_SuffixPreservation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -376,6 +376,18 @@ func (a *Auth) AccountInfo() (string, string) {
|
||||
}
|
||||
}
|
||||
|
||||
// For GitHub provider (including github-copilot), return username
|
||||
if strings.HasPrefix(strings.ToLower(a.Provider), "github") {
|
||||
if a.Metadata != nil {
|
||||
if username, ok := a.Metadata["username"].(string); ok {
|
||||
username = strings.TrimSpace(username)
|
||||
if username != "" {
|
||||
return "oauth", username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check metadata for email first (OAuth-style auth)
|
||||
if a.Metadata != nil {
|
||||
if v, ok := a.Metadata["email"].(string); ok {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||
kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||
@@ -100,6 +101,16 @@ func (s *Service) RegisterUsagePlugin(plugin usage.Plugin) {
|
||||
usage.RegisterPlugin(plugin)
|
||||
}
|
||||
|
||||
// GetWatcher returns the underlying WatcherWrapper instance.
|
||||
// This allows external components (e.g., RefreshManager) to interact with the watcher.
|
||||
// Returns nil if the service or watcher is not initialized.
|
||||
func (s *Service) GetWatcher() *WatcherWrapper {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.watcher
|
||||
}
|
||||
|
||||
// newDefaultAuthManager creates a default authentication manager with all supported providers.
|
||||
func newDefaultAuthManager() *sdkAuth.Manager {
|
||||
return sdkAuth.NewManager(
|
||||
@@ -427,6 +438,12 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace
|
||||
s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))
|
||||
case "kimi":
|
||||
s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg))
|
||||
case "kiro":
|
||||
s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg))
|
||||
case "kilo":
|
||||
s.coreManager.RegisterExecutor(executor.NewKiloExecutor(s.cfg))
|
||||
case "github-copilot":
|
||||
s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg))
|
||||
default:
|
||||
providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
|
||||
if providerKey == "" {
|
||||
@@ -627,6 +644,18 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
}
|
||||
watcherWrapper.SetConfig(s.cfg)
|
||||
|
||||
// 方案 A: 连接 Kiro 后台刷新器回调到 Watcher
|
||||
// 当后台刷新器成功刷新 token 后,立即通知 Watcher 更新内存中的 Auth 对象
|
||||
// 这解决了后台刷新与内存 Auth 对象之间的时间差问题
|
||||
kiroauth.GetRefreshManager().SetOnTokenRefreshed(func(tokenID string, tokenData *kiroauth.KiroTokenData) {
|
||||
if tokenData == nil || watcherWrapper == nil {
|
||||
return
|
||||
}
|
||||
log.Debugf("kiro refresh callback: notifying watcher for token %s", tokenID)
|
||||
watcherWrapper.NotifyTokenRefreshed(tokenID, tokenData.AccessToken, tokenData.RefreshToken, tokenData.ExpiresAt)
|
||||
})
|
||||
log.Debug("kiro: connected background refresh callback to watcher")
|
||||
|
||||
watcherCtx, watcherCancel := context.WithCancel(context.Background())
|
||||
s.watcherCancel = watcherCancel
|
||||
if err = watcherWrapper.Start(watcherCtx); err != nil {
|
||||
@@ -847,6 +876,17 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
||||
models = applyExcludedModels(models, excluded)
|
||||
case "kimi":
|
||||
models = registry.GetKimiModels()
|
||||
models = applyExcludedModels(models, excluded)
|
||||
case "github-copilot":
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
models = executor.FetchGitHubCopilotModels(ctx, a, s.cfg)
|
||||
models = applyExcludedModels(models, excluded)
|
||||
case "kiro":
|
||||
models = s.fetchKiroModels(a)
|
||||
models = applyExcludedModels(models, excluded)
|
||||
case "kilo":
|
||||
models = executor.FetchKiloModels(context.Background(), a, s.cfg)
|
||||
models = applyExcludedModels(models, excluded)
|
||||
default:
|
||||
// Handle OpenAI-compatibility providers by name using config
|
||||
@@ -1462,3 +1502,216 @@ func applyOAuthModelAlias(cfg *config.Config, provider, authKind string, models
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchKiroModels attempts to dynamically fetch Kiro models from the API.
|
||||
// If dynamic fetch fails, it falls back to static registry.GetKiroModels().
|
||||
func (s *Service) fetchKiroModels(a *coreauth.Auth) []*ModelInfo {
|
||||
if a == nil {
|
||||
log.Debug("kiro: auth is nil, using static models")
|
||||
return registry.GetKiroModels()
|
||||
}
|
||||
|
||||
// Extract token data from auth attributes
|
||||
tokenData := s.extractKiroTokenData(a)
|
||||
if tokenData == nil || tokenData.AccessToken == "" {
|
||||
log.Debug("kiro: no valid token data in auth, using static models")
|
||||
return registry.GetKiroModels()
|
||||
}
|
||||
|
||||
// Create KiroAuth instance
|
||||
kAuth := kiroauth.NewKiroAuth(s.cfg)
|
||||
if kAuth == nil {
|
||||
log.Warn("kiro: failed to create KiroAuth instance, using static models")
|
||||
return registry.GetKiroModels()
|
||||
}
|
||||
|
||||
// Use timeout context for API call
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Attempt to fetch dynamic models
|
||||
apiModels, err := kAuth.ListAvailableModels(ctx, tokenData)
|
||||
if err != nil {
|
||||
log.Warnf("kiro: failed to fetch dynamic models: %v, using static models", err)
|
||||
return registry.GetKiroModels()
|
||||
}
|
||||
|
||||
if len(apiModels) == 0 {
|
||||
log.Debug("kiro: API returned no models, using static models")
|
||||
return registry.GetKiroModels()
|
||||
}
|
||||
|
||||
// Convert API models to ModelInfo
|
||||
models := convertKiroAPIModels(apiModels)
|
||||
|
||||
// Generate agentic variants
|
||||
models = generateKiroAgenticVariants(models)
|
||||
|
||||
log.Infof("kiro: successfully fetched %d models from API (including agentic variants)", len(models))
|
||||
return models
|
||||
}
|
||||
|
||||
// extractKiroTokenData extracts KiroTokenData from auth attributes and metadata.
|
||||
// It supports both config-based tokens (stored in Attributes) and file-based tokens (stored in Metadata).
|
||||
func (s *Service) extractKiroTokenData(a *coreauth.Auth) *kiroauth.KiroTokenData {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var accessToken, profileArn, refreshToken string
|
||||
|
||||
// Priority 1: Try to get from Attributes (config.yaml source)
|
||||
if a.Attributes != nil {
|
||||
accessToken = strings.TrimSpace(a.Attributes["access_token"])
|
||||
profileArn = strings.TrimSpace(a.Attributes["profile_arn"])
|
||||
refreshToken = strings.TrimSpace(a.Attributes["refresh_token"])
|
||||
}
|
||||
|
||||
// Priority 2: If not found in Attributes, try Metadata (JSON file source)
|
||||
if accessToken == "" && a.Metadata != nil {
|
||||
if at, ok := a.Metadata["access_token"].(string); ok {
|
||||
accessToken = strings.TrimSpace(at)
|
||||
}
|
||||
if pa, ok := a.Metadata["profile_arn"].(string); ok {
|
||||
profileArn = strings.TrimSpace(pa)
|
||||
}
|
||||
if rt, ok := a.Metadata["refresh_token"].(string); ok {
|
||||
refreshToken = strings.TrimSpace(rt)
|
||||
}
|
||||
}
|
||||
|
||||
// access_token is required
|
||||
if accessToken == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &kiroauth.KiroTokenData{
|
||||
AccessToken: accessToken,
|
||||
ProfileArn: profileArn,
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
// convertKiroAPIModels converts Kiro API models to ModelInfo slice.
|
||||
func convertKiroAPIModels(apiModels []*kiroauth.KiroModel) []*ModelInfo {
|
||||
if len(apiModels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
models := make([]*ModelInfo, 0, len(apiModels))
|
||||
|
||||
for _, m := range apiModels {
|
||||
if m == nil || m.ModelID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create model ID with kiro- prefix
|
||||
modelID := "kiro-" + normalizeKiroModelID(m.ModelID)
|
||||
|
||||
info := &ModelInfo{
|
||||
ID: modelID,
|
||||
Object: "model",
|
||||
Created: now,
|
||||
OwnedBy: "aws",
|
||||
Type: "kiro",
|
||||
DisplayName: formatKiroDisplayName(m.ModelName, m.RateMultiplier),
|
||||
Description: m.Description,
|
||||
ContextLength: 200000,
|
||||
MaxCompletionTokens: 64000,
|
||||
Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
|
||||
}
|
||||
|
||||
if m.MaxInputTokens > 0 {
|
||||
info.ContextLength = m.MaxInputTokens
|
||||
}
|
||||
|
||||
models = append(models, info)
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
||||
// normalizeKiroModelID normalizes a Kiro model ID by converting dots to dashes
|
||||
// and removing common prefixes.
|
||||
func normalizeKiroModelID(modelID string) string {
|
||||
// Remove common prefixes
|
||||
modelID = strings.TrimPrefix(modelID, "anthropic.")
|
||||
modelID = strings.TrimPrefix(modelID, "amazon.")
|
||||
|
||||
// Replace dots with dashes for consistency
|
||||
modelID = strings.ReplaceAll(modelID, ".", "-")
|
||||
|
||||
// Replace underscores with dashes
|
||||
modelID = strings.ReplaceAll(modelID, "_", "-")
|
||||
|
||||
return strings.ToLower(modelID)
|
||||
}
|
||||
|
||||
// formatKiroDisplayName formats the display name with rate multiplier info.
|
||||
func formatKiroDisplayName(modelName string, rateMultiplier float64) string {
|
||||
if modelName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
displayName := "Kiro " + modelName
|
||||
if rateMultiplier > 0 && rateMultiplier != 1.0 {
|
||||
displayName += fmt.Sprintf(" (%.1fx credit)", rateMultiplier)
|
||||
}
|
||||
|
||||
return displayName
|
||||
}
|
||||
|
||||
// generateKiroAgenticVariants generates agentic variants for Kiro models.
|
||||
// Agentic variants have optimized system prompts for coding agents.
|
||||
func generateKiroAgenticVariants(models []*ModelInfo) []*ModelInfo {
|
||||
if len(models) == 0 {
|
||||
return models
|
||||
}
|
||||
|
||||
result := make([]*ModelInfo, 0, len(models)*2)
|
||||
result = append(result, models...)
|
||||
|
||||
for _, m := range models {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if already an agentic variant
|
||||
if strings.HasSuffix(m.ID, "-agentic") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip auto models from agentic variant generation
|
||||
if strings.Contains(m.ID, "-auto") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create agentic variant
|
||||
agentic := &ModelInfo{
|
||||
ID: m.ID + "-agentic",
|
||||
Object: m.Object,
|
||||
Created: m.Created,
|
||||
OwnedBy: m.OwnedBy,
|
||||
Type: m.Type,
|
||||
DisplayName: m.DisplayName + " (Agentic)",
|
||||
Description: m.Description + " - Optimized for coding agents (chunked writes)",
|
||||
ContextLength: m.ContextLength,
|
||||
MaxCompletionTokens: m.MaxCompletionTokens,
|
||||
}
|
||||
|
||||
// Copy thinking support if present
|
||||
if m.Thinking != nil {
|
||||
agentic.Thinking = ®istry.ThinkingSupport{
|
||||
Min: m.Thinking.Min,
|
||||
Max: m.Thinking.Max,
|
||||
ZeroAllowed: m.Thinking.ZeroAllowed,
|
||||
DynamicAllowed: m.Thinking.DynamicAllowed,
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, agentic)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -90,3 +90,26 @@ func TestApplyOAuthModelAlias_ForkAddsMultipleAliases(t *testing.T) {
|
||||
t.Fatalf("expected forked model name %q, got %q", "models/g5-2", out[2].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOAuthModelAlias_DefaultGitHubCopilotAliasViaSanitize(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.SanitizeOAuthModelAlias()
|
||||
|
||||
models := []*ModelInfo{
|
||||
{ID: "claude-opus-4.6", Name: "models/claude-opus-4.6"},
|
||||
}
|
||||
|
||||
out := applyOAuthModelAlias(cfg, "github-copilot", "oauth", models)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2 models (original + default alias), got %d", len(out))
|
||||
}
|
||||
if out[0].ID != "claude-opus-4.6" {
|
||||
t.Fatalf("expected first model id %q, got %q", "claude-opus-4.6", out[0].ID)
|
||||
}
|
||||
if out[1].ID != "claude-opus-4-6" {
|
||||
t.Fatalf("expected second model id %q, got %q", "claude-opus-4-6", out[1].ID)
|
||||
}
|
||||
if out[1].Name != "models/claude-opus-4-6" {
|
||||
t.Fatalf("expected aliased model name %q, got %q", "models/claude-opus-4-6", out[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ type WatcherWrapper struct {
|
||||
snapshotAuths func() []*coreauth.Auth
|
||||
setUpdateQueue func(queue chan<- watcher.AuthUpdate)
|
||||
dispatchRuntimeUpdate func(update watcher.AuthUpdate) bool
|
||||
notifyTokenRefreshed func(tokenID, accessToken, refreshToken, expiresAt string) // 方案 A: 后台刷新通知
|
||||
}
|
||||
|
||||
// Start proxies to the underlying watcher Start implementation.
|
||||
@@ -146,3 +147,16 @@ func (w *WatcherWrapper) SetAuthUpdateQueue(queue chan<- watcher.AuthUpdate) {
|
||||
}
|
||||
w.setUpdateQueue(queue)
|
||||
}
|
||||
|
||||
// NotifyTokenRefreshed 通知 Watcher 后台刷新器已更新 token
|
||||
// 这是方案 A 的核心方法,用于解决后台刷新与内存 Auth 对象的时间差问题
|
||||
// tokenID: token 文件名(如 kiro-xxx.json)
|
||||
// accessToken: 新的 access token
|
||||
// refreshToken: 新的 refresh token
|
||||
// expiresAt: 新的过期时间(RFC3339 格式)
|
||||
func (w *WatcherWrapper) NotifyTokenRefreshed(tokenID, accessToken, refreshToken, expiresAt string) {
|
||||
if w == nil || w.notifyTokenRefreshed == nil {
|
||||
return
|
||||
}
|
||||
w.notifyTokenRefreshed(tokenID, accessToken, refreshToken, expiresAt)
|
||||
}
|
||||
|
||||
@@ -31,5 +31,8 @@ func defaultWatcherFactory(configPath, authDir string, reload func(*config.Confi
|
||||
dispatchRuntimeUpdate: func(update watcher.AuthUpdate) bool {
|
||||
return w.DispatchRuntimeAuthUpdate(update)
|
||||
},
|
||||
notifyTokenRefreshed: func(tokenID, accessToken, refreshToken, expiresAt string) {
|
||||
w.NotifyTokenRefreshed(tokenID, accessToken, refreshToken, expiresAt)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user