openai_codex_model_metadata.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. package service
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "net/url"
  6. "strings"
  7. )
  8. var codexToolCapabilityFields = []string{
  9. "supports_search_tool", "apply_patch_tool_type", "comp_hash", "tool_mode", "use_responses_lite",
  10. "multi_agent_reasoning_effort", "multi_agent_version",
  11. }
  12. func applyCodexToolCapabilities(dst, src map[string]json.RawMessage, overwrite bool) bool {
  13. changed := false
  14. for _, field := range codexToolCapabilityFields {
  15. value := bytes.TrimSpace(src[field])
  16. if len(value) == 0 {
  17. continue
  18. }
  19. // These Codex fields are nullable booleans or strings, never arbitrary objects.
  20. if !bytes.Equal(value, []byte("null")) {
  21. if field == "supports_search_tool" || field == "use_responses_lite" {
  22. if !bytes.Equal(value, []byte("true")) && !bytes.Equal(value, []byte("false")) {
  23. continue
  24. }
  25. } else {
  26. var text string
  27. if json.Unmarshal(value, &text) != nil {
  28. continue
  29. }
  30. }
  31. }
  32. current, exists := dst[field]
  33. if (exists && !overwrite) || bytes.Equal(current, value) {
  34. continue
  35. }
  36. dst[field] = append(json.RawMessage(nil), value...)
  37. changed = true
  38. }
  39. return changed
  40. }
  41. func accountCodexToolCapabilities(account *Account, modelID string) map[string]json.RawMessage {
  42. capabilities := make(map[string]json.RawMessage)
  43. if account == nil {
  44. return capabilities
  45. }
  46. if metadata, ok := account.GetUpstreamModelMetadata(modelID); ok {
  47. applyCodexToolCapabilities(capabilities, metadata.CodexToolCapabilities, true)
  48. }
  49. if account.IsOpenAI() && shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
  50. // This bridge implements client-side tool discovery, even without a native manifest.
  51. applyCodexToolCapabilities(capabilities, map[string]json.RawMessage{"supports_search_tool": json.RawMessage("true")}, false)
  52. }
  53. // Codex 0.153's bundled Astra catalog verifies these values. API-key routes
  54. // use standard Responses, not the ChatGPT-only Responses Lite wire.
  55. baseURL := strings.TrimSpace(account.GetCredential("base_url"))
  56. if baseURL == "" {
  57. baseURL = account.GetOpenAIBaseURL()
  58. }
  59. parsed, err := url.Parse(baseURL)
  60. official := err == nil && (strings.EqualFold(parsed.Hostname(), "api.openai.com") ||
  61. (account.IsOpenAIOAuth() && strings.EqualFold(parsed.Hostname(), "chatgpt.com")))
  62. if account.IsOpenAI() && isOpenAIGPT6AstraModel(modelID) && official {
  63. defaults := map[string]json.RawMessage{
  64. "supports_search_tool": json.RawMessage("true"),
  65. "apply_patch_tool_type": json.RawMessage(`"freeform"`),
  66. "comp_hash": json.RawMessage(`"3000"`),
  67. "tool_mode": json.RawMessage("null"),
  68. "use_responses_lite": json.RawMessage("false"),
  69. }
  70. if account.IsOpenAIOAuth() {
  71. defaults["tool_mode"] = json.RawMessage(`"code_mode_only"`)
  72. defaults["use_responses_lite"] = json.RawMessage("true")
  73. }
  74. applyCodexToolCapabilities(capabilities, defaults, false)
  75. }
  76. if account.IsOpenAIApiKey() {
  77. target := modelID
  78. if isOpenAIGPT6AstraModel(target) {
  79. target = "gpt-6-astra"
  80. }
  81. _, disabled := apiKeyCodexModelsWithoutResponsesLite[target]
  82. if disabled && bytes.Equal(capabilities["use_responses_lite"], []byte("true")) {
  83. capabilities["use_responses_lite"] = json.RawMessage("false")
  84. }
  85. }
  86. return capabilities
  87. }
  88. // codexModelRoutingAccountIDs 返回分组显式为该公开别名声明的账号集合。
  89. //
  90. // 返回非空表示运营者已经用 model_routing 指明“这个别名由这些账号服务”,此时别名的
  91. // 归属不再是需要推断的未知量:能力声明只看这些账号,且不再因为它们映射到不同上游而
  92. // 判定为冲突。返回空表示没有相关规则,保持原有的全量推断与失败即关闭行为。
  93. func codexModelRoutingAccountIDs(group *Group, modelID string) []int64 {
  94. if group == nil {
  95. return nil
  96. }
  97. return group.GetRoutingAccountIDs(strings.TrimSpace(modelID))
  98. }
  99. func groupCodexModelMetadata(
  100. platform string,
  101. modelID string,
  102. accounts []Account,
  103. group *Group,
  104. compositeRoutes []CompositeModelRoute,
  105. compositeRoutesAvailable bool,
  106. ) (codexModelMetadataOverride, bool) {
  107. modelID = strings.TrimSpace(modelID)
  108. if modelID == "" {
  109. return codexModelMetadataOverride{}, false
  110. }
  111. // 显式路由规则本身与平台无关,先取出来供后续两处判定共用。
  112. routedAccountIDs := codexModelRoutingAccountIDs(group, modelID)
  113. routed := len(routedAccountIDs) > 0
  114. upstreamModel := modelID
  115. if platform == PlatformComposite {
  116. var resolved bool
  117. platform, upstreamModel, resolved = resolveCodexCompositeModelTarget(
  118. modelID,
  119. accounts,
  120. compositeRoutes,
  121. compositeRoutesAvailable,
  122. )
  123. if !resolved {
  124. if !routed && codexExplicitModelTargetsConflict(accounts, modelID) {
  125. return codexModelMetadataOverride{
  126. reasoningConflict: true,
  127. inputModalitiesConflict: true,
  128. }, true
  129. }
  130. return codexModelMetadataOverride{}, false
  131. }
  132. }
  133. if !isConcreteRequestPlatform(platform) {
  134. return codexModelMetadataOverride{}, false
  135. }
  136. explicitClaims := false
  137. if upstreamModel == modelID {
  138. for i := range accounts {
  139. account := &accounts[i]
  140. if routed && !containsInt64(routedAccountIDs, account.ID) {
  141. continue
  142. }
  143. if account.Platform == platform && codexExplicitModelMappingClaims(*account, modelID) {
  144. explicitClaims = true
  145. break
  146. }
  147. }
  148. }
  149. // 别名已被显式路由声明时,"多个账号映射到不同上游"是故障转移的正常写法,不是歧义,
  150. // 因此不再失败即关闭;能力回落到与单账号无快照时相同的按名推导。
  151. explicitTargetsConflict := explicitClaims && !routed &&
  152. codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID)
  153. publicAlias := upstreamModel != modelID
  154. candidates := make([]UpstreamModelMetadata, 0)
  155. missingMetadata := false
  156. for i := range accounts {
  157. account := &accounts[i]
  158. if account.Platform != platform {
  159. continue
  160. }
  161. if routed && !containsInt64(routedAccountIDs, account.ID) {
  162. continue
  163. }
  164. var lookupModel string
  165. if explicitClaims {
  166. if !codexExplicitModelMappingClaims(*account, modelID) {
  167. continue
  168. }
  169. lookupModel = account.GetMappedModel(modelID)
  170. } else {
  171. if !account.IsModelSupported(upstreamModel) {
  172. continue
  173. }
  174. lookupModel = account.GetMappedModel(upstreamModel)
  175. }
  176. if strings.TrimSpace(lookupModel) != modelID {
  177. publicAlias = true
  178. }
  179. metadata, ok := account.GetUpstreamModelMetadata(lookupModel)
  180. if !ok {
  181. if explicitTargetsConflict {
  182. return codexModelMetadataOverride{
  183. reasoningConflict: true,
  184. inputModalitiesConflict: true,
  185. }, true
  186. }
  187. missingMetadata = true
  188. }
  189. metadata.CodexToolCapabilities = accountCodexToolCapabilities(account, lookupModel)
  190. candidates = append(candidates, metadata)
  191. }
  192. if len(candidates) == 0 {
  193. return codexModelMetadataOverride{}, false
  194. }
  195. metadata := intersectUpstreamModelMetadata(modelID, candidates)
  196. if missingMetadata {
  197. metadata = codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{
  198. CodexToolCapabilities: metadata.CodexToolCapabilities,
  199. }}
  200. }
  201. if publicAlias {
  202. metadata.DisplayName = modelID
  203. metadata.Description = configuredCodexCustomDescription
  204. }
  205. return metadata, true
  206. }
  207. func codexExplicitModelTargetsConflict(accounts []Account, modelID string) bool {
  208. targets := make(map[string]struct{})
  209. for i := range accounts {
  210. account := &accounts[i]
  211. mappedModel, matched := account.ResolveMappedModel(modelID)
  212. mappedModel = strings.TrimSpace(mappedModel)
  213. if !matched || mappedModel == "" {
  214. continue
  215. }
  216. targets[strings.TrimSpace(account.Platform)+"\x00"+mappedModel] = struct{}{}
  217. }
  218. return len(targets) > 1
  219. }
  220. func codexExplicitModelTargetsConflictForPlatform(accounts []Account, platform, modelID string) bool {
  221. targets := make(map[string]struct{})
  222. for i := range accounts {
  223. account := &accounts[i]
  224. if account.Platform != platform {
  225. continue
  226. }
  227. mappedModel, matched := account.ResolveMappedModel(modelID)
  228. mappedModel = strings.TrimSpace(mappedModel)
  229. if !matched || mappedModel == "" {
  230. continue
  231. }
  232. targets[mappedModel] = struct{}{}
  233. }
  234. return len(targets) > 1
  235. }
  236. func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMetadata) codexModelMetadataOverride {
  237. result := codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ID: strings.TrimSpace(modelID)}}
  238. result.CodexToolCapabilities = make(map[string]json.RawMessage)
  239. for _, field := range codexToolCapabilityFields {
  240. value := bytes.TrimSpace(candidates[0].CodexToolCapabilities[field])
  241. shared := len(value) > 0
  242. declared := shared
  243. for _, candidate := range candidates[1:] {
  244. declared = declared || len(candidate.CodexToolCapabilities[field]) > 0
  245. if !bytes.Equal(value, bytes.TrimSpace(candidate.CodexToolCapabilities[field])) {
  246. shared = false
  247. }
  248. }
  249. if shared {
  250. result.CodexToolCapabilities[field] = value
  251. } else if declared {
  252. fallback := json.RawMessage("null")
  253. if field == "supports_search_tool" || field == "use_responses_lite" {
  254. fallback = json.RawMessage("false")
  255. }
  256. result.CodexToolCapabilities[field] = fallback
  257. }
  258. }
  259. for _, candidate := range candidates {
  260. if result.DisplayName == "" && strings.TrimSpace(candidate.DisplayName) != "" {
  261. result.DisplayName = strings.TrimSpace(candidate.DisplayName)
  262. }
  263. if result.Description == "" && strings.TrimSpace(candidate.Description) != "" {
  264. result.Description = strings.TrimSpace(candidate.Description)
  265. }
  266. }
  267. reasoningKnown := true
  268. reasoningValue := false
  269. for i, candidate := range candidates {
  270. if candidate.Reasoning == nil {
  271. reasoningKnown = false
  272. break
  273. }
  274. if i == 0 {
  275. reasoningValue = *candidate.Reasoning
  276. continue
  277. }
  278. if reasoningValue != *candidate.Reasoning {
  279. reasoningKnown = false
  280. result.reasoningConflict = true
  281. break
  282. }
  283. }
  284. if reasoningKnown {
  285. result.Reasoning = &reasoningValue
  286. if reasoningValue {
  287. levels := normalizeReasoningLevels(candidates[0].SupportedReasoningLevels)
  288. for _, candidate := range candidates[1:] {
  289. levels = intersectOrderedStrings(levels, normalizeReasoningLevels(candidate.SupportedReasoningLevels))
  290. }
  291. result.SupportedReasoningLevels = levels
  292. if len(levels) == 0 {
  293. result.reasoningConflict = true
  294. } else {
  295. sharedDefault := normalizeReasoningLevel(candidates[0].DefaultReasoningLevel)
  296. for _, candidate := range candidates[1:] {
  297. if normalizeReasoningLevel(candidate.DefaultReasoningLevel) != sharedDefault {
  298. sharedDefault = ""
  299. break
  300. }
  301. }
  302. if !stringSliceContains(levels, sharedDefault) {
  303. sharedDefault = levels[0]
  304. }
  305. result.DefaultReasoningLevel = sharedDefault
  306. }
  307. }
  308. }
  309. modalitiesKnown := true
  310. modalities := normalizeCodexInputModalities(candidates[0].InputModalities)
  311. if len(modalities) == 0 {
  312. modalitiesKnown = false
  313. }
  314. for _, candidate := range candidates[1:] {
  315. candidateModalities := normalizeCodexInputModalities(candidate.InputModalities)
  316. if len(candidateModalities) == 0 {
  317. modalitiesKnown = false
  318. break
  319. }
  320. modalities = intersectOrderedStrings(modalities, candidateModalities)
  321. }
  322. if modalitiesKnown && len(modalities) > 0 {
  323. result.InputModalities = modalities
  324. } else if modalitiesKnown {
  325. result.inputModalitiesConflict = true
  326. }
  327. contextKnown := true
  328. for i, candidate := range candidates {
  329. if candidate.ContextWindow <= 0 {
  330. contextKnown = false
  331. break
  332. }
  333. if i == 0 || candidate.ContextWindow < result.ContextWindow {
  334. result.ContextWindow = candidate.ContextWindow
  335. }
  336. }
  337. if !contextKnown {
  338. result.ContextWindow = 0
  339. }
  340. return result
  341. }
  342. func applyUpstreamModelMetadataToCodexDescriptor(
  343. descriptor *configuredCodexModelDescriptor,
  344. metadata codexModelMetadataOverride,
  345. ) {
  346. if descriptor == nil {
  347. return
  348. }
  349. if strings.TrimSpace(metadata.DisplayName) != "" {
  350. descriptor.DisplayName = strings.TrimSpace(metadata.DisplayName)
  351. }
  352. if strings.TrimSpace(metadata.Description) != "" {
  353. descriptor.Description = strings.TrimSpace(metadata.Description)
  354. }
  355. if metadata.reasoningConflict {
  356. descriptor.DefaultReasoningLevel = nil
  357. descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{}
  358. } else if metadata.Reasoning != nil && !*metadata.Reasoning {
  359. none := "none"
  360. descriptor.DefaultReasoningLevel = &none
  361. descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{{
  362. Effort: "none",
  363. Description: configuredCodexReasoningLevelDescription("none"),
  364. }}
  365. } else if metadata.Reasoning != nil && *metadata.Reasoning {
  366. levels := normalizeReasoningLevels(metadata.SupportedReasoningLevels)
  367. if len(levels) == 0 {
  368. descriptor.DefaultReasoningLevel = nil
  369. descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{}
  370. } else {
  371. defaultLevel := normalizeReasoningLevel(metadata.DefaultReasoningLevel)
  372. if !stringSliceContains(levels, defaultLevel) {
  373. defaultLevel = levels[0]
  374. }
  375. descriptor.DefaultReasoningLevel = &defaultLevel
  376. descriptor.SupportedReasoningLevels = make([]configuredCodexReasoningLevel, 0, len(levels))
  377. for _, level := range levels {
  378. descriptor.SupportedReasoningLevels = append(descriptor.SupportedReasoningLevels, configuredCodexReasoningLevel{
  379. Effort: level,
  380. Description: configuredCodexReasoningLevelDescription(level),
  381. })
  382. }
  383. }
  384. }
  385. if metadata.inputModalitiesConflict {
  386. descriptor.InputModalities = []string{"text"}
  387. } else if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 {
  388. descriptor.InputModalities = modalities
  389. }
  390. if metadata.ContextWindow > 0 {
  391. descriptor.ContextWindow = metadata.ContextWindow
  392. descriptor.MaxContextWindow = metadata.ContextWindow
  393. }
  394. }
  395. func configuredCodexReasoningLevelDescription(level string) string {
  396. switch level {
  397. case "none":
  398. return "Use the model's default behavior without configurable reasoning"
  399. case "minimal":
  400. return "Minimal reasoning for the fastest responses"
  401. case "low":
  402. return "Fast responses with lighter reasoning"
  403. case "medium":
  404. return "Balanced reasoning for most coding tasks"
  405. case "high":
  406. return "Greater reasoning depth for coding and agent tasks"
  407. case "xhigh":
  408. return "Extra-high reasoning depth for difficult tasks"
  409. case "max":
  410. return "Maximum reasoning depth for complex tasks"
  411. case "ultra":
  412. return "Maximum reasoning with automatic task delegation"
  413. default:
  414. return "Reasoning effort supported by the upstream model"
  415. }
  416. }
  417. func intersectOrderedStrings(left, right []string) []string {
  418. rightSet := make(map[string]struct{}, len(right))
  419. for _, value := range right {
  420. rightSet[value] = struct{}{}
  421. }
  422. intersection := make([]string, 0, len(left))
  423. for _, value := range left {
  424. if _, ok := rightSet[value]; ok {
  425. intersection = append(intersection, value)
  426. }
  427. }
  428. return intersection
  429. }
  430. func stringSliceContains(values []string, target string) bool {
  431. if target == "" {
  432. return false
  433. }
  434. for _, value := range values {
  435. if value == target {
  436. return true
  437. }
  438. }
  439. return false
  440. }