사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
358 lines
14 KiB
Go
358 lines
14 KiB
Go
package openai
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"iop/apps/edge/internal/authprojection"
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
"iop/packages/go/config"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
func TestAnthropicCanonicalAndAliasRoutes(t *testing.T) {
|
|
fixture := mustReadAnthropicFixture(t, "native_message.json")
|
|
candidate := anthropicTestCandidate(t, "anthropic")
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
poolSelectedCandidate: candidate,
|
|
tunnelServedTarget: "upstream-claude",
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{BearerToken: "iop-token"}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"anthropic": "upstream-claude"}}})
|
|
|
|
for _, path := range []string{"/v1/messages", "/anthropic/v1/messages"} {
|
|
t.Run(path, func(t *testing.T) {
|
|
fake.tunnelFrames = anthropicTunnelFrames(http.StatusOK, "application/json", fixture)
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"claude-route","max_tokens":32,"messages":[{"role":"user","content":"hello"}]}`))
|
|
req.Header.Set("X-Api-Key", "iop-token")
|
|
req.Header.Set(anthropicVersionHeader, anthropicSupportedVersion)
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.Bytes(); !reflect.DeepEqual(got, fixture) {
|
|
t.Fatalf("native body changed:\n got: %s\nwant: %s", got, fixture)
|
|
}
|
|
})
|
|
}
|
|
if got := fake.poolSubmitCountSnapshot(); got != 2 {
|
|
t.Fatalf("provider-pool submissions: got %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestAnthropicPrincipalBearerAndAPIKey(t *testing.T) {
|
|
raw := "principal-token"
|
|
sum := sha256.Sum256([]byte(raw))
|
|
cfg := config.EdgeOpenAIConf{PrincipalTokens: []config.OpenAIPrincipalTokenConf{{
|
|
TokenRef: "token-1", TokenHashSHA256: hex.EncodeToString(sum[:]), PrincipalRef: "principal-1",
|
|
}}}
|
|
srv := NewServer(cfg, &fakeRunService{}, nil)
|
|
for _, headers := range []map[string]string{
|
|
{"Authorization": "Bearer " + raw},
|
|
{"X-Api-Key": raw},
|
|
{"Authorization": "Bearer " + raw, "X-Api-Key": raw},
|
|
} {
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
req.Header.Set(anthropicVersionHeader, anthropicSupportedVersion)
|
|
for key, value := range headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("headers=%v status=%d body=%s", headers, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnthropicPrincipalDualCredentialConflict(t *testing.T) {
|
|
srv := NewServer(config.EdgeOpenAIConf{BearerToken: "first-secret"}, &fakeRunService{}, nil)
|
|
for _, headers := range []map[string]string{
|
|
{"Authorization": "Bearer first-secret", "X-Api-Key": "second-secret"},
|
|
{"Authorization": "Basic first-secret", "X-Api-Key": "first-secret"},
|
|
} {
|
|
req := httptest.NewRequest(http.MethodGet, "/anthropic/v1/models", nil)
|
|
for key, value := range headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
req.Header.Set(anthropicVersionHeader, anthropicSupportedVersion)
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("headers=%v status: got %d body=%s", headers, w.Code, w.Body.String())
|
|
}
|
|
if strings.Contains(w.Body.String(), "first-secret") || strings.Contains(w.Body.String(), "second-secret") {
|
|
t.Fatalf("credential leaked in response: %s", w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestManagedAnthropicDualHeadersSameToken(t *testing.T) {
|
|
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
cache := authprojection.NewCache(authprojection.DefaultLimits(), func() time.Time { return now })
|
|
if err := cache.Apply(managedProjectionFixture(1, now, time.Minute, "managed-anthropic-token")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, &fakeRunService{}, nil)
|
|
setManagedPrincipalProjection(srv, cache)
|
|
handlerCalls := 0
|
|
handler := srv.withAuth(func(w http.ResponseWriter, r *http.Request) {
|
|
handlerCalls++
|
|
principal, ok := principalFromContext(r.Context())
|
|
if !ok || principal.PrincipalRef != "managed-principal" || principal.Source != principalSourceProjection {
|
|
t.Fatalf("managed principal: %+v ok=%v", principal, ok)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/anthropic/v1/models", nil)
|
|
req.Header.Set("Authorization", "Bearer managed-anthropic-token")
|
|
req.Header.Set("X-Api-Key", "managed-anthropic-token")
|
|
w := httptest.NewRecorder()
|
|
handler(w, req)
|
|
if w.Code != http.StatusNoContent || handlerCalls != 1 {
|
|
t.Fatalf("status=%d handler_calls=%d body=%s", w.Code, handlerCalls, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestManagedAnthropicDualHeadersMismatch(t *testing.T) {
|
|
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
cache := authprojection.NewCache(authprojection.DefaultLimits(), func() time.Time { return now })
|
|
if err := cache.Apply(managedProjectionFixture(1, now, time.Minute, "managed-anthropic-token")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{BearerToken: "legacy-token"}, &fakeRunService{}, nil)
|
|
setManagedPrincipalProjection(srv, cache)
|
|
handlerCalls := 0
|
|
handler := srv.withAuth(func(w http.ResponseWriter, _ *http.Request) {
|
|
handlerCalls++
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
bearer string
|
|
apiKey string
|
|
}{
|
|
{name: "mismatch", bearer: "managed-anthropic-token", apiKey: "different-token"},
|
|
{name: "unregistered", apiKey: "unregistered-token"},
|
|
{name: "legacy source", bearer: "legacy-token", apiKey: "legacy-token"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/anthropic/v1/models", nil)
|
|
if tc.bearer != "" {
|
|
req.Header.Set("Authorization", "Bearer "+tc.bearer)
|
|
}
|
|
if tc.apiKey != "" {
|
|
req.Header.Set("X-Api-Key", tc.apiKey)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
handler(w, req)
|
|
if w.Code != http.StatusUnauthorized || !strings.Contains(w.Body.String(), `"type":"authentication_error"`) {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
if err := cache.Apply(managedProjectionFixture(2, now, time.Minute, "")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
revokedReq := httptest.NewRequest(http.MethodGet, "/anthropic/v1/models", nil)
|
|
revokedReq.Header.Set("X-Api-Key", "managed-anthropic-token")
|
|
revokedW := httptest.NewRecorder()
|
|
handler(revokedW, revokedReq)
|
|
if revokedW.Code != http.StatusUnauthorized {
|
|
t.Fatalf("revoked status=%d body=%s", revokedW.Code, revokedW.Body.String())
|
|
}
|
|
if handlerCalls != 0 {
|
|
t.Fatalf("rejected managed auth reached handler %d times", handlerCalls)
|
|
}
|
|
}
|
|
|
|
func TestManagedProjectionExpiryRejectsBeforeDispatch(t *testing.T) {
|
|
clock := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
cache := authprojection.NewCache(authprojection.DefaultLimits(), func() time.Time { return clock })
|
|
if err := cache.Apply(managedProjectionFixture(1, clock, time.Minute, "managed-anthropic-token")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, &fakeRunService{}, nil)
|
|
setManagedPrincipalProjection(srv, cache)
|
|
handlerCalls := 0
|
|
handler := srv.withAuth(func(w http.ResponseWriter, _ *http.Request) {
|
|
handlerCalls++
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
clock = clock.Add(time.Minute)
|
|
req := httptest.NewRequest(http.MethodGet, "/anthropic/v1/models", nil)
|
|
req.Header.Set("X-Api-Key", "managed-anthropic-token")
|
|
w := httptest.NewRecorder()
|
|
handler(w, req)
|
|
if w.Code != http.StatusUnauthorized || handlerCalls != 0 {
|
|
t.Fatalf("expired status=%d handler_calls=%d body=%s", w.Code, handlerCalls, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAnthropicModelsVersionSelectsShape(t *testing.T) {
|
|
srv := NewServer(config.EdgeOpenAIConf{}, &fakeRunService{}, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", DisplayName: "Claude Route"}})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
req.Header.Set(anthropicVersionHeader, anthropicSupportedVersion)
|
|
req.Header.Set("User-Agent", "not-a-schema-selector")
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var got, want any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(mustReadAnthropicFixture(t, "models_anthropic.json"), &want); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("Anthropic model schema mismatch:\n got: %s\nwant: %s", w.Body.String(), mustReadAnthropicFixture(t, "models_anthropic.json"))
|
|
}
|
|
aliasReq := httptest.NewRequest(http.MethodGet, "/anthropic/v1/models", nil)
|
|
aliasReq.Header.Set(anthropicVersionHeader, anthropicSupportedVersion)
|
|
aliasW := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(aliasW, aliasReq)
|
|
if aliasW.Code != http.StatusOK || aliasW.Body.String() != w.Body.String() {
|
|
t.Fatalf("alias schema mismatch: status=%d body=%s", aliasW.Code, aliasW.Body.String())
|
|
}
|
|
for _, req := range []*http.Request{
|
|
httptest.NewRequest(http.MethodGet, "/anthropic/v1/models", nil),
|
|
func() *http.Request {
|
|
r := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
r.Header.Set(anthropicVersionHeader, "2024-01-01")
|
|
return r
|
|
}(),
|
|
} {
|
|
invalidW := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(invalidW, req)
|
|
if invalidW.Code != http.StatusBadRequest || !strings.Contains(invalidW.Body.String(), `"type":"invalid_request_error"`) {
|
|
t.Fatalf("invalid version accepted: path=%s status=%d body=%s", req.URL.Path, invalidW.Code, invalidW.Body.String())
|
|
}
|
|
}
|
|
|
|
openAIReq := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
openAIReq.Header.Set("User-Agent", "claude-code")
|
|
openAIW := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(openAIW, openAIReq)
|
|
if !strings.Contains(openAIW.Body.String(), `"object":"list"`) || strings.Contains(openAIW.Body.String(), `"has_more"`) {
|
|
t.Fatalf("User-Agent selected the wrong model schema: %s", openAIW.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAnthropicCountTokensNativeLocalUnsupported(t *testing.T) {
|
|
requestBody := `{"model":"claude-route","system":"Count this.","messages":[{"role":"user","content":"hello tokens"}]}`
|
|
|
|
t.Run("local deterministic", func(t *testing.T) {
|
|
fake := &providerFakeRunService{}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{
|
|
ID: "claude-route", Providers: map[string]string{"chat": "served-chat"},
|
|
TokenCounter: &config.TokenCounterConf{Mode: config.TokenCounterDeterministic},
|
|
}})
|
|
w := serveAnthropicRequest(srv, "/v1/messages/count_tokens", requestBody)
|
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"input_tokens":`) {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if got := fake.poolSubmitCountSnapshot(); got != 0 {
|
|
t.Fatalf("local counter dispatched provider pool %d times", got)
|
|
}
|
|
})
|
|
|
|
t.Run("native", func(t *testing.T) {
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
poolSelectedCandidate: anthropicTestCandidate(t, "anthropic"),
|
|
tunnelServedTarget: "upstream-claude",
|
|
tunnelFrames: anthropicTunnelFrames(http.StatusOK, "application/json", []byte(`{"input_tokens":7}`)),
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"anthropic": "upstream-claude"}}})
|
|
w := serveAnthropicRequest(srv, "/v1/messages/count_tokens", requestBody)
|
|
if w.Code != http.StatusOK || w.Body.String() != `{"input_tokens":7}` {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
reqs := fake.tunnelReqsSnapshot()
|
|
if len(reqs) != 1 || reqs[0].Operation != string(config.OperationCountTokens) {
|
|
t.Fatalf("native count request mismatch: %+v", reqs)
|
|
}
|
|
})
|
|
|
|
t.Run("unsupported chat profile", func(t *testing.T) {
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
poolSelectedCandidate: anthropicTestCandidate(t, "openai"),
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
|
|
w := serveAnthropicRequest(srv, "/v1/messages/count_tokens", requestBody)
|
|
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), `"type":"not_supported_error"`) {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if got := len(fake.tunnelReqsSnapshot()); got != 0 {
|
|
t.Fatalf("unsupported count reached provider wire: %d requests", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func serveAnthropicRequest(srv *Server, path, body string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
|
req.Header.Set(anthropicVersionHeader, anthropicSupportedVersion)
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func anthropicTestCandidate(t *testing.T, profileID string) edgeservice.ProviderPoolCandidate {
|
|
t.Helper()
|
|
profile, err := config.ResolveProtocolProfile(profileID, "", config.BuiltInProtocolProfileCatalog())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return edgeservice.ProviderPoolCandidate{
|
|
ActualModel: "served-model", ProviderID: profileID + "-provider",
|
|
ExecutionPath: string(edgeservice.ProviderPoolPathTunnel), ProfileID: profile.ID,
|
|
ProfileDriver: string(profile.Driver), ProfileCapabilities: append([]string(nil), profile.Capabilities...),
|
|
ProtocolProfile: &profile,
|
|
}
|
|
}
|
|
|
|
func anthropicTunnelFrames(status int, contentType string, chunks ...[]byte) chan *iop.ProviderTunnelFrame {
|
|
frames := make(chan *iop.ProviderTunnelFrame, len(chunks)+2)
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
|
|
StatusCode: int32(status), Headers: map[string]string{"Content-Type": contentType, "X-Request-Id": "req_fixture"},
|
|
}
|
|
for index, chunk := range chunks {
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
|
|
Sequence: int64(index + 1), Body: append([]byte(nil), chunk...),
|
|
}
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
|
close(frames)
|
|
return frames
|
|
}
|
|
|
|
func mustReadAnthropicFixture(t *testing.T, name string) []byte {
|
|
t.Helper()
|
|
body, err := os.ReadFile("testdata/anthropic/" + name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return body
|
|
}
|