- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동 - 새 테스트 파일 추가 (edge, node, client, config, readability) - readability_audit 스크립트 및 baseline 추가 - roadmap/SDD 문서 갱신 - agent-client/pi/extensions/openai-sampling-parameters 추가
274 lines
11 KiB
Go
274 lines
11 KiB
Go
package openai
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
"iop/packages/go/config"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
// chatProviderAuthServer builds a provider-pool tunnel server with the given
|
|
// provider auth forwarding config, mirroring chatSidebandServer's catalog so
|
|
// pool-model routes over the provider tunnel path.
|
|
func chatProviderAuthServer(frames chan *iop.ProviderTunnelFrame, auth config.EdgeOpenAIProviderAuthConf) (*Server, *providerFakeRunService) {
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
tunnelFrames: frames,
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{ProviderAuth: auth}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{
|
|
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
|
})
|
|
return srv, fake
|
|
}
|
|
|
|
func staticOKTunnelFrames(body string) chan *iop.ProviderTunnelFrame {
|
|
frames := make(chan *iop.ProviderTunnelFrame, 3)
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(body)}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
|
close(frames)
|
|
return frames
|
|
}
|
|
|
|
// TestChatProviderTunnelForwardsProviderAuthHeader verifies SDD S03: a raw user
|
|
// token supplied in the configured provider auth request header is forwarded to
|
|
// the provider tunnel request as the configured target header, formatted with
|
|
// the configured scheme, while already-prefixed values are preserved.
|
|
func TestChatProviderTunnelForwardsProviderAuthHeader(t *testing.T) {
|
|
auth := config.EdgeOpenAIProviderAuthConf{
|
|
Enabled: true,
|
|
FromHeader: "X-IOP-Provider-Authorization",
|
|
TargetHeader: "Authorization",
|
|
Scheme: "Bearer",
|
|
Required: true,
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
callerAuth string
|
|
wantHeader string
|
|
}{
|
|
{name: "raw token gets scheme", callerAuth: "user-token", wantHeader: "Bearer user-token"},
|
|
{name: "already prefixed preserved", callerAuth: "Bearer user-token", wantHeader: "Bearer user-token"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
req.Header.Set("X-IOP-Provider-Authorization", tc.callerAuth)
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
reqs := fake.tunnelReqsSnapshot()
|
|
if len(reqs) != 1 {
|
|
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
|
|
}
|
|
// The handler sets auth headers on the tunnel request in the
|
|
// tunnel path of handleChatCompletionsProviderPool.
|
|
if got := reqs[0].Headers["Authorization"]; got != tc.wantHeader {
|
|
t.Fatalf("provider Authorization header: got %q, want %q", got, tc.wantHeader)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChatProviderTunnelProviderAuthRequired verifies SDD S03: a missing
|
|
// required provider auth header is rejected. For provider-pool tunnel path,
|
|
// auth is checked after SubmitProviderPool returns. The tunnel handle is closed
|
|
// and a 400 is returned (SDD D01).
|
|
func TestChatProviderTunnelProviderAuthRequired(t *testing.T) {
|
|
auth := config.EdgeOpenAIProviderAuthConf{
|
|
Enabled: true,
|
|
FromHeader: "X-IOP-Provider-Authorization",
|
|
TargetHeader: "Authorization",
|
|
Scheme: "Bearer",
|
|
Required: true,
|
|
}
|
|
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
// Auth required check happens in the tunnel path of handleChatCompletionsProviderPool.
|
|
// Since auth is missing, we get 400 and no tunnel request is sent (SDD S03 pre-dispatch).
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if strings.Contains(w.Body.String(), "user-token") {
|
|
t.Fatalf("error body must not echo a raw token: %s", w.Body.String())
|
|
}
|
|
// No tunnel request should be recorded: PrepareTunnel failed before dispatch.
|
|
if got := len(fake.tunnelReqsSnapshot()); got != 0 {
|
|
t.Fatalf("missing required provider auth must not dispatch tunnel: got %d tunnel requests", got)
|
|
}
|
|
}
|
|
|
|
// TestChatCompletionsProviderPoolTunnelProviderAuthForwardedBeforeDispatch verifies
|
|
// SDD S03: when provider auth is configured and present, the auth header lands on
|
|
// the actual tunnel request that the service sends, not via late handle mutation.
|
|
// The auth check runs inside SubmitProviderPool via PrepareTunnel before the
|
|
// tunnel request is built and dispatched.
|
|
func TestChatCompletionsProviderPoolTunnelProviderAuthForwardedBeforeDispatch(t *testing.T) {
|
|
auth := config.EdgeOpenAIProviderAuthConf{
|
|
Enabled: true,
|
|
FromHeader: "X-IOP-Provider-Authorization",
|
|
TargetHeader: "Authorization",
|
|
Scheme: "Bearer",
|
|
Required: true,
|
|
}
|
|
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
req.Header.Set("X-IOP-Provider-Authorization", "user-token")
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
reqs := fake.tunnelReqsSnapshot()
|
|
if len(reqs) != 1 {
|
|
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
|
|
}
|
|
// Auth header must be on the recorded tunnel request (i.e. pre-dispatch),
|
|
// not on a late handle mutation.
|
|
if got := reqs[0].Headers["Authorization"]; got != "Bearer user-token" {
|
|
t.Fatalf("tunnel request Authorization header: got %q, want %q", got, "Bearer user-token")
|
|
}
|
|
}
|
|
|
|
// TestChatCompletionsProviderPoolOllamaSelectionAllowsMissingProviderAuth verifies
|
|
// SDD D01: a missing required provider auth does NOT block the normalized
|
|
// (Ollama/CLI) execution path. Only tunnel dispatch requires the auth header.
|
|
func TestChatCompletionsProviderPoolOllamaSelectionAllowsMissingProviderAuth(t *testing.T) {
|
|
auth := config.EdgeOpenAIProviderAuthConf{
|
|
Enabled: true,
|
|
FromHeader: "X-IOP-Provider-Authorization",
|
|
TargetHeader: "Authorization",
|
|
Scheme: "Bearer",
|
|
Required: true,
|
|
}
|
|
// Ollama path: poolDispatchPath=normalized, no tunnel frames needed.
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathNormalized),
|
|
}
|
|
catalog := []config.ModelCatalogEntry{
|
|
{ID: "ollama-model", Providers: map[string]string{"prov-ollama": "qwen3.6:35b"}},
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{ProviderAuth: auth}, fake, nil)
|
|
srv.SetModelCatalog(catalog)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"ollama-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
// No X-IOP-Provider-Authorization header — should still succeed.
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
// No tunnel dispatch for Ollama-normalized path.
|
|
reqs := fake.tunnelReqsSnapshot()
|
|
if len(reqs) != 0 {
|
|
t.Fatalf("expected 0 tunnel dispatches for Ollama path, got %d", len(reqs))
|
|
}
|
|
}
|
|
|
|
// TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth verifies the
|
|
// provider token is sourced only from the configured provider auth header, not
|
|
// from the inbound IOP Authorization header, keeping IOP inbound auth and
|
|
// outbound provider auth separate.
|
|
func TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth(t *testing.T) {
|
|
auth := config.EdgeOpenAIProviderAuthConf{
|
|
Enabled: true,
|
|
FromHeader: "X-IOP-Provider-Authorization",
|
|
TargetHeader: "Authorization",
|
|
Scheme: "Bearer",
|
|
Required: true,
|
|
}
|
|
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
// Inbound IOP auth and the configured provider auth header carry different
|
|
// tokens; the provider header must come from the provider auth header only.
|
|
req.Header.Set("Authorization", "Bearer iop-inbound-token")
|
|
req.Header.Set("X-IOP-Provider-Authorization", "provider-token")
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
reqs := fake.tunnelReqsSnapshot()
|
|
if len(reqs) != 1 {
|
|
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
|
|
}
|
|
// The tunnel auth headers should come from the provider auth header only,
|
|
// not from the inbound IOP Authorization header.
|
|
if got := reqs[0].Headers["Authorization"]; got != "Bearer provider-token" {
|
|
t.Fatalf("provider Authorization must derive from provider auth header, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestResponsesProviderTunnelForwardsProviderAuthHeader verifies the provider
|
|
// auth helper is applied to the Responses tunnel: a raw request-header token is
|
|
// forwarded as the configured target header, and a missing required token is
|
|
// rejected before dispatch.
|
|
func TestResponsesProviderTunnelForwardsProviderAuthHeader(t *testing.T) {
|
|
auth := config.EdgeOpenAIProviderAuthConf{
|
|
Enabled: true,
|
|
FromHeader: "X-IOP-Provider-Authorization",
|
|
TargetHeader: "Authorization",
|
|
Scheme: "Bearer",
|
|
Required: true,
|
|
}
|
|
t.Run("forwards raw token with scheme", func(t *testing.T) {
|
|
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"pool-model","input":"hi"}`))
|
|
req.Header.Set("X-IOP-Provider-Authorization", "user-token")
|
|
w := httptest.NewRecorder()
|
|
srv.handleResponses(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
reqs := fake.tunnelReqsSnapshot()
|
|
if len(reqs) != 1 {
|
|
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
|
|
}
|
|
if got := reqs[0].Headers["Authorization"]; got != "Bearer user-token" {
|
|
t.Fatalf("provider Authorization header: got %q want %q", got, "Bearer user-token")
|
|
}
|
|
})
|
|
t.Run("missing required token rejected", func(t *testing.T) {
|
|
srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"pool-model","input":"hi"}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleResponses(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if got := len(fake.tunnelReqsSnapshot()); got != 0 {
|
|
t.Fatalf("missing required provider auth must not dispatch: got %d tunnel requests", got)
|
|
}
|
|
})
|
|
}
|