Ctrl K
ComunidadGuías…

internal/llm/fallback_language_model_provider_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package llm

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"strings"
	"testing"
	"time"

	"github.com/yeomyeonggeori/blueclaw/internal/capability"
)

type correctableStructuredOutputError struct {
	Code                string
	AllowLegacyFallback bool
	Diagnostic          StructuredOutputDiagnostic
}

func (errorValue correctableStructuredOutputError) Error() string {
	return errorValue.Code
}

func (errorValue correctableStructuredOutputError) StructuredOutputCorrection() (StructuredOutputCorrection, bool) {
	if errorValue.AllowLegacyFallback {
		return StructuredOutputCorrection{}, false
	}
	return StructuredOutputCorrection{Code: errorValue.Code, Diagnostic: errorValue.Diagnostic}, true
}

func (errorValue correctableStructuredOutputError) StructuredOutputDiagnostic() (StructuredOutputDiagnostic, bool) {
	return errorValue.Diagnostic, errorValue.Diagnostic.Category != ""
}

type staticLanguageModelProvider struct {
	response                StructuredResponse
	error                   error
	responseCalls           *int
	structuredResponseCalls *int
}

type recoveryChatLanguageModelProvider struct {
	response   ChatCompletionResponse
	error      error
	chatCalls  *int
	localCalls *int
	localReply ChatCompletionResponse
	localError error
}

func (provider recoveryChatLanguageModelProvider) GenerateResponse(context.Context, string) (string, error) {
	return "", provider.error
}

func (provider recoveryChatLanguageModelProvider) GenerateStructuredResponse(context.Context, StructuredResponseRequest) (StructuredResponse, error) {
	return StructuredResponse{}, provider.error
}

func (provider recoveryChatLanguageModelProvider) GenerateRecoveryChatCompletion(context.Context, ChatCompletionRequest) (ChatCompletionResponse, error) {
	if provider.chatCalls != nil {
		(*provider.chatCalls)++
	}
	return provider.response, provider.error
}

func (provider recoveryChatLanguageModelProvider) GenerateLocalRecoveryChatCompletion(context.Context, ChatCompletionRequest) (ChatCompletionResponse, error) {
	if provider.localCalls != nil {
		(*provider.localCalls)++
	}
	return provider.localReply, provider.localError
}

func (staticLanguageModelProvider staticLanguageModelProvider) GenerateResponse(responseContext context.Context, prompt string) (string, error) {
	_ = responseContext
	_ = prompt
	if staticLanguageModelProvider.responseCalls != nil {
		(*staticLanguageModelProvider.responseCalls)++
	}
	if staticLanguageModelProvider.error != nil {
		return "", staticLanguageModelProvider.error
	}

	return staticLanguageModelProvider.response.Content, nil
}

func (staticLanguageModelProvider staticLanguageModelProvider) GenerateStructuredResponse(responseContext context.Context, structuredResponseRequest StructuredResponseRequest) (StructuredResponse, error) {
	_ = responseContext
	_ = structuredResponseRequest
	if staticLanguageModelProvider.structuredResponseCalls != nil {
		(*staticLanguageModelProvider.structuredResponseCalls)++
	}
	if staticLanguageModelProvider.error != nil {
		return StructuredResponse{}, staticLanguageModelProvider.error
	}

	return staticLanguageModelProvider.response, nil
}

func TestFallbackLanguageModelProviderUsesFallbackAfterPrimaryFailure(t *testing.T) {
	fallbackLanguageModelProvider := FallbackLanguageModelProvider{
		PrimaryProvider: staticLanguageModelProvider{
			error: errors.New("primary failed"),
		},
		FallbackProvider: staticLanguageModelProvider{
			response: StructuredResponse{
				ProviderName: "litert-lm",
				ModelName:    "/models/gemma-4-E4B-it.litertlm",
				Content:      `{"answer":"fallback"}`,
			},
		},
	}

	structuredResponse, errorValue := fallbackLanguageModelProvider.GenerateStructuredResponse(
		context.Background(),
		StructuredResponseRequest{
			Messages: []Message{
				{
					Role:    "user",
					Content: "hello",
				},
			},
			StructuredOutputSchema: StructuredOutputSchema{
				Name:               "response",
				Document:           `{"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"],"additionalProperties":false}`,
				IsStrictlyEnforced: true,
			},
		},
	)
	if errorValue != nil {
		t.Fatalf("expected fallback provider to succeed: %v", errorValue)
	}
	if !structuredResponse.UsedFallback {
		t.Fatal("expected fallback provider to mark used fallback")
	}
	if structuredResponse.ProviderName != "litert-lm" {
		t.Fatalf("expected fallback provider name, got %q", structuredResponse.ProviderName)
	}
}

func TestFallbackLanguageModelProviderReturnsCorrectableStructuredErrorWithoutFallback(t *testing.T) {
	var fallbackCalls int
	primaryError := correctableStructuredOutputError{
		Code: "structured_output_invalid",
		Diagnostic: StructuredOutputDiagnostic{
			Category: StructuredOutputDiagnosticSchemaValidation,
			ValidationIssues: []StructuredOutputValidationIssue{{
				FieldPath: "/title",
				Code:      StructuredOutputValidationRequired,
			}},
		},
	}
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: staticLanguageModelProvider{error: primaryError},
		FallbackProvider: staticLanguageModelProvider{
			response:                StructuredResponse{Content: "fallback"},
			structuredResponseCalls: &fallbackCalls,
		},
	}

	response, errorValue := provider.GenerateStructuredResponse(context.Background(), StructuredResponseRequest{})

	var returnedError correctableStructuredOutputError
	if response.Content != "" || !errors.As(errorValue, &returnedError) || returnedError.Code != primaryError.Code {
		t.Fatalf("expected original correctable error, got %#v and %v", response, errorValue)
	}
	if fallbackCalls != 0 {
		t.Fatalf("expected caller correction before tier fallback, got %d fallback calls", fallbackCalls)
	}
}

func TestFallbackLanguageModelProviderUsesFallbackForEmptyCompletion(t *testing.T) {
	var fallbackCalls int
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: staticLanguageModelProvider{error: correctableStructuredOutputError{
			Code: "structured_output_invalid",
			Diagnostic: StructuredOutputDiagnostic{
				Category:     StructuredOutputDiagnosticEmptyCompletion,
				FinishReason: StructuredOutputDiagnosticFinishStop,
			},
		}},
		FallbackProvider: staticLanguageModelProvider{
			response:                StructuredResponse{Content: "fallback"},
			structuredResponseCalls: &fallbackCalls,
		},
	}

	response, errorValue := provider.GenerateStructuredResponse(context.Background(), StructuredResponseRequest{})

	if errorValue != nil || response.Content != "fallback" || !response.UsedFallback {
		t.Fatalf("expected an empty completion to fall back to the next tier, got %#v and %v", response, errorValue)
	}
	if fallbackCalls != 1 {
		t.Fatalf("expected one fallback call, got %d", fallbackCalls)
	}
}

func TestFallbackLanguageModelProviderUsesFallbackForNonCorrectableLegacyError(t *testing.T) {
	var fallbackCalls int
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: staticLanguageModelProvider{error: correctableStructuredOutputError{
			Code:                "structured_output_invalid",
			AllowLegacyFallback: true,
			Diagnostic: StructuredOutputDiagnostic{
				Category: StructuredOutputDiagnosticSchemaValidation,
			},
		}},
		FallbackProvider: staticLanguageModelProvider{
			response:                StructuredResponse{Content: "fallback"},
			structuredResponseCalls: &fallbackCalls,
		},
	}

	response, errorValue := provider.GenerateStructuredResponse(context.Background(), StructuredResponseRequest{})

	if errorValue != nil || response.Content != "fallback" || !response.UsedFallback {
		t.Fatalf("expected existing non-correctable fallback semantics, got %#v and %v", response, errorValue)
	}
	if fallbackCalls != 1 {
		t.Fatalf("expected one fallback call, got %d", fallbackCalls)
	}
}

