openai_model_mapping_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. package service
  2. import "testing"
  3. func TestResolveOpenAIForwardModel(t *testing.T) {
  4. tests := []struct {
  5. name string
  6. account *Account
  7. requestedModel string
  8. messagesDispatchMappedModel string
  9. expectedModel string
  10. }{
  11. {
  12. name: "uses messages dispatch model for known claude family",
  13. account: &Account{
  14. Credentials: map[string]any{},
  15. },
  16. requestedModel: "claude-opus-4-6",
  17. messagesDispatchMappedModel: "gpt-4o-mini",
  18. expectedModel: "gpt-4o-mini",
  19. },
  20. {
  21. name: "uses exact messages dispatch model for unknown claude family",
  22. account: &Account{
  23. Credentials: map[string]any{},
  24. },
  25. requestedModel: "claude-fable-5",
  26. messagesDispatchMappedModel: " gpt-5.6-sol ",
  27. expectedModel: "gpt-5.6-sol",
  28. },
  29. {
  30. name: "nil account uses messages dispatch model",
  31. requestedModel: "claude-fable-5",
  32. messagesDispatchMappedModel: "gpt-5.6-sol",
  33. expectedModel: "gpt-5.6-sol",
  34. },
  35. {
  36. name: "nil account without messages dispatch keeps requested model",
  37. requestedModel: "claude-fable-5",
  38. expectedModel: "claude-fable-5",
  39. },
  40. {
  41. name: "ordinary unknown gpt model has no messages dispatch fallback",
  42. account: &Account{
  43. Credentials: map[string]any{},
  44. },
  45. requestedModel: "gpt6",
  46. expectedModel: "gpt6",
  47. },
  48. {
  49. name: "account exact mapping overrides messages dispatch model",
  50. account: &Account{
  51. Credentials: map[string]any{
  52. "model_mapping": map[string]any{
  53. "claude-fable-5": "gpt-5.5",
  54. },
  55. },
  56. },
  57. requestedModel: "claude-fable-5",
  58. messagesDispatchMappedModel: "gpt-5.6-sol",
  59. expectedModel: "gpt-5.5",
  60. },
  61. {
  62. name: "account wildcard mapping overrides messages dispatch model",
  63. account: &Account{
  64. Credentials: map[string]any{
  65. "model_mapping": map[string]any{
  66. "claude-*": "gpt-5.4",
  67. },
  68. },
  69. },
  70. requestedModel: "claude-fable-5",
  71. messagesDispatchMappedModel: "gpt-5.6-sol",
  72. expectedModel: "gpt-5.4",
  73. },
  74. {
  75. name: "account passthrough mapping overrides messages dispatch model",
  76. account: &Account{
  77. Credentials: map[string]any{
  78. "model_mapping": map[string]any{
  79. "claude-fable-5": "claude-fable-5",
  80. },
  81. },
  82. },
  83. requestedModel: "claude-fable-5",
  84. messagesDispatchMappedModel: "gpt-5.6-sol",
  85. expectedModel: "claude-fable-5",
  86. },
  87. {
  88. name: "ordinary codex spark request keeps requested model",
  89. account: &Account{
  90. Credentials: map[string]any{},
  91. },
  92. requestedModel: "gpt-5.3-codex-spark",
  93. expectedModel: "gpt-5.3-codex-spark",
  94. },
  95. {
  96. name: "ordinary gpt-5.5 request keeps requested model",
  97. account: &Account{
  98. Credentials: map[string]any{},
  99. },
  100. requestedModel: "gpt-5.5",
  101. expectedModel: "gpt-5.5",
  102. },
  103. {
  104. name: "ordinary gpt-5.5-pro request keeps requested model",
  105. account: &Account{
  106. Credentials: map[string]any{},
  107. },
  108. requestedModel: "gpt-5.5-pro",
  109. expectedModel: "gpt-5.5-pro",
  110. },
  111. {
  112. name: "ordinary compact-spelled gpt5.5 request keeps requested model",
  113. account: &Account{
  114. Credentials: map[string]any{},
  115. },
  116. requestedModel: "gpt5.5",
  117. expectedModel: "gpt5.5",
  118. },
  119. {
  120. name: "ordinary namespaced gpt-5.5 request keeps requested model",
  121. account: &Account{
  122. Credentials: map[string]any{},
  123. },
  124. requestedModel: "openai/gpt-5.5",
  125. expectedModel: "openai/gpt-5.5",
  126. },
  127. {
  128. name: "ordinary compact gpt-5.5 request keeps requested model",
  129. account: &Account{
  130. Credentials: map[string]any{},
  131. },
  132. requestedModel: "gpt-5.5-openai-compact",
  133. expectedModel: "gpt-5.5-openai-compact",
  134. },
  135. {
  136. name: "whitespace-only messages dispatch model is ignored",
  137. account: &Account{
  138. Credentials: map[string]any{},
  139. },
  140. requestedModel: "gpt-5.5",
  141. messagesDispatchMappedModel: " ",
  142. expectedModel: "gpt-5.5",
  143. },
  144. }
  145. for _, tt := range tests {
  146. t.Run(tt.name, func(t *testing.T) {
  147. if got := resolveOpenAIForwardModel(tt.account, tt.requestedModel, tt.messagesDispatchMappedModel); got != tt.expectedModel {
  148. t.Fatalf("resolveOpenAIForwardModel(...) = %q, want %q", got, tt.expectedModel)
  149. }
  150. })
  151. }
  152. }
  153. func TestResolveOpenAICompactForwardModel(t *testing.T) {
  154. tests := []struct {
  155. name string
  156. account *Account
  157. model string
  158. expectedModel string
  159. }{
  160. {
  161. name: "nil account keeps original model",
  162. account: nil,
  163. model: "gpt-5.4",
  164. expectedModel: "gpt-5.4",
  165. },
  166. {
  167. name: "missing compact mapping keeps original model",
  168. account: &Account{
  169. Credentials: map[string]any{},
  170. },
  171. model: "gpt-5.4",
  172. expectedModel: "gpt-5.4",
  173. },
  174. {
  175. name: "exact compact mapping overrides model",
  176. account: &Account{
  177. Credentials: map[string]any{
  178. "compact_model_mapping": map[string]any{
  179. "gpt-5.4": "gpt-5.4-openai-compact",
  180. },
  181. },
  182. },
  183. model: "gpt-5.4",
  184. expectedModel: "gpt-5.4-openai-compact",
  185. },
  186. {
  187. name: "wildcard compact mapping overrides model",
  188. account: &Account{
  189. Credentials: map[string]any{
  190. "compact_model_mapping": map[string]any{
  191. "gpt-5.*": "gpt-5-openai-compact",
  192. },
  193. },
  194. },
  195. model: "gpt-5.4",
  196. expectedModel: "gpt-5-openai-compact",
  197. },
  198. {
  199. name: "passthrough compact mapping remains unchanged",
  200. account: &Account{
  201. Credentials: map[string]any{
  202. "compact_model_mapping": map[string]any{
  203. "gpt-5.4": "gpt-5.4",
  204. },
  205. },
  206. },
  207. model: "gpt-5.4",
  208. expectedModel: "gpt-5.4",
  209. },
  210. }
  211. for _, tt := range tests {
  212. t.Run(tt.name, func(t *testing.T) {
  213. if got := resolveOpenAICompactForwardModel(tt.account, tt.model); got != tt.expectedModel {
  214. t.Fatalf("resolveOpenAICompactForwardModel(...) = %q, want %q", got, tt.expectedModel)
  215. }
  216. })
  217. }
  218. }
  219. func TestResolveOpenAIForwardMappedModels_CompactMappingPrecedence(t *testing.T) {
  220. conflictingMappings := map[string]any{
  221. "model_mapping": map[string]any{"gpt-5.5": "gpt-5.4"},
  222. "compact_model_mapping": map[string]any{"gpt-5.5": "gpt-5.5-openai-compact"},
  223. }
  224. mappedOnlyCompact := map[string]any{
  225. "model_mapping": map[string]any{"gpt-5.5": "gpt-5.4"},
  226. "compact_model_mapping": map[string]any{"gpt-5.4": "gpt-5.4-openai-compact"},
  227. }
  228. tests := []struct {
  229. name string
  230. account *Account
  231. requireCompact bool
  232. wantBilling string
  233. wantUpstream string
  234. }{
  235. {
  236. name: "compact uses client-visible model before ordinary mapping",
  237. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  238. Credentials: conflictingMappings},
  239. requireCompact: true,
  240. wantBilling: "gpt-5.4",
  241. wantUpstream: "gpt-5.5-openai-compact",
  242. },
  243. {
  244. name: "non-compact uses ordinary mapping",
  245. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  246. Credentials: conflictingMappings},
  247. wantBilling: "gpt-5.4",
  248. wantUpstream: "gpt-5.4",
  249. },
  250. {
  251. name: "compact falls back to ordinary mapped model",
  252. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  253. Credentials: mappedOnlyCompact},
  254. requireCompact: true,
  255. wantBilling: "gpt-5.4",
  256. wantUpstream: "gpt-5.4-openai-compact",
  257. },
  258. {
  259. name: "passthrough ignores ordinary mapping",
  260. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  261. Credentials: conflictingMappings, Extra: map[string]any{"openai_passthrough": true}},
  262. requireCompact: true,
  263. wantBilling: "gpt-5.5",
  264. wantUpstream: "gpt-5.5-openai-compact",
  265. },
  266. {
  267. name: "raw chat fallback never applies compact mapping",
  268. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  269. Credentials: conflictingMappings, Extra: map[string]any{"openai_responses_supported": false}},
  270. requireCompact: true,
  271. wantBilling: "gpt-5.4",
  272. wantUpstream: "gpt-5.4",
  273. },
  274. }
  275. for _, tt := range tests {
  276. t.Run(tt.name, func(t *testing.T) {
  277. billing, upstream := resolveOpenAIForwardMappedModels(tt.account, "gpt-5.5", tt.requireCompact)
  278. if billing != tt.wantBilling {
  279. t.Fatalf("billing model = %q, want %q", billing, tt.wantBilling)
  280. }
  281. if upstream != tt.wantUpstream {
  282. t.Fatalf("upstream model = %q, want %q", upstream, tt.wantUpstream)
  283. }
  284. if scheduler := resolveOpenAIAccountUpstreamModelForRequest(tt.account, "gpt-5.5", tt.requireCompact); scheduler != upstream {
  285. t.Fatalf("scheduler model %q disagrees with Forward model %q", scheduler, upstream)
  286. }
  287. })
  288. }
  289. }
  290. func TestCanonicalOpenAIAccountSchedulingModelMatchesForwardSemantics(t *testing.T) {
  291. tests := []struct {
  292. name string
  293. account *Account
  294. model string
  295. want string
  296. }{
  297. {
  298. name: "OpenAI OAuth applies Codex alias normalization",
  299. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
  300. model: "gpt-5.6",
  301. want: "gpt-5.6-sol",
  302. },
  303. {
  304. name: "OpenAI passthrough ignores ordinary account mapping",
  305. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  306. Credentials: map[string]any{"model_mapping": map[string]any{"public": "private"}},
  307. Extra: map[string]any{"openai_passthrough": true}},
  308. model: "public",
  309. want: "public",
  310. },
  311. {
  312. name: "Grok OAuth does not inherit OpenAI Codex aliases",
  313. account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth},
  314. model: "gpt-5.6",
  315. want: "gpt-5.6",
  316. },
  317. }
  318. for _, tt := range tests {
  319. t.Run(tt.name, func(t *testing.T) {
  320. if got := canonicalOpenAIAccountSchedulingModel(tt.account, tt.model); got != tt.want {
  321. t.Fatalf("canonical scheduling model = %q, want %q", got, tt.want)
  322. }
  323. })
  324. }
  325. }
  326. func TestResolveOpenAIErrorSchedulingModelPrefersActualUpstreamModel(t *testing.T) {
  327. if got := resolveOpenAIErrorSchedulingModel("gpt-5.4", "gpt-5.5-openai-compact"); got != "gpt-5.5-openai-compact" {
  328. t.Fatalf("error scheduling model = %q, want compact upstream model", got)
  329. }
  330. if got := resolveOpenAIErrorSchedulingModel("gpt-5.4", ""); got != "gpt-5.4" {
  331. t.Fatalf("empty upstream fallback = %q, want billing model", got)
  332. }
  333. }
  334. func TestNormalizeCodexModel(t *testing.T) {
  335. cases := map[string]string{
  336. "gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
  337. "gpt-5.3-codex-spark-high": "gpt-5.3-codex-spark",
  338. "gpt-5.3-codex-spark-xhigh": "gpt-5.3-codex-spark",
  339. "gpt-5.3": "gpt-5.3-codex",
  340. "gpt-image-2": "gpt-image-2",
  341. "gpt-5.4-nano": "gpt-5.4-nano",
  342. "gpt-5.4-nano-high": "gpt-5.4-nano",
  343. "gpt6": "gpt6",
  344. "claude-opus-4-6": "claude-opus-4-6",
  345. }
  346. for input, expected := range cases {
  347. if got := normalizeCodexModel(input); got != expected {
  348. t.Fatalf("normalizeCodexModel(%q) = %q, want %q", input, got, expected)
  349. }
  350. }
  351. }
  352. func TestNormalizeOpenAIModelForUpstream(t *testing.T) {
  353. tests := []struct {
  354. name string
  355. account *Account
  356. model string
  357. want string
  358. }{
  359. {
  360. name: "oauth routes bare GPT-5.6 alias to Sol",
  361. account: &Account{Type: AccountTypeOAuth},
  362. model: "gpt-5.6",
  363. want: "gpt-5.6-sol",
  364. },
  365. {
  366. name: "oauth routes provider-prefixed GPT-5.6 alias to Sol",
  367. account: &Account{Type: AccountTypeOAuth},
  368. model: "openai/gpt-5.6",
  369. want: "gpt-5.6-sol",
  370. },
  371. {
  372. name: "oauth preserves unknown non codex model",
  373. account: &Account{Type: AccountTypeOAuth},
  374. model: "gemini-3-flash-preview",
  375. want: "gemini-3-flash-preview",
  376. },
  377. {
  378. name: "oauth preserves invalid gpt model",
  379. account: &Account{Type: AccountTypeOAuth},
  380. model: "gpt6",
  381. want: "gpt6",
  382. },
  383. {
  384. name: "oauth normalizes known codex alias",
  385. account: &Account{Type: AccountTypeOAuth},
  386. model: "gpt-5.4-high",
  387. want: "gpt-5.4",
  388. },
  389. {
  390. name: "oauth preserves GPT-5.5 Pro model",
  391. account: &Account{Type: AccountTypeOAuth},
  392. model: "openai/gpt-5.5-pro",
  393. want: "gpt-5.5-pro",
  394. },
  395. {
  396. name: "oauth preserves codex auto review model",
  397. account: &Account{Type: AccountTypeOAuth},
  398. model: "codex-auto-review",
  399. want: "codex-auto-review",
  400. },
  401. {
  402. name: "apikey preserves official bare GPT-5.6 alias",
  403. account: &Account{Type: AccountTypeAPIKey},
  404. model: "gpt-5.6",
  405. want: "gpt-5.6",
  406. },
  407. {
  408. name: "apikey preserves custom compatible model",
  409. account: &Account{Type: AccountTypeAPIKey},
  410. model: "gemini-3-flash-preview",
  411. want: "gemini-3-flash-preview",
  412. },
  413. {
  414. name: "apikey preserves official non codex model",
  415. account: &Account{Type: AccountTypeAPIKey},
  416. model: "gpt-4.1",
  417. want: "gpt-4.1",
  418. },
  419. }
  420. for _, tt := range tests {
  421. t.Run(tt.name, func(t *testing.T) {
  422. if got := normalizeOpenAIModelForUpstream(tt.account, tt.model); got != tt.want {
  423. t.Fatalf("normalizeOpenAIModelForUpstream(...) = %q, want %q", got, tt.want)
  424. }
  425. })
  426. }
  427. }
  428. func TestUsageBillingModelCandidatesPreserveCodexAutoReviewModel(t *testing.T) {
  429. candidates := usageBillingModelCandidates("codex-auto-review")
  430. expected := []string{"codex-auto-review"}
  431. if len(candidates) != len(expected) {
  432. t.Fatalf("usageBillingModelCandidates(codex-auto-review) = %#v, want %#v", candidates, expected)
  433. }
  434. for i := range expected {
  435. if candidates[i] != expected[i] {
  436. t.Fatalf("usageBillingModelCandidates(codex-auto-review) = %#v, want %#v", candidates, expected)
  437. }
  438. }
  439. }
  440. func TestUsageBillingModelCandidatesPreserveGPT55ProModel(t *testing.T) {
  441. candidates := usageBillingModelCandidates("openai/gpt-5.5-pro")
  442. expected := []string{"openai/gpt-5.5-pro", "gpt-5.5-pro"}
  443. if len(candidates) != len(expected) {
  444. t.Fatalf("usageBillingModelCandidates(openai/gpt-5.5-pro) = %#v, want %#v", candidates, expected)
  445. }
  446. for i := range expected {
  447. if candidates[i] != expected[i] {
  448. t.Fatalf("usageBillingModelCandidates(openai/gpt-5.5-pro) = %#v, want %#v", candidates, expected)
  449. }
  450. }
  451. }