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\ndone\n\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 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 := "\nplain answer\n" 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()) } }