Skip to content

Commit bbec891

Browse files
authored
Merge branch 'dev' into feat/user-export-all
2 parents 46160a9 + ab14ce8 commit bbec891

26 files changed

Lines changed: 4112 additions & 2617 deletions

backend/internal/transport/http/billing/dto.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,30 @@ type ModelPricingDataResponse struct {
480480
ModelPricing ModelPricingResponse `json:"modelPricing"`
481481
}
482482

483+
// OpenRouterOfficialPricingItemResponse OpenRouter 官方模型定价项。
484+
type OpenRouterOfficialPricingItemResponse struct {
485+
ID string `json:"id"`
486+
CanonicalSlug string `json:"canonicalSlug"`
487+
Name string `json:"name"`
488+
Pricing OpenRouterOfficialPricingUnitPricingResponse `json:"pricing"`
489+
}
490+
491+
// OpenRouterOfficialPricingUnitPricingResponse OpenRouter 官方模型价格字段。
492+
type OpenRouterOfficialPricingUnitPricingResponse struct {
493+
Prompt string `json:"prompt"`
494+
Completion string `json:"completion"`
495+
InputCacheRead string `json:"inputCacheRead"`
496+
InputCacheWrite string `json:"inputCacheWrite"`
497+
}
498+
499+
// OpenRouterOfficialPricingDataResponse OpenRouter 官方模型定价缓存响应。
500+
type OpenRouterOfficialPricingDataResponse struct {
501+
FetchedAt time.Time `json:"fetchedAt"`
502+
Cached bool `json:"cached"`
503+
Stale bool `json:"stale"`
504+
Items []OpenRouterOfficialPricingItemResponse `json:"items"`
505+
}
506+
483507
// BillingConfigResponse 计费全局配置响应。
484508
type BillingConfigResponse struct {
485509
Mode string `json:"mode"`
@@ -593,6 +617,12 @@ type ModelPricingListResponseDoc struct {
593617
} `json:"data"`
594618
}
595619

