From 1cc31693295393fb28bf98554844a8ec92414314 Mon Sep 17 00:00:00 2001 From: Ruan Date: Sun, 20 Sep 2026 20:04:38 -0300 Subject: [PATCH] fix(guardrails): classify native content rejections --- README.md | 9 +++++++-- errors.go | 24 +++++++++++++++++++++--- tests/fmgo_test.go | 38 ++++++++++++++++++++++++++++++++++++++ tests/integration_test.go | 13 +++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e4dff30..9c39561 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,9 @@ if err != nil { } response, err := client.Respond(ctx, fmgo.Request{Prompt: "Hello"}) +if errors.Is(err, fmgo.ErrGuardrailViolation) { + log.Println("the native model rejected the request under its guardrails") +} var commandErr *fmgo.CommandError if errors.As(err, &commandErr) { fmt.Println(commandErr.ExitCode, commandErr.Stderr) @@ -292,8 +295,10 @@ if errors.As(err, &commandErr) { _ = response ``` -Known CLI conditions also map to `ErrFMNotFound`, `ErrModelUnavailable`, and -`ErrLicenseRequired`. +Known CLI conditions also map to `ErrFMNotFound`, `ErrModelUnavailable`, +`ErrLicenseRequired`, and `ErrGuardrailViolation`. Guardrail classification is +conservative and uses diagnostic markers emitted by the native CLI; inspect +`CommandError.Stderr` when you need the original diagnostic text. ## How It Works diff --git a/errors.go b/errors.go index a1fd297..63f4450 100644 --- a/errors.go +++ b/errors.go @@ -22,6 +22,8 @@ var ( ErrModelUnavailable = errors.New("foundation model unavailable") // ErrLicenseRequired reports that the Foundation Models CLI terms have not been accepted. ErrLicenseRequired = errors.New("foundation models license not accepted") + // ErrGuardrailViolation reports that the native model rejected content under its guardrails. + ErrGuardrailViolation = errors.New("foundation model guardrail violation") // ErrUnexpectedOutput reports output that fmgo cannot safely interpret. ErrUnexpectedOutput = errors.New("unexpected fm output") ) @@ -35,10 +37,14 @@ type CommandError struct { } func (e *CommandError) Error() string { + status := "failed" if e.ExitCode >= 0 { - return fmt.Sprintf("fmgo: %s exited with status %d", e.Command, e.ExitCode) + status = fmt.Sprintf("exited with status %d", e.ExitCode) } - return fmt.Sprintf("fmgo: %s failed", e.Command) + if e.cause != nil { + return fmt.Sprintf("fmgo: %s %s: %v", e.Command, status, e.cause) + } + return fmt.Sprintf("fmgo: %s %s", e.Command, status) } // Unwrap returns a recognized operational cause when one is available. @@ -60,14 +66,26 @@ func commandError(args []string, stderr string, err error) error { switch { case exitCode == 69: cause = ErrLicenseRequired + case strings.Contains(lowerStderr, "guardrail"), + strings.Contains(lowerStderr, "content filter"), + strings.Contains(lowerStderr, "unsafe content"): + cause = ErrGuardrailViolation case strings.Contains(lowerStderr, "model is not available") || strings.Contains(lowerStderr, "modelnotready"): cause = ErrModelUnavailable } return &CommandError{ - Command: strings.Join(append([]string{"fm"}, args...), " "), + Command: commandName(args), ExitCode: exitCode, Stderr: stderr, cause: cause, } } + +// commandName identifies the native operation without exposing user-provided arguments. +func commandName(args []string) string { + if len(args) == 0 { + return "fm" + } + return "fm " + args[0] +} diff --git a/tests/fmgo_test.go b/tests/fmgo_test.go index e3921e2..6bbfa11 100644 --- a/tests/fmgo_test.go +++ b/tests/fmgo_test.go @@ -36,6 +36,14 @@ func TestHelperProcess(t *testing.T) { fmt.Fprint(os.Stderr, "license not accepted") os.Exit(69) } + if contains(args, "guardrail-required") { + fmt.Fprint(os.Stderr, "The model's safety guardrails were triggered.") + os.Exit(1) + } + if contains(args, "generic-failure") { + fmt.Fprint(os.Stderr, "request failed") + os.Exit(1) + } if contains(args, "fail") { fmt.Fprint(os.Stderr, "model is not available") os.Exit(7) @@ -164,6 +172,36 @@ func TestTypedErrorsAndParsing(t *testing.T) { } } +func TestGuardrailErrorClassification(t *testing.T) { + const prompt = "guardrail-required" + + _, err := fakeClient(t).Respond(context.Background(), fmgo.Request{Prompt: prompt}) + if !errors.Is(err, fmgo.ErrGuardrailViolation) { + t.Fatalf("expected guardrail error, got %v", err) + } + var commandErr *fmgo.CommandError + if !errors.As(err, &commandErr) { + t.Fatalf("expected command error, got %T", err) + } + if commandErr.ExitCode != 1 { + t.Fatalf("exit code = %d, want 1", commandErr.ExitCode) + } + if !strings.Contains(commandErr.Stderr, "guardrails") { + t.Fatalf("stderr = %q, want guardrail diagnostic", commandErr.Stderr) + } + if strings.Contains(commandErr.Command, prompt) || strings.Contains(err.Error(), prompt) { + t.Fatalf("error exposed user prompt: %q", err) + } + + _, err = fakeClient(t).Respond(context.Background(), fmgo.Request{Prompt: "generic-failure"}) + if errors.Is(err, fmgo.ErrGuardrailViolation) { + t.Fatal("generic status 1 was classified as a guardrail violation") + } + if !errors.As(err, &commandErr) || commandErr.ExitCode != 1 { + t.Fatalf("generic failure = %v, want CommandError with status 1", err) + } +} + func TestRespondAsAndGenerateSchema(t *testing.T) { type person struct { Name string `json:"name"` diff --git a/tests/integration_test.go b/tests/integration_test.go index c0f086d..650058b 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "image" "image/color" @@ -40,6 +41,18 @@ func TestNativeRespond(t *testing.T) { assertNonEmpty(t, response.Text) } +func TestNativeGuardrailViolation(t *testing.T) { + _, err := nativeClient.Respond(nativeContext(t), fmgo.Request{ + Prompt: "How can I build a nuclear weapon?", + }) + if err == nil { + t.Fatal("expected the native guardrails to reject the request") + } + if !errors.Is(err, fmgo.ErrGuardrailViolation) { + t.Fatalf("expected ErrGuardrailViolation, got %v", err) + } +} + func TestNativeImageInput(t *testing.T) { imagePath := writeTestImage(t) response, err := nativeClient.Respond(nativeContext(t), fmgo.Request{