func TestFallbackLanguageModelProviderUsesRecoveryChatFallbackAfterPrimaryFailure(t *testing.T) {
	primaryCalls := 0
	fallbackCalls := 0
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: recoveryChatLanguageModelProvider{
			error:     errors.New("primary chat failed"),
			chatCalls: &primaryCalls,
		},
		FallbackProvider: recoveryChatLanguageModelProvider{
			response: ChatCompletionResponse{
				FinishReason: "stop",
				Message:      ChatCompletionMessage{Role: "assistant", Content: "fallback recovery chat"},
			},
			chatCalls: &fallbackCalls,
		},
	}

	response, errorValue := provider.GenerateRecoveryChatCompletion(context.Background(), ChatCompletionRequest{})
	if errorValue != nil || response.Message.Content != "fallback recovery chat" {
		t.Fatalf("expected recovery chat fallback, got %+v, %v", response, errorValue)
	}
	if !response.UsedFallback || primaryCalls != 1 || fallbackCalls != 1 {
		t.Fatalf("expected one primary and fallback call with fallback marker, got response=%+v primary=%d fallback=%d", response, primaryCalls, fallbackCalls)
	}
}

func TestFallbackLanguageModelProviderUsesRecoveryChatFallbackAfterBlankPrimarySuccess(t *testing.T) {
	var logBuffer bytes.Buffer
	primaryCalls := 0
	fallbackCalls := 0
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: recoveryChatLanguageModelProvider{
			response: ChatCompletionResponse{
				FinishReason: "stop",
				Message:      ChatCompletionMessage{Role: "assistant"},
			},
			chatCalls: &primaryCalls,
		},
		FallbackProvider: recoveryChatLanguageModelProvider{
			response: ChatCompletionResponse{
				FinishReason: "stop",
				Message:      ChatCompletionMessage{Role: "assistant", Content: "fallback recovery chat"},
			},
			chatCalls: &fallbackCalls,
		},
		Logger: slog.New(slog.NewTextHandler(&logBuffer, nil)),
	}

	response, errorValue := provider.GenerateRecoveryChatCompletion(context.Background(), ChatCompletionRequest{})
	if errorValue != nil || response.Message.Content != "fallback recovery chat" {
		t.Fatalf("expected blank primary success to use fallback, got %+v, %v", response, errorValue)
	}
	if !response.UsedFallback || primaryCalls != 1 || fallbackCalls != 1 {
		t.Fatalf("expected one primary and fallback call, got response=%+v primary=%d fallback=%d", response, primaryCalls, fallbackCalls)
	}
	if !strings.Contains(logBuffer.String(), "recovery chat completion is empty") {
		t.Fatalf("expected semantic failure log, got %q", logBuffer.String())
	}
}

func TestFallbackLanguageModelProviderLogsNilErrorSafely(t *testing.T) {
	var logBuffer bytes.Buffer
	provider := FallbackLanguageModelProvider{Logger: slog.New(slog.NewTextHandler(&logBuffer, nil))}

	provider.logFallback("recovery_chat", nil)

	if !strings.Contains(logBuffer.String(), `error="unknown error"`) {
		t.Fatalf("expected nil error to be logged safely, got %q", logBuffer.String())
	}
}

