Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
9f20f324
authored
Feb 12, 2026
by
RedwindA
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add OpenRouter pricing support to upstream ratio sync
parent
29d48e26
Show whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
147 additions
and
6 deletions
+147
-6
controller/ratio_sync.go
+131
-1
dto/ratio_sync.go
+1
-0
web/src/components/settings/ChannelSelectorModal.jsx
+4
-0
web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx
+11
-5
No files found.
controller/ratio_sync.go
View file @
9f20f324
...
@@ -5,8 +5,10 @@ import (
...
@@ -5,8 +5,10 @@ import (
"encoding/json"
"encoding/json"
"fmt"
"fmt"
"io"
"io"
"math"
"net"
"net"
"net/http"
"net/http"
"strconv"
"strings"
"strings"
"sync"
"sync"
"time"
"time"
...
@@ -139,9 +141,13 @@ func FetchUpstreamRatios(c *gin.Context) {
...
@@ -139,9 +141,13 @@ func FetchUpstreamRatios(c *gin.Context) {
sem
<-
struct
{}{}
sem
<-
struct
{}{}
defer
func
()
{
<-
sem
}()
defer
func
()
{
<-
sem
}()
isOpenRouter
:=
chItem
.
Endpoint
==
"openrouter"
endpoint
:=
chItem
.
Endpoint
endpoint
:=
chItem
.
Endpoint
var
fullURL
string
var
fullURL
string
if
strings
.
HasPrefix
(
endpoint
,
"http://"
)
||
strings
.
HasPrefix
(
endpoint
,
"https://"
)
{
if
isOpenRouter
{
fullURL
=
chItem
.
BaseURL
+
"/v1/models"
}
else
if
strings
.
HasPrefix
(
endpoint
,
"http://"
)
||
strings
.
HasPrefix
(
endpoint
,
"https://"
)
{
fullURL
=
endpoint
fullURL
=
endpoint
}
else
{
}
else
{
if
endpoint
==
""
{
if
endpoint
==
""
{
...
@@ -167,6 +173,28 @@ func FetchUpstreamRatios(c *gin.Context) {
...
@@ -167,6 +173,28 @@ func FetchUpstreamRatios(c *gin.Context) {
return
return
}
}
// OpenRouter requires Bearer token auth
if
isOpenRouter
&&
chItem
.
ID
!=
0
{
dbCh
,
err
:=
model
.
GetChannelById
(
chItem
.
ID
,
true
)
if
err
!=
nil
{
ch
<-
upstreamResult
{
Name
:
uniqueName
,
Err
:
"failed to get channel key: "
+
err
.
Error
()}
return
}
key
,
_
,
apiErr
:=
dbCh
.
GetNextEnabledKey
()
if
apiErr
!=
nil
{
ch
<-
upstreamResult
{
Name
:
uniqueName
,
Err
:
"failed to get enabled channel key: "
+
apiErr
.
Error
()}
return
}
if
strings
.
TrimSpace
(
key
)
==
""
{
ch
<-
upstreamResult
{
Name
:
uniqueName
,
Err
:
"no API key configured for this channel"
}
return
}
httpReq
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
strings
.
TrimSpace
(
key
))
}
else
if
isOpenRouter
{
ch
<-
upstreamResult
{
Name
:
uniqueName
,
Err
:
"OpenRouter requires a valid channel with API key"
}
return
}
// 简单重试:最多 3 次,指数退避
// 简单重试:最多 3 次,指数退避
var
resp
*
http
.
Response
var
resp
*
http
.
Response
var
lastErr
error
var
lastErr
error
...
@@ -194,6 +222,19 @@ func FetchUpstreamRatios(c *gin.Context) {
...
@@ -194,6 +222,19 @@ func FetchUpstreamRatios(c *gin.Context) {
logger
.
LogWarn
(
c
.
Request
.
Context
(),
"unexpected content-type from "
+
chItem
.
Name
+
": "
+
ct
)
logger
.
LogWarn
(
c
.
Request
.
Context
(),
"unexpected content-type from "
+
chItem
.
Name
+
": "
+
ct
)
}
}
limited
:=
io
.
LimitReader
(
resp
.
Body
,
maxRatioConfigBytes
)
limited
:=
io
.
LimitReader
(
resp
.
Body
,
maxRatioConfigBytes
)
// type3: OpenRouter /v1/models -> convert per-token pricing to ratios
if
isOpenRouter
{
converted
,
err
:=
convertOpenRouterToRatioData
(
limited
)
if
err
!=
nil
{
logger
.
LogWarn
(
c
.
Request
.
Context
(),
"OpenRouter parse failed from "
+
chItem
.
Name
+
": "
+
err
.
Error
())
ch
<-
upstreamResult
{
Name
:
uniqueName
,
Err
:
err
.
Error
()}
return
}
ch
<-
upstreamResult
{
Name
:
uniqueName
,
Data
:
converted
}
return
}
// 兼容两种上游接口格式:
// 兼容两种上游接口格式:
// type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
// type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
...
@@ -508,6 +549,94 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
...
@@ -508,6 +549,94 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
return
differences
return
differences
}
}
// convertOpenRouterToRatioData parses OpenRouter's /v1/models response and converts
// per-token USD pricing into the local ratio format.
// model_ratio = prompt_price_per_token * 1_000_000 * (USD / 1000)
//
// since 1 ratio unit = $0.002/1K tokens and USD=500, the factor is 500_000
//
// completion_ratio = completion_price / prompt_price (output/input multiplier)
func
convertOpenRouterToRatioData
(
reader
io
.
Reader
)
(
map
[
string
]
any
,
error
)
{
var
orResp
struct
{
Data
[]
struct
{
ID
string
`json:"id"`
Pricing
struct
{
Prompt
string
`json:"prompt"`
Completion
string
`json:"completion"`
InputCacheRead
string
`json:"input_cache_read"`
}
`json:"pricing"`
}
`json:"data"`
}
if
err
:=
common
.
DecodeJson
(
reader
,
&
orResp
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"failed to decode OpenRouter response: %w"
,
err
)
}
modelRatioMap
:=
make
(
map
[
string
]
any
)
completionRatioMap
:=
make
(
map
[
string
]
any
)
cacheRatioMap
:=
make
(
map
[
string
]
any
)
for
_
,
m
:=
range
orResp
.
Data
{
promptPrice
,
promptErr
:=
strconv
.
ParseFloat
(
m
.
Pricing
.
Prompt
,
64
)
completionPrice
,
compErr
:=
strconv
.
ParseFloat
(
m
.
Pricing
.
Completion
,
64
)
if
promptErr
!=
nil
&&
compErr
!=
nil
{
// Both unparseable — skip this model
continue
}
// Treat parse errors as 0
if
promptErr
!=
nil
{
promptPrice
=
0
}
if
compErr
!=
nil
{
completionPrice
=
0
}
// Negative values are sentinel values (e.g., -1 for dynamic/variable pricing) — skip
if
promptPrice
<
0
||
completionPrice
<
0
{
continue
}
if
promptPrice
==
0
&&
completionPrice
==
0
{
// Free model
modelRatioMap
[
m
.
ID
]
=
0.0
continue
}
// Normal case: promptPrice > 0
ratio
:=
promptPrice
*
1000
*
ratio_setting
.
USD
ratio
=
math
.
Round
(
ratio
*
1e6
)
/
1e6
modelRatioMap
[
m
.
ID
]
=
ratio
compRatio
:=
completionPrice
/
promptPrice
compRatio
=
math
.
Round
(
compRatio
*
1e6
)
/
1e6
completionRatioMap
[
m
.
ID
]
=
compRatio
// Convert input_cache_read to cache_ratio (= cache_read_price / prompt_price)
if
m
.
Pricing
.
InputCacheRead
!=
""
{
if
cachePrice
,
err
:=
strconv
.
ParseFloat
(
m
.
Pricing
.
InputCacheRead
,
64
);
err
==
nil
&&
cachePrice
>=
0
{
cacheRatio
:=
cachePrice
/
promptPrice
cacheRatio
=
math
.
Round
(
cacheRatio
*
1e6
)
/
1e6
cacheRatioMap
[
m
.
ID
]
=
cacheRatio
}
}
}
converted
:=
make
(
map
[
string
]
any
)
if
len
(
modelRatioMap
)
>
0
{
converted
[
"model_ratio"
]
=
modelRatioMap
}
if
len
(
completionRatioMap
)
>
0
{
converted
[
"completion_ratio"
]
=
completionRatioMap
}
if
len
(
cacheRatioMap
)
>
0
{
converted
[
"cache_ratio"
]
=
cacheRatioMap
}
return
converted
,
nil
}
func
GetSyncableChannels
(
c
*
gin
.
Context
)
{
func
GetSyncableChannels
(
c
*
gin
.
Context
)
{
channels
,
err
:=
model
.
GetAllChannels
(
0
,
0
,
true
,
false
)
channels
,
err
:=
model
.
GetAllChannels
(
0
,
0
,
true
,
false
)
if
err
!=
nil
{
if
err
!=
nil
{
...
@@ -526,6 +655,7 @@ func GetSyncableChannels(c *gin.Context) {
...
@@ -526,6 +655,7 @@ func GetSyncableChannels(c *gin.Context) {
Name
:
channel
.
Name
,
Name
:
channel
.
Name
,
BaseURL
:
channel
.
GetBaseURL
(),
BaseURL
:
channel
.
GetBaseURL
(),
Status
:
channel
.
Status
,
Status
:
channel
.
Status
,
Type
:
channel
.
Type
,
})
})
}
}
}
}
...
...
dto/ratio_sync.go
View file @
9f20f324
...
@@ -35,4 +35,5 @@ type SyncableChannel struct {
...
@@ -35,4 +35,5 @@ type SyncableChannel struct {
Name
string
`json:"name"`
Name
string
`json:"name"`
BaseURL
string
`json:"base_url"`
BaseURL
string
`json:"base_url"`
Status
int
`json:"status"`
Status
int
`json:"status"`
Type
int
`json:"type"`
}
}
web/src/components/settings/ChannelSelectorModal.jsx
View file @
9f20f324
...
@@ -117,6 +117,7 @@ const ChannelSelectorModal = forwardRef(
...
@@ -117,6 +117,7 @@ const ChannelSelectorModal = forwardRef(
const
getEndpointType
=
(
ep
)
=>
{
const
getEndpointType
=
(
ep
)
=>
{
if
(
ep
===
'/api/ratio_config'
)
return
'ratio_config'
;
if
(
ep
===
'/api/ratio_config'
)
return
'ratio_config'
;
if
(
ep
===
'/api/pricing'
)
return
'pricing'
;
if
(
ep
===
'/api/pricing'
)
return
'pricing'
;
if
(
ep
===
'openrouter'
)
return
'openrouter'
;
return
'custom'
;
return
'custom'
;
};
};
...
@@ -127,6 +128,8 @@ const ChannelSelectorModal = forwardRef(
...
@@ -127,6 +128,8 @@ const ChannelSelectorModal = forwardRef(
updateEndpoint
(
channelId
,
'/api/ratio_config'
);
updateEndpoint
(
channelId
,
'/api/ratio_config'
);
}
else
if
(
val
===
'pricing'
)
{
}
else
if
(
val
===
'pricing'
)
{
updateEndpoint
(
channelId
,
'/api/pricing'
);
updateEndpoint
(
channelId
,
'/api/pricing'
);
}
else
if
(
val
===
'openrouter'
)
{
updateEndpoint
(
channelId
,
'openrouter'
);
}
else
{
}
else
{
if
(
currentType
!==
'custom'
)
{
if
(
currentType
!==
'custom'
)
{
updateEndpoint
(
channelId
,
''
);
updateEndpoint
(
channelId
,
''
);
...
@@ -144,6 +147,7 @@ const ChannelSelectorModal = forwardRef(
...
@@ -144,6 +147,7 @@ const ChannelSelectorModal = forwardRef(
optionList=
{
[
optionList=
{
[
{
label
:
'ratio_config'
,
value
:
'ratio_config'
},
{
label
:
'ratio_config'
,
value
:
'ratio_config'
},
{
label
:
'pricing'
,
value
:
'pricing'
},
{
label
:
'pricing'
,
value
:
'pricing'
},
{
label
:
'OpenRouter'
,
value
:
'openrouter'
},
{
label
:
'custom'
,
value
:
'custom'
},
{
label
:
'custom'
,
value
:
'custom'
},
]
}
]
}
/>
/>
...
...
web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx
View file @
9f20f324
...
@@ -154,14 +154,20 @@ export default function UpstreamRatioSync(props) {
...
@@ -154,14 +154,20 @@ export default function UpstreamRatioSync(props) {
const
id
=
channel
.
key
;
const
id
=
channel
.
key
;
const
base
=
channel
.
_originalData
?.
base_url
||
''
;
const
base
=
channel
.
_originalData
?.
base_url
||
''
;
const
name
=
channel
.
label
||
''
;
const
name
=
channel
.
label
||
''
;
const
channelType
=
channel
.
_originalData
?.
type
;
const
isOfficial
=
const
isOfficial
=
id
===
-
100
||
id
===
-
100
||
base
===
'https://basellm.github.io'
||
base
===
'https://basellm.github.io'
||
name
===
'官方倍率预设'
;
name
===
'官方倍率预设'
;
const
isOpenRouter
=
channelType
===
20
;
if
(
!
merged
[
id
])
{
if
(
!
merged
[
id
])
{
merged
[
id
]
=
isOfficial
if
(
isOfficial
)
{
?
'/llm-metadata/api/newapi/ratio_config-v1-base.json'
merged
[
id
]
=
'/llm-metadata/api/newapi/ratio_config-v1-base.json'
;
:
DEFAULT_ENDPOINT
;
}
else
if
(
isOpenRouter
)
{
merged
[
id
]
=
'openrouter'
;
}
else
{
merged
[
id
]
=
DEFAULT_ENDPOINT
;
}
}
}
});
});
return
merged
;
return
merged
;
...
@@ -652,7 +658,7 @@ export default function UpstreamRatioSync(props) {
...
@@ -652,7 +658,7 @@ export default function UpstreamRatioSync(props) {
color=
{
text
!==
null
&&
text
!==
undefined
?
'blue'
:
'default'
}
color=
{
text
!==
null
&&
text
!==
undefined
?
'blue'
:
'default'
}
shape=
'circle'
shape=
'circle'
>
>
{
text
!==
null
&&
text
!==
undefined
?
text
:
t
(
'未设置'
)
}
{
text
!==
null
&&
text
!==
undefined
?
String
(
text
)
:
t
(
'未设置'
)
}
</
Tag
>
</
Tag
>
),
),
},
},
...
@@ -774,7 +780,7 @@ export default function UpstreamRatioSync(props) {
...
@@ -774,7 +780,7 @@ export default function UpstreamRatioSync(props) {
}
}
}
}
}
}
>
>
{
upstreamVal
}
{
String
(
upstreamVal
)
}
</
Checkbox
>
</
Checkbox
>
{
!
isConfident
&&
(
{
!
isConfident
&&
(
<
Tooltip
<
Tooltip
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment