Compare commits

...

22 Commits

Author SHA1 Message Date
CaIon e8afc25c71 chore: remove PR branching strategy workflow file 2025-09-09 23:23:53 +08:00
Calcium-Ion e2c2d182fa Merge pull request #1767 from QuantumNous/copy-claude-header-from-upstream
fix: claude header was not set correctly
2025-09-09 23:21:57 +08:00
creamlike1024 bbfbce9c67 fix: claude header was not set correctly 2025-09-09 23:18:07 +08:00
CaIon 1b1953e21a Merge branch 'alpha'
# Conflicts:
#	README.md
2025-09-09 23:08:17 +08:00
Calcium-Ion b3e67d5ef7 Merge pull request #1692 from yunayj/alpha
修改claude system参数为数组,增加通用性
2025-09-08 14:55:48 +08:00
IcedTangerine 8319198122 Merge pull request #1761 from QuantumNous/openaitoclaude-improve
feat: 改进Claude响应转OpenAI响应
2025-09-07 23:39:30 +08:00
creamlike1024 de73bfff78 feat(relay-claude): mapping stop reason and send text delta on block start type
- convert claude stop reason "max_tokens" to openai "length"
- send content_block_start content text delta
2025-09-07 23:03:19 +08:00
Seefs 80cfa0d0df Merge pull request #1677 from QuantumNous/gemini-2.5-flash-image-preview-billing
feat: gemini-2.5-flash-image-preview 文本和图片输出计费
2025-09-07 14:15:24 +08:00
Seefs 8fcc49377c Merge pull request #1754 from HynoR/fix/dtresp
fix: ensure the BuiltInTools entry exists before incrementing CallCount
2025-09-07 13:56:42 +08:00
HynoR 23a82b9646 fix: enhance tool usage parsing with additional nil checks and error logging 2025-09-07 07:42:25 +08:00
t0ng7u baf134cd50 🐛 fix(models): export setActivePage to prevent tab-change TypeError
Context:
Clicking a vendor tab triggered “setActivePage is not a function” from ModelsTabs.jsx:43.

Root cause:
ModelsTabs expects `setActivePage` via props (spread from `useModelsData`), but the hook did not expose it in its return object, so the prop resolved to `undefined`.

Fix:
Export `setActivePage` from `useModelsData`’s return object so `ModelsTabs` receives a valid function.

Result:
Tab switching now correctly resets pagination to page 1 and reloads models without runtime errors.

Files:
- web/src/hooks/models/useModelsData.jsx

Test plan:
- Open the Models page
- Click different vendor tabs
- Verify no crash occurs and the list reloads with page reset to 1

Refs: web/src/components/table/models/ModelsTabs.jsx:43
2025-09-06 21:57:26 +08:00
HynoR ab5351c270 fix: add error handling for missing built-in tools and validate response in stream handler 2025-09-05 13:58:24 +08:00
Seefs dffbd39cde Merge pull request #1749 from nekohy/feats-negative-number 2025-09-04 23:39:43 +08:00
Nekohy 1de5216148 fix: allow the negative number for override.go 2025-09-04 23:36:19 +08:00
CaIon e53cbd96ad fix(channel): implement per-channel locking to ensure thread-safe updates in multi-key mode 2025-09-03 15:52:54 +08:00
Calcium-Ion 6d81312e7e Add request format conversion functionality
Updated the features list to include request format conversion functionality and adjusted the order of items.
2025-09-03 14:45:00 +08:00
CaIon 4f5c343791 feat(readme): update format conversion feature details in README 2025-09-03 14:43:51 +08:00
CaIon f0183785c9 feat(option): enhance UpdateOption to handle various value types and improve validation 2025-09-03 14:30:25 +08:00
CaIon 1bbabda081 feat(monitor_setting): implement automatic channel testing configuration 2025-09-03 14:00:52 +08:00
t0ng7u 22b724ca44 🐛 fix(models-sync): allow sync when no conflicts selected
When syncing official models, clicking "Apply overwrite" with zero selected
conflict fields resulted in no request being sent and the modal simply closing.
This blocked creation of missing models/vendors even though the backend
supports an empty `overwrite` array and will still create missing items.

