feat(channel-monitor): preserve upstream error body
Monitor:
- callProvider now returns both textPath-extracted text and raw body;
runCheckForModel uses rawBody on non-2xx so history.message stops being
"upstream HTTP 503: " with empty body (gjson textPath produces "" for
error responses like {"error":{"message":"No available accounts..."}})
- truncateForErrorBody collapses whitespace then caps at 300 bytes
(monitorErrorBodySnippetMaxBytes); final truncateMessage still enforces
the 500-byte DB column cap
Frontend:
- MonitorFormDialog: primary_model input text color and ModelTagInput tags
now both track form.provider (via new getPlatformTextClass + existing
getPlatformTagClass with platform prop).
(cherry-picked from 1d3b0418; dropped gateway_handler logging改动,不在本 PR 范围)
This commit is contained in:
@@ -49,7 +49,7 @@ func runCheckForModel(ctx context.Context, provider, endpoint, apiKey, model str
|
|||||||
challenge := generateChallenge()
|
challenge := generateChallenge()
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
respText, statusCode, err := callProvider(ctx, provider, endpoint, apiKey, model, challenge.Prompt)
|
respText, rawBody, statusCode, err := callProvider(ctx, provider, endpoint, apiKey, model, challenge.Prompt)
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
latencyMs := int(latency / time.Millisecond)
|
latencyMs := int(latency / time.Millisecond)
|
||||||
res.LatencyMs = &latencyMs
|
res.LatencyMs = &latencyMs
|
||||||
@@ -60,8 +60,11 @@ func runCheckForModel(ctx context.Context, provider, endpoint, apiKey, model str
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
if statusCode < 200 || statusCode >= 300 {
|
if statusCode < 200 || statusCode >= 300 {
|
||||||
|
// 错误路径:用 rawBody 而非 respText(gjson textPath 抽取在错误响应里通常为空,
|
||||||
|
// 会丢掉真正的上游错误信息,例如 `{"error":{"message":"No available accounts ..."}}`)。
|
||||||
res.Status = MonitorStatusError
|
res.Status = MonitorStatusError
|
||||||
res.Message = truncateMessage(sanitizeErrorMessage(fmt.Sprintf("upstream HTTP %d: %s", statusCode, respText)))
|
bodySnippet := truncateForErrorBody(rawBody)
|
||||||
|
res.Message = truncateMessage(sanitizeErrorMessage(fmt.Sprintf("upstream HTTP %d: %s", statusCode, bodySnippet)))
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,22 +183,27 @@ func isSupportedProvider(p string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// callProvider 通过 providerAdapters 分发到具体实现。
|
// callProvider 通过 providerAdapters 分发到具体实现。
|
||||||
// 返回值:响应中提取的文本、HTTP status、网络/序列化错误。
|
//
|
||||||
func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt string) (string, int, error) {
|
// 返回值:
|
||||||
|
// - extractedText: 按 textPath 抽出的成功文本,仅在 status 2xx 时有意义;非 2xx 时通常为空串
|
||||||
|
// - rawBody: 完整响应体的字符串形式(已被 monitorResponseMaxBytes 截断),用于错误路径保留上游真实回包
|
||||||
|
// - status: HTTP 状态码
|
||||||
|
// - err: 网络 / 序列化错误
|
||||||
|
func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt string) (extractedText, rawBody string, status int, err error) {
|
||||||
adapter, ok := providerAdapters[provider]
|
adapter, ok := providerAdapters[provider]
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", 0, fmt.Errorf("unsupported provider %q", provider)
|
return "", "", 0, fmt.Errorf("unsupported provider %q", provider)
|
||||||
}
|
}
|
||||||
body, err := adapter.buildBody(model, prompt)
|
body, err := adapter.buildBody(model, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, fmt.Errorf("marshal body: %w", err)
|
return "", "", 0, fmt.Errorf("marshal body: %w", err)
|
||||||
}
|
}
|
||||||
full := joinURL(endpoint, adapter.buildPath(model))
|
full := joinURL(endpoint, adapter.buildPath(model))
|
||||||
respBody, status, err := postRawJSON(ctx, full, body, adapter.buildHeaders(apiKey))
|
respBytes, status, err := postRawJSON(ctx, full, body, adapter.buildHeaders(apiKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", status, err
|
return "", "", status, err
|
||||||
}
|
}
|
||||||
return gjson.GetBytes(respBody, adapter.textPath).String(), status, nil
|
return gjson.GetBytes(respBytes, adapter.textPath).String(), string(respBytes), status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// postRawJSON 发送 POST + 已序列化好的 JSON 字节,限制响应体大小,返回响应字节、HTTP status、错误。
|
// postRawJSON 发送 POST + 已序列化好的 JSON 字节,限制响应体大小,返回响应字节、HTTP status、错误。
|
||||||
@@ -297,3 +305,19 @@ func truncateMessage(msg string) string {
|
|||||||
}
|
}
|
||||||
return msg[:cutoff] + ellipsis
|
return msg[:cutoff] + ellipsis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// truncateForErrorBody 把上游错误响应 body 压到 monitorErrorBodySnippetMaxBytes 以内,
|
||||||
|
// 并顺手把连续空白折成一个空格:上游 HTML 错误页常含大量缩进/换行,保留会浪费预算。
|
||||||
|
// 被 truncateMessage 做最终总截断兜底,所以这里只负责 body 自身的精简。
|
||||||
|
func truncateForErrorBody(body string) string {
|
||||||
|
body = strings.Join(strings.Fields(body), " ")
|
||||||
|
if len(body) <= monitorErrorBodySnippetMaxBytes {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
const ellipsis = "...(body truncated)"
|
||||||
|
cutoff := monitorErrorBodySnippetMaxBytes - len(ellipsis)
|
||||||
|
if cutoff < 0 {
|
||||||
|
cutoff = 0
|
||||||
|
}
|
||||||
|
return body[:cutoff] + ellipsis
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const (
|
|||||||
monitorMessageMaxBytes = 500
|
monitorMessageMaxBytes = 500
|
||||||
// monitorResponseMaxBytes 单次模型响应最大读取字节,防止 OOM。
|
// monitorResponseMaxBytes 单次模型响应最大读取字节,防止 OOM。
|
||||||
monitorResponseMaxBytes = 64 * 1024
|
monitorResponseMaxBytes = 64 * 1024
|
||||||
|
// monitorErrorBodySnippetMaxBytes 非 2xx 响应时保留上游 body 片段的最大字节数。
|
||||||
|
// 留 300 字节足够覆盖典型结构化错误(如 `{"error":{"message":"..."}}`),
|
||||||
|
// 又给 "upstream HTTP <status>: " 前缀留出余量,避免最终被 monitorMessageMaxBytes (500) 截得太狠。
|
||||||
|
monitorErrorBodySnippetMaxBytes = 300
|
||||||
// monitorChallengeMin / monitorChallengeMax challenge 操作数范围。
|
// monitorChallengeMin / monitorChallengeMax challenge 操作数范围。
|
||||||
monitorChallengeMin = 1
|
monitorChallengeMin = 1
|
||||||
monitorChallengeMax = 50
|
monitorChallengeMax = 50
|
||||||
|
|||||||
@@ -187,3 +187,14 @@ export function getPlatformTagClass(platform: string): string {
|
|||||||
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
|
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 平台对应的模型文字色(仅 text-*,用于 input/text 场景)— 与 getPlatformTagClass 同色系 */
|
||||||
|
export function getPlatformTextClass(platform: string): string {
|
||||||
|
switch (platform) {
|
||||||
|
case 'anthropic': return 'text-orange-700 dark:text-orange-400'
|
||||||
|
case 'openai': return 'text-emerald-700 dark:text-emerald-400'
|
||||||
|
case 'gemini': return 'text-blue-700 dark:text-blue-400'
|
||||||
|
case 'antigravity': return 'text-purple-700 dark:text-purple-400'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,13 +60,21 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.channelMonitor.form.primaryModel') }} <span class="text-red-500">*</span></label>
|
<label class="input-label">{{ t('admin.channelMonitor.form.primaryModel') }} <span class="text-red-500">*</span></label>
|
||||||
<input v-model="form.primary_model" type="text" required class="input" :placeholder="t('admin.channelMonitor.form.primaryModelPlaceholder')" />
|
<input
|
||||||
|
v-model="form.primary_model"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="input font-medium"
|
||||||
|
:class="getPlatformTextClass(form.provider)"
|
||||||
|
:placeholder="t('admin.channelMonitor.form.primaryModelPlaceholder')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.channelMonitor.form.extraModels') }}</label>
|
<label class="input-label">{{ t('admin.channelMonitor.form.extraModels') }}</label>
|
||||||
<ModelTagInput
|
<ModelTagInput
|
||||||
:models="form.extra_models"
|
:models="form.extra_models"
|
||||||
|
:platform="form.provider"
|
||||||
:placeholder="t('admin.channelMonitor.form.extraModelsPlaceholder')"
|
:placeholder="t('admin.channelMonitor.form.extraModelsPlaceholder')"
|
||||||
@update:models="form.extra_models = $event"
|
@update:models="form.extra_models = $event"
|
||||||
/>
|
/>
|
||||||
@@ -137,6 +145,7 @@ import type { ApiKey } from '@/types'
|
|||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import Toggle from '@/components/common/Toggle.vue'
|
import Toggle from '@/components/common/Toggle.vue'
|
||||||
import ModelTagInput from '@/components/admin/channel/ModelTagInput.vue'
|
import ModelTagInput from '@/components/admin/channel/ModelTagInput.vue'
|
||||||
|
import { getPlatformTextClass } from '@/components/admin/channel/types'
|
||||||
import MonitorKeyPickerDialog from '@/components/admin/monitor/MonitorKeyPickerDialog.vue'
|
import MonitorKeyPickerDialog from '@/components/admin/monitor/MonitorKeyPickerDialog.vue'
|
||||||
import ProviderIcon from '@/components/user/monitor/ProviderIcon.vue'
|
import ProviderIcon from '@/components/user/monitor/ProviderIcon.vue'
|
||||||
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
||||||
|
|||||||
Reference in New Issue
Block a user