222 lines
8.9 KiB
Go
222 lines
8.9 KiB
Go
//go:build unit
|
|||
|
|
|
||
|
|
package handler
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/gin-gonic/gin"
|
||
|
|
"github.com/stretchr/testify/require"
|
||
|
|
|
||
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
|
|
)
|
||
|
|
|
||
|
|
type fakeDiagnoser struct {
|
||
|
|
calls []fakeDiagnoseCall
|
||
|
|
resp service.ModelAvailabilityDiagnosis
|
||
|
|
}
|
||
|
|
|
||
|
|
type fakeDiagnoseCall struct {
|
||
|
|
GroupID *int64
|
||
|
|
Model string
|
||
|
|
Platform string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *fakeDiagnoser) DiagnoseModelAvailabilityForPlatform(
|
||
|
|
_ context.Context,
|
||
|
|
groupID *int64,
|
||
|
|
model, platform string,
|
||
|
|
) service.ModelAvailabilityDiagnosis {
|
||
|
|
f.calls = append(f.calls, fakeDiagnoseCall{
|
||
|
|
GroupID: groupID,
|
||
|
|
Model: model,
|
||
|
|
Platform: platform,
|
||
|
|
})
|
||
|
|
return f.resp
|
||
|
|
}
|
||
|
|
|
||
|
|
func ptrInt64(v int64) *int64 { return &v }
|
||
|
|
|
||
|
|
// newTestGinContextWithRequest wraps the bare newTestGinContext helper
|
||
|
|
// (defined in openai_gateway_cyber_test.go) by additionally attaching a stub
|
||
|
|
// *http.Request so the classifier can extract c.Request.Context().
|
||
|
|
func newTestGinContextWithRequest() *gin.Context {
|
||
|
|
c := newTestGinContext()
|
||
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/test", nil)
|
||
|
|
return c
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyNoAccountError_NilDiagnoser_Falls503(t *testing.T) {
|
||
|
|
c := newTestGinContextWithRequest()
|
||
|
|
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
|
|
||
|
|
cls := classifyNoAccountErrorFromGin(c, nil, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
|
|
||
|
|
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||
|
|
require.Equal(t, "api_error", cls.ErrType)
|
||
|
|
require.False(t, cls.ModelNotFound)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyNoAccountError_NilAPIKey_Falls503(t *testing.T) {
|
||
|
|
c := newTestGinContextWithRequest()
|
||
|
|
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
|
|
|
||
|
|
cls := classifyNoAccountErrorFromGin(c, fd, nil, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
|
|
||
|
|
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||
|
|
require.False(t, cls.ModelNotFound)
|
||
|
|
require.Empty(t, fd.calls, "diagnoser must not be consulted when apiKey missing")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyNoAccountError_NilGroupID_Falls503(t *testing.T) {
|
||
|
|
c := newTestGinContextWithRequest()
|
||
|
|
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
|
|
apiKey := &service.APIKey{GroupID: nil}
|
||
|
|
|
||
|
|
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
|
|
||
|
|
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||
|
|
require.False(t, cls.ModelNotFound)
|
||
|
|
require.Empty(t, fd.calls, "diagnoser must not be consulted when group not bound")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyNoAccountError_EmptyModel_Falls503(t *testing.T) {
|
||
|
|
c := newTestGinContextWithRequest()
|
||
|
|
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
|
|
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
|
|
||
|
|
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, " ", "", service.PlatformOpenAI)
|
||
|
|
|
||
|
|
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||
|
|
require.False(t, cls.ModelNotFound)
|
||
|
|
require.Empty(t, fd.calls)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) {
|
||
|
|
c := newTestGinContextWithRequest()
|
||
|
|
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
|
|
apiKey := &service.APIKey{GroupID: ptrInt64(42)}
|
||
|
|
|
||
|
|
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.1-codex-mini", "gpt-5.1-codex-mini", service.PlatformOpenAI)
|
||
|
|
|
||
|
|
require.Equal(t, http.StatusNotFound, cls.Status)
|
||
|
|
require.Equal(t, "model_not_found", cls.ErrType)
|
||
|
|
require.True(t, cls.ModelNotFound)
|
||
|
|
require.Contains(t, cls.Message, "gpt-5.1-codex-mini", "message must surface the requested model")
|
||
|
|
|
||
|
|
require.Len(t, fd.calls, 1)
|
||
|
|
require.Equal(t, "gpt-5.1-codex-mini", fd.calls[0].Model)
|
||
|
|
require.Equal(t, service.PlatformOpenAI, fd.calls[0].Platform)
|
||
|
|
require.NotNil(t, fd.calls[0].GroupID)
|
||
|
|
require.Equal(t, int64(42), *fd.calls[0].GroupID)
|
||
|
|
require.True(t, service.HasOpsClientBusinessLimited(c))
|
||
|
|
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.T) {
|
||
|
|
c := newTestGinContextWithRequest()
|
||
|
|
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
|
|
groupID := int64(43)
|
||
|
|
apiKey := &service.APIKey{
|
||
|
|
GroupID: &groupID,
|
||
|
|
Group: &service.Group{
|
||
|
|
ID: groupID,
|
||
|
|
Platform: service.PlatformGrok,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, fd, apiKey, "grok-4.5", "grok-4.5")
|
||
|
|
|
||
|
|
require.Equal(t, http.StatusNotFound, cls.Status)
|
||
|
|
require.Equal(t, "model_not_found", cls.ErrType)
|
||
|
|
require.True(t, cls.ModelNotFound)
|
||
|
|
require.Len(t, fd.calls, 1)
|
||
|
|
require.Equal(t, service.PlatformGrok, fd.calls[0].Platform)
|
||
|
|
require.True(t, service.HasOpsClientBusinessLimited(c))
|
||
|
|
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
|
||
|
|
|
||
|
|
logErr := openAICompatibleSelectionErrorForLog(
|
||
|
|
fmt.Errorf("no available OpenAI accounts supporting model: grok-4.5"),
|
||
|
|
service.PlatformGrok,
|
||
|
|
)
|
||
|
|
require.EqualError(t, logErr, "no available Grok accounts supporting model: grok-4.5")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyNoAccountError_PureClassifierDoesNotMarkGinContext(t *testing.T) {
|
||
|
|
c := newTestGinContextWithRequest()
|
||
|
|
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
|
|
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
|
|
||
|
|
cls := classifyNoAccountError(c.Request.Context(), fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
|
|
||
|
|
require.True(t, cls.ModelNotFound)
|
||
|
|
require.False(t, service.HasOpsClientBusinessLimited(c))
|
||
|
|
require.Empty(t, service.OpsClientBusinessLimitedReason(c))
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) {
|
||
|
|
c := newTestGinContextWithRequest()
|
||
|
|
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
|
||
|
|
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
|
|
||
|
|
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
|
|
||
|
|
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "model exists somewhere — caller stays on 503")
|
||
|
|
require.Equal(t, "api_error", cls.ErrType)
|
||
|
|
require.False(t, cls.ModelNotFound)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyNoAccountError_ModelSupportedOnlyByRateLimitedAccount_Returns503(t *testing.T) {
|
||
|
|
c := newTestGinContextWithRequest()
|
||
|
|
// The diagnoser's configured-state lookup still sees the model-supporting
|
||
|
|
// account even though normal scheduling has excluded it during cooldown.
|
||
|
|
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
|
||
|
|
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
|
|
||
|
|
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "claude-opus-4-8", "claude-opus-4-8", service.PlatformAnthropic)
|
||
|
|
|
||
|
|
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||
|
|
require.Equal(t, "api_error", cls.ErrType)
|
||
|
|
require.False(t, cls.ModelNotFound, "temporary account cooldown must remain retryable")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyNoAccountError_NoAccountsInPool_Stays503(t *testing.T) {
|
||
|
|
c := newTestGinContextWithRequest()
|
||
|
|
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: false, HasModelSupport: false}}
|
||
|
|
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
|
|
||
|
|
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
|
|
||
|
|
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "empty pool is a service-availability issue, not a model issue")
|
||
|
|
require.False(t, cls.ModelNotFound)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyNoAccountError_DisplayModelOverridesRoutingForMessage(t *testing.T) {
|
||
|
|
c := newTestGinContextWithRequest()
|
||
|
|
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
|
|
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
|
|
||
|
|
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "claude-3-fancy", service.PlatformOpenAI)
|
||
|
|
|
||
|
|
require.True(t, cls.ModelNotFound)
|
||
|
|
require.Contains(t, cls.Message, "claude-3-fancy", "user-facing message must reference the model the user asked for, not the post-mapping routing model")
|
||
|
|
require.Len(t, fd.calls, 1)
|
||
|
|
require.Equal(t, "gpt-5", fd.calls[0].Model, "diagnosis must run against the routing model (post group dispatch mapping)")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
|
||
|
|
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||
|
|
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||
|
|
|
||
|
|
cls := classifyNoAccountErrorFromGin(nil, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||
|
|
|
||
|
|
require.Equal(t, http.StatusNotFound, cls.Status, "even with a nil gin context the classifier must still run and yield a coherent response")
|
||
|
|
require.True(t, cls.ModelNotFound)
|
||
|
|
require.False(t, service.HasOpsClientBusinessLimited(nil))
|
||
|
|
require.Empty(t, service.OpsClientBusinessLimitedReason(nil))
|
||
|
|
}
|