Changes:
- Remove the early-return guard in `UpstreamConflictModal.handleOk`
- Always call `onSubmit(payload)` even when `payload` is empty
- Keep closing behavior when the request succeeds

Behavior:
- Users can now proceed with upstream sync without selecting any conflict fields
- Missing models/vendors are created as expected
- Existing models are not overwritten unless fields are explicitly selected

Affected:
- web/src/components/table/models/modals/UpstreamConflictModal.jsx

Quality:
- Lint passes
- No breaking changes
- No visual/UI changes beyond the intended behavior

Test plan:
1) Open official models sync and trigger a conflicts preview
2) Click "Apply overwrite" without selecting any fields
3) Expect the sync to proceed and a success toast indicating created models
4) Re-try with some fields selected to confirm overwrites still work
2025-09-03 00:06:27 +08:00
yunayj fe71af943c 修改claude system参数为数组格式,提升API兼容性 2025-08-29 19:06:01 +08:00
creamlike1024 a45513a7a6 feat: gemini-2.5-flash-image-preview 文本和图片输出计费 2025-08-27 21:30:52 +08:00
28 changed files with 356 additions and 181 deletions
-2
View File
@@ -56,8 +56,6 @@
# SESSION_SECRET=random_string
# 其他配置
# 渠道测试频率(单位:秒)
# CHANNEL_TEST_FREQUENCY=10
# 生成默认token
# GENERATE_DEFAULT_TOKEN=false
# Cohere 安全设置
@@ -1,21 +0,0 @@
name: Check PR Branching Strategy
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
check-branching-strategy:
runs-on: ubuntu-latest
steps:
- name: Enforce branching strategy
run: |
if [[ "${{ github.base_ref }}" == "main" ]]; then
if [[ "${{ github.head_ref }}" != "alpha" ]]; then
echo "Error: Pull requests to 'main' are only allowed from the 'alpha' branch."
exit 1
fi
elif [[ "${{ github.base_ref }}" != "alpha" ]]; then
echo "Error: Pull requests must be targeted to the 'alpha' or 'main' branch."
exit 1
fi
echo "Branching strategy check passed."
+5 -1
View File
@@ -96,7 +96,11 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
16. 🔄 思考转内容功能
17. 🔄 针对用户的模型限流功能
18. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费
18. 🔄 请求格式转换功能,支持以下三种格式转换
1. OpenAI Chat Completions => Claude Messages
2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
3. OpenAI Chat Completions => Gemini Chat
19. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
1.`系统设置-运营设置` 中设置 `提示缓存倍率` 选项
2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费
3. 支持的渠道:
+9 -1
View File
@@ -123,8 +123,16 @@ func Interface2String(inter interface{}) string {
return fmt.Sprintf("%d", inter.(int))
case float64:
return fmt.Sprintf("%f", inter.(float64))
case bool:
if inter.(bool) {
return "true"
} else {
return "false"
}
case nil:
return ""
}
return "Not Implemented"
return fmt.Sprintf("%v", inter)
}
func UnescapeHTML(x string) interface{} {
+23 -11
View File
@@ -20,6 +20,7 @@ import (
relayconstant "one-api/relay/constant"
"one-api/relay/helper"
"one-api/service"
"one-api/setting/operation_setting"
"one-api/types"
"strconv"
"strings"
@@ -477,15 +478,26 @@ func TestAllChannels(c *gin.Context) {
return
}
func AutomaticallyTestChannels(frequency int) {
if frequency <= 0 {
common.SysLog("CHANNEL_TEST_FREQUENCY is not set or invalid, skipping automatic channel test")
return
}
for {
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("testing all channels")
_ = testAllChannels(false)
common.SysLog("channel test finished")
}
var autoTestChannelsOnce sync.Once
func AutomaticallyTestChannels() {
autoTestChannelsOnce.Do(func() {
for {
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
time.Sleep(10 * time.Minute)
continue
}
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency))
for {
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("automatically testing all channels")
_ = testAllChannels(false)
common.SysLog("automatically channel test finished")
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
break
}
}
}
})
}
+24 -8
View File
@@ -2,6 +2,7 @@ package controller
import (
"encoding/json"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
@@ -35,8 +36,13 @@ func GetOptions(c *gin.Context) {
return
}
type OptionUpdateRequest struct {
Key string `json:"key"`
Value any `json:"value"`
}
func UpdateOption(c *gin.Context) {
var option model.Option
var option OptionUpdateRequest
err := json.NewDecoder(c.Request.Body).Decode(&option)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
@@ -45,6 +51,16 @@ func UpdateOption(c *gin.Context) {
})
return
}
switch option.Value.(type) {
case bool:
option.Value = common.Interface2String(option.Value.(bool))
case float64:
option.Value = common.Interface2String(option.Value.(float64))
case int:
option.Value = common.Interface2String(option.Value.(int))
default:
option.Value = fmt.Sprintf("%v", option.Value)
}
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" {
@@ -104,7 +120,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "GroupRatio":
err = ratio_setting.CheckGroupRatio(option.Value)
err = ratio_setting.CheckGroupRatio(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -113,7 +129,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "ModelRequestRateLimitGroup":
err = setting.CheckModelRequestRateLimitGroup(option.Value)
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -122,7 +138,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -131,7 +147,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.announcements":
err = console_setting.ValidateConsoleSettings(option.Value, "Announcements")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -140,7 +156,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.faq":
err = console_setting.ValidateConsoleSettings(option.Value, "FAQ")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -149,7 +165,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.uptime_kuma_groups":
err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -158,7 +174,7 @@ func UpdateOption(c *gin.Context) {
return
}
}
err = model.UpdateOption(option.Key, option.Value)
err = model.UpdateOption(option.Key, option.Value.(string))
if err != nil {
common.ApiError(c, err)
return
+9 -7
View File
@@ -2,11 +2,12 @@ package dto
import (
"encoding/json"
"github.com/gin-gonic/gin"
"one-api/common"
"one-api/logger"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
)
type GeminiChatRequest struct {
@@ -268,14 +269,15 @@ type GeminiChatResponse struct {
}
type GeminiUsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokensDetails []GeminiModalityTokenCount `json:"promptTokensDetails"`
CandidatesTokensDetails []GeminiModalityTokenCount `json:"candidatesTokensDetails"`
}
type GeminiPromptTokensDetails struct {
type GeminiModalityTokenCount struct {
Modality string `json:"modality"`
TokenCount int `json:"tokenCount"`
}
+3 -7
View File
@@ -94,13 +94,9 @@ func main() {
}
go controller.AutomaticallyUpdateChannels(frequency)
}
if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" {
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY"))
if err != nil {
common.FatalLog("failed to parse CHANNEL_TEST_FREQUENCY: " + err.Error())
}
go controller.AutomaticallyTestChannels(frequency)
}
go controller.AutomaticallyTestChannels()
if common.IsMasterNode && constant.UpdateTask {
gopool.Go(func() {
controller.UpdateMidjourneyTaskBulk()
+8
View File
@@ -607,8 +607,12 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
return false
}
if channelCache.ChannelInfo.IsMultiKey {
// Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey
pollingLock := GetChannelPollingLock(channelId)
pollingLock.Lock()
// 如果是多Key模式,更新缓存中的状态
handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
pollingLock.Unlock()
//CacheUpdateChannel(channelCache)
//return true
} else {
@@ -639,7 +643,11 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
if channel.ChannelInfo.IsMultiKey {
beforeStatus := channel.Status
// Protect map writes with the same per-channel lock used by readers
pollingLock := GetChannelPollingLock(channelId)
pollingLock.Lock()
handlerMultiKeyUpdate(channel, usingKey, status, reason)
pollingLock.Unlock()
if beforeStatus != channel.Status {
shouldUpdateAbilities = true
}
+6 -1
View File
@@ -130,7 +130,12 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*
Usage: &dto.Usage{},
}
handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, awsResp.Body, RequestModeMessage)
// 复制上游 Content-Type 到客户端响应头
if awsResp.ContentType != nil && *awsResp.ContentType != "" {
c.Writer.Header().Set("Content-Type", *awsResp.ContentType)
}
handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, nil, awsResp.Body, RequestModeMessage)
if handlerErr != nil {
return handlerErr, nil
}
+29 -11
View File
@@ -32,7 +32,7 @@ func stopReasonClaude2OpenAI(reason string) string {
case "end_turn":
return "stop"
case "max_tokens":
return "max_tokens"
return "length"
case "tool_use":
return "tool_calls"
default:
@@ -274,19 +274,28 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
claudeMessages := make([]dto.ClaudeMessage, 0)
isFirstMessage := true
// 初始化system消息数组,用于累积多个system消息
var systemMessages []dto.ClaudeMediaMessage
for _, message := range formatMessages {
if message.Role == "system" {
// 根据Claude API规范,system字段使用数组格式更有通用性
if message.IsStringContent() {
claudeRequest.System = message.StringContent()
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](message.StringContent()),
})
} else {
contents := message.ParseContent()
content := ""
for _, ctx := range contents {
// 支持复合内容的system消息(虽然不常见,但需要考虑完整性)
for _, ctx := range message.ParseContent() {
if ctx.Type == "text" {
content += ctx.Text
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](ctx.Text),
})
}
// 未来可以在这里扩展对图片等其他类型的支持
}
claudeRequest.System = content
}
} else {
if isFirstMessage {
@@ -392,6 +401,12 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
claudeMessages = append(claudeMessages, claudeMessage)
}
}
// 设置累积的system消息
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
}
claudeRequest.Prompt = ""
claudeRequest.Messages = claudeMessages
return &claudeRequest, nil
@@ -426,7 +441,10 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse
choice.Delta.Role = "assistant"
} else if claudeResponse.Type == "content_block_start" {
if claudeResponse.ContentBlock != nil {
//choice.Delta.SetContentString(claudeResponse.ContentBlock.Text)
// 如果是文本块,尽可能发送首段文本(若存在)
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
}
if claudeResponse.ContentBlock.Type == "tool_use" {
tools = append(tools, dto.ToolCallResponse{
Index: common.GetPointer(fcIdx),
@@ -698,7 +716,7 @@ func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.
return claudeInfo.Usage, nil
}
func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data []byte, requestMode int) *types.NewAPIError {
func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, httpResp *http.Response, data []byte, requestMode int) *types.NewAPIError {
var claudeResponse dto.ClaudeResponse
err := common.Unmarshal(data, &claudeResponse)
if err != nil {
@@ -736,7 +754,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
}
service.IOCopyBytesGracefully(c, nil, responseData)
service.IOCopyBytesGracefully(c, httpResp, responseData)
return nil
}
@@ -757,7 +775,7 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
if common.DebugEnabled {
println("responseBody: ", string(responseBody))
}
handleErr := HandleClaudeResponseData(c, info, claudeInfo, responseBody, requestMode)
handleErr := HandleClaudeResponseData(c, info, claudeInfo, resp, responseBody, requestMode)
if handleErr != nil {
return nil, handleErr
}
@@ -46,6 +46,32 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
imageOutputCounts := 0
for _, candidate := range geminiResponse.Candidates {
for _, part := range candidate.Content.Parts {
if part.InlineData != nil && strings.HasPrefix(part.InlineData.MimeType, "image/") {
imageOutputCounts++
}
}
}
if imageOutputCounts != 0 {
usage.CompletionTokens = usage.CompletionTokens - imageOutputCounts*1290
usage.TotalTokens = usage.TotalTokens - imageOutputCounts*1290
c.Set("gemini_image_tokens", imageOutputCounts*1290)
}
}
// if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
// for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails {
// if detail.Modality == "IMAGE" {
// usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount
// usage.TotalTokens = usage.TotalTokens - detail.TokenCount
// c.Set("gemini_image_tokens", detail.TokenCount)
// }
// }
// }
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
@@ -136,6 +162,16 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}
if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails {
if detail.Modality == "IMAGE" {
usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount
usage.TotalTokens = usage.TotalTokens - detail.TokenCount
c.Set("gemini_image_tokens", detail.TokenCount)
}
}
}
}
// 直接发送 GeminiChatResponse 响应
+10 -2
View File
@@ -46,9 +46,17 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
}
}
if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil {
return &usage, nil
}
// 解析 Tools 用量
for _, tool := range responsesResponse.Tools {
info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])].CallCount++
buildToolinfo, ok := info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])]
if !ok || buildToolinfo == nil {
logger.LogError(c, fmt.Sprintf("BuiltInTools not found for tool type: %v", tool["type"]))
continue
}
buildToolinfo.CallCount++
}
return &usage, nil
}
@@ -72,7 +80,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type {
case "response.completed":
if streamResponse.Response.Usage != nil {
if streamResponse.Response != nil && streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
}
+46 -7
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"regexp"
"strconv"
"strings"
)
@@ -151,7 +153,9 @@ func checkConditions(jsonStr string, conditions []ConditionOperation, logic stri
}
func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, error) {
value := gjson.Get(jsonStr, condition.Path)
// 处理负数索引
path := processNegativeIndex(jsonStr, condition.Path)
value := gjson.Get(jsonStr, path)
if !value.Exists() {
if condition.PassMissingKey {
return true, nil
@@ -177,6 +181,37 @@ func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, e
return result, nil
}
func processNegativeIndex(jsonStr string, path string) string {
re := regexp.MustCompile(`\.(-\d+)`)
matches := re.FindAllStringSubmatch(path, -1)
if len(matches) == 0 {
return path
}
result := path
for _, match := range matches {
negIndex := match[1]
index, _ := strconv.Atoi(negIndex)
arrayPath := strings.Split(path, negIndex)[0]
if strings.HasSuffix(arrayPath, ".") {
arrayPath = arrayPath[:len(arrayPath)-1]
}
array := gjson.Get(jsonStr, arrayPath)
if array.IsArray() {
length := len(array.Array())
actualIndex := length + index
if actualIndex >= 0 && actualIndex < length {
result = strings.Replace(result, match[0], "."+strconv.Itoa(actualIndex), 1)
}
}
}
return result
}
// compareGjsonValues 直接比较两个gjson.Result,支持所有比较模式
func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool, error) {
switch mode {
@@ -274,21 +309,25 @@ func applyOperations(jsonStr string, operations []ParamOperation) (string, error
if !ok {
continue // 条件不满足,跳过当前操作
}
// 处理路径中的负数索引
opPath := processNegativeIndex(result, op.Path)
opFrom := processNegativeIndex(result, op.From)
opTo := processNegativeIndex(result, op.To)
switch op.Mode {
case "delete":
result, err = sjson.Delete(result, op.Path)
result, err = sjson.Delete(result, opPath)
case "set":
if op.KeepOrigin && gjson.Get(result, op.Path).Exists() {
if op.KeepOrigin && gjson.Get(result, opPath).Exists() {
continue
}
result, err = sjson.Set(result, op.Path, op.Value)
result, err = sjson.Set(result, opPath, op.Value)
case "move":
result, err = moveValue(result, op.From, op.To)
result, err = moveValue(result, opFrom, opTo)
case "prepend":
result, err = modifyValue(result, op.Path, op.Value, op.KeepOrigin, true)
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true)
case "append":
result, err = modifyValue(result, op.Path, op.Value, op.KeepOrigin, false)
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false)
default:
return "", fmt.Errorf("unknown operation: %s", op.Mode)
}
+2 -4
View File
@@ -2,12 +2,10 @@ package common
import (
"fmt"
"github.com/gin-gonic/gin"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"one-api/constant"
"strings"
"github.com/gin-gonic/gin"
)
func GetFullRequestURL(baseURL string, requestURL string, channelType int) string {
+15
View File
@@ -314,11 +314,22 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
} else {
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
}
var dGeminiImageOutputQuota decimal.Decimal
var imageOutputPrice float64
if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") {
imageOutputPrice = operation_setting.GetGeminiImageOutputPricePerMillionTokens(modelName)
if imageOutputPrice > 0 {
dImageOutputTokens := decimal.NewFromInt(int64(ctx.GetInt("gemini_image_tokens")))
dGeminiImageOutputQuota = decimal.NewFromFloat(imageOutputPrice).Div(decimal.NewFromInt(1000000)).Mul(dImageOutputTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
}
}
// 添加 responses tools call 调用的配额
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
// 添加 Gemini image output 计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(dGeminiImageOutputQuota)
quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens
@@ -413,6 +424,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["audio_input_token_count"] = audioTokens
other["audio_input_price"] = audioInputPrice
}
if !dGeminiImageOutputQuota.IsZero() {
other["image_output_token_count"] = ctx.GetInt("gemini_image_tokens")
other["image_output_price"] = imageOutputPrice
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
+3
View File
@@ -5,6 +5,9 @@ import (
"encoding/base64"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"one-api/common"
+4 -78
View File
@@ -5,6 +5,9 @@ import (
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"log"
"math"
"one-api/common"
@@ -333,7 +336,7 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
for i, file := range meta.Files {
switch file.FileType {
case types.FileTypeImage:
if info.RelayFormat == types.RelayFormatGemini {
if info.RelayFormat == types.RelayFormatGemini && !strings.HasPrefix(model, "gemini-2.5-flash-image-preview") {
tkm += 256
} else {
token, err := getImageToken(file, model, info.IsStream)
@@ -357,33 +360,6 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
return tkm, nil
}
//func CountTokenChatRequest(info *relaycommon.RelayInfo, request dto.GeneralOpenAIRequest) (int, error) {
// tkm := 0
// msgTokens, err := CountTokenMessages(info, request.Messages, request.Model, request.Stream)
// if err != nil {
// return 0, err
// }
// tkm += msgTokens
// if request.Tools != nil {
// openaiTools := request.Tools
// countStr := ""
// for _, tool := range openaiTools {
// countStr = tool.Function.Name
// if tool.Function.Description != "" {
// countStr += tool.Function.Description
// }
// if tool.Function.Parameters != nil {
// countStr += fmt.Sprintf("%v", tool.Function.Parameters)
// }
// }
// toolTokens := CountTokenInput(countStr, request.Model)
// tkm += 8
// tkm += toolTokens
// }
//
// return tkm, nil
//}
func CountTokenClaudeRequest(request dto.ClaudeRequest, model string) (int, error) {
tkm := 0
@@ -543,56 +519,6 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent,
return textToken, audioToken, nil
}
//func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, model string, stream bool) (int, error) {
// //recover when panic
// tokenEncoder := getTokenEncoder(model)
// // Reference:
// // https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
// // https://github.com/pkoukk/tiktoken-go/issues/6
// //
// // Every message follows <|start|>{role/name}\n{content}<|end|>\n
// var tokensPerMessage int
// var tokensPerName int
//
// tokensPerMessage = 3
// tokensPerName = 1
//
// tokenNum := 0
// for _, message := range messages {
// tokenNum += tokensPerMessage
// tokenNum += getTokenNum(tokenEncoder, message.Role)
// if message.Content != nil {
// if message.Name != nil {
// tokenNum += tokensPerName
// tokenNum += getTokenNum(tokenEncoder, *message.Name)
// }
// arrayContent := message.ParseContent()
// for _, m := range arrayContent {
// if m.Type == dto.ContentTypeImageURL {
// imageUrl := m.GetImageMedia()
// imageTokenNum, err := getImageToken(info, imageUrl, model, stream)
// if err != nil {
// return 0, err
// }
// tokenNum += imageTokenNum
// log.Printf("image token num: %d", imageTokenNum)
// } else if m.Type == dto.ContentTypeInputAudio {
// // TODO: 音频token数量计算
// tokenNum += 100
// } else if m.Type == dto.ContentTypeFile {
// tokenNum += 5000
// } else if m.Type == dto.ContentTypeVideoUrl {
// tokenNum += 5000
// } else {
// tokenNum += getTokenNum(tokenEncoder, m.Text)
// }
// }
// }
// }
// tokenNum += 3 // Every reply is primed with <|start|>assistant<|message|>
// return tokenNum, nil
//}
func CountTokenInput(input any, model string) int {
switch v := input.(type) {
case string:
+1
View File
@@ -26,6 +26,7 @@ var defaultGeminiSettings = GeminiSettings{
SupportedImagineModels: []string{
"gemini-2.0-flash-exp-image-generation",
"gemini-2.0-flash-exp",
"gemini-2.5-flash-image-preview",
},
ThinkingAdapterEnabled: false,
ThinkingAdapterBudgetTokensPercentage: 0.6,
@@ -0,0 +1,34 @@
package operation_setting
import (
"one-api/setting/config"
"os"
"strconv"
)
type MonitorSetting struct {
AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"`
AutoTestChannelMinutes int `json:"auto_test_channel_minutes"`
}
// 默认配置
var monitorSetting = MonitorSetting{
AutoTestChannelEnabled: false,
AutoTestChannelMinutes: 10,
}
func init() {
// 注册到全局配置管理器
config.GlobalConfig.Register("monitor_setting", &monitorSetting)
}
func GetMonitorSetting() *MonitorSetting {
if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" {
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY"))
if err == nil && frequency > 0 {
monitorSetting.AutoTestChannelEnabled = true
monitorSetting.AutoTestChannelMinutes = frequency
}
}
return &monitorSetting
}
+11
View File
@@ -24,6 +24,10 @@ const (
ClaudeWebSearchPrice = 10.00
)
const (
Gemini25FlashImagePreviewImageOutputPrice = 30.00
)
func GetClaudeWebSearchPricePerThousand() float64 {
return ClaudeWebSearchPrice
}
@@ -65,3 +69,10 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
}
return 0
}
func GetGeminiImageOutputPricePerMillionTokens(modelName string) float64 {
if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") {
return Gemini25FlashImagePreviewImageOutputPrice
}
return 0
}
+6 -4
View File
@@ -178,6 +178,7 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
"gemini-2.5-flash-image-preview": 0.15, // $0.30text/image) / 1M tokens
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
@@ -293,10 +294,11 @@ var (
)
var defaultCompletionRatio = map[string]float64{
"gpt-4-gizmo-*": 2,
"gpt-4o-gizmo-*": 3,
"gpt-4-all": 2,
"gpt-image-1": 8,
"gpt-4-gizmo-*": 2,
"gpt-4o-gizmo-*": 3,
"gpt-4-all": 2,
"gpt-image-1": 8,
"gemini-2.5-flash-image-preview": 8.3333333333,
}
// InitRatioSettings initializes all model related settings maps
@@ -68,6 +68,8 @@ const OperationSetting = () => {
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10,
});
let [loading, setLoading] = useState(false);
@@ -78,10 +80,7 @@ const OperationSetting = () => {
if (success) {
let newInputs = {};
data.forEach((item) => {
if (
item.key.endsWith('Enabled') ||
['DefaultCollapseSidebar'].includes(item.key)
) {
if (typeof inputs[item.key] === 'boolean') {
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
@@ -258,10 +258,6 @@ const UpstreamConflictModal = ({
}))
.filter((x) => x.fields.length > 0);
if (payload.length === 0) {
onClose?.();
return;
}
const ok = await onSubmit?.(payload);
if (ok) onClose?.();
};
+30 -8
View File
@@ -1017,7 +1017,7 @@ export function renderModelPrice(
cacheRatio = 1.0,
image = false,
imageRatio = 1.0,
imageOutputTokens = 0,
imageInputTokens = 0,
webSearch = false,
webSearchCallCount = 0,
webSearchPrice = 0,
@@ -1027,6 +1027,8 @@ export function renderModelPrice(
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
imageOutputTokens = 0,
imageOutputPrice = 0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -1057,9 +1059,9 @@ export function renderModelPrice(
let effectiveInputTokens =
inputTokens - cacheTokens + cacheTokens * cacheRatio;
// Handle image tokens if present
if (image && imageOutputTokens > 0) {
if (image && imageInputTokens > 0) {
effectiveInputTokens =
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
inputTokens - imageInputTokens + imageInputTokens * imageRatio;
}
if (audioInputTokens > 0) {
effectiveInputTokens -= audioInputTokens;
@@ -1069,7 +1071,8 @@ export function renderModelPrice(
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
(imageOutputTokens / 1000000) * imageOutputPrice * groupRatio;
return (
<>
@@ -1104,7 +1107,7 @@ export function renderModelPrice(
)}
</p>
)}
{image && imageOutputTokens > 0 && (
{image && imageInputTokens > 0 && (
<p>
{i18next.t(
'图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
@@ -1131,17 +1134,26 @@ export function renderModelPrice(
})}
</p>
)}
{imageOutputPrice > 0 && imageOutputTokens > 0 && (
<p>
{i18next.t('图片输出价格:${{price}} * 分组倍率{{ratio}} = ${{total}} / 1M tokens', {
price: imageOutputPrice,
ratio: groupRatio,
total: imageOutputPrice * groupRatio,
})}
</p>
)}
<p></p>
<p>
{(() => {
// 构建输入部分描述
let inputDesc = '';
if (image && imageOutputTokens > 0) {
if (image && imageInputTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
{
nonImageInput: inputTokens - imageOutputTokens,
imageInput: imageOutputTokens,
nonImageInput: inputTokens - imageInputTokens,
imageInput: imageInputTokens,
imageRatio: imageRatio,
price: inputRatioPrice,
},
@@ -1211,6 +1223,16 @@ export function renderModelPrice(
},
)
: '',
imageOutputPrice > 0 && imageOutputTokens > 0
? i18next.t(
' + 图片输出 {{tokenCounts}} tokens * ${{price}} / 1M tokens * 分组倍率{{ratio}}',
{
tokenCounts: imageOutputTokens,
price: imageOutputPrice,
ratio: groupRatio,
},
)
: '',
].join('');
return i18next.t(
+1
View File
@@ -462,6 +462,7 @@ export const useModelsData = () => {
copyText,
// Pagination
setActivePage,
handlePageChange,
handlePageSizeChange,
@@ -447,6 +447,8 @@ export const useLogsData = () => {
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
other?.image_output_token_count || 0,
other?.image_output_price || 0,
);
}
expandDataLocal.push({
@@ -38,6 +38,8 @@ export default function SettingsMonitoring(props) {
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10,
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
@@ -98,6 +100,40 @@ export default function SettingsMonitoring(props) {
style={{ marginBottom: 15 }}
>
<Form.Section text={t('监控设置')}>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field={'monitor_setting.auto_test_channel_enabled'}
label={t('定时测试所有通道')}
size='default'
checkedText=''
uncheckedText=''
onChange={(value) =>
setInputs({
...inputs,
'monitor_setting.auto_test_channel_enabled': value,
})
}
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
label={t('自动测试所有通道间隔时间')}
step={1}
min={1}
suffix={t('分钟')}
extraText={t('每隔多少分钟测试一次所有通道')}
placeholder={''}
field={'monitor_setting.auto_test_channel_minutes'}
onChange={(value) =>
setInputs({
...inputs,
'monitor_setting.auto_test_channel_minutes': parseInt(value),
})
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber