Skip to content

Commit 4c49791

Browse files
feat: add model pricing page
Expose AI Gateway token pricing in the local model API and add a pricing view that matches the app settings/navigation flow. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b636514 commit 4c49791

12 files changed

Lines changed: 748 additions & 35 deletions

File tree

internal/cloud/opencsg.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type remoteModel struct {
4949
OwnedBy string `json:"owned_by"`
5050
Task string `json:"task"`
5151
DisplayName string `json:"display_name"`
52+
OfficialName string `json:"official_name"`
5253
Public bool `json:"public"`
5354
MaxInputTokens int `json:"max_input_tokens"`
5455
MaxTokens int `json:"max_tokens"`
@@ -172,6 +173,9 @@ func modelInfoFromRemote(item remoteModel) (api.ModelInfo, bool) {
172173
}
173174

174175
displayName := strings.TrimSpace(item.DisplayName)
176+
if displayName == "" {
177+
displayName = strings.TrimSpace(item.OfficialName)
178+
}
175179
if displayName == "" {
176180
displayName = strings.TrimSpace(item.ID)
177181
}
@@ -187,6 +191,10 @@ func modelInfoFromRemote(item remoteModel) (api.ModelInfo, bool) {
187191
}
188192
limits := modelTokenLimitsFromRemote(item)
189193

194+
llmType := extractLLMType(item.Metadata)
195+
ownedBy := strings.TrimSpace(item.OwnedBy)
196+
pricing := extractPricing(item.Metadata)
197+
190198
return api.ModelInfo{
191199
Name: item.ID,
192200
Model: item.ID,
@@ -198,6 +206,9 @@ func modelInfoFromRemote(item remoteModel) (api.ModelInfo, bool) {
198206
PipelineTag: pipelineTag,
199207
HasMMProj: item.Task == "image-text-to-text",
200208
ContextWindow: int64(limits.MaxInputTokens),
209+
LLMType: llmType,
210+
OwnedBy: ownedBy,
211+
Pricing: pricing,
201212
}, true
202213
}
203214

@@ -325,6 +336,90 @@ func firstPositive(values ...int) int {
325336
return 0
326337
}
327338

339+
func extractLLMType(metadata map[string]interface{}) string {
340+
if len(metadata) == 0 {
341+
return ""
342+
}
343+
if v, ok := metadata["llm_type"]; ok {
344+
switch val := v.(type) {
345+
case string:
346+
return strings.TrimSpace(val)
347+
}
348+
}
349+
return ""
350+
}
351+
352+
func extractPricing(metadata map[string]interface{}) *api.ModelPricing {
353+
if len(metadata) == 0 {
354+
return nil
355+
}
356+
357+
pricingMap, ok := metadata["pricing"].(map[string]interface{})
358+
if !ok || len(pricingMap) == 0 {
359+
return nil
360+
}
361+
362+
input := extractTokenPrice(pricingMap["input_token_price"])
363+
output := extractTokenPrice(pricingMap["output_token_price"])
364+
if input == nil && output == nil {
365+
return nil
366+
}
367+
368+
return &api.ModelPricing{
369+
InputTokenPrice: input,
370+
OutputTokenPrice: output,
371+
}
372+
}
373+
374+
func extractTokenPrice(value interface{}) *api.ModelTokenPrice {
375+
priceMap, ok := value.(map[string]interface{})
376+
if !ok || len(priceMap) == 0 {
377+
return nil
378+
}
379+
380+
price, ok := numericPriceValue(priceMap["price_per_million"])
381+
if !ok {
382+
return nil
383+
}
384+
385+
return &api.ModelTokenPrice{
386+
Currency: stringValue(priceMap["currency"]),
387+
PricePerMillion: price,
388+
}
389+
}
390+
391+
func stringValue(value interface{}) string {
392+
switch v := value.(type) {
393+
case string:
394+
return strings.TrimSpace(v)
395+
default:
396+
return ""
397+
}
398+
}
399+
400+
func numericPriceValue(value interface{}) (float64, bool) {
401+
switch v := value.(type) {
402+
case float64:
403+
return v, true
404+
case float32:
405+
return float64(v), true
406+
case int:
407+
return float64(v), true
408+
case int64:
409+
return float64(v), true
410+
case int32:
411+
return float64(v), true
412+
case json.Number:
413+
n, err := v.Float64()
414+
return n, err == nil
415+
case string:
416+
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
417+
return n, err == nil
418+
default:
419+
return 0, false
420+
}
421+
}
422+
328423
func cloneModels(models []api.ModelInfo) []api.ModelInfo {
329424
if len(models) == 0 {
330425
return nil

internal/cloud/opencsg_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ func TestModelInfoFromRemote_LabelFallsBackToID(t *testing.T) {
4848
}
4949
}
5050

51+
func TestModelInfoFromRemote_LabelUsesOfficialName(t *testing.T) {
52+
info, ok := modelInfoFromRemote(remoteModel{
53+
ID: "Qwen/Qwen3Guard-Gen-0.6B:s-qwen-qwen3guard-gen-0-6b(OpenCSG)",
54+
Task: "text-generation",
55+
OfficialName: "Qwen3Guard-Gen-0.6B",
56+
})
57+
if !ok {
58+
t.Fatal("expected model to be included")
59+
}
60+
if info.Label != "Qwen3Guard-Gen-0.6B" {
61+
t.Fatalf("Label = %q, want official name", info.Label)
62+
}
63+
}
64+
5165
func TestModelInfoFromRemote_LabelPreservesProviderSuffix(t *testing.T) {
5266
info, ok := modelInfoFromRemote(remoteModel{
5367
ID: "deepseek-v3.2",
@@ -62,6 +76,45 @@ func TestModelInfoFromRemote_LabelPreservesProviderSuffix(t *testing.T) {
6276
}
6377
}
6478

79+
func TestModelInfoFromRemote_ExtractsPricing(t *testing.T) {
80+
info, ok := modelInfoFromRemote(remoteModel{
81+
ID: "Qwen/Qwen3Guard-Gen-0.6B",
82+
Task: "text-generation",
83+
OwnedBy: "OpenCSG",
84+
Metadata: map[string]interface{}{
85+
"llm_type": "serverless",
86+
"pricing": map[string]interface{}{
87+
"input_token_price": map[string]interface{}{
88+
"currency": "¥",
89+
"price_per_million": 0.12,
90+
},
91+
"output_token_price": map[string]interface{}{
92+
"currency": "¥",
93+
"price_per_million": "0.24",
94+
},
95+
},
96+
},
97+
})
98+
if !ok {
99+
t.Fatal("expected model to be included")
100+
}
101+
if info.LLMType != "serverless" {
102+
t.Fatalf("LLMType = %q, want serverless", info.LLMType)
103+
}
104+
if info.OwnedBy != "OpenCSG" {
105+
t.Fatalf("OwnedBy = %q, want OpenCSG", info.OwnedBy)
106+
}
107+
if info.Pricing == nil || info.Pricing.InputTokenPrice == nil || info.Pricing.OutputTokenPrice == nil {
108+
t.Fatalf("Pricing = %#v, want input and output prices", info.Pricing)
109+
}
110+
if info.Pricing.InputTokenPrice.Currency != "¥" || info.Pricing.InputTokenPrice.PricePerMillion != 0.12 {
111+
t.Fatalf("InputTokenPrice = %#v, want ¥0.12", info.Pricing.InputTokenPrice)
112+
}
113+
if info.Pricing.OutputTokenPrice.Currency != "¥" || info.Pricing.OutputTokenPrice.PricePerMillion != 0.24 {
114+
t.Fatalf("OutputTokenPrice = %#v, want ¥0.24", info.Pricing.OutputTokenPrice)
115+
}
116+
}
117+
65118
func TestModelInfoFromRemote_VisionModelEnablesImages(t *testing.T) {
66119
info, ok := modelInfoFromRemote(remoteModel{
67120
ID: "Qwen/Qwen3.5-35B-A3B-FP8:xyz",

internal/server/cloud_models.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package server
33
import (
44
"context"
55
"net/http"
6+
"sort"
67
"strings"
78
"time"
89

@@ -70,6 +71,8 @@ func (s *Server) listAvailableModelsWithRefresh(ctx context.Context, refreshClou
7071
out = append(out, item)
7172
}
7273

74+
sortModelsByPriority(out)
75+
7376
return out, nil
7477
}
7578

@@ -124,3 +127,26 @@ func (s *Server) refreshCloudChatModels(ctx context.Context) ([]api.ModelInfo, e
124127
return models, err
125128
}
126129
}
130+
131+
func sortModelsByPriority(models []api.ModelInfo) {
132+
sort.SliceStable(models, func(i, j int) bool {
133+
iType := strings.TrimSpace(strings.ToLower(models[i].LLMType))
134+
jType := strings.TrimSpace(strings.ToLower(models[j].LLMType))
135+
iOwner := strings.TrimSpace(strings.ToLower(models[i].OwnedBy))
136+
jOwner := strings.TrimSpace(strings.ToLower(models[j].OwnedBy))
137+
138+
iIsExternal := iType == "external_llm"
139+
jIsExternal := jType == "external_llm"
140+
if iIsExternal != jIsExternal {
141+
return iIsExternal
142+
}
143+
144+
iIsOpenCSG := iOwner == "opencsg"
145+
jIsOpenCSG := jOwner == "opencsg"
146+
if iIsOpenCSG != jIsOpenCSG {
147+
return iIsOpenCSG
148+
}
149+
150+
return models[i].Model < models[j].Model
151+
})
152+
}

internal/server/static/openapi/local-api.json

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3259,24 +3259,62 @@
32593259
"display_name": {
32603260
"type": "string"
32613261
},
3262+
"label": {
3263+
"type": "string"
3264+
},
32623265
"source": {
3263-
"type": "string",
3264-
"enum": [
3265-
"local",
3266-
"cloud"
3267-
]
3266+
"type": "string"
32683267
},
32693268
"pipeline_tag": {
32703269
"type": "string"
32713270
},
32723271
"has_mmproj": {
32733272
"type": "boolean"
32743273
},
3274+
"context_window": {
3275+
"type": "integer"
3276+
},
32753277
"description": {
32763278
"type": "string"
32773279
},
32783280
"license": {
32793281
"type": "string"
3282+
},
3283+
"llm_type": {
3284+
"type": "string"
3285+
},
3286+
"owned_by": {
3287+
"type": "string"
3288+
},
3289+
"pricing": {
3290+
"$ref": "#/components/schemas/ModelPricing"
3291+
}
3292+
}
3293+
},
3294+
"ModelPricing": {
3295+
"type": "object",
3296+
"additionalProperties": false,
3297+
"properties": {
3298+
"input_token_price": {
3299+
"$ref": "#/components/schemas/ModelTokenPrice"
3300+
},
3301+
"output_token_price": {
3302+
"$ref": "#/components/schemas/ModelTokenPrice"
3303+
}
3304+
}
3305+
},
3306+
"ModelTokenPrice": {
3307+
"type": "object",
3308+
"additionalProperties": false,
3309+
"required": [
3310+
"price_per_million"
3311+
],
3312+
"properties": {
3313+
"currency": {
3314+
"type": "string"
3315+
},
3316+
"price_per_million": {
3317+
"type": "number"
32803318
}
32813319
}
32823320
},

openapi/local-api.json

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3259,24 +3259,62 @@
32593259
"display_name": {
32603260
"type": "string"
32613261
},
3262+
"label": {
3263+
"type": "string"
3264+
},
32623265
"source": {
3263-
"type": "string",
3264-
"enum": [
3265-
"local",
3266-
"cloud"
3267-
]
3266+
"type": "string"
32683267
},
32693268
"pipeline_tag": {
32703269
"type": "string"
32713270
},
32723271
"has_mmproj": {
32733272
"type": "boolean"
32743273
},
3274+
"context_window": {
3275+
"type": "integer"
3276+
},
32753277
"description": {
32763278
"type": "string"
32773279
},
32783280
"license": {
32793281
"type": "string"
3282+
},
3283+
"llm_type": {
3284+
"type": "string"
3285+
},
3286+
"owned_by": {
3287+
"type": "string"
3288+
},
3289+
"pricing": {
3290+
"$ref": "#/components/schemas/ModelPricing"
3291+
}
3292+
}
3293+
},
3294+
"ModelPricing": {
3295+
"type": "object",
3296+
"additionalProperties": false,
3297+
"properties": {
3298+
"input_token_price": {
3299+
"$ref": "#/components/schemas/ModelTokenPrice"
3300+
},
3301+
"output_token_price": {
3302+
"$ref": "#/components/schemas/ModelTokenPrice"
3303+
}
3304+
}
3305+
},
3306+
"ModelTokenPrice": {
3307+
"type": "object",
3308+
"additionalProperties": false,
3309+
"required": [
3310+
"price_per_million"
3311+
],
3312+
"properties": {
3313+
"currency": {
3314+
"type": "string"
3315+
},
3316+
"price_per_million": {
3317+
"type": "number"
32803318
}
32813319
}
32823320
},

0 commit comments

Comments
 (0)