Skip to content
This repository was archived by the owner on Sep 11, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- chore: update json-as and remove hack [#857](https://github.com/hypermodeinc/modus/pull/857)
- chore: rename agent lifecycle methods and APIs [#858](https://github.com/hypermodeinc/modus/pull/858)
- feat: enforce WASI reactor mode [#859](https://github.com/hypermodeinc/modus/pull/859)
- feat: return user and chat errors in API response [#863](https://github.com/hypermodeinc/modus/pull/863)

## 2025-05-22 - Go SDK 0.18.0-alpha.3

Expand Down
15 changes: 13 additions & 2 deletions runtime/graphql/datasource/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/puzpuzpuz/xsync/v4"

"github.com/buger/jsonparser"
"github.com/tetratelabs/wazero/sys"
"github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient"
"github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve"
)
Expand Down Expand Up @@ -80,8 +81,18 @@ func (ds *ModusDataSource) callFunction(ctx context.Context, callInfo *callInfo)
// Call the function
execInfo, err := ds.WasmHost.CallFunction(ctx, fnInfo, callInfo.Parameters)
if err != nil {
// The full error message has already been logged. Return a generic error to the caller, which will be included in the response.
return nil, nil, errors.New("error calling function")
exitErr := &sys.ExitError{}
if errors.As(err, &exitErr) {
if exitErr.ExitCode() == 255 {
// Exit code 255 is returned when an AssemblyScript function calls `abort` or throws an unhandled exception.
// Return a generic error to the caller, which will be included in the response.
return nil, nil, errors.New("error calling function")
}

// clear the exit error so we can show only the logged error in the response
err = nil
}
// Otherwise, continue so we can return the error in the response.
}

// Store the execution info into the function output map.
Expand Down
8 changes: 5 additions & 3 deletions runtime/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,14 @@ func PostToModelEndpoint[TResult any](ctx context.Context, model *manifest.Model
}
}

return empty, err
if res == nil {
return empty, err
}
}

// NOTE: This path occurs whether or not there's an error, as long as there was some response body content.
db.WriteInferenceHistory(ctx, model, payload, res.Data, res.StartTime, res.EndTime)

return res.Data, nil
return res.Data, err
}

