Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 50 additions & 5 deletions internal/pluginhost/host_callbacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"

"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
Expand Down Expand Up @@ -335,15 +336,59 @@ func modelExecutionError(errMsg *interfaces.ErrorMessage) error {
if errMsg == nil {
return nil
}
if errMsg.Error != nil {
return errMsg.Error
err := errMsg.Error
if err == nil {
if errMsg.StatusCode > 0 {
err = fmt.Errorf("model execution failed with status %d", errMsg.StatusCode)
} else {
err = fmt.Errorf("model execution failed")
}
}
status := hostErrorStatus(err)
if status == 0 {
status = errMsg.StatusCode
}
if status < 400 || status > 599 {
status = http.StatusInternalServerError
}
if errMsg.StatusCode > 0 {
return fmt.Errorf("model execution failed with status %d", errMsg.StatusCode)
return modelExecutionStatusError{err: err, status: status}
}

// hostErrorStatus returns the first valid HTTP error status in depth-first,
// pre-order traversal. Invalid outer statuses do not hide their causes.
func hostErrorStatus(err error) int {
for err != nil {
if statusErr, ok := err.(interface{ StatusCode() int }); ok {
if status := statusErr.StatusCode(); status >= 400 && status <= 599 {
return status
}
}
switch wrapped := err.(type) {
case interface{ Unwrap() error }:
err = wrapped.Unwrap()
case interface{ Unwrap() []error }:
for _, child := range wrapped.Unwrap() {
if status := hostErrorStatus(child); status != 0 {
return status
}
}
return 0
default:
return 0
}
}
return fmt.Errorf("model execution failed")
return 0
}

type modelExecutionStatusError struct {
err error
status int
}

func (e modelExecutionStatusError) Error() string { return e.err.Error() }
func (e modelExecutionStatusError) Unwrap() error { return e.err }
func (e modelExecutionStatusError) StatusCode() int { return e.status }

func (h *Host) callHostLog(ctx context.Context, request []byte) ([]byte, error) {
var req rpcHostLogRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/pluginhost/host_callbacks_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func cliproxyHostCall(hostCtx unsafe.Pointer, method *C.char, request *C.uint8_t
ctx := withHostCallbackPluginID(context.Background(), entry.pluginID)
resp, errCall := entry.host.callFromPlugin(ctx, C.GoString(method), requestBytes)
if errCall != nil {
resp = marshalRPCError("host_call_failed", errCall.Error())
resp = marshalHostCallError(errCall)
}
if len(resp) == 0 || response == nil {
return 0
Expand Down
176 changes: 176 additions & 0 deletions internal/pluginhost/host_status_envelope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package pluginhost

import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"

"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
)

func TestHostErrorTreeStatus(t *testing.T) {
upstream := &rpcError{message: " opaque upstream failure ", statusCode: 503}
rateLimit := &rpcError{message: "rate limited", statusCode: 429}
base := &auth.Error{Code: "auth_unavailable", Message: "no auth available"}
// Match the scheduler's actual zero-status wrapper around its last upstream error.
wrapped := auth.WithCause(base, upstream)
cases := []struct {
name string
err error
want int
}{
{"auth unavailable", wrapped, 503},
{"fmt wrapper", fmt.Errorf("outer: %w", wrapped), 503},
{"nested invalid", auth.WithCause(&auth.Error{HTTPStatus: 200}, fmt.Errorf("middle: %w", wrapped)), 503},
{"valid outer", auth.WithCause(&auth.Error{HTTPStatus: 422}, wrapped), 422},
{"lower boundary", auth.WithCause(&auth.Error{HTTPStatus: 400}, wrapped), 400},
{"upper boundary", auth.WithCause(&auth.Error{HTTPStatus: 599}, wrapped), 599},
{"join depth first", errors.Join(wrapped, rateLimit), 503},
{"join reversed", errors.Join(rateLimit, wrapped), 429},
{"join skip invalid branch", errors.Join(&auth.Error{HTTPStatus: 200}, wrapped), 503},
{"join nested", errors.Join(errors.Join(errors.New("plain"), wrapped), rateLimit), 503},
{"invalid outer join", auth.WithCause(&auth.Error{}, errors.Join(rateLimit, upstream)), 429},
{"valid outer join", auth.WithCause(&auth.Error{HTTPStatus: 401}, errors.Join(rateLimit, upstream)), 401},
{"no valid", errors.Join(auth.WithCause(&auth.Error{HTTPStatus: 600}, &auth.Error{HTTPStatus: 399}), errors.New("upstream returned 503")), 0},
}
for _, status := range []int{0, -1, 200, 399, 600, int(^uint(0) >> 1)} {
for _, cause := range []*rpcError{rateLimit, upstream} {
cases = append(cases, struct {
name string
err error
want int
}{fmt.Sprintf("outer %d/inner %d", status, cause.statusCode), auth.WithCause(&auth.Error{HTTPStatus: status}, cause), cause.statusCode})
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var envelope pluginabi.Envelope
if err := json.Unmarshal(marshalHostCallError(tc.err), &envelope); err != nil {
t.Fatal(err)
}
if envelope.OK || envelope.Error == nil || envelope.Error.Code != "host_call_failed" || envelope.Error.HTTPStatus != tc.want || envelope.Error.Message != tc.err.Error() {
t.Errorf("raw envelope = %#v, want status %d and unchanged message", envelope.Error, tc.want)
}
for _, fallback := range []int{401, 0} {
for _, method := range []string{pluginabi.MethodHostModelExecute, pluginabi.MethodHostModelExecuteStream} {
t.Run(fmt.Sprintf("%s/fallback %d", method, fallback), func(t *testing.T) {
msg := &interfaces.ErrorMessage{StatusCode: fallback, Error: tc.err}
host := New()
host.SetModelExecutor(&fakeHostModelExecutor{
executeModel: func(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionResponse, *interfaces.ErrorMessage) {
return handlers.ModelExecutionResponse{}, msg
},
executeModelStream: func(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) {
return handlers.ModelExecutionStream{}, msg
},
})
req := []byte(fmt.Sprintf(`{"model":"synthetic","stream":%t}`, method == pluginabi.MethodHostModelExecuteStream))
_, err := host.callFromPlugin(context.Background(), method, req)
if err == nil || err.Error() != tc.err.Error() || !errors.Is(err, tc.err) {
t.Fatal("callback lost message or error identity")
}
var original, recovered *auth.Error
if errors.As(tc.err, &original) && (!errors.As(err, &recovered) || original != recovered || !errors.Is(err, original)) {
t.Fatal("auth error identity lost")
}
var originalUpstream, recoveredUpstream *rpcError
if errors.As(tc.err, &originalUpstream) && (!errors.As(err, &recoveredUpstream) || originalUpstream != recoveredUpstream || !errors.Is(err, originalUpstream)) {
t.Fatal("upstream error identity lost")
}
want := tc.want
if want == 0 {
want = fallback
if want == 0 {
want = 500
}
}
var statusErr interface{ StatusCode() int }
if !errors.As(err, &statusErr) {
t.Fatal("missing returned status accessor")
}
if got := statusErr.StatusCode(); got != want {
t.Errorf("returned status = %d, want %d", got, want)
}
var envelope pluginabi.Envelope
if err := json.Unmarshal(marshalHostCallError(err), &envelope); err != nil {
t.Fatal(err)
}
if envelope.OK || envelope.Error == nil || envelope.Error.Code != "host_call_failed" || envelope.Error.HTTPStatus != want || envelope.Error.Message != tc.err.Error() {
t.Errorf("callback envelope = %#v, want status %d and unchanged message", envelope.Error, want)
}
})
}
}
})
}
}

func TestHostModelErrorEnvelope(t *testing.T) {
for _, method := range []string{pluginabi.MethodHostModelExecute, pluginabi.MethodHostModelExecuteStream} {
for _, status := range []int{503, 429, 400, 401, 422, 0} {
t.Run(fmt.Sprintf("%s/%d", method, status), func(t *testing.T) {
cause := errors.New(" opaque failure\n")
msg := &interfaces.ErrorMessage{StatusCode: status, Error: cause}
host := New()
host.SetModelExecutor(&fakeHostModelExecutor{
executeModel: func(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionResponse, *interfaces.ErrorMessage) {
return handlers.ModelExecutionResponse{}, msg
},
executeModelStream: func(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) {
return handlers.ModelExecutionStream{}, msg
},
})
req := []byte(fmt.Sprintf(`{"model":"synthetic","stream":%t}`, method == pluginabi.MethodHostModelExecuteStream))
_, err := host.callFromPlugin(context.Background(), method, req)
if err == nil {
t.Fatal("expected callback error")
}
if !errors.Is(err, cause) {
t.Fatal("callback lost original error")
}
var envelope pluginabi.Envelope
if err := json.Unmarshal(marshalHostCallError(err), &envelope); err != nil {
t.Fatal(err)
}
want := status
if want == 0 {
want = 500
}
if envelope.OK || envelope.Error == nil || envelope.Error.Code != "host_call_failed" || envelope.Error.HTTPStatus != want || envelope.Error.Message != cause.Error() {
t.Fatalf("unexpected envelope: %+v", envelope.Error)
}
})
}
}
}

func TestHostCallErrorStatusValidation(t *testing.T) {
for _, status := range []int{-1, 0, 200, 399, 400, 429, 503, 599, 600, int(^uint(0) >> 1)} {
err := fmt.Errorf("outer: %w", rpcError{message: " opaque ", statusCode: status})
var envelope pluginabi.Envelope
if err := json.Unmarshal(marshalHostCallError(err), &envelope); err != nil {
t.Fatal(err)
}
want := status
if want < 400 || want > 599 {
want = 0
}
if envelope.OK || envelope.Error == nil || envelope.Error.Code != "host_call_failed" || envelope.Error.HTTPStatus != want || envelope.Error.Message != err.Error() {
t.Fatalf("status %d: %+v", status, envelope.Error)
}
}
for _, cause := range []error{context.Canceled, fmt.Errorf("outer: %w", context.Canceled), errors.New("upstream returned 503")} {
var envelope pluginabi.Envelope
if err := json.Unmarshal(marshalHostCallError(cause), &envelope); err != nil {
t.Fatal(err)
}
if envelope.OK || envelope.Error == nil || envelope.Error.Code != "host_call_failed" || envelope.Error.HTTPStatus != 0 || envelope.Error.Message != cause.Error() {
t.Fatalf("unexpected untyped error: %+v", envelope.Error)
}
}
}
74 changes: 74 additions & 0 deletions internal/pluginhost/host_status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package pluginhost

import (
"context"
"errors"
"fmt"
"testing"

"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
)

func TestModelExecutionErrorStatus(t *testing.T) {
if modelExecutionError(nil) != nil {
t.Fatal("nil input must return nil")
}
plain := errors.New(" opaque failure\n")
typed := &rpcError{message: "opaque failure", statusCode: 429}
for _, tc := range []struct {
name string
cause error
status, want int
}{
{"503", plain, 503, 503},
{"429", plain, 429, 429},
{"400", plain, 400, 400},
{"401", plain, 401, 401},
{"422", plain, 422, 422},
{"upper boundary", plain, 599, 599},
{"wrapped plain", fmt.Errorf("outer: %w", plain), 503, 503},
{"typed precedence", typed, 503, 429},
{"wrapped typed precedence", fmt.Errorf("outer: %w", typed), 503, 429},
{"invalid typed fallback", &rpcError{message: "opaque", statusCode: 600}, 401, 401},
{"overflow typed fallback", &rpcError{message: "opaque", statusCode: int(^uint(0) >> 1)}, 422, 422},
{"invalid typed default", &rpcError{message: "opaque", statusCode: -1}, 0, 500},
{"default", plain, 0, 500},
{"negative", plain, -1, 500},
{"success is not error", plain, 200, 500},
{"below boundary", plain, 399, 500},
{"above boundary", plain, 600, 500},
{"too large", plain, int(^uint(0) >> 1), 500},
{"cancel", context.Canceled, 499, 499},
{"wrapped cancel", fmt.Errorf("outer: %w", context.Canceled), 0, 500},
{"status only", nil, 503, 503},
{"empty", nil, 0, 500},
} {
t.Run(tc.name, func(t *testing.T) {
err := modelExecutionError(&interfaces.ErrorMessage{StatusCode: tc.status, Error: tc.cause})
var statusErr interface{ StatusCode() int }
if !errors.As(err, &statusErr) || statusErr.StatusCode() != tc.want {
t.Errorf("error %v must preserve status %d", err, tc.want)
}
if tc.cause != nil {
if err.Error() != tc.cause.Error() || !errors.Is(err, tc.cause) {
t.Fatal("message or error identity lost")
}
var original *rpcError
if errors.As(tc.cause, &original) {
var recovered *rpcError
if !errors.As(err, &recovered) || recovered != original {
t.Fatal("errors.As identity lost")
}
}
} else {
want := "model execution failed"
if tc.status > 0 {
want = fmt.Sprintf("model execution failed with status %d", tc.status)
}
if err.Error() != want {
t.Fatalf("message = %q, want %q", err.Error(), want)
}
}
})
}
}
2 changes: 1 addition & 1 deletion internal/pluginhost/loader_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ func windowsHostCall(hostCtx uintptr, methodPtr uintptr, requestPtr uintptr, req
ctx := withHostCallbackPluginID(context.Background(), entry.pluginID)
resp, errCall := entry.host.callFromPlugin(ctx, windowsString(methodPtr), request)
if errCall != nil {
resp = marshalRPCError("host_call_failed", errCall.Error())
resp = marshalHostCallError(errCall)
}
if len(resp) == 0 || responsePtr == 0 {
return 0
Expand Down
12 changes: 12 additions & 0 deletions internal/pluginhost/rpc_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,18 @@ func marshalRPCError(code, message string) []byte {
return raw
}

func marshalHostCallError(err error) []byte {
raw, _ := json.Marshal(pluginabi.Envelope{
OK: false,
Error: &pluginabi.Error{
Code: "host_call_failed",
Message: err.Error(),
HTTPStatus: hostErrorStatus(err),
},
})
return raw
}

func (a *rpcPluginAdapter) openHostCallbackContext(ctx context.Context) (string, func()) {
if a == nil || a.host == nil {
return "", func() {}
Expand Down
Loading