Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
452
backend/internal/watcher/synthesizer/config.go
Normal file
452
backend/internal/watcher/synthesizer/config.go
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
package synthesizer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
// ConfigSynthesizer generates Auth entries from configuration API keys.
|
||||
// It handles Gemini, Interactions, Claude, Codex, xAI, OpenAI-compat, and Vertex-compat providers.
|
||||
type ConfigSynthesizer struct{}
|
||||
|
||||
// NewConfigSynthesizer creates a new ConfigSynthesizer instance.
|
||||
func NewConfigSynthesizer() *ConfigSynthesizer {
|
||||
return &ConfigSynthesizer{}
|
||||
}
|
||||
|
||||
func addWeightToAttrs(weight *int, attrs map[string]string) {
|
||||
if weight == nil {
|
||||
return
|
||||
}
|
||||
normalized := *weight
|
||||
if normalized <= 0 {
|
||||
normalized = 0
|
||||
}
|
||||
attrs[coreauth.AttributeWeight] = strconv.Itoa(normalized)
|
||||
}
|
||||
|
||||
// Synthesize generates Auth entries from config API keys.
|
||||
func (s *ConfigSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, error) {
|
||||
out := make([]*coreauth.Auth, 0, 32)
|
||||
if ctx == nil || ctx.Config == nil {
|
||||
return out, nil
|
||||
}
|
||||
if errValidate := ctx.Config.ValidateCredentialWeights(); errValidate != nil {
|
||||
return nil, fmt.Errorf("synthesize config API key auths: %w", errValidate)
|
||||
}
|
||||
|
||||
// Gemini API Keys
|
||||
out = append(out, s.synthesizeGeminiKeys(ctx)...)
|
||||
// Native Interactions API Keys
|
||||
out = append(out, s.synthesizeInteractionsKeys(ctx)...)
|
||||
// Claude API Keys
|
||||
out = append(out, s.synthesizeClaudeKeys(ctx)...)
|
||||
// Codex API Keys
|
||||
out = append(out, s.synthesizeCodexKeys(ctx)...)
|
||||
// xAI API Keys
|
||||
out = append(out, s.synthesizeXAIKeys(ctx)...)
|
||||
// OpenAI-compat
|
||||
out = append(out, s.synthesizeOpenAICompat(ctx)...)
|
||||
// Vertex-compat
|
||||
out = append(out, s.synthesizeVertexCompat(ctx)...)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// synthesizeGeminiKeys creates Auth entries for Gemini API keys.
|
||||
func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*coreauth.Auth {
|
||||
return s.synthesizeGeminiKeyEntries(ctx, ctx.Config.GeminiKey, "gemini:apikey", "gemini", "gemini-apikey", constant.Gemini)
|
||||
}
|
||||
|
||||
// synthesizeInteractionsKeys creates Auth entries for native Interactions API keys.
|
||||
func (s *ConfigSynthesizer) synthesizeInteractionsKeys(ctx *SynthesisContext) []*coreauth.Auth {
|
||||
return s.synthesizeGeminiKeyEntries(ctx, ctx.Config.InteractionsKey, "gemini-interactions:apikey", "interactions", "interactions-apikey", constant.GeminiInteractions)
|
||||
}
|
||||
|
||||
func (s *ConfigSynthesizer) synthesizeGeminiKeyEntries(ctx *SynthesisContext, entries []config.GeminiKey, idKind, sourceName, label, provider string) []*coreauth.Auth {
|
||||
cfg := ctx.Config
|
||||
now := ctx.Now
|
||||
idGen := ctx.IDGenerator
|
||||
|
||||
out := make([]*coreauth.Auth, 0, len(entries))
|
||||
for i := range entries {
|
||||
entry := entries[i]
|
||||
key := strings.TrimSpace(entry.APIKey)
|
||||
base := strings.TrimSpace(entry.BaseURL)
|
||||
if key == "" && base == "" {
|
||||
continue
|
||||
}
|
||||
prefix := strings.TrimSpace(entry.Prefix)
|
||||
proxyURL := strings.TrimSpace(entry.ProxyURL)
|
||||
id, token := idGen.Next(idKind, key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:%s[%s]", sourceName, token),
|
||||
"config_index": strconv.Itoa(i),
|
||||
}
|
||||
if key != "" {
|
||||
attrs["api_key"] = key
|
||||
}
|
||||
metadata := map[string]any{}
|
||||
if entry.DisableCooling != nil {
|
||||
metadata["disable_cooling"] = *entry.DisableCooling
|
||||
}
|
||||
addRequestRetryToMetadata(entry.RequestRetry, metadata)
|
||||
addRequestScopedErrorsToMetadata(entry.RequestScopedErrors, metadata)
|
||||
if entry.Priority != 0 {
|
||||
attrs["priority"] = strconv.Itoa(entry.Priority)
|
||||
}
|
||||
addWeightToAttrs(entry.Weight, attrs)
|
||||
if base != "" {
|
||||
attrs["base_url"] = base
|
||||
}
|
||||
if hash := diff.ComputeGeminiModelsHash(entry.Models); hash != "" {
|
||||
attrs["models_hash"] = hash
|
||||
}
|
||||
addConfigHeadersToAttrs(entry.Headers, attrs)
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: provider,
|
||||
Label: label,
|
||||
Prefix: prefix,
|
||||
Status: coreauth.StatusActive,
|
||||
ProxyURL: proxyURL,
|
||||
Attributes: attrs,
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
ApplyAuthExcludedModelsMeta(a, cfg, entry.ExcludedModels, "apikey")
|
||||
if len(a.Metadata) == 0 {
|
||||
a.Metadata = nil
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// synthesizeClaudeKeys creates Auth entries for Claude API keys.
|
||||
func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*coreauth.Auth {
|
||||
cfg := ctx.Config
|
||||
now := ctx.Now
|
||||
idGen := ctx.IDGenerator
|
||||
|
||||
out := make([]*coreauth.Auth, 0, len(cfg.ClaudeKey))
|
||||
for i := range cfg.ClaudeKey {
|
||||
ck := cfg.ClaudeKey[i]
|
||||
key := strings.TrimSpace(ck.APIKey)
|
||||
base := strings.TrimSpace(ck.BaseURL)
|
||||
if key == "" && base == "" {
|
||||
continue
|
||||
}
|
||||
prefix := strings.TrimSpace(ck.Prefix)
|
||||
proxyURL := strings.TrimSpace(ck.ProxyURL)
|
||||
id, token := idGen.Next("claude:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(ck.Headers))
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:claude[%s]", token),
|
||||
"config_index": strconv.Itoa(i),
|
||||
}
|
||||
if key != "" {
|
||||
attrs["api_key"] = key
|
||||
}
|
||||
metadata := map[string]any{}
|
||||
if ck.DisableCooling != nil {
|
||||
metadata["disable_cooling"] = *ck.DisableCooling
|
||||
}
|
||||
addRequestRetryToMetadata(ck.RequestRetry, metadata)
|
||||
addRequestScopedErrorsToMetadata(ck.RequestScopedErrors, metadata)
|
||||
if ck.Priority != 0 {
|
||||
attrs["priority"] = strconv.Itoa(ck.Priority)
|
||||
}
|
||||
addWeightToAttrs(ck.Weight, attrs)
|
||||
if base != "" {
|
||||
attrs["base_url"] = base
|
||||
}
|
||||
if ck.RebuildMidSystemMessage {
|
||||
attrs["rebuild_mid_system_message"] = "true"
|
||||
}
|
||||
if profile := strings.ToLower(strings.TrimSpace(ck.FingerprintProfile)); profile != "" {
|
||||
attrs["fingerprint_profile"] = profile
|
||||
}
|
||||
if hash := diff.ComputeClaudeModelsHash(ck.Models); hash != "" {
|
||||
attrs["models_hash"] = hash
|
||||
}
|
||||
addConfigHeadersToAttrs(ck.Headers, attrs)
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: "claude",
|
||||
Label: "claude-apikey",
|
||||
Prefix: prefix,
|
||||
Status: coreauth.StatusActive,
|
||||
ProxyURL: proxyURL,
|
||||
Attributes: attrs,
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
ApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey")
|
||||
if len(a.Metadata) == 0 {
|
||||
a.Metadata = nil
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// synthesizeCodexKeys creates Auth entries for Codex API keys.
|
||||
func (s *ConfigSynthesizer) synthesizeCodexKeys(ctx *SynthesisContext) []*coreauth.Auth {
|
||||
return s.synthesizeCodexStyleKeys(ctx, ctx.Config.CodexKey, "codex")
|
||||
}
|
||||
|
||||
// synthesizeXAIKeys creates Auth entries for xAI API keys.
|
||||
func (s *ConfigSynthesizer) synthesizeXAIKeys(ctx *SynthesisContext) []*coreauth.Auth {
|
||||
return s.synthesizeCodexStyleKeys(ctx, ctx.Config.XAIKey, "xai")
|
||||
}
|
||||
|
||||
func (s *ConfigSynthesizer) synthesizeCodexStyleKeys(ctx *SynthesisContext, entries []config.CodexKey, provider string) []*coreauth.Auth {
|
||||
cfg := ctx.Config
|
||||
now := ctx.Now
|
||||
idGen := ctx.IDGenerator
|
||||
|
||||
out := make([]*coreauth.Auth, 0, len(entries))
|
||||
for i := range entries {
|
||||
entry := entries[i]
|
||||
key := strings.TrimSpace(entry.APIKey)
|
||||
baseURL := strings.TrimSpace(entry.BaseURL)
|
||||
if key == "" && baseURL == "" {
|
||||
continue
|
||||
}
|
||||
prefix := strings.TrimSpace(entry.Prefix)
|
||||
proxyURL := strings.TrimSpace(entry.ProxyURL)
|
||||
id, token := idGen.Next(provider+":apikey", key, baseURL, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:%s[%s]", provider, token),
|
||||
"config_index": strconv.Itoa(i),
|
||||
}
|
||||
if key != "" {
|
||||
attrs["api_key"] = key
|
||||
}
|
||||
metadata := map[string]any{}
|
||||
if entry.DisableCooling != nil {
|
||||
metadata["disable_cooling"] = *entry.DisableCooling
|
||||
}
|
||||
addRequestRetryToMetadata(entry.RequestRetry, metadata)
|
||||
addRequestScopedErrorsToMetadata(entry.RequestScopedErrors, metadata)
|
||||
if entry.Priority != 0 {
|
||||
attrs["priority"] = strconv.Itoa(entry.Priority)
|
||||
}
|
||||
addWeightToAttrs(entry.Weight, attrs)
|
||||
if baseURL != "" {
|
||||
attrs["base_url"] = baseURL
|
||||
}
|
||||
if entry.Websockets {
|
||||
attrs["websockets"] = "true"
|
||||
}
|
||||
if provider == "codex" && entry.AlphaSearch {
|
||||
attrs[coreauth.AttributeCodexAlphaSearch] = "true"
|
||||
}
|
||||
if hash := diff.ComputeCodexModelsHash(entry.Models); hash != "" {
|
||||
attrs["models_hash"] = hash
|
||||
}
|
||||
addConfigHeadersToAttrs(entry.Headers, attrs)
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: provider,
|
||||
Label: provider + "-apikey",
|
||||
Prefix: prefix,
|
||||
Status: coreauth.StatusActive,
|
||||
ProxyURL: strings.TrimSpace(entry.ProxyURL),
|
||||
Attributes: attrs,
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
ApplyAuthExcludedModelsMeta(a, cfg, entry.ExcludedModels, "apikey")
|
||||
if len(a.Metadata) == 0 {
|
||||
a.Metadata = nil
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// synthesizeOpenAICompat creates Auth entries for OpenAI-compatible providers.
|
||||
func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*coreauth.Auth {
|
||||
cfg := ctx.Config
|
||||
now := ctx.Now
|
||||
idGen := ctx.IDGenerator
|
||||
|
||||
out := make([]*coreauth.Auth, 0)
|
||||
for i := range cfg.OpenAICompatibility {
|
||||
compat := &cfg.OpenAICompatibility[i]
|
||||
if compat.Disabled {
|
||||
continue
|
||||
}
|
||||
prefix := strings.TrimSpace(compat.Prefix)
|
||||
providerName := strings.ToLower(strings.TrimSpace(compat.Name))
|
||||
if providerName == "" {
|
||||
providerName = "openai-compatibility"
|
||||
}
|
||||
internalProviderKey := util.OpenAICompatibleProviderKey(providerName)
|
||||
base := strings.TrimSpace(compat.BaseURL)
|
||||
disableCooling := compat.DisableCooling
|
||||
|
||||
// Handle new APIKeyEntries format (preferred)
|
||||
createdEntries := 0
|
||||
for j := range compat.APIKeyEntries {
|
||||
entry := &compat.APIKeyEntries[j]
|
||||
key := strings.TrimSpace(entry.APIKey)
|
||||
proxyURL := strings.TrimSpace(entry.ProxyURL)
|
||||
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
|
||||
id, token := idGen.Next(idKind, key, base, proxyURL)
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
|
||||
"base_url": base,
|
||||
"compat_name": compat.Name,
|
||||
"provider_key": internalProviderKey,
|
||||
"config_index": strconv.Itoa(i),
|
||||
}
|
||||
metadata := map[string]any{}
|
||||
if disableCooling != nil {
|
||||
metadata["disable_cooling"] = *disableCooling
|
||||
}
|
||||
addRequestRetryToMetadata(compat.RequestRetry, metadata)
|
||||
addRequestScopedErrorsToMetadata(compat.RequestScopedErrors, metadata)
|
||||
if compat.Priority != 0 {
|
||||
attrs["priority"] = strconv.Itoa(compat.Priority)
|
||||
}
|
||||
addWeightToAttrs(entry.Weight, attrs)
|
||||
if key != "" {
|
||||
attrs["api_key"] = key
|
||||
}
|
||||
if hash := diff.ComputeOpenAICompatModelsHash(compat.Models); hash != "" {
|
||||
attrs["models_hash"] = hash
|
||||
}
|
||||
addConfigHeadersToAttrs(compat.Headers, attrs)
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: internalProviderKey,
|
||||
Label: compat.Name,
|
||||
Prefix: prefix,
|
||||
Status: coreauth.StatusActive,
|
||||
ProxyURL: proxyURL,
|
||||
Attributes: attrs,
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if len(a.Metadata) == 0 {
|
||||
a.Metadata = nil
|
||||
}
|
||||
out = append(out, a)
|
||||
createdEntries++
|
||||
}
|
||||
// Fallback: create entry without API key if no APIKeyEntries
|
||||
if createdEntries == 0 {
|
||||
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
|
||||
id, token := idGen.Next(idKind, base)
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
|
||||
"base_url": base,
|
||||
"compat_name": compat.Name,
|
||||
"provider_key": internalProviderKey,
|
||||
"config_index": strconv.Itoa(i),
|
||||
}
|
||||
metadata := map[string]any{}
|
||||
if disableCooling != nil {
|
||||
metadata["disable_cooling"] = *disableCooling
|
||||
}
|
||||
addRequestRetryToMetadata(compat.RequestRetry, metadata)
|
||||
addRequestScopedErrorsToMetadata(compat.RequestScopedErrors, metadata)
|
||||
if compat.Priority != 0 {
|
||||
attrs["priority"] = strconv.Itoa(compat.Priority)
|
||||
}
|
||||
if hash := diff.ComputeOpenAICompatModelsHash(compat.Models); hash != "" {
|
||||
attrs["models_hash"] = hash
|
||||
}
|
||||
addConfigHeadersToAttrs(compat.Headers, attrs)
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: internalProviderKey,
|
||||
Label: compat.Name,
|
||||
Prefix: prefix,
|
||||
Status: coreauth.StatusActive,
|
||||
Attributes: attrs,
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if len(a.Metadata) == 0 {
|
||||
a.Metadata = nil
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// synthesizeVertexCompat creates Auth entries for Vertex-compatible providers.
|
||||
func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*coreauth.Auth {
|
||||
cfg := ctx.Config
|
||||
now := ctx.Now
|
||||
idGen := ctx.IDGenerator
|
||||
|
||||
out := make([]*coreauth.Auth, 0, len(cfg.VertexCompatAPIKey))
|
||||
for i := range cfg.VertexCompatAPIKey {
|
||||
compat := &cfg.VertexCompatAPIKey[i]
|
||||
providerName := "vertex"
|
||||
base := strings.TrimSpace(compat.BaseURL)
|
||||
|
||||
key := strings.TrimSpace(compat.APIKey)
|
||||
prefix := strings.TrimSpace(compat.Prefix)
|
||||
proxyURL := strings.TrimSpace(compat.ProxyURL)
|
||||
idKind := "vertex:apikey"
|
||||
id, token := idGen.Next(idKind, key, base, proxyURL)
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:vertex-apikey[%s]", token),
|
||||
"base_url": base,
|
||||
"provider_key": providerName,
|
||||
"config_index": strconv.Itoa(i),
|
||||
}
|
||||
if compat.Priority != 0 {
|
||||
attrs["priority"] = strconv.Itoa(compat.Priority)
|
||||
}
|
||||
addWeightToAttrs(compat.Weight, attrs)
|
||||
if key != "" {
|
||||
attrs["api_key"] = key
|
||||
}
|
||||
if hash := diff.ComputeVertexCompatModelsHash(compat.Models); hash != "" {
|
||||
attrs["models_hash"] = hash
|
||||
}
|
||||
addConfigHeadersToAttrs(compat.Headers, attrs)
|
||||
metadata := map[string]any{}
|
||||
if compat.DisableCooling != nil {
|
||||
metadata["disable_cooling"] = *compat.DisableCooling
|
||||
}
|
||||
addRequestRetryToMetadata(compat.RequestRetry, metadata)
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: providerName,
|
||||
Label: "vertex-apikey",
|
||||
Prefix: prefix,
|
||||
Status: coreauth.StatusActive,
|
||||
ProxyURL: proxyURL,
|
||||
Attributes: attrs,
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
ApplyAuthExcludedModelsMeta(a, cfg, compat.ExcludedModels, "apikey")
|
||||
if len(a.Metadata) == 0 {
|
||||
a.Metadata = nil
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
1270
backend/internal/watcher/synthesizer/config_test.go
Normal file
1270
backend/internal/watcher/synthesizer/config_test.go
Normal file
File diff suppressed because it is too large
Load diff
35
backend/internal/watcher/synthesizer/context.go
Normal file
35
backend/internal/watcher/synthesizer/context.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package synthesizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
// PluginAuthParser parses auth JSON owned by plugin providers.
|
||||
type PluginAuthParser interface {
|
||||
ParseAuth(context.Context, pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error)
|
||||
}
|
||||
|
||||
// PluginMultiAuthParser expands one auth JSON payload into multiple plugin auth records.
|
||||
// Returning handled=true with an empty slice means the plugin intentionally suppresses built-in parsing.
|
||||
type PluginMultiAuthParser interface {
|
||||
ParseAuths(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error)
|
||||
}
|
||||
|
||||
// SynthesisContext provides the context needed for auth synthesis.
|
||||
type SynthesisContext struct {
|
||||
// Config is the current configuration
|
||||
Config *config.Config
|
||||
// AuthDir is the directory containing auth files
|
||||
AuthDir string
|
||||
// Now is the current time for timestamps
|
||||
Now time.Time
|
||||
// IDGenerator generates stable IDs for auth entries
|
||||
IDGenerator *StableIDGenerator
|
||||
// PluginAuthParser parses plugin-owned auth files
|
||||
PluginAuthParser PluginAuthParser
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package synthesizer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
func boolPointer(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func TestConfigSynthesizerPreservesExplicitFalseCoolingOverrides(t *testing.T) {
|
||||
disableCooling := false
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.Config
|
||||
}{
|
||||
{
|
||||
name: "gemini",
|
||||
cfg: &config.Config{GeminiKey: []config.GeminiKey{{
|
||||
APIKey: "gemini-key",
|
||||
DisableCooling: &disableCooling,
|
||||
}}},
|
||||
},
|
||||
{
|
||||
name: "interactions",
|
||||
cfg: &config.Config{InteractionsKey: []config.GeminiKey{{
|
||||
APIKey: "interactions-key",
|
||||
DisableCooling: &disableCooling,
|
||||
}}},
|
||||
},
|
||||
{
|
||||
name: "claude",
|
||||
cfg: &config.Config{ClaudeKey: []config.ClaudeKey{{
|
||||
APIKey: "claude-key",
|
||||
DisableCooling: &disableCooling,
|
||||
}}},
|
||||
},
|
||||
{
|
||||
name: "codex",
|
||||
cfg: &config.Config{CodexKey: []config.CodexKey{{
|
||||
APIKey: "codex-key",
|
||||
BaseURL: "https://codex.example.com",
|
||||
DisableCooling: &disableCooling,
|
||||
}}},
|
||||
},
|
||||
{
|
||||
name: "xai",
|
||||
cfg: &config.Config{XAIKey: []config.XAIKey{{
|
||||
APIKey: "xai-key",
|
||||
BaseURL: "https://api.x.ai/v1",
|
||||
DisableCooling: &disableCooling,
|
||||
}}},
|
||||
},
|
||||
{
|
||||
name: "openai compatibility",
|
||||
cfg: &config.Config{OpenAICompatibility: []config.OpenAICompatibility{{
|
||||
Name: "compat",
|
||||
BaseURL: "https://compat.example.com",
|
||||
DisableCooling: &disableCooling,
|
||||
APIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: "compat-key"}},
|
||||
}}},
|
||||
},
|
||||
{
|
||||
name: "vertex",
|
||||
cfg: &config.Config{VertexCompatAPIKey: []config.VertexCompatKey{{
|
||||
APIKey: "vertex-key",
|
||||
BaseURL: "https://vertex.example.com",
|
||||
DisableCooling: &disableCooling,
|
||||
}}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
auths, errSynthesize := NewConfigSynthesizer().Synthesize(&SynthesisContext{
|
||||
Config: tc.cfg,
|
||||
Now: time.Unix(100, 0).UTC(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
})
|
||||
if errSynthesize != nil {
|
||||
t.Fatalf("Synthesize() error = %v", errSynthesize)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("auth count = %d, want 1", len(auths))
|
||||
}
|
||||
disabled, present := auths[0].DisableCoolingOverride()
|
||||
if !present || disabled {
|
||||
t.Fatalf("DisableCoolingOverride() = %t, %t, want false, true", disabled, present)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
337
backend/internal/watcher/synthesizer/file.go
Normal file
337
backend/internal/watcher/synthesizer/file.go
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
package synthesizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// FileSynthesizer generates Auth entries from OAuth JSON files.
|
||||
// It handles file-based authentication.
|
||||
type FileSynthesizer struct{}
|
||||
|
||||
// NewFileSynthesizer creates a new FileSynthesizer instance.
|
||||
func NewFileSynthesizer() *FileSynthesizer {
|
||||
return &FileSynthesizer{}
|
||||
}
|
||||
|
||||
// Synthesize generates Auth entries from auth files in the auth directory.
|
||||
func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, error) {
|
||||
out := make([]*coreauth.Auth, 0, 16)
|
||||
if ctx == nil || ctx.AuthDir == "" {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(ctx.AuthDir)
|
||||
if err != nil {
|
||||
// Not an error if directory doesn't exist
|
||||
return out, nil
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(ctx.AuthDir, name)
|
||||
data, errRead := os.ReadFile(full)
|
||||
if errRead != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
auths, errSynthesize := synthesizeFileAuths(ctx, full, data)
|
||||
if errSynthesize != nil {
|
||||
log.WithError(errSynthesize).Warnf("skipping auth file %s", name)
|
||||
continue
|
||||
}
|
||||
if len(auths) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, auths...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SynthesizeAuthFile generates Auth entries for one auth JSON file payload.
|
||||
// It shares exactly the same mapping behavior as FileSynthesizer.Synthesize.
|
||||
func SynthesizeAuthFile(ctx *SynthesisContext, fullPath string, data []byte) ([]*coreauth.Auth, error) {
|
||||
return synthesizeFileAuths(ctx, fullPath, data)
|
||||
}
|
||||
|
||||
func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) ([]*coreauth.Auth, error) {
|
||||
if ctx == nil || len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
now := ctx.Now
|
||||
cfg := ctx.Config
|
||||
var metadata map[string]any
|
||||
if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil {
|
||||
return nil, nil
|
||||
}
|
||||
coreauth.NormalizeCredentialMetadata(metadata)
|
||||
if errWeight := coreauth.ValidateAuthWeight(&coreauth.Auth{Metadata: metadata}); errWeight != nil {
|
||||
return nil, fmt.Errorf("invalid weight in %s: %w", filepath.Base(fullPath), errWeight)
|
||||
}
|
||||
t, _ := metadata["type"].(string)
|
||||
provider := strings.ToLower(strings.TrimSpace(t))
|
||||
if provider == "gemini" {
|
||||
provider = "gemini-cli"
|
||||
}
|
||||
if ctx.PluginAuthParser != nil {
|
||||
auths, handled, errParse := parsePluginFileAuths(ctx.PluginAuthParser, pluginapi.AuthParseRequest{
|
||||
Provider: provider,
|
||||
Path: fullPath,
|
||||
FileName: filepath.Base(fullPath),
|
||||
RawJSON: data,
|
||||
})
|
||||
if errParse == nil && handled {
|
||||
auths = compactPluginAuths(auths)
|
||||
if len(auths) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
perAccountExcluded := extractExcludedModelsFromMetadata(metadata)
|
||||
perAccountModelAliases := extractOAuthModelAliasesFromMetadata(metadata)
|
||||
disabled, _ := metadata["disabled"].(bool)
|
||||
for index, auth := range auths {
|
||||
if auth == nil {
|
||||
continue
|
||||
}
|
||||
coreauth.NormalizeCredentialMetadata(auth.Metadata)
|
||||
if len(auths) > 1 {
|
||||
coreauth.MarkPluginVirtualAuth(auth, fullPath, index)
|
||||
}
|
||||
auth.CreatedAt = now
|
||||
auth.UpdatedAt = now
|
||||
if auth.Attributes == nil {
|
||||
auth.Attributes = make(map[string]string)
|
||||
}
|
||||
auth.Attributes[coreauth.AttributePath] = fullPath
|
||||
auth.Attributes[coreauth.AttributeSource] = fullPath
|
||||
auth.Attributes[coreauth.AttributeSourceBackend] = coreauth.AuthSourceFile
|
||||
if disabled {
|
||||
auth.Disabled = true
|
||||
auth.Status = coreauth.StatusDisabled
|
||||
if auth.Metadata == nil {
|
||||
auth.Metadata = make(map[string]any)
|
||||
}
|
||||
auth.Metadata["disabled"] = true
|
||||
}
|
||||
if errWeight := coreauth.ApplyAuthWeightMetadata(auth, metadata); errWeight != nil {
|
||||
return nil, fmt.Errorf("invalid plugin auth weight in %s: %w", filepath.Base(fullPath), errWeight)
|
||||
}
|
||||
coreauth.SetOAuthModelAliasesAttribute(auth, perAccountModelAliases)
|
||||
ApplyAuthExcludedModelsMeta(auth, cfg, perAccountExcluded, "oauth")
|
||||
coreauth.ApplyCustomHeadersFromMetadata(auth)
|
||||
applyFingerprintProfileAttribute(auth, metadata)
|
||||
}
|
||||
return auths, nil
|
||||
}
|
||||
}
|
||||
if provider == "" || provider == "gemini-cli" {
|
||||
return nil, nil
|
||||
}
|
||||
label := provider
|
||||
if email, _ := metadata["email"].(string); email != "" {
|
||||
label = email
|
||||
}
|
||||
// Use relative path under authDir as ID to stay consistent with the file-based token store.
|
||||
id := fullPath
|
||||
if strings.TrimSpace(ctx.AuthDir) != "" {
|
||||
if rel, errRel := filepath.Rel(ctx.AuthDir, fullPath); errRel == nil && rel != "" {
|
||||
id = rel
|
||||
}
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
id = strings.ToLower(id)
|
||||
}
|
||||
|
||||
proxyURL := ""
|
||||
if p, ok := metadata["proxy_url"].(string); ok {
|
||||
proxyURL = p
|
||||
}
|
||||
|
||||
prefix := ""
|
||||
if rawPrefix, ok := metadata["prefix"].(string); ok {
|
||||
trimmed := strings.TrimSpace(rawPrefix)
|
||||
trimmed = strings.Trim(trimmed, "/")
|
||||
if trimmed != "" && !strings.Contains(trimmed, "/") {
|
||||
prefix = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
disabled, _ := metadata["disabled"].(bool)
|
||||
status := coreauth.StatusActive
|
||||
if disabled {
|
||||
status = coreauth.StatusDisabled
|
||||
}
|
||||
|
||||
// Read per-account excluded models from the OAuth JSON file.
|
||||
perAccountExcluded := extractExcludedModelsFromMetadata(metadata)
|
||||
perAccountModelAliases := extractOAuthModelAliasesFromMetadata(metadata)
|
||||
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: provider,
|
||||
Label: label,
|
||||
Prefix: prefix,
|
||||
Status: status,
|
||||
Disabled: disabled,
|
||||
Attributes: map[string]string{
|
||||
coreauth.AttributeSource: fullPath,
|
||||
coreauth.AttributePath: fullPath,
|
||||
coreauth.AttributeSourceBackend: coreauth.AuthSourceFile,
|
||||
},
|
||||
ProxyURL: proxyURL,
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
// Read priority from auth file.
|
||||
if rawPriority, ok := metadata["priority"]; ok {
|
||||
switch v := rawPriority.(type) {
|
||||
case float64:
|
||||
a.Attributes["priority"] = strconv.Itoa(int(v))
|
||||
case string:
|
||||
priority := strings.TrimSpace(v)
|
||||
if _, errAtoi := strconv.Atoi(priority); errAtoi == nil {
|
||||
a.Attributes["priority"] = priority
|
||||
}
|
||||
}
|
||||
}
|
||||
if errWeight := coreauth.ApplyAuthWeightMetadata(a, metadata); errWeight != nil {
|
||||
return nil, fmt.Errorf("invalid auth weight in %s: %w", filepath.Base(fullPath), errWeight)
|
||||
}
|
||||
// Read note from auth file.
|
||||
if rawNote, ok := metadata["note"]; ok {
|
||||
if note, isStr := rawNote.(string); isStr {
|
||||
if trimmed := strings.TrimSpace(note); trimmed != "" {
|
||||
a.Attributes["note"] = trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
coreauth.ApplyCustomHeadersFromMetadata(a)
|
||||
coreauth.SetOAuthModelAliasesAttribute(a, perAccountModelAliases)
|
||||
ApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, "oauth")
|
||||
applyFingerprintProfileAttribute(a, metadata)
|
||||
// For codex auth files, extract plan_type from the JWT id_token.
|
||||
if provider == "codex" {
|
||||
if idTokenRaw, ok := metadata["id_token"].(string); ok && strings.TrimSpace(idTokenRaw) != "" {
|
||||
if claims, errParse := codex.ParseJWTToken(idTokenRaw); errParse == nil && claims != nil {
|
||||
if pt := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); pt != "" {
|
||||
a.Attributes["plan_type"] = pt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []*coreauth.Auth{a}, nil
|
||||
}
|
||||
|
||||
func parsePluginFileAuths(parser PluginAuthParser, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
|
||||
if parser == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if multiParser, ok := parser.(PluginMultiAuthParser); ok {
|
||||
return multiParser.ParseAuths(context.Background(), req)
|
||||
}
|
||||
auth, handled, errParse := parser.ParseAuth(context.Background(), req)
|
||||
if errParse != nil || !handled || auth == nil {
|
||||
return nil, handled, errParse
|
||||
}
|
||||
return []*coreauth.Auth{auth}, true, nil
|
||||
}
|
||||
|
||||
func compactPluginAuths(auths []*coreauth.Auth) []*coreauth.Auth {
|
||||
if len(auths) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := auths[:0]
|
||||
for _, auth := range auths {
|
||||
if auth == nil {
|
||||
continue
|
||||
}
|
||||
if errWeight := coreauth.ValidateAuthWeight(auth); errWeight != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, auth)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extractOAuthModelAliasesFromMetadata reads per-account model aliases from OAuth JSON metadata.
|
||||
// "model_aliases" is canonical; "model-aliases" remains a legacy alias.
|
||||
func extractOAuthModelAliasesFromMetadata(metadata map[string]any) []config.OAuthModelAlias {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := metadata["model_aliases"]
|
||||
if !ok {
|
||||
raw, ok = metadata["model-aliases"]
|
||||
}
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
data, errMarshal := json.Marshal(raw)
|
||||
if errMarshal != nil {
|
||||
return nil
|
||||
}
|
||||
var aliases []config.OAuthModelAlias
|
||||
if errUnmarshal := json.Unmarshal(data, &aliases); errUnmarshal != nil {
|
||||
return nil
|
||||
}
|
||||
cfg := config.Config{
|
||||
OAuthModelAlias: map[string][]config.OAuthModelAlias{
|
||||
"auth": aliases,
|
||||
},
|
||||
}
|
||||
cfg.SanitizeOAuthModelAlias()
|
||||
return cfg.OAuthModelAlias["auth"]
|
||||
}
|
||||
|
||||
// extractExcludedModelsFromMetadata reads per-account excluded models from the OAuth JSON metadata.
|
||||
// "excluded_models" is canonical; "excluded-models" remains a legacy alias.
|
||||
func extractExcludedModelsFromMetadata(metadata map[string]any) []string {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := metadata["excluded_models"]
|
||||
if !ok {
|
||||
raw, ok = metadata["excluded-models"]
|
||||
}
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
var stringSlice []string
|
||||
switch v := raw.(type) {
|
||||
case []string:
|
||||
stringSlice = v
|
||||
case []interface{}:
|
||||
stringSlice = make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
stringSlice = append(stringSlice, s)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(stringSlice))
|
||||
for _, s := range stringSlice {
|
||||
if trimmed := strings.TrimSpace(s); trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
832
backend/internal/watcher/synthesizer/file_test.go
Normal file
832
backend/internal/watcher/synthesizer/file_test.go
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
package synthesizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
func TestNewFileSynthesizer(t *testing.T) {
|
||||
synth := NewFileSynthesizer()
|
||||
if synth == nil {
|
||||
t.Fatal("expected non-nil synthesizer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_NilContext(t *testing.T) {
|
||||
synth := NewFileSynthesizer()
|
||||
auths, err := synth.Synthesize(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(auths) != 0 {
|
||||
t.Fatalf("expected empty auths, got %d", len(auths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_EmptyAuthDir(t *testing.T) {
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: "",
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
auths, err := synth.Synthesize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(auths) != 0 {
|
||||
t.Fatalf("expected empty auths, got %d", len(auths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_NonExistentDir(t *testing.T) {
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: "/non/existent/path",
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
auths, err := synth.Synthesize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(auths) != 0 {
|
||||
t.Fatalf("expected empty auths, got %d", len(auths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_ValidAuthFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a valid auth file
|
||||
authData := map[string]any{
|
||||
"type": "claude",
|
||||
"email": "test@example.com",
|
||||
"proxy_url": "http://proxy.local",
|
||||
"prefix": "test-prefix",
|
||||
"headers": map[string]string{
|
||||
" X-Test ": " value ",
|
||||
"X-Empty": " ",
|
||||
},
|
||||
"disable_cooling": true,
|
||||
"request_retry": 2,
|
||||
}
|
||||
data, _ := json.Marshal(authData)
|
||||
err := os.WriteFile(filepath.Join(tempDir, "claude-auth.json"), data, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write auth file: %v", err)
|
||||
}
|
||||
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
|
||||
auths, err := synth.Synthesize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("expected 1 auth, got %d", len(auths))
|
||||
}
|
||||
|
||||
if auths[0].Provider != "claude" {
|
||||
t.Errorf("expected provider claude, got %s", auths[0].Provider)
|
||||
}
|
||||
if auths[0].Label != "test@example.com" {
|
||||
t.Errorf("expected label test@example.com, got %s", auths[0].Label)
|
||||
}
|
||||
if auths[0].Prefix != "test-prefix" {
|
||||
t.Errorf("expected prefix test-prefix, got %s", auths[0].Prefix)
|
||||
}
|
||||
if auths[0].ProxyURL != "http://proxy.local" {
|
||||
t.Errorf("expected proxy_url http://proxy.local, got %s", auths[0].ProxyURL)
|
||||
}
|
||||
if got := auths[0].Attributes["header:X-Test"]; got != "value" {
|
||||
t.Errorf("expected header:X-Test value, got %q", got)
|
||||
}
|
||||
if _, ok := auths[0].Attributes["header:X-Empty"]; ok {
|
||||
t.Errorf("expected header:X-Empty to be absent, got %q", auths[0].Attributes["header:X-Empty"])
|
||||
}
|
||||
if v, ok := auths[0].Metadata["disable_cooling"].(bool); !ok || !v {
|
||||
t.Errorf("expected disable_cooling true, got %v", auths[0].Metadata["disable_cooling"])
|
||||
}
|
||||
if v, ok := auths[0].Metadata["request_retry"].(float64); !ok || int(v) != 2 {
|
||||
t.Errorf("expected request_retry 2, got %v", auths[0].Metadata["request_retry"])
|
||||
}
|
||||
if auths[0].Status != coreauth.StatusActive {
|
||||
t.Errorf("expected status active, got %s", auths[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_LegacyKimiFingerprintProfile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
authData := map[string]any{
|
||||
"type": "kimi",
|
||||
"access_token": "kimi-access-token",
|
||||
"refresh_token": "kimi-refresh-token",
|
||||
"fingerprint-profile": "claude-code-cli",
|
||||
}
|
||||
data, errMarshal := json.Marshal(authData)
|
||||
if errMarshal != nil {
|
||||
t.Fatalf("marshal kimi auth: %v", errMarshal)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tempDir, "kimi-auth.json"), data, 0644); err != nil {
|
||||
t.Fatalf("failed to write kimi auth file: %v", err)
|
||||
}
|
||||
|
||||
auths, err := NewFileSynthesizer().Synthesize(&SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("expected 1 auth, got %d", len(auths))
|
||||
}
|
||||
if auths[0].Provider != "kimi" {
|
||||
t.Fatalf("provider = %q, want kimi", auths[0].Provider)
|
||||
}
|
||||
if got := auths[0].Attributes["fingerprint_profile"]; got != "claude-code-cli" {
|
||||
t.Fatalf("attributes fingerprint_profile = %q, want claude-code-cli", got)
|
||||
}
|
||||
if got, _ := auths[0].Metadata["fingerprint_profile"].(string); got != "claude-code-cli" {
|
||||
t.Fatalf("metadata fingerprint_profile = %q, want claude-code-cli", got)
|
||||
}
|
||||
if _, exists := auths[0].Metadata["fingerprint-profile"]; exists {
|
||||
t.Fatalf("legacy fingerprint-profile was not normalized: %#v", auths[0].Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_IgnoresGeminiProviderFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
authData := map[string]any{
|
||||
"type": "gemini",
|
||||
"email": "gemini@example.com",
|
||||
}
|
||||
data, _ := json.Marshal(authData)
|
||||
err := os.WriteFile(filepath.Join(tempDir, "gemini-auth.json"), data, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write auth file: %v", err)
|
||||
}
|
||||
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
|
||||
auths, err := synth.Synthesize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(auths) != 0 {
|
||||
t.Fatalf("expected Gemini auth file to be ignored, got %d auths", len(auths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizeAuthFileExpandsPluginMultiAuths(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
fullPath := filepath.Join(tempDir, "geminicli.json")
|
||||
raw := []byte(`{"type":"gemini-cli","excluded_models":["model-a"],"headers":{"X-Test":"value"}}`)
|
||||
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC),
|
||||
PluginAuthParser: multiAuthParserFunc(func(ctx context.Context, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
|
||||
if req.Provider != "gemini-cli" || req.Path != fullPath || req.FileName != "geminicli.json" {
|
||||
t.Fatalf("ParseAuths request = %#v, want file context", req)
|
||||
}
|
||||
return []*coreauth.Auth{
|
||||
{
|
||||
ID: "geminicli.json",
|
||||
Provider: "gemini-cli",
|
||||
Metadata: map[string]any{
|
||||
"type": "gemini-cli",
|
||||
"headers": map[string]any{
|
||||
"X-Test": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
{
|
||||
ID: "geminicli-project-a.json",
|
||||
Provider: "gemini-cli",
|
||||
Metadata: map[string]any{
|
||||
"type": "gemini-cli",
|
||||
"project_id": "project-a",
|
||||
"headers": map[string]any{
|
||||
"X-Test": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, true, nil
|
||||
}),
|
||||
}
|
||||
|
||||
auths, errSynthesize := SynthesizeAuthFile(ctx, fullPath, raw)
|
||||
if errSynthesize != nil {
|
||||
t.Fatalf("SynthesizeAuthFile() error = %v", errSynthesize)
|
||||
}
|
||||
if len(auths) != 2 {
|
||||
t.Fatalf("SynthesizeAuthFile() len = %d, want two plugin auths", len(auths))
|
||||
}
|
||||
if firstIndex, secondIndex := auths[0].EnsureIndex(), auths[1].EnsureIndex(); firstIndex == "" || firstIndex == secondIndex {
|
||||
t.Fatalf("auth indexes = %q/%q, want distinct non-empty indexes", firstIndex, secondIndex)
|
||||
}
|
||||
for _, auth := range auths {
|
||||
if !coreauth.IsPluginVirtualAuth(auth) {
|
||||
t.Fatalf("auth attributes = %#v, want plugin virtual marker", auth.Attributes)
|
||||
}
|
||||
if auth.Attributes[coreauth.AttributeVirtualSource] != fullPath {
|
||||
t.Fatalf("virtual_source = %q, want %q", auth.Attributes[coreauth.AttributeVirtualSource], fullPath)
|
||||
}
|
||||
if auth.Attributes["path"] != fullPath || auth.Attributes["source"] != fullPath {
|
||||
t.Fatalf("auth attributes = %#v, want source path", auth.Attributes)
|
||||
}
|
||||
if gotHeader := auth.Attributes["header:X-Test"]; gotHeader != "value" {
|
||||
t.Fatalf("header:X-Test = %q, want value", gotHeader)
|
||||
}
|
||||
if gotKind := auth.Attributes["auth_kind"]; gotKind != "oauth" {
|
||||
t.Fatalf("auth_kind = %q, want oauth", gotKind)
|
||||
}
|
||||
}
|
||||
if gotProject := auths[1].Metadata["project_id"]; gotProject != "project-a" {
|
||||
t.Fatalf("project_id = %#v, want project-a", gotProject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizeAuthFileSkipsInvalidPluginAuthWeight(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
fullPath := filepath.Join(tempDir, "plugin.json")
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC),
|
||||
PluginAuthParser: multiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
|
||||
return []*coreauth.Auth{
|
||||
{ID: "invalid", Provider: "plugin", Attributes: map[string]string{coreauth.AttributeWeight: "1.5"}},
|
||||
{ID: "valid", Provider: "plugin", Attributes: map[string]string{coreauth.AttributeWeight: "0"}},
|
||||
}, true, nil
|
||||
}),
|
||||
}
|
||||
|
||||
auths, errSynthesize := SynthesizeAuthFile(ctx, fullPath, []byte(`{"type":"plugin"}`))
|
||||
if errSynthesize != nil {
|
||||
t.Fatalf("SynthesizeAuthFile() error = %v", errSynthesize)
|
||||
}
|
||||
if len(auths) != 1 || auths[0].ID != "valid" {
|
||||
t.Fatalf("SynthesizeAuthFile() auths = %#v, want only valid zero-weight auth", auths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizeAuthFileAppliesSourceDisabledToPluginMultiAuths(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
fullPath := filepath.Join(tempDir, "geminicli.json")
|
||||
raw := []byte(`{"type":"gemini-cli","disabled":true}`)
|
||||
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC),
|
||||
PluginAuthParser: multiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
|
||||
return []*coreauth.Auth{
|
||||
{ID: "geminicli.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli"}},
|
||||
{ID: "geminicli-project-a.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli", "project_id": "project-a"}},
|
||||
}, true, nil
|
||||
}),
|
||||
}
|
||||
|
||||
auths, errSynthesize := SynthesizeAuthFile(ctx, fullPath, raw)
|
||||
if errSynthesize != nil {
|
||||
t.Fatalf("SynthesizeAuthFile() error = %v", errSynthesize)
|
||||
}
|
||||
if len(auths) != 2 {
|
||||
t.Fatalf("SynthesizeAuthFile() len = %d, want two plugin auths", len(auths))
|
||||
}
|
||||
for _, auth := range auths {
|
||||
if !auth.Disabled || auth.Status != coreauth.StatusDisabled {
|
||||
t.Fatalf("auth %s disabled/status = %v/%s, want disabled", auth.ID, auth.Disabled, auth.Status)
|
||||
}
|
||||
if got, _ := auth.Metadata["disabled"].(bool); !got {
|
||||
t.Fatalf("auth %s metadata disabled = %#v, want true", auth.ID, auth.Metadata["disabled"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizeAuthFilePluginHandledEmptySuppressesBuiltin(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
fullPath := filepath.Join(tempDir, "codex.json")
|
||||
raw := []byte(`{"type":"codex","access_token":"token"}`)
|
||||
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC),
|
||||
PluginAuthParser: multiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
|
||||
return nil, true, nil
|
||||
}),
|
||||
}
|
||||
|
||||
auths, errSynthesize := SynthesizeAuthFile(ctx, fullPath, raw)
|
||||
if errSynthesize != nil {
|
||||
t.Fatalf("SynthesizeAuthFile() error = %v", errSynthesize)
|
||||
}
|
||||
if len(auths) != 0 {
|
||||
t.Fatalf("SynthesizeAuthFile() len = %d, want plugin-handled empty result", len(auths))
|
||||
}
|
||||
}
|
||||
|
||||
type multiAuthParserFunc func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error)
|
||||
|
||||
func (f multiAuthParserFunc) ParseAuth(context.Context, pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (f multiAuthParserFunc) ParseAuths(ctx context.Context, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
|
||||
return f(ctx, req)
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_SkipsInvalidFiles(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create various invalid files
|
||||
_ = os.WriteFile(filepath.Join(tempDir, "not-json.txt"), []byte("text content"), 0644)
|
||||
_ = os.WriteFile(filepath.Join(tempDir, "invalid.json"), []byte("not valid json"), 0644)
|
||||
_ = os.WriteFile(filepath.Join(tempDir, "empty.json"), []byte(""), 0644)
|
||||
_ = os.WriteFile(filepath.Join(tempDir, "no-type.json"), []byte(`{"email": "test@example.com"}`), 0644)
|
||||
|
||||
// Create one valid file
|
||||
validData, _ := json.Marshal(map[string]any{"type": "claude", "email": "valid@example.com"})
|
||||
_ = os.WriteFile(filepath.Join(tempDir, "valid.json"), validData, 0644)
|
||||
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
|
||||
auths, err := synth.Synthesize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("only valid auth file should be processed, got %d", len(auths))
|
||||
}
|
||||
if auths[0].Label != "valid@example.com" {
|
||||
t.Errorf("expected label valid@example.com, got %s", auths[0].Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_SkipsDirectories(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a subdirectory with a json file inside
|
||||
subDir := filepath.Join(tempDir, "subdir.json")
|
||||
err := os.Mkdir(subDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
// Create a valid file in root
|
||||
validData, _ := json.Marshal(map[string]any{"type": "claude"})
|
||||
_ = os.WriteFile(filepath.Join(tempDir, "valid.json"), validData, 0644)
|
||||
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
|
||||
auths, err := synth.Synthesize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("expected 1 auth, got %d", len(auths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_RelativeID(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
authData := map[string]any{"type": "claude"}
|
||||
data, _ := json.Marshal(authData)
|
||||
err := os.WriteFile(filepath.Join(tempDir, "my-auth.json"), data, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write auth file: %v", err)
|
||||
}
|
||||
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
|
||||
auths, err := synth.Synthesize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("expected 1 auth, got %d", len(auths))
|
||||
}
|
||||
|
||||
// ID should be relative path
|
||||
if auths[0].ID != "my-auth.json" {
|
||||
t.Errorf("expected ID my-auth.json, got %s", auths[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_PrefixValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
wantPrefix string
|
||||
}{
|
||||
{"valid prefix", "myprefix", "myprefix"},
|
||||
{"prefix with slashes trimmed", "/myprefix/", "myprefix"},
|
||||
{"prefix with spaces trimmed", " myprefix ", "myprefix"},
|
||||
{"prefix with internal slash rejected", "my/prefix", ""},
|
||||
{"empty prefix", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
authData := map[string]any{
|
||||
"type": "claude",
|
||||
"prefix": tt.prefix,
|
||||
}
|
||||
data, _ := json.Marshal(authData)
|
||||
_ = os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644)
|
||||
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
|
||||
auths, err := synth.Synthesize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("expected 1 auth, got %d", len(auths))
|
||||
}
|
||||
if auths[0].Prefix != tt.wantPrefix {
|
||||
t.Errorf("expected prefix %q, got %q", tt.wantPrefix, auths[0].Prefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_PriorityParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
priority any
|
||||
want string
|
||||
hasValue bool
|
||||
}{
|
||||
{
|
||||
name: "string with spaces",
|
||||
priority: " 10 ",
|
||||
want: "10",
|
||||
hasValue: true,
|
||||
},
|
||||
{
|
||||
name: "number",
|
||||
priority: 8,
|
||||
want: "8",
|
||||
hasValue: true,
|
||||
},
|
||||
{
|
||||
name: "invalid string",
|
||||
priority: "1x",
|
||||
hasValue: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
authData := map[string]any{
|
||||
"type": "claude",
|
||||
"priority": tt.priority,
|
||||
}
|
||||
data, _ := json.Marshal(authData)
|
||||
errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644)
|
||||
if errWriteFile != nil {
|
||||
t.Fatalf("failed to write auth file: %v", errWriteFile)
|
||||
}
|
||||
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
|
||||
auths, errSynthesize := synth.Synthesize(ctx)
|
||||
if errSynthesize != nil {
|
||||
t.Fatalf("unexpected error: %v", errSynthesize)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("expected 1 auth, got %d", len(auths))
|
||||
}
|
||||
|
||||
value, ok := auths[0].Attributes["priority"]
|
||||
if tt.hasValue {
|
||||
if !ok {
|
||||
t.Fatal("expected priority attribute to be set")
|
||||
}
|
||||
if value != tt.want {
|
||||
t.Fatalf("expected priority %q, got %q", tt.want, value)
|
||||
}
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("expected priority attribute to be absent, got %q", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_WeightParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
weight any
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{name: "number", weight: 5, want: "5", valid: true},
|
||||
{name: "numeric string", weight: " 3 ", want: "3", valid: true},
|
||||
{name: "zero excludes", weight: 0, want: "0", valid: true},
|
||||
{name: "negative excludes", weight: -5, want: "0", valid: true},
|
||||
{name: "maximum", weight: 1000000, want: "1000000", valid: true},
|
||||
{name: "fraction rejected", weight: 1.5},
|
||||
{name: "above maximum rejected", weight: 1000001},
|
||||
{name: "overflow rejected", weight: "9223372036854775808"},
|
||||
{name: "invalid string", weight: "heavy"},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
data, errMarshal := json.Marshal(map[string]any{"type": "claude", "weight": testCase.weight})
|
||||
if errMarshal != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", errMarshal)
|
||||
}
|
||||
if errWrite := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
auths, errSynthesize := NewFileSynthesizer().Synthesize(ctx)
|
||||
if errSynthesize != nil {
|
||||
t.Fatalf("Synthesize() error = %v", errSynthesize)
|
||||
}
|
||||
if !testCase.valid {
|
||||
if len(auths) != 0 {
|
||||
t.Fatalf("auth count = %d, want invalid credential skipped", len(auths))
|
||||
}
|
||||
if _, errDirect := SynthesizeAuthFile(ctx, filepath.Join(tempDir, "auth.json"), data); errDirect == nil {
|
||||
t.Fatal("SynthesizeAuthFile() error = nil, want weight validation error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("auth count = %d, want 1", len(auths))
|
||||
}
|
||||
if gotWeight := auths[0].Attributes[coreauth.AttributeWeight]; gotWeight != testCase.want {
|
||||
t.Fatalf("weight = %q, want %q", gotWeight, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_OAuthExcludedModelsMerged(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
authData := map[string]any{
|
||||
"type": "claude",
|
||||
"excluded_models": []string{"custom-model", "MODEL-B"},
|
||||
}
|
||||
data, _ := json.Marshal(authData)
|
||||
errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644)
|
||||
if errWriteFile != nil {
|
||||
t.Fatalf("failed to write auth file: %v", errWriteFile)
|
||||
}
|
||||
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{
|
||||
OAuthExcludedModels: map[string][]string{
|
||||
"claude": {"shared", "model-b"},
|
||||
},
|
||||
},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
|
||||
auths, errSynthesize := synth.Synthesize(ctx)
|
||||
if errSynthesize != nil {
|
||||
t.Fatalf("unexpected error: %v", errSynthesize)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("expected 1 auth, got %d", len(auths))
|
||||
}
|
||||
|
||||
got := auths[0].Attributes["excluded_models"]
|
||||
want := "custom-model,model-b,shared"
|
||||
if got != want {
|
||||
t.Fatalf("expected excluded_models %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_OAuthModelAliases(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
authData := map[string]any{
|
||||
"type": "codex",
|
||||
"email": "codex@example.com",
|
||||
"model_aliases": []map[string]any{
|
||||
{"name": " gpt-5.3-codex-spark ", "alias": " gpt-5.5 "},
|
||||
{"name": "gpt-5.3-codex-spark", "alias": "gpt-5.4", "fork": true},
|
||||
{"name": "gpt-5.3-codex-spark", "alias": "gpt-5.5"},
|
||||
{"name": "", "alias": "ignored"},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(authData)
|
||||
errWriteFile := os.WriteFile(filepath.Join(tempDir, "codex-auth.json"), data, 0644)
|
||||
if errWriteFile != nil {
|
||||
t.Fatalf("failed to write auth file: %v", errWriteFile)
|
||||
}
|
||||
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
|
||||
auths, errSynthesize := synth.Synthesize(ctx)
|
||||
if errSynthesize != nil {
|
||||
t.Fatalf("unexpected error: %v", errSynthesize)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("expected 1 auth, got %d", len(auths))
|
||||
}
|
||||
|
||||
got := auths[0].Attributes["model_aliases"]
|
||||
want := `[{"name":"gpt-5.3-codex-spark","alias":"gpt-5.5"},{"name":"gpt-5.3-codex-spark","alias":"gpt-5.4","fork":true}]`
|
||||
if got != want {
|
||||
t.Fatalf("expected model_aliases %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_IgnoresGeminiOAuthFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
authData := map[string]any{
|
||||
"type": "gemini",
|
||||
"email": "multi@example.com",
|
||||
"project_id": "project-a, project-b, project-c",
|
||||
"priority": " 10 ",
|
||||
}
|
||||
data, _ := json.Marshal(authData)
|
||||
err := os.WriteFile(filepath.Join(tempDir, "gemini-multi.json"), data, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write auth file: %v", err)
|
||||
}
|
||||
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
|
||||
auths, err := synth.Synthesize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(auths) != 0 {
|
||||
t.Fatalf("expected Gemini auth file to be ignored, got %d auths", len(auths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSynthesizer_Synthesize_NoteParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
note any
|
||||
want string
|
||||
hasValue bool
|
||||
}{
|
||||
{
|
||||
name: "valid string note",
|
||||
note: "hello world",
|
||||
want: "hello world",
|
||||
hasValue: true,
|
||||
},
|
||||
{
|
||||
name: "string note with whitespace",
|
||||
note: " trimmed note ",
|
||||
want: "trimmed note",
|
||||
hasValue: true,
|
||||
},
|
||||
{
|
||||
name: "empty string note",
|
||||
note: "",
|
||||
hasValue: false,
|
||||
},
|
||||
{
|
||||
name: "whitespace only note",
|
||||
note: " ",
|
||||
hasValue: false,
|
||||
},
|
||||
{
|
||||
name: "non-string note ignored",
|
||||
note: 12345,
|
||||
hasValue: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
authData := map[string]any{
|
||||
"type": "claude",
|
||||
"note": tt.note,
|
||||
}
|
||||
data, _ := json.Marshal(authData)
|
||||
errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644)
|
||||
if errWriteFile != nil {
|
||||
t.Fatalf("failed to write auth file: %v", errWriteFile)
|
||||
}
|
||||
|
||||
synth := NewFileSynthesizer()
|
||||
ctx := &SynthesisContext{
|
||||
Config: &config.Config{},
|
||||
AuthDir: tempDir,
|
||||
Now: time.Now(),
|
||||
IDGenerator: NewStableIDGenerator(),
|
||||
}
|
||||
|
||||
auths, errSynthesize := synth.Synthesize(ctx)
|
||||
if errSynthesize != nil {
|
||||
t.Fatalf("unexpected error: %v", errSynthesize)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("expected 1 auth, got %d", len(auths))
|
||||
}
|
||||
|
||||
value, ok := auths[0].Attributes["note"]
|
||||
if tt.hasValue {
|
||||
if !ok {
|
||||
t.Fatal("expected note attribute to be set")
|
||||
}
|
||||
if value != tt.want {
|
||||
t.Fatalf("expected note %q, got %q", tt.want, value)
|
||||
}
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("expected note attribute to be absent, got %q", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
167
backend/internal/watcher/synthesizer/helpers.go
Normal file
167
backend/internal/watcher/synthesizer/helpers.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package synthesizer
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
// StableIDGenerator generates stable, deterministic IDs for auth entries.
|
||||
// It uses SHA256 hashing with collision handling via counters.
|
||||
// It is not safe for concurrent use.
|
||||
type StableIDGenerator struct {
|
||||
counters map[string]int
|
||||
}
|
||||
|
||||
// NewStableIDGenerator creates a new StableIDGenerator instance.
|
||||
func NewStableIDGenerator() *StableIDGenerator {
|
||||
return &StableIDGenerator{counters: make(map[string]int)}
|
||||
}
|
||||
|
||||
// Next generates a stable ID based on the kind and parts.
|
||||
// Returns the full ID (kind:hash) and the short hash portion.
|
||||
func (g *StableIDGenerator) Next(kind string, parts ...string) (string, string) {
|
||||
if g == nil {
|
||||
return kind + ":000000000000", "000000000000"
|
||||
}
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(kind))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write([]byte(trimmed))
|
||||
}
|
||||
digest := hex.EncodeToString(hasher.Sum(nil))
|
||||
if len(digest) < 12 {
|
||||
digest = fmt.Sprintf("%012s", digest)
|
||||
}
|
||||
short := digest[:12]
|
||||
key := kind + ":" + short
|
||||
index := g.counters[key]
|
||||
g.counters[key] = index + 1
|
||||
if index > 0 {
|
||||
short = fmt.Sprintf("%s-%d", short, index)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s", kind, short), short
|
||||
}
|
||||
|
||||
// ApplyAuthExcludedModelsMeta applies excluded models metadata to an auth entry.
|
||||
// It computes a hash of excluded models and sets the auth_kind attribute.
|
||||
// For OAuth entries, perKey (from the JSON file's excluded-models field) is merged
|
||||
// with the global oauth-excluded-models config for the provider.
|
||||
func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) {
|
||||
if auth == nil || cfg == nil {
|
||||
return
|
||||
}
|
||||
authKindKey := strings.ToLower(strings.TrimSpace(authKind))
|
||||
seen := make(map[string]struct{})
|
||||
add := func(list []string) {
|
||||
for _, entry := range list {
|
||||
if trimmed := strings.TrimSpace(entry); trimmed != "" {
|
||||
key := strings.ToLower(trimmed)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
if authKindKey == "apikey" {
|
||||
add(perKey)
|
||||
} else {
|
||||
// For OAuth: merge per-account excluded models with global provider-level exclusions
|
||||
add(perKey)
|
||||
if cfg.OAuthExcludedModels != nil {
|
||||
providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
|
||||
add(cfg.OAuthExcludedModels[providerKey])
|
||||
}
|
||||
}
|
||||
combined := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
combined = append(combined, k)
|
||||
}
|
||||
sort.Strings(combined)
|
||||
hash := diff.ComputeExcludedModelsHash(combined)
|
||||
if auth.Attributes == nil {
|
||||
auth.Attributes = make(map[string]string)
|
||||
}
|
||||
if hash != "" {
|
||||
auth.Attributes["excluded_models_hash"] = hash
|
||||
}
|
||||
// Store the combined excluded models list so that routing can read it at runtime
|
||||
if len(combined) > 0 {
|
||||
auth.Attributes["excluded_models"] = strings.Join(combined, ",")
|
||||
}
|
||||
if authKind != "" {
|
||||
auth.Attributes["auth_kind"] = authKind
|
||||
}
|
||||
}
|
||||
|
||||
// addRequestRetryToMetadata copies a per-credential request-retry override into metadata.
|
||||
// Nil or negative values are treated as unset and are not written.
|
||||
func addRequestRetryToMetadata(requestRetry *int, metadata map[string]any) {
|
||||
if requestRetry == nil || *requestRetry < 0 || metadata == nil {
|
||||
return
|
||||
}
|
||||
metadata["request_retry"] = *requestRetry
|
||||
}
|
||||
|
||||
// addRequestScopedErrorsToMetadata copies per-credential request-scoped error rules into metadata.
|
||||
func addRequestScopedErrorsToMetadata(rules []config.RequestScopedErrorRule, metadata map[string]any) {
|
||||
if len(rules) == 0 || metadata == nil {
|
||||
return
|
||||
}
|
||||
metadata["request_scoped_errors"] = rules
|
||||
}
|
||||
|
||||
func fingerprintProfileFromMetadata(metadata map[string]any) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"fingerprint_profile", "fingerprint-profile"} {
|
||||
raw, _ := metadata[key].(string)
|
||||
if profile := strings.ToLower(strings.TrimSpace(raw)); profile != "" {
|
||||
return profile
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// applyFingerprintProfileAttribute copies fingerprint-profile from an OAuth JSON
|
||||
// file (Kimi, Claude, etc.) onto auth attributes so Claude Messages opt-in works
|
||||
// the same way as claude-api-key config.
|
||||
func applyFingerprintProfileAttribute(auth *coreauth.Auth, metadata map[string]any) {
|
||||
if auth == nil {
|
||||
return
|
||||
}
|
||||
profile := fingerprintProfileFromMetadata(metadata)
|
||||
if profile == "" {
|
||||
return
|
||||
}
|
||||
if auth.Attributes == nil {
|
||||
auth.Attributes = make(map[string]string)
|
||||
}
|
||||
auth.Attributes["fingerprint_profile"] = profile
|
||||
}
|
||||
|
||||
// addConfigHeadersToAttrs adds header configuration to auth attributes.
|
||||
// Headers are prefixed with "header:" in the attributes map.
|
||||
func addConfigHeadersToAttrs(headers map[string]string, attrs map[string]string) {
|
||||
if len(headers) == 0 || attrs == nil {
|
||||
return
|
||||
}
|
||||
for hk, hv := range headers {
|
||||
key := strings.TrimSpace(hk)
|
||||
val := strings.TrimSpace(hv)
|
||||
if key == "" || val == "" {
|
||||
continue
|
||||
}
|
||||
attrs["header:"+key] = val
|
||||
}
|
||||
}
|
||||
321
backend/internal/watcher/synthesizer/helpers_test.go
Normal file
321
backend/internal/watcher/synthesizer/helpers_test.go
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
package synthesizer
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func TestNewStableIDGenerator(t *testing.T) {
|
||||
gen := NewStableIDGenerator()
|
||||
if gen == nil {
|
||||
t.Fatal("expected non-nil generator")
|
||||
}
|
||||
if gen.counters == nil {
|
||||
t.Fatal("expected non-nil counters map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableIDGenerator_Next(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
parts []string
|
||||
wantPrefix string
|
||||
}{
|
||||
{
|
||||
name: "basic gemini apikey",
|
||||
kind: "gemini:apikey",
|
||||
parts: []string{"test-key", ""},
|
||||
wantPrefix: "gemini:apikey:",
|
||||
},
|
||||
{
|
||||
name: "claude with base url",
|
||||
kind: "claude:apikey",
|
||||
parts: []string{"sk-ant-xxx", "https://api.anthropic.com"},
|
||||
wantPrefix: "claude:apikey:",
|
||||
},
|
||||
{
|
||||
name: "empty parts",
|
||||
kind: "codex:apikey",
|
||||
parts: []string{},
|
||||
wantPrefix: "codex:apikey:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gen := NewStableIDGenerator()
|
||||
id, short := gen.Next(tt.kind, tt.parts...)
|
||||
|
||||
if !strings.Contains(id, tt.wantPrefix) {
|
||||
t.Errorf("expected id to contain %q, got %q", tt.wantPrefix, id)
|
||||
}
|
||||
if short == "" {
|
||||
t.Error("expected non-empty short id")
|
||||
}
|
||||
if len(short) != 12 {
|
||||
t.Errorf("expected short id length 12, got %d", len(short))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableIDGenerator_Stability(t *testing.T) {
|
||||
gen1 := NewStableIDGenerator()
|
||||
gen2 := NewStableIDGenerator()
|
||||
|
||||
id1, _ := gen1.Next("gemini:apikey", "test-key", "https://api.example.com")
|
||||
id2, _ := gen2.Next("gemini:apikey", "test-key", "https://api.example.com")
|
||||
|
||||
if id1 != id2 {
|
||||
t.Errorf("same inputs should produce same ID: got %q and %q", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableIDGenerator_CollisionHandling(t *testing.T) {
|
||||
gen := NewStableIDGenerator()
|
||||
|
||||
id1, short1 := gen.Next("gemini:apikey", "same-key")
|
||||
id2, short2 := gen.Next("gemini:apikey", "same-key")
|
||||
|
||||
if id1 == id2 {
|
||||
t.Error("collision should be handled with suffix")
|
||||
}
|
||||
if short1 == short2 {
|
||||
t.Error("short ids should differ")
|
||||
}
|
||||
if !strings.Contains(short2, "-1") {
|
||||
t.Errorf("second short id should contain -1 suffix, got %q", short2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableIDGenerator_NilReceiver(t *testing.T) {
|
||||
var gen *StableIDGenerator = nil
|
||||
id, short := gen.Next("test:kind", "part")
|
||||
|
||||
if id != "test:kind:000000000000" {
|
||||
t.Errorf("expected test:kind:000000000000, got %q", id)
|
||||
}
|
||||
if short != "000000000000" {
|
||||
t.Errorf("expected 000000000000, got %q", short)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAuthExcludedModelsMeta(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
auth *coreauth.Auth
|
||||
cfg *config.Config
|
||||
perKey []string
|
||||
authKind string
|
||||
wantHash bool
|
||||
wantKind string
|
||||
}{
|
||||
{
|
||||
name: "apikey with excluded models",
|
||||
auth: &coreauth.Auth{
|
||||
Provider: "gemini",
|
||||
Attributes: make(map[string]string),
|
||||
},
|
||||
cfg: &config.Config{},
|
||||
perKey: []string{"model-a", "model-b"},
|
||||
authKind: "apikey",
|
||||
wantHash: true,
|
||||
wantKind: "apikey",
|
||||
},
|
||||
{
|
||||
name: "oauth with provider excluded models",
|
||||
auth: &coreauth.Auth{
|
||||
Provider: "claude",
|
||||
Attributes: make(map[string]string),
|
||||
},
|
||||
cfg: &config.Config{
|
||||
OAuthExcludedModels: map[string][]string{
|
||||
"claude": {"claude-2.0"},
|
||||
},
|
||||
},
|
||||
perKey: nil,
|
||||
authKind: "oauth",
|
||||
wantHash: true,
|
||||
wantKind: "oauth",
|
||||
},
|
||||
{
|
||||
name: "nil auth",
|
||||
auth: nil,
|
||||
cfg: &config.Config{},
|
||||
},
|
||||
{
|
||||
name: "nil config",
|
||||
auth: &coreauth.Auth{Provider: "test"},
|
||||
cfg: nil,
|
||||
authKind: "apikey",
|
||||
},
|
||||
{
|
||||
name: "nil attributes initialized",
|
||||
auth: &coreauth.Auth{
|
||||
Provider: "gemini",
|
||||
Attributes: nil,
|
||||
},
|
||||
cfg: &config.Config{},
|
||||
perKey: []string{"model-x"},
|
||||
authKind: "apikey",
|
||||
wantHash: true,
|
||||
wantKind: "apikey",
|
||||
},
|
||||
{
|
||||
name: "apikey with duplicate excluded models",
|
||||
auth: &coreauth.Auth{
|
||||
Provider: "gemini",
|
||||
Attributes: make(map[string]string),
|
||||
},
|
||||
cfg: &config.Config{},
|
||||
perKey: []string{"model-a", "MODEL-A", "model-b", "model-a"},
|
||||
authKind: "apikey",
|
||||
wantHash: true,
|
||||
wantKind: "apikey",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ApplyAuthExcludedModelsMeta(tt.auth, tt.cfg, tt.perKey, tt.authKind)
|
||||
|
||||
if tt.auth != nil && tt.cfg != nil {
|
||||
if tt.wantHash {
|
||||
if _, ok := tt.auth.Attributes["excluded_models_hash"]; !ok {
|
||||
t.Error("expected excluded_models_hash in attributes")
|
||||
}
|
||||
}
|
||||
if tt.wantKind != "" {
|
||||
if got := tt.auth.Attributes["auth_kind"]; got != tt.wantKind {
|
||||
t.Errorf("expected auth_kind=%s, got %s", tt.wantKind, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAuthExcludedModelsMeta_OAuthMergeWritesCombinedModels(t *testing.T) {
|
||||
auth := &coreauth.Auth{
|
||||
Provider: "claude",
|
||||
Attributes: make(map[string]string),
|
||||
}
|
||||
cfg := &config.Config{
|
||||
OAuthExcludedModels: map[string][]string{
|
||||
"claude": {"global-a", "shared"},
|
||||
},
|
||||
}
|
||||
|
||||
ApplyAuthExcludedModelsMeta(auth, cfg, []string{"per", "SHARED"}, "oauth")
|
||||
|
||||
const wantCombined = "global-a,per,shared"
|
||||
if gotCombined := auth.Attributes["excluded_models"]; gotCombined != wantCombined {
|
||||
t.Fatalf("expected excluded_models=%q, got %q", wantCombined, gotCombined)
|
||||
}
|
||||
|
||||
expectedHash := diff.ComputeExcludedModelsHash([]string{"global-a", "per", "shared"})
|
||||
if gotHash := auth.Attributes["excluded_models_hash"]; gotHash != expectedHash {
|
||||
t.Fatalf("expected excluded_models_hash=%q, got %q", expectedHash, gotHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddConfigHeadersToAttrs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
attrs map[string]string
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "basic headers",
|
||||
headers: map[string]string{
|
||||
"Authorization": "Bearer token",
|
||||
"X-Custom": "value",
|
||||
},
|
||||
attrs: map[string]string{"existing": "key"},
|
||||
want: map[string]string{
|
||||
"existing": "key",
|
||||
"header:Authorization": "Bearer token",
|
||||
"header:X-Custom": "value",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty headers",
|
||||
headers: map[string]string{},
|
||||
attrs: map[string]string{"existing": "key"},
|
||||
want: map[string]string{"existing": "key"},
|
||||
},
|
||||
{
|
||||
name: "nil headers",
|
||||
headers: nil,
|
||||
attrs: map[string]string{"existing": "key"},
|
||||
want: map[string]string{"existing": "key"},
|
||||
},
|
||||
{
|
||||
name: "nil attrs",
|
||||
headers: map[string]string{"key": "value"},
|
||||
attrs: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "skip empty keys and values",
|
||||
headers: map[string]string{
|
||||
"": "value",
|
||||
"key": "",
|
||||
" ": "value",
|
||||
"valid": "valid-value",
|
||||
},
|
||||
attrs: make(map[string]string),
|
||||
want: map[string]string{
|
||||
"header:valid": "valid-value",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
addConfigHeadersToAttrs(tt.headers, tt.attrs)
|
||||
if !reflect.DeepEqual(tt.attrs, tt.want) {
|
||||
t.Errorf("expected %v, got %v", tt.want, tt.attrs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRequestRetryToMetadata(t *testing.T) {
|
||||
zero := 0
|
||||
positive := 2
|
||||
negative := -1
|
||||
|
||||
metadata := map[string]any{}
|
||||
addRequestRetryToMetadata(&zero, metadata)
|
||||
if got, ok := metadata["request_retry"].(int); !ok || got != 0 {
|
||||
t.Fatalf("zero request-retry = %v, want 0", metadata["request_retry"])
|
||||
}
|
||||
|
||||
metadata = map[string]any{}
|
||||
addRequestRetryToMetadata(&positive, metadata)
|
||||
if got, ok := metadata["request_retry"].(int); !ok || got != 2 {
|
||||
t.Fatalf("positive request-retry = %v, want 2", metadata["request_retry"])
|
||||
}
|
||||
|
||||
metadata = map[string]any{}
|
||||
addRequestRetryToMetadata(&negative, metadata)
|
||||
if _, exists := metadata["request_retry"]; exists {
|
||||
t.Fatalf("negative request-retry should be omitted, got %v", metadata["request_retry"])
|
||||
}
|
||||
|
||||
metadata = map[string]any{}
|
||||
addRequestRetryToMetadata(nil, metadata)
|
||||
if _, exists := metadata["request_retry"]; exists {
|
||||
t.Fatalf("nil request-retry should be omitted, got %v", metadata["request_retry"])
|
||||
}
|
||||
|
||||
addRequestRetryToMetadata(&positive, nil)
|
||||
}
|
||||
16
backend/internal/watcher/synthesizer/interface.go
Normal file
16
backend/internal/watcher/synthesizer/interface.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Package synthesizer provides auth synthesis strategies for the watcher package.
|
||||
// It implements the Strategy pattern to support multiple auth sources:
|
||||
// - ConfigSynthesizer: generates Auth entries from config API keys
|
||||
// - FileSynthesizer: generates Auth entries from OAuth JSON files
|
||||
package synthesizer
|
||||
|
||||
import (
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
// AuthSynthesizer defines the interface for generating Auth entries from various sources.
|
||||
type AuthSynthesizer interface {
|
||||
// Synthesize generates Auth entries from the given context.
|
||||
// Returns a slice of Auth pointers and any error encountered.
|
||||
Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, error)
|
||||
}
|
||||
Loading…
Reference in a new issue