Compare commits

..

6 Commits

Author SHA1 Message Date
github-actions[bot]
e17cd1d61b chore: sync VERSION file with release v1.1.216 [skip ci] 2025-11-30 13:13:59 +00:00
Wesley Liddick
b9d53647bd Merge pull request #727 from xilu0/main
fix: 修复 Claude API 400 错误:tool_result/tool_use 不匹配问题
2025-11-30 08:13:43 -05:00
github-actions[bot]
a872529b2e chore: sync VERSION file with release v1.1.215 [skip ci] 2025-11-29 13:31:00 +00:00
shaw
dfee7be944 fix: 调整gemini-api BaseApi后缀以适配更多端点 2025-11-29 21:30:28 +08:00
github-actions[bot]
392601efd5 chore: sync VERSION file with release v1.1.215 [skip ci] 2025-11-29 09:51:09 +00:00
Dave
249e256360 fix: 修复 Claude API 400 错误:tool_result/tool_use 不匹配问题
错误信息:
     messages.14.content.0: unexpected tool_use_id found in tool_result blocks: toolu_01Ekn6YJMk7yt7hNcn4PZxtM.
     Each tool_result block must have a corresponding tool_use block in the previous message.
根本原因:
     文件: src/services/claudeRelayService.js 中的 _enforceCacheControlLimit() 方法
原实现问题:
     1. 当 cache_control 块超过 4 个时,直接删除整个内容块(splice)
     2. 这会删除 tool_use 块,导致后续的 tool_result 找不到对应的 tool_use_id
     3. 也会删除用户的文本消息,导致上下文丢失
重要背景(官方文档确认)
     根据 Claude API 官方文档:
     - 最多可定义 4 个 cache_control 断点
     - 如果超过限制,API 不会报错,只是静默地忽略多余的断点
     - "20 个块回溯窗口" 是缓存命中检查的范围,与断点数量限制无关
     因此,这个函数的原始设计(删除内容块)是不必要且有害的。
修复方案:
     保留函数但修改行为:只删除 cache_control 属性,保留内容本身
修改位置;
     文件: src/services/claudeRelayService.js
修改内容:
     将 removeFromMessages() 和 removeFromSystem() 函数从"删除整个内容块"改为"只删除 cache_control 属性":
     // 修改前:直接删除整个内容块
     message.content.splice(contentIndex, 1)
     // 修改后:只删除 cache_control 属性,保留内容
     delete contentItem.cache_control
效果对比;
     | 场景         | 修复前            | 修复后            |
     |------------|----------------|----------------|
     | 用户文本消息     |  整个消息被删除      |  保留消息,只移除缓存标记 |
     | tool_use 块 |  被删除导致 400 错误 |  保留完整内容       |
     | system 提示词 |  整个提示词被删除     |  保留提示词内容      |
     | 缓存功能       | ⚠️ 强制限制        |  降级(不缓存但内容完整) |
2025-11-29 17:50:45 +08:00
5 changed files with 133 additions and 38 deletions

View File

@@ -1 +1 @@
1.1.214 1.1.216

View File

