Merge branch 'main' into plus

This commit is contained in:
Luis Pater
2026-03-09 09:33:22 +08:00
committed by GitHub
126 changed files with 32196 additions and 421 deletions

View File

@@ -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)

View File

@@ -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 ""

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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: &registry.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 = &registry.ThinkingSupport{
Min: m.Thinking.Min,
Max: m.Thinking.Max,
ZeroAllowed: m.Thinking.ZeroAllowed,
DynamicAllowed: m.Thinking.DynamicAllowed,
}
}
result = append(result, agentic)
}
return result
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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
}