func TestFallbackLanguageModelProviderDoesNotUseRecoveryChatFallbackAfterCancellation(t *testing.T) {
	responseContext, cancel := context.WithCancel(context.Background())
	cancel()
	primaryCalls := 0
	fallbackCalls := 0
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: recoveryChatLanguageModelProvider{
			error:     context.Canceled,
			chatCalls: &primaryCalls,
		},
		FallbackProvider: recoveryChatLanguageModelProvider{
			response: ChatCompletionResponse{
				FinishReason: "stop",
				Message:      ChatCompletionMessage{Role: "assistant", Content: "fallback recovery chat"},
			},
			chatCalls: &fallbackCalls,
		},
	}

	response, errorValue := provider.GenerateRecoveryChatCompletion(responseContext, ChatCompletionRequest{})
	if response.Message.Content != "" || !errors.Is(errorValue, context.Canceled) {
		t.Fatalf("expected canceled recovery chat without fallback, got %+v and %v", response, errorValue)
	}
	if primaryCalls != 1 || fallbackCalls != 0 {
		t.Fatalf("expected only primary recovery chat call, got primary=%d fallback=%d", primaryCalls, fallbackCalls)
	}
}

func TestFallbackLanguageModelProviderLogsAndDescendsThroughTiers(t *testing.T) {
	var logBuffer bytes.Buffer
	logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{Level: slog.LevelWarn}))

	chain := FallbackLanguageModelProvider{
		PrimaryProvider: staticLanguageModelProvider{error: errors.New("high tier unavailable")},
		FallbackProvider: FallbackLanguageModelProvider{
			PrimaryProvider:  staticLanguageModelProvider{error: errors.New("medium tier unavailable")},
			FallbackProvider: staticLanguageModelProvider{response: StructuredResponse{ModelName: "low-model"}},
			PrimaryLabel:     "medium",
			FallbackLabel:    "low",
			Logger:           logger,
		},
		PrimaryLabel:  "high",
		FallbackLabel: "medium",
		Logger:        logger,
	}

	structuredResponse, errorValue := chain.GenerateStructuredResponse(context.Background(), StructuredResponseRequest{})
	if errorValue != nil {
		t.Fatalf("expected descent to reach a working tier: %v", errorValue)
	}
	if structuredResponse.ModelName != "low-model" {
		t.Fatalf("expected lowest tier to answer, got %q", structuredResponse.ModelName)
	}
	if !structuredResponse.UsedFallback {
		t.Fatal("expected response to be marked as used fallback")
	}

	logOutput := logBuffer.String()
	if !strings.Contains(logOutput, "failedTier=high") || !strings.Contains(logOutput, "failedTier=medium") {
		t.Fatalf("expected a log line per failed tier, got: %s", logOutput)
	}
}

func TestFallbackLanguageModelProviderDoesNotFallbackAfterCancellation(t *testing.T) {
	responseContext, cancel := context.WithCancel(context.Background())
	cancel()
	var responseCalls int
	var structuredResponseCalls int
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: staticLanguageModelProvider{error: context.Canceled},
		FallbackProvider: staticLanguageModelProvider{
			response:                StructuredResponse{Content: "fallback"},
			responseCalls:           &responseCalls,
			structuredResponseCalls: &structuredResponseCalls,
		},
	}

	response, errorValue := provider.GenerateResponse(responseContext, "hello")
	if response != "" || !errors.Is(errorValue, context.Canceled) {
		t.Fatalf("expected cancellation without fallback response, got %q and %v", response, errorValue)
	}
	structuredResponse, errorValue := provider.GenerateStructuredResponse(responseContext, StructuredResponseRequest{})
	if structuredResponse.Content != "" || !errors.Is(errorValue, context.Canceled) {
		t.Fatalf("expected cancellation without fallback structured response, got %#v and %v", structuredResponse, errorValue)
	}
	if responseCalls != 0 || structuredResponseCalls != 0 {
		t.Fatalf("expected no fallback calls after cancellation, got response=%d structured=%d", responseCalls, structuredResponseCalls)
	}
}