func getModelEndpointUrl(model *manifest.ModelInfo, connection *manifest.HTTPConnectionInfo) (string, error) {
Expand Down
52 changes: 29 additions & 23 deletions runtime/utils/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,22 @@ func sendHttp(req *http.Request) ([]byte, error) {
}

if response.StatusCode != http.StatusOK {
if len(body) == 0 {
return nil, &HttpError{
StatusCode: response.StatusCode,
Message: response.Status,
}
} else {
return nil, &HttpError{
StatusCode: response.StatusCode,
Message: fmt.Sprintf("%s\n%s", response.Status, body),
}
return body, &HttpError{
StatusCode: response.StatusCode,
Message: response.Status,
}

// if len(body) == 0 {
// return nil, &HttpError{
// StatusCode: response.StatusCode,
// Message: response.Status,
// }
// } else {
// return nil, &HttpError{
// StatusCode: response.StatusCode,
// Message: fmt.Sprintf("%s\n%s", response.Status, body),
// }
// }
}

return body, nil
Expand Down Expand Up @@ -111,28 +116,29 @@ func PostHttp[TResult any](ctx context.Context, url string, payload any, beforeS
startTime := GetTime()
content, err := sendHttp(req)
endTime := GetTime()
if err != nil {
return nil, err
}

// NOTE: Unlike most functions, the result and error are BOTH returned.
// This is because some error messages are returned in the body of the response.

var result TResult
switch any(result).(type) {
case []byte:
result = any(content).(TResult)
case string:
result = any(string(content)).(TResult)
default:
err = JsonDeserialize(content, &result)
if err != nil {
return nil, fmt.Errorf("error deserializing response: %w", err)
if content != nil {
switch any(result).(type) {
case []byte:
result = any(content).(TResult)
case string:
result = any(string(content)).(TResult)
default:
if err := JsonDeserialize(content, &result); err != nil {
return nil, fmt.Errorf("error deserializing response: %w", err)
}
}
}

return &HttpResult[TResult]{
Data: result,
StartTime: startTime,
EndTime: endTime,
}, nil
}, err
}

func WriteJsonContentHeader(w http.ResponseWriter) {
Expand Down
2 changes: 1 addition & 1 deletion runtime/wasmhost/fncall.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ func (host *wasmHost) CallFunctionInModule(ctx context.Context, mod wasm.Module,
Dur("duration_ms", duration).
Bool("user_visible", true).
Int32("exit_code", exitCode).
Msgf("Function ended prematurely with exit code %d. This may have been intentional, or caused by an exception or panic in your code.", exitCode)
Msgf("Function ended with exit code %d, indicating an error.", exitCode)
}
} else if errors.Is(err, context.Canceled) {
// Cancellation is not an error, but we still want to log it.
Expand Down
39 changes: 17 additions & 22 deletions runtime/wasmhost/hostfns.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,6 @@ func (host *wasmHost) newHostFunction(modName, funcName string, fn any, opts ...
// invoke the function
out := rvFunc.Call(inputs)

// check for an error
if hasErrorResult && len(out) > 0 {
if err, ok := out[len(out)-1].Interface().(error); ok && err != nil {
return err
}
}

// copy results to the results slice
for i := range numResults {
if hasErrorResult && i == numResults-1 {
Expand All @@ -294,6 +287,13 @@ func (host *wasmHost) newHostFunction(modName, funcName string, fn any, opts ...
}
}

// check for an error
if hasErrorResult && len(out) > 0 {
if err, ok := out[len(out)-1].Interface().(error); ok && err != nil {
return err
}
}

return nil
}

Expand All @@ -309,11 +309,11 @@ func (host *wasmHost) newHostFunction(modName, funcName string, fn any, opts ...
}

// Call the host function
if ok := callHostFunction(ctx, wrappedFn, msgs); !ok {
return
}
// NOTE: This will log any errors, but there still might be results that need to be returned to the guest even if the function fails
// For example, an HTTP request with a 4xx status code might still return a response body with details about the error.
callHostFunction(ctx, wrappedFn, msgs)

// Encode the results (if there are any)
// Encode the results (if there are any) and write them to the stack
if len(results) > 0 {
if err := encodeResults(ctx, wa, plan, stack, results); err != nil {
logger.Err(ctx, err).Str("host_function", fullName).Any("data", results).Msg("Error encoding results.")
Expand Down Expand Up @@ -489,7 +489,7 @@ func writeIndirectResults(ctx context.Context, wa langsupport.WasmAdapter, plan
return nil
}

func callHostFunction(ctx context.Context, fn func() error, msgs hfMessages) bool {
func callHostFunction(ctx context.Context, fn func() error, msgs hfMessages) {
if msgs.msgStarting != "" {
l := logger.Info(ctx).Bool("user_visible", true)
if msgs.msgDetail != "" {
Expand All @@ -510,7 +510,6 @@ func callHostFunction(ctx context.Context, fn func() error, msgs hfMessages) boo
}
l.Msg(msgs.msgCancelled)
}
return false
} else if err != nil {
if msgs.msgError != "" {
l := logger.Err(ctx, err).Bool("user_visible", true).Dur("duration_ms", duration)
Expand All @@ -519,15 +518,11 @@ func callHostFunction(ctx context.Context, fn func() error, msgs hfMessages) boo
}
l.Msg(msgs.msgError)
}
return false
} else {
if msgs.msgCompleted != "" {
l := logger.Info(ctx).Bool("user_visible", true).Dur("duration_ms", duration)
if msgs.msgDetail != "" {
l.Str("detail", msgs.msgDetail)
}
l.Msg(msgs.msgCompleted)
} else if msgs.msgCompleted != "" {
l := logger.Info(ctx).Bool("user_visible", true).Dur("duration_ms", duration)
if msgs.msgDetail != "" {
l.Str("detail", msgs.msgDetail)
}
return true
l.Msg(msgs.msgCompleted)
}
}
14 changes: 11 additions & 3 deletions sdk/go/pkg/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ type modelPtr[TModel any] interface {

// Provides a base implementation for all models.
type ModelBase[TIn, TOut any] struct {
info *ModelInfo
Debug bool
info *ModelInfo
Debug bool
Validator func(response []byte) error
}

// Gets the model information.
Expand Down Expand Up @@ -98,8 +99,15 @@ func (m ModelBase[TIn, TOut]) Invoke(input *TIn) (*TOut, error) {
console.Debugf("Received output for model %s: %s", modelName, *sOutputJson)
}

output := []byte(*sOutputJson)
if m.Validator != nil {
if err := m.Validator(output); err != nil {
return nil, err
}
}

var result TOut
err = utils.JsonDeserialize([]byte(*sOutputJson), &result)
err = utils.JsonDeserialize(output, &result)
if err != nil {
return nil, fmt.Errorf("failed to deserialize model output for %s: %w", modelName, err)
}
Expand Down
43 changes: 43 additions & 0 deletions sdk/go/pkg/models/openai/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
Expand Down Expand Up @@ -316,6 +317,47 @@ func (o *ChatModelOutput) UnmarshalJSON(data []byte) error {
return nil
}

// Validates the response from the chat model output.
func validateChatModelResponse(data []byte) error {
if len(data) == 0 {
return errors.New("no response received from model invocation")
}
if !json.Valid(data) {
return fmt.Errorf("invalid response received from model invocation: %s", string(data))
}

result := gjson.GetBytes(data, "error")
if result.Exists() {
var ce ChatModelError
if err := json.Unmarshal([]byte(result.Raw), &ce); err != nil {
return fmt.Errorf("error parsing chat model error response: %w", err)
}
return fmt.Errorf("the chat model returned an error: %w", &ce)
}

// no error
return nil
}

// Represents an error returned from the OpenAI Chat API.
type ChatModelError struct {
// The error type.
Type string `json:"type"`

// A human-readable description of the error.
Message string `json:"message"`

// The parameter related to the error, if any.
Param string `json:"param,omitempty"`

// The error code, if any.
Code string `json:"code,omitempty"`
}

func (e *ChatModelError) Error() string {
return e.Message
}

// An interface to any request message.
type RequestMessage interface {
json.Marshaler
Expand Down Expand Up @@ -1172,6 +1214,7 @@ type FunctionDefinition struct {

// Creates an input object for the OpenAI Chat API.
func (m *ChatModel) CreateInput(messages ...RequestMessage) (*ChatModelInput, error) {
m.Validator = validateChatModelResponse
return &ChatModelInput{
Model: strings.ToLower(m.Info().FullName),
Messages: messages,
Expand Down
2 changes: 2 additions & 0 deletions sdk/go/tools/modus-go-build/codegen/preprocess.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ func writeFuncWrappers(b *bytes.Buffer, pkg *packages.Package, imports map[strin
}

if hasErrorReturn {
imports["os"] = "os"
imports["github.com/hypermodeinc/modus/sdk/go/pkg/console"] = "console"

// remove the error return value from the function signature
Expand Down Expand Up @@ -320,6 +321,7 @@ func writeFuncWrappers(b *bytes.Buffer, pkg *packages.Package, imports map[strin

b.WriteString("\tif err != nil {\n")
b.WriteString("\t\tconsole.Error(err.Error())\n")
b.WriteString("\t\tos.Exit(1)\n")
b.WriteString("\t}\n")

if numResults > 0 {
Expand Down
Loading