| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/http/httptest"
- "os"
- "strconv"
- "strings"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- type countTokensRuntimeStateRepo struct {
- AccountRepository
- tempUnschedCalls int
- setErrorCalls int
- }
- func (r *countTokensRuntimeStateRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error {
- r.tempUnschedCalls++
- return nil
- }
- func (r *countTokensRuntimeStateRepo) SetError(_ context.Context, _ int64, _ string) error {
- r.setErrorCalls++
- return nil
- }
- func TestOpenAIGatewayService_ForwardCountTokensAsAnthropic_APIKeyUsesResponsesInputTokens(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"claude-sonnet-4-5","system":"You are helpful.","messages":[{"role":"user","content":"hello"}],"tools":[{"name":"lookup","input_schema":{"type":"object"}}]}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"object":"response.input_tokens","input_tokens":42}`)),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{
- Enabled: false,
- AllowInsecureHTTP: true,
- }}},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 101,
- Name: "openai-apikey",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "http://upstream.example",
- },
- Status: StatusActive,
- Schedulable: true,
- }
- err := svc.ForwardCountTokensAsAnthropic(context.Background(), c, account, body, "gpt-5.3-codex")
- require.NoError(t, err)
- require.Equal(t, http.StatusOK, rec.Code)
- require.JSONEq(t, `{"input_tokens":42}`, rec.Body.String())
- require.NotNil(t, upstream.lastReq)
- require.Equal(t, "http://upstream.example/v1/responses/input_tokens", upstream.lastReq.URL.String())
- require.Equal(t, "Bearer sk-test", upstream.lastReq.Header.Get("authorization"))
- require.Equal(t, "gpt-5.3-codex", gjson.GetBytes(upstream.lastBody, "model").String())
- require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
- require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
- }
- func TestOpenAIGatewayService_ForwardCountTokensAsAnthropic_OAuthFallsBackWhenPlatformEndpointUnsupported(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"claude-opus-4-1","messages":[{"role":"user","content":"hello"}]}`)
- account := &Account{
- ID: 202,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "refresh_token": "oauth-refresh-token",
- },
- Status: StatusActive,
- Schedulable: true,
- }
- prepared, err := prepareOpenAIInputTokensCountRequest(body, account, "gpt-5.4")
- require.NoError(t, err)
- expectedEstimate, err := estimateOpenAIInputTokens(prepared.Request)
- require.NoError(t, err)
- cases := []struct {
- name string
- statusCode int
- body string
- }{
- {
- name: "401_missing_responses_write_scope",
- statusCode: http.StatusUnauthorized,
- body: `{"error":{"type":"invalid_request_error","code":"missing_scope","message":"You have insufficient permissions for this operation. Missing scopes: api.responses.write."}}`,
- },
- {
- name: "403_missing_responses_write_scope",
- statusCode: http.StatusForbidden,
- body: `{"error":{"type":"invalid_request_error","code":"missing_scope","message":"Missing scopes: api.responses.write"}}`,
- },
- {
- name: "403_html_proxy_page",
- statusCode: http.StatusForbidden,
- body: "<!doctype html><html><body>Forbidden</body></html>",
- },
- {
- name: "404_input_tokens_unsupported",
- statusCode: http.StatusNotFound,
- body: `{"error":{"type":"invalid_request_error","message":"The /v1/responses/input_tokens endpoint was not found"}}`,
- },
- }
- for _, tt := range cases {
- t.Run(tt.name, func(t *testing.T) {
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- c.Request.Header.Set("User-Agent", "Claude-Code/1.0")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: tt.statusCode,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(tt.body)),
- }}
- repo := &countTokensRuntimeStateRepo{}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{},
- httpUpstream: upstream,
- rateLimitService: &RateLimitService{accountRepo: repo, cfg: &config.Config{}},
- }
- err := svc.ForwardCountTokensAsAnthropic(context.Background(), c, account, body, "gpt-5.4")
- require.NoError(t, err)
- require.Equal(t, http.StatusOK, rec.Code)
- require.JSONEq(t, `{"input_tokens":`+strconv.Itoa(expectedEstimate)+`}`, rec.Body.String())
- require.NotNil(t, upstream.lastReq)
- require.Equal(t, "https://api.openai.com/v1/responses/input_tokens", upstream.lastReq.URL.String())
- require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("authorization"))
- require.Empty(t, upstream.lastReq.Header.Get("Chatgpt-Account-Id"))
- require.Zero(t, repo.tempUnschedCalls, "OAuth input_tokens unsupported errors must not temp-unschedule the account")
- require.Zero(t, repo.setErrorCalls, "OAuth input_tokens unsupported errors must not mark the account error")
- })
- }
- }
- func TestOpenAIGatewayService_OpenAIOAuthInputTokensFallbackUsesMinimumWhenEstimateFails(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- prepared := &openAIInputTokensCountPrepared{
- Request: openAIInputTokensCountRequest{
- Model: "gpt-5",
- Input: json.RawMessage(`[`),
- },
- UpstreamModel: "gpt-5",
- }
- writeOpenAIOAuthInputTokensFallback(c, &Account{ID: 303}, prepared, http.StatusUnauthorized)
- require.Equal(t, http.StatusOK, rec.Code)
- require.JSONEq(t, `{"input_tokens":1}`, rec.Body.String())
- }
- func TestEstimateOpenAIInputTokens_RequestSamples(t *testing.T) {
- cases := []struct {
- name string
- req openAIInputTokensCountRequest
- want int
- }{
- {
- name: "simple text input",
- req: openAIInputTokensCountRequest{
- Model: "gpt-5",
- Input: json.RawMessage(`[{"role":"user","content":"hello world"}]`),
- },
- want: 6,
- },
- {
- name: "instructions plus tool schema",
- req: openAIInputTokensCountRequest{
- Model: "gpt-5",
- Instructions: "You are helpful.",
- Input: json.RawMessage(`[{"role":"user","content":"lookup weather in shanghai"}]`),
- Tools: []apicompat.ResponsesTool{
- {
- Type: "function",
- Name: "lookup_weather",
- Description: "Look up current weather",
- Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
- },
- },
- },
- want: 50,
- },
- {
- name: "input parts and tool output",
- req: openAIInputTokensCountRequest{
- Model: "gpt-4.1",
- Input: json.RawMessage(`[
- {"role":"user","content":[{"type":"input_text","text":"first line"},{"type":"input_text","text":"second line"}]},
- {"type":"function_call_output","call_id":"call_123","output":"{\"ok\":true}"}
- ]`),
- },
- want: 24,
- },
- }
- for _, tt := range cases {
- t.Run(tt.name, func(t *testing.T) {
- got, err := estimateOpenAIInputTokens(tt.req)
- require.NoError(t, err)
- require.Equal(t, tt.want, got)
- })
- }
- }
- func TestEstimateGrokCountTokens_AnthropicRequests(t *testing.T) {
- cases := []struct {
- name string
- body string
- }{
- {
- name: "simple message",
- body: `{"model":"grok-4","messages":[{"role":"user","content":"hello world"}]}`,
- },
- {
- name: "system blocks and tools",
- body: `{
- "model":"grok-4",
- "system":[{"type":"text","text":"You are helpful."}],
- "messages":[{"role":"user","content":[{"type":"text","text":"look up the weather"}]}],
- "tools":[{"name":"lookup_weather","description":"Look up weather","input_schema":{"type":"object","properties":{"city":{"type":"string"}}}}],
- "tool_choice":{"type":"auto"}
- }`,
- },
- {
- name: "empty conversation uses positive minimum",
- body: `{"model":"grok-4","messages":[]}`,
- },
- }
- for _, tt := range cases {
- t.Run(tt.name, func(t *testing.T) {
- got, err := EstimateGrokCountTokens([]byte(tt.body))
- require.NoError(t, err)
- require.Positive(t, got)
- })
- }
- }
- func TestEstimateGrokCountTokens_RejectsInvalidRequests(t *testing.T) {
- for _, body := range []string{
- `{`,
- `{"messages":[{"role":"user","content":"hello"}]}`,
- `{"model":"grok-4","messages":[{"role":"user","content":{"unexpected":true}}]}`,
- } {
- _, err := EstimateGrokCountTokens([]byte(body))
- require.Error(t, err, "body=%s", body)
- }
- }
- func TestOpenAIInputTokensEncodingForModel(t *testing.T) {
- cases := []struct {
- model string
- want string
- }{
- {model: "gpt-5", want: "o200k_base"},
- {model: "gpt-5.3-codex", want: "o200k_base"},
- {model: "gpt-4o-mini", want: "o200k_base"},
- {model: "gpt-4.1", want: "o200k_base"},
- {model: "gpt-4-turbo", want: "cl100k_base"},
- {model: "gpt-3.5-turbo", want: "cl100k_base"},
- }
- for _, tt := range cases {
- t.Run(tt.model, func(t *testing.T) {
- require.Equal(t, tt.want, string(openAIInputTokensEncodingForModel(tt.model)))
- })
- }
- }
- func TestEstimateOpenAIInputTokens_CompareWithOpenAIAPI(t *testing.T) {
- apiKey := strings.TrimSpace(os.Getenv("OPENAI_API_KEY"))
- if apiKey == "" {
- t.Skip("OPENAI_API_KEY not set")
- }
- // Invalid/expired keys in local env must not fail the unit suite.
- if strings.HasPrefix(apiKey, "sk-") && len(apiKey) < 20 {
- t.Skip("OPENAI_API_KEY looks incomplete")
- }
- client := &http.Client{Timeout: 30 * time.Second}
- cases := []struct {
- name string
- anthropicBody []byte
- defaultOpenAIModel string
- }{
- {
- name: "simple user text",
- defaultOpenAIModel: "gpt-5",
- anthropicBody: []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello world from sub2api"}]}`),
- },
- {
- name: "system plus tool",
- defaultOpenAIModel: "gpt-5",
- 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"]}}]}`),
- },
- {
- name: "multi turn text",
- defaultOpenAIModel: "gpt-4.1",
- anthropicBody: []byte(`{"model":"claude-opus-4-1","messages":[{"role":"user","content":"summarize this repo"},{"role":"assistant","content":"which repo?"},{"role":"user","content":"sub2api"}]}`),
- },
- }
- account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- prepared, err := prepareOpenAIInputTokensCountRequest(tc.anthropicBody, account, tc.defaultOpenAIModel)
- require.NoError(t, err)
- estimated, err := estimateOpenAIInputTokens(prepared.Request)
- require.NoError(t, err)
- actual, err := callOpenAIInputTokensAPIForTest(client, apiKey, prepared.Request)
- if err != nil {
- // This is an optional live-API comparison. Credential and transient
- // network failures must not make the deterministic unit suite fail.
- var netErr net.Error
- if strings.Contains(err.Error(), "status=401") ||
- strings.Contains(err.Error(), "invalid_api_key") ||
- errors.As(err, &netErr) {
- t.Skipf("OpenAI live comparison unavailable: %v", err)
- }
- require.NoError(t, err)
- }
- diff := estimated - actual
- if diff < 0 {
- diff = -diff
- }
- t.Logf("model=%s estimated=%d actual=%d diff=%d", prepared.Request.Model, estimated, actual, diff)
- require.LessOrEqual(t, diff, maxLocalInt(24, actual/4))
- })
- }
- }
- func callOpenAIInputTokensAPIForTest(client *http.Client, apiKey string, reqBody openAIInputTokensCountRequest) (int, error) {
- body, err := marshalOpenAIUpstreamJSON(reqBody)
- if err != nil {
- return 0, err
- }
- req, err := http.NewRequest(http.MethodPost, openaiPlatformAPIInputTokensURL, bytes.NewReader(body))
- if err != nil {
- return 0, err
- }
- req.Header.Set("authorization", "Bearer "+apiKey)
- req.Header.Set("content-type", "application/json")
- req.Header.Set("accept", "application/json")
- resp, err := client.Do(req)
- if err != nil {
- return 0, err
- }
- defer func() { _ = resp.Body.Close() }()
- respBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return 0, err
- }
- if resp.StatusCode >= 400 {
- return 0, fmt.Errorf("openai input_tokens api error: status=%d body=%s", resp.StatusCode, string(respBody))
- }
- value := gjson.GetBytes(respBody, "input_tokens")
- if !value.Exists() {
- return 0, fmt.Errorf("openai input_tokens api missing input_tokens: %s", string(respBody))
- }
- return int(value.Int()), nil
- }
- func maxLocalInt(a, b int) int {
- if a > b {
- return a
- }
- return b
- }
|