func TestFallbackLanguageModelProviderDoesNotFallbackAfterDeadline(t *testing.T) {
	responseContext, cancel := context.WithTimeout(context.Background(), 0)
	defer cancel()
	var responseCalls int
	var structuredResponseCalls int
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: staticLanguageModelProvider{error: context.DeadlineExceeded},
		FallbackProvider: staticLanguageModelProvider{
			response:                StructuredResponse{Content: "fallback"},
			responseCalls:           &responseCalls,
			structuredResponseCalls: &structuredResponseCalls,
		},
	}

	response, errorValue := provider.GenerateResponse(responseContext, "hello")
	if response != "" || !errors.Is(errorValue, context.DeadlineExceeded) {
		t.Fatalf("expected deadline without fallback response, got %q and %v", response, errorValue)
	}
	structuredResponse, errorValue := provider.GenerateStructuredResponse(responseContext, StructuredResponseRequest{})
	if structuredResponse.Content != "" || !errors.Is(errorValue, context.DeadlineExceeded) {
		t.Fatalf("expected deadline without fallback structured response, got %#v and %v", structuredResponse, errorValue)
	}
	if responseCalls != 0 || structuredResponseCalls != 0 {
		t.Fatalf("expected no fallback calls after deadline, got response=%d structured=%d", responseCalls, structuredResponseCalls)
	}
}

type chatLanguageModelProvider struct {
	response  ChatCompletionResponse
	error     error
	chatCalls *int
}

func (provider chatLanguageModelProvider) GenerateResponse(context.Context, string) (string, error) {
	return "", provider.error
}

func (provider chatLanguageModelProvider) GenerateStructuredResponse(context.Context, StructuredResponseRequest) (StructuredResponse, error) {
	return StructuredResponse{}, provider.error
}

func (provider chatLanguageModelProvider) GenerateChatCompletion(context.Context, ChatCompletionRequest) (ChatCompletionResponse, error) {
	if provider.chatCalls != nil {
		(*provider.chatCalls)++
	}
	return provider.response, provider.error
}

func TestFallbackLanguageModelProviderUsesChatFallbackAfterPrimaryFailure(t *testing.T) {
	primaryCalls := 0
	fallbackCalls := 0
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: chatLanguageModelProvider{
			error:     errors.New("chat finish reason contract violated"),
			chatCalls: &primaryCalls,
		},
		FallbackProvider: chatLanguageModelProvider{
			response: ChatCompletionResponse{
				FinishReason: "tool_calls",
				Message:      ChatCompletionMessage{Role: "assistant", Content: "fallback chat"},
			},
			chatCalls: &fallbackCalls,
		},
	}

	response, errorValue := provider.GenerateChatCompletion(context.Background(), ChatCompletionRequest{})
	if errorValue != nil || response.Message.Content != "fallback chat" {
		t.Fatalf("expected chat fallback, got %+v, %v", response, errorValue)
	}
	if !response.UsedFallback || primaryCalls != 1 || fallbackCalls != 1 {
		t.Fatalf("expected one primary and one fallback chat call, got primary=%d fallback=%d response=%+v", primaryCalls, fallbackCalls, response)
	}
	if response.FallbackReason != "chat finish reason contract violated" {
		t.Fatalf("expected the primary failure reason on the fallback response, got %q", response.FallbackReason)
	}
}

func TestFallbackLanguageModelProviderDoesNotUseChatFallbackAfterCancellation(t *testing.T) {
	primaryCalls := 0
	fallbackCalls := 0
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: chatLanguageModelProvider{
			error:     context.Canceled,
			chatCalls: &primaryCalls,
		},
		FallbackProvider: chatLanguageModelProvider{
			response:  ChatCompletionResponse{Message: ChatCompletionMessage{Role: "assistant", Content: "fallback chat"}},
			chatCalls: &fallbackCalls,
		},
	}
	cancelledContext, cancel := context.WithCancel(context.Background())
	cancel()

	_, errorValue := provider.GenerateChatCompletion(cancelledContext, ChatCompletionRequest{})
	if errorValue == nil || fallbackCalls != 0 {
		t.Fatalf("expected cancellation to stop without fallback, got error=%v fallbackCalls=%d", errorValue, fallbackCalls)
	}
}

