2026-05-18 19:01:12 +08:00
package service
import (
"context"
2026-08-22 14:46:40 +09:00
"encoding/json"
2026-05-18 19:01:12 +08:00
"errors"
"io"
"net/http"
"strings"
"testing"
2026-07-22 13:32:35 +08:00
"time"
2026-05-18 19:01:12 +08:00
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
2026-08-22 14:46:40 +09:00
type upstreamModelMetadataRepoStub struct {
AccountRepository
accountID int64
updates map [ string ] any
err error
}
func headerValuesEqualFold ( header http . Header , name string ) [] string {
for key , values := range header {
if strings . EqualFold ( key , name ) {
return values
}
}
return nil
}
func ( r * upstreamModelMetadataRepoStub ) UpdateExtra ( _ context . Context , id int64 , updates map [ string ] any ) error {
r . accountID = id
r . updates = updates
return r . err
}
2026-05-18 19:01:12 +08:00
func upstreamModelSyncTestConfig () * config . Config {
return & config . Config {
Security : config . SecurityConfig {
URLAllowlist : config . URLAllowlistConfig { Enabled : false },
},
}
}
2026-07-22 13:32:35 +08:00
func grokOAuthModelSyncTestAccount ( baseURL string ) * Account {
credentials := map [ string ] any {
"access_token" : "oauth-access-token" ,
"refresh_token" : "oauth-refresh-token" ,
"expires_at" : time . Now (). Add ( time . Hour ). UTC (). Format ( time . RFC3339 ),
"sub" : "grok-user-id" ,
"email" : "grok-user@example.com" ,
}
if strings . TrimSpace ( baseURL ) != "" {
credentials [ "base_url" ] = baseURL
}
return & Account {
ID : 10 ,
Platform : PlatformGrok ,
Type : AccountTypeOAuth ,
Credentials : credentials ,
}
}
2026-05-18 19:01:12 +08:00
func TestBuildV1ModelsURL ( t * testing . T ) {
t . Parallel ()
require . Equal ( t , "https://api.anthropic.com/v1/models" , buildV1ModelsURL ( "https://api.anthropic.com" ))
require . Equal ( t , "https://api.anthropic.com/v1/models" , buildV1ModelsURL ( "https://api.anthropic.com/v1" ))
require . Equal ( t , "https://api.anthropic.com/v1/models" , buildV1ModelsURL ( "https://api.anthropic.com/v1/models" ))
require . Equal ( t , "https://gateway.example.com/antigravity/v1/models" , buildV1ModelsURL ( "https://gateway.example.com/antigravity/" ))
}
2026-07-06 10:51:25 +08:00
func TestBuildOpenAIModelsURL ( t * testing . T ) {
t . Parallel ()
tests := [] struct {
name string
base string
want string
}{
{
name : "zhipu v4 coding base url" ,
base : "https://open.bigmodel.cn/api/coding/paas/v4" ,
want : "https://open.bigmodel.cn/api/coding/paas/v4/models" ,
},
{
name : "openai v1 base url" ,
base : "https://api.openai.com/v1" ,
want : "https://api.openai.com/v1/models" ,
},
{
name : "models url unchanged" ,
base : "https://api.openai.com/v1/models" ,
want : "https://api.openai.com/v1/models" ,
},
{
name : "host fallback uses v1" ,
base : "https://api.openai.com" ,
want : "https://api.openai.com/v1/models" ,
},
{
name : "trailing slash on v4" ,
base : "https://open.bigmodel.cn/api/coding/paas/v4/" ,
want : "https://open.bigmodel.cn/api/coding/paas/v4/models" ,
},
{
name : "v2 base url" ,
base : "https://gateway.example.com/openai/v2" ,
want : "https://gateway.example.com/openai/v2/models" ,
},
{
name : "v3 base url" ,
base : "https://gateway.example.com/openai/v3" ,
want : "https://gateway.example.com/openai/v3/models" ,
},
}
for _ , tt := range tests {
tt := tt
t . Run ( tt . name , func ( t * testing . T ) {
t . Parallel ()
require . Equal ( t , tt . want , buildOpenAIModelsURL ( tt . base ))
})
}
}
2026-05-18 19:01:12 +08:00
func TestBuildGeminiModelsURL ( t * testing . T ) {
t . Parallel ()
require . Equal ( t , "https://generativelanguage.googleapis.com/v1beta/models" , buildGeminiModelsURL ( "https://generativelanguage.googleapis.com" ))
require . Equal ( t , "https://generativelanguage.googleapis.com/v1beta/models" , buildGeminiModelsURL ( "https://generativelanguage.googleapis.com/v1beta" ))
require . Equal ( t , "https://generativelanguage.googleapis.com/v1beta/models" , buildGeminiModelsURL ( "https://generativelanguage.googleapis.com/v1beta/models" ))
}
func TestExtractUpstreamModelIDs ( t * testing . T ) {
t . Parallel ()
tests := [] struct {
name string
body string
want [] string
}{
{
name : "openai and anthropic data array" ,
body : `{"data":[{"id":"claude-sonnet-4-5"},{"id":"gpt-5"},{"id":"gpt-5"},{"id":""}]}` ,
want : [] string { "claude-sonnet-4-5" , "gpt-5" },
},
{
name : "gemini models array strips prefix" ,
body : `{"models":[{"name":"models/gemini-2.5-pro"},{"name":"gemini-2.5-flash"}]}` ,
want : [] string { "gemini-2.5-flash" , "gemini-2.5-pro" },
},
{
name : "top level array" ,
body : `[{"id":"z-model"},{"name":"models/a-model"}]` ,
want : [] string { "a-model" , "z-model" },
},
2026-07-22 13:32:35 +08:00
{
name : "standard id wins over provider-specific model field" ,
body : `{"data":[{"id":"canonical-id","model":"display-model"}]}` ,
want : [] string { "canonical-id" },
},
2026-08-23 01:42:27 +08:00
{
name : "codex manifest uses slug" ,
body : `{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}` ,
want : [] string { "gpt-5.5-codex" , "gpt-5.6-sol" },
},
2026-05-18 19:01:12 +08:00
}
for _ , tt := range tests {
tt := tt
t . Run ( tt . name , func ( t * testing . T ) {
t . Parallel ()
got , err := extractUpstreamModelIDs ([] byte ( tt . body ))
require . NoError ( t , err )
require . Equal ( t , tt . want , got )
})
}
}
2026-08-23 01:42:27 +08:00
func TestBuildUpstreamModelsRequestSupportsOpenAIOAuth ( t * testing . T ) {
svc := & AccountTestService { cfg : upstreamModelSyncTestConfig ()}
account := & Account {
ID : 11 ,
Platform : PlatformOpenAI ,
Type : AccountTypeOAuth ,
Credentials : map [ string ] any {
"access_token" : "openai-oauth-token" ,
"chatgpt_account_id" : "chatgpt-account" ,
},
}
req , err := svc . buildUpstreamModelsRequest ( context . Background (), account )
require . NoError ( t , err )
require . Equal ( t , chatgptCodexModelsURL , req . URL . Scheme + "://" + req . URL . Host + req . URL . Path )
require . NotEmpty ( t , req . URL . Query (). Get ( "client_version" ))
require . Equal ( t , "Bearer openai-oauth-token" , req . Header . Get ( "Authorization" ))
require . Equal ( t , "chatgpt-account" , req . Header . Get ( "chatgpt-account-id" ))
require . NotEmpty ( t , req . Header . Get ( "Originator" ))
require . NotEmpty ( t , req . Header . Get ( "User-Agent" ))
require . NotEmpty ( t , req . Header . Get ( "Version" ))
}
func TestFetchUpstreamSupportedModelsParsesOpenAIOAuthManifest ( t * testing . T ) {
upstream := & httpUpstreamRecorder { resp : & http . Response {
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}` )),
}}
svc := & AccountTestService {
httpUpstream : upstream ,
cfg : upstreamModelSyncTestConfig (),
}
models , err := svc . FetchUpstreamSupportedModels ( context . Background (), & Account {
ID : 12 ,
Platform : PlatformOpenAI ,
Type : AccountTypeOAuth ,
Credentials : map [ string ] any {
"access_token" : "openai-oauth-token" ,
},
})
require . NoError ( t , err )
require . Equal ( t , [] string { "gpt-5.5-codex" , "gpt-5.6-sol" }, models )
require . Equal ( t , "Bearer openai-oauth-token" , upstream . lastReq . Header . Get ( "Authorization" ))
}
2026-07-22 13:32:35 +08:00
func TestExtractGrokUpstreamModelIDs ( t * testing . T ) {
t . Parallel ()
models , err := extractGrokUpstreamModelIDs ([] byte ( `{"data":[{"id":"display-id","model":"grok-4.5"},{"modelId":"grok-build-0.1"},{"model_id":"grok-composer-2.5-fast"},{"name":"Grok Meta Display Name","_meta":{"model":"grok-meta"}},{"name":"grok-name"},{"id":"grok-safe","_meta":"not-an-object"}]}` ))
require . NoError ( t , err )
require . Equal ( t , [] string { "grok-4.5" , "grok-build-0.1" , "grok-composer-2.5-fast" , "grok-meta" , "grok-name" , "grok-safe" }, models )
}
2026-05-18 19:01:12 +08:00
func TestBuildUpstreamModelsRequestsForAPIKeyAccounts ( t * testing . T ) {
t . Parallel ()
svc := & AccountTestService { cfg : upstreamModelSyncTestConfig ()}
ctx := context . Background ()
anthropicReq , err := svc . buildAnthropicUpstreamModelsRequest ( ctx , & Account {
Platform : PlatformAnthropic ,
Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "anthropic-key" ,
"base_url" : "https://anthropic.example.com/v1" ,
},
})
require . NoError ( t , err )
require . Equal ( t , "https://anthropic.example.com/v1/models" , anthropicReq . URL . String ())
require . Equal ( t , "anthropic-key" , anthropicReq . Header . Get ( "x-api-key" ))
require . Equal ( t , "2023-06-01" , anthropicReq . Header . Get ( "anthropic-version" ))
2026-07-02 10:12:35 +08:00
anthropicBearerReq , err := svc . buildAnthropicUpstreamModelsRequest ( ctx , & Account {
Platform : PlatformAnthropic ,
Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "ollama-key" ,
"base_url" : "https://ollama.com" ,
},
Extra : map [ string ] any {
"anthropic_apikey_auth_scheme" : AnthropicAPIKeyAuthSchemeAuthorizationBearer ,
},
})
require . NoError ( t , err )
require . Equal ( t , "https://ollama.com/v1/models" , anthropicBearerReq . URL . String ())
require . Equal ( t , "Bearer ollama-key" , anthropicBearerReq . Header . Get ( "Authorization" ))
require . Empty ( t , anthropicBearerReq . Header . Get ( "x-api-key" ))
require . Equal ( t , "2023-06-01" , anthropicBearerReq . Header . Get ( "anthropic-version" ))
2026-05-18 19:01:12 +08:00
openAIReq , err := svc . buildOpenAIUpstreamModelsRequest ( ctx , & Account {
Platform : PlatformOpenAI ,
Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "openai-key" ,
"base_url" : "https://openai.example.com" ,
},
})
require . NoError ( t , err )
require . Equal ( t , "https://openai.example.com/v1/models" , openAIReq . URL . String ())
require . Equal ( t , "Bearer openai-key" , openAIReq . Header . Get ( "Authorization" ))
2026-07-13 11:46:52 +08:00
grokReq , err := svc . buildUpstreamModelsRequest ( ctx , & Account {
Platform : PlatformGrok ,
Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "xai-key" ,
"base_url" : "https://xai.example.com/v1" ,
},
})
require . NoError ( t , err )
require . Equal ( t , "https://xai.example.com/v1/models" , grokReq . URL . String ())
require . Equal ( t , "Bearer xai-key" , grokReq . Header . Get ( "Authorization" ))
2026-05-18 19:01:12 +08:00
geminiReq , err := svc . buildGeminiUpstreamModelsRequest ( ctx , & Account {
Platform : PlatformGemini ,
Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "gemini-key" ,
"base_url" : "https://generativelanguage.googleapis.com/v1beta" ,
},
})
require . NoError ( t , err )
require . Equal ( t , "https://generativelanguage.googleapis.com/v1beta/models" , geminiReq . URL . String ())
require . Equal ( t , "gemini-key" , geminiReq . Header . Get ( "x-goog-api-key" ))
antigravityReq , err := svc . buildAntigravityAPIKeyModelsRequest ( ctx , & Account {
Platform : PlatformAntigravity ,
Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "antigravity-key" ,
"base_url" : "https://gateway.example.com/antigravity" ,
},
})
require . NoError ( t , err )
require . Equal ( t , "https://gateway.example.com/antigravity/v1/models" , antigravityReq . URL . String ())
require . Equal ( t , "antigravity-key" , antigravityReq . Header . Get ( "x-api-key" ))
}
2026-07-22 13:32:35 +08:00
func TestBuildUpstreamModelsRequestSupportsGrokOAuth ( t * testing . T ) {
t . Parallel ()
svc := & AccountTestService {
cfg : upstreamModelSyncTestConfig (),
grokTokenProvider : NewGrokTokenProvider ( nil , nil ),
}
req , err := svc . buildUpstreamModelsRequest ( context . Background (), grokOAuthModelSyncTestAccount ( "" ))
require . NoError ( t , err )
require . Equal ( t , "https://cli-chat-proxy.grok.com/v1/models" , req . URL . String ())
require . Equal ( t , "Bearer oauth-access-token" , req . Header . Get ( "Authorization" ))
require . Equal ( t , grokCLIVersion , req . Header . Get ( "X-Grok-Client-Version" ))
require . Equal ( t , "interactive" , req . Header . Get ( "X-Grok-Client-Mode" ))
2026-08-07 16:55:36 +08:00
require . Equal ( t , defaultGrokUpstreamUserAgent (), req . Header . Get ( "User-Agent" ))
2026-07-22 13:32:35 +08:00
require . Equal ( t , "grok-user-id" , req . Header . Get ( "X-UserID" ))
require . Equal ( t , "grok-user@example.com" , req . Header . Get ( "X-Email" ))
require . NotContains ( t , req . Header . Get ( "Authorization" ), "oauth-refresh-token" )
}
func TestBuildUpstreamModelsRequestGrokOAuthRequiresTokenProvider ( t * testing . T ) {
2026-07-13 11:46:52 +08:00
t . Parallel ()
svc := & AccountTestService { cfg : upstreamModelSyncTestConfig ()}
2026-07-22 13:32:35 +08:00
_ , err := svc . buildUpstreamModelsRequest ( context . Background (), grokOAuthModelSyncTestAccount ( "" ))
2026-07-13 11:46:52 +08:00
require . Error ( t , err )
var syncErr * UpstreamModelSyncError
require . True ( t , errors . As ( err , & syncErr ))
2026-07-22 13:32:35 +08:00
require . Equal ( t , UpstreamModelSyncErrorConfiguration , syncErr . Kind )
require . Contains ( t , syncErr . SafeMessage (), "token provider" )
2026-07-13 11:46:52 +08:00
}
2026-05-18 19:01:12 +08:00
func TestBuildAntigravityAPIKeyModelsRequestRejectsOfficialCloudCodeBase ( t * testing . T ) {
t . Parallel ()
svc := & AccountTestService { cfg : upstreamModelSyncTestConfig ()}
_ , err := svc . buildAntigravityAPIKeyModelsRequest ( context . Background (), & Account {
Platform : PlatformAntigravity ,
Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "antigravity-key" ,
"base_url" : "https://cloudcode-pa.googleapis.com" ,
},
})
require . Error ( t , err )
var syncErr * UpstreamModelSyncError
require . True ( t , errors . As ( err , & syncErr ))
require . Equal ( t , UpstreamModelSyncErrorUnsupported , syncErr . Kind )
require . Contains ( t , syncErr . SafeMessage (), "compatible gateway" )
}
func TestBuildAnthropicUpstreamModelsRequestRejectsBedrock ( t * testing . T ) {
t . Parallel ()
svc := & AccountTestService { cfg : upstreamModelSyncTestConfig ()}
_ , err := svc . buildAnthropicUpstreamModelsRequest ( context . Background (), & Account {
Platform : PlatformAnthropic ,
Type : AccountTypeBedrock ,
})
require . Error ( t , err )
var syncErr * UpstreamModelSyncError
require . True ( t , errors . As ( err , & syncErr ))
require . Equal ( t , UpstreamModelSyncErrorUnsupported , syncErr . Kind )
}
func TestFetchUpstreamSupportedModelsParsesOpenAIResponse ( t * testing . T ) {
t . Parallel ()
upstream := & httpUpstreamRecorder { resp : & http . Response {
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"data":[{"id":"gpt-5"},{"id":"gpt-5"},{"name":"o3"}]}` )),
}}
svc := & AccountTestService {
httpUpstream : upstream ,
cfg : upstreamModelSyncTestConfig (),
}
models , err := svc . FetchUpstreamSupportedModels ( context . Background (), & Account {
ID : 7 ,
Platform : PlatformOpenAI ,
Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "openai-key" ,
"base_url" : "https://openai.example.com/v1" ,
},
})
require . NoError ( t , err )
require . Equal ( t , [] string { "gpt-5" , "o3" }, models )
require . Equal ( t , "https://openai.example.com/v1/models" , upstream . lastReq . URL . String ())
require . Equal ( t , "Bearer openai-key" , upstream . lastReq . Header . Get ( "Authorization" ))
}
2026-08-22 14:46:40 +09:00
// Scenario: ID-only 模型列表从 Models.dev 补齐能力。
func TestSyncUpstreamModelCatalogEnrichesOpenCodeIDOnlyListAndPersistsSnapshot ( t * testing . T ) {
upstream := & httpUpstreamRecorder { responses : [] * http . Response {
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"object":"list","data":[{"id":"x-preview-f-free","object":"model"}]}` )),
},
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{
"opencode": {
"id": "opencode",
"name": "OpenCode Zen",
"api": "https://opencode.ai/zen/v1",
"models": {
"x-preview-f-free": {
"id": "x-preview-f-free",
"name": "Ox Alpha Free (Unlimited)",
"description": "Stealth reasoning model for coding, agentic tasks, and tool use",
"reasoning": true,
"reasoning_options": [{"type":"effort","values":["low","high","max"]}],
"modalities": {"input":["text","image","video"],"output":["text"]},
"limit": {"context":1000000,"output":131072}
}
}
}
}` )),
},
}}
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService {
accountRepo : repo ,
httpUpstream : upstream ,
cfg : upstreamModelSyncTestConfig (),
}
account := & Account {
ID : 91 ,
Platform : PlatformOpenAI ,
Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "opencode-key" ,
"base_url" : "https://opencode.ai/zen/v1" ,
"header_override_enabled" : true ,
"header_overrides" : map [ string ] any {
"X-Custom-Account-Header" : "account-secret" ,
},
},
}
catalog , err := svc . SyncUpstreamModelCatalog ( context . Background (), account )
require . NoError ( t , err )
require . Equal ( t , [] string { "x-preview-f-free" }, catalog . Models )
require . Len ( t , upstream . requests , 2 )
require . Equal ( t , "https://opencode.ai/zen/v1/models" , upstream . requests [ 0 ]. URL . String ())
require . Equal ( t , [] string { "account-secret" }, headerValuesEqualFold ( upstream . requests [ 0 ]. Header , "X-Custom-Account-Header" ))
require . Equal ( t , modelsDevRegistryURL , upstream . requests [ 1 ]. URL . String ())
require . Empty ( t , upstream . requests [ 1 ]. Header . Get ( "Authorization" ))
require . Empty ( t , upstream . requests [ 1 ]. Header . Get ( "x-api-key" ))
require . Empty ( t , headerValuesEqualFold ( upstream . requests [ 1 ]. Header , "X-Custom-Account-Header" ))
metadata := catalog . Metadata [ "x-preview-f-free" ]
require . Equal ( t , "Ox Alpha Free (Unlimited)" , metadata . DisplayName )
require . NotNil ( t , metadata . Reasoning )
require . True ( t , * metadata . Reasoning )
require . Equal ( t , [] string { "low" , "high" , "max" }, metadata . SupportedReasoningLevels )
require . Equal ( t , [] string { "text" , "image" }, metadata . InputModalities )
require . Equal ( t , int64 ( 1_000_000 ), metadata . ContextWindow )
require . Equal ( t , int64 ( 131_072 ), metadata . MaxOutputTokens )
require . Equal ( t , int64 ( 91 ), repo . accountID )
rawSnapshot , ok := repo . updates [ UpstreamModelMetadataExtraKey ]
require . True ( t , ok )
encoded , err := json . Marshal ( rawSnapshot )
require . NoError ( t , err )
var snapshot UpstreamModelMetadataSnapshot
require . NoError ( t , json . Unmarshal ( encoded , & snapshot ))
require . Equal ( t , "models.dev" , snapshot . Source )
require . Equal ( t , metadata , snapshot . Models [ "x-preview-f-free" ])
}
// Scenario: 不提供 /models 的兼容上游使用管理员已配置模型继续同步能力。
func TestSyncUpstreamModelCatalogUsesConfiguredModelsWhenListEndpointUnsupported ( t * testing . T ) {
upstream := & httpUpstreamRecorder { responses : [] * http . Response {
{
StatusCode : http . StatusNotFound ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"error":"not found"}` )),
},
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{
"configured-provider": {
"id": "configured-provider",
"name": "Configured Provider",
"api": "https://provider.example/v1",
"models": {
"glm-5.3": {
"id": "glm-5.3",
"name": "GLM-5.3",
"reasoning": true,
"reasoning_options": [{"type":"effort","values":["low","medium","high"]}],
"modalities": {"input":["text"],"output":["text"]},
"limit": {"context":1000000,"output":131072}
}
}
}
}` )),
},
}}
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
account := & Account {
ID : 97 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "key" ,
"base_url" : "https://provider.example/v1" ,
"model_mapping" : map [ string ] any {
"public-glm" : "glm-5.3" ,
"duplicate" : "glm-5.3" ,
"wildcard" : "glm-*" ,
"empty" : "" ,
},
},
}
2026-09-05 16:24:15 +08:00
account . SetUpstreamModelMetadataSnapshot ( UpstreamModelMetadataSnapshot { Models : map [ string ] UpstreamModelMetadata {
"old-live-model" : { ID : "old-live-model" , ContextWindow : 256000 },
}})
2026-08-22 14:46:40 +09:00
catalog , err := svc . SyncUpstreamModelCatalog ( context . Background (), account )
require . NoError ( t , err )
2026-09-05 16:24:15 +08:00
require . Contains ( t , account . GetUpstreamModelMetadataSnapshot (). Models , "old-live-model" , "an unavailable model-list endpoint is not evidence of removal" )
2026-08-22 14:46:40 +09:00
require . Equal ( t , [] string { "glm-5.3" }, catalog . Models )
require . Empty ( t , catalog . Warnings )
require . Len ( t , upstream . requests , 2 )
require . Equal ( t , "https://provider.example/v1/models" , upstream . requests [ 0 ]. URL . String ())
require . Equal ( t , modelsDevRegistryURL , upstream . requests [ 1 ]. URL . String ())
metadata := catalog . Metadata [ "glm-5.3" ]
require . Equal ( t , [] string { "low" , "medium" , "high" }, metadata . SupportedReasoningLevels )
require . Equal ( t , [] string { "text" }, metadata . InputModalities )
require . Equal ( t , int64 ( 1_000_000 ), metadata . ContextWindow )
require . NotNil ( t , repo . updates )
}
func TestSyncUpstreamModelCatalogDoesNotUseConfiguredModelsForRealUpstreamFailures ( t * testing . T ) {
tests := [] struct {
name string
statusCode int
}{
{ name : "unauthorized" , statusCode : http . StatusUnauthorized },
{ name : "rate limited" , statusCode : http . StatusTooManyRequests },
{ name : "server error" , statusCode : http . StatusBadGateway },
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
upstream := & httpUpstreamRecorder { resp : & http . Response {
StatusCode : tt . statusCode ,
Body : io . NopCloser ( strings . NewReader ( `{"error":"failed"}` )),
}}
svc := & AccountTestService { httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
_ , err := svc . SyncUpstreamModelCatalog ( context . Background (), & Account {
ID : 98 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "key" ,
"base_url" : "https://provider.example/v1" ,
"model_mapping" : map [ string ] any { "public-glm" : "glm-5.3" },
},
})
require . Error ( t , err )
require . Len ( t , upstream . requests , 1 )
require . Equal ( t , tt . statusCode , upstreamModelSyncStatusCode ( err ))
})
}
}
func TestSyncUpstreamModelCatalogRequiresConfiguredModelsForUnsupportedListEndpoint ( t * testing . T ) {
upstream := & httpUpstreamRecorder { resp : & http . Response {
StatusCode : http . StatusMethodNotAllowed ,
Body : io . NopCloser ( strings . NewReader ( `{"error":"method not allowed"}` )),
}}
svc := & AccountTestService { httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
_ , err := svc . SyncUpstreamModelCatalog ( context . Background (), & Account {
ID : 99 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any { "api_key" : "key" , "base_url" : "https://provider.example/v1" },
})
require . Error ( t , err )
require . Equal ( t , http . StatusMethodNotAllowed , upstreamModelSyncStatusCode ( err ))
require . Len ( t , upstream . requests , 1 )
}
// Scenario: 完整上游模型清单优先保存能力。
func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata ( t * testing . T ) {
upstream := & httpUpstreamRecorder { resp : & http . Response {
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"models":[{
"slug":"custom-thinking-model",
"display_name":"Upstream Display",
"description":"Upstream description",
"default_reasoning_level":"high",
2026-08-26 12:46:31 +09:00
"supported_reasoning_levels":[{"effort":"low"},{"effort":"high"},{"effort":"ultra"}],
2026-08-22 14:46:40 +09:00
"input_modalities":["text","image"],
"context_window":256000
}]}` )),
}}
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
catalog , err := svc . SyncUpstreamModelCatalog ( context . Background (), & Account {
ID : 92 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any { "api_key" : "key" , "base_url" : "https://provider.example/v1" },
})
require . NoError ( t , err )
require . Len ( t , upstream . requests , 1 , "complete upstream metadata must not be replaced by a registry fetch" )
metadata := catalog . Metadata [ "custom-thinking-model" ]
require . Equal ( t , "Upstream Display" , metadata . DisplayName )
require . Equal ( t , "high" , metadata . DefaultReasoningLevel )
2026-08-26 12:46:31 +09:00
require . Equal ( t , [] string { "low" , "high" , "ultra" }, metadata . SupportedReasoningLevels )
2026-08-22 14:46:40 +09:00
require . Equal ( t , [] string { "text" , "image" }, metadata . InputModalities )
require . Equal ( t , int64 ( 256_000 ), metadata . ContextWindow )
}
2026-08-26 12:46:31 +09:00
// Scenario: 上游 /models 增删型号后,正式同步用最新清单替换能力快照。
func TestSyncUpstreamModelCatalogReplacesSnapshotWhenUpstreamModelsChange ( t * testing . T ) {
upstream := & httpUpstreamRecorder { responses : [] * http . Response {
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"data":[
{"id":"old-model","reasoning":false,"input_modalities":["text"],"context_window":128000},
{"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000}
]}` )),
},
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"data":[
{"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000},
{"id":"new-model","reasoning":false,"input_modalities":["text"],"context_window":256000}
]}` )),
},
}}
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
account := & Account {
ID : 101 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "key" ,
"base_url" : "https://provider.example/v1" ,
},
}
first , err := svc . SyncUpstreamModelCatalog ( context . Background (), account )
require . NoError ( t , err )
require . Equal ( t , [] string { "kept-model" , "old-model" }, first . Models )
second , err := svc . SyncUpstreamModelCatalog ( context . Background (), account )
require . NoError ( t , err )
require . Equal ( t , [] string { "kept-model" , "new-model" }, second . Models )
require . NotContains ( t , second . Metadata , "old-model" )
require . Contains ( t , second . Metadata , "new-model" )
encoded , err := json . Marshal ( repo . updates [ UpstreamModelMetadataExtraKey ])
require . NoError ( t , err )
var snapshot UpstreamModelMetadataSnapshot
require . NoError ( t , json . Unmarshal ( encoded , & snapshot ))
require . NotContains ( t , snapshot . Models , "old-model" )
require . Contains ( t , snapshot . Models , "new-model" )
}
2026-08-22 14:46:40 +09:00
// Scenario: 上游明确声明无推理能力时保存 false。
func TestSyncUpstreamModelCatalogPersistsExplicitNonReasoningCapability ( t * testing . T ) {
upstream := & httpUpstreamRecorder { resp : & http . Response {
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"models":[{
"id":"company-coding-model",
"display_name":"Company Coding Model",
"reasoning":false,
"input_modalities":["text"],
"context_window":64000
}]}` )),
}}
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
catalog , err := svc . SyncUpstreamModelCatalog ( context . Background (), & Account {
ID : 94 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any { "api_key" : "key" , "base_url" : "https://provider.example/v1" },
})
require . NoError ( t , err )
require . Len ( t , upstream . requests , 1 )
metadata := catalog . Metadata [ "company-coding-model" ]
require . NotNil ( t , metadata . Reasoning )
require . False ( t , * metadata . Reasoning )
require . Empty ( t , metadata . SupportedReasoningLevels )
require . Equal ( t , [] string { "text" }, metadata . InputModalities )
require . Equal ( t , int64 ( 64_000 ), metadata . ContextWindow )
require . NotNil ( t , repo . updates )
}
func TestSyncUpstreamModelCatalogClassifiesSnapshotPersistenceFailureAsInternal ( t * testing . T ) {
upstream := & httpUpstreamRecorder { resp : & http . Response {
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"models":[{
"id":"company-coding-model",
"reasoning":false,
"input_modalities":["text"],
"context_window":64000
}]}` )),
}}
repo := & upstreamModelMetadataRepoStub { err : errors . New ( "database unavailable" )}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
_ , err := svc . SyncUpstreamModelCatalog ( context . Background (), & Account {
ID : 95 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any { "api_key" : "key" , "base_url" : "https://provider.example/v1" },
})
require . Error ( t , err )
var syncErr * UpstreamModelSyncError
require . ErrorAs ( t , err , & syncErr )
require . Equal ( t , UpstreamModelSyncErrorInternal , syncErr . Kind )
}
// Scenario: 元数据源失败时保留已有快照。
func TestSyncUpstreamModelCatalogDoesNotOverwriteSnapshotWhenRegistryFails ( t * testing . T ) {
upstream := & httpUpstreamRecorder { responses : [] * http . Response {
{ StatusCode : http . StatusOK , Body : io . NopCloser ( strings . NewReader ( `{"data":[{"id":"x-preview-f-free"}]}` ))},
{ StatusCode : http . StatusBadGateway , Body : io . NopCloser ( strings . NewReader ( `{"error":"unavailable"}` ))},
}}
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
catalog , err := svc . SyncUpstreamModelCatalog ( context . Background (), & Account {
ID : 93 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any { "api_key" : "key" , "base_url" : "https://opencode.ai/zen/v1" },
Extra : map [ string ] any { UpstreamModelMetadataExtraKey : map [ string ] any {
"source" : "models.dev" , "models" : map [ string ] any { "x-preview-f-free" : map [ string ] any { "reasoning" : true }},
}},
})
require . NoError ( t , err )
require . Equal ( t , [] string { "x-preview-f-free" }, catalog . Models )
require . Empty ( t , catalog . Metadata )
require . Equal ( t , [] UpstreamModelSyncWarning {{
Code : UpstreamModelMetadataIncompleteCode ,
Message : "Model IDs were synced, but capability metadata is incomplete." ,
}}, catalog . Warnings )
require . Nil ( t , repo . updates , "a failed metadata enrichment must not erase a previously saved snapshot" )
}
func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails ( t * testing . T ) {
upstream := & httpUpstreamRecorder { responses : [] * http . Response {
{ StatusCode : http . StatusOK , Body : io . NopCloser ( strings . NewReader ( `{"models":[{
"id":"partially-described-model",
"display_name":"Partial Model"
}]}` ))},
{ StatusCode : http . StatusBadGateway , Body : io . NopCloser ( strings . NewReader ( `{"error":"unavailable"}` ))},
}}
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
catalog , err := svc . SyncUpstreamModelCatalog ( context . Background (), & Account {
ID : 96 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any { "api_key" : "key" , "base_url" : "https://provider.example/v1" },
Extra : map [ string ] any { UpstreamModelMetadataExtraKey : map [ string ] any {
"source" : "upstream" , "models" : map [ string ] any { "partially-described-model" : map [ string ] any {
"reasoning" : true , "supported_reasoning_levels" : [] any { "low" , "high" },
}},
}},
})
require . NoError ( t , err )
require . Equal ( t , [] string { "partially-described-model" }, catalog . Models )
require . Equal ( t , "Partial Model" , catalog . Metadata [ "partially-described-model" ]. DisplayName )
require . Equal ( t , UpstreamModelMetadataIncompleteCode , catalog . Warnings [ 0 ]. Code )
require . Nil ( t , repo . updates , "partial metadata must not replace a more complete persisted snapshot" )
}
2026-09-05 14:45:06 +09:00
// Scenario: 图片专用模型缺少 context 时,不阻止 agent 模型能力落库,也不误报整批失败。
func TestSyncUpstreamModelCatalogIgnoresDedicatedMediaModelsForCompleteness ( t * testing . T ) {
upstream := & httpUpstreamRecorder { responses : [] * http . Response {
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"object":"list","data":[
{"id":"gpt-6-astra","object":"model"},
{"id":"gpt-image-2","object":"model"}
]}` )),
},
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{
"openai": {
"id": "openai",
"models": {
"gpt-6-astra": {
"id": "gpt-6-astra",
"name": "GPT-6 Astra",
"reasoning": true,
"reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
"modalities": {"input":["text","image"],"output":["text"]},
"limit": {"context":1050000,"output":128000}
},
"gpt-image-2": {
"id": "gpt-image-2",
"name": "gpt-image-2",
"reasoning": false,
"modalities": {"input":["text","image"],"output":["image"]},
"limit": {"context":0,"output":0}
}
}
}
}` )),
},
}}
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
catalog , err := svc . SyncUpstreamModelCatalog ( context . Background (), & Account {
ID : 113 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "sk-test" ,
"base_url" : "https://api.openai.com/v1" ,
"model_mapping" : map [ string ] any {
"gpt-6-astra" : "gpt-6-astra" ,
"gpt-image-2" : "gpt-image-2" ,
},
},
})
require . NoError ( t , err )
require . Empty ( t , catalog . Warnings , "media generators must not keep agent capability sync in a failed state" )
require . NotNil ( t , repo . updates )
encoded , err := json . Marshal ( repo . updates [ UpstreamModelMetadataExtraKey ])
require . NoError ( t , err )
var snapshot UpstreamModelMetadataSnapshot
require . NoError ( t , json . Unmarshal ( encoded , & snapshot ))
require . Contains ( t , snapshot . Models , "gpt-6-astra" )
require . Equal ( t , [] string { "low" , "medium" , "high" , "xhigh" , "max" }, snapshot . Models [ "gpt-6-astra" ]. SupportedReasoningLevels )
require . NotContains ( t , snapshot . Models , "gpt-image-2" )
}
2026-08-24 14:20:00 +08:00
func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit ( t * testing . T ) {
t . Parallel ()
upstream := & httpUpstreamRecorder { resp : & http . Response {
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"data":[{"id":"gpt-5"}]}` )),
}}
cfg := upstreamModelSyncTestConfig ()
cfg . Gateway . ModelsListReadMaxBytes = 8
svc := & AccountTestService { httpUpstream : upstream , cfg : cfg }
_ , err := svc . FetchUpstreamSupportedModels ( context . Background (), & Account {
ID : 7 ,
Platform : PlatformOpenAI ,
Type : AccountTypeAPIKey ,
Credentials : map [ string ] any {
"api_key" : "openai-key" ,
"base_url" : "https://openai.example.com/v1" ,
},
})
require . Error ( t , err )
require . Contains ( t , err . Error (), "response exceeds 8 bytes" )
}
2026-09-05 14:45:06 +09:00
func TestMatchModelsDevProviderFallsBackToOpenAIProviderWithoutAPIField ( t * testing . T ) {
2026-07-13 11:46:52 +08:00
t . Parallel ()
2026-09-05 14:45:06 +09:00
registry := map [ string ] modelsDevProvider {
"openai" : {
2026-09-05 14:58:51 +09:00
ID : "openai" ,
2026-09-05 14:45:06 +09:00
Name : "OpenAI" ,
// Official models.dev entry currently omits `api`.
Models : map [ string ] modelsDevModel {
"gpt-6-astra" : { ID : "gpt-6-astra" , Name : "GPT-6 Astra" },
},
},
"opencode" : {
ID : "opencode" ,
API : "https://opencode.ai/zen/v1" ,
Models : map [ string ] modelsDevModel {
"x-preview-f-free" : { ID : "x-preview-f-free" , Name : "Ox" },
},
},
2026-07-13 11:46:52 +08:00
}
2026-09-05 14:45:06 +09:00
for _ , baseURL := range [] string {
"https://api.openai.com" ,
"https://api.openai.com/v1" ,
"https://chatgpt.com/backend-api/codex" ,
} {
provider , ok := matchModelsDevProvider ( registry , baseURL )
require . True ( t , ok , baseURL )
require . Equal ( t , "openai" , provider . ID , baseURL )
require . Contains ( t , provider . Models , "gpt-6-astra" , baseURL )
}
_ , ok := matchModelsDevProvider ( registry , "https://compatible.example/v1" )
require . False ( t , ok , "custom hosts must not inherit the official OpenAI provider by name" )
provider , ok := matchModelsDevProvider ( registry , "https://opencode.ai/zen/v1" )
require . True ( t , ok )
require . Equal ( t , "opencode" , provider . ID )
}
// Scenario: 官方 OpenAI ID-only /models + 无 api 字段的 models.dev openai 条目仍能补齐并落库。
func TestSyncUpstreamModelCatalogEnrichesOfficialOpenAIHostWithoutRegistryAPIField ( t * testing . T ) {
upstream := & httpUpstreamRecorder { responses : [] * http . Response {
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"object":"list","data":[
{"id":"gpt-6-astra","object":"model"},
{"id":"gpt-5.6-sol","object":"model"}
]}` )),
},
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{
"openai": {
"id": "openai",
"name": "OpenAI",
"models": {
"gpt-6-astra": {
"id": "gpt-6-astra",
"name": "GPT-6 Astra",
"description": "OpenAI GPT-6 Astra",
"reasoning": true,
"reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
"modalities": {"input":["text","image","pdf"],"output":["text"]},
"limit": {"context":1050000,"output":128000}
},
"gpt-5.6-sol": {
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"reasoning": true,
"reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
"modalities": {"input":["text","image"],"output":["text"]},
"limit": {"context":1050000,"output":128000}
}
}
}
}` )),
},
}}
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
catalog , err := svc . SyncUpstreamModelCatalog ( context . Background (), & Account {
ID : 110 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
2026-07-13 11:46:52 +08:00
Credentials : map [ string ] any {
2026-09-05 14:45:06 +09:00
"api_key" : "sk-test" ,
"base_url" : "https://api.openai.com/v1" ,
2026-07-13 11:46:52 +08:00
},
})
require . NoError ( t , err )
2026-09-05 14:45:06 +09:00
require . Empty ( t , catalog . Warnings )
require . Equal ( t , [] string { "gpt-5.6-sol" , "gpt-6-astra" }, catalog . Models )
2026-07-13 11:46:52 +08:00
2026-09-05 14:45:06 +09:00
astra := catalog . Metadata [ "gpt-6-astra" ]
require . Equal ( t , "GPT-6 Astra" , astra . DisplayName )
require . NotNil ( t , astra . Reasoning )
require . True ( t , * astra . Reasoning )
require . Equal ( t , [] string { "low" , "medium" , "high" , "xhigh" , "max" }, astra . SupportedReasoningLevels )
require . Equal ( t , [] string { "text" , "image" }, astra . InputModalities )
require . Equal ( t , int64 ( 1_050_000 ), astra . ContextWindow )
2026-07-22 13:32:35 +08:00
2026-09-05 14:45:06 +09:00
require . NotNil ( t , repo . updates )
encoded , err := json . Marshal ( repo . updates [ UpstreamModelMetadataExtraKey ])
2026-07-22 13:32:35 +08:00
require . NoError ( t , err )
2026-09-05 14:45:06 +09:00
var snapshot UpstreamModelMetadataSnapshot
require . NoError ( t , json . Unmarshal ( encoded , & snapshot ))
require . Equal ( t , "models.dev" , snapshot . Source )
require . Equal ( t , astra , snapshot . Models [ "gpt-6-astra" ])
2026-07-22 13:32:35 +08:00
}
2026-09-05 14:45:06 +09:00
// Scenario: 同一批同步里部分模型能力完整时仍落库完整条目,并对不完整条目告警。
func TestSyncUpstreamModelCatalogPersistsCompleteModelsWhenSomeRemainIncomplete ( t * testing . T ) {
upstream := & httpUpstreamRecorder { responses : [] * http . Response {
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{"models":[
{
"id":"complete-model",
"display_name":"Complete",
"reasoning":true,
"supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}],
"input_modalities":["text","image"],
"context_window":128000
},
{
"id":"incomplete-model",
"display_name":"Incomplete Only"
}
]}` )),
},
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{
"provider": {
"id": "provider",
"api": "https://provider.example/v1",
"models": {}
}
}` )),
},
2026-05-18 19:01:12 +08:00
}}
2026-09-05 14:45:06 +09:00
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
2026-05-18 19:01:12 +08:00
2026-09-05 14:45:06 +09:00
catalog , err := svc . SyncUpstreamModelCatalog ( context . Background (), & Account {
ID : 111 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any { "api_key" : "key" , "base_url" : "https://provider.example/v1" },
})
require . NoError ( t , err )
require . Equal ( t , [] string { "complete-model" , "incomplete-model" }, catalog . Models )
require . Equal ( t , UpstreamModelMetadataPartialCode , catalog . Warnings [ 0 ]. Code )
require . NotNil ( t , repo . updates )
encoded , err := json . Marshal ( repo . updates [ UpstreamModelMetadataExtraKey ])
require . NoError ( t , err )
var snapshot UpstreamModelMetadataSnapshot
require . NoError ( t , json . Unmarshal ( encoded , & snapshot ))
require . Contains ( t , snapshot . Models , "complete-model" )
require . NotContains ( t , snapshot . Models , "incomplete-model" )
require . Equal ( t , [] string { "low" , "high" }, snapshot . Models [ "complete-model" ]. SupportedReasoningLevels )
}
// Scenario: 上游清单未包含管理员 mapping 目标时,仍按 mapping 补齐并写入快照。
func TestSyncUpstreamModelCatalogEnrichesConfiguredMappingModelsMissingFromUpstreamList ( t * testing . T ) {
upstream := & httpUpstreamRecorder { responses : [] * http . Response {
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
2026-09-05 14:58:51 +09:00
Body : io . NopCloser ( strings . NewReader ( `{"object":"list","data":[{"id":"gpt-5.6-sol","object":"model"}]}` )),
2026-09-05 14:45:06 +09:00
},
{
StatusCode : http . StatusOK ,
Header : http . Header { "Content-Type" : [] string { "application/json" }},
Body : io . NopCloser ( strings . NewReader ( `{
"openai": {
"id": "openai",
"models": {
"gpt-5.6-sol": {
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"reasoning": true,
"reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
"modalities": {"input":["text","image"],"output":["text"]},
"limit": {"context":1050000,"output":128000}
},
"gpt-6-astra": {
"id": "gpt-6-astra",
"name": "GPT-6 Astra",
"reasoning": true,
"reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
"modalities": {"input":["text","image"],"output":["text"]},
"limit": {"context":1050000,"output":128000}
}
}
}
}` )),
},
}}
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
catalog , err := svc . SyncUpstreamModelCatalog ( context . Background (), & Account {
ID : 112 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
2026-05-18 19:01:12 +08:00
Credentials : map [ string ] any {
2026-09-05 14:45:06 +09:00
"api_key" : "sk-test" ,
"base_url" : "https://api.openai.com/v1" ,
"model_mapping" : map [ string ] any {
"gpt-6-astra" : "gpt-6-astra" ,
},
2026-05-18 19:01:12 +08:00
},
})
2026-09-05 14:45:06 +09:00
require . NoError ( t , err )
require . Equal ( t , [] string { "gpt-5.6-sol" }, catalog . Models , "UI model list stays upstream-only" )
require . Empty ( t , catalog . Warnings )
require . Contains ( t , catalog . Metadata , "gpt-6-astra" )
require . Equal ( t , [] string { "low" , "medium" , "high" , "xhigh" , "max" }, catalog . Metadata [ "gpt-6-astra" ]. SupportedReasoningLevels )
2026-05-18 19:01:12 +08:00
2026-09-05 14:45:06 +09:00
encoded , err := json . Marshal ( repo . updates [ UpstreamModelMetadataExtraKey ])
require . NoError ( t , err )
var snapshot UpstreamModelMetadataSnapshot
require . NoError ( t , json . Unmarshal ( encoded , & snapshot ))
require . Contains ( t , snapshot . Models , "gpt-5.6-sol" )
require . Contains ( t , snapshot . Models , "gpt-6-astra" )
2026-05-18 19:01:12 +08:00
}
2026-09-05 16:13:28 +08:00
func TestSyncUpstreamModelCatalogAstraPartialRefreshPreservesKnownCapabilities ( t * testing . T ) {
upstream := & httpUpstreamRecorder { responses : [] * http . Response {
{ StatusCode : http . StatusOK , Body : io . NopCloser ( strings . NewReader ( `{"data":[
{"id":"gpt-6-astra","supports_search_tool":false,"apply_patch_tool_type":null},
{"id":"still-listed"},{"id":"gpt-image-2"}
]}` ))},
{ StatusCode : http . StatusOK , Body : io . NopCloser ( strings . NewReader ( `{"openai":{"id":"openai","models":{
"gpt-6-astra":{"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"modalities":{"input":["text","image"]},"limit":{"context":1050000,"output":128000}},
"mapped-only":{"reasoning":false,"modalities":{"input":["text"]},"limit":{"context":64000}},
"gpt-image-2":{"reasoning":false,"modalities":{"input":["text","image"]},"limit":{"context":0}}
}}}` ))},
}}
repo := & upstreamModelMetadataRepoStub {}
svc := & AccountTestService { accountRepo : repo , httpUpstream : upstream , cfg : upstreamModelSyncTestConfig ()}
account := & Account { ID : 114 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey ,
Credentials : map [ string ] any { "api_key" : "test" , "base_url" : "https://api.openai.com/v1" ,
"model_mapping" : map [ string ] any { "public-model" : "mapped-only" }},
}
old := UpstreamModelMetadata { ID : "still-listed" , ContextWindow : 256000 }
account . SetUpstreamModelMetadataSnapshot ( UpstreamModelMetadataSnapshot { Models : map [ string ] UpstreamModelMetadata {
"still-listed" : old , "removed" : { ID : "removed" , ContextWindow : 128000 },
"gpt-6-astra" : { ID : "gpt-6-astra" , CodexToolCapabilities : map [ string ] json . RawMessage {
"supports_search_tool" : json . RawMessage ( "true" ), "apply_patch_tool_type" : json . RawMessage ( `"freeform"` ),
"comp_hash" : json . RawMessage ( `"3000"` ),
}},
}})
catalog , err := svc . SyncUpstreamModelCatalog ( context . Background (), account )
require . NoError ( t , err )
require . Equal ( t , UpstreamModelMetadataPartialCode , catalog . Warnings [ 0 ]. Code )
require . NotContains ( t , catalog . Models , "mapped-only" , "capability enrichment must not change discovery" )
snapshot := account . GetUpstreamModelMetadataSnapshot ()
require . Equal ( t , old , snapshot . Models [ "still-listed" ])
require . NotContains ( t , snapshot . Models , "removed" )
require . NotContains ( t , snapshot . Models , "gpt-image-2" )
require . Contains ( t , snapshot . Models , "mapped-only" )
astra := snapshot . Models [ "gpt-6-astra" ]
require . Equal ( t , int64 ( 1050000 ), astra . ContextWindow )
require . Equal ( t , [] string { "low" , "medium" , "high" , "xhigh" , "max" }, astra . SupportedReasoningLevels )
require . JSONEq ( t , "false" , string ( astra . CodexToolCapabilities [ "supports_search_tool" ]))
require . JSONEq ( t , "null" , string ( astra . CodexToolCapabilities [ "apply_patch_tool_type" ]))
require . JSONEq ( t , `"3000"` , string ( astra . CodexToolCapabilities [ "comp_hash" ]))
require . NotNil ( t , repo . updates )
}
func TestMatchModelsDevProviderOfficialHostsWithoutAPI ( t * testing . T ) {
registry := map [ string ] modelsDevProvider {
"openai" : { ID : "openai" , Models : map [ string ] modelsDevModel { "gpt-6-astra" : { ID : "gpt-6-astra" }}},
"relay" : { ID : "relay" , API : "https://relay.example/v1" },
}
for _ , baseURL := range [] string { "https://api.openai.com/v1" , "https://chatgpt.com/backend-api/codex" } {
provider , ok := matchModelsDevProvider ( registry , baseURL )
require . True ( t , ok )
require . Equal ( t , "openai" , provider . ID )
}
for _ , baseURL := range [] string { "https://api.openai.com.evil.example/v1" , "https://unknown.example/v1" } {
_ , ok := matchModelsDevProvider ( registry , baseURL )
require . False ( t , ok )
}
provider , ok := matchModelsDevProvider ( registry , "https://relay.example/v1" )
require . True ( t , ok )
require . Equal ( t , "relay" , provider . ID )
metadata := map [ string ] UpstreamModelMetadata { "gpt-6-astra" : {
Reasoning : new ( bool ), InputModalities : [] string { "text" }, ContextWindow : 1050000 ,
}}
2026-09-05 16:19:57 +08:00
require . False ( t , upstreamCatalogNeedsRegistry ( capabilitySyncModelIDs ([] string { "gpt-6-astra" , "gpt-image-2" }), metadata ))
2026-09-05 16:13:28 +08:00
}