upstream_models.go 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "log/slog"
  9. "net/http"
  10. "net/url"
  11. "sort"
  12. "strings"
  13. "time"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
  17. )
  18. const (
  19. upstreamModelsBodyLimit int64 = 8 << 20
  20. modelsDevRegistryURL = "https://models.dev/api.json"
  21. modelsDevRegistryTTL = 6 * time.Hour
  22. UpstreamModelMetadataExtraKey = "upstream_model_metadata"
  23. UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete"
  24. UpstreamModelMetadataPartialCode = "upstream_model_metadata_partial"
  25. )
  26. type UpstreamModelMetadata struct {
  27. ID string `json:"id"`
  28. DisplayName string `json:"display_name,omitempty"`
  29. Description string `json:"description,omitempty"`
  30. Reasoning *bool `json:"reasoning,omitempty"`
  31. DefaultReasoningLevel string `json:"default_reasoning_level,omitempty"`
  32. SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"`
  33. InputModalities []string `json:"input_modalities,omitempty"`
  34. ContextWindow int64 `json:"context_window,omitempty"`
  35. MaxOutputTokens int64 `json:"max_output_tokens,omitempty"`
  36. CodexToolCapabilities map[string]json.RawMessage `json:"codex_tool_capabilities,omitempty"`
  37. }
  38. type UpstreamModelMetadataSnapshot struct {
  39. Source string `json:"source"`
  40. SyncedAt string `json:"synced_at"`
  41. Models map[string]UpstreamModelMetadata `json:"models"`
  42. }
  43. type UpstreamModelCatalog struct {
  44. Models []string `json:"models"`
  45. Metadata map[string]UpstreamModelMetadata `json:"metadata,omitempty"`
  46. Warnings []UpstreamModelSyncWarning `json:"warnings,omitempty"`
  47. }
  48. type UpstreamModelSyncWarning struct {
  49. Code string `json:"code"`
  50. Message string `json:"message"`
  51. }
  52. type modelsDevProvider struct {
  53. ID string `json:"id"`
  54. Name string `json:"name"`
  55. API string `json:"api"`
  56. Models map[string]modelsDevModel `json:"models"`
  57. }
  58. type modelsDevModel struct {
  59. ID string `json:"id"`
  60. Name string `json:"name"`
  61. Description string `json:"description"`
  62. Reasoning *bool `json:"reasoning"`
  63. ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"`
  64. Modalities modelsDevModalities `json:"modalities"`
  65. Limit modelsDevLimit `json:"limit"`
  66. }
  67. type modelsDevReasoningOption struct {
  68. Type string `json:"type"`
  69. Values []any `json:"values"`
  70. }
  71. type modelsDevModalities struct {
  72. Input []string `json:"input"`
  73. Output []string `json:"output"`
  74. }
  75. type modelsDevLimit struct {
  76. Context int64 `json:"context"`
  77. Output int64 `json:"output"`
  78. }
  79. func (a *Account) SetUpstreamModelMetadataSnapshot(snapshot UpstreamModelMetadataSnapshot) {
  80. if a == nil {
  81. return
  82. }
  83. if a.Extra == nil {
  84. a.Extra = make(map[string]any)
  85. }
  86. a.Extra[UpstreamModelMetadataExtraKey] = snapshot
  87. }
  88. func (a *Account) GetUpstreamModelMetadataSnapshot() *UpstreamModelMetadataSnapshot {
  89. if a == nil || a.Extra == nil {
  90. return nil
  91. }
  92. raw, ok := a.Extra[UpstreamModelMetadataExtraKey]
  93. if !ok || raw == nil {
  94. return nil
  95. }
  96. body, err := json.Marshal(raw)
  97. if err != nil {
  98. return nil
  99. }
  100. var snapshot UpstreamModelMetadataSnapshot
  101. if err := json.Unmarshal(body, &snapshot); err != nil || len(snapshot.Models) == 0 {
  102. return nil
  103. }
  104. return &snapshot
  105. }
  106. func (a *Account) GetUpstreamModelMetadata(modelID string) (UpstreamModelMetadata, bool) {
  107. snapshot := a.GetUpstreamModelMetadataSnapshot()
  108. if snapshot == nil {
  109. return UpstreamModelMetadata{}, false
  110. }
  111. metadata, ok := snapshot.Models[strings.TrimSpace(modelID)]
  112. return metadata, ok
  113. }
  114. // UpstreamModelSyncErrorKind classifies model sync failures for safe HTTP mapping.
  115. type UpstreamModelSyncErrorKind string
  116. const (
  117. // UpstreamModelSyncErrorConfiguration means the account or server configuration cannot perform the sync.
  118. UpstreamModelSyncErrorConfiguration UpstreamModelSyncErrorKind = "configuration"
  119. // UpstreamModelSyncErrorUnsupported means the account format is intentionally unsupported for live model sync.
  120. UpstreamModelSyncErrorUnsupported UpstreamModelSyncErrorKind = "unsupported"
  121. // UpstreamModelSyncErrorUpstream means the configured upstream failed or returned an unusable response.
  122. UpstreamModelSyncErrorUpstream UpstreamModelSyncErrorKind = "upstream"
  123. // UpstreamModelSyncErrorInternal means local persistence or service state failed after a valid upstream response.
  124. UpstreamModelSyncErrorInternal UpstreamModelSyncErrorKind = "internal"
  125. )
  126. // UpstreamModelSyncError keeps internal failure details wrapped while exposing a safe client message.
  127. type UpstreamModelSyncError struct {
  128. Kind UpstreamModelSyncErrorKind
  129. Message string
  130. StatusCode int
  131. Err error
  132. }
  133. func (e *UpstreamModelSyncError) Error() string {
  134. if e == nil {
  135. return ""
  136. }
  137. if e.Err == nil {
  138. return e.Message
  139. }
  140. return e.Message + ": " + e.Err.Error()
  141. }
  142. func (e *UpstreamModelSyncError) Unwrap() error {
  143. if e == nil {
  144. return nil
  145. }
  146. return e.Err
  147. }
  148. // SafeMessage returns the sanitized message that can be sent to API clients.
  149. func (e *UpstreamModelSyncError) SafeMessage() string {
  150. if e == nil || strings.TrimSpace(e.Message) == "" {
  151. return "Failed to sync upstream models"
  152. }
  153. return e.Message
  154. }
  155. func newUpstreamModelSyncConfigError(message string, err error) error {
  156. return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorConfiguration, Message: message, Err: err}
  157. }
  158. func newUpstreamModelSyncUnsupportedError(message string, err error) error {
  159. return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorUnsupported, Message: message, Err: err}
  160. }
  161. func newUpstreamModelSyncUpstreamError(message string, err error) error {
  162. return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorUpstream, Message: message, Err: err}
  163. }
  164. func newUpstreamModelSyncInternalError(message string, err error) error {
  165. return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorInternal, Message: message, Err: err}
  166. }
  167. // FetchUpstreamSupportedModels fetches only live model IDs. The admin sync path
  168. // uses SyncUpstreamModelCatalog so capability metadata can also be persisted.
  169. func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, account *Account) ([]string, error) {
  170. models, _, err := s.fetchUpstreamModelList(ctx, account)
  171. return models, err
  172. }
  173. // SyncUpstreamModelCatalog fetches the account's live model list, enriches
  174. // missing capability fields from the provider registry used by the upstream,
  175. // and persists a normalized account snapshot when complete metadata is available.
  176. //
  177. // Persistence is per-model: models with complete capability fields are saved even
  178. // when other IDs in the same sync remain incomplete. An incomplete warning is
  179. // still returned so admins can tell ID sync succeeded without a full capability
  180. // snapshot. When no model is complete, the existing account snapshot is left
  181. // untouched.
  182. func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, account *Account) (*UpstreamModelCatalog, error) {
  183. models, body, err := s.fetchUpstreamModelList(ctx, account)
  184. liveListAvailable := err == nil
  185. if err != nil {
  186. configuredModels := configuredUpstreamModelsForCapabilitySync(account)
  187. if !upstreamModelListEndpointUnsupported(err) || len(configuredModels) == 0 {
  188. return nil, err
  189. }
  190. models = configuredModels
  191. body = nil
  192. slog.Info("upstream model list endpoint unavailable; using configured models for capability sync",
  193. "account_id", upstreamModelSyncAccountID(account),
  194. "platform", upstreamModelSyncPlatform(account),
  195. "status_code", upstreamModelSyncStatusCode(err),
  196. "model_count", len(models),
  197. )
  198. }
  199. catalog := &UpstreamModelCatalog{Models: models, Metadata: make(map[string]UpstreamModelMetadata)}
  200. if len(body) > 0 {
  201. _, directMetadata, parseErr := extractUpstreamModelCatalog(body, account != nil && account.IsGrok())
  202. if parseErr == nil {
  203. catalog.Metadata = directMetadata
  204. }
  205. }
  206. // Capability enrichment also covers concrete model_mapping targets. Admins may
  207. // whitelist models that the live /models list omitted; those still need registry
  208. // metadata so Codex catalogs can advertise reasoning and modalities.
  209. enrichIDs := dedupeAndSortModelIDs(append(append([]string{}, models...), configuredUpstreamModelsForCapabilitySync(account)...))
  210. // Dedicated image/video generators are not Codex agent catalog entries and often
  211. // omit context windows in public registries. Keep them out of completeness checks
  212. // so they do not mask successful agent-model capability sync.
  213. capabilityIDs := capabilitySyncModelIDs(enrichIDs)
  214. source := "upstream"
  215. if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) {
  216. if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, enrichIDs); registryErr == nil {
  217. for modelID, fallback := range registryMetadata {
  218. current := catalog.Metadata[modelID]
  219. merged, changed := mergeUpstreamModelMetadata(current, fallback)
  220. catalog.Metadata[modelID] = merged
  221. if changed {
  222. source = "models.dev"
  223. }
  224. }
  225. } else {
  226. slog.Warn("upstream model capability metadata enrichment failed",
  227. "account_id", upstreamModelSyncAccountID(account),
  228. "platform", upstreamModelSyncPlatform(account),
  229. "error", registryErr,
  230. )
  231. }
  232. }
  233. completeMetadata := completeUpstreamModelMetadataSubset(capabilityIDs, catalog.Metadata)
  234. persistedCapabilities := false
  235. if len(completeMetadata) > 0 && account != nil && account.ID > 0 && s.accountRepo != nil {
  236. // Retain known metadata only for models still listed or explicitly mapped.
  237. if previous := account.GetUpstreamModelMetadataSnapshot(); previous != nil {
  238. retainedModels := capabilityIDs
  239. if !liveListAvailable {
  240. retainedModels = append([]string(nil), capabilityIDs...)
  241. for modelID := range previous.Models {
  242. retainedModels = append(retainedModels, modelID)
  243. }
  244. }
  245. for _, modelID := range retainedModels {
  246. old, exists := previous.Models[modelID]
  247. if !exists {
  248. continue
  249. }
  250. if entry, ok := completeMetadata[modelID]; ok {
  251. if entry.CodexToolCapabilities == nil {
  252. entry.CodexToolCapabilities = make(map[string]json.RawMessage)
  253. }
  254. applyCodexToolCapabilities(entry.CodexToolCapabilities, old.CodexToolCapabilities, false)
  255. completeMetadata[modelID] = entry
  256. } else {
  257. completeMetadata[modelID] = old
  258. }
  259. }
  260. }
  261. snapshot := UpstreamModelMetadataSnapshot{
  262. Source: source,
  263. SyncedAt: time.Now().UTC().Format(time.RFC3339),
  264. Models: completeMetadata,
  265. }
  266. if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil {
  267. return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err)
  268. }
  269. account.SetUpstreamModelMetadataSnapshot(snapshot)
  270. persistedCapabilities = true
  271. }
  272. if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) {
  273. if persistedCapabilities {
  274. catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{
  275. Code: UpstreamModelMetadataPartialCode,
  276. Message: "Some model capabilities were saved; remaining models are still incomplete.",
  277. })
  278. } else {
  279. catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{
  280. Code: UpstreamModelMetadataIncompleteCode,
  281. Message: "Model IDs were synced, but capability metadata is incomplete.",
  282. })
  283. }
  284. }
  285. return catalog, nil
  286. }
  287. func upstreamModelSyncStatusCode(err error) int {
  288. var syncErr *UpstreamModelSyncError
  289. if errors.As(err, &syncErr) {
  290. return syncErr.StatusCode
  291. }
  292. return 0
  293. }
  294. func upstreamModelListEndpointUnsupported(err error) bool {
  295. statusCode := upstreamModelSyncStatusCode(err)
  296. return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed
  297. }
  298. func configuredUpstreamModelsForCapabilitySync(account *Account) []string {
  299. if account == nil {
  300. return nil
  301. }
  302. models := make([]string, 0)
  303. for _, mappedModel := range account.GetModelMapping() {
  304. mappedModel = strings.TrimSpace(mappedModel)
  305. if mappedModel == "" || strings.Contains(mappedModel, "*") {
  306. continue
  307. }
  308. models = append(models, mappedModel)
  309. }
  310. return dedupeAndSortModelIDs(models)
  311. }
  312. func capabilitySyncModelIDs(modelIDs []string) []string {
  313. filtered := make([]string, 0, len(modelIDs))
  314. for _, modelID := range modelIDs {
  315. modelID = strings.TrimSpace(modelID)
  316. if modelID == "" || isCodexDedicatedMediaModel(modelID) {
  317. continue
  318. }
  319. filtered = append(filtered, modelID)
  320. }
  321. return filtered
  322. }
  323. func upstreamModelSyncAccountID(account *Account) int64 {
  324. if account == nil {
  325. return 0
  326. }
  327. return account.ID
  328. }
  329. func upstreamModelSyncPlatform(account *Account) string {
  330. if account == nil {
  331. return ""
  332. }
  333. return account.Platform
  334. }
  335. func upstreamCatalogNeedsRegistry(models []string, metadata map[string]UpstreamModelMetadata) bool {
  336. for _, modelID := range models {
  337. modelID = strings.TrimSpace(modelID)
  338. model, ok := metadata[modelID]
  339. if !ok || !upstreamModelMetadataIsComplete(model) {
  340. return true
  341. }
  342. }
  343. return false
  344. }
  345. func upstreamModelMetadataIsUseful(metadata UpstreamModelMetadata) bool {
  346. return strings.TrimSpace(metadata.DisplayName) != "" ||
  347. strings.TrimSpace(metadata.Description) != "" ||
  348. metadata.Reasoning != nil ||
  349. len(metadata.SupportedReasoningLevels) > 0 ||
  350. len(metadata.InputModalities) > 0 ||
  351. len(metadata.CodexToolCapabilities) > 0 ||
  352. metadata.ContextWindow > 0 ||
  353. metadata.MaxOutputTokens > 0
  354. }
  355. // upstreamModelMetadataIsComplete reports whether a snapshot entry is safe to
  356. // persist and later prefer over local Codex name-based fallbacks.
  357. func upstreamModelMetadataIsComplete(metadata UpstreamModelMetadata) bool {
  358. if metadata.Reasoning == nil {
  359. return false
  360. }
  361. if len(normalizeCodexInputModalities(metadata.InputModalities)) == 0 {
  362. return false
  363. }
  364. if metadata.ContextWindow <= 0 {
  365. return false
  366. }
  367. if *metadata.Reasoning && len(normalizeReasoningLevels(metadata.SupportedReasoningLevels)) == 0 {
  368. return false
  369. }
  370. return true
  371. }
  372. func completeUpstreamModelMetadataSubset(
  373. modelIDs []string,
  374. metadata map[string]UpstreamModelMetadata,
  375. ) map[string]UpstreamModelMetadata {
  376. if len(metadata) == 0 {
  377. return nil
  378. }
  379. complete := make(map[string]UpstreamModelMetadata)
  380. for _, modelID := range modelIDs {
  381. modelID = strings.TrimSpace(modelID)
  382. if modelID == "" {
  383. continue
  384. }
  385. entry, ok := metadata[modelID]
  386. if !ok || !upstreamModelMetadataIsComplete(entry) {
  387. continue
  388. }
  389. if strings.TrimSpace(entry.ID) == "" {
  390. entry.ID = modelID
  391. }
  392. complete[modelID] = entry
  393. }
  394. if len(complete) == 0 {
  395. return nil
  396. }
  397. return complete
  398. }
  399. func mergeUpstreamModelMetadata(primary, fallback UpstreamModelMetadata) (UpstreamModelMetadata, bool) {
  400. merged := primary
  401. changed := false
  402. if strings.TrimSpace(merged.ID) == "" && strings.TrimSpace(fallback.ID) != "" {
  403. merged.ID = strings.TrimSpace(fallback.ID)
  404. changed = true
  405. }
  406. if strings.TrimSpace(merged.DisplayName) == "" && strings.TrimSpace(fallback.DisplayName) != "" {
  407. merged.DisplayName = strings.TrimSpace(fallback.DisplayName)
  408. changed = true
  409. }
  410. if strings.TrimSpace(merged.Description) == "" && strings.TrimSpace(fallback.Description) != "" {
  411. merged.Description = strings.TrimSpace(fallback.Description)
  412. changed = true
  413. }
  414. if merged.Reasoning == nil && fallback.Reasoning != nil {
  415. reasoning := *fallback.Reasoning
  416. merged.Reasoning = &reasoning
  417. changed = true
  418. }
  419. if strings.TrimSpace(merged.DefaultReasoningLevel) == "" && strings.TrimSpace(fallback.DefaultReasoningLevel) != "" {
  420. merged.DefaultReasoningLevel = strings.TrimSpace(fallback.DefaultReasoningLevel)
  421. changed = true
  422. }
  423. if len(merged.SupportedReasoningLevels) == 0 && len(fallback.SupportedReasoningLevels) > 0 {
  424. merged.SupportedReasoningLevels = append([]string(nil), fallback.SupportedReasoningLevels...)
  425. changed = true
  426. }
  427. if len(merged.InputModalities) == 0 && len(fallback.InputModalities) > 0 {
  428. merged.InputModalities = append([]string(nil), fallback.InputModalities...)
  429. changed = true
  430. }
  431. if merged.ContextWindow <= 0 && fallback.ContextWindow > 0 {
  432. merged.ContextWindow = fallback.ContextWindow
  433. changed = true
  434. }
  435. if merged.MaxOutputTokens <= 0 && fallback.MaxOutputTokens > 0 {
  436. merged.MaxOutputTokens = fallback.MaxOutputTokens
  437. changed = true
  438. }
  439. return merged, changed
  440. }
  441. func (s *AccountTestService) fetchModelsDevMetadata(
  442. ctx context.Context,
  443. account *Account,
  444. modelIDs []string,
  445. ) (map[string]UpstreamModelMetadata, error) {
  446. if s == nil || s.httpUpstream == nil || account == nil {
  447. return nil, fmt.Errorf("model metadata registry is not configured")
  448. }
  449. registry, err := s.fetchModelsDevRegistry(ctx, account)
  450. if err != nil {
  451. return nil, err
  452. }
  453. provider, ok := matchModelsDevProvider(registry, upstreamModelRegistryBaseURL(account))
  454. if !ok {
  455. return nil, fmt.Errorf("no model metadata provider matches account base URL")
  456. }
  457. metadata := make(map[string]UpstreamModelMetadata)
  458. for _, modelID := range modelIDs {
  459. modelID = strings.TrimSpace(modelID)
  460. model, found := provider.Models[modelID]
  461. if !found {
  462. for candidateID, candidate := range provider.Models {
  463. if strings.EqualFold(strings.TrimSpace(candidateID), modelID) || strings.EqualFold(strings.TrimSpace(candidate.ID), modelID) {
  464. model = candidate
  465. found = true
  466. break
  467. }
  468. }
  469. }
  470. if !found {
  471. continue
  472. }
  473. entry := upstreamMetadataFromModelsDevModel(modelID, model)
  474. if upstreamModelMetadataIsUseful(entry) {
  475. metadata[modelID] = entry
  476. }
  477. }
  478. return metadata, nil
  479. }
  480. func (s *AccountTestService) fetchModelsDevRegistry(ctx context.Context, account *Account) (map[string]modelsDevProvider, error) {
  481. now := time.Now()
  482. s.modelMetadataRegistryMu.Lock()
  483. if len(s.modelMetadataRegistry) > 0 && now.Sub(s.modelMetadataRegistryAt) < modelsDevRegistryTTL {
  484. cached := s.modelMetadataRegistry
  485. s.modelMetadataRegistryMu.Unlock()
  486. return cached, nil
  487. }
  488. s.modelMetadataRegistryMu.Unlock()
  489. req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsDevRegistryURL, nil)
  490. if err != nil {
  491. return nil, err
  492. }
  493. req.Header.Set("Accept", "application/json")
  494. resp, err := s.doUpstreamModelsRequest(req, upstreamModelsProxyURL(account), account)
  495. if err != nil {
  496. return nil, err
  497. }
  498. defer func() { _ = resp.Body.Close() }()
  499. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
  500. return nil, fmt.Errorf("model metadata registry returned HTTP %d", resp.StatusCode)
  501. }
  502. body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1))
  503. if err != nil {
  504. return nil, err
  505. }
  506. if int64(len(body)) > upstreamModelsBodyLimit {
  507. return nil, fmt.Errorf("model metadata registry response exceeds %d bytes", upstreamModelsBodyLimit)
  508. }
  509. var registry map[string]modelsDevProvider
  510. if err := json.Unmarshal(body, &registry); err != nil {
  511. return nil, fmt.Errorf("parse model metadata registry: %w", err)
  512. }
  513. if len(registry) == 0 {
  514. return nil, fmt.Errorf("model metadata registry is empty")
  515. }
  516. s.modelMetadataRegistryMu.Lock()
  517. s.modelMetadataRegistry = registry
  518. s.modelMetadataRegistryAt = now
  519. s.modelMetadataRegistryMu.Unlock()
  520. return registry, nil
  521. }
  522. func upstreamMetadataFromModelsDevModel(modelID string, model modelsDevModel) UpstreamModelMetadata {
  523. levels := reasoningLevelsFromModelsDevOptions(model.ReasoningOptions)
  524. reasoning := model.Reasoning
  525. if reasoning == nil && len(levels) > 0 {
  526. inferred := true
  527. reasoning = &inferred
  528. }
  529. metadata := UpstreamModelMetadata{
  530. ID: strings.TrimSpace(modelID),
  531. DisplayName: strings.TrimSpace(model.Name),
  532. Description: strings.TrimSpace(model.Description),
  533. Reasoning: reasoning,
  534. SupportedReasoningLevels: levels,
  535. InputModalities: normalizeCodexInputModalities(model.Modalities.Input),
  536. ContextWindow: model.Limit.Context,
  537. MaxOutputTokens: model.Limit.Output,
  538. }
  539. if len(levels) > 0 {
  540. metadata.DefaultReasoningLevel = levels[0]
  541. }
  542. if strings.TrimSpace(model.ID) != "" {
  543. metadata.ID = strings.TrimSpace(model.ID)
  544. }
  545. return metadata
  546. }
  547. func reasoningLevelsFromModelsDevOptions(options []modelsDevReasoningOption) []string {
  548. levels := make([]string, 0)
  549. for _, option := range options {
  550. if !strings.EqualFold(strings.TrimSpace(option.Type), "effort") {
  551. continue
  552. }
  553. for _, value := range option.Values {
  554. if value == nil {
  555. levels = append(levels, "none")
  556. continue
  557. }
  558. if effort, ok := value.(string); ok {
  559. levels = append(levels, effort)
  560. }
  561. }
  562. }
  563. return normalizeReasoningLevels(levels)
  564. }
  565. func upstreamModelRegistryBaseURL(account *Account) string {
  566. if account == nil {
  567. return ""
  568. }
  569. switch {
  570. case account.IsOpenAI() || account.IsCNProvider():
  571. return account.GetOpenAIFormatBaseURL()
  572. case account.IsGrok():
  573. return account.GetGrokBaseURL()
  574. case account.IsGemini():
  575. return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL)
  576. case account.IsAnthropic():
  577. return account.GetBaseURL()
  578. case account.Platform == PlatformAntigravity:
  579. return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL)
  580. default:
  581. return strings.TrimSpace(account.GetCredential("base_url"))
  582. }
  583. }
  584. func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) {
  585. if provider, ok := matchModelsDevProviderByAPIURL(registry, accountBaseURL); ok {
  586. return provider, true
  587. }
  588. return matchModelsDevProviderByKnownHost(registry, accountBaseURL)
  589. }
  590. func matchModelsDevProviderByAPIURL(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) {
  591. accountBaseURL = normalizeModelRegistryBaseURL(accountBaseURL)
  592. if accountBaseURL == "" {
  593. return modelsDevProvider{}, false
  594. }
  595. var best modelsDevProvider
  596. bestScore := -1
  597. for _, provider := range registry {
  598. providerBaseURL := normalizeModelRegistryBaseURL(provider.API)
  599. if providerBaseURL == "" {
  600. continue
  601. }
  602. if accountBaseURL != providerBaseURL &&
  603. !strings.HasPrefix(accountBaseURL, providerBaseURL+"/") &&
  604. !strings.HasPrefix(providerBaseURL, accountBaseURL+"/") {
  605. continue
  606. }
  607. if len(providerBaseURL) > bestScore {
  608. best = provider
  609. bestScore = len(providerBaseURL)
  610. }
  611. }
  612. return best, bestScore >= 0
  613. }
  614. // matchModelsDevProviderByKnownHost covers first-party hosts whose models.dev
  615. // entries omit the `api` field (notably the official OpenAI provider). Custom
  616. // compatible gateways must still match by API URL so same-named models are not
  617. // cross-attributed across vendors.
  618. func matchModelsDevProviderByKnownHost(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) {
  619. host := modelRegistryHostname(accountBaseURL)
  620. if host == "" {
  621. return modelsDevProvider{}, false
  622. }
  623. providerID := ""
  624. switch host {
  625. case "api.openai.com", "chatgpt.com":
  626. providerID = "openai"
  627. default:
  628. return modelsDevProvider{}, false
  629. }
  630. provider, ok := registry[providerID]
  631. if !ok || len(provider.Models) == 0 {
  632. return modelsDevProvider{}, false
  633. }
  634. if strings.TrimSpace(provider.ID) == "" {
  635. provider.ID = providerID
  636. }
  637. return provider, true
  638. }
  639. func modelRegistryHostname(raw string) string {
  640. normalized := normalizeModelRegistryBaseURL(raw)
  641. if normalized == "" {
  642. return ""
  643. }
  644. parsed, err := url.Parse(normalized)
  645. if err != nil {
  646. return ""
  647. }
  648. return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
  649. }
  650. func normalizeModelRegistryBaseURL(raw string) string {
  651. parsed, err := url.Parse(strings.TrimSpace(raw))
  652. if err != nil || parsed.Scheme == "" || parsed.Host == "" {
  653. return ""
  654. }
  655. path := strings.TrimRight(parsed.Path, "/")
  656. if strings.HasSuffix(strings.ToLower(path), "/models") {
  657. path = strings.TrimRight(path[:len(path)-len("/models")], "/")
  658. }
  659. return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host) + path
  660. }
  661. func (s *AccountTestService) fetchUpstreamModelList(ctx context.Context, account *Account) ([]string, []byte, error) {
  662. if s == nil {
  663. return nil, nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil)
  664. }
  665. if account == nil {
  666. return nil, nil, newUpstreamModelSyncConfigError("Account is required", nil)
  667. }
  668. if account.Platform == PlatformAntigravity && account.Type != AccountTypeAPIKey {
  669. models, err := s.fetchAntigravityOAuthUpstreamModels(ctx, account)
  670. return models, nil, err
  671. }
  672. if s.httpUpstream == nil {
  673. return nil, nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil)
  674. }
  675. req, err := s.buildUpstreamModelsRequest(ctx, account)
  676. if err != nil {
  677. return nil, nil, err
  678. }
  679. proxyURL := upstreamModelsProxyURL(account)
  680. resp, err := s.doUpstreamModelsRequest(req, proxyURL, account)
  681. if err != nil {
  682. return nil, nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err)
  683. }
  684. defer func() { _ = resp.Body.Close() }()
  685. bodyLimit := resolveModelsListReadLimit(s.cfg)
  686. body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
  687. if err != nil {
  688. return nil, nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err)
  689. }
  690. if int64(len(body)) > bodyLimit {
  691. return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit))
  692. }
  693. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
  694. return nil, nil, &UpstreamModelSyncError{
  695. Kind: UpstreamModelSyncErrorUpstream,
  696. Message: fmt.Sprintf("Upstream model list request failed with HTTP %d", resp.StatusCode),
  697. StatusCode: resp.StatusCode,
  698. Err: fmt.Errorf("upstream model list returned HTTP %d", resp.StatusCode),
  699. }
  700. }
  701. extractModels := extractUpstreamModelIDs
  702. if account.IsGrok() {
  703. extractModels = extractGrokUpstreamModelIDs
  704. }
  705. models, err := extractModels(body)
  706. if err != nil {
  707. return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err)
  708. }
  709. if len(models) == 0 {
  710. return nil, nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil)
  711. }
  712. return models, body, nil
  713. }
  714. func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) {
  715. switch {
  716. case account.Platform == PlatformAntigravity:
  717. return s.buildAntigravityAPIKeyModelsRequest(ctx, account)
  718. case account.IsGrok():
  719. return s.buildGrokUpstreamModelsRequest(ctx, account)
  720. case account.IsOpenAI() || account.IsCNProvider():
  721. // 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)复用 OpenAI /v1/models 探测。
  722. return s.buildOpenAIUpstreamModelsRequest(ctx, account)
  723. case account.IsGemini():
  724. return s.buildGeminiUpstreamModelsRequest(ctx, account)
  725. case account.IsAnthropic():
  726. return s.buildAnthropicUpstreamModelsRequest(ctx, account)
  727. default:
  728. return nil, newUpstreamModelSyncUnsupportedError(
  729. fmt.Sprintf("Unsupported platform for upstream model sync: %s", account.Platform), nil,
  730. )
  731. }
  732. }
  733. func (s *AccountTestService) buildGrokUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) {
  734. if account == nil {
  735. return nil, newUpstreamModelSyncConfigError("Account is required", nil)
  736. }
  737. var (
  738. authToken string
  739. normalizedBaseURL string
  740. isOAuth = account.IsGrokOAuth()
  741. )
  742. switch account.Type {
  743. case AccountTypeAPIKey:
  744. authToken = strings.TrimSpace(account.GetCredential("api_key"))
  745. if authToken == "" {
  746. return nil, newUpstreamModelSyncConfigError("No Grok API key is available", nil)
  747. }
  748. baseURL := strings.TrimSpace(account.GetCredential("base_url"))
  749. if baseURL == "" {
  750. baseURL = "https://api.x.ai"
  751. }
  752. validatedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
  753. if err != nil {
  754. return nil, newUpstreamModelSyncConfigError("Invalid Grok base URL", err)
  755. }
  756. normalizedBaseURL = validatedBaseURL
  757. case AccountTypeOAuth:
  758. if s.grokTokenProvider == nil {
  759. return nil, newUpstreamModelSyncConfigError("Grok token provider is not configured", nil)
  760. }
  761. accessToken, err := s.grokTokenProvider.GetAccessTokenForManualTest(ctx, account)
  762. if err != nil {
  763. return nil, newUpstreamModelSyncUpstreamError("Failed to get Grok access token", err)
  764. }
  765. authToken = strings.TrimSpace(accessToken)
  766. if authToken == "" {
  767. return nil, newUpstreamModelSyncConfigError("No Grok access token is available", nil)
  768. }
  769. validator, err := grokBaseURLValidator(account, s.cfg)
  770. if err != nil {
  771. return nil, newUpstreamModelSyncConfigError("Invalid Grok base URL", err)
  772. }
  773. baseURL := account.GetGrokBaseURL()
  774. if s.settingService != nil {
  775. baseURL = s.settingService.ResolveGrokBaseURL(ctx, account)
  776. }
  777. validatedBaseURL, err := validator(baseURL)
  778. if err != nil {
  779. return nil, newUpstreamModelSyncConfigError("Invalid Grok base URL", err)
  780. }
  781. normalizedBaseURL = validatedBaseURL
  782. default:
  783. return nil, newUpstreamModelSyncUnsupportedError(
  784. fmt.Sprintf("Unsupported Grok account type for upstream model sync: %s", account.Type), nil,
  785. )
  786. }
  787. req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildOpenAIModelsURL(normalizedBaseURL), nil)
  788. if err != nil {
  789. return nil, newUpstreamModelSyncConfigError("Invalid Grok model list URL", err)
  790. }
  791. req.Header.Set("Accept", "application/json")
  792. req.Header.Set("Authorization", "Bearer "+authToken)
  793. if isOAuth {
  794. // The shared HTTP transport adds the official CLI marker/version for the
  795. // exact proxy host. Keep the request builder aligned with the other Grok
  796. // probes and only forward account identity headers to that trusted host.
  797. applyGrokCLIHeaders(req.Header)
  798. if isGrokCLIProxyTarget(req.URL.String()) {
  799. if userID := strings.TrimSpace(account.GetCredential("sub")); userID != "" {
  800. req.Header.Set("X-UserID", userID)
  801. }
  802. if email := strings.TrimSpace(account.GetCredential("email")); email != "" {
  803. req.Header.Set("X-Email", email)
  804. }
  805. }
  806. }
  807. account.ApplyHeaderOverrides(req.Header)
  808. return req, nil
  809. }
  810. func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) {
  811. if account.IsBedrock() || account.Type == AccountTypeServiceAccount {
  812. return nil, newUpstreamModelSyncUnsupportedError(
  813. fmt.Sprintf("Unsupported Anthropic account type for upstream model sync: %s", account.Type), nil,
  814. )
  815. }
  816. baseURL := "https://api.anthropic.com"
  817. authHeaderName := ""
  818. authHeaderValue := ""
  819. apiKeyAuthToken := ""
  820. betaHeader := ""
  821. if account.IsOAuth() {
  822. accessToken := strings.TrimSpace(account.GetCredential("access_token"))
  823. if accessToken == "" && s.claudeTokenProvider != nil {
  824. token, tokenErr := s.claudeTokenProvider.GetAccessToken(ctx, account)
  825. if tokenErr != nil {
  826. return nil, newUpstreamModelSyncUpstreamError("Failed to get Anthropic access token", tokenErr)
  827. }
  828. accessToken = strings.TrimSpace(token)
  829. }
  830. if accessToken == "" {
  831. return nil, newUpstreamModelSyncConfigError("No Anthropic access token is available", nil)
  832. }
  833. authHeaderName = "Authorization"
  834. authHeaderValue = "Bearer " + accessToken
  835. betaHeader = claude.DefaultBetaHeader
  836. } else if account.Type == AccountTypeAPIKey {
  837. apiKey := strings.TrimSpace(account.GetCredential("api_key"))
  838. if apiKey == "" {
  839. return nil, newUpstreamModelSyncConfigError("No Anthropic API key is available", nil)
  840. }
  841. baseURL = account.GetBaseURL()
  842. if strings.TrimSpace(baseURL) == "" {
  843. baseURL = "https://api.anthropic.com"
  844. }
  845. apiKeyAuthToken = apiKey
  846. betaHeader = claude.APIKeyBetaHeader
  847. } else {
  848. return nil, newUpstreamModelSyncUnsupportedError(
  849. fmt.Sprintf("Unsupported Anthropic account type for upstream model sync: %s", account.Type), nil,
  850. )
  851. }
  852. normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
  853. if err != nil {
  854. return nil, newUpstreamModelSyncConfigError("Invalid Anthropic base URL", err)
  855. }
  856. req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildV1ModelsURL(normalizedBaseURL), nil)
  857. if err != nil {
  858. return nil, newUpstreamModelSyncConfigError("Invalid Anthropic model list URL", err)
  859. }
  860. for key, value := range claude.DefaultHeaders {
  861. req.Header.Set(key, value)
  862. }
  863. req.Header.Set("Accept", "application/json")
  864. req.Header.Set("anthropic-version", "2023-06-01")
  865. req.Header.Set("anthropic-beta", betaHeader)
  866. if authHeaderName != "" {
  867. req.Header.Set(authHeaderName, authHeaderValue)
  868. } else {
  869. // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持
  870. // extra/default 行为。
  871. setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken, normalizedBaseURL)
  872. }
  873. // 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头
  874. account.ApplyHeaderOverrides(req.Header)
  875. return req, nil
  876. }
  877. func (s *AccountTestService) buildAntigravityAPIKeyModelsRequest(ctx context.Context, account *Account) (*http.Request, error) {
  878. if account.Type != AccountTypeAPIKey {
  879. return nil, newUpstreamModelSyncUnsupportedError(
  880. fmt.Sprintf("Unsupported Antigravity account type for upstream model sync: %s", account.Type), nil,
  881. )
  882. }
  883. apiKey := strings.TrimSpace(account.GetCredential("api_key"))
  884. if apiKey == "" {
  885. return nil, newUpstreamModelSyncConfigError("No Antigravity API key is available", nil)
  886. }
  887. baseURL := strings.TrimRight(strings.TrimSpace(account.GetCredential("base_url")), "/")
  888. if baseURL == "" {
  889. return nil, newUpstreamModelSyncConfigError("Antigravity API-key base URL is required for upstream model sync", nil)
  890. }
  891. if !strings.HasSuffix(strings.ToLower(baseURL), "/antigravity") {
  892. return nil, newUpstreamModelSyncUnsupportedError(
  893. "Antigravity API-key upstream model sync requires a compatible gateway base URL ending in /antigravity; use Antigravity OAuth for official Cloud Code upstreams",
  894. nil,
  895. )
  896. }
  897. normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
  898. if err != nil {
  899. return nil, newUpstreamModelSyncConfigError("Invalid Antigravity base URL", err)
  900. }
  901. req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildV1ModelsURL(normalizedBaseURL), nil)
  902. if err != nil {
  903. return nil, newUpstreamModelSyncConfigError("Invalid Antigravity model list URL", err)
  904. }
  905. for key, value := range claude.DefaultHeaders {
  906. req.Header.Set(key, value)
  907. }
  908. req.Header.Set("Accept", "application/json")
  909. req.Header.Set("anthropic-version", "2023-06-01")
  910. req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
  911. req.Header.Set("x-api-key", apiKey)
  912. return req, nil
  913. }
  914. func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) {
  915. if account.IsOpenAIOAuth() {
  916. return s.buildOpenAIOAuthUpstreamModelsRequest(ctx, account)
  917. }
  918. return buildOpenAIAPIKeyModelsRequest(ctx, account, s.validateUpstreamBaseURL)
  919. }
  920. // buildOpenAIAPIKeyModelsRequest is shared by admin discovery and public model
  921. // listing. Codex content negotiation is intentionally absent from this request.
  922. func buildOpenAIAPIKeyModelsRequest(ctx context.Context, account *Account, validateBaseURL func(string) (string, error)) (*http.Request, error) {
  923. if account.Type != AccountTypeAPIKey {
  924. return nil, newUpstreamModelSyncUnsupportedError(
  925. fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", account.Type), nil,
  926. )
  927. }
  928. apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey())
  929. if apiKey == "" {
  930. return nil, newUpstreamModelSyncConfigError("No OpenAI API key is available", nil)
  931. }
  932. // 协议感知:Anthropic 协议账号的凭证 base_url 指向 /anthropic 端点,模型
  933. // 列表同步需使用 OpenAI 格式 base(供应商 × 模式默认)。
  934. baseURL := account.GetOpenAIFormatBaseURL()
  935. if strings.TrimSpace(baseURL) == "" {
  936. baseURL = "https://api.openai.com"
  937. }
  938. normalizedBaseURL, err := validateBaseURL(baseURL)
  939. if err != nil {
  940. return nil, newUpstreamModelSyncConfigError("Invalid OpenAI base URL", err)
  941. }
  942. req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildOpenAIModelsURL(normalizedBaseURL), nil)
  943. if err != nil {
  944. return nil, newUpstreamModelSyncConfigError("Invalid OpenAI model list URL", err)
  945. }
  946. req.Header.Set("Accept", "application/json")
  947. req.Header.Set("Authorization", "Bearer "+apiKey)
  948. // 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头
  949. account.ApplyHeaderOverrides(req.Header)
  950. return req, nil
  951. }
  952. // buildOpenAIOAuthUpstreamModelsRequest uses ChatGPT's Codex model manifest.
  953. // OAuth subscriptions do not expose the public Platform API /v1/models endpoint,
  954. // so treating them like API-key accounts makes the admin sync button fail locally.
  955. func (s *AccountTestService) buildOpenAIOAuthUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) {
  956. credentialAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account)
  957. if err != nil {
  958. return nil, newUpstreamModelSyncConfigError("Failed to resolve OpenAI account credentials", err)
  959. }
  960. if !credentialAccount.IsOpenAIOAuth() {
  961. return nil, newUpstreamModelSyncUnsupportedError(
  962. fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", credentialAccount.Type), nil,
  963. )
  964. }
  965. modelsURL, err := buildCodexModelsManifestURL(
  966. chatgptCodexModelsURL,
  967. false,
  968. CodexCanonicalClientVersion(),
  969. )
  970. if err != nil {
  971. return nil, newUpstreamModelSyncConfigError("Invalid OpenAI Codex model list URL", err)
  972. }
  973. req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL.String(), nil)
  974. if err != nil {
  975. return nil, newUpstreamModelSyncConfigError("Invalid OpenAI Codex model list request", err)
  976. }
  977. if credentialAccount.IsOpenAIAgentIdentity() {
  978. authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(
  979. ctx,
  980. s.accountRepo,
  981. s.agentIdentityWS,
  982. &s.agentIdentityTaskMu,
  983. credentialAccount,
  984. )
  985. if authErr != nil {
  986. return nil, newUpstreamModelSyncUpstreamError("Failed to build OpenAI Agent Identity authentication", authErr)
  987. }
  988. for key, values := range authHeaders {
  989. for _, value := range values {
  990. req.Header.Add(key, value)
  991. }
  992. }
  993. } else {
  994. accessToken := strings.TrimSpace(credentialAccount.GetOpenAIAccessToken())
  995. if accessToken == "" {
  996. return nil, newUpstreamModelSyncConfigError("No OpenAI access token is available", nil)
  997. }
  998. req.Header.Set("Authorization", "Bearer "+accessToken)
  999. }
  1000. identity := resolveCodexOutboundIdentity(credentialAccount.GetOpenAIUserAgent())
  1001. req.Header.Set("Accept", "application/json")
  1002. req.Header.Set("Originator", identity.originator)
  1003. req.Header.Set("User-Agent", identity.userAgent)
  1004. req.Header.Set("Version", identity.version)
  1005. setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
  1006. credentialAccount.ApplyHeaderOverrides(req.Header)
  1007. enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent())
  1008. return req, nil
  1009. }
  1010. func (s *AccountTestService) buildGeminiUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) {
  1011. baseURL := account.GetGeminiBaseURL(geminicli.AIStudioBaseURL)
  1012. if strings.TrimSpace(baseURL) == "" {
  1013. baseURL = geminicli.AIStudioBaseURL
  1014. }
  1015. normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
  1016. if err != nil {
  1017. return nil, newUpstreamModelSyncConfigError("Invalid Gemini base URL", err)
  1018. }
  1019. req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildGeminiModelsURL(normalizedBaseURL), nil)
  1020. if err != nil {
  1021. return nil, newUpstreamModelSyncConfigError("Invalid Gemini model list URL", err)
  1022. }
  1023. req.Header.Set("Accept", "application/json")
  1024. switch account.Type {
  1025. case AccountTypeAPIKey:
  1026. apiKey := strings.TrimSpace(account.GetCredential("api_key"))
  1027. if apiKey == "" {
  1028. return nil, newUpstreamModelSyncConfigError("No Gemini API key is available", nil)
  1029. }
  1030. req.Header.Set("x-goog-api-key", apiKey)
  1031. case AccountTypeOAuth:
  1032. if strings.TrimSpace(account.GetCredential("project_id")) != "" {
  1033. return nil, newUpstreamModelSyncUnsupportedError("Gemini Code Assist model listing is not supported by this sync button", nil)
  1034. }
  1035. if s.geminiTokenProvider == nil {
  1036. return nil, newUpstreamModelSyncConfigError("Gemini token provider is not configured", nil)
  1037. }
  1038. accessToken, tokenErr := s.geminiTokenProvider.GetAccessToken(ctx, account)
  1039. if tokenErr != nil {
  1040. return nil, newUpstreamModelSyncUpstreamError("Failed to get Gemini access token", tokenErr)
  1041. }
  1042. accessToken = strings.TrimSpace(accessToken)
  1043. if accessToken == "" {
  1044. return nil, newUpstreamModelSyncConfigError("No Gemini access token is available", nil)
  1045. }
  1046. req.Header.Set("Authorization", "Bearer "+accessToken)
  1047. default:
  1048. return nil, newUpstreamModelSyncUnsupportedError(
  1049. fmt.Sprintf("Unsupported Gemini account type for upstream model sync: %s", account.Type), nil,
  1050. )
  1051. }
  1052. return req, nil
  1053. }
  1054. func (s *AccountTestService) fetchAntigravityOAuthUpstreamModels(ctx context.Context, account *Account) ([]string, error) {
  1055. if s.antigravityGatewayService == nil || s.antigravityGatewayService.GetTokenProvider() == nil {
  1056. return nil, newUpstreamModelSyncConfigError("Antigravity token provider is not configured", nil)
  1057. }
  1058. accessToken, err := s.antigravityGatewayService.GetTokenProvider().GetAccessToken(ctx, account)
  1059. if err != nil {
  1060. return nil, newUpstreamModelSyncUpstreamError("Failed to get Antigravity access token", err)
  1061. }
  1062. accessToken = strings.TrimSpace(accessToken)
  1063. if accessToken == "" {
  1064. return nil, newUpstreamModelSyncConfigError("No Antigravity access token is available", nil)
  1065. }
  1066. client, err := antigravity.NewClient(upstreamModelsProxyURL(account))
  1067. if err != nil {
  1068. return nil, newUpstreamModelSyncConfigError("Failed to configure Antigravity client", err)
  1069. }
  1070. modelsResp, _, err := client.FetchAvailableModels(
  1071. ctx,
  1072. accessToken,
  1073. strings.TrimSpace(account.GetCredential("project_id")),
  1074. resolveModelsListReadLimit(s.cfg),
  1075. )
  1076. if err != nil {
  1077. return nil, newUpstreamModelSyncUpstreamError("Failed to fetch Antigravity available models", err)
  1078. }
  1079. if modelsResp == nil || len(modelsResp.Models) == 0 {
  1080. return nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil)
  1081. }
  1082. models := make([]string, 0, len(modelsResp.Models))
  1083. for modelID := range modelsResp.Models {
  1084. models = append(models, strings.TrimSpace(modelID))
  1085. }
  1086. return dedupeAndSortModelIDs(models), nil
  1087. }
  1088. func (s *AccountTestService) doUpstreamModelsRequest(req *http.Request, proxyURL string, account *Account) (*http.Response, error) {
  1089. if s.tlsFPProfileService == nil {
  1090. return s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, nil)
  1091. }
  1092. return s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
  1093. }
  1094. func upstreamModelsProxyURL(account *Account) string {
  1095. if account != nil && account.ProxyID != nil && account.Proxy != nil {
  1096. return account.Proxy.URL()
  1097. }
  1098. return ""
  1099. }
  1100. func buildV1ModelsURL(base string) string {
  1101. normalized := strings.TrimRight(strings.TrimSpace(base), "/")
  1102. if strings.HasSuffix(normalized, "/v1/models") {
  1103. return normalized
  1104. }
  1105. if strings.HasSuffix(normalized, "/v1") {
  1106. return normalized + "/models"
  1107. }
  1108. return normalized + "/v1/models"
  1109. }
  1110. func buildOpenAIModelsURL(base string) string {
  1111. return buildOpenAIEndpointURL(base, "/v1/models")
  1112. }
  1113. func buildGeminiModelsURL(base string) string {
  1114. normalized := strings.TrimRight(strings.TrimSpace(base), "/")
  1115. if strings.HasSuffix(normalized, "/v1beta/models") {
  1116. return normalized
  1117. }
  1118. if strings.HasSuffix(normalized, "/v1beta") {
  1119. return normalized + "/models"
  1120. }
  1121. return normalized + "/v1beta/models"
  1122. }
  1123. type upstreamModelEntry struct {
  1124. ID string `json:"id"`
  1125. Slug string `json:"slug"`
  1126. Model string `json:"model"`
  1127. ModelID string `json:"modelId"`
  1128. ModelIDSnake string `json:"model_id"`
  1129. Name string `json:"name"`
  1130. Meta json.RawMessage `json:"_meta"`
  1131. }
  1132. type upstreamModelEntryMetadata struct {
  1133. ID string `json:"id"`
  1134. Slug string `json:"slug"`
  1135. Model string `json:"model"`
  1136. ModelID string `json:"modelId"`
  1137. ModelIDSnake string `json:"model_id"`
  1138. Name string `json:"name"`
  1139. }
  1140. type upstreamModelCapabilityEntry struct {
  1141. upstreamModelEntry
  1142. DisplayName string `json:"display_name"`
  1143. Description string `json:"description"`
  1144. Reasoning *bool `json:"reasoning"`
  1145. DefaultReasoningLevel string `json:"default_reasoning_level"`
  1146. SupportedReasoningLevels []json.RawMessage `json:"supported_reasoning_levels"`
  1147. ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"`
  1148. InputModalities []string `json:"input_modalities"`
  1149. Modalities modelsDevModalities `json:"modalities"`
  1150. ContextWindow int64 `json:"context_window"`
  1151. MaxContextWindow int64 `json:"max_context_window"`
  1152. MaxOutputTokens int64 `json:"max_output_tokens"`
  1153. Limit modelsDevLimit `json:"limit"`
  1154. }
  1155. func extractUpstreamModelIDs(body []byte) ([]string, error) {
  1156. return extractUpstreamModelIDsWithSelector(body, upstreamModelEntryID)
  1157. }
  1158. func extractGrokUpstreamModelIDs(body []byte) ([]string, error) {
  1159. return extractUpstreamModelIDsWithSelector(body, grokUpstreamModelEntryID)
  1160. }
  1161. func extractUpstreamModelCatalog(body []byte, grok bool) ([]string, map[string]UpstreamModelMetadata, error) {
  1162. entries, err := extractUpstreamModelRawEntries(body)
  1163. if err != nil {
  1164. return nil, nil, err
  1165. }
  1166. selectID := upstreamModelEntryID
  1167. if grok {
  1168. selectID = grokUpstreamModelEntryID
  1169. }
  1170. models := make([]string, 0, len(entries))
  1171. metadata := make(map[string]UpstreamModelMetadata)
  1172. for _, raw := range entries {
  1173. var capability upstreamModelCapabilityEntry
  1174. if err := json.Unmarshal(raw, &capability); err != nil {
  1175. continue
  1176. }
  1177. modelID := strings.TrimSpace(selectID(capability.upstreamModelEntry))
  1178. if modelID == "" {
  1179. continue
  1180. }
  1181. models = append(models, modelID)
  1182. entry := upstreamMetadataFromCapabilityEntry(modelID, capability)
  1183. var fields map[string]json.RawMessage
  1184. if err := json.Unmarshal(raw, &fields); err == nil {
  1185. entry.CodexToolCapabilities = make(map[string]json.RawMessage)
  1186. applyCodexToolCapabilities(entry.CodexToolCapabilities, fields, true)
  1187. }
  1188. if upstreamModelMetadataIsUseful(entry) {
  1189. metadata[modelID] = entry
  1190. }
  1191. }
  1192. return dedupeAndSortModelIDs(models), metadata, nil
  1193. }
  1194. func extractUpstreamModelRawEntries(body []byte) ([]json.RawMessage, error) {
  1195. var response struct {
  1196. Data []json.RawMessage `json:"data"`
  1197. Models []json.RawMessage `json:"models"`
  1198. }
  1199. if err := json.Unmarshal(body, &response); err == nil && (response.Data != nil || response.Models != nil) {
  1200. entries := make([]json.RawMessage, 0, len(response.Data)+len(response.Models))
  1201. entries = append(entries, response.Data...)
  1202. entries = append(entries, response.Models...)
  1203. return entries, nil
  1204. }
  1205. var entries []json.RawMessage
  1206. if err := json.Unmarshal(body, &entries); err != nil {
  1207. return nil, fmt.Errorf("parse upstream model catalog: %w", err)
  1208. }
  1209. return entries, nil
  1210. }
  1211. func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapabilityEntry) UpstreamModelMetadata {
  1212. levels := reasoningLevelsFromRawEntries(entry.SupportedReasoningLevels)
  1213. if len(levels) == 0 {
  1214. levels = reasoningLevelsFromModelsDevOptions(entry.ReasoningOptions)
  1215. }
  1216. reasoning := entry.Reasoning
  1217. if reasoning == nil && len(levels) > 0 {
  1218. inferred := len(levels) != 1 || levels[0] != "none"
  1219. reasoning = &inferred
  1220. }
  1221. modalities := entry.InputModalities
  1222. if len(modalities) == 0 {
  1223. modalities = entry.Modalities.Input
  1224. }
  1225. contextWindow := entry.ContextWindow
  1226. if contextWindow <= 0 {
  1227. contextWindow = entry.MaxContextWindow
  1228. }
  1229. if contextWindow <= 0 {
  1230. contextWindow = entry.Limit.Context
  1231. }
  1232. maxOutputTokens := entry.MaxOutputTokens
  1233. if maxOutputTokens <= 0 {
  1234. maxOutputTokens = entry.Limit.Output
  1235. }
  1236. defaultReasoningLevel := normalizeReasoningLevel(entry.DefaultReasoningLevel)
  1237. if defaultReasoningLevel == "" && len(levels) > 0 {
  1238. defaultReasoningLevel = levels[0]
  1239. }
  1240. displayName := strings.TrimSpace(entry.DisplayName)
  1241. if displayName == "" && strings.TrimSpace(entry.Name) != "" && strings.TrimSpace(entry.Name) != modelID {
  1242. displayName = strings.TrimSpace(entry.Name)
  1243. }
  1244. return UpstreamModelMetadata{
  1245. ID: modelID,
  1246. DisplayName: displayName,
  1247. Description: strings.TrimSpace(entry.Description),
  1248. Reasoning: reasoning,
  1249. DefaultReasoningLevel: defaultReasoningLevel,
  1250. SupportedReasoningLevels: levels,
  1251. InputModalities: normalizeCodexInputModalities(modalities),
  1252. ContextWindow: contextWindow,
  1253. MaxOutputTokens: maxOutputTokens,
  1254. }
  1255. }
  1256. func reasoningLevelsFromRawEntries(entries []json.RawMessage) []string {
  1257. levels := make([]string, 0, len(entries))
  1258. for _, raw := range entries {
  1259. var effort string
  1260. if err := json.Unmarshal(raw, &effort); err == nil {
  1261. levels = append(levels, effort)
  1262. continue
  1263. }
  1264. var level struct {
  1265. Effort string `json:"effort"`
  1266. }
  1267. if err := json.Unmarshal(raw, &level); err == nil {
  1268. levels = append(levels, level.Effort)
  1269. }
  1270. }
  1271. return normalizeReasoningLevels(levels)
  1272. }
  1273. func normalizeReasoningLevels(levels []string) []string {
  1274. seen := make(map[string]struct{}, len(levels))
  1275. normalized := make([]string, 0, len(levels))
  1276. for _, level := range levels {
  1277. level = normalizeReasoningLevel(level)
  1278. if level == "" {
  1279. continue
  1280. }
  1281. if _, exists := seen[level]; exists {
  1282. continue
  1283. }
  1284. seen[level] = struct{}{}
  1285. normalized = append(normalized, level)
  1286. }
  1287. return normalized
  1288. }
  1289. func normalizeReasoningLevel(level string) string {
  1290. level = strings.ToLower(strings.TrimSpace(level))
  1291. switch level {
  1292. case "off", "disabled":
  1293. return "none"
  1294. case "extra-high", "extra_high":
  1295. return "xhigh"
  1296. case "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra":
  1297. return level
  1298. default:
  1299. return ""
  1300. }
  1301. }
  1302. func normalizeCodexInputModalities(modalities []string) []string {
  1303. seen := make(map[string]struct{}, len(modalities))
  1304. normalized := make([]string, 0, len(modalities))
  1305. for _, modality := range modalities {
  1306. modality = strings.ToLower(strings.TrimSpace(modality))
  1307. if modality != "text" && modality != "image" {
  1308. continue
  1309. }
  1310. if _, exists := seen[modality]; exists {
  1311. continue
  1312. }
  1313. seen[modality] = struct{}{}
  1314. normalized = append(normalized, modality)
  1315. }
  1316. return normalized
  1317. }
  1318. func extractUpstreamModelIDsWithSelector(body []byte, selectID func(upstreamModelEntry) string) ([]string, error) {
  1319. var response struct {
  1320. Data []upstreamModelEntry `json:"data"`
  1321. Models []upstreamModelEntry `json:"models"`
  1322. }
  1323. if err := json.Unmarshal(body, &response); err != nil {
  1324. var arrayResponse []upstreamModelEntry
  1325. if arrayErr := json.Unmarshal(body, &arrayResponse); arrayErr != nil {
  1326. return nil, fmt.Errorf("parse upstream model list: %w", err)
  1327. }
  1328. models := make([]string, 0, len(arrayResponse))
  1329. for _, entry := range arrayResponse {
  1330. models = append(models, selectID(entry))
  1331. }
  1332. return dedupeAndSortModelIDs(models), nil
  1333. }
  1334. models := make([]string, 0, len(response.Data)+len(response.Models))
  1335. for _, entry := range response.Data {
  1336. models = append(models, selectID(entry))
  1337. }
  1338. for _, entry := range response.Models {
  1339. models = append(models, selectID(entry))
  1340. }
  1341. if len(models) == 0 {
  1342. var arrayResponse []upstreamModelEntry
  1343. if err := json.Unmarshal(body, &arrayResponse); err == nil {
  1344. for _, entry := range arrayResponse {
  1345. models = append(models, selectID(entry))
  1346. }
  1347. }
  1348. }
  1349. return dedupeAndSortModelIDs(models), nil
  1350. }
  1351. func upstreamModelEntryID(entry upstreamModelEntry) string {
  1352. modelID := strings.TrimSpace(entry.ID)
  1353. if modelID == "" {
  1354. modelID = strings.TrimSpace(entry.Slug)
  1355. }
  1356. if modelID == "" {
  1357. modelID = strings.TrimSpace(entry.Name)
  1358. }
  1359. return strings.TrimPrefix(modelID, "models/")
  1360. }
  1361. func grokUpstreamModelEntryID(entry upstreamModelEntry) string {
  1362. candidates := []string{
  1363. entry.Model,
  1364. entry.ModelID,
  1365. entry.ModelIDSnake,
  1366. entry.ID,
  1367. entry.Slug,
  1368. }
  1369. if len(entry.Meta) > 0 {
  1370. var meta upstreamModelEntryMetadata
  1371. if err := json.Unmarshal(entry.Meta, &meta); err == nil {
  1372. candidates = append(candidates,
  1373. meta.Model,
  1374. meta.ModelID,
  1375. meta.ModelIDSnake,
  1376. meta.ID,
  1377. meta.Slug,
  1378. meta.Name,
  1379. )
  1380. }
  1381. }
  1382. // `name` is a display label in the Grok catalog, so keep it as the final
  1383. // compatibility fallback rather than preferring it over protocol model IDs.
  1384. candidates = append(candidates, entry.Name)
  1385. for _, candidate := range candidates {
  1386. modelID := strings.TrimSpace(candidate)
  1387. if modelID != "" {
  1388. return strings.TrimPrefix(modelID, "models/")
  1389. }
  1390. }
  1391. return ""
  1392. }
  1393. func dedupeAndSortModelIDs(models []string) []string {
  1394. seen := make(map[string]struct{}, len(models))
  1395. result := make([]string, 0, len(models))
  1396. for _, model := range models {
  1397. model = strings.TrimSpace(model)
  1398. if model == "" {
  1399. continue
  1400. }
  1401. if _, exists := seen[model]; exists {
  1402. continue
  1403. }
  1404. seen[model] = struct{}{}
  1405. result = append(result, model)
  1406. }
  1407. sort.Strings(result)
  1408. return result
  1409. }