diff --git a/config.example.yaml b/config.example.yaml index 58e9e3975..0c12fb3b0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -45,6 +45,10 @@ usage-statistics-enabled: false # Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1: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 diff --git a/internal/api/modules/amp/amp.go b/internal/api/modules/amp/amp.go index c18657c9c..988032ea3 100644 --- a/internal/api/modules/amp/amp.go +++ b/internal/api/modules/amp/amp.go @@ -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 { @@ -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) @@ -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() + } + } + } } diff --git a/internal/api/modules/amp/routes_test.go b/internal/api/modules/amp/routes_test.go index 67b391902..f13896947 100644 --- a/internal/api/modules/amp/routes_test.go +++ b/internal/api/modules/amp/routes_test.go @@ -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() @@ -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 @@ -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 { diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request.go b/internal/translator/codex/openai/chat-completions/codex_openai_request.go index 272037da0..309c974e8 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_request.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_request.go @@ -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") @@ -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) } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index f8bcb7b1e..16a557cf3 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -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 diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index f321a7c94..2eab3a33a 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -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)) } diff --git a/sdk/api/handlers/openai/openai_handlers.go b/sdk/api/handlers/openai/openai_handlers.go index 485afe4d4..4fd547b42 100644 --- a/sdk/api/handlers/openai/openai_handlers.go +++ b/sdk/api/handlers/openai/openai_handlers.go @@ -7,6 +7,7 @@ package openai import ( + "bytes" "context" "encoding/json" "fmt" @@ -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. // @@ -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 + } + 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 @@ -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 { @@ -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()) @@ -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()) diff --git a/sdk/api/handlers/openai/openai_handlers_test.go b/sdk/api/handlers/openai/openai_handlers_test.go new file mode 100644 index 000000000..a70fcf563 --- /dev/null +++ b/sdk/api/handlers/openai/openai_handlers_test.go @@ -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") + } +} diff --git a/sdk/config/config.go b/sdk/config/config.go index acb340ef4..bc84d98a3 100644 --- a/sdk/config/config.go +++ b/sdk/config/config.go @@ -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"` diff --git a/test/builtin_tools_translation_test.go b/test/builtin_tools_translation_test.go new file mode 100644 index 000000000..b4ca7b0da --- /dev/null +++ b/test/builtin_tools_translation_test.go @@ -0,0 +1,54 @@ +package test + +import ( + "testing" + + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" + + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestOpenAIToCodex_PreservesBuiltinTools(t *testing.T) { + in := []byte(`{ + "model":"gpt-5", + "messages":[{"role":"user","content":"hi"}], + "tools":[{"type":"web_search","search_context_size":"high"}], + "tool_choice":{"type":"web_search"} + }`) + + out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAI, sdktranslator.FormatCodex, "gpt-5", in, false) + + if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 { + t.Fatalf("expected 1 tool, got %d: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.type").String(); got != "web_search" { + t.Fatalf("expected tools[0].type=web_search, got %q: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.search_context_size").String(); got != "high" { + t.Fatalf("expected tools[0].search_context_size=high, got %q: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "web_search" { + t.Fatalf("expected tool_choice.type=web_search, got %q: %s", got, string(out)) + } +} + +func TestOpenAIResponsesToOpenAI_PreservesBuiltinTools(t *testing.T) { + in := []byte(`{ + "model":"gpt-5", + "input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}], + "tools":[{"type":"web_search","search_context_size":"low"}] + }`) + + out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "gpt-5", in, false) + + if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 { + t.Fatalf("expected 1 tool, got %d: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.type").String(); got != "web_search" { + t.Fatalf("expected tools[0].type=web_search, got %q: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.search_context_size").String(); got != "low" { + t.Fatalf("expected tools[0].search_context_size=low, got %q: %s", got, string(out)) + } +}