620+
// OpenRouterOfficialPricingResponseDoc OpenRouter 官方模型定价响应文档。
621+
type OpenRouterOfficialPricingResponseDoc struct {
622+
ErrorMsg string `json:"errorMsg"`
623+
Data OpenRouterOfficialPricingDataResponse `json:"data"`
624+
}
625+
596626
// BillingConfigResponseDoc 计费全局配置响应文档。
597627
type BillingConfigResponseDoc struct {
598628
ErrorMsg string `json:"errorMsg"`
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package billing
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"os"
11+
"path/filepath"
12+
"strings"
13+
"time"
14+
15+
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/response"
16+
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/security"
17+
"github.com/gin-gonic/gin"
18+
)
19+
20+
const (
21+
openRouterModelsURL = "https://openrouter.ai/api/v1/models"
22+
openRouterPricingCacheRelPath = "admin/openrouter-model-pricing.json"
23+
openRouterPricingCacheTTL = 24 * time.Hour
24+
)
25+
26+
type openRouterOfficialPricingCacheFile struct {
27+
FetchedAt time.Time `json:"fetchedAt"`
28+
Items []OpenRouterOfficialPricingItemResponse `json:"items"`
29+
}
30+
31+
type openRouterModelsResponse struct {
32+
Data []openRouterModelItem `json:"data"`
33+
}
34+
35+
type openRouterModelItem struct {
36+
ID string `json:"id"`
37+
CanonicalSlug string `json:"canonical_slug"`
38+
Name string `json:"name"`
39+
Pricing openRouterModelPricing `json:"pricing"`
40+
}
41+
42+
type openRouterModelPricing struct {
43+
Prompt string `json:"prompt"`
44+
Completion string `json:"completion"`
45+
InputCacheRead string `json:"input_cache_read"`
46+
InputCacheWrite string `json:"input_cache_write"`
47+
}
48+
49+
// GetOpenRouterOfficialPricing godoc
50+
// @Summary 管理员获取 OpenRouter 官方模型定价
51+
// @Description 从 storage 缓存读取 OpenRouter 模型定价;缓存不存在、过期或 refresh=true 时由后端刷新。
52+
// @Tags admin-billing
53+
// @Accept json
54+
// @Produce json
55+
// @Security BearerAuth
56+
// @Param refresh query bool false "强制刷新缓存"
57+
// @Success 200 {object} OpenRouterOfficialPricingResponseDoc
58+
// @Failure 502 {object} ErrorDoc
59+
// @Router /admin/billing/official-pricing/openrouter [get]
60+
func (h *Handler) GetOpenRouterOfficialPricing(c *gin.Context) {
61+
refresh := strings.EqualFold(strings.TrimSpace(c.Query("refresh")), "true")
62+
cache, cacheOK := h.readOpenRouterOfficialPricingCache()
63+
respondWithStaleCache := func() {
64+
response.Success(c, OpenRouterOfficialPricingDataResponse{
65+
FetchedAt: cache.FetchedAt,
66+
Cached: true,
67+
Stale: true,
68+
Items: cache.Items,
69+
})
70+
}
71+
if cacheOK && !refresh && !openRouterOfficialPricingCacheStale(cache.FetchedAt) {
72+
response.Success(c, OpenRouterOfficialPricingDataResponse{
73+
FetchedAt: cache.FetchedAt,
74+
Cached: true,
75+
Stale: false,
76+
Items: cache.Items,
77+
})
78+
return
79+
}
80+
81+
items, err := h.fetchOpenRouterOfficialPricing(c.Request.Context())
82+
if err != nil {
83+
if cacheOK {
84+
// 官方价格只用于管理员辅助填充;远程失败时优先保留可用旧缓存,避免阻塞已有配置流程。
85+
respondWithStaleCache()
86+
return
87+
}
88+
response.Error(c, http.StatusBadGateway, "fetch openrouter official pricing failed")
89+
return
90+
}
91+
if len(items) == 0 {
92+
if cacheOK {
93+
respondWithStaleCache()
94+
return
95+
}
96+
response.Error(c, http.StatusBadGateway, "openrouter official pricing is empty")
97+
return
98+
}
99+
100+
nextCache := openRouterOfficialPricingCacheFile{
101+
FetchedAt: time.Now().UTC(),
102+
Items: items,
103+
}
104+
if writeErr := h.writeOpenRouterOfficialPricingCache(nextCache); writeErr != nil {
105+
response.Error(c, http.StatusInternalServerError, "cache openrouter official pricing failed")
106+
return
107+
}
108+
response.Success(c, OpenRouterOfficialPricingDataResponse{
109+
FetchedAt: nextCache.FetchedAt,
110+
Cached: false,
111+
Stale: false,
112+
Items: nextCache.Items,
113+
})
114+
}
115+
116+
func (h *Handler) openRouterOfficialPricingCachePath() string {
117+
root := ""
118+
if h.cfg != nil {
119+
root = strings.TrimSpace(h.cfg.Snapshot().StorageRootDir)
120+
}
121+
if root == "" {
122+
root = "./storage"
123+
}
124+
return filepath.Join(root, filepath.FromSlash(openRouterPricingCacheRelPath))
125+
}
126+
127+
func (h *Handler) readOpenRouterOfficialPricingCache() (openRouterOfficialPricingCacheFile, bool) {
128+
path := h.openRouterOfficialPricingCachePath()
129+
raw, err := os.ReadFile(path)
130+
if err != nil {
131+
return openRouterOfficialPricingCacheFile{}, false
132+
}
133+
var cache openRouterOfficialPricingCacheFile
134+
if err := json.Unmarshal(raw, &cache); err != nil || cache.FetchedAt.IsZero() || len(cache.Items) == 0 {
135+
return openRouterOfficialPricingCacheFile{}, false
136+
}
137+
return cache, true
138+
}
139+
140+
func (h *Handler) writeOpenRouterOfficialPricingCache(cache openRouterOfficialPricingCacheFile) error {
141+
path := h.openRouterOfficialPricingCachePath()
142+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
143+
return err
144+
}
145+
raw, err := json.MarshalIndent(cache, "", " ")
146+
if err != nil {
147+
return err
148+
}
149+
tmpPath := fmt.Sprintf("%s.tmp.%d", path, time.Now().UnixNano())
150+
if err := os.WriteFile(tmpPath, raw, 0o644); err != nil {
151+
return err
152+
}
153+
if err := os.Rename(tmpPath, path); err != nil {
154+
_ = os.Remove(tmpPath)
155+
return err
156+
}
157+
return nil
158+
}
159+
160+
func (h *Handler) fetchOpenRouterOfficialPricing(ctx context.Context) ([]OpenRouterOfficialPricingItemResponse, error) {
161+
env := ""
162+
ssrfProtectionEnabled := false
163+
if h.cfg != nil {
164+
cfg := h.cfg.Snapshot()
165+
env = cfg.Env
166+
ssrfProtectionEnabled = cfg.SSRFProtectionEnabled
167+
}
168+
client := security.NewOutboundHTTPClient(env, ssrfProtectionEnabled, 15*time.Second)
169+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, openRouterModelsURL, nil)
170+
if err != nil {
171+
return nil, err
172+
}
173+
req.Header.Set("Accept", "application/json")
174+
resp, err := client.Do(req)
175+
if err != nil {
176+
return nil, err
177+
}
178+
defer resp.Body.Close()
179+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
180+
return nil, fmt.Errorf("openrouter models request failed: %d", resp.StatusCode)
181+
}
182+
var payload openRouterModelsResponse
183+
decoder := json.NewDecoder(io.LimitReader(resp.Body, 8<<20))
184+
if err := decoder.Decode(&payload); err != nil {
185+
return nil, err
186+
}
187+
items := make([]OpenRouterOfficialPricingItemResponse, 0, len(payload.Data))
188+
for _, item := range payload.Data {
189+
normalized := normalizeOpenRouterOfficialPricingItem(item)
190+
if normalized.ID != "" {
191+
items = append(items, normalized)
192+
}
193+
}
194+
if len(items) == 0 {
195+
return nil, errors.New("openrouter model list is empty")
196+
}
197+
return items, nil
198+
}
199+
200+
func normalizeOpenRouterOfficialPricingItem(item openRouterModelItem) OpenRouterOfficialPricingItemResponse {
201+
id := strings.TrimSpace(item.ID)
202+
if id == "" {
203+
return OpenRouterOfficialPricingItemResponse{}
204+
}
205+
canonicalSlug := strings.TrimSpace(item.CanonicalSlug)
206+
if canonicalSlug == "" {
207+
canonicalSlug = id
208+
}
209+
name := strings.TrimSpace(item.Name)
210+
if name == "" {
211+
name = id
212+
}
213+
return OpenRouterOfficialPricingItemResponse{
214+
ID: id,
215+
CanonicalSlug: canonicalSlug,
216+
Name: name,
217+
Pricing: OpenRouterOfficialPricingUnitPricingResponse{
218+
Prompt: strings.TrimSpace(item.Pricing.Prompt),
219+
Completion: strings.TrimSpace(item.Pricing.Completion),
220+
InputCacheRead: strings.TrimSpace(item.Pricing.InputCacheRead),
221+
InputCacheWrite: strings.TrimSpace(item.Pricing.InputCacheWrite),
222+
},
223+
}
224+
}
225+
226+
func openRouterOfficialPricingCacheStale(fetchedAt time.Time) bool {
227+
return fetchedAt.IsZero() || time.Since(fetchedAt) > openRouterPricingCacheTTL
228+
}