@@ -22,6 +22,54 @@ const ProxyHelper = require('../utils/proxyHelper')
// 工具函数 // 工具函数
// ============================================================================ // ============================================================================
/**
* 构建 Gemini API URL
* 兼容新旧 baseUrl 格式:
* - 新格式(以 /models 结尾): https://xxx.com/v1beta/models -> 直接拼接 /{model}:action
* - 旧格式(不以 /models 结尾): https://xxx.com -> 拼接 /v1beta/models/{model}:action
*
* @param {string} baseUrl - 账户配置的基础地址
* @param {string} model - 模型名称
* @param {string} action - API 动作 (generateContent, streamGenerateContent, countTokens)
* @param {string} apiKey - API Key
* @param {object} options - 额外选项 { stream: boolean, listModels: boolean }
* @returns {string} 完整的 API URL
*/
function buildGeminiApiUrl(baseUrl, model, action, apiKey, options = {}) {
const { stream = false, listModels = false } = options
// 移除末尾的斜杠(如果有)
const normalizedBaseUrl = baseUrl.replace(/\/+$/, '')
// 检查是否为新格式(以 /models 结尾)
const isNewFormat = normalizedBaseUrl.endsWith('/models')
let url
if (listModels) {
// 获取模型列表
if (isNewFormat) {
// 新格式: baseUrl 已包含 /v1beta/models直接添加查询参数
url = `${normalizedBaseUrl}?key=${apiKey}`
} else {
// 旧格式: 需要拼接 /v1beta/models
url = `${normalizedBaseUrl}/v1beta/models?key=${apiKey}`
}
} else {
// 模型操作 (generateContent, streamGenerateContent, countTokens)
const streamParam = stream ? '&alt=sse' : ''
if (isNewFormat) {
// 新格式: baseUrl 已包含 /v1beta/models直接拼接 /{model}:action
url = `${normalizedBaseUrl}/${model}:${action}?key=${apiKey}${streamParam}`
} else {
// 旧格式: 需要拼接 /v1beta/models/{model}:action
url = `${normalizedBaseUrl}/v1beta/models/${model}:${action}?key=${apiKey}${streamParam}`
}
}
return url
}
/** /**
* 生成会话哈希 * 生成会话哈希
*/ */
@@ -378,9 +426,13 @@ async function handleMessages(req, res) {
// 解析代理配置 // 解析代理配置
const proxyConfig = parseProxyConfig(account) const proxyConfig = parseProxyConfig(account)
const apiUrl = stream const apiUrl = buildGeminiApiUrl(
? `${account.baseUrl}/v1beta/models/${model}:streamGenerateContent?key=${account.apiKey}&alt=sse` account.baseUrl,
: `${account.baseUrl}/v1beta/models/${model}:generateContent?key=${account.apiKey}` model,
stream ? 'streamGenerateContent' : 'generateContent',
account.apiKey,
{ stream }
)
const axiosConfig = { const axiosConfig = {
method: 'POST', method: 'POST',
@@ -671,7 +723,9 @@ async function handleModels(req, res) {
// API Key 账户:使用 API Key 获取模型列表 // API Key 账户:使用 API Key 获取模型列表
const proxyConfig = parseProxyConfig(account) const proxyConfig = parseProxyConfig(account)
try { try {
const apiUrl = `${account.baseUrl}/v1beta/models?key=${account.apiKey}` const apiUrl = buildGeminiApiUrl(account.baseUrl, null, null, account.apiKey, {
listModels: true
})
const axiosConfig = { const axiosConfig = {
method: 'GET', method: 'GET',
url: apiUrl, url: apiUrl,
@@ -1169,8 +1223,8 @@ async function handleCountTokens(req, res) {
let response let response
if (isApiAccount) { if (isApiAccount) {
// API Key 账户:直接使用 API Key 请求 // API Key 账户:直接使用 API Key 请求
const modelPath = model.startsWith('models/') ? model : `models/${model}` const modelName = model.startsWith('models/') ? model.replace('models/', '') : model
const apiUrl = `${account.baseUrl}/v1beta/${modelPath}:countTokens?key=${account.apiKey}` const apiUrl = buildGeminiApiUrl(account.baseUrl, modelName, 'countTokens', account.apiKey)
const axiosConfig = { const axiosConfig = {
method: 'POST', method: 'POST',
@@ -1897,7 +1951,7 @@ async function handleStandardGenerateContent(req, res) {
if (isApiAccount) { if (isApiAccount) {
// Gemini API 账户:直接使用 API Key 请求 // Gemini API 账户:直接使用 API Key 请求
const apiUrl = `${account.baseUrl}/v1beta/models/${model}:generateContent?key=${account.apiKey}` const apiUrl = buildGeminiApiUrl(account.baseUrl, model, 'generateContent', account.apiKey)
const axiosConfig = { const axiosConfig = {
method: 'POST', method: 'POST',
@@ -2168,7 +2222,15 @@ async function handleStandardStreamGenerateContent(req, res) {
if (isApiAccount) { if (isApiAccount) {
// Gemini API 账户:直接使用 API Key 请求流式接口 // Gemini API 账户:直接使用 API Key 请求流式接口
const apiUrl = `${account.baseUrl}/v1beta/models/${model}:streamGenerateContent?key=${account.apiKey}&alt=sse` const apiUrl = buildGeminiApiUrl(
account.baseUrl,
model,
'streamGenerateContent',
account.apiKey,
{
stream: true
}
)
const axiosConfig = { const axiosConfig = {
method: 'POST', method: 'POST',

View File

@@ -789,7 +789,8 @@ class ClaudeRelayService {
return total return total
} }
const removeFromMessages = () => { // 只移除 cache_control 属性,保留内容本身,避免丢失用户消息
const removeCacheControlFromMessages = () => {
if (!Array.isArray(body.messages)) { if (!Array.isArray(body.messages)) {
return false return false
} }
@@ -803,12 +804,8 @@ class ClaudeRelayService {
for (let contentIndex = 0; contentIndex < message.content.length; contentIndex += 1) { for (let contentIndex = 0; contentIndex < message.content.length; contentIndex += 1) {
const contentItem = message.content[contentIndex] const contentItem = message.content[contentIndex]
if (contentItem && contentItem.cache_control) { if (contentItem && contentItem.cache_control) {
message.content.splice(contentIndex, 1) // 只删除 cache_control 属性,保留内容
delete contentItem.cache_control
if (message.content.length === 0) {
body.messages.splice(messageIndex, 1)
}
return true return true
} }
} }
@@ -817,7 +814,8 @@ class ClaudeRelayService {
return false return false
} }
const removeFromSystem = () => { // 只移除 cache_control 属性,保留 system 内容
const removeCacheControlFromSystem = () => {
if (!Array.isArray(body.system)) { if (!Array.isArray(body.system)) {
return false return false
} }
@@ -825,12 +823,8 @@ class ClaudeRelayService {
for (let index = 0; index < body.system.length; index += 1) { for (let index = 0; index < body.system.length; index += 1) {
const systemItem = body.system[index] const systemItem = body.system[index]
if (systemItem && systemItem.cache_control) { if (systemItem && systemItem.cache_control) {
body.system.splice(index, 1) // 只删除 cache_control 属性,保留内容
delete systemItem.cache_control
if (body.system.length === 0) {
delete body.system
}
return true return true
} }
} }
@@ -841,12 +835,13 @@ class ClaudeRelayService {
let total = countCacheControlBlocks() let total = countCacheControlBlocks()
while (total > MAX_CACHE_CONTROL_BLOCKS) { while (total > MAX_CACHE_CONTROL_BLOCKS) {
if (removeFromMessages()) { // 优先从 messages 中移除 cache_control再从 system 中移除
if (removeCacheControlFromMessages()) {
total -= 1 total -= 1
continue continue
} }
if (removeFromSystem()) { if (removeCacheControlFromSystem()) {
total -= 1 total -= 1
continue continue
} }

View File

@@ -1524,24 +1524,32 @@
<input <input
v-model="form.baseUrl" v-model="form.baseUrl"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="https://generativelanguage.googleapis.com" :class="{ 'border-red-500 dark:border-red-400': errors.baseUrl }"
placeholder="https://generativelanguage.googleapis.com/v1beta/models"
required required
type="url" type="url"
/> />
<p v-if="errors.baseUrl" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.baseUrl }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
填写 API 基础地址(可包含路径前缀),系统会自动拼接 填写 API 基础地址,必须以
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600">/models</code>
结尾。系统会自动拼接
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>/v1beta/models/{model}:generateContent</code >/{model}:generateContent</code
> >
</p> </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
官方: 官方:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://generativelanguage.googleapis.com</code >https://generativelanguage.googleapis.com/v1beta/models</code
> >
| 上游为 CRS: </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
上游为 CRS:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://your-crs.com/gemini</code >https://your-crs.com/gemini/v1beta/models</code
> >
</p> </p>
</div> </div>
@@ -3025,23 +3033,31 @@
<input <input
v-model="form.baseUrl" v-model="form.baseUrl"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
placeholder="https://generativelanguage.googleapis.com" :class="{ 'border-red-500 dark:border-red-400': errors.baseUrl }"
placeholder="https://generativelanguage.googleapis.com/v1beta/models"
type="url" type="url"
/> />
<p v-if="errors.baseUrl" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.baseUrl }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
填写 API 基础地址(可包含路径前缀),系统会自动拼接 填写 API 基础地址,必须以
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600">/models</code>
结尾。系统会自动拼接
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>/v1beta/models/{model}:generateContent</code >/{model}:generateContent</code
> >
</p> </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
官方: 官方:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://generativelanguage.googleapis.com</code >https://generativelanguage.googleapis.com/v1beta/models</code
> >
| 上游为 CRS: </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
上游为 CRS:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://your-crs.com/gemini</code >https://your-crs.com/gemini/v1beta/models</code
> >
</p> </p>
</div> </div>
@@ -4485,6 +4501,14 @@ const createAccount = async () => {
errors.value.apiKey = '请填写 API Key' errors.value.apiKey = '请填写 API Key'
hasError = true hasError = true
} }
// 验证 baseUrl 必须以 /models 结尾
if (!form.value.baseUrl || form.value.baseUrl.trim() === '') {
errors.value.baseUrl = '请填写 API 基础地址'
hasError = true
} else if (!form.value.baseUrl.trim().endsWith('/models')) {
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
hasError = true
}
} else { } else {
// 其他平台(如 Droid使用多 API Key 输入 // 其他平台(如 Droid使用多 API Key 输入
const apiKeys = parseApiKeysInput(form.value.apiKeysInput) const apiKeys = parseApiKeysInput(form.value.apiKeysInput)
@@ -4748,6 +4772,7 @@ const updateAccount = async () => {
// 清除之前的错误 // 清除之前的错误
errors.value.name = '' errors.value.name = ''
errors.value.apiKeys = '' errors.value.apiKeys = ''
errors.value.baseUrl = ''
// 验证账户名称 // 验证账户名称
if (!form.value.name || form.value.name.trim() === '') { if (!form.value.name || form.value.name.trim() === '') {
@@ -4755,6 +4780,19 @@ const updateAccount = async () => {
return return
} }
// Gemini API 的 baseUrl 验证(必须以 /models 结尾)
if (form.value.platform === 'gemini-api') {
const baseUrl = form.value.baseUrl?.trim() || ''
if (!baseUrl) {
errors.value.baseUrl = '请填写 API 基础地址'
return
}
if (!baseUrl.endsWith('/models')) {
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
return
}
}
// 分组类型验证 - 更新账户流程修复 // 分组类型验证 - 更新账户流程修复
if ( if (
form.value.accountType === 'group' && form.value.accountType === 'group' &&

View File

@@ -229,7 +229,7 @@
<i v-else class="fas fa-sort ml-1 text-gray-400" /> <i v-else class="fas fa-sort ml-1 text-gray-400" />
</th> </th>
<th <th
class="w-[100px] min-w-[120px] max-w-[150px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600" class="w-[120px] min-w-[180px] max-w-[20s0px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('status')" @click="sortAccounts('status')"
> >
状态 状态