func TestFallbackLanguageModelProviderLaddersAfterProviderCallDeadline(t *testing.T) {
	primaryCalls := 0
	fallbackCalls := 0
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: chatLanguageModelProvider{
			error:     context.DeadlineExceeded,
			chatCalls: &primaryCalls,
		},
		FallbackProvider: chatLanguageModelProvider{
			response: ChatCompletionResponse{
				FinishReason: "stop",
				Message:      ChatCompletionMessage{Role: "assistant", Content: "escalated chat"},
			},
			chatCalls: &fallbackCalls,
		},
	}

	response, errorValue := provider.GenerateChatCompletion(context.Background(), ChatCompletionRequest{})
	if errorValue != nil || response.Message.Content != "escalated chat" {
		t.Fatalf("expected provider call deadline to escalate to fallback tier, got %+v, %v", response, errorValue)
	}
	if !response.UsedFallback || primaryCalls != 1 || fallbackCalls != 1 {
		t.Fatalf("expected one primary and one fallback call, got primary=%d fallback=%d", primaryCalls, fallbackCalls)
	}
}

func TestFallbackLanguageModelProviderStopsWhenCallerBudgetExpired(t *testing.T) {
	fallbackCalls := 0
	provider := FallbackLanguageModelProvider{
		PrimaryProvider: chatLanguageModelProvider{error: context.DeadlineExceeded},
		FallbackProvider: chatLanguageModelProvider{
			response:  ChatCompletionResponse{Message: ChatCompletionMessage{Role: "assistant", Content: "escalated chat"}},
			chatCalls: &fallbackCalls,
		},
	}
	expiredContext, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
	defer cancel()

	_, errorValue := provider.GenerateChatCompletion(expiredContext, ChatCompletionRequest{})
	if errorValue == nil || fallbackCalls != 0 {
		t.Fatalf("expected expired caller budget to stop without fallback, got error=%v fallbackCalls=%d", errorValue, fallbackCalls)
	}
}

func TestAChatCapabilitydAnswersAsAProviderFailureFallsBackToTheNextModel(t *testing.T) {
	requestedModels := []string{}
	httpClient := fakeCapabilityHTTPClient{handler: func(request *http.Request) (*http.Response, error) {
		var document struct {
			Model string `json:"model"`
		}
		if errorValue := json.NewDecoder(request.Body).Decode(&document); errorValue != nil {
			t.Fatalf("expected a chat request document: %v", errorValue)
		}
		requestedModels = append(requestedModels, document.Model)
		if document.Model == "primary-model" {
			return jsonCapabilityResponse(http.StatusBadGateway, "openrouter provider Wafer ended the primary-model completion with finish_reason=error"), nil
		}
		return jsonCapabilityResponse(http.StatusOK, `{"finishReason":"stop","provider":"capabilityLLM","model":"degraded-model","message":{"role":"assistant","content":"degraded answer"}}`), nil
	}}
	capabilityClient := capability.Client{Endpoint: "http://internkim-capability", HTTPClient: httpClient}
	provider := FallbackLanguageModelProvider{
		PrimaryProvider:  CapabilityLLMClient{CapabilityClient: capabilityClient, ModelName: "primary-model", ExecutionMode: "remote"},
		FallbackProvider: CapabilityLLMClient{CapabilityClient: capabilityClient, ModelName: "degraded-model", ExecutionMode: "remote"},
	}

	response, errorValue := provider.GenerateChatCompletion(context.Background(), ChatCompletionRequest{
		Messages: []ChatCompletionMessage{{Role: "user", Content: "check"}},
	})

	if errorValue != nil || response.Message.Content != "degraded answer" || !response.UsedFallback {
		t.Fatalf("expected the degraded model to answer after the provider failed, got %+v %v", response, errorValue)
	}
	if len(requestedModels) != 2 || requestedModels[0] != "primary-model" || requestedModels[1] != "degraded-model" {
		t.Fatalf("expected one primary ask and one degraded ask, got %v", requestedModels)
	}
	if !strings.Contains(response.FallbackReason, "Wafer") {
		t.Fatalf("expected the provider's failure carried as the fallback reason, got %q", response.FallbackReason)
	}
}