backend/internal/transport/http/billing/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ func (m *Module) RegisterAdminRoutes(adminGroup *gin.RouterGroup) {
3838
adminGroup.DELETE("/billing/redemption-codes/:id", m.Handler.DeleteRedemptionCode)
3939
adminGroup.GET("/billing/model-prices", m.Handler.ListModelPricing)
4040
adminGroup.PUT("/billing/model-prices", m.Handler.UpsertModelPricing)
41+
adminGroup.GET("/billing/official-pricing/openrouter", m.Handler.GetOpenRouterOfficialPricing)
4142
}

frontend/features/admin/api/billing.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
AdminModelPricingDTO,
1616
AdminModelPricingData,
1717
AdminModelPricingPage,
18+
AdminOfficialPricingCatalogData,
1819
CreateAdminRedemptionCodeRequest,
1920
UpdateAdminRedemptionCodeRequest,
2021
UpdateAdminBillingConfigRequest,
@@ -183,3 +184,19 @@ export async function upsertAdminModelPricing(
183184
true,
184185
);
185186
}
187+
188+
export async function getAdminOpenRouterOfficialPricing(
189+
accessToken: string,
190+
options: { refresh?: boolean } = {},
191+
): Promise<AdminOfficialPricingCatalogData> {
192+
const params = new URLSearchParams();
193+
if (options.refresh) {
194+
params.set("refresh", "true");
195+
}
196+
const suffix = params.size > 0 ? `?${params.toString()}` : "";
197+
return authedRequest<AdminOfficialPricingCatalogData>(
198+
`/api/v1/admin/billing/official-pricing/openrouter${suffix}`,
199+
{ accessToken },
200+
true,
201+
);
202+
}

frontend/features/admin/api/billing.types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,25 @@ export type AdminModelPricingData = {
6868
modelPricing: AdminModelPricingDTO;
6969
};
7070

71+
export type AdminOfficialPricingCatalogItemDTO = {
72+
id: string;
73+
canonicalSlug: string;
74+
name: string;
75+
pricing: {
76+
prompt: string;
77+
completion: string;
78+
inputCacheRead: string;
79+
inputCacheWrite: string;
80+
};
81+
};
82+
83+
export type AdminOfficialPricingCatalogData = {
84+
fetchedAt: string;
85+
cached: boolean;
86+
stale: boolean;
87+
items: AdminOfficialPricingCatalogItemDTO[];
88+
};
89+
7190
export type UpdateAdminBillingPlanRequest = {
7291
name: string;
7392
description: string;

0 commit comments

Comments
 (0)