Move authentication probes into the outbound adapter

This commit is contained in:
2026-11-05 14:20:04 +00:00
parent dbba1485a7
commit f745b14b71
8 changed files with 313 additions and 172 deletions

View File

@@ -1,11 +1,8 @@
package usecase
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"go.maximumdirect.net/promptkit/internal/domain"
@@ -13,11 +10,11 @@ import (
"go.maximumdirect.net/promptkit/internal/llm"
)
type authenticationProbe struct {
client smokeHTTPGetter
type smokeAuthenticationClient interface {
ProbeOpenRouterKey(context.Context, string, int, domain.ExecutionTarget) (llm.AuthenticationEvidence, error)
}
const maxAuthenticationResponseBytes = 1 << 20
type authenticationProbe struct{ client smokeAuthenticationClient }
type authenticationProbeOutcome struct {
supported bool
@@ -28,16 +25,33 @@ type authenticationProbeOutcome struct {
failure domain.ExecutionFailureKind
}
func (p authenticationProbe) run(
ctx context.Context,
backend domain.Backend,
target domain.SmokeTestTarget,
) (authenticationProbeOutcome, error) {
func (p authenticationProbe) run(ctx context.Context, backend domain.Backend, target domain.SmokeTestTarget) (authenticationProbeOutcome, error) {
outcome, err := p.support(backend)
if err != nil || !outcome.supported || (outcome.applicable != nil && !*outcome.applicable) {
return outcome, err
}
return p.runOpenRouterKey(ctx, target)
result, requestErr := p.client.ProbeOpenRouterKey(ctx, target.Endpoint, target.EffectiveSettings.TimeoutSeconds, target.EffectiveSettings)
outcome.evidence = result.HTTP
outcome.credentialsAccepted = result.Accepted
if result.Overflow {
outcome.reason = domain.SmokeTestReasonAuthenticationResponseLimitExceeded
outcome.failure = domain.ExecutionFailureOther
} else if result.Unrecognized {
outcome.reason = domain.SmokeTestReasonAuthenticationResponseUnrecognized
outcome.failure = domain.ExecutionFailureMalformedResponse
} else if requestErr != nil {
if !result.HTTP.DispatchInvoked {
return outcome, fmt.Errorf("execute authentication request: %w", requestErr)
}
outcome.failure = httpProbeFailure(ctx, requestErr)
} else if result.HTTP.ResponseReceived {
if result.HTTP.HTTPStatus != nil && *result.HTTP.HTTPStatus != http.StatusOK && *result.HTTP.HTTPStatus != http.StatusUnauthorized {
outcome.failure = generation.ClassifyFailure(llm.ErrUnexpectedStatus)
}
} else {
outcome.failure = httpProbeFailure(ctx, nil)
}
return outcome, nil
}
func (p authenticationProbe) support(backend domain.Backend) (authenticationProbeOutcome, error) {
@@ -50,11 +64,7 @@ func (p authenticationProbe) support(backend domain.Backend) (authenticationProb
}
switch *authentication.Kind {
case "none":
return authenticationProbeOutcome{
supported: true,
applicable: boolPointer(false),
reason: domain.SmokeTestReasonAuthenticationNotApplicable,
}, nil
return authenticationProbeOutcome{supported: true, applicable: boolPointer(false), reason: domain.SmokeTestReasonAuthenticationNotApplicable}, nil
case "openrouter_key":
if p.client == nil {
return authenticationProbeOutcome{reason: domain.SmokeTestReasonHTTPDiagnosticsUnavailable}, nil
@@ -64,139 +74,3 @@ func (p authenticationProbe) support(backend domain.Backend) (authenticationProb
return authenticationProbeOutcome{}, fmt.Errorf("%w: unsupported authentication probe kind %q", ErrInvalidRequest, *authentication.Kind)
}
}
func (p authenticationProbe) runOpenRouterKey(
ctx context.Context,
target domain.SmokeTestTarget,
) (authenticationProbeOutcome, error) {
endpoint, err := endpointWithoutUserInfo(target.Endpoint)
if err != nil {
return authenticationProbeOutcome{}, fmt.Errorf("prepare authentication endpoint: %w", err)
}
response, evidence, requestErr := p.client.GetWithAuthenticationEvidence(
ctx,
endpoint,
target.EffectiveSettings.TimeoutSeconds,
target.EffectiveSettings,
)
if response != nil && response.Body != nil {
defer response.Body.Close()
}
outcome := authenticationProbeOutcome{
supported: true,
applicable: boolPointer(true),
evidence: evidence,
}
if evidence.ResponseReceived || response != nil {
status := evidence.HTTPStatus
if status == nil && response != nil {
status = &response.StatusCode
}
if status == nil {
outcome.failure = domain.ExecutionFailureMalformedResponse
outcome.reason = domain.SmokeTestReasonAuthenticationResponseUnrecognized
return outcome, nil
}
switch *status {
case http.StatusUnauthorized:
outcome.credentialsAccepted = boolPointer(false)
case http.StatusOK:
if response == nil || response.Body == nil {
outcome.reason = domain.SmokeTestReasonAuthenticationResponseUnrecognized
outcome.failure = domain.ExecutionFailureMalformedResponse
return outcome, nil
}
contents, bodyErr := io.ReadAll(io.LimitReader(response.Body, maxAuthenticationResponseBytes+1))
if len(contents) > maxAuthenticationResponseBytes {
outcome.reason = domain.SmokeTestReasonAuthenticationResponseLimitExceeded
outcome.failure = domain.ExecutionFailureOther
return outcome, nil
}
if bodyErr != nil {
outcome.failure = httpProbeFailure(ctx, bodyErr)
return outcome, nil
}
recognized, bodyErr := recognizedOpenRouterKeyResponse(bytes.NewReader(contents))
if bodyErr != nil {
outcome.failure = httpProbeFailure(ctx, bodyErr)
return outcome, nil
}
if recognized {
outcome.credentialsAccepted = boolPointer(true)
} else {
outcome.reason = domain.SmokeTestReasonAuthenticationResponseUnrecognized
outcome.failure = domain.ExecutionFailureMalformedResponse
}
default:
outcome.failure = generation.ClassifyFailure(llm.ErrUnexpectedStatus)
}
return outcome, nil
}
if evidence.DispatchInvoked {
if requestErr != nil {
outcome.failure = httpProbeFailure(ctx, requestErr)
} else {
outcome.failure = httpProbeFailure(ctx, nil)
}
return outcome, nil
}
if requestErr != nil {
return outcome, fmt.Errorf("execute authentication request: %w", requestErr)
}
outcome.failure = generation.ClassifyFailure(llm.ErrTransport)
return outcome, nil
}
func recognizedOpenRouterKeyResponse(body io.Reader) (bool, error) {
contents, err := io.ReadAll(body)
if err != nil {
return false, err
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(contents, &envelope); err != nil || envelope == nil {
return false, nil
}
dataJSON, ok := envelope["data"]
if !ok || !isJSONObject(dataJSON) {
return false, nil
}
var data map[string]json.RawMessage
if err := json.Unmarshal(dataJSON, &data); err != nil || data == nil {
return false, nil
}
label, labelOK := data["label"]
freeTier, freeTierOK := data["is_free_tier"]
usage, usageOK := data["usage"]
if !labelOK || !isJSONString(label) || !freeTierOK || !isJSONBoolean(freeTier) || !usageOK {
return false, nil
}
var number any
usageDecoder := json.NewDecoder(bytes.NewReader(usage))
usageDecoder.UseNumber()
if usageDecoder.Decode(&number) != nil {
return false, nil
}
_, ok = number.(json.Number)
return ok, nil
}
func isJSONObject(value json.RawMessage) bool {
value = bytes.TrimSpace(value)
return len(value) > 0 && value[0] == '{'
}
func isJSONString(value json.RawMessage) bool {
value = bytes.TrimSpace(value)
if len(value) == 0 || value[0] != '"' {
return false
}
var parsed string
return json.Unmarshal(value, &parsed) == nil
}
func isJSONBoolean(value json.RawMessage) bool {
value = bytes.TrimSpace(value)
return bytes.Equal(value, []byte("true")) || bytes.Equal(value, []byte("false"))
}

View File

@@ -26,6 +26,7 @@ func (b *measuredAuthenticationBody) Read(p []byte) (int, error) {
func (b *measuredAuthenticationBody) Close() error { b.closed = true; return nil }
func TestAuthenticationResponseRecognitionBound(t *testing.T) {
const maxAuthenticationResponseBytes = 1 << 20
valid := `{"data":{"label":"member","is_free_tier":false,"usage":0}}`
for _, test := range []struct {
name string
@@ -211,21 +212,26 @@ func TestAuthenticationProbeInterpretsOpenRouterResponses(t *testing.T) {
}
func TestAuthenticationProbeRecognizesOnlyOneCompleteJSONObject(t *testing.T) {
for _, body := range []string{
`{"data":{"label":"member","is_free_tier":false,"usage":1}} {}`,
`{"data":[]}`,
`{"data":{"label":"member","is_free_tier":false,"usage":null}}`,
`{"data":{"label":"member","is_free_tier":0,"usage":1}}`,
`{"data":{"is_free_tier":false,"usage":1}}`,
`{"data":{"label":null,"is_free_tier":false,"usage":1}}`,
for _, tc := range []struct {
body string
accepted bool
}{
{`{"data":{"label":"member","is_free_tier":false,"usage":1}} {}`, false},
{`{"data":[]}`, false},
{`{"data":{"label":"member","is_free_tier":false,"usage":null}}`, false},
{`{"data":{"label":"member","is_free_tier":0,"usage":1}}`, false},
{`{"data":{"is_free_tier":false,"usage":1}}`, false},
{`{"data":{"label":null,"is_free_tier":false,"usage":1}}`, false},
{`{"data":{"label":"","is_free_tier":false,"usage":1e2,"ignored":true},"ignored":true}`, true},
} {
if recognized, err := recognizedOpenRouterKeyResponse(strings.NewReader(body)); err != nil || recognized {
t.Errorf("recognized invalid account response %s", body)
client := reachabilityClient(t, func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: ioNopCloser(tc.body)}, nil
})
outcome, err := (authenticationProbe{client: client}).run(context.Background(), backendWithAuthenticationKind("openrouter_key"), domain.SmokeTestTarget{Endpoint: "https://example.invalid/key"})
if err != nil || (outcome.credentialsAccepted != nil && *outcome.credentialsAccepted) != tc.accepted {
t.Errorf("account response %s: outcome = %+v, error = %v", tc.body, outcome, err)
}
}
if recognized, err := recognizedOpenRouterKeyResponse(strings.NewReader(`{"data":{"label":"","is_free_tier":false,"usage":1e2,"ignored":true},"ignored":true}`)); err != nil || !recognized {
t.Fatal("valid minimal identifying response was rejected")
}
}
func TestAuthenticationProbeTreatsInterruptedResponseBodyAsTransportFailure(t *testing.T) {

View File

@@ -14,7 +14,6 @@ import (
type smokeHTTPGetter interface {
GetWithEvidence(context.Context, string, int) (*http.Response, llm.HTTPExecutionEvidence, error)
GetWithAuthenticationEvidence(context.Context, string, int, domain.ExecutionTarget) (*http.Response, llm.HTTPExecutionEvidence, error)
}
type reachabilityProbe struct {

View File

@@ -3,10 +3,10 @@ package usecase
import (
"context"
"fmt"
"net/url"
"go.maximumdirect.net/promptkit/internal/defaults"
"go.maximumdirect.net/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/llm"
)
const (
@@ -176,7 +176,7 @@ func resolveSmokeTestEndpoint(kind domain.SmokeTestKind, baseEndpoint string, sm
return *authentication.Endpoint, nil
}
if authentication.Kind != nil && *authentication.Kind == "openrouter_key" {
return url.JoinPath(baseEndpoint, "key")
return llm.OpenRouterKeyEndpoint(baseEndpoint)
}
}
}

View File

@@ -17,6 +17,7 @@ import (
type SmokeTester struct {
runner *Runner
httpClient smokeHTTPGetter
authClient smokeAuthenticationClient
now func() time.Time
}
@@ -24,18 +25,22 @@ type SmokeTester struct {
// leaves HTTP operations unsupported rather than falling back to another client.
func NewSmokeTester(runner *Runner, httpClient *llm.OpenAICompatibleClient) *SmokeTester {
var getter smokeHTTPGetter
var authentication smokeAuthenticationClient
if httpClient != nil {
getter = httpClient
authentication = httpClient
}
return &SmokeTester{runner: runner, httpClient: getter, now: time.Now}
return &SmokeTester{runner: runner, httpClient: getter, authClient: authentication, now: time.Now}
}
func newSmokeTesterWithClock(runner *Runner, httpClient *llm.OpenAICompatibleClient, now func() time.Time) *SmokeTester {
var getter smokeHTTPGetter
var authentication smokeAuthenticationClient
if httpClient != nil {
getter = httpClient
authentication = httpClient
}
return &SmokeTester{runner: runner, httpClient: getter, now: now}
return &SmokeTester{runner: runner, httpClient: getter, authClient: authentication, now: now}
}
// Run selects and executes one diagnostic, retaining only factual results.
@@ -126,7 +131,7 @@ func (t *SmokeTester) Run(ctx context.Context, request domain.SmokeTestRequest)
return t.finishHTTP(ctx, report, finish)
case domain.SmokeTestAuthentication:
requestStartedAt := now().UTC()
outcome, err := (authenticationProbe{client: t.httpClient}).run(ctx, selection.backend, selection.target)
outcome, err := (authenticationProbe{client: t.authClient}).run(ctx, selection.backend, selection.target)
requestDuration := nonnegativeDuration(now().UTC().Sub(requestStartedAt))
if err != nil {
if contextErr := ctx.Err(); contextErr != nil {
@@ -349,7 +354,7 @@ func (t *SmokeTester) setSupport(report *domain.SmokeTestReport, selection *smok
report.Applicable = boolPointer(true)
return nil
case domain.SmokeTestAuthentication:
outcome, err := (authenticationProbe{client: t.httpClient}).support(selection.backend)
outcome, err := (authenticationProbe{client: t.authClient}).support(selection.backend)
if err != nil {
return err
}