fix(openai): 自动透传预检 instructions 并本地 403 拦截
This commit is contained in:
@@ -1276,6 +1276,29 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
|||||||
startTime time.Time,
|
startTime time.Time,
|
||||||
) (*OpenAIForwardResult, error) {
|
) (*OpenAIForwardResult, error) {
|
||||||
if account != nil && account.Type == AccountTypeOAuth {
|
if account != nil && account.Type == AccountTypeOAuth {
|
||||||
|
if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" {
|
||||||
|
rejectMsg := "OpenAI codex passthrough requires a non-empty instructions field"
|
||||||
|
setOpsUpstreamError(c, http.StatusForbidden, rejectMsg, "")
|
||||||
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||||
|
Platform: account.Platform,
|
||||||
|
AccountID: account.ID,
|
||||||
|
AccountName: account.Name,
|
||||||
|
UpstreamStatusCode: http.StatusForbidden,
|
||||||
|
Passthrough: true,
|
||||||
|
Kind: "request_error",
|
||||||
|
Message: rejectMsg,
|
||||||
|
Detail: rejectReason,
|
||||||
|
})
|
||||||
|
logOpenAIPassthroughInstructionsRejected(ctx, c, account, reqModel, rejectReason, body)
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"type": "forbidden_error",
|
||||||
|
"message": rejectMsg,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("openai passthrough rejected before upstream: %s", rejectReason)
|
||||||
|
}
|
||||||
|
|
||||||
normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body)
|
normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1395,6 +1418,37 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logOpenAIPassthroughInstructionsRejected(
|
||||||
|
ctx context.Context,
|
||||||
|
c *gin.Context,
|
||||||
|
account *Account,
|
||||||
|
reqModel string,
|
||||||
|
rejectReason string,
|
||||||
|
body []byte,
|
||||||
|
) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
accountID := int64(0)
|
||||||
|
accountName := ""
|
||||||
|
accountType := ""
|
||||||
|
if account != nil {
|
||||||
|
accountID = account.ID
|
||||||
|
accountName = strings.TrimSpace(account.Name)
|
||||||
|
accountType = strings.TrimSpace(string(account.Type))
|
||||||
|
}
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("component", "service.openai_gateway"),
|
||||||
|
zap.Int64("account_id", accountID),
|
||||||
|
zap.String("account_name", accountName),
|
||||||
|
zap.String("account_type", accountType),
|
||||||
|
zap.String("request_model", strings.TrimSpace(reqModel)),
|
||||||
|
zap.String("reject_reason", strings.TrimSpace(rejectReason)),
|
||||||
|
}
|
||||||
|
fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, body)
|
||||||
|
logger.FromContext(ctx).With(fields...).Warn("OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
c *gin.Context,
|
c *gin.Context,
|
||||||
@@ -2948,3 +3002,22 @@ func normalizeOpenAIReasoningEffort(raw string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string {
|
||||||
|
model := strings.ToLower(strings.TrimSpace(reqModel))
|
||||||
|
if !strings.Contains(model, "codex") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
instructions := gjson.GetBytes(body, "instructions")
|
||||||
|
if !instructions.Exists() {
|
||||||
|
return "instructions_missing"
|
||||||
|
}
|
||||||
|
if instructions.Type != gjson.String {
|
||||||
|
return "instructions_not_string"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(instructions.String()) == "" {
|
||||||
|
return "instructions_empty"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
|
|||||||
c.Request.Header.Set("Proxy-Authorization", "Basic abc")
|
c.Request.Header.Set("Proxy-Authorization", "Basic abc")
|
||||||
c.Request.Header.Set("X-Test", "keep")
|
c.Request.Header.Set("X-Test", "keep")
|
||||||
|
|
||||||
originalBody := []byte(`{"model":"gpt-5.2","stream":true,"store":true,"input":[{"type":"text","text":"hi"}]}`)
|
originalBody := []byte(`{"model":"gpt-5.2","stream":true,"store":true,"instructions":"local-test-instructions","input":[{"type":"text","text":"hi"}]}`)
|
||||||
|
|
||||||
upstreamSSE := strings.Join([]string{
|
upstreamSSE := strings.Join([]string{
|
||||||
`data: {"type":"response.output_item.added","item":{"type":"tool_call","tool_calls":[{"function":{"name":"apply_patch"}}]}}`,
|
`data: {"type":"response.output_item.added","item":{"type":"tool_call","tool_calls":[{"function":{"name":"apply_patch"}}]}}`,
|
||||||
@@ -211,6 +211,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
|
|||||||
// 1) 透传 OAuth 请求体与旧链路关键行为保持一致:store=false + stream=true。
|
// 1) 透传 OAuth 请求体与旧链路关键行为保持一致:store=false + stream=true。
|
||||||
require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool())
|
require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool())
|
||||||
require.Equal(t, true, gjson.GetBytes(upstream.lastBody, "stream").Bool())
|
require.Equal(t, true, gjson.GetBytes(upstream.lastBody, "stream").Bool())
|
||||||
|
require.Equal(t, "local-test-instructions", strings.TrimSpace(gjson.GetBytes(upstream.lastBody, "instructions").String()))
|
||||||
// 其余关键字段保持原值。
|
// 其余关键字段保持原值。
|
||||||
require.Equal(t, "gpt-5.2", gjson.GetBytes(upstream.lastBody, "model").String())
|
require.Equal(t, "gpt-5.2", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||||
require.Equal(t, "hi", gjson.GetBytes(upstream.lastBody, "input.0.text").String())
|
require.Equal(t, "hi", gjson.GetBytes(upstream.lastBody, "input.0.text").String())
|
||||||
@@ -235,6 +236,59 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
|
|||||||
require.NotContains(t, body, "\"name\":\"edit\"")
|
require.NotContains(t, body, "\"name\":\"edit\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsRejectedBeforeUpstream(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
logSink, restore := captureStructuredLog(t)
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil))
|
||||||
|
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown")
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
c.Request.Header.Set("OpenAI-Beta", "responses=experimental")
|
||||||
|
|
||||||
|
// Codex 模型且缺少 instructions,应在本地直接 403 拒绝,不触达上游。
|
||||||
|
originalBody := []byte(`{"model":"gpt-5.1-codex-max","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`)
|
||||||
|
|
||||||
|
upstream := &httpUpstreamRecorder{
|
||||||
|
resp: &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &OpenAIGatewayService{
|
||||||
|
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
|
||||||
|
httpUpstream: upstream,
|
||||||
|
}
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 123,
|
||||||
|
Name: "acc",
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Concurrency: 1,
|
||||||
|
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||||
|
Extra: map[string]any{"openai_passthrough": true},
|
||||||
|
Status: StatusActive,
|
||||||
|
Schedulable: true,
|
||||||
|
RateMultiplier: f64p(1),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.Forward(context.Background(), c, account, originalBody)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, result)
|
||||||
|
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||||
|
require.Contains(t, rec.Body.String(), "requires a non-empty instructions field")
|
||||||
|
require.Nil(t, upstream.lastReq)
|
||||||
|
|
||||||
|
require.True(t, logSink.ContainsMessage("OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions"))
|
||||||
|
require.True(t, logSink.ContainsFieldValue("request_user_agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown"))
|
||||||
|
require.True(t, logSink.ContainsFieldValue("reject_reason", "instructions_missing"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *testing.T) {
|
func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user