diff --git a/PR_DESCRIPTION_CN.md b/PR_DESCRIPTION_CN.md new file mode 100644 index 000000000000..b896f20f1ca9 --- /dev/null +++ b/PR_DESCRIPTION_CN.md @@ -0,0 +1,85 @@ +# fix: 模型渠道配置体验改进 — 无 AI Proxy 时的替代方案与直连 API 测试 + +关联: #6525 +分支: `fix/issue-6525-aiproxy-channel-stability`(提交 `a20ec2e47`) + +--- + +## 背景 + +自部署用户在不使用或未正确配置 **AI Proxy** 时,容易在渠道管理页遇到 **fetch failed**,或在模型测试中因 **base_url / 路径** 填写不当出现 **404**。本 PR 在错误提示、API 测试路径与文档上补齐「可操作的」指引。 + +--- + +## 变更摘要 + +### 1. 用户友好的替代配置指引 + +- 当 **AI Proxy 不可用或未配置** 时,返回**结构化错误信息**,明确提示可改用 **自定义请求地址**(`requestUrl` / `requestAuth`)直连厂商或自建网关。 +- 对网络类错误补充**可诊断**的说明(例如 Docker 内 `localhost` 指向容器自身、需改用服务名或可访问主机等)。 + +### 2. 直连 API 配置与模型测试 + +- 更新 **模型测试 API**(`projects/app/src/pages/api/core/ai/model/test.ts`):支持在模型配置中填写 **自定义 `requestUrl` / `requestAuth`** 进行连通性测试,**不强制依赖 AI Proxy**。 +- 厘清 **渠道路由** 与 **自定义请求 URL** 的适用场景:需要走统一代理/审计时用渠道 + Proxy;仅需直连官方或兼容 OpenAI 的地址时,用自定义 URL 即可。 + +### 3. 界面提示 + +- 在 **渠道管理** 相关界面(`Channel/index.tsx`)增加 **警告横幅**:当 AI Proxy 不可用或请求失败时,给出醒目提示,减少「空白失败」带来的困惑。 + +### 4. 文档与模板 + +- **中英文**文档(`document/content/docs/self-host/config/model/intro.mdx`、`intro.en.mdx`):说明如何**在不配置 AI Proxy** 的情况下配置模型与官方 API。 +- 更新 **`projects/app/.env.template`**:对 `AIPROXY_*` 等变量补充注释与可选性说明。 +- 提供 **官方 API + 自定义请求 URL** 的配置示例,便于对照填写。 + +### 5. 其他(与 #6525 修复一致) + +- **`[...path].ts`**:AI Proxy 代理层错误处理增强。 +- **`channel.ts`**:`normalizeChannelBaseUrl()` 等,避免误填完整 `.../chat/completions` 路径导致 404。 + +--- + +## 涉及文件 + +| 文件 | 说明 | +|------|------| +| `projects/app/src/pages/api/aiproxy/[...path].ts` | AI Proxy 代理错误处理 | +| `projects/app/src/pages/api/core/ai/model/test.ts` | 自定义 URL 模型测试 | +| `projects/app/src/pageComponents/account/model/Channel/index.tsx` | 渠道页警告 UI | +| `projects/app/src/web/core/ai/channel.ts` | base_url 规范化等 | +| `projects/app/.env.template` | 环境变量说明 | +| `document/content/docs/self-host/config/model/intro.mdx` | 中文文档 | +| `document/content/docs/self-host/config/model/intro.en.mdx` | 英文文档 | + +--- + +## 如何测试 + +### 方式 A:不使用 AI Proxy(替代路径) + +1. **不设置**或暂时注释 `AIPROXY_API_ENDPOINT` / `AIPROXY_API_TOKEN`(按你部署方式为准)。 +2. 在模型配置中填写 **`requestUrl`**(如 `https://api.openai.com/v1`)与 **`requestAuth`**(API Key)。 +3. 打开 **模型测试**,应能直接验证连通性,**无需** AI Proxy。 + +### 方式 B:使用 AI Proxy + +1. 在 `.env` 中配置例如: + ```env + AIPROXY_API_ENDPOINT=http://your-aiproxy-host:3010 + AIPROXY_API_TOKEN=your-token + ``` +2. 进入 **渠道管理** 等依赖代理的功能,应能正常加载;若代理宕机,应看到**明确错误与横幅提示**,而非仅「fetch failed」。 + +### 回归建议 + +- [ ] 未配置 AI Proxy 时进入渠道页:有清晰错误/指引,无未处理异常。 +- [ ] 使用官方兼容地址 + 自定义 `requestUrl`:模型测试成功。 +- [ ] `base_url` 误带 `/chat/completions` 等后缀:保存/请求前被规范化,避免 404。 + +--- + +## 合并说明(供维护者) + +- **不依赖** GitHub/GitLab 上再开 PR 时,可将本文件作为描述粘贴;本地仅作备份与评审用。 +- 若远程使用 **私有仓库**,将本分支推送至 `origin` 后,在内部按流程评审合并即可。 diff --git a/document/content/docs/self-host/config/model/intro.en.mdx b/document/content/docs/self-host/config/model/intro.en.mdx index 38d72ce47dee..0b579c693527 100644 --- a/document/content/docs/self-host/config/model/intro.en.mdx +++ b/document/content/docs/self-host/config/model/intro.en.mdx @@ -255,6 +255,48 @@ If you find it tedious to configure models through the UI, you can use a configu +## Configuration Without AI Proxy + +If you don't want to deploy the AI Proxy service, you can configure models to use official APIs directly by setting custom request URLs. This approach is suitable for simple scenarios but lacks load balancing, logging, and other features. + +### Configuration Steps + +1. Navigate to the `Model Configuration` page and click "Add Model" +2. Fill in the basic model information (Model ID, Name, etc.) +3. In the `Custom Request URL` field, enter the complete API endpoint +4. In the `Custom Request Key` field, enter your API Key + + +Important Notes: + +1. The custom request URL must be a **complete endpoint URL**, not just the BaseUrl. For example: + - LLM: `https://api.openai.com/v1/chat/completions` + - Embedding: `https://api.openai.com/v1/embeddings` + +2. If both an AI Proxy channel and a custom request URL are configured, the system will prioritize the custom request URL. + +3. When using custom request URLs, the model test function works directly without requiring the AI Proxy service. + + +### Example: Direct OpenAI API Usage + +```json +{ + "model": "gpt-4o-custom", + "metadata": { + "provider": "OpenAI", + "model": "gpt-4o", + "name": "GPT-4o (Direct)", + "requestUrl": "https://api.openai.com/v1/chat/completions", + "requestAuth": "sk-xxxxx", + "maxContext": 128000, + "maxResponse": 4096, + "isActive": true, + "isCustom": true + } +} +``` + ## Other ### Channel Priority diff --git a/document/content/docs/self-host/config/model/intro.mdx b/document/content/docs/self-host/config/model/intro.mdx index cfc59901e98b..0717d44232c2 100644 --- a/document/content/docs/self-host/config/model/intro.mdx +++ b/document/content/docs/self-host/config/model/intro.mdx @@ -255,6 +255,48 @@ FastGPT 页面上提供了每类模型的简单测试,可以初步检查模型 +## 不使用 AI Proxy 的配置方法 + +如果你不想部署 AI Proxy 服务,也可以通过配置模型的自定义请求地址来直接使用官方 API。这种方式适合简单场景,但缺少负载均衡、日志等功能。 + +### 配置步骤 + +1. 在`模型配置`页面中,点击新增模型 +2. 填写模型基本信息(模型 ID、名称等) +3. 在`自定义请求地址`字段填写完整的 API 地址 +4. 在`自定义请求 Key` 字段填写 API Key + + +注意事项: + +1. 自定义请求地址需要填写**完整的请求地址**,不是 BaseUrl。例如: + - LLM: `https://api.openai.com/v1/chat/completions` + - Embedding: `https://api.openai.com/v1/embeddings` + +2. 如果同时配置了 AI Proxy 渠道和自定义请求地址,系统会优先使用自定义请求地址。 + +3. 使用自定义请求地址时,模型测试功能可以直接使用,不需要 AI Proxy 服务。 + + +### 示例:直接使用 OpenAI API + +```json +{ + "model": "gpt-4o-custom", + "metadata": { + "provider": "OpenAI", + "model": "gpt-4o", + "name": "GPT-4o (Direct)", + "requestUrl": "https://api.openai.com/v1/chat/completions", + "requestAuth": "sk-xxxxx", + "maxContext": 128000, + "maxResponse": 4096, + "isActive": true, + "isCustom": true + } +} +``` + ## 其他 ### 渠道优先级 diff --git a/projects/app/.env.template b/projects/app/.env.template index d9ce1c5222b7..188ecf97c1ba 100644 --- a/projects/app/.env.template +++ b/projects/app/.env.template @@ -31,8 +31,11 @@ PLUGIN_TOKEN=token CODE_SANDBOX_URL=http://localhost:3002 CODE_SANDBOX_TOKEN=codesandbox -# AI Proxy API -AIPROXY_API_ENDPOINT=https://localhost:3010 +# AI Proxy API - Optional +# Configure these if you want to use the channel management feature for load balancing, logging, etc. +# If not configured, you can still use models by setting custom request URLs in the model configuration. +# See: https://doc.fastgpt.io/docs/self-host/config/model/intro/#configuration-without-ai-proxy +AIPROXY_API_ENDPOINT=http://localhost:3010 AIPROXY_API_TOKEN=token # Agent sandbox diff --git a/projects/app/src/pageComponents/account/model/Channel/index.tsx b/projects/app/src/pageComponents/account/model/Channel/index.tsx index e63fe5d58aad..615b66cb6416 100644 --- a/projects/app/src/pageComponents/account/model/Channel/index.tsx +++ b/projects/app/src/pageComponents/account/model/Channel/index.tsx @@ -18,7 +18,10 @@ import { Box, Flex, Button, - HStack + HStack, + Alert, + AlertIcon, + AlertDescription } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; import MyBox from '@fastgpt/web/components/common/MyBox'; @@ -53,7 +56,14 @@ const ChannelTable = ({ Tab }: { Tab: React.ReactNode }) => { runAsync: refreshChannelList, loading: loadingChannelList } = useRequest(getChannelList, { - manual: false + manual: false, + onError: (error: any) => { + // Handle AI Proxy configuration errors with user-friendly messages + const errorMsg = error?.message || error; + if (errorMsg.includes('AI Proxy') || errorMsg.includes('AIPROXY')) { + setAiproxyError(errorMsg); + } + } }); const { data: channelProviders = {} } = useRequest(getChannelProviders, { @@ -88,6 +98,7 @@ const ChannelTable = ({ Tab }: { Tab: React.ReactNode }) => { }); const [modelTestData, setTestModelData] = useState<{ channelId: number; models: string[] }>(); + const [aiproxyError, setAiproxyError] = useState(); const isLoading = loadingChannelList || @@ -106,6 +117,12 @@ const ChannelTable = ({ Tab }: { Tab: React.ReactNode }) => { )} + {aiproxyError && ( + + + {aiproxyError} + + )} diff --git a/projects/app/src/pages/api/aiproxy/[...path].ts b/projects/app/src/pages/api/aiproxy/[...path].ts index fb17106b6f76..0e5796075e05 100644 --- a/projects/app/src/pages/api/aiproxy/[...path].ts +++ b/projects/app/src/pages/api/aiproxy/[...path].ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; -import { jsonRes } from '@fastgpt/service/common/response'; import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; import { Readable } from 'stream'; +import { getErrText } from '@fastgpt/global/common/error/utils'; const baseUrl = process.env.AIPROXY_API_ENDPOINT; const token = process.env.AIPROXY_API_TOKEN; @@ -11,12 +11,42 @@ const endPathMap: Record = { 'api/dashboardv2': true }; +const normalizeEndpoint = (endpoint: string) => endpoint.trim().replace(/\/+$/, ''); + +const formatAiproxyError = (error: unknown, endpoint?: string) => { + const errorText = getErrText(error); + const detail = endpoint ? ` (AIPROXY_API_ENDPOINT=${endpoint})` : ''; + const tips: string[] = []; + + if (endpoint) { + try { + const url = new URL(endpoint); + const hostname = url.hostname.toLowerCase(); + const isLocalHost = ['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname); + + if (isLocalHost && url.protocol === 'https:') { + tips.push( + 'If AI Proxy is local and not behind TLS, use http:// instead of https:// for AIPROXY_API_ENDPOINT.' + ); + } + if (isLocalHost) { + tips.push( + 'If FastGPT runs in Docker, localhost points to the FastGPT container itself. Use a container service name or a reachable host IP.' + ); + } + } catch {} + } + + return `AI Proxy request failed${detail}: ${errorText}${tips.length > 0 ? ` ${tips.join(' ')}` : ''}`; +}; + export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { await authSystemAdmin({ req }); if (!baseUrl || !token) { - throw new Error('AIPROXY_API_ENDPOINT or AIPROXY_API_TOKEN is not set'); + const errorMsg = 'AI Proxy service is not configured. Please set AIPROXY_API_ENDPOINT and AIPROXY_API_TOKEN environment variables to use channel management features. Alternatively, you can configure models with custom request URLs in the model configuration section.'; + throw new Error(errorMsg); } const { path = [], ...query } = req.query as any; @@ -29,8 +59,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Determine whether the base path requires a trailing slash. const basePath = `/${path?.join('/')}${endPathMap[path?.join('/')] ? '/' : ''}`; const requestPath = queryStr ? `${basePath}?${queryStr}` : basePath; - - const targetUrl = new URL(requestPath, baseUrl); + const targetUrl = new URL(requestPath, `${normalizeEndpoint(baseUrl)}/`); const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { @@ -66,9 +95,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.end(); } } catch (error) { - jsonRes(res, { - code: 500, - error + res.status(500).json({ + success: false, + message: formatAiproxyError(error, baseUrl), + data: null }); } } diff --git a/projects/app/src/pages/api/core/ai/model/test.ts b/projects/app/src/pages/api/core/ai/model/test.ts index 53dc4128cfc8..bff1b2ed47bb 100644 --- a/projects/app/src/pages/api/core/ai/model/test.ts +++ b/projects/app/src/pages/api/core/ai/model/test.ts @@ -36,9 +36,20 @@ async function handler( if (!modelData) return Promise.reject('Model not found'); + // If channelId is provided, use channel-based routing (AI Proxy) + // Otherwise, use custom requestUrl/requestAuth if available if (channelId) { + // When using AI Proxy channel, remove custom URL to force channel routing delete modelData.requestUrl; delete modelData.requestAuth; + } else { + // When not using channel, check if model has custom request configuration + // This allows direct API testing without AI Proxy + const hasCustomRequest = modelData.requestUrl && modelData.requestAuth; + if (!hasCustomRequest) { + // If no custom request is configured, will use default AI API configuration + logger.debug('Testing model without custom request URL, will use default AI API configuration'); + } } const headers: Record = channelId diff --git a/projects/app/src/web/core/ai/channel.ts b/projects/app/src/web/core/ai/channel.ts index 5927759408fe..ed3198311e83 100644 --- a/projects/app/src/web/core/ai/channel.ts +++ b/projects/app/src/web/core/ai/channel.ts @@ -16,6 +16,47 @@ interface ResponseDataType { data: any; } +const channelApiEndpointSuffixList = [ + '/chat/completions', + '/embeddings', + '/audio/transcriptions', + '/audio/speech', + '/rerank', + '/responses' +]; + +const trimTrailingSlash = (path: string) => { + if (path === '/') return path; + return path.replace(/\/+$/, '') || '/'; +}; + +const stripApiEndpointSuffix = (pathname: string) => { + const matchedSuffix = channelApiEndpointSuffixList.find((suffix) => pathname.endsWith(suffix)); + if (!matchedSuffix) return pathname; + + const stripped = pathname.slice(0, -matchedSuffix.length) || '/'; + return trimTrailingSlash(stripped); +}; + +export const normalizeChannelBaseUrl = (baseUrl?: string) => { + const formatBaseUrl = baseUrl?.trim(); + if (!formatBaseUrl) return ''; + + try { + const url = new URL(formatBaseUrl); + const formatPathname = trimTrailingSlash(url.pathname || '/'); + const normalizedPathname = stripApiEndpointSuffix(formatPathname); + + url.pathname = normalizedPathname === '/' ? '' : normalizedPathname; + url.search = ''; + url.hash = ''; + + return url.toString().replace(/\/$/, ''); + } catch { + return formatBaseUrl; + } +}; + /** * 请求成功,检查请求头 */ @@ -137,7 +178,7 @@ export const postCreateChannel = (data: CreateChannelProps) => POST(`/createChannel`, { type: data.type, name: data.name, - base_url: data.base_url, + base_url: normalizeChannelBaseUrl(data.base_url), models: data.models, model_mapping: data.model_mapping, key: data.key, @@ -152,7 +193,7 @@ export const putChannel = (data: ChannelInfoType) => PUT(`/channel/${data.id}`, { type: data.type, name: data.name, - base_url: data.base_url, + base_url: normalizeChannelBaseUrl(data.base_url), models: data.models, model_mapping: data.model_mapping, key: data.key,