fix: clarify OpenAI OAuth proxy errors

This commit is contained in:
zhoukailian
2026-04-23 12:23:04 +08:00
parent 0b85a8da88
commit 2489ea3699
6 changed files with 99 additions and 3 deletions

View File

@@ -2,6 +2,7 @@ package repository
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
@@ -53,6 +54,9 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie
Post(s.tokenURL)
if err != nil {
if shouldReturnOpenAINoProxyHint(ctx, proxyURL, err) {
return nil, newOpenAINoProxyHintError(err)
}
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_REQUEST_FAILED", "request failed: %v", err)
}
@@ -98,6 +102,9 @@ func (s *openaiOAuthService) refreshTokenWithClientID(ctx context.Context, refre
Post(s.tokenURL)
if err != nil {
if shouldReturnOpenAINoProxyHint(ctx, proxyURL, err) {
return nil, newOpenAINoProxyHintError(err)
}
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_REQUEST_FAILED", "request failed: %v", err)
}
@@ -114,3 +121,21 @@ func createOpenAIReqClient(proxyURL string) (*req.Client, error) {
Timeout: 120 * time.Second,
})
}
func shouldReturnOpenAINoProxyHint(ctx context.Context, proxyURL string, err error) bool {
if strings.TrimSpace(proxyURL) != "" || err == nil {
return false
}
if ctx != nil && ctx.Err() != nil {
return false
}
return !errors.Is(err, context.Canceled)
}
func newOpenAINoProxyHintError(cause error) error {
return infraerrors.New(
http.StatusBadGateway,
"OPENAI_OAUTH_PROXY_REQUIRED",
"OpenAI OAuth request failed: no proxy is configured and this server could not reach OpenAI directly. Select a proxy that can access OpenAI, then retry; if the authorization code has expired, regenerate the authorization URL.",
).WithCause(cause)
}

View File

@@ -8,6 +8,7 @@ import (
"net/url"
"testing"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
@@ -204,6 +205,17 @@ func (s *OpenAIOAuthServiceSuite) TestRequestError_ClosedServer() {
require.ErrorContains(s.T(), err, "request failed")
}
func (s *OpenAIOAuthServiceSuite) TestExchangeCode_RequestErrorWithoutProxyReturnsProxyHint() {
s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
s.srv.Close()
_, err := s.svc.ExchangeCode(s.ctx, "code", "ver", openai.DefaultRedirectURI, "", "")
require.Error(s.T(), err)
require.Equal(s.T(), "OPENAI_OAUTH_PROXY_REQUIRED", infraerrors.Reason(err))
require.Contains(s.T(), infraerrors.Message(err), "no proxy is configured")
}
func (s *OpenAIOAuthServiceSuite) TestContextCancel() {
started := make(chan struct{})
block := make(chan struct{})

View File

@@ -6,6 +6,19 @@ vi.mock('@/stores/app', () => ({
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => {
const messages: Record<string, string> = {
'admin.accounts.oauth.openai.failedToExchangeCode': 'OpenAI 授权码兑换失败',
'admin.accounts.oauth.openai.errors.OPENAI_OAUTH_PROXY_REQUIRED':
'未设置代理,当前服务器无法直连 OpenAI导致 OpenAI OAuth 请求失败。请先选择可访问 OpenAI 的代理后重试;如果授权码已失效,请重新生成授权链接。'
}
return messages[key] ?? key
}
})
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
@@ -17,6 +30,7 @@ vi.mock('@/api/admin', () => ({
}))
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { adminAPI } from '@/api/admin'
describe('useOpenAIOAuth.buildCredentials', () => {
it('should keep client_id when token response contains it', () => {
@@ -46,3 +60,21 @@ describe('useOpenAIOAuth.buildCredentials', () => {
expect(creds.refresh_token).toBe('rt')
})
})
describe('useOpenAIOAuth.exchangeAuthCode', () => {
it('shows a clear proxy hint when code exchange fails without a proxy', async () => {
vi.mocked(adminAPI.accounts.exchangeCode).mockRejectedValueOnce({
status: 502,
reason: 'OPENAI_OAUTH_PROXY_REQUIRED',
message: 'OpenAI OAuth token exchange failed: no proxy is configured.'
})
const oauth = useOpenAIOAuth()
const tokenInfo = await oauth.exchangeAuthCode('code', 'session-id', 'state')
expect(tokenInfo).toBeNull()
expect(oauth.error.value).toBe(
'未设置代理,当前服务器无法直连 OpenAI导致 OpenAI OAuth 请求失败。请先选择可访问 OpenAI 的代理后重试;如果授权码已失效,请重新生成授权链接。'
)
})
})

View File

@@ -1,6 +1,8 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError'
export interface OpenAITokenInfo {
access_token?: string
@@ -26,6 +28,7 @@ export type OpenAIOAuthPlatform = 'openai'
export function useOpenAIOAuth() {
const appStore = useAppStore()
const { t } = useI18n()
const endpointPrefix = '/admin/openai'
// State
@@ -78,7 +81,7 @@ export function useOpenAIOAuth() {
}
return true
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to generate OpenAI auth URL'
error.value = extractApiErrorMessage(err, t('admin.accounts.oauth.openai.failedToGenerateUrl'))
appStore.showError(error.value)
return false
} finally {
@@ -114,7 +117,12 @@ export function useOpenAIOAuth() {
const tokenInfo = await adminAPI.accounts.exchangeCode(`${endpointPrefix}/exchange-code`, payload)
return tokenInfo as OpenAITokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to exchange OpenAI auth code'
error.value = extractI18nErrorMessage(
err,
t,
'admin.accounts.oauth.openai.errors',
t('admin.accounts.oauth.openai.failedToExchangeCode')
)
appStore.showError(error.value)
return null
} finally {
@@ -147,7 +155,12 @@ export function useOpenAIOAuth() {
)
return tokenInfo as OpenAITokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || err.message || 'Failed to validate refresh token'
error.value = extractI18nErrorMessage(
err,
t,
'admin.accounts.oauth.openai.errors',
t('admin.accounts.oauth.openai.failedToValidateRT')
)
appStore.showError(error.value)
return null
} finally {

View File

@@ -2774,6 +2774,13 @@ export default {
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
authCodeHint:
'You can copy the entire URL or just the code parameter value, the system will auto-detect',
failedToGenerateUrl: 'Failed to generate OpenAI auth URL',
failedToExchangeCode: 'Failed to exchange OpenAI auth code',
failedToValidateRT: 'Failed to validate refresh token',
errors: {
OPENAI_OAUTH_PROXY_REQUIRED:
'No proxy is configured and this server could not reach OpenAI directly, so the OpenAI OAuth request failed. Select a proxy that can access OpenAI and retry; if the authorization code has expired, regenerate the authorization URL.'
},
// Refresh Token auth
refreshTokenAuth: 'Manual RT Input',
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',

View File

@@ -2911,6 +2911,13 @@ export default {
authCodePlaceholder:
'方式1复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2仅复制 code 参数的值',
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
failedToGenerateUrl: '生成 OpenAI 授权链接失败',
failedToExchangeCode: 'OpenAI 授权码兑换失败',
failedToValidateRT: '验证 Refresh Token 失败',
errors: {
OPENAI_OAUTH_PROXY_REQUIRED:
'未设置代理,当前服务器无法直连 OpenAI导致 OpenAI OAuth 请求失败。请先选择可访问 OpenAI 的代理后重试;如果授权码已失效,请重新生成授权链接。'
},
// Refresh Token auth
refreshTokenAuth: '手动输入 RT',
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token支持批量输入每行一个系统将自动验证并创建账号。',