fix: clarify OpenAI OAuth proxy errors
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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 的代理后重试;如果授权码已失效,请重新生成授权链接。'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||
|
||||
Reference in New Issue
Block a user