2026-08-09 22:47:43 +02:00
|
|
|
package processing
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-08-16 16:57:36 +02:00
|
|
|
"encoding/json"
|
2026-08-16 18:59:29 +02:00
|
|
|
"fmt"
|
2026-08-09 22:47:43 +02:00
|
|
|
"net/http"
|
2026-08-16 16:57:36 +02:00
|
|
|
"net/http/httptest"
|
2026-08-09 22:47:43 +02:00
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-16 16:57:36 +02:00
|
|
|
func TestHeuristicCompleter_thinProductInventFromAttrs(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
h := HeuristicCompleter{}
|
|
|
|
|
system := `Retail product copywriter. Schema: {"name":"string","description":"string"}`
|
|
|
|
|
user := "Category: Monitors\nName: UltraView 27\nDesc: UltraView 27\nAttrs: {\"brand\":\"Acme\",\"size\":\"27 inch\",\"panel\":\"IPS\"}"
|
|
|
|
|
comp, err := h.Complete(context.Background(), system, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var out map[string]string
|
|
|
|
|
if err := json.Unmarshal([]byte(comp.Text), &out); err != nil {
|
|
|
|
|
t.Fatalf("json: %v text=%q", err, comp.Text)
|
|
|
|
|
}
|
|
|
|
|
if out["name"] != "UltraView 27" {
|
|
|
|
|
t.Fatalf("name=%q", out["name"])
|
|
|
|
|
}
|
|
|
|
|
desc := out["description"]
|
|
|
|
|
if desc == "" || strings.EqualFold(desc, "UltraView 27") || desc == "Product description" {
|
|
|
|
|
t.Fatalf("desc=%q want invented non-title copy", desc)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(strings.ToLower(desc), "acme") && !strings.Contains(strings.ToLower(desc), "ips") && !strings.Contains(strings.ToLower(desc), "27") {
|
|
|
|
|
t.Fatalf("desc=%q want attrs-derived facts", desc)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
func TestNewOpenAIClient_capsRetries(t *testing.T) {
|
|
|
|
|
c := NewOpenAIClient("k", "https://api.openai.com/v1", "m", 0, 99)
|
|
|
|
|
if c.MaxRetries != maxOpenAIRetries {
|
|
|
|
|
t.Fatalf("MaxRetries=%d want %d", c.MaxRetries, maxOpenAIRetries)
|
|
|
|
|
}
|
|
|
|
|
c2 := NewOpenAIClient("k", "", "m", 0, 0)
|
|
|
|
|
if c2.MaxRetries != 3 {
|
|
|
|
|
t.Fatalf("default MaxRetries=%d want 3", c2.MaxRetries)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNewOpenAIClient_blocksPrivateDial(t *testing.T) {
|
|
|
|
|
c := NewOpenAIClient("k", "https://api.openai.com/v1", "m", 0, 1)
|
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:9/", nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
|
_, err = c.HTTPClient.Do(req)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected dial to private/loopback blocked")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestOpenAIBaseAllowsLoopback(t *testing.T) {
|
|
|
|
|
if !openAIBaseAllowsLoopback("http://localhost:11434/v1") {
|
|
|
|
|
t.Fatal("expected localhost allowed")
|
|
|
|
|
}
|
|
|
|
|
if !openAIBaseAllowsLoopback("http://127.0.0.1:11434/v1") {
|
|
|
|
|
t.Fatal("expected 127.0.0.1 allowed")
|
|
|
|
|
}
|
|
|
|
|
if openAIBaseAllowsLoopback("https://api.openai.com/v1") {
|
|
|
|
|
t.Fatal("expected public host denied for loopback flag")
|
|
|
|
|
}
|
|
|
|
|
if openAIBaseAllowsLoopback("https://192.168.1.1/v1") {
|
|
|
|
|
t.Fatal("expected private IP denied")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestOpenAIBaseAllowsPrivateNonProd(t *testing.T) {
|
|
|
|
|
t.Setenv("APP_ENV", "local")
|
|
|
|
|
if !openAIBaseAllowsPrivate("http://192.168.50.181:8767/v1") {
|
|
|
|
|
t.Fatal("expected LAN proxy allowed in local")
|
|
|
|
|
}
|
|
|
|
|
if !openAIDialPolicy("http://192.168.50.181:8767/v1").AllowPrivate {
|
|
|
|
|
t.Fatal("expected dial policy AllowPrivate")
|
|
|
|
|
}
|
|
|
|
|
t.Setenv("APP_ENV", "production")
|
|
|
|
|
if openAIBaseAllowsPrivate("http://192.168.50.181:8767/v1") {
|
|
|
|
|
t.Fatal("expected LAN proxy blocked in production")
|
|
|
|
|
}
|
|
|
|
|
t.Setenv("APP_ENV", "local")
|
|
|
|
|
if openAIBaseAllowsPrivate("http://169.254.169.254/v1") {
|
|
|
|
|
t.Fatal("expected link-local metadata blocked")
|
|
|
|
|
}
|
|
|
|
|
if openAIBaseAllowsPrivate("https://api.openai.com/v1") {
|
|
|
|
|
t.Fatal("expected public host not private-allowed")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNewOpenAIClient_allowsPrivateDialNonProd(t *testing.T) {
|
|
|
|
|
t.Setenv("APP_ENV", "development")
|
|
|
|
|
c := NewOpenAIClient("k", "http://192.168.50.181:8767/v1", "m", 0, 1)
|
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://192.168.50.181:9/", nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
|
_, err = c.HTTPClient.Do(req)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected connection error, not success")
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(err.Error(), "host is not allowed") {
|
|
|
|
|
t.Fatalf("SSRF blocked LAN OpenAI base unexpectedly: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNewOpenAIClient_blocksPrivateDialInProduction(t *testing.T) {
|
|
|
|
|
t.Setenv("APP_ENV", "production")
|
|
|
|
|
c := NewOpenAIClient("k", "http://192.168.50.181:8767/v1", "m", 0, 1)
|
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://192.168.50.181:9/", nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
|
_, err = c.HTTPClient.Do(req)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected dial blocked in production")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "host is not allowed") {
|
|
|
|
|
t.Fatalf("expected host is not allowed, got: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-16 16:57:36 +02:00
|
|
|
|
|
|
|
|
func TestChoiceMessageText_prefersContentThenReasoningJSON(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
if got := choiceMessageText(json.RawMessage(`"{\"name\":\"A\",\"description\":\"B\"}"`), "", ""); got == "" {
|
|
|
|
|
t.Fatal("expected content string")
|
|
|
|
|
}
|
|
|
|
|
reasoning := `Thinking…\nDraft JSON:\n{"name":"NOSILEC W53070","description":"Stenski nosilec za TV."}\nVerify…`
|
|
|
|
|
got := choiceMessageText(json.RawMessage(`""`), reasoning, "")
|
|
|
|
|
if !strings.Contains(got, `"name"`) || !strings.Contains(got, "NOSILEC") {
|
|
|
|
|
t.Fatalf("got=%q want JSON from reasoning_content", got)
|
|
|
|
|
}
|
|
|
|
|
if got := choiceMessageText(json.RawMessage(`null`), "no json here", ""); got != "" {
|
|
|
|
|
t.Fatalf("expected empty without JSON, got %q", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestOpenAIClient_doComplete_emptyContentWithReasoningJSON(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"model": "code-fast",
|
|
|
|
|
"choices": []map[string]any{{
|
|
|
|
|
"finish_reason": "length",
|
|
|
|
|
"message": map[string]any{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": "",
|
|
|
|
|
"reasoning_content": `steps… {"name":"TV Mount","description":"A wall mount for TVs."} more`,
|
|
|
|
|
},
|
|
|
|
|
}},
|
|
|
|
|
"usage": map[string]int{"prompt_tokens": 10, "completion_tokens": 50, "total_tokens": 60},
|
|
|
|
|
})
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
c := NewOpenAIClient("test-key", srv.URL, "code-fast", 0, 1)
|
|
|
|
|
c.HTTPClient = srv.Client()
|
|
|
|
|
comp, err := c.CompleteWithOptions(context.Background(), "sys", "user", CompleteOptions{MaxTokens: 350, Temperature: 0.2})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(comp.Text, "TV Mount") {
|
|
|
|
|
t.Fatalf("text=%q", comp.Text)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 12:40:59 +02:00
|
|
|
func TestOpenAIClient_doComplete_gpt56LunaUsesMaxCompletionTokens(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req map[string]any
|
|
|
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
2026-08-23 12:56:19 +02:00
|
|
|
msgs, _ := req["messages"].([]any)
|
|
|
|
|
if len(msgs) < 1 {
|
|
|
|
|
t.Errorf("expected messages")
|
|
|
|
|
} else if m0, ok := msgs[0].(map[string]any); ok {
|
|
|
|
|
if m0["role"] != "developer" {
|
|
|
|
|
t.Errorf("system role=%v want developer for gpt-5.6", m0["role"])
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-23 12:40:59 +02:00
|
|
|
if _, ok := req["temperature"]; ok {
|
|
|
|
|
t.Errorf("temperature must be omitted for gpt-5.6-luna")
|
|
|
|
|
}
|
|
|
|
|
if _, ok := req["max_tokens"]; ok {
|
|
|
|
|
t.Errorf("max_tokens must be omitted for gpt-5.6-luna")
|
|
|
|
|
}
|
|
|
|
|
gotMax, _ := req["max_completion_tokens"].(float64)
|
|
|
|
|
if int(gotMax) != MaxTokensEnhance {
|
|
|
|
|
t.Errorf("max_completion_tokens=%v want %d", gotMax, MaxTokensEnhance)
|
|
|
|
|
}
|
|
|
|
|
if req["reasoning_effort"] != "low" {
|
|
|
|
|
t.Errorf("reasoning_effort=%v want low", req["reasoning_effort"])
|
|
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"model": "gpt-5.6-luna",
|
|
|
|
|
"choices": []map[string]any{{
|
|
|
|
|
"finish_reason": "stop",
|
|
|
|
|
"message": map[string]any{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": `{"name":"Acme Monitor","description":"<h1>Acme Monitor</h1><p>IPS panel for office use.</p>"}`,
|
|
|
|
|
},
|
|
|
|
|
}},
|
|
|
|
|
"usage": map[string]int{"prompt_tokens": 20, "completion_tokens": 40, "total_tokens": 60},
|
|
|
|
|
})
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
c := NewOpenAIClient("test-key", srv.URL, "gpt-5.6-luna", 0, 1)
|
|
|
|
|
c.HTTPClient = srv.Client()
|
|
|
|
|
comp, err := c.CompleteWithOptions(context.Background(), "sys", "user", CompleteOptions{
|
|
|
|
|
MaxTokens: MaxTokensEnhance,
|
|
|
|
|
Temperature: DefaultStructuredTemp,
|
|
|
|
|
ReasoningEffort: "low",
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(comp.Text, "Acme Monitor") {
|
|
|
|
|
t.Fatalf("text=%q", comp.Text)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 16:57:36 +02:00
|
|
|
func TestOpenAIClient_doComplete_emptyContentLengthRetriesWithBudget(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
calls := 0
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
calls++
|
|
|
|
|
var req map[string]any
|
|
|
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
|
maxTok, _ := req["max_tokens"].(float64)
|
|
|
|
|
if calls == 1 {
|
|
|
|
|
if maxTok != 350 {
|
|
|
|
|
t.Errorf("first max_tokens=%v want 350", maxTok)
|
|
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"model": "code-fast",
|
|
|
|
|
"choices": []map[string]any{{
|
|
|
|
|
"finish_reason": "length",
|
|
|
|
|
"message": map[string]any{"role": "assistant", "content": "", "reasoning_content": "still thinking"},
|
|
|
|
|
}},
|
|
|
|
|
"usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 350, "total_tokens": 351},
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-16 21:35:48 +02:00
|
|
|
if maxTok != float64(MaxTokensEnhanceRetry) {
|
|
|
|
|
t.Errorf("retry max_tokens=%v want %d", maxTok, MaxTokensEnhanceRetry)
|
2026-08-16 16:57:36 +02:00
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"model": "code-fast",
|
|
|
|
|
"choices": []map[string]any{{
|
|
|
|
|
"finish_reason": "stop",
|
|
|
|
|
"message": map[string]any{"role": "assistant", "content": `{"name":"X","description":"Y product description text here."}`},
|
|
|
|
|
}},
|
|
|
|
|
"usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 40, "total_tokens": 41},
|
|
|
|
|
})
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
c := NewOpenAIClient("test-key", srv.URL, "code-fast", 0, 2)
|
|
|
|
|
c.HTTPClient = srv.Client()
|
|
|
|
|
comp, err := c.CompleteWithOptions(context.Background(), "sys", "user", CompleteOptions{MaxTokens: 350, Temperature: 0.2})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if calls != 2 {
|
|
|
|
|
t.Fatalf("calls=%d want 2", calls)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(comp.Text, "\"name\"") {
|
|
|
|
|
t.Fatalf("text=%q", comp.Text)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-16 18:59:29 +02:00
|
|
|
|
2026-08-16 21:35:48 +02:00
|
|
|
func TestOpenAIClient_doComplete_truncatedJSONLengthRetriesFromEnhanceBudget(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
calls := 0
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
calls++
|
|
|
|
|
var req map[string]any
|
|
|
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
|
maxTok, _ := req["max_tokens"].(float64)
|
|
|
|
|
if calls == 1 {
|
|
|
|
|
if maxTok != float64(MaxTokensEnhance) {
|
|
|
|
|
t.Errorf("first max_tokens=%v want %d", maxTok, MaxTokensEnhance)
|
|
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"model": "code-fast",
|
|
|
|
|
"choices": []map[string]any{{
|
|
|
|
|
"finish_reason": "length",
|
|
|
|
|
"message": map[string]any{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": `{"name":"Lenovo","description":"<h1>Lenovo G27-20: Igralski monitor za vrhunsko vizualno izkušn`,
|
|
|
|
|
},
|
|
|
|
|
}},
|
|
|
|
|
"usage": map[string]int{"prompt_tokens": 200, "completion_tokens": MaxTokensEnhance, "total_tokens": 200 + MaxTokensEnhance},
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if maxTok != float64(MaxTokensEnhanceRetry) {
|
|
|
|
|
t.Errorf("retry max_tokens=%v want %d", maxTok, MaxTokensEnhanceRetry)
|
|
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"model": "code-fast",
|
|
|
|
|
"choices": []map[string]any{{
|
|
|
|
|
"finish_reason": "stop",
|
|
|
|
|
"message": map[string]any{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": `{"name":"Lenovo G27-20","description":"<h1>Lenovo G27-20</h1><p>Monitor.</p>"}`,
|
|
|
|
|
},
|
|
|
|
|
}},
|
|
|
|
|
"usage": map[string]int{"prompt_tokens": 200, "completion_tokens": 80, "total_tokens": 280},
|
|
|
|
|
})
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
c := NewOpenAIClient("test-key", srv.URL, "code-fast", 0, 2)
|
|
|
|
|
c.HTTPClient = srv.Client()
|
|
|
|
|
comp, err := c.CompleteWithOptions(context.Background(), "sys", "user", CompleteOptions{MaxTokens: MaxTokensEnhance, Temperature: 0.2})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if calls != 2 {
|
|
|
|
|
t.Fatalf("calls=%d want 2 (length truncated must not succeed)", calls)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(comp.Text, "</h1>") {
|
|
|
|
|
t.Fatalf("text=%q", comp.Text)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 18:59:29 +02:00
|
|
|
func TestOpenAIClient_Complete_timeoutNotRetried(t *testing.T) {
|
|
|
|
|
calls := 0
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
calls++
|
|
|
|
|
deadline := time.After(300 * time.Millisecond)
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-r.Context().Done():
|
|
|
|
|
return
|
|
|
|
|
case <-deadline:
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"choices": []map[string]any{
|
|
|
|
|
{"message": map[string]any{"content": "late"}},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
case <-time.After(10 * time.Millisecond):
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
c := NewOpenAIClient("test-key", srv.URL, "code-fast", 0, 3)
|
|
|
|
|
c.MaxRetries = 3
|
|
|
|
|
c.HTTPClient = &http.Client{Timeout: 60 * time.Millisecond}
|
|
|
|
|
start := time.Now()
|
|
|
|
|
_, err := c.Complete(context.Background(), "sys", "user")
|
|
|
|
|
elapsed := time.Since(start)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected timeout error")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "timed out waiting for model") {
|
|
|
|
|
t.Fatalf("want clear timeout message, got: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if calls != 1 {
|
|
|
|
|
t.Fatalf("calls=%d want 1 (timeout must not retry)", calls)
|
|
|
|
|
}
|
|
|
|
|
if elapsed > time.Second {
|
|
|
|
|
t.Fatalf("timeout too slow: %s", elapsed)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 21:35:48 +02:00
|
|
|
func TestOpenAIClient_Complete_emptyAtEnhanceRetryBudgetNotRetried(t *testing.T) {
|
2026-08-16 18:59:29 +02:00
|
|
|
t.Parallel()
|
|
|
|
|
calls := 0
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
calls++
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"model": "code-fast",
|
|
|
|
|
"choices": []map[string]any{{
|
|
|
|
|
"finish_reason": "length",
|
|
|
|
|
"message": map[string]any{"role": "assistant", "content": "", "reasoning_content": "still thinking"},
|
|
|
|
|
}},
|
2026-08-16 21:35:48 +02:00
|
|
|
"usage": map[string]int{"prompt_tokens": 1, "completion_tokens": MaxTokensEnhanceRetry, "total_tokens": 1 + MaxTokensEnhanceRetry},
|
|
|
|
|
})
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
c := NewOpenAIClient("test-key", srv.URL, "code-fast", 0, 3)
|
|
|
|
|
c.HTTPClient = srv.Client()
|
|
|
|
|
_, err := c.CompleteWithOptions(context.Background(), "sys", "user", CompleteOptions{MaxTokens: MaxTokensEnhanceRetry})
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected length-capped error")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "length-capped") {
|
|
|
|
|
t.Fatalf("err=%v", err)
|
|
|
|
|
}
|
|
|
|
|
if calls != 1 {
|
|
|
|
|
t.Fatalf("calls=%d want 1 (no retry at enhance retry ceiling)", calls)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestOpenAIClient_Complete_truncatedAtEnhanceRetryBudgetNotSuccess(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
calls := 0
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
calls++
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"model": "code-fast",
|
|
|
|
|
"choices": []map[string]any{{
|
|
|
|
|
"finish_reason": "length",
|
|
|
|
|
"message": map[string]any{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": `{"name":"X","description":"<h1>cut off`,
|
|
|
|
|
},
|
|
|
|
|
}},
|
|
|
|
|
"usage": map[string]int{"prompt_tokens": 10, "completion_tokens": MaxTokensEnhanceRetry, "total_tokens": 10 + MaxTokensEnhanceRetry},
|
2026-08-16 18:59:29 +02:00
|
|
|
})
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
c := NewOpenAIClient("test-key", srv.URL, "code-fast", 0, 3)
|
|
|
|
|
c.HTTPClient = srv.Client()
|
2026-08-16 21:35:48 +02:00
|
|
|
_, err := c.CompleteWithOptions(context.Background(), "sys", "user", CompleteOptions{MaxTokens: MaxTokensEnhanceRetry})
|
2026-08-16 18:59:29 +02:00
|
|
|
if err == nil {
|
2026-08-16 21:35:48 +02:00
|
|
|
t.Fatal("truncated JSON at ceiling must not succeed")
|
2026-08-16 18:59:29 +02:00
|
|
|
}
|
2026-08-16 21:35:48 +02:00
|
|
|
if !strings.Contains(err.Error(), "length-capped") {
|
2026-08-16 18:59:29 +02:00
|
|
|
t.Fatalf("err=%v", err)
|
|
|
|
|
}
|
|
|
|
|
if calls != 1 {
|
2026-08-16 21:35:48 +02:00
|
|
|
t.Fatalf("calls=%d want 1", calls)
|
2026-08-16 18:59:29 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIsOpenAITimeoutErr(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
if !isOpenAITimeoutErr(context.DeadlineExceeded) {
|
|
|
|
|
t.Fatal("DeadlineExceeded")
|
|
|
|
|
}
|
|
|
|
|
if !isOpenAITimeoutErr(fmt.Errorf("Get \"http://x\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)")) {
|
|
|
|
|
t.Fatal("Client.Timeout message")
|
|
|
|
|
}
|
|
|
|
|
if isOpenAITimeoutErr(fmt.Errorf("connection refused")) {
|
|
|
|
|
t.Fatal("connection refused should not be timeout")
|
|
|
|
|
}
|
|
|
|
|
}
|