feat: 请求日志添加模型字段 (#113)

* feat: 请求日志添加模型字段

* fix: 调整gemini判断模型顺序
This commit is contained in:
tbphp
2025-08-03 12:29:18 +08:00
committed by GitHub
parent 2d9a7859aa
commit 2be2ea697e
9 changed files with 115 additions and 40 deletions

View File

@@ -61,6 +61,17 @@ func (ch *AnthropicChannel) IsStreamRequest(c *gin.Context, bodyBytes []byte) bo
return false return false
} }
func (ch *AnthropicChannel) ExtractModel(c *gin.Context, bodyBytes []byte) string {
type modelPayload struct {
Model string `json:"model"`
}
var p modelPayload
if err := json.Unmarshal(bodyBytes, &p); err == nil {
return p.Model
}
return ""
}
// ValidateKey checks if the given API key is valid by making a messages request. // ValidateKey checks if the given API key is valid by making a messages request.
func (ch *AnthropicChannel) ValidateKey(ctx context.Context, key string) (bool, error) { func (ch *AnthropicChannel) ValidateKey(ctx context.Context, key string) (bool, error) {
upstreamURL := ch.getUpstreamURL() upstreamURL := ch.getUpstreamURL()

View File

@@ -29,6 +29,9 @@ type ChannelProxy interface {
// IsStreamRequest checks if the request is for a streaming response, // IsStreamRequest checks if the request is for a streaming response,
IsStreamRequest(c *gin.Context, bodyBytes []byte) bool IsStreamRequest(c *gin.Context, bodyBytes []byte) bool
// ExtractModel extracts the model name from the request.
ExtractModel(c *gin.Context, bodyBytes []byte) string
// ValidateKey checks if the given API key is valid. // ValidateKey checks if the given API key is valid.
ValidateKey(ctx context.Context, key string) (bool, error) ValidateKey(ctx context.Context, key string) (bool, error)
} }

View File

@@ -71,6 +71,29 @@ func (ch *GeminiChannel) IsStreamRequest(c *gin.Context, bodyBytes []byte) bool
return false return false
} }
func (ch *GeminiChannel) ExtractModel(c *gin.Context, bodyBytes []byte) string {
// gemini format
path := c.Request.URL.Path
parts := strings.Split(path, "/")
for i, part := range parts {
if part == "models" && i+1 < len(parts) {
modelPart := parts[i+1]
return strings.Split(modelPart, ":")[0]
}
}
// openai format
type modelPayload struct {
Model string `json:"model"`
}
var p modelPayload
if err := json.Unmarshal(bodyBytes, &p); err == nil && p.Model != "" {
return strings.TrimPrefix(p.Model, "models/")
}
return ""
}
// ValidateKey checks if the given API key is valid by making a generateContent request. // ValidateKey checks if the given API key is valid by making a generateContent request.
func (ch *GeminiChannel) ValidateKey(ctx context.Context, key string) (bool, error) { func (ch *GeminiChannel) ValidateKey(ctx context.Context, key string) (bool, error) {
upstreamURL := ch.getUpstreamURL() upstreamURL := ch.getUpstreamURL()

View File

@@ -60,6 +60,17 @@ func (ch *OpenAIChannel) IsStreamRequest(c *gin.Context, bodyBytes []byte) bool
return false return false
} }
func (ch *OpenAIChannel) ExtractModel(c *gin.Context, bodyBytes []byte) string {
type modelPayload struct {
Model string `json:"model"`
}
var p modelPayload
if err := json.Unmarshal(bodyBytes, &p); err == nil {
return p.Model
}
return ""
}
// ValidateKey checks if the given API key is valid by making a chat completion request. // ValidateKey checks if the given API key is valid by making a chat completion request.
func (ch *OpenAIChannel) ValidateKey(ctx context.Context, key string) (bool, error) { func (ch *OpenAIChannel) ValidateKey(ctx context.Context, key string) (bool, error) {
upstreamURL := ch.getUpstreamURL() upstreamURL := ch.getUpstreamURL()

View File

@@ -83,6 +83,7 @@ type RequestLog struct {
GroupID uint `gorm:"not null;index" json:"group_id"` GroupID uint `gorm:"not null;index" json:"group_id"`
GroupName string `gorm:"type:varchar(255);index" json:"group_name"` GroupName string `gorm:"type:varchar(255);index" json:"group_name"`
KeyValue string `gorm:"type:varchar(700)" json:"key_value"` KeyValue string `gorm:"type:varchar(700)" json:"key_value"`
Model string `gorm:"type:varchar(255);index" json:"model"`
IsSuccess bool `gorm:"not null" json:"is_success"` IsSuccess bool `gorm:"not null" json:"is_success"`
SourceIP string `gorm:"type:varchar(64)" json:"source_ip"` SourceIP string `gorm:"type:varchar(64)" json:"source_ip"`
StatusCode int `gorm:"not null" json:"status_code"` StatusCode int `gorm:"not null" json:"status_code"`

View File

@@ -114,11 +114,11 @@ func (ps *ProxyServer) executeRequestWithRetry(
} }
logrus.Debugf("Max retries exceeded for group %s after %d attempts. Parsed Error: %s", group.Name, retryCount, logMessage) logrus.Debugf("Max retries exceeded for group %s after %d attempts. Parsed Error: %s", group.Name, retryCount, logMessage)
ps.logRequest(c, group, &models.APIKey{KeyValue: lastError.KeyValue}, startTime, lastError.StatusCode, retryCount, errors.New(logMessage), isStream, lastError.UpstreamAddr) ps.logRequest(c, group, &models.APIKey{KeyValue: lastError.KeyValue}, startTime, lastError.StatusCode, retryCount, errors.New(logMessage), isStream, lastError.UpstreamAddr, channelHandler, bodyBytes)
} else { } else {
response.Error(c, app_errors.ErrMaxRetriesExceeded) response.Error(c, app_errors.ErrMaxRetriesExceeded)
logrus.Debugf("Max retries exceeded for group %s after %d attempts.", group.Name, retryCount) logrus.Debugf("Max retries exceeded for group %s after %d attempts.", group.Name, retryCount)
ps.logRequest(c, group, nil, startTime, http.StatusServiceUnavailable, retryCount, app_errors.ErrMaxRetriesExceeded, isStream, "") ps.logRequest(c, group, nil, startTime, http.StatusServiceUnavailable, retryCount, app_errors.ErrMaxRetriesExceeded, isStream, "", channelHandler, bodyBytes)
} }
return return
} }
@@ -127,7 +127,7 @@ func (ps *ProxyServer) executeRequestWithRetry(
if err != nil { if err != nil {
logrus.Errorf("Failed to select a key for group %s on attempt %d: %v", group.Name, retryCount+1, err) logrus.Errorf("Failed to select a key for group %s on attempt %d: %v", group.Name, retryCount+1, err)
response.Error(c, app_errors.NewAPIError(app_errors.ErrNoKeysAvailable, err.Error())) response.Error(c, app_errors.NewAPIError(app_errors.ErrNoKeysAvailable, err.Error()))
ps.logRequest(c, group, nil, startTime, http.StatusServiceUnavailable, retryCount, err, isStream, "") ps.logRequest(c, group, nil, startTime, http.StatusServiceUnavailable, retryCount, err, isStream, "", channelHandler, bodyBytes)
return return
} }
@@ -184,7 +184,7 @@ func (ps *ProxyServer) executeRequestWithRetry(
if err != nil || (resp != nil && resp.StatusCode >= 400) { if err != nil || (resp != nil && resp.StatusCode >= 400) {
if err != nil && app_errors.IsIgnorableError(err) { if err != nil && app_errors.IsIgnorableError(err) {
logrus.Debugf("Client-side ignorable error for key %s, aborting retries: %v", utils.MaskAPIKey(apiKey.KeyValue), err) logrus.Debugf("Client-side ignorable error for key %s, aborting retries: %v", utils.MaskAPIKey(apiKey.KeyValue), err)
ps.logRequest(c, group, apiKey, startTime, 499, retryCount+1, err, isStream, upstreamURL) ps.logRequest(c, group, apiKey, startTime, 499, retryCount+1, err, isStream, upstreamURL, channelHandler, bodyBytes)
return return
} }
@@ -227,7 +227,7 @@ func (ps *ProxyServer) executeRequestWithRetry(
// ps.keyProvider.UpdateStatus(apiKey, group, true) // 请求成功不再重置成功次数减少IO消耗 // ps.keyProvider.UpdateStatus(apiKey, group, true) // 请求成功不再重置成功次数减少IO消耗
logrus.Debugf("Request for group %s succeeded on attempt %d with key %s", group.Name, retryCount+1, utils.MaskAPIKey(apiKey.KeyValue)) logrus.Debugf("Request for group %s succeeded on attempt %d with key %s", group.Name, retryCount+1, utils.MaskAPIKey(apiKey.KeyValue))
ps.logRequest(c, group, apiKey, startTime, resp.StatusCode, retryCount+1, nil, isStream, upstreamURL) ps.logRequest(c, group, apiKey, startTime, resp.StatusCode, retryCount+1, nil, isStream, upstreamURL, channelHandler, bodyBytes)
for key, values := range resp.Header { for key, values := range resp.Header {
for _, value := range values { for _, value := range values {
@@ -254,6 +254,8 @@ func (ps *ProxyServer) logRequest(
finalError error, finalError error,
isStream bool, isStream bool,
upstreamAddr string, upstreamAddr string,
channelHandler channel.ChannelProxy,
bodyBytes []byte,
) { ) {
if ps.requestLogService == nil { if ps.requestLogService == nil {
return return
@@ -274,6 +276,11 @@ func (ps *ProxyServer) logRequest(
IsStream: isStream, IsStream: isStream,
UpstreamAddr: utils.TruncateString(upstreamAddr, 500), UpstreamAddr: utils.TruncateString(upstreamAddr, 500),
} }
if channelHandler != nil && bodyBytes != nil {
logEntry.Model = channelHandler.ExtractModel(c, bodyBytes)
}
if apiKey != nil { if apiKey != nil {
logEntry.KeyValue = apiKey.KeyValue logEntry.KeyValue = apiKey.KeyValue
} }

View File

@@ -45,6 +45,9 @@ func logFiltersScope(c *gin.Context) func(db *gorm.DB) *gorm.DB {
} }
db = db.Where("key_value LIKE ?", likePattern) db = db.Where("key_value LIKE ?", likePattern)
} }
if model := c.Query("model"); model != "" {
db = db.Where("model LIKE ?", "%"+model+"%")
}
if isSuccessStr := c.Query("is_success"); isSuccessStr != "" { if isSuccessStr := c.Query("is_success"); isSuccessStr != "" {
if isSuccess, err := strconv.ParseBool(isSuccessStr); err == nil { if isSuccess, err := strconv.ParseBool(isSuccessStr); err == nil {
db = db.Where("is_success = ?", isSuccess) db = db.Where("is_success = ?", isSuccess)

View File

@@ -33,6 +33,7 @@ const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
const filters = reactive({ const filters = reactive({
group_name: "", group_name: "",
key_value: "", key_value: "",
model: "",
is_success: "" as "true" | "false" | "", is_success: "" as "true" | "false" | "",
status_code: "", status_code: "",
source_ip: "", source_ip: "",
@@ -56,6 +57,7 @@ const loadLogs = async () => {
page_size: pageSize.value, page_size: pageSize.value,
group_name: filters.group_name || undefined, group_name: filters.group_name || undefined,
key_value: filters.key_value || undefined, key_value: filters.key_value || undefined,
model: filters.model || undefined,
is_success: filters.is_success === "" ? undefined : filters.is_success === "true", is_success: filters.is_success === "" ? undefined : filters.is_success === "true",
status_code: filters.status_code ? parseInt(filters.status_code, 10) : undefined, status_code: filters.status_code ? parseInt(filters.status_code, 10) : undefined,
source_ip: filters.source_ip || undefined, source_ip: filters.source_ip || undefined,
@@ -130,6 +132,7 @@ const createColumns = () => [
{ title: "耗时(ms)", key: "duration_ms", width: 80 }, { title: "耗时(ms)", key: "duration_ms", width: 80 },
{ title: "重试", key: "retries", width: 50 }, { title: "重试", key: "retries", width: 50 },
{ title: "分组", key: "group_name", width: 120 }, { title: "分组", key: "group_name", width: 120 },
{ title: "模型", key: "model", width: 300 },
{ {
title: "Key", title: "Key",
key: "key_value", key: "key_value",
@@ -196,6 +199,7 @@ const handleSearch = () => {
const resetFilters = () => { const resetFilters = () => {
filters.group_name = ""; filters.group_name = "";
filters.key_value = ""; filters.key_value = "";
filters.model = "";
filters.is_success = ""; filters.is_success = "";
filters.status_code = ""; filters.status_code = "";
filters.source_ip = ""; filters.source_ip = "";
@@ -209,6 +213,7 @@ const exportLogs = () => {
const params: Omit<LogFilter, "page" | "page_size"> = { const params: Omit<LogFilter, "page" | "page_size"> = {
group_name: filters.group_name || undefined, group_name: filters.group_name || undefined,
key_value: filters.key_value || undefined, key_value: filters.key_value || undefined,
model: filters.model || undefined,
is_success: filters.is_success === "" ? undefined : filters.is_success === "true", is_success: filters.is_success === "" ? undefined : filters.is_success === "true",
status_code: filters.status_code ? parseInt(filters.status_code, 10) : undefined, status_code: filters.status_code ? parseInt(filters.status_code, 10) : undefined,
source_ip: filters.source_ip || undefined, source_ip: filters.source_ip || undefined,
@@ -238,21 +243,11 @@ function changePageSize(size: number) {
<div class="filter-row"> <div class="filter-row">
<div class="filter-grid"> <div class="filter-grid">
<div class="filter-item"> <div class="filter-item">
<n-input <n-select
v-model:value="filters.group_name" v-model:value="filters.is_success"
placeholder="分组名" :options="successOptions"
size="small" size="small"
clearable @update:value="handleSearch"
@keyup.enter="handleSearch"
/>
</div>
<div class="filter-item">
<n-input
v-model:value="filters.key_value"
placeholder="密钥"
size="small"
clearable
@keyup.enter="handleSearch"
/> />
</div> </div>
<div class="filter-item"> <div class="filter-item">
@@ -265,17 +260,27 @@ function changePageSize(size: number) {
/> />
</div> </div>
<div class="filter-item"> <div class="filter-item">
<n-select <n-input
v-model:value="filters.is_success" v-model:value="filters.group_name"
:options="successOptions" placeholder="分组名"
size="small" size="small"
@update:value="handleSearch" clearable
@keyup.enter="handleSearch"
/> />
</div> </div>
<div class="filter-item"> <div class="filter-item">
<n-input <n-input
v-model:value="filters.error_contains" v-model:value="filters.model"
placeholder="错误信息" placeholder="模型"
size="small"
clearable
@keyup.enter="handleSearch"
/>
</div>
<div class="filter-item">
<n-input
v-model:value="filters.key_value"
placeholder="密钥"
size="small" size="small"
clearable clearable
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
@@ -299,6 +304,14 @@ function changePageSize(size: number) {
placeholder="结束时间" placeholder="结束时间"
/> />
</div> </div>
<div class="filter-item">
<n-input
v-model:value="filters.error_contains"
placeholder="错误信息"
size="small"
clearable
@keyup.enter="handleSearch"
/>
</div> </div>
<div class="filter-actions"> <div class="filter-actions">
<n-button ghost size="small" :disabled="loading" @click="handleSearch"> <n-button ghost size="small" :disabled="loading" @click="handleSearch">
@@ -318,6 +331,7 @@ function changePageSize(size: number) {
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="table-main"> <div class="table-main">
<!-- 表格 --> <!-- 表格 -->
<div class="table-container"> <div class="table-container">

View File

@@ -118,6 +118,7 @@ export interface RequestLog {
retries: number; retries: number;
group_name?: string; group_name?: string;
key_value?: string; key_value?: string;
model: string;
upstream_addr: string; upstream_addr: string;
is_stream: boolean; is_stream: boolean;
} }
@@ -139,6 +140,7 @@ export interface LogFilter {
page_size?: number; page_size?: number;
group_name?: string; group_name?: string;
key_value?: string; key_value?: string;
model?: string;
is_success?: boolean | null; is_success?: boolean | null;
status_code?: number | null; status_code?: number | null;
source_ip?: string; source_ip?: string;