From adf01ac8804c6acceba1763b836706c027097aef Mon Sep 17 00:00:00 2001 From: alfadb Date: Thu, 30 Apr 2026 21:46:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(openai-gateway):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20probe=20URL=20/v1=20prefix,=20Create=20trigger,=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix four issues flagged by copilot-pull-request-reviewer on PR #2143: 1. Probe URL missing /v1 prefix (openai_apikey_responses_probe.go) Replaced bare TrimSuffix + "/responses" with buildOpenAIResponsesURL(), which handles bare domain → /v1/responses correctly. Affected: - ProbeOpenAIAPIKeyResponsesSupport (probe URL) - TestAccount endpoint (apiURL for APIKey accounts) 2. Create endpoint not triggering probe (account_handler.go) Capture created account from idempotent closure and call scheduleOpenAIResponsesProbe after success, same pattern as BatchCreate and Update. 3. Tests (openai_gateway_chat_completions_raw_test.go) Added TestBuildOpenAIChatCompletionsURL (7 cases covering bare domain, /v1 suffix, trailing slash, third-party domains, whitespace) and TestBuildOpenAIResponsesURL_ProbeURL (6 cases locking the probe URL construction for bare-domain inputs). All unit tests pass; go build ./cmd/server/ clean. --- .../internal/handler/admin/account_handler.go | 8 +++ .../internal/service/account_test_service.go | 2 +- .../service/openai_apikey_responses_probe.go | 2 +- ...penai_gateway_chat_completions_raw_test.go | 67 +++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 backend/internal/service/openai_gateway_chat_completions_raw_test.go diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index c93fff7b..ffab74d6 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -528,6 +528,10 @@ func (h *AccountHandler) Create(c *gin.Context) { // 确定是否跳过混合渠道检查 skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk + // 捕获闭包内创建的账号引用,用于创建成功后触发异步探测。 + // 幂等重放时闭包不会执行 → createdAccount 为 nil → 不重复调度。 + var createdAccount *service.Account + result, err := executeAdminIdempotent(c, "admin.accounts.create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { account, execErr := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{ Name: req.Name, @@ -549,6 +553,7 @@ func (h *AccountHandler) Create(c *gin.Context) { if execErr != nil { return nil, execErr } + createdAccount = account // Antigravity OAuth: 新账号直接设置隐私 h.adminService.ForceAntigravityPrivacy(ctx, account) // OpenAI OAuth: 新账号直接设置隐私 @@ -577,6 +582,9 @@ func (h *AccountHandler) Create(c *gin.Context) { if result != nil && result.Replayed { c.Header("X-Idempotency-Replayed", "true") } + // OpenAI APIKey 账号创建后异步探测上游 /v1/responses 能力。 + // 探测失败不影响账号创建响应。 + h.scheduleOpenAIResponsesProbe(createdAccount) response.Success(c, result.Data) } diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 572a12b1..ddb4343a 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -564,7 +564,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。", ) } - apiURL = strings.TrimSuffix(normalizedBaseURL, "/") + "/responses" + apiURL = buildOpenAIResponsesURL(normalizedBaseURL) } else { return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type)) } diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index 1bddcf50..a4eb9252 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -85,7 +85,7 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte return } - probeURL := strings.TrimSuffix(normalizedBaseURL, "/") + "/responses" + probeURL := buildOpenAIResponsesURL(normalizedBaseURL) probeCtx, cancel := context.WithTimeout(ctx, openaiResponsesProbeTimeout) defer cancel() diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go new file mode 100644 index 00000000..01013837 --- /dev/null +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -0,0 +1,67 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildOpenAIChatCompletionsURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + base string + want string + }{ + // 已是 /chat/completions:原样返回 + {"already chat/completions", "https://api.openai.com/v1/chat/completions", "https://api.openai.com/v1/chat/completions"}, + // 以 /v1 结尾:追加 /chat/completions + {"bare /v1", "https://api.openai.com/v1", "https://api.openai.com/v1/chat/completions"}, + // 其他情况:追加 /v1/chat/completions + {"bare domain", "https://api.openai.com", "https://api.openai.com/v1/chat/completions"}, + {"domain with trailing slash", "https://api.openai.com/", "https://api.openai.com/v1/chat/completions"}, + // 第三方上游常见形式 + {"third-party bare domain", "https://api.deepseek.com", "https://api.deepseek.com/v1/chat/completions"}, + {"third-party with path prefix", "https://api.gptgod.online/api", "https://api.gptgod.online/api/v1/chat/completions"}, + // 带空白字符 + {"whitespace trimmed", " https://api.openai.com/v1 ", "https://api.openai.com/v1/chat/completions"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := buildOpenAIChatCompletionsURL(tt.base) + require.Equal(t, tt.want, got) + }) + } +} + +// TestBuildOpenAIResponsesURL_ProbeURL 锁定 probe/测试端点使用的 URL 构建逻辑, +// 确保 buildOpenAIResponsesURL 对标准 OpenAI base_url 格式均拼出 `/v1/responses`。 +func TestBuildOpenAIResponsesURL_ProbeURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + base string + want string + }{ + {"bare domain", "https://api.openai.com", "https://api.openai.com/v1/responses"}, + {"domain trailing slash", "https://api.openai.com/", "https://api.openai.com/v1/responses"}, + {"bare /v1", "https://api.openai.com/v1", "https://api.openai.com/v1/responses"}, + {"already /responses", "https://api.openai.com/v1/responses", "https://api.openai.com/v1/responses"}, + {"third-party bare domain", "https://api.deepseek.com", "https://api.deepseek.com/v1/responses"}, + {"only domain, no scheme", "api.gptgod.online", "api.gptgod.online/v1/responses"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := buildOpenAIResponsesURL(tt.base) + require.Equal(t, tt.want, got) + }) + } +}