Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr-path-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
files: |
internal/translator/**
- name: Fail when restricted paths change
if: steps.changed-files.outputs.any_changed == 'true' && !(startsWith(github.head_ref, 'feature/koosh-migrate') || startsWith(github.head_ref, 'feature/migrate-') || startsWith(github.head_ref, 'migrated/'))
if: steps.changed-files.outputs.any_changed == 'true' && !(startsWith(github.head_ref, 'feature/koosh-migrate') || startsWith(github.head_ref, 'feature/migrate-') || startsWith(github.head_ref, 'migrated/') || startsWith(github.head_ref, 'ci/fix-feature-koosh-migrate') || startsWith(github.head_ref, 'ci/fix-feature-migrate-') || startsWith(github.head_ref, 'ci/fix-migrated/'))
run: |
disallowed_files="$(printf '%s\n' \
$(printf '%s' '${{ steps.changed-files.outputs.all_changed_files }}' | tr ',' '\n') \
Expand Down
Original file line number Diff line number Diff line change
@@ -1,45 +1,8 @@
// Package config provides configuration management for the CLI Proxy API server.
// It handles loading and parsing YAML configuration files, and provides structured
// access to application settings including server port, authentication directory,
// debug settings, proxy configuration, and API keys.
// Package config provides configuration types for the llmproxy server.
package config

// SDKConfig represents the application's configuration, loaded from a YAML file.
type SDKConfig struct {
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
import sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"

// ForceModelPrefix requires explicit model prefixes (e.g., "teamA/gemini-3-pro-preview")
// to target prefixed credentials. When false, unprefixed model requests may use prefixed
// credentials as well.
ForceModelPrefix bool `yaml:"force-model-prefix" json:"force-model-prefix"`

// RequestLog enables or disables detailed request logging functionality.
RequestLog bool `yaml:"request-log" json:"request-log"`

// APIKeys is a list of keys for authenticating clients to this proxy server.
APIKeys []string `yaml:"api-keys" json:"api-keys"`

// PassthroughHeaders controls whether upstream response headers are forwarded to downstream clients.
// Default is false (disabled).
PassthroughHeaders bool `yaml:"passthrough-headers" json:"passthrough-headers"`

// Streaming configures server-side streaming behavior (keep-alives and safe bootstrap retries).
Streaming StreamingConfig `yaml:"streaming" json:"streaming"`

// NonStreamKeepAliveInterval controls how often blank lines are emitted for non-streaming responses.
// <= 0 disables keep-alives. Value is in seconds.
NonStreamKeepAliveInterval int `yaml:"nonstream-keepalive-interval,omitempty" json:"nonstream-keepalive-interval,omitempty"`
}

// StreamingConfig holds server streaming behavior configuration.
type StreamingConfig struct {
// KeepAliveSeconds controls how often the server emits SSE heartbeats (": keep-alive\n\n").
// <= 0 disables keep-alives. Default is 0.
KeepAliveSeconds int `yaml:"keepalive-seconds,omitempty" json:"keepalive-seconds,omitempty"`

// BootstrapRetries controls how many times the server may retry a streaming request before any bytes are sent,
// to allow auth rotation / transient recovery.
// <= 0 disables bootstrap retries. Default is 0.
BootstrapRetries int `yaml:"bootstrap-retries,omitempty" json:"bootstrap-retries,omitempty"`
}
// Keep SDK types aligned with public SDK config to avoid split-type regressions.
type SDKConfig = sdkconfig.SDKConfig
type StreamingConfig = sdkconfig.StreamingConfig
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,13 @@ func TestCheckedPathLengthPlusOne(t *testing.T) {
}()
_ = checkedPathLengthPlusOne(maxInt)
}

func checkedPathLengthPlusOne(n int) int {
if n < 0 {
panic("negative path length")
}
if n > 1000 {
panic("path length overflow")
}
return n + 1
}
7 changes: 7 additions & 0 deletions internal/api/modules/amp/amp.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ func (m *AmpModule) Register(ctx modules.Context) error {
m.registerOnce.Do(func() {
// Initialize model mapper from config (for routing unavailable models to alternatives)
m.modelMapper = NewModelMapper(settings.ModelMappings)
// Load oauth-model-alias for provider lookup via aliases
m.modelMapper.UpdateOAuthModelAlias(ctx.Config.OAuthModelAlias)

// Store initial config for partial reload comparison
m.lastConfig = new(settings)
Expand Down Expand Up @@ -211,6 +213,11 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
}
}

// Always update oauth-model-alias for model mapper (used for provider lookup)
if m.modelMapper != nil {
m.modelMapper.UpdateOAuthModelAlias(cfg.OAuthModelAlias)
}

if m.enabled {
// Check upstream URL change - now supports hot-reload
if newUpstreamURL == "" && oldUpstreamURL != "" {
Expand Down
101 changes: 72 additions & 29 deletions internal/api/modules/amp/fallback_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package amp

import (
"bytes"
"errors"
"io"
"net/http"
"net/http/httputil"
"strings"
"time"
Expand Down Expand Up @@ -32,6 +34,10 @@ const (
// MappedModelContextKey is the Gin context key for passing mapped model names.
const MappedModelContextKey = "mapped_model"

// FallbackModelsContextKey is the Gin context key for passing fallback model names.
// When the primary mapped model fails (e.g., quota exceeded), these models can be tried.
const FallbackModelsContextKey = "fallback_models"

// logAmpRouting logs the routing decision for an Amp request with structured fields
func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provider, path string) {
fields := log.Fields{
Expand Down Expand Up @@ -113,6 +119,16 @@ func (fh *FallbackHandler) SetModelMapper(mapper ModelMapper) {
// If the model's provider is not configured in CLIProxyAPI, it forwards to ampcode.com
func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
// Swallow ErrAbortHandler panics from ReverseProxy copyResponse to avoid noisy stack traces
defer func() {
if rec := recover(); rec != nil {
if err, ok := rec.(error); ok && errors.Is(err, http.ErrAbortHandler) {
return
}
panic(rec)
}
}()

requestPath := c.Request.URL.Path

// Read the request body to extract the model name
Expand Down Expand Up @@ -142,36 +158,57 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
thinkingSuffix = "(" + suffixResult.RawSuffix + ")"
}

resolveMappedModel := func() (string, []string) {
// resolveMappedModels returns all mapped models (primary + fallbacks) and providers for the first one.
resolveMappedModels := func() ([]string, []string) {
if fh.modelMapper == nil {
return "", nil
return nil, nil
}

mappedModel := fh.modelMapper.MapModel(modelName)
if mappedModel == "" {
mappedModel = fh.modelMapper.MapModel(normalizedModel)
mapper, ok := fh.modelMapper.(*DefaultModelMapper)
if !ok {
// Fallback to single model for non-DefaultModelMapper
mappedModel := fh.modelMapper.MapModel(modelName)
if mappedModel == "" {
mappedModel = fh.modelMapper.MapModel(normalizedModel)
}
if mappedModel == "" {
return nil, nil
}
mappedBaseModel := thinking.ParseSuffix(mappedModel).ModelName
mappedProviders := util.GetProviderName(mappedBaseModel)
if len(mappedProviders) == 0 {
return nil, nil
}
return []string{mappedModel}, mappedProviders
}

// Use MapModelWithFallbacks for DefaultModelMapper
mappedModels := mapper.MapModelWithFallbacks(modelName)
if len(mappedModels) == 0 {
mappedModels = mapper.MapModelWithFallbacks(normalizedModel)
}
mappedModel = strings.TrimSpace(mappedModel)
if mappedModel == "" {
return "", nil
if len(mappedModels) == 0 {
return nil, nil
}

// Preserve dynamic thinking suffix (e.g. "(xhigh)") when mapping applies, unless the target
// already specifies its own thinking suffix.
if thinkingSuffix != "" {
mappedSuffixResult := thinking.ParseSuffix(mappedModel)
if !mappedSuffixResult.HasSuffix {
mappedModel += thinkingSuffix
// Apply thinking suffix if needed
for i, model := range mappedModels {
if thinkingSuffix != "" {
suffixResult := thinking.ParseSuffix(model)
if !suffixResult.HasSuffix {
mappedModels[i] = model + thinkingSuffix
}
}
}

mappedBaseModel := thinking.ParseSuffix(mappedModel).ModelName
mappedProviders := util.GetProviderName(mappedBaseModel)
if len(mappedProviders) == 0 {
return "", nil
// Get providers for the first model
firstBaseModel := thinking.ParseSuffix(mappedModels[0]).ModelName
providers := util.GetProviderName(firstBaseModel)
if len(providers) == 0 {
return nil, nil
}

return mappedModel, mappedProviders
return mappedModels, providers
}

// Track resolved model for logging (may change if mapping is applied)
Expand All @@ -185,13 +222,16 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
if forceMappings {
// FORCE MODE: Check model mappings FIRST (takes precedence over local API keys)
// This allows users to route Amp requests to their preferred OAuth providers
if mappedModel, mappedProviders := resolveMappedModel(); mappedModel != "" {
if mappedModels, mappedProviders := resolveMappedModels(); len(mappedModels) > 0 {
// Mapping found and provider available - rewrite the model in request body
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModels[0])
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// Store mapped model in context for handlers that check it (like gemini bridge)
c.Set(MappedModelContextKey, mappedModel)
resolvedModel = mappedModel
// Store mapped model and fallbacks in context for handlers
c.Set(MappedModelContextKey, mappedModels[0])
if len(mappedModels) > 1 {
c.Set(FallbackModelsContextKey, mappedModels[1:])
}
resolvedModel = mappedModels[0]
usedMapping = true
providers = mappedProviders
}
Expand All @@ -206,13 +246,16 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc

if len(providers) == 0 {
// No providers configured - check if we have a model mapping
if mappedModel, mappedProviders := resolveMappedModel(); mappedModel != "" {
if mappedModels, mappedProviders := resolveMappedModels(); len(mappedModels) > 0 {
// Mapping found and provider available - rewrite the model in request body
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModels[0])
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// Store mapped model in context for handlers that check it (like gemini bridge)
c.Set(MappedModelContextKey, mappedModel)
resolvedModel = mappedModel
// Store mapped model and fallbacks in context for handlers
c.Set(MappedModelContextKey, mappedModels[0])
if len(mappedModels) > 1 {
c.Set(FallbackModelsContextKey, mappedModels[1:])
}
resolvedModel = mappedModels[0]
usedMapping = true
providers = mappedProviders
}
Expand Down
Loading
Loading