upstream_models_test.go 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/stretchr/testify/require"
  13. )
  14. type upstreamModelMetadataRepoStub struct {
  15. AccountRepository
  16. accountID int64
  17. updates map[string]any
  18. err error
  19. }
  20. func headerValuesEqualFold(header http.Header, name string) []string {
  21. for key, values := range header {
  22. if strings.EqualFold(key, name) {
  23. return values
  24. }
  25. }
  26. return nil
  27. }
  28. func (r *upstreamModelMetadataRepoStub) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
  29. r.accountID = id
  30. r.updates = updates
  31. return r.err
  32. }
  33. func upstreamModelSyncTestConfig() *config.Config {
  34. return &config.Config{
  35. Security: config.SecurityConfig{
  36. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  37. },
  38. }
  39. }
  40. func grokOAuthModelSyncTestAccount(baseURL string) *Account {
  41. credentials := map[string]any{
  42. "access_token": "oauth-access-token",
  43. "refresh_token": "oauth-refresh-token",
  44. "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
  45. "sub": "grok-user-id",
  46. "email": "grok-user@example.com",
  47. }
  48. if strings.TrimSpace(baseURL) != "" {
  49. credentials["base_url"] = baseURL
  50. }
  51. return &Account{
  52. ID: 10,
  53. Platform: PlatformGrok,
  54. Type: AccountTypeOAuth,
  55. Credentials: credentials,
  56. }
  57. }
  58. func TestBuildV1ModelsURL(t *testing.T) {
  59. t.Parallel()
  60. require.Equal(t, "https://api.anthropic.com/v1/models", buildV1ModelsURL("https://api.anthropic.com"))
  61. require.Equal(t, "https://api.anthropic.com/v1/models", buildV1ModelsURL("https://api.anthropic.com/v1"))
  62. require.Equal(t, "https://api.anthropic.com/v1/models", buildV1ModelsURL("https://api.anthropic.com/v1/models"))
  63. require.Equal(t, "https://gateway.example.com/antigravity/v1/models", buildV1ModelsURL("https://gateway.example.com/antigravity/"))
  64. }
  65. func TestBuildOpenAIModelsURL(t *testing.T) {
  66. t.Parallel()
  67. tests := []struct {
  68. name string
  69. base string
  70. want string
  71. }{
  72. {
  73. name: "zhipu v4 coding base url",
  74. base: "https://open.bigmodel.cn/api/coding/paas/v4",
  75. want: "https://open.bigmodel.cn/api/coding/paas/v4/models",
  76. },
  77. {
  78. name: "openai v1 base url",
  79. base: "https://api.openai.com/v1",
  80. want: "https://api.openai.com/v1/models",
  81. },
  82. {
  83. name: "models url unchanged",
  84. base: "https://api.openai.com/v1/models",
  85. want: "https://api.openai.com/v1/models",
  86. },
  87. {
  88. name: "host fallback uses v1",
  89. base: "https://api.openai.com",
  90. want: "https://api.openai.com/v1/models",
  91. },
  92. {
  93. name: "trailing slash on v4",
  94. base: "https://open.bigmodel.cn/api/coding/paas/v4/",
  95. want: "https://open.bigmodel.cn/api/coding/paas/v4/models",
  96. },
  97. {
  98. name: "v2 base url",
  99. base: "https://gateway.example.com/openai/v2",
  100. want: "https://gateway.example.com/openai/v2/models",
  101. },
  102. {
  103. name: "v3 base url",
  104. base: "https://gateway.example.com/openai/v3",
  105. want: "https://gateway.example.com/openai/v3/models",
  106. },
  107. }
  108. for _, tt := range tests {
  109. tt := tt
  110. t.Run(tt.name, func(t *testing.T) {
  111. t.Parallel()
  112. require.Equal(t, tt.want, buildOpenAIModelsURL(tt.base))
  113. })
  114. }
  115. }
  116. func TestBuildGeminiModelsURL(t *testing.T) {
  117. t.Parallel()
  118. require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", buildGeminiModelsURL("https://generativelanguage.googleapis.com"))
  119. require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", buildGeminiModelsURL("https://generativelanguage.googleapis.com/v1beta"))
  120. require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", buildGeminiModelsURL("https://generativelanguage.googleapis.com/v1beta/models"))
  121. }
  122. func TestExtractUpstreamModelIDs(t *testing.T) {
  123. t.Parallel()
  124. tests := []struct {
  125. name string
  126. body string
  127. want []string
  128. }{
  129. {
  130. name: "openai and anthropic data array",
  131. body: `{"data":[{"id":"claude-sonnet-4-5"},{"id":"gpt-5"},{"id":"gpt-5"},{"id":""}]}`,
  132. want: []string{"claude-sonnet-4-5", "gpt-5"},
  133. },
  134. {
  135. name: "gemini models array strips prefix",
  136. body: `{"models":[{"name":"models/gemini-2.5-pro"},{"name":"gemini-2.5-flash"}]}`,
  137. want: []string{"gemini-2.5-flash", "gemini-2.5-pro"},
  138. },
  139. {
  140. name: "top level array",
  141. body: `[{"id":"z-model"},{"name":"models/a-model"}]`,
  142. want: []string{"a-model", "z-model"},
  143. },
  144. {
  145. name: "standard id wins over provider-specific model field",
  146. body: `{"data":[{"id":"canonical-id","model":"display-model"}]}`,
  147. want: []string{"canonical-id"},
  148. },
  149. {
  150. name: "codex manifest uses slug",
  151. body: `{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`,
  152. want: []string{"gpt-5.5-codex", "gpt-5.6-sol"},
  153. },
  154. }
  155. for _, tt := range tests {
  156. tt := tt
  157. t.Run(tt.name, func(t *testing.T) {
  158. t.Parallel()
  159. got, err := extractUpstreamModelIDs([]byte(tt.body))
  160. require.NoError(t, err)
  161. require.Equal(t, tt.want, got)
  162. })
  163. }
  164. }
  165. func TestBuildUpstreamModelsRequestSupportsOpenAIOAuth(t *testing.T) {
  166. svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()}
  167. account := &Account{
  168. ID: 11,
  169. Platform: PlatformOpenAI,
  170. Type: AccountTypeOAuth,
  171. Credentials: map[string]any{
  172. "access_token": "openai-oauth-token",
  173. "chatgpt_account_id": "chatgpt-account",
  174. },
  175. }
  176. req, err := svc.buildUpstreamModelsRequest(context.Background(), account)
  177. require.NoError(t, err)
  178. require.Equal(t, chatgptCodexModelsURL, req.URL.Scheme+"://"+req.URL.Host+req.URL.Path)
  179. require.NotEmpty(t, req.URL.Query().Get("client_version"))
  180. require.Equal(t, "Bearer openai-oauth-token", req.Header.Get("Authorization"))
  181. require.Equal(t, "chatgpt-account", req.Header.Get("chatgpt-account-id"))
  182. require.NotEmpty(t, req.Header.Get("Originator"))
  183. require.NotEmpty(t, req.Header.Get("User-Agent"))
  184. require.NotEmpty(t, req.Header.Get("Version"))
  185. }
  186. func TestFetchUpstreamSupportedModelsParsesOpenAIOAuthManifest(t *testing.T) {
  187. upstream := &httpUpstreamRecorder{resp: &http.Response{
  188. StatusCode: http.StatusOK,
  189. Header: http.Header{"Content-Type": []string{"application/json"}},
  190. Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`)),
  191. }}
  192. svc := &AccountTestService{
  193. httpUpstream: upstream,
  194. cfg: upstreamModelSyncTestConfig(),
  195. }
  196. models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{
  197. ID: 12,
  198. Platform: PlatformOpenAI,
  199. Type: AccountTypeOAuth,
  200. Credentials: map[string]any{
  201. "access_token": "openai-oauth-token",
  202. },
  203. })
  204. require.NoError(t, err)
  205. require.Equal(t, []string{"gpt-5.5-codex", "gpt-5.6-sol"}, models)
  206. require.Equal(t, "Bearer openai-oauth-token", upstream.lastReq.Header.Get("Authorization"))
  207. }
  208. func TestExtractGrokUpstreamModelIDs(t *testing.T) {
  209. t.Parallel()
  210. 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"}]}`))
  211. require.NoError(t, err)
  212. require.Equal(t, []string{"grok-4.5", "grok-build-0.1", "grok-composer-2.5-fast", "grok-meta", "grok-name", "grok-safe"}, models)
  213. }
  214. func TestBuildUpstreamModelsRequestsForAPIKeyAccounts(t *testing.T) {
  215. t.Parallel()
  216. svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()}
  217. ctx := context.Background()
  218. anthropicReq, err := svc.buildAnthropicUpstreamModelsRequest(ctx, &Account{
  219. Platform: PlatformAnthropic,
  220. Type: AccountTypeAPIKey,
  221. Credentials: map[string]any{
  222. "api_key": "anthropic-key",
  223. "base_url": "https://anthropic.example.com/v1",
  224. },
  225. })
  226. require.NoError(t, err)
  227. require.Equal(t, "https://anthropic.example.com/v1/models", anthropicReq.URL.String())
  228. require.Equal(t, "anthropic-key", anthropicReq.Header.Get("x-api-key"))
  229. require.Equal(t, "2023-06-01", anthropicReq.Header.Get("anthropic-version"))
  230. anthropicBearerReq, err := svc.buildAnthropicUpstreamModelsRequest(ctx, &Account{
  231. Platform: PlatformAnthropic,
  232. Type: AccountTypeAPIKey,
  233. Credentials: map[string]any{
  234. "api_key": "ollama-key",
  235. "base_url": "https://ollama.com",
  236. },
  237. Extra: map[string]any{
  238. "anthropic_apikey_auth_scheme": AnthropicAPIKeyAuthSchemeAuthorizationBearer,
  239. },
  240. })
  241. require.NoError(t, err)
  242. require.Equal(t, "https://ollama.com/v1/models", anthropicBearerReq.URL.String())
  243. require.Equal(t, "Bearer ollama-key", anthropicBearerReq.Header.Get("Authorization"))
  244. require.Empty(t, anthropicBearerReq.Header.Get("x-api-key"))
  245. require.Equal(t, "2023-06-01", anthropicBearerReq.Header.Get("anthropic-version"))
  246. openAIReq, err := svc.buildOpenAIUpstreamModelsRequest(ctx, &Account{
  247. Platform: PlatformOpenAI,
  248. Type: AccountTypeAPIKey,
  249. Credentials: map[string]any{
  250. "api_key": "openai-key",
  251. "base_url": "https://openai.example.com",
  252. },
  253. })
  254. require.NoError(t, err)
  255. require.Equal(t, "https://openai.example.com/v1/models", openAIReq.URL.String())
  256. require.Equal(t, "Bearer openai-key", openAIReq.Header.Get("Authorization"))
  257. grokReq, err := svc.buildUpstreamModelsRequest(ctx, &Account{
  258. Platform: PlatformGrok,
  259. Type: AccountTypeAPIKey,
  260. Credentials: map[string]any{
  261. "api_key": "xai-key",
  262. "base_url": "https://xai.example.com/v1",
  263. },
  264. })
  265. require.NoError(t, err)
  266. require.Equal(t, "https://xai.example.com/v1/models", grokReq.URL.String())
  267. require.Equal(t, "Bearer xai-key", grokReq.Header.Get("Authorization"))
  268. geminiReq, err := svc.buildGeminiUpstreamModelsRequest(ctx, &Account{
  269. Platform: PlatformGemini,
  270. Type: AccountTypeAPIKey,
  271. Credentials: map[string]any{
  272. "api_key": "gemini-key",
  273. "base_url": "https://generativelanguage.googleapis.com/v1beta",
  274. },
  275. })
  276. require.NoError(t, err)
  277. require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", geminiReq.URL.String())
  278. require.Equal(t, "gemini-key", geminiReq.Header.Get("x-goog-api-key"))
  279. antigravityReq, err := svc.buildAntigravityAPIKeyModelsRequest(ctx, &Account{
  280. Platform: PlatformAntigravity,
  281. Type: AccountTypeAPIKey,
  282. Credentials: map[string]any{
  283. "api_key": "antigravity-key",
  284. "base_url": "https://gateway.example.com/antigravity",
  285. },
  286. })
  287. require.NoError(t, err)
  288. require.Equal(t, "https://gateway.example.com/antigravity/v1/models", antigravityReq.URL.String())
  289. require.Equal(t, "antigravity-key", antigravityReq.Header.Get("x-api-key"))
  290. }
  291. func TestBuildUpstreamModelsRequestSupportsGrokOAuth(t *testing.T) {
  292. t.Parallel()
  293. svc := &AccountTestService{
  294. cfg: upstreamModelSyncTestConfig(),
  295. grokTokenProvider: NewGrokTokenProvider(nil, nil),
  296. }
  297. req, err := svc.buildUpstreamModelsRequest(context.Background(), grokOAuthModelSyncTestAccount(""))
  298. require.NoError(t, err)
  299. require.Equal(t, "https://cli-chat-proxy.grok.com/v1/models", req.URL.String())
  300. require.Equal(t, "Bearer oauth-access-token", req.Header.Get("Authorization"))
  301. require.Equal(t, grokCLIVersion, req.Header.Get("X-Grok-Client-Version"))
  302. require.Equal(t, "interactive", req.Header.Get("X-Grok-Client-Mode"))
  303. require.Equal(t, defaultGrokUpstreamUserAgent(), req.Header.Get("User-Agent"))
  304. require.Equal(t, "grok-user-id", req.Header.Get("X-UserID"))
  305. require.Equal(t, "grok-user@example.com", req.Header.Get("X-Email"))
  306. require.NotContains(t, req.Header.Get("Authorization"), "oauth-refresh-token")
  307. }
  308. func TestBuildUpstreamModelsRequestGrokOAuthRequiresTokenProvider(t *testing.T) {
  309. t.Parallel()
  310. svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()}
  311. _, err := svc.buildUpstreamModelsRequest(context.Background(), grokOAuthModelSyncTestAccount(""))
  312. require.Error(t, err)
  313. var syncErr *UpstreamModelSyncError
  314. require.True(t, errors.As(err, &syncErr))
  315. require.Equal(t, UpstreamModelSyncErrorConfiguration, syncErr.Kind)
  316. require.Contains(t, syncErr.SafeMessage(), "token provider")
  317. }
  318. func TestBuildAntigravityAPIKeyModelsRequestRejectsOfficialCloudCodeBase(t *testing.T) {
  319. t.Parallel()
  320. svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()}
  321. _, err := svc.buildAntigravityAPIKeyModelsRequest(context.Background(), &Account{
  322. Platform: PlatformAntigravity,
  323. Type: AccountTypeAPIKey,
  324. Credentials: map[string]any{
  325. "api_key": "antigravity-key",
  326. "base_url": "https://cloudcode-pa.googleapis.com",
  327. },
  328. })
  329. require.Error(t, err)
  330. var syncErr *UpstreamModelSyncError
  331. require.True(t, errors.As(err, &syncErr))
  332. require.Equal(t, UpstreamModelSyncErrorUnsupported, syncErr.Kind)
  333. require.Contains(t, syncErr.SafeMessage(), "compatible gateway")
  334. }
  335. func TestBuildAnthropicUpstreamModelsRequestRejectsBedrock(t *testing.T) {
  336. t.Parallel()
  337. svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()}
  338. _, err := svc.buildAnthropicUpstreamModelsRequest(context.Background(), &Account{
  339. Platform: PlatformAnthropic,
  340. Type: AccountTypeBedrock,
  341. })
  342. require.Error(t, err)
  343. var syncErr *UpstreamModelSyncError
  344. require.True(t, errors.As(err, &syncErr))
  345. require.Equal(t, UpstreamModelSyncErrorUnsupported, syncErr.Kind)
  346. }
  347. func TestFetchUpstreamSupportedModelsParsesOpenAIResponse(t *testing.T) {
  348. t.Parallel()
  349. upstream := &httpUpstreamRecorder{resp: &http.Response{
  350. StatusCode: http.StatusOK,
  351. Header: http.Header{"Content-Type": []string{"application/json"}},
  352. Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"gpt-5"},{"id":"gpt-5"},{"name":"o3"}]}`)),
  353. }}
  354. svc := &AccountTestService{
  355. httpUpstream: upstream,
  356. cfg: upstreamModelSyncTestConfig(),
  357. }
  358. models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{
  359. ID: 7,
  360. Platform: PlatformOpenAI,
  361. Type: AccountTypeAPIKey,
  362. Credentials: map[string]any{
  363. "api_key": "openai-key",
  364. "base_url": "https://openai.example.com/v1",
  365. },
  366. })
  367. require.NoError(t, err)
  368. require.Equal(t, []string{"gpt-5", "o3"}, models)
  369. require.Equal(t, "https://openai.example.com/v1/models", upstream.lastReq.URL.String())
  370. require.Equal(t, "Bearer openai-key", upstream.lastReq.Header.Get("Authorization"))
  371. }
  372. // Scenario: ID-only 模型列表从 Models.dev 补齐能力。
  373. func TestSyncUpstreamModelCatalogEnrichesOpenCodeIDOnlyListAndPersistsSnapshot(t *testing.T) {
  374. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  375. {
  376. StatusCode: http.StatusOK,
  377. Header: http.Header{"Content-Type": []string{"application/json"}},
  378. Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"x-preview-f-free","object":"model"}]}`)),
  379. },
  380. {
  381. StatusCode: http.StatusOK,
  382. Header: http.Header{"Content-Type": []string{"application/json"}},
  383. Body: io.NopCloser(strings.NewReader(`{
  384. "opencode": {
  385. "id": "opencode",
  386. "name": "OpenCode Zen",
  387. "api": "https://opencode.ai/zen/v1",
  388. "models": {
  389. "x-preview-f-free": {
  390. "id": "x-preview-f-free",
  391. "name": "Ox Alpha Free (Unlimited)",
  392. "description": "Stealth reasoning model for coding, agentic tasks, and tool use",
  393. "reasoning": true,
  394. "reasoning_options": [{"type":"effort","values":["low","high","max"]}],
  395. "modalities": {"input":["text","image","video"],"output":["text"]},
  396. "limit": {"context":1000000,"output":131072}
  397. }
  398. }
  399. }
  400. }`)),
  401. },
  402. }}
  403. repo := &upstreamModelMetadataRepoStub{}
  404. svc := &AccountTestService{
  405. accountRepo: repo,
  406. httpUpstream: upstream,
  407. cfg: upstreamModelSyncTestConfig(),
  408. }
  409. account := &Account{
  410. ID: 91,
  411. Platform: PlatformOpenAI,
  412. Type: AccountTypeAPIKey,
  413. Credentials: map[string]any{
  414. "api_key": "opencode-key",
  415. "base_url": "https://opencode.ai/zen/v1",
  416. "header_override_enabled": true,
  417. "header_overrides": map[string]any{
  418. "X-Custom-Account-Header": "account-secret",
  419. },
  420. },
  421. }
  422. catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
  423. require.NoError(t, err)
  424. require.Equal(t, []string{"x-preview-f-free"}, catalog.Models)
  425. require.Len(t, upstream.requests, 2)
  426. require.Equal(t, "https://opencode.ai/zen/v1/models", upstream.requests[0].URL.String())
  427. require.Equal(t, []string{"account-secret"}, headerValuesEqualFold(upstream.requests[0].Header, "X-Custom-Account-Header"))
  428. require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String())
  429. require.Empty(t, upstream.requests[1].Header.Get("Authorization"))
  430. require.Empty(t, upstream.requests[1].Header.Get("x-api-key"))
  431. require.Empty(t, headerValuesEqualFold(upstream.requests[1].Header, "X-Custom-Account-Header"))
  432. metadata := catalog.Metadata["x-preview-f-free"]
  433. require.Equal(t, "Ox Alpha Free (Unlimited)", metadata.DisplayName)
  434. require.NotNil(t, metadata.Reasoning)
  435. require.True(t, *metadata.Reasoning)
  436. require.Equal(t, []string{"low", "high", "max"}, metadata.SupportedReasoningLevels)
  437. require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
  438. require.Equal(t, int64(1_000_000), metadata.ContextWindow)
  439. require.Equal(t, int64(131_072), metadata.MaxOutputTokens)
  440. require.Equal(t, int64(91), repo.accountID)
  441. rawSnapshot, ok := repo.updates[UpstreamModelMetadataExtraKey]
  442. require.True(t, ok)
  443. encoded, err := json.Marshal(rawSnapshot)
  444. require.NoError(t, err)
  445. var snapshot UpstreamModelMetadataSnapshot
  446. require.NoError(t, json.Unmarshal(encoded, &snapshot))
  447. require.Equal(t, "models.dev", snapshot.Source)
  448. require.Equal(t, metadata, snapshot.Models["x-preview-f-free"])
  449. }
  450. // Scenario: 不提供 /models 的兼容上游使用管理员已配置模型继续同步能力。
  451. func TestSyncUpstreamModelCatalogUsesConfiguredModelsWhenListEndpointUnsupported(t *testing.T) {
  452. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  453. {
  454. StatusCode: http.StatusNotFound,
  455. Header: http.Header{"Content-Type": []string{"application/json"}},
  456. Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)),
  457. },
  458. {
  459. StatusCode: http.StatusOK,
  460. Header: http.Header{"Content-Type": []string{"application/json"}},
  461. Body: io.NopCloser(strings.NewReader(`{
  462. "configured-provider": {
  463. "id": "configured-provider",
  464. "name": "Configured Provider",
  465. "api": "https://provider.example/v1",
  466. "models": {
  467. "glm-5.3": {
  468. "id": "glm-5.3",
  469. "name": "GLM-5.3",
  470. "reasoning": true,
  471. "reasoning_options": [{"type":"effort","values":["low","medium","high"]}],
  472. "modalities": {"input":["text"],"output":["text"]},
  473. "limit": {"context":1000000,"output":131072}
  474. }
  475. }
  476. }
  477. }`)),
  478. },
  479. }}
  480. repo := &upstreamModelMetadataRepoStub{}
  481. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  482. account := &Account{
  483. ID: 97, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  484. Credentials: map[string]any{
  485. "api_key": "key",
  486. "base_url": "https://provider.example/v1",
  487. "model_mapping": map[string]any{
  488. "public-glm": "glm-5.3",
  489. "duplicate": "glm-5.3",
  490. "wildcard": "glm-*",
  491. "empty": "",
  492. },
  493. },
  494. }
  495. account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
  496. "old-live-model": {ID: "old-live-model", ContextWindow: 256000},
  497. }})
  498. catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
  499. require.NoError(t, err)
  500. require.Contains(t, account.GetUpstreamModelMetadataSnapshot().Models, "old-live-model", "an unavailable model-list endpoint is not evidence of removal")
  501. require.Equal(t, []string{"glm-5.3"}, catalog.Models)
  502. require.Empty(t, catalog.Warnings)
  503. require.Len(t, upstream.requests, 2)
  504. require.Equal(t, "https://provider.example/v1/models", upstream.requests[0].URL.String())
  505. require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String())
  506. metadata := catalog.Metadata["glm-5.3"]
  507. require.Equal(t, []string{"low", "medium", "high"}, metadata.SupportedReasoningLevels)
  508. require.Equal(t, []string{"text"}, metadata.InputModalities)
  509. require.Equal(t, int64(1_000_000), metadata.ContextWindow)
  510. require.NotNil(t, repo.updates)
  511. }
  512. func TestSyncUpstreamModelCatalogDoesNotUseConfiguredModelsForRealUpstreamFailures(t *testing.T) {
  513. tests := []struct {
  514. name string
  515. statusCode int
  516. }{
  517. {name: "unauthorized", statusCode: http.StatusUnauthorized},
  518. {name: "rate limited", statusCode: http.StatusTooManyRequests},
  519. {name: "server error", statusCode: http.StatusBadGateway},
  520. }
  521. for _, tt := range tests {
  522. t.Run(tt.name, func(t *testing.T) {
  523. upstream := &httpUpstreamRecorder{resp: &http.Response{
  524. StatusCode: tt.statusCode,
  525. Body: io.NopCloser(strings.NewReader(`{"error":"failed"}`)),
  526. }}
  527. svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  528. _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
  529. ID: 98, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  530. Credentials: map[string]any{
  531. "api_key": "key",
  532. "base_url": "https://provider.example/v1",
  533. "model_mapping": map[string]any{"public-glm": "glm-5.3"},
  534. },
  535. })
  536. require.Error(t, err)
  537. require.Len(t, upstream.requests, 1)
  538. require.Equal(t, tt.statusCode, upstreamModelSyncStatusCode(err))
  539. })
  540. }
  541. }
  542. func TestSyncUpstreamModelCatalogRequiresConfiguredModelsForUnsupportedListEndpoint(t *testing.T) {
  543. upstream := &httpUpstreamRecorder{resp: &http.Response{
  544. StatusCode: http.StatusMethodNotAllowed,
  545. Body: io.NopCloser(strings.NewReader(`{"error":"method not allowed"}`)),
  546. }}
  547. svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  548. _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
  549. ID: 99, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  550. Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
  551. })
  552. require.Error(t, err)
  553. require.Equal(t, http.StatusMethodNotAllowed, upstreamModelSyncStatusCode(err))
  554. require.Len(t, upstream.requests, 1)
  555. }
  556. // Scenario: 完整上游模型清单优先保存能力。
  557. func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata(t *testing.T) {
  558. upstream := &httpUpstreamRecorder{resp: &http.Response{
  559. StatusCode: http.StatusOK,
  560. Header: http.Header{"Content-Type": []string{"application/json"}},
  561. Body: io.NopCloser(strings.NewReader(`{"models":[{
  562. "slug":"custom-thinking-model",
  563. "display_name":"Upstream Display",
  564. "description":"Upstream description",
  565. "default_reasoning_level":"high",
  566. "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"},{"effort":"ultra"}],
  567. "input_modalities":["text","image"],
  568. "context_window":256000
  569. }]}`)),
  570. }}
  571. repo := &upstreamModelMetadataRepoStub{}
  572. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  573. catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
  574. ID: 92, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  575. Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
  576. })
  577. require.NoError(t, err)
  578. require.Len(t, upstream.requests, 1, "complete upstream metadata must not be replaced by a registry fetch")
  579. metadata := catalog.Metadata["custom-thinking-model"]
  580. require.Equal(t, "Upstream Display", metadata.DisplayName)
  581. require.Equal(t, "high", metadata.DefaultReasoningLevel)
  582. require.Equal(t, []string{"low", "high", "ultra"}, metadata.SupportedReasoningLevels)
  583. require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
  584. require.Equal(t, int64(256_000), metadata.ContextWindow)
  585. }
  586. // Scenario: 上游 /models 增删型号后,正式同步用最新清单替换能力快照。
  587. func TestSyncUpstreamModelCatalogReplacesSnapshotWhenUpstreamModelsChange(t *testing.T) {
  588. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  589. {
  590. StatusCode: http.StatusOK,
  591. Header: http.Header{"Content-Type": []string{"application/json"}},
  592. Body: io.NopCloser(strings.NewReader(`{"data":[
  593. {"id":"old-model","reasoning":false,"input_modalities":["text"],"context_window":128000},
  594. {"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000}
  595. ]}`)),
  596. },
  597. {
  598. StatusCode: http.StatusOK,
  599. Header: http.Header{"Content-Type": []string{"application/json"}},
  600. Body: io.NopCloser(strings.NewReader(`{"data":[
  601. {"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000},
  602. {"id":"new-model","reasoning":false,"input_modalities":["text"],"context_window":256000}
  603. ]}`)),
  604. },
  605. }}
  606. repo := &upstreamModelMetadataRepoStub{}
  607. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  608. account := &Account{
  609. ID: 101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  610. Credentials: map[string]any{
  611. "api_key": "key",
  612. "base_url": "https://provider.example/v1",
  613. },
  614. }
  615. first, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
  616. require.NoError(t, err)
  617. require.Equal(t, []string{"kept-model", "old-model"}, first.Models)
  618. second, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
  619. require.NoError(t, err)
  620. require.Equal(t, []string{"kept-model", "new-model"}, second.Models)
  621. require.NotContains(t, second.Metadata, "old-model")
  622. require.Contains(t, second.Metadata, "new-model")
  623. encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
  624. require.NoError(t, err)
  625. var snapshot UpstreamModelMetadataSnapshot
  626. require.NoError(t, json.Unmarshal(encoded, &snapshot))
  627. require.NotContains(t, snapshot.Models, "old-model")
  628. require.Contains(t, snapshot.Models, "new-model")
  629. }
  630. // Scenario: 上游明确声明无推理能力时保存 false。
  631. func TestSyncUpstreamModelCatalogPersistsExplicitNonReasoningCapability(t *testing.T) {
  632. upstream := &httpUpstreamRecorder{resp: &http.Response{
  633. StatusCode: http.StatusOK,
  634. Header: http.Header{"Content-Type": []string{"application/json"}},
  635. Body: io.NopCloser(strings.NewReader(`{"models":[{
  636. "id":"company-coding-model",
  637. "display_name":"Company Coding Model",
  638. "reasoning":false,
  639. "input_modalities":["text"],
  640. "context_window":64000
  641. }]}`)),
  642. }}
  643. repo := &upstreamModelMetadataRepoStub{}
  644. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  645. catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
  646. ID: 94, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  647. Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
  648. })
  649. require.NoError(t, err)
  650. require.Len(t, upstream.requests, 1)
  651. metadata := catalog.Metadata["company-coding-model"]
  652. require.NotNil(t, metadata.Reasoning)
  653. require.False(t, *metadata.Reasoning)
  654. require.Empty(t, metadata.SupportedReasoningLevels)
  655. require.Equal(t, []string{"text"}, metadata.InputModalities)
  656. require.Equal(t, int64(64_000), metadata.ContextWindow)
  657. require.NotNil(t, repo.updates)
  658. }
  659. func TestSyncUpstreamModelCatalogClassifiesSnapshotPersistenceFailureAsInternal(t *testing.T) {
  660. upstream := &httpUpstreamRecorder{resp: &http.Response{
  661. StatusCode: http.StatusOK,
  662. Header: http.Header{"Content-Type": []string{"application/json"}},
  663. Body: io.NopCloser(strings.NewReader(`{"models":[{
  664. "id":"company-coding-model",
  665. "reasoning":false,
  666. "input_modalities":["text"],
  667. "context_window":64000
  668. }]}`)),
  669. }}
  670. repo := &upstreamModelMetadataRepoStub{err: errors.New("database unavailable")}
  671. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  672. _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
  673. ID: 95, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  674. Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
  675. })
  676. require.Error(t, err)
  677. var syncErr *UpstreamModelSyncError
  678. require.ErrorAs(t, err, &syncErr)
  679. require.Equal(t, UpstreamModelSyncErrorInternal, syncErr.Kind)
  680. }
  681. // Scenario: 元数据源失败时保留已有快照。
  682. func TestSyncUpstreamModelCatalogDoesNotOverwriteSnapshotWhenRegistryFails(t *testing.T) {
  683. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  684. {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`))},
  685. {StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))},
  686. }}
  687. repo := &upstreamModelMetadataRepoStub{}
  688. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  689. catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
  690. ID: 93, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  691. Credentials: map[string]any{"api_key": "key", "base_url": "https://opencode.ai/zen/v1"},
  692. Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{
  693. "source": "models.dev", "models": map[string]any{"x-preview-f-free": map[string]any{"reasoning": true}},
  694. }},
  695. })
  696. require.NoError(t, err)
  697. require.Equal(t, []string{"x-preview-f-free"}, catalog.Models)
  698. require.Empty(t, catalog.Metadata)
  699. require.Equal(t, []UpstreamModelSyncWarning{{
  700. Code: UpstreamModelMetadataIncompleteCode,
  701. Message: "Model IDs were synced, but capability metadata is incomplete.",
  702. }}, catalog.Warnings)
  703. require.Nil(t, repo.updates, "a failed metadata enrichment must not erase a previously saved snapshot")
  704. }
  705. func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails(t *testing.T) {
  706. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  707. {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"models":[{
  708. "id":"partially-described-model",
  709. "display_name":"Partial Model"
  710. }]}`))},
  711. {StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))},
  712. }}
  713. repo := &upstreamModelMetadataRepoStub{}
  714. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  715. catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
  716. ID: 96, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  717. Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
  718. Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{
  719. "source": "upstream", "models": map[string]any{"partially-described-model": map[string]any{
  720. "reasoning": true, "supported_reasoning_levels": []any{"low", "high"},
  721. }},
  722. }},
  723. })
  724. require.NoError(t, err)
  725. require.Equal(t, []string{"partially-described-model"}, catalog.Models)
  726. require.Equal(t, "Partial Model", catalog.Metadata["partially-described-model"].DisplayName)
  727. require.Equal(t, UpstreamModelMetadataIncompleteCode, catalog.Warnings[0].Code)
  728. require.Nil(t, repo.updates, "partial metadata must not replace a more complete persisted snapshot")
  729. }
  730. // Scenario: 图片专用模型缺少 context 时,不阻止 agent 模型能力落库,也不误报整批失败。
  731. func TestSyncUpstreamModelCatalogIgnoresDedicatedMediaModelsForCompleteness(t *testing.T) {
  732. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  733. {
  734. StatusCode: http.StatusOK,
  735. Header: http.Header{"Content-Type": []string{"application/json"}},
  736. Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[
  737. {"id":"gpt-6-astra","object":"model"},
  738. {"id":"gpt-image-2","object":"model"}
  739. ]}`)),
  740. },
  741. {
  742. StatusCode: http.StatusOK,
  743. Header: http.Header{"Content-Type": []string{"application/json"}},
  744. Body: io.NopCloser(strings.NewReader(`{
  745. "openai": {
  746. "id": "openai",
  747. "models": {
  748. "gpt-6-astra": {
  749. "id": "gpt-6-astra",
  750. "name": "GPT-6 Astra",
  751. "reasoning": true,
  752. "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
  753. "modalities": {"input":["text","image"],"output":["text"]},
  754. "limit": {"context":1050000,"output":128000}
  755. },
  756. "gpt-image-2": {
  757. "id": "gpt-image-2",
  758. "name": "gpt-image-2",
  759. "reasoning": false,
  760. "modalities": {"input":["text","image"],"output":["image"]},
  761. "limit": {"context":0,"output":0}
  762. }
  763. }
  764. }
  765. }`)),
  766. },
  767. }}
  768. repo := &upstreamModelMetadataRepoStub{}
  769. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  770. catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
  771. ID: 113, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  772. Credentials: map[string]any{
  773. "api_key": "sk-test",
  774. "base_url": "https://api.openai.com/v1",
  775. "model_mapping": map[string]any{
  776. "gpt-6-astra": "gpt-6-astra",
  777. "gpt-image-2": "gpt-image-2",
  778. },
  779. },
  780. })
  781. require.NoError(t, err)
  782. require.Empty(t, catalog.Warnings, "media generators must not keep agent capability sync in a failed state")
  783. require.NotNil(t, repo.updates)
  784. encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
  785. require.NoError(t, err)
  786. var snapshot UpstreamModelMetadataSnapshot
  787. require.NoError(t, json.Unmarshal(encoded, &snapshot))
  788. require.Contains(t, snapshot.Models, "gpt-6-astra")
  789. require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, snapshot.Models["gpt-6-astra"].SupportedReasoningLevels)
  790. require.NotContains(t, snapshot.Models, "gpt-image-2")
  791. }
  792. func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) {
  793. t.Parallel()
  794. upstream := &httpUpstreamRecorder{resp: &http.Response{
  795. StatusCode: http.StatusOK,
  796. Header: http.Header{"Content-Type": []string{"application/json"}},
  797. Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"gpt-5"}]}`)),
  798. }}
  799. cfg := upstreamModelSyncTestConfig()
  800. cfg.Gateway.ModelsListReadMaxBytes = 8
  801. svc := &AccountTestService{httpUpstream: upstream, cfg: cfg}
  802. _, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{
  803. ID: 7,
  804. Platform: PlatformOpenAI,
  805. Type: AccountTypeAPIKey,
  806. Credentials: map[string]any{
  807. "api_key": "openai-key",
  808. "base_url": "https://openai.example.com/v1",
  809. },
  810. })
  811. require.Error(t, err)
  812. require.Contains(t, err.Error(), "response exceeds 8 bytes")
  813. }
  814. func TestMatchModelsDevProviderFallsBackToOpenAIProviderWithoutAPIField(t *testing.T) {
  815. t.Parallel()
  816. registry := map[string]modelsDevProvider{
  817. "openai": {
  818. ID: "openai",
  819. Name: "OpenAI",
  820. // Official models.dev entry currently omits `api`.
  821. Models: map[string]modelsDevModel{
  822. "gpt-6-astra": {ID: "gpt-6-astra", Name: "GPT-6 Astra"},
  823. },
  824. },
  825. "opencode": {
  826. ID: "opencode",
  827. API: "https://opencode.ai/zen/v1",
  828. Models: map[string]modelsDevModel{
  829. "x-preview-f-free": {ID: "x-preview-f-free", Name: "Ox"},
  830. },
  831. },
  832. }
  833. for _, baseURL := range []string{
  834. "https://api.openai.com",
  835. "https://api.openai.com/v1",
  836. "https://chatgpt.com/backend-api/codex",
  837. } {
  838. provider, ok := matchModelsDevProvider(registry, baseURL)
  839. require.True(t, ok, baseURL)
  840. require.Equal(t, "openai", provider.ID, baseURL)
  841. require.Contains(t, provider.Models, "gpt-6-astra", baseURL)
  842. }
  843. _, ok := matchModelsDevProvider(registry, "https://compatible.example/v1")
  844. require.False(t, ok, "custom hosts must not inherit the official OpenAI provider by name")
  845. provider, ok := matchModelsDevProvider(registry, "https://opencode.ai/zen/v1")
  846. require.True(t, ok)
  847. require.Equal(t, "opencode", provider.ID)
  848. }
  849. // Scenario: 官方 OpenAI ID-only /models + 无 api 字段的 models.dev openai 条目仍能补齐并落库。
  850. func TestSyncUpstreamModelCatalogEnrichesOfficialOpenAIHostWithoutRegistryAPIField(t *testing.T) {
  851. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  852. {
  853. StatusCode: http.StatusOK,
  854. Header: http.Header{"Content-Type": []string{"application/json"}},
  855. Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[
  856. {"id":"gpt-6-astra","object":"model"},
  857. {"id":"gpt-5.6-sol","object":"model"}
  858. ]}`)),
  859. },
  860. {
  861. StatusCode: http.StatusOK,
  862. Header: http.Header{"Content-Type": []string{"application/json"}},
  863. Body: io.NopCloser(strings.NewReader(`{
  864. "openai": {
  865. "id": "openai",
  866. "name": "OpenAI",
  867. "models": {
  868. "gpt-6-astra": {
  869. "id": "gpt-6-astra",
  870. "name": "GPT-6 Astra",
  871. "description": "OpenAI GPT-6 Astra",
  872. "reasoning": true,
  873. "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
  874. "modalities": {"input":["text","image","pdf"],"output":["text"]},
  875. "limit": {"context":1050000,"output":128000}
  876. },
  877. "gpt-5.6-sol": {
  878. "id": "gpt-5.6-sol",
  879. "name": "GPT-5.6 Sol",
  880. "reasoning": true,
  881. "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
  882. "modalities": {"input":["text","image"],"output":["text"]},
  883. "limit": {"context":1050000,"output":128000}
  884. }
  885. }
  886. }
  887. }`)),
  888. },
  889. }}
  890. repo := &upstreamModelMetadataRepoStub{}
  891. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  892. catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
  893. ID: 110, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  894. Credentials: map[string]any{
  895. "api_key": "sk-test",
  896. "base_url": "https://api.openai.com/v1",
  897. },
  898. })
  899. require.NoError(t, err)
  900. require.Empty(t, catalog.Warnings)
  901. require.Equal(t, []string{"gpt-5.6-sol", "gpt-6-astra"}, catalog.Models)
  902. astra := catalog.Metadata["gpt-6-astra"]
  903. require.Equal(t, "GPT-6 Astra", astra.DisplayName)
  904. require.NotNil(t, astra.Reasoning)
  905. require.True(t, *astra.Reasoning)
  906. require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, astra.SupportedReasoningLevels)
  907. require.Equal(t, []string{"text", "image"}, astra.InputModalities)
  908. require.Equal(t, int64(1_050_000), astra.ContextWindow)
  909. require.NotNil(t, repo.updates)
  910. encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
  911. require.NoError(t, err)
  912. var snapshot UpstreamModelMetadataSnapshot
  913. require.NoError(t, json.Unmarshal(encoded, &snapshot))
  914. require.Equal(t, "models.dev", snapshot.Source)
  915. require.Equal(t, astra, snapshot.Models["gpt-6-astra"])
  916. }
  917. // Scenario: 同一批同步里部分模型能力完整时仍落库完整条目,并对不完整条目告警。
  918. func TestSyncUpstreamModelCatalogPersistsCompleteModelsWhenSomeRemainIncomplete(t *testing.T) {
  919. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  920. {
  921. StatusCode: http.StatusOK,
  922. Header: http.Header{"Content-Type": []string{"application/json"}},
  923. Body: io.NopCloser(strings.NewReader(`{"models":[
  924. {
  925. "id":"complete-model",
  926. "display_name":"Complete",
  927. "reasoning":true,
  928. "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}],
  929. "input_modalities":["text","image"],
  930. "context_window":128000
  931. },
  932. {
  933. "id":"incomplete-model",
  934. "display_name":"Incomplete Only"
  935. }
  936. ]}`)),
  937. },
  938. {
  939. StatusCode: http.StatusOK,
  940. Header: http.Header{"Content-Type": []string{"application/json"}},
  941. Body: io.NopCloser(strings.NewReader(`{
  942. "provider": {
  943. "id": "provider",
  944. "api": "https://provider.example/v1",
  945. "models": {}
  946. }
  947. }`)),
  948. },
  949. }}
  950. repo := &upstreamModelMetadataRepoStub{}
  951. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  952. catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
  953. ID: 111, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  954. Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
  955. })
  956. require.NoError(t, err)
  957. require.Equal(t, []string{"complete-model", "incomplete-model"}, catalog.Models)
  958. require.Equal(t, UpstreamModelMetadataPartialCode, catalog.Warnings[0].Code)
  959. require.NotNil(t, repo.updates)
  960. encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
  961. require.NoError(t, err)
  962. var snapshot UpstreamModelMetadataSnapshot
  963. require.NoError(t, json.Unmarshal(encoded, &snapshot))
  964. require.Contains(t, snapshot.Models, "complete-model")
  965. require.NotContains(t, snapshot.Models, "incomplete-model")
  966. require.Equal(t, []string{"low", "high"}, snapshot.Models["complete-model"].SupportedReasoningLevels)
  967. }
  968. // Scenario: 上游清单未包含管理员 mapping 目标时,仍按 mapping 补齐并写入快照。
  969. func TestSyncUpstreamModelCatalogEnrichesConfiguredMappingModelsMissingFromUpstreamList(t *testing.T) {
  970. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  971. {
  972. StatusCode: http.StatusOK,
  973. Header: http.Header{"Content-Type": []string{"application/json"}},
  974. Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol","object":"model"}]}`)),
  975. },
  976. {
  977. StatusCode: http.StatusOK,
  978. Header: http.Header{"Content-Type": []string{"application/json"}},
  979. Body: io.NopCloser(strings.NewReader(`{
  980. "openai": {
  981. "id": "openai",
  982. "models": {
  983. "gpt-5.6-sol": {
  984. "id": "gpt-5.6-sol",
  985. "name": "GPT-5.6 Sol",
  986. "reasoning": true,
  987. "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
  988. "modalities": {"input":["text","image"],"output":["text"]},
  989. "limit": {"context":1050000,"output":128000}
  990. },
  991. "gpt-6-astra": {
  992. "id": "gpt-6-astra",
  993. "name": "GPT-6 Astra",
  994. "reasoning": true,
  995. "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
  996. "modalities": {"input":["text","image"],"output":["text"]},
  997. "limit": {"context":1050000,"output":128000}
  998. }
  999. }
  1000. }
  1001. }`)),
  1002. },
  1003. }}
  1004. repo := &upstreamModelMetadataRepoStub{}
  1005. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  1006. catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
  1007. ID: 112, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  1008. Credentials: map[string]any{
  1009. "api_key": "sk-test",
  1010. "base_url": "https://api.openai.com/v1",
  1011. "model_mapping": map[string]any{
  1012. "gpt-6-astra": "gpt-6-astra",
  1013. },
  1014. },
  1015. })
  1016. require.NoError(t, err)
  1017. require.Equal(t, []string{"gpt-5.6-sol"}, catalog.Models, "UI model list stays upstream-only")
  1018. require.Empty(t, catalog.Warnings)
  1019. require.Contains(t, catalog.Metadata, "gpt-6-astra")
  1020. require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, catalog.Metadata["gpt-6-astra"].SupportedReasoningLevels)
  1021. encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
  1022. require.NoError(t, err)
  1023. var snapshot UpstreamModelMetadataSnapshot
  1024. require.NoError(t, json.Unmarshal(encoded, &snapshot))
  1025. require.Contains(t, snapshot.Models, "gpt-5.6-sol")
  1026. require.Contains(t, snapshot.Models, "gpt-6-astra")
  1027. }
  1028. func TestSyncUpstreamModelCatalogAstraPartialRefreshPreservesKnownCapabilities(t *testing.T) {
  1029. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  1030. {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[
  1031. {"id":"gpt-6-astra","supports_search_tool":false,"apply_patch_tool_type":null},
  1032. {"id":"still-listed"},{"id":"gpt-image-2"}
  1033. ]}`))},
  1034. {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"openai":{"id":"openai","models":{
  1035. "gpt-6-astra":{"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"modalities":{"input":["text","image"]},"limit":{"context":1050000,"output":128000}},
  1036. "mapped-only":{"reasoning":false,"modalities":{"input":["text"]},"limit":{"context":64000}},
  1037. "gpt-image-2":{"reasoning":false,"modalities":{"input":["text","image"]},"limit":{"context":0}}
  1038. }}}`))},
  1039. }}
  1040. repo := &upstreamModelMetadataRepoStub{}
  1041. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
  1042. account := &Account{ID: 114, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  1043. Credentials: map[string]any{"api_key": "test", "base_url": "https://api.openai.com/v1",
  1044. "model_mapping": map[string]any{"public-model": "mapped-only"}},
  1045. }
  1046. old := UpstreamModelMetadata{ID: "still-listed", ContextWindow: 256000}
  1047. account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
  1048. "still-listed": old, "removed": {ID: "removed", ContextWindow: 128000},
  1049. "gpt-6-astra": {ID: "gpt-6-astra", CodexToolCapabilities: map[string]json.RawMessage{
  1050. "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`),
  1051. "comp_hash": json.RawMessage(`"3000"`),
  1052. }},
  1053. }})
  1054. catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
  1055. require.NoError(t, err)
  1056. require.Equal(t, UpstreamModelMetadataPartialCode, catalog.Warnings[0].Code)
  1057. require.NotContains(t, catalog.Models, "mapped-only", "capability enrichment must not change discovery")
  1058. snapshot := account.GetUpstreamModelMetadataSnapshot()
  1059. require.Equal(t, old, snapshot.Models["still-listed"])
  1060. require.NotContains(t, snapshot.Models, "removed")
  1061. require.NotContains(t, snapshot.Models, "gpt-image-2")
  1062. require.Contains(t, snapshot.Models, "mapped-only")
  1063. astra := snapshot.Models["gpt-6-astra"]
  1064. require.Equal(t, int64(1050000), astra.ContextWindow)
  1065. require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, astra.SupportedReasoningLevels)
  1066. require.JSONEq(t, "false", string(astra.CodexToolCapabilities["supports_search_tool"]))
  1067. require.JSONEq(t, "null", string(astra.CodexToolCapabilities["apply_patch_tool_type"]))
  1068. require.JSONEq(t, `"3000"`, string(astra.CodexToolCapabilities["comp_hash"]))
  1069. require.NotNil(t, repo.updates)
  1070. }
  1071. func TestMatchModelsDevProviderOfficialHostsWithoutAPI(t *testing.T) {
  1072. registry := map[string]modelsDevProvider{
  1073. "openai": {ID: "openai", Models: map[string]modelsDevModel{"gpt-6-astra": {ID: "gpt-6-astra"}}},
  1074. "relay": {ID: "relay", API: "https://relay.example/v1"},
  1075. }
  1076. for _, baseURL := range []string{"https://api.openai.com/v1", "https://chatgpt.com/backend-api/codex"} {
  1077. provider, ok := matchModelsDevProvider(registry, baseURL)
  1078. require.True(t, ok)
  1079. require.Equal(t, "openai", provider.ID)
  1080. }
  1081. for _, baseURL := range []string{"https://api.openai.com.evil.example/v1", "https://unknown.example/v1"} {
  1082. _, ok := matchModelsDevProvider(registry, baseURL)
  1083. require.False(t, ok)
  1084. }
  1085. provider, ok := matchModelsDevProvider(registry, "https://relay.example/v1")
  1086. require.True(t, ok)
  1087. require.Equal(t, "relay", provider.ID)
  1088. metadata := map[string]UpstreamModelMetadata{"gpt-6-astra": {
  1089. Reasoning: new(bool), InputModalities: []string{"text"}, ContextWindow: 1050000,
  1090. }}
  1091. require.False(t, upstreamCatalogNeedsRegistry(capabilitySyncModelIDs([]string{"gpt-6-astra", "gpt-image-2"}), metadata))
  1092. }