Skip to content
Closed
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
4 changes: 4 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ usage-statistics-enabled: false
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:[email protected]:1080/
proxy-url: ""

# When true, prepend a default assistant message to /v1/chat/completions requests.
# Intended for compatibility with copilot-api, where this workaround may avoid rate limiting.
copilot-unlimited-mode: false

# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
request-retry: 3

Expand Down
12 changes: 10 additions & 2 deletions internal/api/modules/amp/amp.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
if oldSettings != nil {
oldUpstreamURL = strings.TrimSpace(oldSettings.UpstreamURL)
}
upstreamURLChanged := oldUpstreamURL != newUpstreamURL

if !m.enabled && newUpstreamURL != "" {
if err := m.enableUpstreamProxy(newUpstreamURL, &newSettings); err != nil {
Expand All @@ -217,8 +218,8 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
if newUpstreamURL == "" && oldUpstreamURL != "" {
m.setProxy(nil)
m.enabled = false
} else if oldUpstreamURL != "" && newUpstreamURL != oldUpstreamURL && newUpstreamURL != "" {
// Recreate proxy with new URL
} else if upstreamURLChanged && newUpstreamURL != "" {
// Recreate proxy with new URL (including "first config" cases where oldUpstreamURL is empty)
proxy, err := createReverseProxy(newUpstreamURL, m.secretSource)
if err != nil {
log.Errorf("amp config: failed to create proxy for new upstream URL %s: %v", newUpstreamURL, err)
Expand All @@ -237,6 +238,13 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
}
}
}
if upstreamURLChanged {
if m.secretSource != nil {
if ms, ok := m.secretSource.(*MultiSourceSecret); ok {
ms.InvalidateCache()
}
}
}

}

Expand Down
11 changes: 9 additions & 2 deletions internal/api/modules/amp/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
)

type closeNotifyRecorder struct {
*httptest.ResponseRecorder
closeCh chan bool
}

func (w *closeNotifyRecorder) CloseNotify() <-chan bool { return w.closeCh }

func TestRegisterManagementRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
Expand All @@ -32,7 +39,7 @@ func TestRegisterManagementRoutes(t *testing.T) {
m.setProxy(proxy)

base := &handlers.BaseAPIHandler{}
m.registerManagementRoutes(r, base)
m.registerManagementRoutes(r, base, nil)

managementPaths := []struct {
path string
Expand Down Expand Up @@ -64,7 +71,7 @@ func TestRegisterManagementRoutes(t *testing.T) {
t.Run(path.path, func(t *testing.T) {
proxyCalled = false
req := httptest.NewRequest(path.method, path.path, nil)
w := httptest.NewRecorder()
w := &closeNotifyRecorder{ResponseRecorder: httptest.NewRecorder(), closeCh: make(chan bool, 1)}
r.ServeHTTP(w, req)

if w.Code == http.StatusNotFound {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,15 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
arr := tools.Array()
for i := 0; i < len(arr); i++ {
t := arr[i]
if t.Get("type").String() == "function" {
toolType := t.Get("type").String()
// Pass through built-in tools (e.g. {"type":"web_search"}) directly for the Responses API.
// Only "function" needs structural conversion because Chat Completions nests details under "function".
if toolType != "" && toolType != "function" && t.IsObject() {
out, _ = sjson.SetRaw(out, "tools.-1", t.Raw)
continue
}

if toolType == "function" {
item := `{}`
item, _ = sjson.Set(item, "type", "function")
fn := t.Get("function")
Expand Down Expand Up @@ -304,6 +312,37 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
}
}

// Map tool_choice when present.
// Chat Completions: "tool_choice" can be a string ("auto"/"none") or an object (e.g. {"type":"function","function":{"name":"..."}}).
// Responses API: keep built-in tool choices as-is; flatten function choice to {"type":"function","name":"..."}.
if tc := gjson.GetBytes(rawJSON, "tool_choice"); tc.Exists() {
switch {
case tc.Type == gjson.String:
out, _ = sjson.Set(out, "tool_choice", tc.String())
case tc.IsObject():
tcType := tc.Get("type").String()
if tcType == "function" {
name := tc.Get("function.name").String()
if name != "" {
if short, ok := originalToolNameMap[name]; ok {
name = short
} else {
name = shortenNameIfNeeded(name)
}
}
choice := `{}`
choice, _ = sjson.Set(choice, "type", "function")
if name != "" {
choice, _ = sjson.Set(choice, "name", name)
}
out, _ = sjson.SetRaw(out, "tool_choice", choice)
} else if tcType != "" {
// Built-in tool choices (e.g. {"type":"web_search"}) are already Responses-compatible.
out, _ = sjson.SetRaw(out, "tool_choice", tc.Raw)
}
}
}

out, _ = sjson.Set(out, "store", false)
return []byte(out)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
var chatCompletionsTools []interface{}

tools.ForEach(func(_, tool gjson.Result) bool {
// Built-in tools (e.g. {"type":"web_search"}) are already compatible with the Chat Completions schema.
// Only function tools need structural conversion because Chat Completions nests details under "function".
toolType := tool.Get("type").String()
if toolType != "" && toolType != "function" && tool.IsObject() {
chatCompletionsTools = append(chatCompletionsTools, tool.Value())
return true
}

chatTool := `{"type":"function","function":{}}`

// Convert tool structure from responses format to chat completions format
Expand Down
3 changes: 3 additions & 0 deletions internal/watcher/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -1730,6 +1730,9 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if oldCfg.RequestLog != newCfg.RequestLog {
changes = append(changes, fmt.Sprintf("request-log: %t -> %t", oldCfg.RequestLog, newCfg.RequestLog))
}
if oldCfg.CopilotUnlimitedMode != newCfg.CopilotUnlimitedMode {
changes = append(changes, fmt.Sprintf("copilot-unlimited-mode: %t -> %t", oldCfg.CopilotUnlimitedMode, newCfg.CopilotUnlimitedMode))
}
if oldCfg.RequestRetry != newCfg.RequestRetry {
changes = append(changes, fmt.Sprintf("request-retry: %d -> %d", oldCfg.RequestRetry, newCfg.RequestRetry))
}
Expand Down
62 changes: 62 additions & 0 deletions sdk/api/handlers/openai/openai_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package openai

import (
"bytes"
"context"
"encoding/json"
"fmt"
Expand All @@ -28,6 +29,8 @@ type OpenAIAPIHandler struct {
*handlers.BaseAPIHandler
}

var copilotUnlimitedAssistantMessage = []byte(`{"role":"assistant","content":"You are helpful assistant"}`)

// NewOpenAIAPIHandler creates a new OpenAI API handlers instance.
// It takes an BaseAPIHandler instance as input and returns an OpenAIAPIHandler.
//
Expand All @@ -47,6 +50,61 @@ func (h *OpenAIAPIHandler) HandlerType() string {
return OpenAI
}

func (h *OpenAIAPIHandler) applyCopilotUnlimitedModeIfEnabled(rawJSON []byte) []byte {
if h == nil || h.BaseAPIHandler == nil || h.BaseAPIHandler.Cfg == nil {
return rawJSON
}
if !h.BaseAPIHandler.Cfg.CopilotUnlimitedMode {
return rawJSON
}
return prependAssistantMessage(rawJSON)
}

// prependAssistantMessage inserts a default assistant message at the beginning of the "messages" array.
//
// This is primarily used for copilot-api compatibility. If the payload does not contain a messages array,
// or if the first message is already an assistant message, the payload is returned unchanged.
func prependAssistantMessage(rawJSON []byte) []byte {
messages := gjson.GetBytes(rawJSON, "messages")
if !messages.Exists() || !messages.IsArray() {
return rawJSON
}

// Make the operation idempotent: if a client already sends an assistant-first message,
// do not prepend another one.
arr := messages.Array()
if len(arr) > 0 && arr[0].Get("role").String() == "assistant" {
return rawJSON
}

messagesRaw := bytes.TrimSpace([]byte(messages.Raw))
if len(messagesRaw) < 2 || messagesRaw[0] != '[' || messagesRaw[len(messagesRaw)-1] != ']' {
return rawJSON
}

inner := bytes.TrimSpace(messagesRaw[1 : len(messagesRaw)-1])
var newMessagesRaw []byte
if len(inner) == 0 {
newMessagesRaw = make([]byte, 0, 2+len(copilotUnlimitedAssistantMessage))
newMessagesRaw = append(newMessagesRaw, '[')
newMessagesRaw = append(newMessagesRaw, copilotUnlimitedAssistantMessage...)
newMessagesRaw = append(newMessagesRaw, ']')
} else {
newMessagesRaw = make([]byte, 0, 3+len(copilotUnlimitedAssistantMessage)+len(inner))
newMessagesRaw = append(newMessagesRaw, '[')
newMessagesRaw = append(newMessagesRaw, copilotUnlimitedAssistantMessage...)
newMessagesRaw = append(newMessagesRaw, ',')
newMessagesRaw = append(newMessagesRaw, inner...)
newMessagesRaw = append(newMessagesRaw, ']')
}

modified, err := sjson.SetRawBytes(rawJSON, "messages", newMessagesRaw)
if err != nil {
return rawJSON
}
Comment on lines +102 to +104
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error from sjson.SetRawBytes is silently ignored. While an error here is unlikely, it's good practice to log it for debugging purposes. This would help identify potential issues if the input rawJSON is malformed in an unexpected way.

You will need to import log "github.com/sirupsen/logrus" to apply this suggestion, which appears to be the standard logger for this project.

Suggested change
if err != nil {
return rawJSON
}
if err != nil {
log.Warnf("Failed to prepend assistant message for copilot unlimited mode: %v", err)
return rawJSON
}

return modified
}

// Models returns the OpenAI-compatible model metadata supported by this handler.
func (h *OpenAIAPIHandler) Models() []map[string]any {
// Get dynamic models from the global registry
Expand Down Expand Up @@ -107,6 +165,8 @@ func (h *OpenAIAPIHandler) ChatCompletions(c *gin.Context) {
return
}

rawJSON = h.applyCopilotUnlimitedModeIfEnabled(rawJSON)

// Check if the client requested a streaming response.
streamResult := gjson.GetBytes(rawJSON, "stream")
if streamResult.Type == gjson.True {
Expand Down Expand Up @@ -452,6 +512,7 @@ func (h *OpenAIAPIHandler) handleCompletionsNonStreamingResponse(c *gin.Context,

// Convert completions request to chat completions format
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)
chatCompletionsJSON = h.applyCopilotUnlimitedModeIfEnabled(chatCompletionsJSON)

modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
Expand Down Expand Up @@ -493,6 +554,7 @@ func (h *OpenAIAPIHandler) handleCompletionsStreamingResponse(c *gin.Context, ra

// Convert completions request to chat completions format
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)
chatCompletionsJSON = h.applyCopilotUnlimitedModeIfEnabled(chatCompletionsJSON)

modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
Expand Down
84 changes: 84 additions & 0 deletions sdk/api/handlers/openai/openai_handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package openai

import (
"bytes"
"testing"

"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/tidwall/gjson"
)

func TestPrependAssistantMessage_PrependsForMessagesArray(t *testing.T) {
in := []byte(`{"model":"gpt-test","messages":[{"role":"user","content":"hi"}]}`)

out := prependAssistantMessage(in)
if bytes.Equal(out, in) {
t.Fatalf("expected payload to change")
}

if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
t.Fatalf("messages[0].role = %q, want %q", got, "assistant")
}
if got := gjson.GetBytes(out, "messages.0.content").String(); got != "You are helpful assistant" {
t.Fatalf("messages[0].content = %q, want %q", got, "You are helpful assistant")
}
if got := gjson.GetBytes(out, "messages.1.role").String(); got != "user" {
t.Fatalf("messages[1].role = %q, want %q", got, "user")
}
if got := gjson.GetBytes(out, "messages.1.content").String(); got != "hi" {
t.Fatalf("messages[1].content = %q, want %q", got, "hi")
}
if got := gjson.GetBytes(out, "model").String(); got != "gpt-test" {
t.Fatalf("model = %q, want %q", got, "gpt-test")
}

out2 := prependAssistantMessage(out)
if got, want := len(gjson.GetBytes(out2, "messages").Array()), 2; got != want {
t.Fatalf("messages length after second prepend = %d, want %d", got, want)
}
}

func TestPrependAssistantMessage_IdempotentWhenAssistantFirst(t *testing.T) {
in := []byte(`{"messages":[{"role":"assistant","content":"already"},{"role":"user","content":"hi"}]}`)
out := prependAssistantMessage(in)
if !bytes.Equal(out, in) {
t.Fatalf("expected payload to remain unchanged when assistant is already first")
}
}

func TestPrependAssistantMessage_NoMessages_NoChange(t *testing.T) {
in := []byte(`{"model":"gpt-test"}`)
out := prependAssistantMessage(in)
if !bytes.Equal(out, in) {
t.Fatalf("expected payload to remain unchanged when messages is missing")
}
}

func TestApplyCopilotUnlimitedModeIfEnabled_Guards(t *testing.T) {
in := []byte(`{"messages":[{"role":"user","content":"hi"}]}`)

var nilHandler *OpenAIAPIHandler
if out := nilHandler.applyCopilotUnlimitedModeIfEnabled(in); !bytes.Equal(out, in) {
t.Fatalf("expected nil handler to return original payload")
}

disabled := &OpenAIAPIHandler{
BaseAPIHandler: &handlers.BaseAPIHandler{
Cfg: &config.SDKConfig{CopilotUnlimitedMode: false},
},
}
if out := disabled.applyCopilotUnlimitedModeIfEnabled(in); !bytes.Equal(out, in) {
t.Fatalf("expected disabled mode to return original payload")
}

enabled := &OpenAIAPIHandler{
BaseAPIHandler: &handlers.BaseAPIHandler{
Cfg: &config.SDKConfig{CopilotUnlimitedMode: true},
},
}
out := enabled.applyCopilotUnlimitedModeIfEnabled(in)
if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
t.Fatalf("messages[0].role = %q, want %q", got, "assistant")
}
}
7 changes: 7 additions & 0 deletions sdk/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ type SDKConfig struct {
// RequestLog enables or disables detailed request logging functionality.
RequestLog bool `yaml:"request-log" json:"request-log"`

// CopilotUnlimitedMode prepends a default assistant message to /v1/chat/completions requests.
// This is intended for compatibility with copilot-api (OpenAI-compatible GitHub Copilot proxy),
// where this workaround may enable "unlimited" usage by avoiding rate-limit behavior.
//
// Config key: copilot-unlimited-mode
CopilotUnlimitedMode bool `yaml:"copilot-unlimited-mode" json:"copilot-unlimited-mode"`

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

Expand Down
Loading
Loading