iop/apps/edge/internal/openai/anthropic_surface_test.go
toki 4c8441e6c9 feat(credential): Provider Credential Slot 라우팅을 구현한다
사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2026-08-02 09:10:11 +09:00

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
}