iop/apps/edge/internal/openai/provider_policy_test.go
toki 01dc2ef78b refactor: readability baseline 및 테스트 구조 개선
- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동
- 새 테스트 파일 추가 (edge, node, client, config, readability)
- readability_audit 스크립트 및 baseline 추가
- roadmap/SDD 문서 갱신
- agent-client/pi/extensions/openai-sampling-parameters 추가
2026-07-17 16:02:12 +09:00

488 lines
18 KiB
Go

package openai
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
// TestChatCompletionsPassthroughProviderPoolGenerationPolicy verifies that
// provider-pool omitted-mode passthrough requests apply the catalog entry's
// generation policy (default_max_tokens, min_max_tokens, default_thinking_token_budget)
// before dispatching to the provider. The fixture omits a top-level model echo,
// so the response body remains byte-identical.
func TestChatCompletionsPassthroughProviderPoolGenerationPolicy(t *testing.T) {
providerBody := `{"id":"cmpl-provider-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}`
var gotProviderReq []byte
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotProviderReq, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(providerBody))
}))
defer provider.Close()
fake := &providerFakeRunService{
tunnelProviderURL: provider.URL,
tunnelServedTarget: "served-model",
}
// Enable strict output to trigger think=true from policy
srv := NewServer(config.EdgeOpenAIConf{StrictOutput: true}, fake, nil)
srv.SetModelCatalog([]config.ModelCatalogEntry{
{
ID: "pool-model",
Providers: map[string]string{"prov-1": "served-model"},
DefaultMaxTokens: 100,
MinMaxTokens: 50,
DefaultThinkingTokenBudget: 30,
},
})
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"pool-model",
"messages":[{"role":"user","content":"hello"}],
"max_completion_tokens":20
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if got := w.Body.String(); got != providerBody {
t.Fatalf("body not byte-identical to provider:\n got: %q\nwant: %q", got, providerBody)
}
var parsedReq map[string]interface{}
if err := json.Unmarshal(gotProviderReq, &parsedReq); err != nil {
t.Fatalf("failed to parse provider request: %v", err)
}
if model, ok := parsedReq["model"].(string); !ok || model != "served-model" {
t.Errorf("expected model served-model, got %v", parsedReq["model"])
}
// MinMaxTokens (50) should reach max_tokens (overriding max_completion_tokens 20)
if maxTokens, ok := parsedReq["max_tokens"].(float64); !ok || maxTokens != 50 {
t.Errorf("expected max_tokens 50, got %v", parsedReq["max_tokens"])
}
if _, ok := parsedReq["max_completion_tokens"]; ok {
t.Errorf("max_completion_tokens should be deleted")
}
// DefaultThinkingTokenBudget (30) and Think (true) should reach provider
if budget, ok := parsedReq["thinking_token_budget"].(float64); !ok || budget != 30 {
t.Errorf("expected thinking_token_budget 30, got %v", parsedReq["thinking_token_budget"])
}
if think, ok := parsedReq["think"].(bool); !ok || !think {
t.Errorf("expected think true, got %v", parsedReq["think"])
}
}
func TestChatCompletionsProviderPoolAppliesGenerationPolicy(t *testing.T) {
fake := &providerFakeRunService{
tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`),
tunnelServedTarget: "Ornith-1.0-35B",
}
catalog := []config.ModelCatalogEntry{{
ID: "ornith:35b",
DefaultMaxTokens: 32768,
MinMaxTokens: 32768,
DefaultThinkingTokenBudget: 8192,
Providers: map[string]string{"prov-vllm": "Ornith-1.0-35B"},
}}
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"ornith:35b",
"messages":[{"role":"user","content":"hello"}],
"max_tokens":4096
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if len(fake.reqsSnapshot()) != 0 {
t.Fatalf("provider-pool generation policy must stay on tunnel path, got %d SubmitRun calls", len(fake.reqsSnapshot()))
}
bodies := fake.tunnelBodiesSnapshot()
if len(bodies) != 1 {
t.Fatalf("expected 1 tunnel body, got %d", len(bodies))
}
var providerReq map[string]any
if err := json.Unmarshal(bodies[0], &providerReq); err != nil {
t.Fatalf("provider body JSON: %v body=%s", err, bodies[0])
}
if providerReq["model"] != "Ornith-1.0-35B" {
t.Fatalf("served model rewrite not applied: %+v", providerReq["model"])
}
if providerReq["max_tokens"].(float64) != 32768 {
t.Fatalf("max_tokens policy not applied: %+v", providerReq)
}
if providerReq["thinking_token_budget"].(float64) != 8192 {
t.Fatalf("thinking_token_budget policy not applied: %+v", providerReq)
}
}
func TestChatCompletionsProviderPoolThinkingPolicyOverridesStrictOutputDisable(t *testing.T) {
fake := &providerFakeRunService{
tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`),
tunnelServedTarget: "Ornith-1.0-35B",
}
catalog := []config.ModelCatalogEntry{{
ID: "ornith:35b",
DefaultThinkingTokenBudget: 8192,
Providers: map[string]string{"prov-vllm": "Ornith-1.0-35B"},
}}
srv := NewServer(config.EdgeOpenAIConf{StrictOutput: true}, fake, nil)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"ornith:35b",
"messages":[{"role":"user","content":"hello"}]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if len(fake.reqsSnapshot()) != 0 {
t.Fatalf("provider-pool thinking policy must stay on tunnel path, got %d SubmitRun calls", len(fake.reqsSnapshot()))
}
bodies := fake.tunnelBodiesSnapshot()
if len(bodies) != 1 {
t.Fatalf("expected 1 tunnel body, got %d", len(bodies))
}
var providerReq map[string]any
if err := json.Unmarshal(bodies[0], &providerReq); err != nil {
t.Fatalf("provider body JSON: %v body=%s", err, bodies[0])
}
if providerReq["think"] != true {
t.Fatalf("provider-pool thinking policy should keep thinking enabled under strict output: %+v", providerReq)
}
if providerReq["thinking_token_budget"].(float64) != 8192 {
t.Fatalf("thinking_token_budget policy not applied: %+v", providerReq)
}
}
func TestChatCompletionsProviderPoolProviderNativeThinkingDisablesDefaultBudgetInjection(t *testing.T) {
fake := &providerFakeRunService{
tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`),
tunnelServedTarget: "Ornith-1.0-35B",
}
catalog := []config.ModelCatalogEntry{{
ID: "ornith:35b",
DefaultThinkingTokenBudget: 8192,
Providers: map[string]string{"prov-vllm": "Ornith-1.0-35B"},
}}
srv := NewServer(config.EdgeOpenAIConf{StrictOutput: true}, fake, nil)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"ornith:35b",
"messages":[{"role":"user","content":"hello"}],
"chat_template_kwargs":{"enable_thinking":false}
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
bodies := fake.tunnelBodiesSnapshot()
if len(bodies) != 1 {
t.Fatalf("expected 1 tunnel body, got %d", len(bodies))
}
var providerReq map[string]any
if err := json.Unmarshal(bodies[0], &providerReq); err != nil {
t.Fatalf("provider body JSON: %v body=%s", err, bodies[0])
}
ctk, ok := providerReq["chat_template_kwargs"].(map[string]any)
if !ok {
t.Fatalf("chat_template_kwargs not preserved as object: %+v", providerReq)
}
if ctk["enable_thinking"] != false {
t.Fatalf("provider-native enable_thinking must stay false: %+v", ctk)
}
if _, ok := providerReq["think"]; ok {
t.Fatalf("catalog policy must not inject top-level think over provider-native thinking: %+v", providerReq)
}
if _, ok := providerReq["thinking_token_budget"]; ok {
t.Fatalf("catalog policy must not inject thinking_token_budget over provider-native thinking: %+v", providerReq)
}
}
func TestChatCompletionsProviderPoolPreservesLargerGenerationPolicyValues(t *testing.T) {
fake := &providerFakeRunService{
tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`),
tunnelServedTarget: "Ornith-1.0-35B",
}
catalog := []config.ModelCatalogEntry{{
ID: "ornith:35b",
DefaultMaxTokens: 32768,
MinMaxTokens: 32768,
DefaultThinkingTokenBudget: 8192,
Providers: map[string]string{"prov-vllm": "Ornith-1.0-35B"},
}}
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"ornith:35b",
"messages":[{"role":"user","content":"hello"}],
"max_tokens":40000,
"thinking_token_budget":2048
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if len(fake.reqsSnapshot()) != 0 {
t.Fatalf("provider-pool generation policy must stay on tunnel path, got %d SubmitRun calls", len(fake.reqsSnapshot()))
}
bodies := fake.tunnelBodiesSnapshot()
if len(bodies) != 1 {
t.Fatalf("expected 1 tunnel body, got %d", len(bodies))
}
var providerReq map[string]any
if err := json.Unmarshal(bodies[0], &providerReq); err != nil {
t.Fatalf("provider body JSON: %v body=%s", err, bodies[0])
}
if providerReq["max_tokens"].(float64) != 40000 {
t.Fatalf("larger max_tokens should be preserved: %+v", providerReq)
}
if providerReq["thinking_token_budget"].(float64) != 2048 {
t.Fatalf("explicit thinking_token_budget should be preserved: %+v", providerReq)
}
}
// TestResponsesProviderPoolPassthroughOmitsThinkingPolicy verifies that raw
// passthrough forwards the caller body as-is under strict output: the
// normalized-path thinking policy is not injected into the provider body.
func TestResponsesProviderPoolPassthroughOmitsThinkingPolicy(t *testing.T) {
fake := &providerFakeRunService{
tunnelFrames: staticProviderTunnelFrames(`{"ok":true}`),
tunnelServedTarget: "Ornith-1.0-35B",
}
catalog := []config.ModelCatalogEntry{{
ID: "ornith:35b",
DefaultThinkingTokenBudget: 8192,
Providers: map[string]string{"prov-vllm": "Ornith-1.0-35B"},
}}
srv := NewServer(config.EdgeOpenAIConf{TimeoutSec: 5, StrictOutput: true}, fake, nil)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"ornith:35b",
"input":"hello"
}`))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if len(fake.reqsSnapshot()) != 0 {
t.Fatalf("provider-pool /v1/responses must not call SubmitRun, got %d calls", len(fake.reqsSnapshot()))
}
bodies := fake.tunnelBodiesSnapshot()
if len(bodies) != 1 {
t.Fatalf("expected 1 tunnel body, got %d", len(bodies))
}
var providerReq map[string]any
if err := json.Unmarshal(bodies[0], &providerReq); err != nil {
t.Fatalf("provider body JSON: %v body=%s", err, bodies[0])
}
if _, ok := providerReq["think"]; ok {
t.Fatalf("passthrough must not inject think: %+v", providerReq)
}
if _, ok := providerReq["thinking_token_budget"]; ok {
t.Fatalf("passthrough must not inject thinking_token_budget: %+v", providerReq)
}
}
// TestResponsesProviderPoolStrictOutputNormalizesAgentResponse verifies that the
// provider-pool normalized path preserves the strict-output contract from
// PrepareRun into completeResponse. A prompt containing an XML completion
// contract must have its response wrapped in the expected block (SDD D02).
func TestResponsesProviderPoolStrictOutputNormalizesAgentResponse(t *testing.T) {
fake := &providerFakeRunService{
poolDispatchPath: string(edgeservice.ProviderPoolPathNormalized),
}
fake.poolRunFrames = make(chan *iop.RunEvent, 3)
fake.poolRunFrames <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden"}
fake.poolRunFrames <- &iop.RunEvent{Type: "delta", Delta: "plain answer"}
fake.poolRunFrames <- &iop.RunEvent{Type: "complete", RunId: "run-pool-normalized"}
close(fake.poolRunFrames)
catalog := []config.ModelCatalogEntry{
{ID: "ollama-strict-model", Providers: map[string]string{"prov-ollama": "served-ollama"}},
}
srv := NewServer(config.EdgeOpenAIConf{StrictOutput: true}, fake, nil)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"ollama-strict-model",
"input":"Once you've completed the user's task, you must use the attempt_completion tool to present the result.\n\n<attempt_completion>\n<result>done</result>\n</attempt_completion>\n\nfinish"
}`))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
// PrepareRun must have been invoked (normalized path).
if !fake.poolPrepareRunCalledSnapshot() {
t.Fatal("PrepareRun was not called on normalized path")
}
// The prepared request must carry strict_output and the injected contract
// instruction.
if len(fake.reqsSnapshot()) != 1 {
t.Fatalf("expected one prepared req, got %d", len(fake.reqsSnapshot()))
}
reqPrep := fake.reqsSnapshot()[0]
if reqPrep.Metadata["strict_output"] != "true" {
t.Fatalf("strict_output metadata lost in prepared request: %+v", reqPrep.Metadata)
}
if !strings.Contains(reqPrep.Prompt, "output exactly one <attempt_completion> block") {
t.Fatalf("strict contract instruction not injected into prepared prompt:\n%s", reqPrep.Prompt)
}
var resp responsesResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
want := "<attempt_completion>\n<result>plain answer</result>\n</attempt_completion>"
if resp.OutputText != want {
t.Fatalf("output_text:\ngot %q\nwant %q", resp.OutputText, want)
}
}
func TestChatCompletionsAcceptsThinkControlFields(t *testing.T) {
fake := &fakeRunService{
events: make(chan *iop.RunEvent, 1),
}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
// Case 1: All valid think-control fields provided
reqBody := `{
"model":"client-model",
"messages":[{"role":"user","content":"hi"}],
"think": true,
"reasoning_effort": "medium",
"thinking_token_budget": 1024,
"include_reasoning": false
}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody))
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 fake.req.Input["think"] != true {
t.Fatalf("think: got %v, want true", fake.req.Input["think"])
}
if fake.req.Input["reasoning_effort"] != "medium" {
t.Fatalf("reasoning_effort: got %v, want 'medium'", fake.req.Input["reasoning_effort"])
}
budgetVal := fake.req.Input["thinking_token_budget"]
switch v := budgetVal.(type) {
case float64:
if v != 1024 {
t.Fatalf("thinking_token_budget: got %f, want 1024", v)
}
case int:
if v != 1024 {
t.Fatalf("thinking_token_budget: got %d, want 1024", v)
}
default:
t.Fatalf("thinking_token_budget: got %T, want number type", budgetVal)
}
if fake.req.Input["include_reasoning"] != false {
t.Fatalf("include_reasoning: got %v, want false", fake.req.Input["include_reasoning"])
}
// Case 2: Omitted think-control fields should NOT add keys to runInput
fake2 := &fakeRunService{
events: make(chan *iop.RunEvent, 1),
}
fake2.events <- &iop.RunEvent{Type: "complete"}
srv2 := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake2, nil)
reqBody2 := `{
"model":"client-model",
"messages":[{"role":"user","content":"hi"}]
}`
req2 := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody2))
w2 := httptest.NewRecorder()
srv2.routes().ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w2.Code, w2.Body.String())
}
for _, key := range []string{"think", "reasoning_effort", "thinking_token_budget", "include_reasoning"} {
if _, ok := fake2.req.Input[key]; ok {
t.Fatalf("expected key %q to be omitted from runInput, but it was present", key)
}
}
}
func TestChatCompletionsRejectsInvalidThinkControlFields(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
cases := []struct {
name string
body string
}{
{"negative budget", `{"model":"m","messages":[{"role":"user","content":"hi"}],"thinking_token_budget":-1}`},
{"bad effort", `{"model":"m","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"super_high"}`},
{"empty effort", `{"model":"m","messages":[{"role":"user","content":"hi"}],"reasoning_effort":""}`},
{"think=false and reasoning_effort other than none", `{"model":"m","messages":[{"role":"user","content":"hi"}],"think":false,"reasoning_effort":"high"}`},
{"thinking_token_budget conflicts with think=false", `{"model":"m","messages":[{"role":"user","content":"hi"}],"think":false,"thinking_token_budget":100}`},
{"thinking_token_budget conflicts with reasoning_effort=none", `{"model":"m","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none","thinking_token_budget":100}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(tc.body))
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
}
})
}
}
func TestChatCompletionsRejectsNumCtxOptionsWrapper(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"from-request",
"messages":[{"role":"user","content":"hi"}],
"options":{"num_ctx":8192}
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
}
}