iop/apps/node/internal/adapters/openai_compat/protocol_profile_test.go
toki 61d4e76fe8 feat(protocol-profile): GLM Coding Plan 프로필을 분리한다
General API와 Coding Plan이 서로 다른 엔드포인트와 사용량 경계를 유지하도록 프로필, credential route, provider mapping, full-cycle 검증을 함께 반영한다.
2026-08-02 13:24:42 +09:00

517 lines
21 KiB
Go

package openai_compat
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
runtime "iop/packages/go/agentruntime"
"iop/packages/go/config"
)
// TestProtocolProfileOperationURLFixtures executes each documented Chat or
// Messages variant against a loopback upstream. Catalog endpoints and auth
// were checked against official provider documentation on 2026-08-02; GLM
// General and Coding Plan endpoints are distinct, while MiMo pay-go endpoints
// are used and plan-specific MiMo endpoints are intentionally not guessed.
func TestProtocolProfileOperationURLFixtures(t *testing.T) {
fixtures := []struct {
id string
operation config.ProtocolOperation
basePrefix string
wantURI string
authHeader string
}{
{id: "openai", operation: config.OperationChatCompletions, basePrefix: "/v1", wantURI: "/v1/chat/completions", authHeader: "Authorization"},
{id: "gemini", operation: config.OperationChatCompletions, basePrefix: "/v1beta/openai", wantURI: "/v1beta/openai/chat/completions", authHeader: "Authorization"},
{id: "glm", operation: config.OperationChatCompletions, basePrefix: "/api/paas/v4", wantURI: "/api/paas/v4/chat/completions", authHeader: "Authorization"},
{id: "glm_coding", operation: config.OperationChatCompletions, basePrefix: "/api/coding/paas/v4", wantURI: "/api/coding/paas/v4/chat/completions", authHeader: "Authorization"},
{id: "kimi", operation: config.OperationChatCompletions, basePrefix: "/v1", wantURI: "/v1/chat/completions", authHeader: "Authorization"},
{id: "minimax_chat", operation: config.OperationChatCompletions, basePrefix: "/v1", wantURI: "/v1/chat/completions", authHeader: "Authorization"},
{id: "mimo_chat", operation: config.OperationChatCompletions, basePrefix: "/v1", wantURI: "/v1/chat/completions", authHeader: "Authorization"},
{id: "grok", operation: config.OperationChatCompletions, basePrefix: "/v1", wantURI: "/v1/chat/completions", authHeader: "Authorization"},
{id: "seulgi_chat", operation: config.OperationChatCompletions, basePrefix: "/openai/v1", wantURI: "/openai/v1/chat/completions", authHeader: "Authorization"},
{id: "anthropic", operation: config.OperationMessages, wantURI: "/v1/messages", authHeader: "X-Api-Key"},
{id: "minimax_messages", operation: config.OperationMessages, basePrefix: "/anthropic", wantURI: "/anthropic/v1/messages", authHeader: "Authorization"},
{id: "mimo_messages", operation: config.OperationMessages, basePrefix: "/anthropic", wantURI: "/anthropic/v1/messages", authHeader: "api-key"},
{id: "seulgi_messages", operation: config.OperationMessages, basePrefix: "/anthropic/v1", wantURI: "/anthropic/v1/messages", authHeader: "X-Api-Key"},
}
for _, fx := range fixtures {
t.Run(fx.id, func(t *testing.T) {
outcomes := []struct {
name string
status int
contentType string
body []byte
}{
{name: "json", status: http.StatusOK, contentType: "application/json", body: []byte(`{"ok":true}`)},
{name: "sse", status: http.StatusOK, contentType: "text/event-stream", body: fixtureSSEBody(fx.operation)},
{name: "provider_error", status: http.StatusUnprocessableEntity, contentType: "application/json", body: []byte(`{"error":{"message":"fixture"}}`)},
}
for _, outcome := range outcomes {
t.Run(outcome.name, func(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.RequestURI != fx.wantURI {
t.Errorf("RequestURI = %q, want %q", r.RequestURI, fx.wantURI)
}
wantAuth := "test-secret"
if fx.authHeader == "Authorization" {
wantAuth = "Bearer test-secret"
}
if got := r.Header.Get(fx.authHeader); got != wantAuth {
t.Errorf("%s = %q, want %q", fx.authHeader, got, wantAuth)
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("decode body: %v", err)
}
if got := payload["model"]; got != "upstream-model" {
t.Errorf("model = %v, want upstream-model", got)
}
w.Header().Set("Content-Type", outcome.contentType)
w.WriteHeader(outcome.status)
_, _ = w.Write(outcome.body)
}))
defer server.Close()
profile := mustResolveProfile(t, fx.id)
profile.BaseURL = server.URL + fx.basePrefix
profile.ModelMapping = map[string]string{"canonical-model": "upstream-model"}
adapter := New(config.OpenAICompatConf{
Headers: map[string]string{"Authorization": "Bearer test-secret"},
RuntimeProfile: &profile,
}, zap.NewNop())
sink := &fakeTunnelSink{}
err := adapter.TunnelProvider(context.Background(), runtime.ProviderTunnelRequest{
RunID: "run-" + fx.id + "-" + outcome.name,
TunnelID: "tunnel-" + fx.id + "-" + outcome.name,
Method: http.MethodPost,
Operation: string(fx.operation),
Body: []byte(`{"model":"canonical-model","messages":[]}`),
}, sink)
if err != nil {
t.Fatalf("TunnelProvider: %v", err)
}
if requests != 1 {
t.Fatalf("requests = %d, want 1", requests)
}
frames := sink.all()
if len(frames) < 3 {
t.Fatalf("expected at least 3 frames (start/body/end), got %d", len(frames))
}
if frames[0].Kind != runtime.ProviderTunnelFrameKindResponseStart {
t.Fatalf("expected RESPONSE_START, got %s", frames[0].Kind)
}
if frames[0].StatusCode != outcome.status {
t.Fatalf("ResponseStart status = %d, want %d", frames[0].StatusCode, outcome.status)
}
if got := frames[0].Headers["Content-Type"]; got != outcome.contentType {
t.Fatalf("ResponseStart Content-Type = %q, want %q", got, outcome.contentType)
}
var bodyBuffer bytes.Buffer
for _, f := range frames[1 : len(frames)-1] {
if f.Kind != runtime.ProviderTunnelFrameKindBody {
t.Errorf("expected BODY kind, got %s", f.Kind)
}
bodyBuffer.Write(f.Body)
}
if !bytes.Equal(bodyBuffer.Bytes(), outcome.body) {
t.Fatalf("body mismatch:\n got: %q\nwant: %q", bodyBuffer.Bytes(), outcome.body)
}
lastFrame := frames[len(frames)-1]
if outcome.name == "provider_error" {
if lastFrame.Kind != runtime.ProviderTunnelFrameKindEnd {
t.Fatalf("provider HTTP error must end with END, got %s", lastFrame.Kind)
}
} else {
if lastFrame.Kind != runtime.ProviderTunnelFrameKindEnd {
t.Fatalf("expected END, got %s", lastFrame.Kind)
}
if !lastFrame.End {
t.Fatal("expected End true")
}
}
assertOrderedTunnelFrames(t, frames)
})
}
})
}
}
func TestProtocolProfileModelMappingPreservesRawBody(t *testing.T) {
profile := mustResolveProfile(t, "openai")
profile.ModelMapping = map[string]string{"canonical-model": "upstream-model"}
adapter := New(config.OpenAICompatConf{RuntimeProfile: &profile}, zap.NewNop())
body := []byte("{\n \"model\" : \"canonical-model\", \"large\":9007199254740993, \"exp\":1.2300e+04, \"nested\":{\"model\":\"leave-me\"}, \"escaped\":\"a\\\\b\\\"c\"\n}")
want := []byte("{\n \"model\" : \"upstream-model\", \"large\":9007199254740993, \"exp\":1.2300e+04, \"nested\":{\"model\":\"leave-me\"}, \"escaped\":\"a\\\\b\\\"c\"\n}")
got, err := adapter.mapProfileBodyModel(body)
if err != nil {
t.Fatalf("mapProfileBodyModel: %v", err)
}
if string(got) != string(want) {
t.Fatalf("mapped body bytes changed unexpectedly:\n got: %s\nwant: %s", got, want)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamBody, readErr := io.ReadAll(r.Body)
if readErr != nil {
t.Errorf("read body: %v", readErr)
}
if string(upstreamBody) != string(want) {
t.Errorf("upstream body bytes:\n got: %s\nwant: %s", upstreamBody, want)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
profile.BaseURL = server.URL + "/v1"
adapter = New(config.OpenAICompatConf{RuntimeProfile: &profile}, zap.NewNop())
if err := adapter.TunnelProvider(context.Background(), runtime.ProviderTunnelRequest{
RunID: "raw-body", TunnelID: "raw-body", Method: http.MethodPost,
Operation: string(config.OperationChatCompletions), Body: body,
}, &fakeTunnelSink{}); err != nil {
t.Fatalf("TunnelProvider: %v", err)
}
}
func TestProtocolProfileModelMappingIdentityAndInvalidBodies(t *testing.T) {
profile := mustResolveProfile(t, "openai")
profile.ModelMapping = map[string]string{"canonical": "upstream", "identity": "identity"}
adapter := New(config.OpenAICompatConf{RuntimeProfile: &profile}, zap.NewNop())
for _, body := range [][]byte{
[]byte(`{"other":1}`),
[]byte(`{"model":123,"large":9007199254740993}`),
[]byte(` { "model" : "identity", "n":1.00 } `),
} {
got, err := adapter.mapProfileBodyModel(body)
if err != nil {
t.Fatalf("identity body %s: %v", body, err)
}
if string(got) != string(body) {
t.Fatalf("identity body changed: got %s want %s", got, body)
}
}
for _, body := range [][]byte{
[]byte(`{"model":"canonical"`),
[]byte(`["canonical"]`),
[]byte(`null`),
} {
if _, err := adapter.mapProfileBodyModel(body); err == nil || !strings.Contains(err.Error(), "decode profile request body") {
t.Fatalf("malformed/non-object body %s error = %v", body, err)
}
}
}
// TestProtocolProfileAuthHeaders exercises actual HTTP requests. The expected
// MiniMax and MiMo Anthropic-compatible headers were checked on 2026-08-01:
// https://platform.minimax.io/docs/token-plan/other-tools
// https://mimo.mi.com/docs/en-US/quick-start/faq/api-integration
// https://mimo.mi.com/docs/en-US/quick-start/summary/first-api-call
func TestProtocolProfileAuthHeaders(t *testing.T) {
for _, tc := range []struct {
profile string
seed map[string]string
wantKey string
wantVal string
absent []string
}{
{profile: "minimax_messages", seed: map[string]string{"X-Api-Key": "test-secret"}, wantKey: "Authorization", wantVal: "Bearer test-secret", absent: []string{"X-Api-Key"}},
{profile: "mimo_messages", seed: map[string]string{"Authorization": "Bearer test-secret", "X-Api-Key": "stale"}, wantKey: "api-key", wantVal: "test-secret", absent: []string{"Authorization", "X-Api-Key"}},
} {
t.Run(tc.profile, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get(tc.wantKey); got != tc.wantVal {
t.Errorf("%s = %q, want %q", tc.wantKey, got, tc.wantVal)
}
for _, key := range tc.absent {
if got := r.Header.Get(key); got != "" {
t.Errorf("stale %s = %q", key, got)
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
profile := mustResolveProfile(t, tc.profile)
profile.BaseURL = server.URL + "/anthropic"
adapter := New(config.OpenAICompatConf{Headers: tc.seed, RuntimeProfile: &profile}, zap.NewNop())
if err := adapter.TunnelProvider(context.Background(), runtime.ProviderTunnelRequest{
RunID: tc.profile, TunnelID: tc.profile, Method: http.MethodPost,
Operation: string(config.OperationMessages), Body: []byte(`{"model":"test","messages":[]}`),
}, &fakeTunnelSink{}); err != nil {
t.Fatalf("TunnelProvider: %v", err)
}
})
}
}
// TestProtocolProfileUnsupportedOperationRejectedBeforeDispatch verifies that
// an unsupported operation (e.g. chat_completions on anthropic) makes zero
// upstream requests.
func TestProtocolProfileUnsupportedOperationRejectedBeforeDispatch(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(`data: [DONE]`))
}))
defer server.Close()
profile := mustResolveProfile(t, "anthropic")
adapter := New(config.OpenAICompatConf{
Endpoint: server.URL,
RuntimeProfile: &profile,
}, zap.NewNop())
sink := &fakeTunnelSink{}
err := adapter.TunnelProvider(context.Background(), runtime.ProviderTunnelRequest{
RunID: "run-unsupported",
TunnelID: "tunnel-unsupported",
Adapter: "anthropic",
Target: "claude-3",
Method: http.MethodPost,
Operation: string(config.OperationChatCompletions),
Path: "/chat/completions",
}, sink)
if err == nil {
t.Fatal("expected error for unsupported operation, got nil")
}
if !strings.Contains(err.Error(), "not supported") {
t.Fatalf("expected 'not supported' error, got %v", err)
}
if requestCount != 0 {
t.Fatalf("expected zero upstream requests for unsupported operation, got %d", requestCount)
}
openAIProfile := mustResolveProfile(t, "openai")
openAIProfile.BaseURL = server.URL + "/v1"
openAIAdapter := New(config.OpenAICompatConf{RuntimeProfile: &openAIProfile}, zap.NewNop())
err = openAIAdapter.TunnelProvider(context.Background(), runtime.ProviderTunnelRequest{
RunID: "run-empty-operation", TunnelID: "tunnel-empty-operation", Method: http.MethodPost,
Path: "/v1/chat/completions", Body: []byte(`{"model":"canonical"}`),
}, &fakeTunnelSink{})
if err == nil || !strings.Contains(err.Error(), "operation must not be empty") {
t.Fatalf("expected empty operation rejection, got %v", err)
}
if requestCount != 0 {
t.Fatalf("expected zero upstream requests after empty operation, got %d", requestCount)
}
}
// TestProtocolProfileRequestHeadersAndModelMapping covers production Models and
// Responses callers in addition to the catalog variant table above.
func TestProtocolProfileRequestHeadersAndModelMapping(t *testing.T) {
t.Run("models", func(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.RequestURI != "/v1/models" {
t.Errorf("RequestURI = %q, want /v1/models", r.RequestURI)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-secret" {
t.Errorf("Authorization = %q", got)
}
_, _ = w.Write([]byte(`{"data":[{"id":"upstream-model"}]}`))
}))
defer server.Close()
profile := mustResolveProfile(t, "openai")
profile.BaseURL = server.URL + "/v1"
adapter := New(config.OpenAICompatConf{
Headers: map[string]string{"Authorization": "test-secret"},
RuntimeProfile: &profile,
}, zap.NewNop())
models, err := adapter.fetchTargets(context.Background())
if err != nil {
t.Fatalf("fetchTargets: %v", err)
}
if len(models) != 1 || models[0] != "upstream-model" || requests != 1 {
t.Fatalf("models=%v requests=%d", models, requests)
}
})
t.Run("responses_absolute_operation", func(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.RequestURI != "/absolute/responses?version=1" {
t.Errorf("RequestURI = %q", r.RequestURI)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["model"] != "upstream-response" {
t.Errorf("model = %v", body["model"])
}
_, _ = w.Write([]byte(`{"id":"resp"}`))
}))
defer server.Close()
profile := mustResolveProfile(t, "openai")
profile.Operations[string(config.OperationResponses)] = server.URL + "/absolute/responses?version=1"
profile.ModelMapping = map[string]string{"canonical-response": "upstream-response"}
adapter := New(config.OpenAICompatConf{
Headers: map[string]string{"Authorization": "Bearer test-secret"},
RuntimeProfile: &profile,
}, zap.NewNop())
err := adapter.TunnelProvider(context.Background(), runtime.ProviderTunnelRequest{
RunID: "run-responses", TunnelID: "tunnel-responses", Method: http.MethodPost,
Operation: string(config.OperationResponses), Body: []byte(`{"model":"canonical-response"}`),
}, &fakeTunnelSink{})
if err != nil {
t.Fatalf("TunnelProvider: %v", err)
}
if requests != 1 {
t.Fatalf("requests = %d, want 1", requests)
}
})
}
// TestLegacyEndpointPathFallback verifies that when no profile is present,
// the legacy endpoint + path fallback works correctly.
func TestLegacyEndpointPathFallback(t *testing.T) {
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(`data: [DONE]`))
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeTunnelSink{}
err := adapter.TunnelProvider(context.Background(), runtime.ProviderTunnelRequest{
RunID: "run-legacy",
TunnelID: "tunnel-legacy",
Adapter: "openai_compat",
Target: "gpt-4",
Method: http.MethodPost,
Path: "/v1/chat/completions",
}, sink)
if err != nil {
t.Fatalf("TunnelProvider: %v", err)
}
if gotPath != "/v1/chat/completions" {
t.Errorf("expected legacy path /v1/chat/completions, got %q", gotPath)
}
}
// TestProtocolProfileTunnelOperationSurvivesHandler verifies that the operation
// field survives the parser/handler boundary.
func TestProtocolProfileTunnelOperationSurvivesHandler(t *testing.T) {
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(`data: [DONE]`))
}))
defer server.Close()
// Override the profile's base_url to point at the test server so the
// profile-resolved URL reaches the test server.
profile := mustResolveProfile(t, "openai")
profile.BaseURL = server.URL + "/v1"
adapter := New(config.OpenAICompatConf{
Endpoint: server.URL,
RuntimeProfile: &profile,
}, zap.NewNop())
sink := &fakeTunnelSink{}
err := adapter.TunnelProvider(context.Background(), runtime.ProviderTunnelRequest{
RunID: "run-op-survives",
TunnelID: "tunnel-op-survives",
Adapter: "openai",
Target: "gpt-4",
Method: http.MethodPost,
Operation: string(config.OperationChatCompletions),
}, sink)
if err != nil {
t.Fatalf("TunnelProvider: %v", err)
}
if gotPath != "/v1/chat/completions" {
t.Errorf("expected /v1/chat/completions, got %q", gotPath)
}
}
// TestProtocolProfileTunnelLegacyPathFallback verifies that legacy Path field
// still works when no profile is present.
func TestProtocolProfileTunnelLegacyPathFallback(t *testing.T) {
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(`data: [DONE]`))
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeTunnelSink{}
err := adapter.TunnelProvider(context.Background(), runtime.ProviderTunnelRequest{
RunID: "run-legacy-fallback",
TunnelID: "tunnel-legacy-fallback",
Adapter: "openai_compat",
Target: "gpt-4",
Method: http.MethodPost,
Path: "/v1/chat/completions",
}, sink)
if err != nil {
t.Fatalf("TunnelProvider: %v", err)
}
if gotPath != "/v1/chat/completions" {
t.Errorf("expected /v1/chat/completions, got %q", gotPath)
}
}
func TestProtocolProfileAdapterSnapshotClone(t *testing.T) {
profile := mustResolveProfile(t, "openai")
profile.Extensions = map[string]any{"nested": map[string]any{"value": "original"}}
adapter := New(config.OpenAICompatConf{RuntimeProfile: &profile}, zap.NewNop())
profile.BaseURL = "https://mutated.example.invalid"
profile.Operations[string(config.OperationChatCompletions)] = "/mutated"
profile.Extensions["nested"].(map[string]any)["value"] = "mutated"
if adapter.profile.BaseURL == profile.BaseURL || adapter.profile.Operations[string(config.OperationChatCompletions)] == "/mutated" {
t.Fatal("source mutation crossed adapter snapshot boundary")
}
if got := adapter.profile.Extensions["nested"].(map[string]any)["value"]; got != "original" {
t.Fatalf("nested extension mutation crossed adapter boundary: %v", got)
}
}
// fixtureSSEBody returns deterministic SSE bytes appropriate for the operation
// so every profile's streaming leg can be qualified without a provider.
func fixtureSSEBody(operation config.ProtocolOperation) []byte {
if operation == config.OperationMessages {
return []byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_fixture\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"upstream-model\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
}
return []byte("data: {\"choices\":[{\"delta\":{\"content\":\"fixture\"}}]}\n\ndata: [DONE]\n\n")
}
func mustResolveProfile(t *testing.T, id string) config.ConcreteProtocolProfile {
t.Helper()
profile, err := config.ResolveProtocolProfile(id, "", config.BuiltInProtocolProfiles)
if err != nil {
t.Fatalf("ResolveProtocolProfile(%q): %v", id, err)
}
return profile
}