openai_gateway_count_tokens_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "net"
  10. "net/http"
  11. "net/http/httptest"
  12. "os"
  13. "strconv"
  14. "strings"
  15. "testing"
  16. "time"
  17. "github.com/Wei-Shaw/sub2api/internal/config"
  18. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  19. "github.com/gin-gonic/gin"
  20. "github.com/stretchr/testify/require"
  21. "github.com/tidwall/gjson"
  22. )
  23. type countTokensRuntimeStateRepo struct {
  24. AccountRepository
  25. tempUnschedCalls int
  26. setErrorCalls int
  27. }
  28. func (r *countTokensRuntimeStateRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error {
  29. r.tempUnschedCalls++
  30. return nil
  31. }
  32. func (r *countTokensRuntimeStateRepo) SetError(_ context.Context, _ int64, _ string) error {
  33. r.setErrorCalls++
  34. return nil
  35. }
  36. func TestOpenAIGatewayService_ForwardCountTokensAsAnthropic_APIKeyUsesResponsesInputTokens(t *testing.T) {
  37. gin.SetMode(gin.TestMode)
  38. rec := httptest.NewRecorder()
  39. c, _ := gin.CreateTestContext(rec)
  40. body := []byte(`{"model":"claude-sonnet-4-5","system":"You are helpful.","messages":[{"role":"user","content":"hello"}],"tools":[{"name":"lookup","input_schema":{"type":"object"}}]}`)
  41. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", bytes.NewReader(body))
  42. c.Request.Header.Set("Content-Type", "application/json")
  43. upstream := &httpUpstreamRecorder{resp: &http.Response{
  44. StatusCode: http.StatusOK,
  45. Header: http.Header{"Content-Type": []string{"application/json"}},
  46. Body: io.NopCloser(strings.NewReader(`{"object":"response.input_tokens","input_tokens":42}`)),
  47. }}
  48. svc := &OpenAIGatewayService{
  49. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{
  50. Enabled: false,
  51. AllowInsecureHTTP: true,
  52. }}},
  53. httpUpstream: upstream,
  54. }
  55. account := &Account{
  56. ID: 101,
  57. Name: "openai-apikey",
  58. Platform: PlatformOpenAI,
  59. Type: AccountTypeAPIKey,
  60. Concurrency: 1,
  61. Credentials: map[string]any{
  62. "api_key": "sk-test",
  63. "base_url": "http://upstream.example",
  64. },
  65. Status: StatusActive,
  66. Schedulable: true,
  67. }
  68. err := svc.ForwardCountTokensAsAnthropic(context.Background(), c, account, body, "gpt-5.3-codex")
  69. require.NoError(t, err)
  70. require.Equal(t, http.StatusOK, rec.Code)
  71. require.JSONEq(t, `{"input_tokens":42}`, rec.Body.String())
  72. require.NotNil(t, upstream.lastReq)
  73. require.Equal(t, "http://upstream.example/v1/responses/input_tokens", upstream.lastReq.URL.String())
  74. require.Equal(t, "Bearer sk-test", upstream.lastReq.Header.Get("authorization"))
  75. require.Equal(t, "gpt-5.3-codex", gjson.GetBytes(upstream.lastBody, "model").String())
  76. require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
  77. require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
  78. }
  79. func TestOpenAIGatewayService_ForwardCountTokensAsAnthropic_OAuthFallsBackWhenPlatformEndpointUnsupported(t *testing.T) {
  80. gin.SetMode(gin.TestMode)
  81. body := []byte(`{"model":"claude-opus-4-1","messages":[{"role":"user","content":"hello"}]}`)
  82. account := &Account{
  83. ID: 202,
  84. Name: "openai-oauth",
  85. Platform: PlatformOpenAI,
  86. Type: AccountTypeOAuth,
  87. Concurrency: 1,
  88. Credentials: map[string]any{
  89. "access_token": "oauth-token",
  90. "refresh_token": "oauth-refresh-token",
  91. },
  92. Status: StatusActive,
  93. Schedulable: true,
  94. }
  95. prepared, err := prepareOpenAIInputTokensCountRequest(body, account, "gpt-5.4")
  96. require.NoError(t, err)
  97. expectedEstimate, err := estimateOpenAIInputTokens(prepared.Request)
  98. require.NoError(t, err)
  99. cases := []struct {
  100. name string
  101. statusCode int
  102. body string
  103. }{
  104. {
  105. name: "401_missing_responses_write_scope",
  106. statusCode: http.StatusUnauthorized,
  107. body: `{"error":{"type":"invalid_request_error","code":"missing_scope","message":"You have insufficient permissions for this operation. Missing scopes: api.responses.write."}}`,
  108. },
  109. {
  110. name: "403_missing_responses_write_scope",
  111. statusCode: http.StatusForbidden,
  112. body: `{"error":{"type":"invalid_request_error","code":"missing_scope","message":"Missing scopes: api.responses.write"}}`,
  113. },
  114. {
  115. name: "403_html_proxy_page",
  116. statusCode: http.StatusForbidden,
  117. body: "<!doctype html><html><body>Forbidden</body></html>",
  118. },
  119. {
  120. name: "404_input_tokens_unsupported",
  121. statusCode: http.StatusNotFound,
  122. body: `{"error":{"type":"invalid_request_error","message":"The /v1/responses/input_tokens endpoint was not found"}}`,
  123. },
  124. }
  125. for _, tt := range cases {
  126. t.Run(tt.name, func(t *testing.T) {
  127. rec := httptest.NewRecorder()
  128. c, _ := gin.CreateTestContext(rec)
  129. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", bytes.NewReader(body))
  130. c.Request.Header.Set("Content-Type", "application/json")
  131. c.Request.Header.Set("User-Agent", "Claude-Code/1.0")
  132. upstream := &httpUpstreamRecorder{resp: &http.Response{
  133. StatusCode: tt.statusCode,
  134. Header: http.Header{"Content-Type": []string{"application/json"}},
  135. Body: io.NopCloser(strings.NewReader(tt.body)),
  136. }}
  137. repo := &countTokensRuntimeStateRepo{}
  138. svc := &OpenAIGatewayService{
  139. cfg: &config.Config{},
  140. httpUpstream: upstream,
  141. rateLimitService: &RateLimitService{accountRepo: repo, cfg: &config.Config{}},
  142. }
  143. err := svc.ForwardCountTokensAsAnthropic(context.Background(), c, account, body, "gpt-5.4")
  144. require.NoError(t, err)
  145. require.Equal(t, http.StatusOK, rec.Code)
  146. require.JSONEq(t, `{"input_tokens":`+strconv.Itoa(expectedEstimate)+`}`, rec.Body.String())
  147. require.NotNil(t, upstream.lastReq)
  148. require.Equal(t, "https://api.openai.com/v1/responses/input_tokens", upstream.lastReq.URL.String())
  149. require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("authorization"))
  150. require.Empty(t, upstream.lastReq.Header.Get("Chatgpt-Account-Id"))
  151. require.Zero(t, repo.tempUnschedCalls, "OAuth input_tokens unsupported errors must not temp-unschedule the account")
  152. require.Zero(t, repo.setErrorCalls, "OAuth input_tokens unsupported errors must not mark the account error")
  153. })
  154. }
  155. }
  156. func TestOpenAIGatewayService_OpenAIOAuthInputTokensFallbackUsesMinimumWhenEstimateFails(t *testing.T) {
  157. gin.SetMode(gin.TestMode)
  158. rec := httptest.NewRecorder()
  159. c, _ := gin.CreateTestContext(rec)
  160. prepared := &openAIInputTokensCountPrepared{
  161. Request: openAIInputTokensCountRequest{
  162. Model: "gpt-5",
  163. Input: json.RawMessage(`[`),
  164. },
  165. UpstreamModel: "gpt-5",
  166. }
  167. writeOpenAIOAuthInputTokensFallback(c, &Account{ID: 303}, prepared, http.StatusUnauthorized)
  168. require.Equal(t, http.StatusOK, rec.Code)
  169. require.JSONEq(t, `{"input_tokens":1}`, rec.Body.String())
  170. }
  171. func TestEstimateOpenAIInputTokens_RequestSamples(t *testing.T) {
  172. cases := []struct {
  173. name string
  174. req openAIInputTokensCountRequest
  175. want int
  176. }{
  177. {
  178. name: "simple text input",
  179. req: openAIInputTokensCountRequest{
  180. Model: "gpt-5",
  181. Input: json.RawMessage(`[{"role":"user","content":"hello world"}]`),
  182. },
  183. want: 6,
  184. },
  185. {
  186. name: "instructions plus tool schema",
  187. req: openAIInputTokensCountRequest{
  188. Model: "gpt-5",
  189. Instructions: "You are helpful.",
  190. Input: json.RawMessage(`[{"role":"user","content":"lookup weather in shanghai"}]`),
  191. Tools: []apicompat.ResponsesTool{
  192. {
  193. Type: "function",
  194. Name: "lookup_weather",
  195. Description: "Look up current weather",
  196. Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
  197. },
  198. },
  199. },
  200. want: 50,
  201. },
  202. {
  203. name: "input parts and tool output",
  204. req: openAIInputTokensCountRequest{
  205. Model: "gpt-4.1",
  206. Input: json.RawMessage(`[
  207. {"role":"user","content":[{"type":"input_text","text":"first line"},{"type":"input_text","text":"second line"}]},
  208. {"type":"function_call_output","call_id":"call_123","output":"{\"ok\":true}"}
  209. ]`),
  210. },
  211. want: 24,
  212. },
  213. }
  214. for _, tt := range cases {
  215. t.Run(tt.name, func(t *testing.T) {
  216. got, err := estimateOpenAIInputTokens(tt.req)
  217. require.NoError(t, err)
  218. require.Equal(t, tt.want, got)
  219. })
  220. }
  221. }
  222. func TestEstimateGrokCountTokens_AnthropicRequests(t *testing.T) {
  223. cases := []struct {
  224. name string
  225. body string
  226. }{
  227. {
  228. name: "simple message",
  229. body: `{"model":"grok-4","messages":[{"role":"user","content":"hello world"}]}`,
  230. },
  231. {
  232. name: "system blocks and tools",
  233. body: `{
  234. "model":"grok-4",
  235. "system":[{"type":"text","text":"You are helpful."}],
  236. "messages":[{"role":"user","content":[{"type":"text","text":"look up the weather"}]}],
  237. "tools":[{"name":"lookup_weather","description":"Look up weather","input_schema":{"type":"object","properties":{"city":{"type":"string"}}}}],
  238. "tool_choice":{"type":"auto"}
  239. }`,
  240. },
  241. {
  242. name: "empty conversation uses positive minimum",
  243. body: `{"model":"grok-4","messages":[]}`,
  244. },
  245. }
  246. for _, tt := range cases {
  247. t.Run(tt.name, func(t *testing.T) {
  248. got, err := EstimateGrokCountTokens([]byte(tt.body))
  249. require.NoError(t, err)
  250. require.Positive(t, got)
  251. })
  252. }
  253. }
  254. func TestEstimateGrokCountTokens_RejectsInvalidRequests(t *testing.T) {
  255. for _, body := range []string{
  256. `{`,
  257. `{"messages":[{"role":"user","content":"hello"}]}`,
  258. `{"model":"grok-4","messages":[{"role":"user","content":{"unexpected":true}}]}`,
  259. } {
  260. _, err := EstimateGrokCountTokens([]byte(body))
  261. require.Error(t, err, "body=%s", body)
  262. }
  263. }
  264. func TestOpenAIInputTokensEncodingForModel(t *testing.T) {
  265. cases := []struct {
  266. model string
  267. want string
  268. }{
  269. {model: "gpt-5", want: "o200k_base"},
  270. {model: "gpt-5.3-codex", want: "o200k_base"},
  271. {model: "gpt-4o-mini", want: "o200k_base"},
  272. {model: "gpt-4.1", want: "o200k_base"},
  273. {model: "gpt-4-turbo", want: "cl100k_base"},
  274. {model: "gpt-3.5-turbo", want: "cl100k_base"},
  275. }
  276. for _, tt := range cases {
  277. t.Run(tt.model, func(t *testing.T) {
  278. require.Equal(t, tt.want, string(openAIInputTokensEncodingForModel(tt.model)))
  279. })
  280. }
  281. }
  282. func TestEstimateOpenAIInputTokens_CompareWithOpenAIAPI(t *testing.T) {
  283. apiKey := strings.TrimSpace(os.Getenv("OPENAI_API_KEY"))
  284. if apiKey == "" {
  285. t.Skip("OPENAI_API_KEY not set")
  286. }
  287. // Invalid/expired keys in local env must not fail the unit suite.
  288. if strings.HasPrefix(apiKey, "sk-") && len(apiKey) < 20 {
  289. t.Skip("OPENAI_API_KEY looks incomplete")
  290. }
  291. client := &http.Client{Timeout: 30 * time.Second}
  292. cases := []struct {
  293. name string
  294. anthropicBody []byte
  295. defaultOpenAIModel string
  296. }{
  297. {
  298. name: "simple user text",
  299. defaultOpenAIModel: "gpt-5",
  300. anthropicBody: []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello world from sub2api"}]}`),
  301. },
  302. {
  303. name: "system plus tool",
  304. defaultOpenAIModel: "gpt-5",
  305. anthropicBody: []byte(`{"model":"claude-sonnet-4-5","system":"You are helpful.","messages":[{"role":"user","content":"find weather in shanghai"}],"tools":[{"name":"lookup_weather","description":"Look up current weather","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}`),
  306. },
  307. {
  308. name: "multi turn text",
  309. defaultOpenAIModel: "gpt-4.1",
  310. anthropicBody: []byte(`{"model":"claude-opus-4-1","messages":[{"role":"user","content":"summarize this repo"},{"role":"assistant","content":"which repo?"},{"role":"user","content":"sub2api"}]}`),
  311. },
  312. }
  313. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  314. for _, tc := range cases {
  315. t.Run(tc.name, func(t *testing.T) {
  316. prepared, err := prepareOpenAIInputTokensCountRequest(tc.anthropicBody, account, tc.defaultOpenAIModel)
  317. require.NoError(t, err)
  318. estimated, err := estimateOpenAIInputTokens(prepared.Request)
  319. require.NoError(t, err)
  320. actual, err := callOpenAIInputTokensAPIForTest(client, apiKey, prepared.Request)
  321. if err != nil {
  322. // This is an optional live-API comparison. Credential and transient
  323. // network failures must not make the deterministic unit suite fail.
  324. var netErr net.Error
  325. if strings.Contains(err.Error(), "status=401") ||
  326. strings.Contains(err.Error(), "invalid_api_key") ||
  327. errors.As(err, &netErr) {
  328. t.Skipf("OpenAI live comparison unavailable: %v", err)
  329. }
  330. require.NoError(t, err)
  331. }
  332. diff := estimated - actual
  333. if diff < 0 {
  334. diff = -diff
  335. }
  336. t.Logf("model=%s estimated=%d actual=%d diff=%d", prepared.Request.Model, estimated, actual, diff)
  337. require.LessOrEqual(t, diff, maxLocalInt(24, actual/4))
  338. })
  339. }
  340. }
  341. func callOpenAIInputTokensAPIForTest(client *http.Client, apiKey string, reqBody openAIInputTokensCountRequest) (int, error) {
  342. body, err := marshalOpenAIUpstreamJSON(reqBody)
  343. if err != nil {
  344. return 0, err
  345. }
  346. req, err := http.NewRequest(http.MethodPost, openaiPlatformAPIInputTokensURL, bytes.NewReader(body))
  347. if err != nil {
  348. return 0, err
  349. }
  350. req.Header.Set("authorization", "Bearer "+apiKey)
  351. req.Header.Set("content-type", "application/json")
  352. req.Header.Set("accept", "application/json")
  353. resp, err := client.Do(req)
  354. if err != nil {
  355. return 0, err
  356. }
  357. defer func() { _ = resp.Body.Close() }()
  358. respBody, err := io.ReadAll(resp.Body)
  359. if err != nil {
  360. return 0, err
  361. }
  362. if resp.StatusCode >= 400 {
  363. return 0, fmt.Errorf("openai input_tokens api error: status=%d body=%s", resp.StatusCode, string(respBody))
  364. }
  365. value := gjson.GetBytes(respBody, "input_tokens")
  366. if !value.Exists() {
  367. return 0, fmt.Errorf("openai input_tokens api missing input_tokens: %s", string(respBody))
  368. }
  369. return int(value.Int()), nil
  370. }
  371. func maxLocalInt(a, b int) int {
  372. if a > b {
  373. return a
  374. }
  375. return b
  376. }