From cfc0dd5b2b15d61c37d08b8928757b04ad6a21bb Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 14 May 2026 14:26:41 +0100 Subject: [PATCH 01/18] Added Chat API scaffolding, including routes, handlers, middleware, and data structures --- handler/module.go | 2 + handler/mued.go | 28 ++++++++++++ handler/routes.go | 8 ++++ internal/server/middleware.go | 6 +++ runtime/mued.go | 84 +++++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+) diff --git a/handler/module.go b/handler/module.go index a58f29f..c16e381 100644 --- a/handler/module.go +++ b/handler/module.go @@ -10,5 +10,7 @@ func Module() fx.Option { fx.Provide(NewHealthRoute), fx.Provide(NewMuEdEvaluateRoute), fx.Provide(NewMuEdEvaluateHealthRoute), + fx.Provide(NewMuEdChatRoute), + fx.Provide(NewMuEdChatHealthRoute), ) } diff --git a/handler/mued.go b/handler/mued.go index 6897369..9ade7c8 100644 --- a/handler/mued.go +++ b/handler/mued.go @@ -140,6 +140,34 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(feedback) //nolint:errcheck } +// ServeChat handles POST /chat. +func (h *MuEdHandler) ServeChat(w http.ResponseWriter, r *http.Request) { + if !h.checkAuth(w, r) { + return + } + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + http.Error(w, "not implemented", http.StatusNotImplemented) +} + +// ServeChatHealth handles GET /chat/health. +func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) { + if !h.checkAuth(w, r) { + return + } + + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + http.Error(w, "not implemented", http.StatusNotImplemented) +} + // ServeHealth handles GET /evaluate/health. func (h *MuEdHandler) ServeHealth(w http.ResponseWriter, r *http.Request) { if !h.checkAuth(w, r) { diff --git a/handler/routes.go b/handler/routes.go index 1419d78..681e28a 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -21,3 +21,11 @@ func NewMuEdEvaluateRoute(handler *MuEdHandler) server.HttpHandlerResult { func NewMuEdEvaluateHealthRoute(handler *MuEdHandler) server.HttpHandlerResult { return server.AsHttpHandler("/evaluate/health", http.HandlerFunc(handler.ServeHealth)) } + +func NewMuEdChatRoute(handler *MuEdHandler) server.HttpHandlerResult { + return server.AsHttpHandler("/chat", http.HandlerFunc(handler.ServeChat)) +} + +func NewMuEdChatHealthRoute(handler *MuEdHandler) server.HttpHandlerResult { + return server.AsHttpHandler("/chat/health", http.HandlerFunc(handler.ServeChatHealth)) +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 381ad48..b45f80e 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -19,6 +19,12 @@ func NormalizePath(next http.Handler) http.Handler { case strings.HasSuffix(r.URL.Path, "/evaluate"): r = r.Clone(r.Context()) r.URL.Path = "/evaluate" + case strings.HasSuffix(r.URL.Path, "/chat/health"): + r = r.Clone(r.Context()) + r.URL.Path = "/chat/health" + case strings.HasSuffix(r.URL.Path, "/chat"): + r = r.Clone(r.Context()) + r.URL.Path = "/chat" } next.ServeHTTP(w, r) }) diff --git a/runtime/mued.go b/runtime/mued.go index 363ae8a..4957207 100644 --- a/runtime/mued.go +++ b/runtime/mued.go @@ -159,3 +159,87 @@ func MuEdToPreviewFeedback(result map[string]any) []map[string]any { {"preSubmissionFeedback": result}, } } + +type MuEdChatRole string + +const ( + MuEdChatRoleUser MuEdChatRole = "USER" + MuEdChatRoleAssistant MuEdChatRole = "ASSISTANT" + MuEdChatRoleSystem MuEdChatRole = "SYSTEM" +) + +type MuEdChatMessage struct { + Role MuEdChatRole `json:"role"` + Content string `json:"content"` +} + +type MuEdChatUserPreferences struct { + Tone string `json:"tone,omitempty"` + Detail string `json:"detail,omitempty"` + Language string `json:"language,omitempty"` +} + +type MuEdChatContext struct { + Course map[string]any `json:"course,omitempty"` + Task map[string]any `json:"task,omitempty"` + Submission map[string]any `json:"submission,omitempty"` +} + +type MuEdChatLLMConfig struct { + Model string `json:"model,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"maxTokens,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + +type MuEdChatDataPolicy struct { + RetainData bool `json:"retainData"` + AllowReview bool `json:"allowReview"` +} + +type MuEdChatConfiguration struct { + LLM *MuEdChatLLMConfig `json:"llm,omitempty"` + DataPolicy *MuEdChatDataPolicy `json:"dataPolicy,omitempty"` +} + +type MuEdChatRequest struct { + Messages []MuEdChatMessage `json:"messages"` + ConversationID string `json:"conversationId,omitempty"` + User *MuEdChatUserPreferences `json:"user,omitempty"` + Context *MuEdChatContext `json:"context,omitempty"` + Configuration *MuEdChatConfiguration `json:"configuration,omitempty"` +} + +type MuEdChatResponseMetadata struct { + Tokens map[string]any `json:"tokens,omitempty"` + Model string `json:"model,omitempty"` + Timing map[string]any `json:"timing,omitempty"` +} + +type MuEdChatResponse struct { + Output MuEdChatMessage `json:"output"` + Metadata *MuEdChatResponseMetadata `json:"metadata,omitempty"` +} + +type MuEdChatHealthStatus string + +const ( + MuEdChatHealthStatusOK MuEdChatHealthStatus = "OK" + MuEdChatHealthStatusDegraded MuEdChatHealthStatus = "DEGRADED" + MuEdChatHealthStatusUnavailable MuEdChatHealthStatus = "UNAVAILABLE" +) + +type MuEdChatCapabilities struct { + Chat bool `json:"chat"` + UserPreferences bool `json:"userPreferences"` + Streaming bool `json:"streaming"` + DataPolicy bool `json:"dataPolicy"` +} + +type MuEdChatHealthResponse struct { + Status MuEdChatHealthStatus `json:"status"` + Capabilities MuEdChatCapabilities `json:"capabilities"` + SupportedLanguages []string `json:"supportedLanguages"` + SupportedModels []string `json:"supportedModels"` + SupportedAPIVersions []string `json:"supportedAPIVersions"` +} From 78f3e37691edd7b594ea846965334f5008c4c2d4 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 14 May 2026 14:37:21 +0100 Subject: [PATCH 02/18] Refactored muEd into evaluate and chat --- handler/chat.go | 43 +++++++++++ handler/{mued.go => evaluate.go} | 37 +++------- handler/{mued_test.go => evaluate_test.go} | 0 handler/routes.go | 16 ---- runtime/chat.go | 85 ++++++++++++++++++++++ runtime/{mued.go => evaluate.go} | 84 --------------------- runtime/{mued_test.go => evaluate_test.go} | 0 7 files changed, 137 insertions(+), 128 deletions(-) create mode 100644 handler/chat.go rename handler/{mued.go => evaluate.go} (85%) rename handler/{mued_test.go => evaluate_test.go} (100%) create mode 100644 runtime/chat.go rename runtime/{mued.go => evaluate.go} (60%) rename runtime/{mued_test.go => evaluate_test.go} (100%) diff --git a/handler/chat.go b/handler/chat.go new file mode 100644 index 0000000..ae577f2 --- /dev/null +++ b/handler/chat.go @@ -0,0 +1,43 @@ +package handler + +import ( + "net/http" + + "github.com/lambda-feedback/shimmy/internal/server" +) + +// ServeChat handles POST /chat. +func (h *MuEdHandler) ServeChat(w http.ResponseWriter, r *http.Request) { + if !h.checkAuth(w, r) { + return + } + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + http.Error(w, "not implemented", http.StatusNotImplemented) +} + +// ServeChatHealth handles GET /chat/health. +func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) { + if !h.checkAuth(w, r) { + return + } + + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + http.Error(w, "not implemented", http.StatusNotImplemented) +} + +func NewMuEdChatRoute(handler *MuEdHandler) server.HttpHandlerResult { + return server.AsHttpHandler("/chat", http.HandlerFunc(handler.ServeChat)) +} + +func NewMuEdChatHealthRoute(handler *MuEdHandler) server.HttpHandlerResult { + return server.AsHttpHandler("/chat/health", http.HandlerFunc(handler.ServeChatHealth)) +} diff --git a/handler/mued.go b/handler/evaluate.go similarity index 85% rename from handler/mued.go rename to handler/evaluate.go index 9ade7c8..fd9d4c9 100644 --- a/handler/mued.go +++ b/handler/evaluate.go @@ -9,6 +9,7 @@ import ( "go.uber.org/zap" "github.com/lambda-feedback/shimmy/config" + "github.com/lambda-feedback/shimmy/internal/server" "github.com/lambda-feedback/shimmy/runtime" ) @@ -140,34 +141,6 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(feedback) //nolint:errcheck } -// ServeChat handles POST /chat. -func (h *MuEdHandler) ServeChat(w http.ResponseWriter, r *http.Request) { - if !h.checkAuth(w, r) { - return - } - - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - http.Error(w, "not implemented", http.StatusNotImplemented) -} - -// ServeChatHealth handles GET /chat/health. -func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) { - if !h.checkAuth(w, r) { - return - } - - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - http.Error(w, "not implemented", http.StatusNotImplemented) -} - // ServeHealth handles GET /evaluate/health. func (h *MuEdHandler) ServeHealth(w http.ResponseWriter, r *http.Request) { if !h.checkAuth(w, r) { @@ -198,3 +171,11 @@ func (h *MuEdHandler) ServeHealth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(result) //nolint:errcheck } + +func NewMuEdEvaluateRoute(handler *MuEdHandler) server.HttpHandlerResult { + return server.AsHttpHandler("/evaluate", http.HandlerFunc(handler.ServeEvaluate)) +} + +func NewMuEdEvaluateHealthRoute(handler *MuEdHandler) server.HttpHandlerResult { + return server.AsHttpHandler("/evaluate/health", http.HandlerFunc(handler.ServeHealth)) +} diff --git a/handler/mued_test.go b/handler/evaluate_test.go similarity index 100% rename from handler/mued_test.go rename to handler/evaluate_test.go diff --git a/handler/routes.go b/handler/routes.go index 681e28a..0b26431 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -13,19 +13,3 @@ func NewLegacyRoute(handler *CommandHandler) server.HttpHandlerResult { func NewHealthRoute() server.HttpHandlerResult { return server.AsHttpHandler("/health", http.HandlerFunc(HealthHandler)) } - -func NewMuEdEvaluateRoute(handler *MuEdHandler) server.HttpHandlerResult { - return server.AsHttpHandler("/evaluate", http.HandlerFunc(handler.ServeEvaluate)) -} - -func NewMuEdEvaluateHealthRoute(handler *MuEdHandler) server.HttpHandlerResult { - return server.AsHttpHandler("/evaluate/health", http.HandlerFunc(handler.ServeHealth)) -} - -func NewMuEdChatRoute(handler *MuEdHandler) server.HttpHandlerResult { - return server.AsHttpHandler("/chat", http.HandlerFunc(handler.ServeChat)) -} - -func NewMuEdChatHealthRoute(handler *MuEdHandler) server.HttpHandlerResult { - return server.AsHttpHandler("/chat/health", http.HandlerFunc(handler.ServeChatHealth)) -} diff --git a/runtime/chat.go b/runtime/chat.go new file mode 100644 index 0000000..5a9a6a3 --- /dev/null +++ b/runtime/chat.go @@ -0,0 +1,85 @@ +package runtime + +type MuEdChatRole string + +const ( + MuEdChatRoleUser MuEdChatRole = "USER" + MuEdChatRoleAssistant MuEdChatRole = "ASSISTANT" + MuEdChatRoleSystem MuEdChatRole = "SYSTEM" +) + +type MuEdChatMessage struct { + Role MuEdChatRole `json:"role"` + Content string `json:"content"` +} + +type MuEdChatUserPreferences struct { + Tone string `json:"tone,omitempty"` + Detail string `json:"detail,omitempty"` + Language string `json:"language,omitempty"` +} + +type MuEdChatContext struct { + Course map[string]any `json:"course,omitempty"` + Task map[string]any `json:"task,omitempty"` + Submission map[string]any `json:"submission,omitempty"` +} + +type MuEdChatLLMConfig struct { + Model string `json:"model,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"maxTokens,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + +type MuEdChatDataPolicy struct { + RetainData bool `json:"retainData"` + AllowReview bool `json:"allowReview"` +} + +type MuEdChatConfiguration struct { + LLM *MuEdChatLLMConfig `json:"llm,omitempty"` + DataPolicy *MuEdChatDataPolicy `json:"dataPolicy,omitempty"` +} + +type MuEdChatRequest struct { + Messages []MuEdChatMessage `json:"messages"` + ConversationID string `json:"conversationId,omitempty"` + User *MuEdChatUserPreferences `json:"user,omitempty"` + Context *MuEdChatContext `json:"context,omitempty"` + Configuration *MuEdChatConfiguration `json:"configuration,omitempty"` +} + +type MuEdChatResponseMetadata struct { + Tokens map[string]any `json:"tokens,omitempty"` + Model string `json:"model,omitempty"` + Timing map[string]any `json:"timing,omitempty"` +} + +type MuEdChatResponse struct { + Output MuEdChatMessage `json:"output"` + Metadata *MuEdChatResponseMetadata `json:"metadata,omitempty"` +} + +type MuEdChatHealthStatus string + +const ( + MuEdChatHealthStatusOK MuEdChatHealthStatus = "OK" + MuEdChatHealthStatusDegraded MuEdChatHealthStatus = "DEGRADED" + MuEdChatHealthStatusUnavailable MuEdChatHealthStatus = "UNAVAILABLE" +) + +type MuEdChatCapabilities struct { + Chat bool `json:"chat"` + UserPreferences bool `json:"userPreferences"` + Streaming bool `json:"streaming"` + DataPolicy bool `json:"dataPolicy"` +} + +type MuEdChatHealthResponse struct { + Status MuEdChatHealthStatus `json:"status"` + Capabilities MuEdChatCapabilities `json:"capabilities"` + SupportedLanguages []string `json:"supportedLanguages"` + SupportedModels []string `json:"supportedModels"` + SupportedAPIVersions []string `json:"supportedAPIVersions"` +} diff --git a/runtime/mued.go b/runtime/evaluate.go similarity index 60% rename from runtime/mued.go rename to runtime/evaluate.go index 4957207..363ae8a 100644 --- a/runtime/mued.go +++ b/runtime/evaluate.go @@ -159,87 +159,3 @@ func MuEdToPreviewFeedback(result map[string]any) []map[string]any { {"preSubmissionFeedback": result}, } } - -type MuEdChatRole string - -const ( - MuEdChatRoleUser MuEdChatRole = "USER" - MuEdChatRoleAssistant MuEdChatRole = "ASSISTANT" - MuEdChatRoleSystem MuEdChatRole = "SYSTEM" -) - -type MuEdChatMessage struct { - Role MuEdChatRole `json:"role"` - Content string `json:"content"` -} - -type MuEdChatUserPreferences struct { - Tone string `json:"tone,omitempty"` - Detail string `json:"detail,omitempty"` - Language string `json:"language,omitempty"` -} - -type MuEdChatContext struct { - Course map[string]any `json:"course,omitempty"` - Task map[string]any `json:"task,omitempty"` - Submission map[string]any `json:"submission,omitempty"` -} - -type MuEdChatLLMConfig struct { - Model string `json:"model,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"maxTokens,omitempty"` - Extra map[string]any `json:"extra,omitempty"` -} - -type MuEdChatDataPolicy struct { - RetainData bool `json:"retainData"` - AllowReview bool `json:"allowReview"` -} - -type MuEdChatConfiguration struct { - LLM *MuEdChatLLMConfig `json:"llm,omitempty"` - DataPolicy *MuEdChatDataPolicy `json:"dataPolicy,omitempty"` -} - -type MuEdChatRequest struct { - Messages []MuEdChatMessage `json:"messages"` - ConversationID string `json:"conversationId,omitempty"` - User *MuEdChatUserPreferences `json:"user,omitempty"` - Context *MuEdChatContext `json:"context,omitempty"` - Configuration *MuEdChatConfiguration `json:"configuration,omitempty"` -} - -type MuEdChatResponseMetadata struct { - Tokens map[string]any `json:"tokens,omitempty"` - Model string `json:"model,omitempty"` - Timing map[string]any `json:"timing,omitempty"` -} - -type MuEdChatResponse struct { - Output MuEdChatMessage `json:"output"` - Metadata *MuEdChatResponseMetadata `json:"metadata,omitempty"` -} - -type MuEdChatHealthStatus string - -const ( - MuEdChatHealthStatusOK MuEdChatHealthStatus = "OK" - MuEdChatHealthStatusDegraded MuEdChatHealthStatus = "DEGRADED" - MuEdChatHealthStatusUnavailable MuEdChatHealthStatus = "UNAVAILABLE" -) - -type MuEdChatCapabilities struct { - Chat bool `json:"chat"` - UserPreferences bool `json:"userPreferences"` - Streaming bool `json:"streaming"` - DataPolicy bool `json:"dataPolicy"` -} - -type MuEdChatHealthResponse struct { - Status MuEdChatHealthStatus `json:"status"` - Capabilities MuEdChatCapabilities `json:"capabilities"` - SupportedLanguages []string `json:"supportedLanguages"` - SupportedModels []string `json:"supportedModels"` - SupportedAPIVersions []string `json:"supportedAPIVersions"` -} diff --git a/runtime/mued_test.go b/runtime/evaluate_test.go similarity index 100% rename from runtime/mued_test.go rename to runtime/evaluate_test.go From aaa35b12c75a7ec28690651ff3a5118dd48bd29f Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 14 May 2026 14:42:08 +0100 Subject: [PATCH 03/18] Added tests for chat --- handler/chat_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 handler/chat_test.go diff --git a/handler/chat_test.go b/handler/chat_test.go new file mode 100644 index 0000000..a8ccd65 --- /dev/null +++ b/handler/chat_test.go @@ -0,0 +1,57 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// --- ServeChat tests --- + +func TestServeChat_NotImplemented(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/chat", nil) + w := httptest.NewRecorder() + newMuEdHandler(nil, nil, "").ServeChat(w, req) + assert.Equal(t, http.StatusNotImplemented, w.Result().StatusCode) +} + +func TestServeChat_Unauthorized(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/chat", nil) + req.Header.Set("api-key", "wrong") + w := httptest.NewRecorder() + newMuEdHandler(nil, nil, "secret").ServeChat(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Result().StatusCode) +} + +func TestServeChat_MethodNotAllowed(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/chat", nil) + w := httptest.NewRecorder() + newMuEdHandler(nil, nil, "").ServeChat(w, req) + assert.Equal(t, http.StatusMethodNotAllowed, w.Result().StatusCode) +} + +// --- ServeChatHealth tests --- + +func TestServeChatHealth_NotImplemented(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/chat/health", nil) + w := httptest.NewRecorder() + newMuEdHandler(nil, nil, "").ServeChatHealth(w, req) + assert.Equal(t, http.StatusNotImplemented, w.Result().StatusCode) +} + +func TestServeChatHealth_Unauthorized(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/chat/health", nil) + req.Header.Set("api-key", "wrong") + w := httptest.NewRecorder() + newMuEdHandler(nil, nil, "secret").ServeChatHealth(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Result().StatusCode) +} + +func TestServeChatHealth_MethodNotAllowed(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/chat/health", nil) + w := httptest.NewRecorder() + newMuEdHandler(nil, nil, "").ServeChatHealth(w, req) + assert.Equal(t, http.StatusMethodNotAllowed, w.Result().StatusCode) +} From 9b9409c1b734490e0f8a64d2f31078fc5acf1feb Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 14 May 2026 15:15:46 +0100 Subject: [PATCH 04/18] Implemented chat functionality, added request/response handling, and comprehensive tests for `ServeChat` and `ServeChatHealth`. --- handler/chat.go | 67 +++++++++++++++++- handler/chat_test.go | 160 ++++++++++++++++++++++++++++++++++++++++--- runtime/chat.go | 61 +++++++++++++++++ runtime/chat_test.go | 159 ++++++++++++++++++++++++++++++++++++++++++ runtime/models.go | 6 ++ 5 files changed, 443 insertions(+), 10 deletions(-) create mode 100644 runtime/chat_test.go diff --git a/handler/chat.go b/handler/chat.go index ae577f2..af86ac2 100644 --- a/handler/chat.go +++ b/handler/chat.go @@ -1,9 +1,12 @@ package handler import ( + "encoding/json" + "io" "net/http" "github.com/lambda-feedback/shimmy/internal/server" + "github.com/lambda-feedback/shimmy/runtime" ) // ServeChat handles POST /chat. @@ -17,7 +20,48 @@ func (h *MuEdHandler) ServeChat(w http.ResponseWriter, r *http.Request) { return } - http.Error(w, "not implemented", http.StatusNotImplemented) + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + var chatReq runtime.MuEdChatRequest + if err := json.Unmarshal(body, &chatReq); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + reqData, err := runtime.MuEdBuildChatRequest(chatReq) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := h.runtime.Handle(r.Context(), runtime.EvaluationRequest{ + Command: runtime.CommandChat, + Data: reqData, + }) + if err != nil { + http.Error(w, "chat failed", http.StatusInternalServerError) + return + } + + resultMap, ok := resp["result"].(map[string]any) + if !ok { + http.Error(w, "invalid response from chat function", http.StatusInternalServerError) + return + } + + chatResp, err := runtime.MuEdToChatResponse(resultMap) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(chatResp) //nolint:errcheck } // ServeChatHealth handles GET /chat/health. @@ -31,7 +75,26 @@ func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) { return } - http.Error(w, "not implemented", http.StatusNotImplemented) + resp, err := h.runtime.Handle(r.Context(), runtime.EvaluationRequest{ + Command: runtime.CommandChatHealth, + Data: map[string]any{}, + }) + if err != nil { + http.Error(w, "chat health check failed", http.StatusInternalServerError) + return + } + + resultMap, ok := resp["result"].(map[string]any) + if !ok { + http.Error(w, "invalid chat health response", http.StatusInternalServerError) + return + } + + healthResp := runtime.MuEdToChatHealthResponse(resultMap) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(healthResp) //nolint:errcheck } func NewMuEdChatRoute(handler *MuEdHandler) server.HttpHandlerResult { diff --git a/handler/chat_test.go b/handler/chat_test.go index a8ccd65..a983469 100644 --- a/handler/chat_test.go +++ b/handler/chat_test.go @@ -1,57 +1,201 @@ package handler import ( + "bytes" + "encoding/json" + "errors" + "io" "net/http" "net/http/httptest" "testing" + "github.com/lambda-feedback/shimmy/runtime" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) +// --- Helpers --- + +func chatRequestBody(t *testing.T) []byte { + t.Helper() + b, err := json.Marshal(map[string]any{ + "messages": []map[string]any{ + {"role": "USER", "content": "hello"}, + }, + }) + require.NoError(t, err) + return b +} + +func chatRuntimeResponse(role, content string) runtime.EvaluationResponse { + return runtime.EvaluationResponse{ + "command": "chat", + "result": map[string]any{ + "output": map[string]any{ + "role": role, + "content": content, + }, + }, + } +} + // --- ServeChat tests --- -func TestServeChat_NotImplemented(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/chat", nil) +func TestServeChat_Success(t *testing.T) { + mockRuntime := new(MockRuntime) + mockRuntime.On("Handle", mock.Anything, mock.MatchedBy(func(req runtime.EvaluationRequest) bool { + return req.Command == runtime.CommandChat + })).Return(chatRuntimeResponse("ASSISTANT", "Hello!"), nil) + + req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(chatRequestBody(t))) w := httptest.NewRecorder() - newMuEdHandler(nil, nil, "").ServeChat(w, req) - assert.Equal(t, http.StatusNotImplemented, w.Result().StatusCode) + + newMuEdHandler(nil, mockRuntime, "").ServeChat(w, req) + + res := w.Result() + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "application/json", res.Header.Get("Content-Type")) + + var chatResp map[string]any + require.NoError(t, json.Unmarshal(body, &chatResp)) + output, ok := chatResp["output"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "ASSISTANT", output["role"]) + assert.Equal(t, "Hello!", output["content"]) + + mockRuntime.AssertExpectations(t) } func TestServeChat_Unauthorized(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/chat", nil) + req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(chatRequestBody(t))) req.Header.Set("api-key", "wrong") w := httptest.NewRecorder() + newMuEdHandler(nil, nil, "secret").ServeChat(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Result().StatusCode) } func TestServeChat_MethodNotAllowed(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/chat", nil) w := httptest.NewRecorder() + newMuEdHandler(nil, nil, "").ServeChat(w, req) + assert.Equal(t, http.StatusMethodNotAllowed, w.Result().StatusCode) } +func TestServeChat_InvalidJSON(t *testing.T) { + mockRuntime := new(MockRuntime) + + req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader([]byte("not json"))) + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeChat(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) + mockRuntime.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) +} + +func TestServeChat_EmptyMessages(t *testing.T) { + mockRuntime := new(MockRuntime) + + body, _ := json.Marshal(map[string]any{"messages": []any{}}) + req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(body)) + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeChat(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) + mockRuntime.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) +} + +func TestServeChat_RuntimeError(t *testing.T) { + mockRuntime := new(MockRuntime) + mockRuntime.On("Handle", mock.Anything, mock.Anything). + Return(runtime.EvaluationResponse{}, errors.New("chat failed")) + + req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(chatRequestBody(t))) + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeChat(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Result().StatusCode) + mockRuntime.AssertExpectations(t) +} + // --- ServeChatHealth tests --- -func TestServeChatHealth_NotImplemented(t *testing.T) { +func TestServeChatHealth_Success(t *testing.T) { + mockRuntime := new(MockRuntime) + mockRuntime.On("Handle", mock.Anything, runtime.EvaluationRequest{ + Command: runtime.CommandChatHealth, + Data: map[string]any{}, + }).Return(runtime.EvaluationResponse{ + "command": "chat/health", + "result": map[string]any{ + "status": "OK", + "capabilities": map[string]any{ + "chat": true, + }, + "supportedLanguages": []any{}, + "supportedModels": []any{}, + "supportedAPIVersions": []any{}, + }, + }, nil) + req := httptest.NewRequest(http.MethodGet, "/chat/health", nil) w := httptest.NewRecorder() - newMuEdHandler(nil, nil, "").ServeChatHealth(w, req) - assert.Equal(t, http.StatusNotImplemented, w.Result().StatusCode) + + newMuEdHandler(nil, mockRuntime, "").ServeChatHealth(w, req) + + res := w.Result() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "application/json", res.Header.Get("Content-Type")) + + var result map[string]any + require.NoError(t, json.Unmarshal(raw, &result)) + assert.Equal(t, "OK", result["status"]) + + mockRuntime.AssertExpectations(t) } func TestServeChatHealth_Unauthorized(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/chat/health", nil) req.Header.Set("api-key", "wrong") w := httptest.NewRecorder() + newMuEdHandler(nil, nil, "secret").ServeChatHealth(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Result().StatusCode) } func TestServeChatHealth_MethodNotAllowed(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/chat/health", nil) w := httptest.NewRecorder() + newMuEdHandler(nil, nil, "").ServeChatHealth(w, req) + assert.Equal(t, http.StatusMethodNotAllowed, w.Result().StatusCode) } + +func TestServeChatHealth_RuntimeError(t *testing.T) { + mockRuntime := new(MockRuntime) + mockRuntime.On("Handle", mock.Anything, mock.Anything). + Return(runtime.EvaluationResponse{}, errors.New("worker unavailable")) + + req := httptest.NewRequest(http.MethodGet, "/chat/health", nil) + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeChatHealth(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Result().StatusCode) + mockRuntime.AssertExpectations(t) +} diff --git a/runtime/chat.go b/runtime/chat.go index 5a9a6a3..ecbfc1c 100644 --- a/runtime/chat.go +++ b/runtime/chat.go @@ -1,5 +1,10 @@ package runtime +import ( + "encoding/json" + "fmt" +) + type MuEdChatRole string const ( @@ -83,3 +88,59 @@ type MuEdChatHealthResponse struct { SupportedModels []string `json:"supportedModels"` SupportedAPIVersions []string `json:"supportedAPIVersions"` } + +// MuEdBuildChatRequest converts a MuEdChatRequest to the map sent to the worker. +func MuEdBuildChatRequest(req MuEdChatRequest) (map[string]any, error) { + if len(req.Messages) == 0 { + return nil, fmt.Errorf("messages must not be empty") + } + b, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal chat request: %w", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("failed to build chat request: %w", err) + } + return m, nil +} + +// MuEdToChatResponse transforms a worker result map into a MuEdChatResponse. +func MuEdToChatResponse(result map[string]any) (*MuEdChatResponse, error) { + b, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal chat result: %w", err) + } + var resp MuEdChatResponse + if err := json.Unmarshal(b, &resp); err != nil { + return nil, fmt.Errorf("failed to unmarshal chat response: %w", err) + } + if resp.Output.Role == "" { + return nil, fmt.Errorf("chat response missing output role") + } + if resp.Output.Content == "" { + return nil, fmt.Errorf("chat response missing output content") + } + return &resp, nil +} + +// MuEdToChatHealthResponse transforms a worker result map into a MuEdChatHealthResponse. +// nil slices are normalised to empty slices so they serialise as [] not null. +func MuEdToChatHealthResponse(result map[string]any) MuEdChatHealthResponse { + b, _ := json.Marshal(result) + var resp MuEdChatHealthResponse + json.Unmarshal(b, &resp) //nolint:errcheck + if resp.Status == "" { + resp.Status = MuEdChatHealthStatusOK + } + if resp.SupportedLanguages == nil { + resp.SupportedLanguages = []string{} + } + if resp.SupportedModels == nil { + resp.SupportedModels = []string{} + } + if resp.SupportedAPIVersions == nil { + resp.SupportedAPIVersions = []string{} + } + return resp +} diff --git a/runtime/chat_test.go b/runtime/chat_test.go new file mode 100644 index 0000000..8060068 --- /dev/null +++ b/runtime/chat_test.go @@ -0,0 +1,159 @@ +package runtime_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lambda-feedback/shimmy/runtime" +) + +// --- MuEdBuildChatRequest --- + +func TestMuEdBuildChatRequest_Valid(t *testing.T) { + req := runtime.MuEdChatRequest{ + Messages: []runtime.MuEdChatMessage{ + {Role: runtime.MuEdChatRoleUser, Content: "hello"}, + }, + } + body, err := runtime.MuEdBuildChatRequest(req) + require.NoError(t, err) + + msgs, ok := body["messages"].([]any) + require.True(t, ok) + require.Len(t, msgs, 1) + msg := msgs[0].(map[string]any) + assert.Equal(t, "USER", msg["role"]) + assert.Equal(t, "hello", msg["content"]) +} + +func TestMuEdBuildChatRequest_EmptyMessages(t *testing.T) { + req := runtime.MuEdChatRequest{Messages: []runtime.MuEdChatMessage{}} + _, err := runtime.MuEdBuildChatRequest(req) + require.Error(t, err) +} + +func TestMuEdBuildChatRequest_NilMessages(t *testing.T) { + req := runtime.MuEdChatRequest{} + _, err := runtime.MuEdBuildChatRequest(req) + require.Error(t, err) +} + +func TestMuEdBuildChatRequest_OptionalFieldsOmitted(t *testing.T) { + req := runtime.MuEdChatRequest{ + Messages: []runtime.MuEdChatMessage{ + {Role: runtime.MuEdChatRoleUser, Content: "hi"}, + }, + } + body, err := runtime.MuEdBuildChatRequest(req) + require.NoError(t, err) + + _, hasUser := body["user"] + _, hasContext := body["context"] + _, hasConversationID := body["conversationId"] + assert.False(t, hasUser) + assert.False(t, hasContext) + assert.False(t, hasConversationID) +} + +func TestMuEdBuildChatRequest_ConversationIDIncluded(t *testing.T) { + req := runtime.MuEdChatRequest{ + Messages: []runtime.MuEdChatMessage{{Role: runtime.MuEdChatRoleUser, Content: "hi"}}, + ConversationID: "abc-123", + } + body, err := runtime.MuEdBuildChatRequest(req) + require.NoError(t, err) + assert.Equal(t, "abc-123", body["conversationId"]) +} + +// --- MuEdToChatResponse --- + +func TestMuEdToChatResponse_Valid(t *testing.T) { + result := map[string]any{ + "output": map[string]any{ + "role": "ASSISTANT", + "content": "Hello there!", + }, + } + resp, err := runtime.MuEdToChatResponse(result) + require.NoError(t, err) + assert.Equal(t, runtime.MuEdChatRoleAssistant, resp.Output.Role) + assert.Equal(t, "Hello there!", resp.Output.Content) + assert.Nil(t, resp.Metadata) +} + +func TestMuEdToChatResponse_MissingRole(t *testing.T) { + result := map[string]any{ + "output": map[string]any{ + "content": "Hello", + }, + } + _, err := runtime.MuEdToChatResponse(result) + require.Error(t, err) +} + +func TestMuEdToChatResponse_MissingContent(t *testing.T) { + result := map[string]any{ + "output": map[string]any{ + "role": "ASSISTANT", + }, + } + _, err := runtime.MuEdToChatResponse(result) + require.Error(t, err) +} + +func TestMuEdToChatResponse_MetadataForwarded(t *testing.T) { + result := map[string]any{ + "output": map[string]any{ + "role": "ASSISTANT", + "content": "Hi", + }, + "metadata": map[string]any{ + "model": "gpt-4", + }, + } + resp, err := runtime.MuEdToChatResponse(result) + require.NoError(t, err) + require.NotNil(t, resp.Metadata) + assert.Equal(t, "gpt-4", resp.Metadata.Model) +} + +// --- MuEdToChatHealthResponse --- + +func TestMuEdToChatHealthResponse_Valid(t *testing.T) { + result := map[string]any{ + "status": "DEGRADED", + "capabilities": map[string]any{ + "chat": true, + }, + "supportedLanguages": []any{"en"}, + "supportedModels": []any{"gpt-4"}, + "supportedAPIVersions": []any{"1.0"}, + } + resp := runtime.MuEdToChatHealthResponse(result) + assert.Equal(t, runtime.MuEdChatHealthStatusDegraded, resp.Status) + assert.True(t, resp.Capabilities.Chat) + assert.Equal(t, []string{"en"}, resp.SupportedLanguages) + assert.Equal(t, []string{"gpt-4"}, resp.SupportedModels) + assert.Equal(t, []string{"1.0"}, resp.SupportedAPIVersions) +} + +func TestMuEdToChatHealthResponse_DefaultsStatusOK(t *testing.T) { + resp := runtime.MuEdToChatHealthResponse(map[string]any{}) + assert.Equal(t, runtime.MuEdChatHealthStatusOK, resp.Status) +} + +func TestMuEdToChatHealthResponse_NilSlicesDefaultToEmpty(t *testing.T) { + resp := runtime.MuEdToChatHealthResponse(map[string]any{}) + + raw, err := json.Marshal(resp) + require.NoError(t, err) + + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + assert.Equal(t, []any{}, out["supportedLanguages"]) + assert.Equal(t, []any{}, out["supportedModels"]) + assert.Equal(t, []any{}, out["supportedAPIVersions"]) +} diff --git a/runtime/models.go b/runtime/models.go index 8e8fa1a..66c0f25 100644 --- a/runtime/models.go +++ b/runtime/models.go @@ -16,6 +16,12 @@ const ( // CommandHealth is the command for healthcheck CommandHealth = "healthcheck" + + // CommandChat is the command for chat. + CommandChat Command = "chat" + + // CommandChatHealth is the command for the chat health check. + CommandChatHealth Command = "chat/health" ) // ParseCommand parses a command from a given path. From 4562d8309f6d5e85bf5b14fa9b2a0077c4812d55 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 14 May 2026 16:51:46 +0100 Subject: [PATCH 05/18] Added OpenAPI request/response validation middleware and integrated OpenAPI specification --- api/spec.go | 6 ++++ go.mod | 13 ++++++- go.sum | 22 ++++++++++++ internal/server/module.go | 2 ++ internal/server/openapi.go | 70 ++++++++++++++++++++++++++++++++++++++ internal/server/server.go | 5 ++- 6 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 api/spec.go create mode 100644 internal/server/openapi.go diff --git a/api/spec.go b/api/spec.go new file mode 100644 index 0000000..6885600 --- /dev/null +++ b/api/spec.go @@ -0,0 +1,6 @@ +package api + +import _ "embed" + +//go:embed openapi.yml +var OpenAPISpec []byte diff --git a/go.mod b/go.mod index 1f343f2..10caf84 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/lambda-feedback/shimmy -go 1.24.5 +go 1.25 require ( github.com/aws/aws-lambda-go v1.46.0 @@ -23,12 +23,23 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/StackExchange/wmi v1.2.1 // indirect github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/getkin/kin-openapi v0.138.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/holiman/uint256 v1.2.4 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oasdiff/yaml v0.0.9 // indirect + github.com/oasdiff/yaml3 v0.0.12 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect golang.org/x/crypto v0.24.0 // indirect diff --git a/go.sum b/go.sum index b65c019..014f78c 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/ethereum/go-ethereum v1.14.5 h1:szuFzO1MhJmweXjoM5nSAeDvjNUH3vIQoMzzQ github.com/ethereum/go-ethereum v1.14.5/go.mod h1:VEDGGhSxY7IEjn98hJRFXl/uFvpRgbIIf2PpXiyGGgc= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4= +github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -38,6 +40,10 @@ github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= @@ -48,6 +54,8 @@ github.com/holiman/uint256 v1.2.4 h1:jUc4Nk8fm9jZabQuqr2JzednajVmBpC+oiTiXZJEApU github.com/holiman/uint256 v1.2.4/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU= @@ -60,18 +68,28 @@ github.com/knadh/koanf/providers/file v0.1.0 h1:fs6U7nrV58d3CFAFh8VTde8TM262ObYf github.com/knadh/koanf/providers/file v0.1.0/go.mod h1:rjJ/nHQl64iYCtAW2QQnF0eSmDEX/YZ/eNFj5yR6BvA= github.com/knadh/koanf/v2 v2.1.0 h1:eh4QmHHBuU8BybfIJ8mB8K8gsGCD/AUQTdwGq/GzId8= github.com/knadh/koanf/v2 v2.1.0/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY= github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= +github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= +github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= +github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.27.7 h1:fVih9JD6ogIiHUN6ePK7HJidyEDpWGVB5mzM7cWNXoU= github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -80,6 +98,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -96,6 +116,8 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= diff --git a/internal/server/module.go b/internal/server/module.go index f41556b..7644bed 100644 --- a/internal/server/module.go +++ b/internal/server/module.go @@ -6,6 +6,8 @@ func Module(config HttpConfig) fx.Option { return fx.Module("server", // provide config fx.Supply(config), + // provide openapi spec + fx.Provide(LoadOpenAPISpec), // provide server fx.Provide(NewLifecycleServer), // invoke server diff --git a/internal/server/openapi.go b/internal/server/openapi.go new file mode 100644 index 0000000..53a5114 --- /dev/null +++ b/internal/server/openapi.go @@ -0,0 +1,70 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/legacy" + "go.uber.org/zap" + + "github.com/lambda-feedback/shimmy/api" +) + +func LoadOpenAPISpec() (*openapi3.T, error) { + loader := openapi3.NewLoader() + return loader.LoadFromData(api.OpenAPISpec) +} + +func OpenAPIMiddleware(spec *openapi3.T, log *zap.Logger) func(http.Handler) http.Handler { + router, _ := legacy.NewRouter(spec) + opts := &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc} + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + route, pathParams, err := router.FindRoute(r) + if err != nil { + // Not a µEd route — pass through unvalidated + next.ServeHTTP(w, r) + return + } + + // Validate request + reqInput := &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + Options: opts, + } + if err := openapi3filter.ValidateRequest(r.Context(), reqInput); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Capture response for validation + rec := httptest.NewRecorder() + next.ServeHTTP(rec, r) + + // Validate response (lenient — log only) + respInput := &openapi3filter.ResponseValidationInput{ + RequestValidationInput: reqInput, + Status: rec.Code, + Header: rec.Header(), + Body: io.NopCloser(rec.Body), + Options: opts, + } + if err := openapi3filter.ValidateResponse(r.Context(), respInput); err != nil { + log.Warn("response failed OpenAPI validation", zap.Error(err)) + } + + // Forward captured response + for k, v := range rec.Header() { + w.Header()[k] = v + } + w.WriteHeader(rec.Code) + w.Write(rec.Body.Bytes()) //nolint:errcheck + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 883be2f..c9c7c84 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,6 +6,7 @@ import ( "net" "net/http" + "github.com/getkin/kin-openapi/openapi3" "go.uber.org/fx" "go.uber.org/zap" "golang.org/x/net/http2" @@ -18,6 +19,7 @@ type HttpServerParams struct { Context context.Context Config HttpConfig + Spec *openapi3.T Handlers []*HttpHandler `group:"handlers"` Logger *zap.Logger @@ -39,8 +41,9 @@ func NewHttpServer(params HttpServerParams) *HttpServer { } var handler http.Handler = NormalizePath(mux) + handler = OpenAPIMiddleware(params.Spec, params.Logger)(handler) if params.Config.H2c { - handler = h2c.NewHandler(NormalizePath(mux), &http2.Server{}) + handler = h2c.NewHandler(handler, &http2.Server{}) } server := &http.Server{ From 576037d1ba7a1106f91dbc4743fdeb5b2a2cfef7 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 14 May 2026 17:01:17 +0100 Subject: [PATCH 06/18] =?UTF-8?q?Add=20embedded=20=C2=B5Ed=20OpenAPI=20spe?= =?UTF-8?q?cification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- api/openapi.yml | 2050 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2050 insertions(+) create mode 100644 api/openapi.yml diff --git a/api/openapi.yml b/api/openapi.yml new file mode 100644 index 0000000..c5ca3c8 --- /dev/null +++ b/api/openapi.yml @@ -0,0 +1,2050 @@ +openapi: 3.1.0 +info: + title: µEd API - Educational Microservices + version: 0.1.0 + contact: + name: µEd API Maintainers + description: | + The µEd API ("microservices for education") is a specification for interoperable educational services.

Currently defined endpoints:
- **Evaluate Task**: automatic feedback and grading for student submissions.
- **Chat**: conversational interactions around tasks, submissions, or general + learning questions. +tags: + - name: evaluate + description: Endpoints for evaluating student submissions and generating feedback. + - name: chat + description: Conversational endpoints for educational dialogue. +paths: + /evaluate: + post: + summary: Evaluate a submission and generate feedback + operationId: evaluateSubmission + description: | + Generates a list of feedback items for a given student submission. The request can optionally include the task context, user information, criteria to evaluate on, pre-submission feedback options, configuration, and a callback URL for asynchronous result delivery. + tags: + - evaluate + parameters: + - $ref: '#/components/parameters/Authorization' + - $ref: '#/components/parameters/X-Request-Id' + - $ref: '#/components/parameters/X-Api-Version' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluateRequest' + examples: + simpleTextSubmission: + summary: Simple text submission without user or criteria + value: + submission: + submissionId: sub-123 + taskId: task-42 + type: TEXT + format: plain + content: + text: Explain what polymorphism is in object-oriented programming. + submittedAt: '2025-12-16T09:30:00Z' + version: 1 + configuration: null + preSubmissionFeedbackExample: + summary: Pre-submission feedback (non-final) + value: + submission: + submissionId: sub-777 + taskId: task-42 + type: TEXT + format: plain + content: + text: My short answer... + preSubmissionFeedback: + enabled: true + configuration: + llm: + model: gpt-5.2 + temperature: 0.4 + asyncCallbackExample: + summary: Asynchronous processing via callback URL + value: + submission: + submissionId: sub-async-001 + taskId: task-42 + type: TEXT + format: plain + content: + text: Detailed essay answer that may require longer processing. + submittedAt: '2025-12-16T09:45:00Z' + version: 1 + callbackUrl: https://learning-platform.example.com/hooks/evaluate-result + withTaskAndExtras: + summary: With task context, user, criteria and configuration + value: + task: + taskId: task-12 + title: Explain polymorphism + content: + text: Define polymorphism and give at least one example in Java. + learningObjectives: + - Explain the concept of polymorphism. + - Provide an example of subtype polymorphism in Java. + referenceSolution: + text: | + Polymorphism allows the same method call to result in different behavior depending on the object's runtime type. For example, a variable of type Shape can reference a Circle or Rectangle, and calling draw() will invoke the appropriate implementation. + context: + constraints: Answer in 3-6 sentences. + language: en + submission: + submissionId: sub-456 + taskId: task-12 + type: TEXT + format: plain + content: + text: | + Polymorphism means that an object can take many forms, for example subclasses implementing methods differently. + submittedAt: '2025-12-16T10:00:00Z' + version: 2 + user: + userId: user-789 + type: LEARNER + detailPreference: DETAILED + tonePreference: FRIENDLY + languagePreference: en + criteria: + - criterionId: crit-1 + name: Correctness + context: The explanation of polymorphism is conceptually correct. + maxPoints: 10 + - criterionId: crit-2 + name: Clarity + context: The explanation is clear, well-structured, and easy to understand. + maxPoints: 5 + preSubmissionFeedback: + enabled: false + configuration: + llm: + model: gpt-5.2 + temperature: 0.2 + maxTokens: 800 + credentials: + type: JWT + key: Some-Key + enforceRubricStrictness: true + codeSubmissionExample: + summary: Code submission (Python) + value: + submission: + submissionId: sub-code-001 + taskId: task-python-101 + type: CODE + format: python + content: + code: | + def fibonacci(n): + if n <= 1: + return n + return fibonacci(n-1) + fibonacci(n-2) + + # Test the function + for i in range(10): + print(fibonacci(i)) + submittedAt: '2025-12-16T11:00:00Z' + version: 1 + codeMultiFileExample: + summary: Code submission with multiple files + value: + submission: + submissionId: sub-code-002 + taskId: task-java-201 + type: CODE + format: java + content: + files: + - path: src/Main.java + content: | + public class Main { + public static void main(String[] args) { + Calculator calc = new Calculator(); + System.out.println(calc.add(2, 3)); + } + } + - path: src/Calculator.java + content: | + public class Calculator { + public int add(int a, int b) { + return a + b; + } + } + entryPoint: src/Main.java + submittedAt: '2025-12-16T11:30:00Z' + version: 1 + codeSympyExample: + summary: Math submission (SymPy/Python) + value: + submission: + submissionId: sub-math-002 + taskId: task-algebra-101 + type: CODE + format: sympy + content: + expression: solve(x**2 - 4, x) + imports: + - from sympy import symbols, solve + - x = symbols('x') + submittedAt: '2025-12-16T12:30:00Z' + version: 1 + mathInlineLatexExample: + summary: Inline math submission (Inline LaTeX) + value: + submission: + submissionId: sub-math-001 + taskId: task-calculus-101 + type: MATH + format: latex + content: + expression: \int_{0}^{\infty} e^{-x^2} dx = \frac{\sqrt{\pi}}{2} + submittedAt: '2025-12-16T12:00:00Z' + version: 1 + mathMathMLExample: + summary: Math submission (MathML) + value: + submission: + submissionId: sub-math-003 + taskId: task-geometry-101 + type: MATH + format: mathml + content: + expression: | + + + a + = + + + b2 + + + c2 + + + + + submittedAt: '2025-12-16T13:00:00Z' + version: 1 + modelUmlExample: + summary: Model submission (UML class diagram - PlantUML) + value: + submission: + submissionId: sub-model-001 + taskId: task-oop-design-101 + type: MODEL + format: uml + content: + model: | + @startuml + abstract class Animal { + +name: String + +speak(): String + } + + class Dog extends Animal { + +speak(): String + } + + class Cat extends Animal { + +speak(): String + } + @enduml + notation: plantuml + diagramType: class + submittedAt: '2025-12-16T14:00:00Z' + version: 1 + modelErExample: + summary: Model submission (ER diagram - JSON structure) + value: + submission: + submissionId: sub-model-002 + taskId: task-database-101 + type: MODEL + format: er + content: + model: + entities: + - name: Student + attributes: + - name: student_id + type: INTEGER + primaryKey: true + - name: name + type: VARCHAR(100) + - name: email + type: VARCHAR(255) + - name: Course + attributes: + - name: course_id + type: INTEGER + primaryKey: true + - name: title + type: VARCHAR(200) + relationships: + - name: enrolls_in + from: Student + to: Course + cardinality: many-to-many + notation: json + submittedAt: '2025-12-16T14:30:00Z' + version: 1 + modelBpmnExample: + summary: Model submission (BPMN process) + value: + submission: + submissionId: sub-model-003 + taskId: task-process-101 + type: MODEL + format: bpmn + content: + model: | + + + + + + + + + + + notation: bpmn-xml + submittedAt: '2025-12-16T15:00:00Z' + version: 1 + textMarkdownExample: + summary: Text submission (Markdown with formatting) + value: + submission: + submissionId: sub-text-002 + taskId: task-essay-101 + type: TEXT + format: markdown + content: + markdown: | + # Introduction to Polymorphism + + Polymorphism is a fundamental concept in **object-oriented programming** that allows objects to be treated as instances of their parent class. + + ## Key Points + + 1. **Subtype polymorphism**: Different classes can be used interchangeably + 2. **Method overriding**: Subclasses provide specific implementations + submittedAt: '2025-12-16T15:30:00Z' + version: 1 + responses: + '200': + description: Successfully generated feedback. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Feedback' + examples: + exampleResponse: + summary: Example feedback response + value: + - feedbackId: fb-1 + title: Clarify your definition + message: Your explanation of polymorphism is generally correct, but it would help to distinguish between subtype polymorphism and parametric polymorphism. + suggestedAction: Add one or two concrete examples of polymorphism in Java, e.g., method overriding. + awardedPoints: 2.5 + criterion: + criterionId: crit-1 + name: Correctness + context: The solution produces correct results for the specified problem. + maxPoints: 10 + target: + artefactType: TEXT + format: plain + locator: + type: span + startIndex: 0 + endIndex: 120 + - feedbackId: fb-2 + title: Overall structure + message: The overall structure of your answer is clear and easy to follow. + '202': + $ref: '#/components/responses/202-Accepted' + '400': + $ref: '#/components/responses/400-BadRequest' + '403': + $ref: '#/components/responses/403-Forbidden' + '406': + $ref: '#/components/responses/406-VersionNotSupported' + '500': + $ref: '#/components/responses/500-InternalError' + '501': + $ref: '#/components/responses/501-NotImplemented' + /evaluate/health: + get: + summary: Health and capabilities of the evaluate service + operationId: getEvaluateHealth + description: | + Returns health information and capabilities of the evaluate service. Clients can use this endpoint to discover whether the service supports optional features such as pre-submission feedback, formative feedback, and summative feedback. + tags: + - evaluate + parameters: + - $ref: '#/components/parameters/X-Request-Id' + - $ref: '#/components/parameters/X-Api-Version' + responses: + '200': + description: Evaluate service is reachable and reporting capabilities. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluateHealthResponse' + examples: + exampleHealth: + summary: Example healthy service with capabilities + value: + status: OK + message: Service healthy + version: 1.0.0 + capabilities: + supportsEvaluate: true + supportsPreSubmissionFeedback: false + supportsFormativeFeedback: true + supportsSummativeFeedback: true + supportsDataPolicy: PARTIAL + supportedArtefactProfiles: + - type: TEXT + supportedFormats: + - plain + - markdown + - type: CODE + supportedFormats: + - python + - java + - javascript + - type: MATH + supportedFormats: + - latex + - mathml + supportedLanguages: + - en + - de + supportedVersions: + - 0.1.0 + '406': + $ref: '#/components/responses/406-VersionNotSupported' + '501': + description: The server does not implement the health endpoint for evaluate. + $ref: '#/components/responses/501-NotImplemented' + '503': + $ref: '#/components/responses/503-ServiceUnavailable' + /chat: + post: + summary: Chat about tasks, submissions, or learning topics + operationId: chat + description: | + Conversational endpoint for educational chat use cases. A conversation can be grounded in a specific course, task or submission and may use user information to adapt tone and detail. Typical use cases include: asking follow-up questions on feedback, requesting hints, or clarifying concepts. + tags: + - chat + parameters: + - $ref: '#/components/parameters/Authorization' + - $ref: '#/components/parameters/X-Request-Id' + - $ref: '#/components/parameters/X-Api-Version' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatRequest' + examples: + minimalChat: + summary: Minimal chat request + value: + messages: + - role: USER + content: Can you explain polymorphism? + configuration: null + chatWithLlmConfig: + summary: Chat request with optional LLM configuration + value: + conversationId: conv-1001 + user: + userId: user-456 + type: LEARNER + detailPreference: MEDIUM + tonePreference: FRIENDLY + languagePreference: en + messages: + - role: USER + content: Give me a hint for my answer about polymorphism. + context: + task: + taskId: task-12 + title: Explain polymorphism + content: + text: Define polymorphism and give at least one example in Java. + configuration: + type: Java Assistant + llm: + model: gpt-5.2 + temperature: 0.7 + stream: false + credentials: + type: JWT + key: Some-Key + chatWithContext: + summary: Chat request with complex educational context + value: + messages: + - role: USER + content: What should I do for this part? + user: + userId: user-321 + type: LEARNER + detailPreference: DETAILED + tonePreference: NEUTRAL + languagePreference: en + taskProgress: + currentQuestionId: question-321 + timeSpentOnQuestion: 30 minutes + currentPart: + partId: part-1 + timeSpentOnPart: 10 minutes + submission: + type: TEXT + content: + text: outputs= ["Woof!", "Meow!"] + feedback: + - feedbackId: fb-101 + message: Incomplete answer, explain why these outputs occur. + context: + module: + moduleId: module-456 + title: Introduction to Object-Oriented Programming (OOP) + set: + setId: set-789 + title: Fundamentals + question: + questionId: question-321 + title: Understanding Polymorphism + content: | + Answer the questions for the following example of polymorphism in Python. + ```python class Animal: + def speak(self): + pass + + class Dog(Animal): + def speak(self): + return "Woof!" + + class Cat(Animal): + def speak(self): + return "Meow!" + + animals = [Dog(), Cat()] for animal in animals: + print(animal.speak()) + ``` + estimatedTime: 15-25 minutes + parts: + - partId: part-1 + content: | + Looking at the code example, identify which method is being overridden and explain how this demonstrates polymorphism. What output would the code produce and why? + - partId: part-2 + content: | + Write a new class called `Bird` that inherits from `Animal` and overrides the `speak()` method to return "Tweet!". Then add an instance of `Bird` to the animals list. + referenceSolution: + code: | + class Bird(Animal): + def speak(self): + return "Tweet!" + + animals = [Dog(), Cat(), Bird()] + for animal in animals: + print(animal.speak()) + configuration: + type: CS Assistant + llm: + model: gpt-5.2 + temperature: 0.5 + responses: + '200': + description: Successful chat response. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ChatResponse' + examples: + minimalChatResponse: + summary: Minimal chat response + value: + output: + role: ASSISTANT + content: | + Polymorphism is a core concept in object-oriented programming that allows objects of different classes to be treated as objects of a common superclass. It enables a single interface to represent different underlying forms (data types). For example, in Java, you can have a superclass `Animal` with a method `speak()`, and subclasses like `Dog` and `Cat` that provide their own implementations of `speak()`. When you call `speak()` on an `Animal` reference, the appropriate subclass method is invoked based on the actual object type at runtime. + metadata: null + chatWithLlmConfigResponse: + summary: Chat response with LLM configuration + value: + output: + role: ASSISTANT + content: | + Here's a hint for your answer about polymorphism: Focus on how polymorphism allows methods to do different things based on the object that it is acting upon, even when accessed through a common interface. You might want to mention method overriding and how it enables this behavior in object-oriented programming. + metadata: + responseTimeMs: 1800 + type: Java Assistant + createdAt: '2025-12-10T11:15:00Z' + llm: + model: gpt-5.2 + temperature: 0.7 + outputTokens: 78 + chatWithContextResponse: + summary: Chat response with context + value: + output: + role: ASSISTANT + content: | + In the provided code example, the `speak()` method is being overridden in the `Dog` and `Cat` subclasses of the `Animal` superclass. This demonstrates polymorphism because when we call `speak()` on each animal in the `animals` list, the method that gets executed depends on the actual object type (either `Dog` or `Cat`), not the reference type (`Animal`). The output of the code would be: + ``` Woof! Meow! ``` + This happens because each subclass provides its own implementation of the `speak()` method, and at runtime, the correct method is called based on the object's type. + metadata: + responseTimeMs: 2000 + type: CS Assistant + createdAt: '2025-12-10T11:15:00Z' + llm: + model: gpt-5.2 + temperature: 0.5 + outputTokens: 143 + '400': + $ref: '#/components/responses/400-BadRequest-2' + '403': + $ref: '#/components/responses/403-Forbidden' + '406': + $ref: '#/components/responses/406-VersionNotSupported' + '500': + $ref: '#/components/responses/500-InternalError-2' + '501': + $ref: '#/components/responses/501-NotImplemented-2' + /chat/health: + get: + summary: Health and capabilities of the chat service + operationId: getChatHealth + description: | + Returns health information and capabilities of the chat service. Clients can use this endpoint to discover whether the service supports optional features such as user preferences or streaming responses. + tags: + - chat + parameters: + - $ref: '#/components/parameters/X-Request-Id' + - $ref: '#/components/parameters/X-Api-Version' + responses: + '200': + description: Chat service is reachable and reporting capabilities. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ChatHealthResponse' + examples: + exampleHealth: + summary: Example healthy service with capabilities + value: + status: OK + statusMessage: Service healthy + version: 1.0.0 + capabilities: + supportsChat: true + supportsUserPreferences: true + supportsStreaming: true + supportsDataPolicy: NOT_SUPPORTED + supportedLanguages: + - en + - de + supportedModels: + - gpt-4o + - llama-3 + supportedVersions: + - 0.1.0 + '406': + $ref: '#/components/responses/406-VersionNotSupported' + '501': + description: The server does not implement the health endpoint for chat. + $ref: '#/components/responses/501-NotImplemented-2' + '503': + $ref: '#/components/responses/503-ServiceUnavailable-2' +components: + parameters: + Authorization: + in: header + name: Authorization + schema: + type: string + required: false + description: Optional authorization header. + X-Request-Id: + in: header + name: X-Request-Id + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + in: header + name: X-Api-Version + description: | + The µEd API version the client is targeting (e.g. "0.1.0"). If omitted, the server will use the latest version it supports. If the requested version cannot be served, the server returns 406 Version Not Supported. + required: false + schema: + type: string + example: 0.1.0 + schemas: + Task: + type: object + description: | + Task context including the content, learning objectives, optional reference solution, optional context information, and optional metadata. + required: + - title + properties: + taskId: + type: string + description: Optional unique identifier for the task. + title: + type: string + description: Short title or label for the task. + content: + type: + - object + - 'null' + description: Optional content shown to the learner (structure is task-specific). + additionalProperties: true + context: + type: + - object + - 'null' + description: Optional educational context (e.g., course material). + additionalProperties: true + learningObjectives: + type: + - array + - 'null' + description: Optional list of learning objectives addressed by this task. + items: + type: string + referenceSolution: + type: + - object + - 'null' + description: Optional reference or example solution (structure is task-specific). + additionalProperties: true + metadata: + type: + - object + - 'null' + description: Optional metadata such as difficulty, topic, tags, etc. + additionalProperties: true + ArtefactType: + type: string + description: | + High-level type of artefact. Use the 'format' field to specify the exact format (e.g., programming language for CODE, notation for MATH). + enum: + - TEXT + - CODE + - MODEL + - MATH + - OTHER + Submission: + type: object + description: | + A student's submission for a task. The structure of 'content' is intentionally generic and task-specific. + required: + - type + - content + properties: + submissionId: + type: string + description: Optional unique identifier of the submission. + taskId: + type: + - string + - 'null' + description: Optional identifier of the task this submission belongs to. + type: + $ref: '#/components/schemas/ArtefactType' + format: + type: + - string + - 'null' + description: | + Optional format specifier providing additional detail about the artefact. For TEXT: plain_text, markdown, html, rich_text, etc. For CODE: programming language (e.g., python, java, javascript, wolfram, matlab). For MATH: latex, mathml, sympy, wolfram, asciimath, etc. For MODEL: uml, er, bpmn, petri_net, state_machine, etc. Use lowercase values and snakecase. Services should document which formats they support. + content: + type: object + additionalProperties: true + description: | + Logical representation of the submission content. The expected structure depends on the artefact type: - TEXT: { text: string } or { markdown: string } - CODE: { code: string } or { files: [{ path: string, content: string }], entryPoint?: string } - MATH: { expression: string } - MODEL: { model: string | object, notation?: string } + submittedAt: + type: + - string + - 'null' + format: date-time + description: Optional timestamp when the submission was created. + version: + type: + - integer + - 'null' + format: int32 + description: Optional version number (e.g., resubmissions). + UserType: + type: string + description: Type of user interacting with the API. + enum: + - LEARNER + - TEACHER + - EDU_ADMIN + - SYS_ADMIN + - OTHER + Detail: + type: string + description: Level of detail preferred in responses or feedback. + enum: + - BRIEF + - MEDIUM + - DETAILED + Tone: + type: string + description: Preferred tone for responses or feedback. + enum: + - FORMAL + - NEUTRAL + - FRIENDLY + User: + type: object + description: User information including type and optional preferences influencing response tone, detail, and language. + required: + - type + additionalProperties: true + properties: + userId: + type: + - string + - 'null' + description: Optional unique identifier for the user. + type: + $ref: '#/components/schemas/UserType' + preference: + type: object + properties: + detail: + description: Optional preferred level of detail in responses. + $ref: '#/components/schemas/Detail' + tone: + description: Optional preferred tone for responses. + $ref: '#/components/schemas/Tone' + language: + type: + - string + - 'null' + description: Optional preferred language code following ISO 639 language codes (e.g., 'en', 'de'). + additionalProperties: true + taskProgress: + type: + - object + - 'null' + description: Optional information about the user's progress on this task/topic. + additionalProperties: true + NumericGrade: + title: Numeric Grade + type: object + required: + - min + - max + - value + properties: + min: + type: number + description: Minimum value for the numeric range + max: + type: number + description: Maximum value for the numeric range + value: + type: number + description: The actual rating value within the min-max range + LetterOnlyGrade: + title: Letter Only Grade + type: object + required: + - value + properties: + value: + type: string + enum: + - A + - B + - C + - D + - E + - F + - n/a + LetterPlusMinusGrade: + title: Letter +/- grades + type: object + required: + - value + properties: + value: + type: string + enum: + - A+ + - A + - A- + - B+ + - B + - B- + - C+ + - C + - C- + - D+ + - D + - D- + - E+ + - E + - E- + - F + OtherGrade: + title: Other + type: object + required: + - value + properties: + value: + type: string + description: Free-form string rating + Criterion: + type: object + description: A criterion used to assess one dimension of a submission. + required: + - name + properties: + criterionId: + type: string + description: Optional unique identifier of the criterion. + name: + type: string + description: Human-readable name of the criterion. + context: + type: + - string + - object + - 'null' + description: Optional additional context about how to apply this criterion. + additionalProperties: true + gradeConfig: + oneOf: + - $ref: '#/components/schemas/NumericGrade' + - $ref: '#/components/schemas/LetterOnlyGrade' + - $ref: '#/components/schemas/LetterPlusMinusGrade' + - $ref: '#/components/schemas/OtherGrade' + description: Optional configuration for grades for this criterion. + PreSubmissionFeedback: + type: object + description: Optional configuration for pre-submission feedback runs. + required: + - enabled + additionalProperties: true + properties: + enabled: + type: boolean + description: Indicates whether pre-submission feedback is requested. + LLMConfiguration: + type: object + description: | + Optional configuration for an LLM provider. All fields are optional and provider-specific values may be included via additional properties. + additionalProperties: true + properties: + model: + type: + - string + - 'null' + description: Optional model identifier (e.g., 'gpt-4o', 'llama-3'). + temperature: + type: + - number + - 'null' + description: Optional sampling temperature. + maxTokens: + type: + - integer + - 'null' + description: Optional maximum number of tokens to generate. + stream: + type: + - boolean + - 'null' + description: Optional flag indicating whether streaming responses are requested. + credentials: + type: + - object + - 'null' + description: Optional credentials object to be supplied with time-based key via a proxy. + additionalProperties: true + Region: + type: string + description: | + Geographic regions using ISO 3166-1 alpha-2 country codes (e.g., US, GB, DE) or regional groupings (e.g., EEA, EU, APAC). + AnonymizationLevel: + type: string + description: Level of required anonymization. + enum: + - NONE + - PSEUDONYMIZED + - ANONYMIZED + - AGGREGATED + DataPolicy: + type: object + description: | + Declares what downstream services are allowed to do with data associated with this request: which legal regimes apply, what uses are permitted, how long data may be retained, where it may be processed, and what constraints apply (especially for children / sensitive data). + additionalProperties: true + properties: + legal: + type: + - object + - 'null' + description: Legal framework and authority governing this data. + properties: + applicableLaws: + type: array + description: One or more applicable legal regimes. + items: + type: string + enum: + - GDPR + - UK_GDPR + - EPRIVACY + - CCPA_CPRA + - COPPA + - FERPA + - PPRA + - PIPEDA + - LGPD + - POPIA + - APPI + - PIPL + - OTHER + legalBasis: + type: + - array + - 'null' + description: Optional but recommended legal basis for processing. + items: + type: string + enum: + - CONSENT + - CONTRACT + - LEGAL_OBLIGATION + - PUBLIC_TASK + - LEGITIMATE_INTERESTS + - VITAL_INTERESTS + - OTHER + jurisdiction: + type: + - object + - 'null' + description: Geographic constraints for data subjects and processing. + properties: + dataSubjectRegions: + type: + - array + - 'null' + description: Where the data subjects are located. + items: + $ref: '#/components/schemas/Region' + allowedProcessingRegions: + type: + - array + - 'null' + description: Where processing/storage is allowed. + items: + $ref: '#/components/schemas/Region' + disallowedProcessingRegions: + type: + - array + - 'null' + description: Explicit exclusions for processing regions. + items: + $ref: '#/components/schemas/Region' + dataSubject: + type: + - object + - 'null' + description: Information about who this data is about. + properties: + population: + type: + - string + - 'null' + description: Type of population this data concerns. + enum: + - STUDENT + - STAFF + - GUARDIAN + - MIXED + - OTHER + isChildData: + type: + - boolean + - 'null' + description: Indicates whether this data concerns children. If true, then minAge required. + minAge: + type: + - integer + - 'null' + description: Minimum age of data subjects, if relevant/known. + dataCategory: + type: + - object + - 'null' + description: Classification of the type of data. + properties: + classification: + type: + - string + - 'null' + description: Primary classification of the data. + enum: + - ANONYMOUS + - PSEUDONYMOUS + - PERSONAL + - EDUCATION_RECORD + - SENSITIVE + - OTHER + additionalProperties: true + retentionPermission: + type: + - array + - 'null' + description: What retention and secondary uses are permitted. + items: + type: string + enum: + - NEVER + - SECURITY + - LOGGING + - PRODUCT-IMPROVEMENT-NO-SHARE + - PRODUCT-IMPROVEMENT-SHARE-LIMITED + - RESEARCH-CONFIDENTIAL + - PUBLIC + - OTHER + retention: + type: + - object + - 'null' + description: How long data may be retained. + properties: + retentionPeriod: + type: + - string + - 'null' + description: Concrete retention period, following ISO 8601 standard. + deleteOnRequest: + type: + - boolean + - 'null' + description: Whether data must be deleted on user request. + legalHoldAllowed: + type: + - boolean + - 'null' + description: Whether legal holds are permitted on this data. + sharing: + type: + - object + - 'null' + description: Constraints on who can receive this data. + properties: + thirdPartySharing: + type: + - string + - 'null' + description: Third-party sharing policy. + enum: + - PROHIBITED + - ALLOWED + - ALLOWED-LIMITED + subprocessorsAllowed: + type: + - boolean + - 'null' + description: Whether subprocessors are allowed. + allowedRecipients: + type: + - array + - 'null' + description: Categories of allowed recipients. + items: + type: string + enum: + - CONTROLLER-ONLY + - INTERNAL-SERVICES + - NAMED-PARTNERS + - PUBLIC + deidentification: + type: + - object + - 'null' + description: Required deidentification for specific uses. + properties: + requiredForServiceImprovement: + $ref: '#/components/schemas/AnonymizationLevel' + requiredForResearch: + $ref: '#/components/schemas/AnonymizationLevel' + ExecutionPolicy: + type: object + description: | + Declares execution constraints that the server or client consuming this API should apply when triggering calls to external providers (such as LLMs). Covers queue management and response timeouts. + additionalProperties: true + properties: + priority: + type: + - string + - 'null' + description: Request priority for queue management when capacity is constrained. + enum: + - low + - normal + - high + - null + timeout: + type: + - integer + - 'null' + description: Maximum time in milliseconds to wait for a complete response before failing. + minimum: 1 + EvaluateRequest: + type: object + description: | + Input for task evaluate service. The submission is mandatory; task, user, criteria, pre-submission feedback options, and configuration, and callback URL are optional. + required: + - submission + properties: + task: + description: | + Optional task context that can include prompt content, learning objectives, a reference solution, and additional context metadata. + type: + - object + - 'null' + allOf: + - $ref: '#/components/schemas/Task' + submission: + $ref: '#/components/schemas/Submission' + user: + description: Optional user information including type and preferences that can influence response tone, detail, and language. + type: + - object + - 'null' + allOf: + - $ref: '#/components/schemas/User' + criteria: + type: + - array + - 'null' + description: Optional criteria used for evaluate. + items: + $ref: '#/components/schemas/Criterion' + preSubmissionFeedback: + description: | + Optional settings for pre-submission feedback (non-final). When enabled, the service should avoid committing or finalizing grades. + type: + - object + - 'null' + allOf: + - $ref: '#/components/schemas/PreSubmissionFeedback' + callbackUrl: + type: + - string + - 'null' + format: uri + description: | + Optional HTTPS callback URL for asynchronous processing. If provided, the service may return 202 Accepted immediately and deliver feedback results to this URL once processing is complete. + configuration: + description: | + Optional key-value configuration dictionary for provider-specific or experimental parameters. Not standardized. + type: + - object + - 'null' + additionalProperties: true + properties: + llm: + description: Optional LLM configuration used for this request. + type: + - object + - 'null' + allOf: + - $ref: '#/components/schemas/LLMConfiguration' + dataPolicy: + description: Optional data policy governing how this request's data may be processed and retained. + type: + - object + - 'null' + allOf: + - $ref: '#/components/schemas/DataPolicy' + executionPolicy: + description: Optional execution constraints for this request. + type: + - object + - 'null' + allOf: + - $ref: '#/components/schemas/ExecutionPolicy' + FeedbackTarget: + type: object + description: Optional reference to a specific part of the submission. + required: + - artefactType + properties: + artefactType: + $ref: '#/components/schemas/ArtefactType' + format: + type: + - string + - 'null' + description: | + Optional format specifier matching the submission format (e.g., python, latex). + locator: + type: + - object + - 'null' + description: | + Optional locator into the submission content. Standard locator types: - TEXT: { type: "span", startIndex: number, endIndex: number } - CODE: { type: "range", file?: string, startLine: number, endLine: number, startColumn?: number, endColumn?: number } - MATH: { type: "subexpression", path: string } (e.g., path to subexpression) - MODEL: { type: "element", elementId: string, elementType?: string } + additionalProperties: true + Feedback: + type: object + description: | + A single feedback item produced for the submission. It may include suggested actions, optional points, optional criterion linkage, and optional targeting into the submission. + properties: + feedbackId: + type: string + description: Unique identifier of the feedback item. + title: + type: + - string + - 'null' + description: Optional short label for this feedback item. + message: + type: + - string + - 'null' + description: Optional feedback text shown to the learner. + suggestedAction: + type: + - string + - 'null' + description: Optional suggestion for how to act on this feedback. + awardedPoints: + type: + - number + - 'null' + format: double + description: Optional points awarded for this feedback item. + criterion: + type: + - object + - 'null' + description: Optional criterion linked to this feedback item. + allOf: + - $ref: '#/components/schemas/Criterion' + target: + type: + - object + - 'null' + description: Optional target reference inside the submission. + allOf: + - $ref: '#/components/schemas/FeedbackTarget' + EvaluateAcceptedResponse: + type: object + description: Acknowledgement that evaluation was accepted for asynchronous processing. + required: + - status + - requestId + properties: + status: + type: string + enum: + - ACCEPTED + description: Indicates that the request has been accepted for asynchronous processing. + requestId: + type: string + description: Identifier to correlate this accepted request with callback delivery. + message: + type: + - string + - 'null' + description: Optional human-readable message about asynchronous processing. + ErrorResponse: + type: object + description: Standard error response returned by µEd API services. + required: + - title + properties: + title: + type: string + description: Short, human-readable error title. + message: + type: + - string + - 'null' + description: Optional human-readable error message. + code: + type: + - string + - 'null' + description: Optional application-specific error code. + trace: + type: + - string + - 'null' + description: Optional debug trace or stack trace (should be omitted in production by default). + details: + type: + - object + - 'null' + description: Optional provider-specific details for debugging or programmatic handling. + additionalProperties: true + HealthStatus: + type: string + description: Overall health status of the service. + enum: + - OK + - DEGRADED + - UNAVAILABLE + EvaluateRequirements: + type: object + description: | + Requirements for calling the evaluate endpoint, e.g. whether an Authorization header and/or LLM configuration or credentials (provided via a proxy, preferably time-based and/or signed tokens) are required. + additionalProperties: true + properties: + requiresAuthorizationHeader: + type: + - boolean + - 'null' + description: Optional flag indicating whether an Authorization header is required. + requiresLlmConfiguration: + type: + - boolean + - 'null' + description: Optional flag indicating whether configuration.llm must be provided. + requiresLlmCredentialProxy: + type: + - boolean + - 'null' + description: Optional flag indicating whether configuration.llm.credentials must be provided via a proxy. + DataPolicySupport: + type: string + enum: + - SUPPORTED + - NOT_SUPPORTED + - PARTIAL + description: Indicates whether the service supports data policy configuration. + ArtefactProfile: + type: object + description: | + Describes support for a specific artefact type and its formats. Used in health/capabilities responses to advertise what a service can handle. + required: + - type + properties: + type: + $ref: '#/components/schemas/ArtefactType' + supportedFormats: + type: + - array + - 'null' + description: | + List of supported formats for this artefact type. Use lowercase values. If null or empty, the service accepts any format for this type. + items: + type: string + examples: + - - plain + - markdown + - - python + - java + - javascript + - wolfram + - matlab + - - latex + - mathml + contentSchema: + type: + - object + - 'null' + description: | + Optional JSON Schema describing the expected content structure for this artefact type. Allows services to advertise their exact requirements. + additionalProperties: true + locatorSchema: + type: + - object + - 'null' + description: | + Optional JSON Schema describing the locator structure used for feedback targeting within this artefact type. + additionalProperties: true + EvaluateCapabilities: + type: object + description: Capabilities of the evaluate service. + required: + - supportsEvaluate + - supportsPreSubmissionFeedback + - supportsFormativeFeedback + - supportsSummativeFeedback + - supportsDataPolicy + additionalProperties: true + properties: + supportsEvaluate: + type: boolean + description: Indicates whether the /evaluate endpoint is implemented and usable. + supportsPreSubmissionFeedback: + type: boolean + description: Indicates whether the service supports pre-submission feedback runs. + supportsFormativeFeedback: + type: boolean + description: Indicates whether the service supports qualitative feedback without points. + supportsSummativeFeedback: + type: boolean + description: Indicates whether the service supports feedback with points / grading signals. + supportsDataPolicy: + $ref: '#/components/schemas/DataPolicySupport' + supportedArtefactProfiles: + type: + - array + - 'null' + description: | + Optional list of supported artefact profiles. Each profile specifies an artefact type and the formats supported for that type. + items: + $ref: '#/components/schemas/ArtefactProfile' + supportedLanguages: + type: + - array + - 'null' + description: Optional list of supported language codes (e.g., 'en', 'de'). + items: + type: string + supportedAPIVersions: + type: + - array + - 'null' + description: | + Optional list of µEd API versions supported by this service implementation (e.g., ["0.1.0"]). Clients can use this to select a compatible X-Api-Version. + items: + type: string + EvaluateHealthResponse: + type: object + description: Health status and capabilities of the evaluate service. + required: + - status + - capabilities + properties: + status: + $ref: '#/components/schemas/HealthStatus' + message: + type: + - string + - 'null' + description: Optional human-readable status message. + version: + type: + - string + - 'null' + description: Optional version of the evaluate service implementation. + requirements: + type: + - object + - 'null' + description: Optional requirements clients must satisfy to use this service. + allOf: + - $ref: '#/components/schemas/EvaluateRequirements' + capabilities: + $ref: '#/components/schemas/EvaluateCapabilities' + Message: + type: object + required: + - role + - content + properties: + role: + type: string + enum: + - USER + - ASSISTANT + - SYSTEM + - TOOL + content: + type: string + ChatRequest: + type: object + description: Request body for the chat endpoint. + required: + - messages + properties: + messages: + type: array + description: List of messages in the conversation (including history). + items: + $ref: '#/components/schemas/Message' + conversationId: + type: + - string + - 'null' + description: Optional identifier for the conversation session. + user: + description: Optional user information to adapt the chat style and level. + type: + - object + - 'null' + allOf: + - $ref: '#/components/schemas/User' + context: + type: + - object + - 'null' + description: Optional educational context (e.g., course material, task context). + additionalProperties: true + configuration: + type: + - object + - 'null' + description: Optional configuration for the model(s). + additionalProperties: true + properties: + type: + type: + - string + - 'null' + description: Optional type for the chatbot or chat model. + llm: + description: Optional LLM configuration used for this request. + type: + - object + - 'null' + allOf: + - $ref: '#/components/schemas/LLMConfiguration' + dataPolicy: + description: Optional data policy governing how this request's data may be processed and retained. + type: + - object + - 'null' + allOf: + - $ref: '#/components/schemas/DataPolicy' + executionPolicy: + description: Optional execution constraints for this request. + type: + - object + - 'null' + allOf: + - $ref: '#/components/schemas/ExecutionPolicy' + ChatResponse: + type: object + description: Response body for the chat endpoint. + required: + - output + properties: + output: + $ref: '#/components/schemas/Message' + description: The generated assistant response. + metadata: + type: + - object + - 'null' + description: Optional metadata about response generation. + additionalProperties: true + ChatCapabilities: + type: object + description: Capabilities of the chat service. + required: + - supportsChat + - supportsDataPolicy + additionalProperties: true + properties: + supportsChat: + type: boolean + description: Indicates whether the /chat endpoint is implemented and usable. + supportsUserPreferences: + type: boolean + description: Indicates whether the service supports adapting to user preferences. + supportsStreaming: + type: boolean + description: Indicates whether the service supports streaming responses. + supportsDataPolicy: + $ref: '#/components/schemas/DataPolicySupport' + supportedLanguages: + type: + - array + - 'null' + description: Optional list of supported language codes. + items: + type: string + supportedModels: + type: + - array + - 'null' + description: Optional list of supported models. + items: + type: string + supportedAPIVersions: + type: + - array + - 'null' + description: | + Optional list of µEd API versions supported by this service implementation (e.g., ["0.1.0"]). Clients can use this to select a compatible X-Api-Version. + items: + type: string + ChatHealthResponse: + type: object + description: Health status and capabilities of the chat service. + required: + - status + - capabilities + properties: + status: + $ref: '#/components/schemas/HealthStatus' + statusMessage: + type: + - string + - 'null' + description: Optional human-readable status message. + version: + type: + - string + - 'null' + description: Optional version of the chat service implementation. + capabilities: + $ref: '#/components/schemas/ChatCapabilities' + responses: + 202-Accepted: + description: Request accepted for asynchronous evaluation processing. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluateAcceptedResponse' + examples: + asyncAccepted: + summary: Example accepted async request + value: + status: ACCEPTED + requestId: req-7c193f38 + message: Evaluation queued. Results will be sent to callbackUrl. + 400-BadRequest: + description: Invalid request (e.g. missing content or invalid schema). + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + validationError: + summary: Example validation error + value: + title: Invalid request + message: submission.content must not be empty. + code: VALIDATION_ERROR + trace: null + details: + field: submission.content + 403-Forbidden: + description: Forbidden (e.g. insufficient permissions or access denied). + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + permissionError: + summary: Example permission error + value: + title: Forbidden + message: You do not have permission to access this resource. + code: PERMISSION_DENIED + trace: null + details: + resource: submission + required_permission: write + 406-VersionNotSupported: + description: | + The requested API version (supplied via X-Api-Version) is not supported by this service. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + versionNotSupported: + summary: Example version not supported error + value: + title: API version not supported + message: 'The requested API version ''0.0'' is not supported. Supported versions are: [''0.1.0''].' + code: VERSION_NOT_SUPPORTED + trace: null + details: + requestedVersion: '0.0' + supportedVersions: + - 0.1.0 + 500-InternalError: + description: Internal server error. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + internalError: + summary: Example internal error + value: + title: Internal server error + message: Unexpected failure while generating feedback. + code: INTERNAL_ERROR + trace: 'java.lang.RuntimeException: ... (stack trace omitted)' + details: + subsystem: llm-provider + 501-NotImplemented: + description: | + The server does not support the evaluate method. This allows service providers to implement only subsets of the µEd API. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notImplemented: + summary: Example not implemented error + value: + title: Not implemented + message: This service does not implement /evaluate. + code: NOT_IMPLEMENTED + trace: null + details: null + 503-ServiceUnavailable: + description: Service is currently unavailable or unhealthy. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serviceUnavailable: + summary: Example unavailable error + value: + title: Service unavailable + message: Evaluate service is currently unavailable. + code: SERVICE_UNAVAILABLE + trace: null + details: + reason: Database connection failed + unhealthy: + summary: Example degraded / unavailable service + value: + title: Service unhealthy + status: UNAVAILABLE + message: Database connection failed + version: 1.0.0 + capabilities: + supportsEvaluate: false + supportsPreSubmissionFeedback: false + supportsFormativeFeedback: false + supportsSummativeFeedback: false + supportsDataPolicy: NOT_SUPPORTED + supportedArtefactProfiles: [] + supportedLanguages: [] + 400-BadRequest-2: + description: Invalid request (e.g. missing content or invalid schema). + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidChatRequest: + summary: Example invalid chat request + value: + title: Invalid request + message: messages must contain at least one item. + code: VALIDATION_ERROR + trace: null + details: + field: messages + 500-InternalError-2: + description: Internal server error. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + modelProviderError: + summary: Example model provider error + value: + title: Model provider error + message: LLM provider returned an error while generating a response. + code: LLM_PROVIDER_ERROR + trace: null + details: + provider: openai + 501-NotImplemented-2: + description: | + The server does not support the chat method. This allows service providers to implement only subsets of the µEd API. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notImplemented: + summary: Example not implemented error + value: + title: Not implemented + message: This service does not implement /chat. + code: NOT_IMPLEMENTED + trace: null + details: null + 503-ServiceUnavailable-2: + description: Service is currently unavailable or unhealthy. + headers: + X-Request-Id: + description: Request id for tracing this request across services. + schema: + type: string + X-Api-Version: + description: The API version that was used to serve this response. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serviceUnavailable: + summary: Example unavailable error + value: + title: Service unavailable + message: Chat service is currently unavailable. + code: SERVICE_UNAVAILABLE + trace: null + details: + reason: LLM provider connection failed + serviceUnhealthy: + summary: Example degraded / unavailable service + value: + title: Service unhealthy + message: LLM provider connection failed + code: SERVICE_UNHEALTHY + version: 1.0.0 + capabilities: + supportsChat: false + supportsUserPreferences: false + supportsStreaming: false + supportsDataPolicy: NOT_SUPPORTED + supportedLanguages: [] + supportedModels: [] From 61590bfa9a49a718940cacc2962aa5c5d06e4467 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 14 May 2026 17:04:23 +0100 Subject: [PATCH 07/18] =?UTF-8?q?Move=20=C2=B5Ed=20OpenAPI=20spec=20into?= =?UTF-8?q?=20runtime/schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocates the spec from api/ into runtime/schema/ alongside the existing JSON schema files, and renames it to mued_v0.1.0.yml to make the version explicit. Removes the api/ package; embed is now owned by runtime/schema. Co-Authored-By: Claude Sonnet 4.6 --- .idea/.gitignore | 8 ++++++++ .idea/inspectionProfiles/Project_Default.xml | 6 ++++++ .idea/modules.xml | 8 ++++++++ .idea/shimmy.iml | 9 +++++++++ .idea/vcs.xml | 6 ++++++ internal/server/openapi.go | 4 ++-- api/openapi.yml => runtime/schema/mued_v0.1.0.yml | 0 api/spec.go => runtime/schema/openapi.go | 4 ++-- 8 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/shimmy.iml create mode 100644 .idea/vcs.xml rename api/openapi.yml => runtime/schema/mued_v0.1.0.yml (100%) rename api/spec.go => runtime/schema/openapi.go (50%) diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..4f03dde --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..069b7c4 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/shimmy.iml b/.idea/shimmy.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/shimmy.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/internal/server/openapi.go b/internal/server/openapi.go index 53a5114..85efd65 100644 --- a/internal/server/openapi.go +++ b/internal/server/openapi.go @@ -10,12 +10,12 @@ import ( "github.com/getkin/kin-openapi/routers/legacy" "go.uber.org/zap" - "github.com/lambda-feedback/shimmy/api" + "github.com/lambda-feedback/shimmy/runtime/schema" ) func LoadOpenAPISpec() (*openapi3.T, error) { loader := openapi3.NewLoader() - return loader.LoadFromData(api.OpenAPISpec) + return loader.LoadFromData(schema.OpenAPISpec) } func OpenAPIMiddleware(spec *openapi3.T, log *zap.Logger) func(http.Handler) http.Handler { diff --git a/api/openapi.yml b/runtime/schema/mued_v0.1.0.yml similarity index 100% rename from api/openapi.yml rename to runtime/schema/mued_v0.1.0.yml diff --git a/api/spec.go b/runtime/schema/openapi.go similarity index 50% rename from api/spec.go rename to runtime/schema/openapi.go index 6885600..b771890 100644 --- a/api/spec.go +++ b/runtime/schema/openapi.go @@ -1,6 +1,6 @@ -package api +package schema import _ "embed" -//go:embed openapi.yml +//go:embed mued_v0.1.0.yml var OpenAPISpec []byte From f3eb8832a2ee875f38688370c709be0f15d82172 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 14 May 2026 17:04:32 +0100 Subject: [PATCH 08/18] Ignore .idea/ directory Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 +- .idea/.gitignore | 8 -------- .idea/inspectionProfiles/Project_Default.xml | 6 ------ .idea/modules.xml | 8 -------- .idea/shimmy.iml | 9 --------- .idea/vcs.xml | 6 ------ 6 files changed, 1 insertion(+), 38 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/shimmy.iml delete mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index ca54b99..83e00e5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,4 @@ lcov.info go.work # Local .env files -*.local \ No newline at end of file +*.local.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 4f03dde..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 069b7c4..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/shimmy.iml b/.idea/shimmy.iml deleted file mode 100644 index 5e764c4..0000000 --- a/.idea/shimmy.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From 01da9b43f756c1a436530513d1b67d2ebc7c87cd Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 14 May 2026 17:10:04 +0100 Subject: [PATCH 09/18] =?UTF-8?q?Make=20OpenAPI=20response=20validation=20?= =?UTF-8?q?strict=20for=20=C2=B5Ed=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, responses that failed spec validation were only logged as warnings and forwarded anyway. Now a failed µEd response validation returns 500 to the caller. The legacy / route is unaffected — it has no matching path in the spec so the middleware passes it through unchanged. Co-Authored-By: Claude Sonnet 4.6 --- internal/server/openapi.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/server/openapi.go b/internal/server/openapi.go index 85efd65..f94f392 100644 --- a/internal/server/openapi.go +++ b/internal/server/openapi.go @@ -56,7 +56,9 @@ func OpenAPIMiddleware(spec *openapi3.T, log *zap.Logger) func(http.Handler) htt Options: opts, } if err := openapi3filter.ValidateResponse(r.Context(), respInput); err != nil { - log.Warn("response failed OpenAPI validation", zap.Error(err)) + log.Error("response failed OpenAPI validation", zap.Error(err)) + http.Error(w, "invalid response format", http.StatusInternalServerError) + return } // Forward captured response From 0ef754033b1ed7c6d4100ca7ce112d99adaa545c Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 14 May 2026 18:49:30 +0100 Subject: [PATCH 10/18] Add X-Api-Version header support to chat handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat passes the µEd ChatRequest directly to workers (no legacy conversion). Adds checkMuEdVersion to reject unsupported API versions with 406, and sets the X-Api-Version response header on all /chat and /chat/health responses. Co-Authored-By: Claude Sonnet 4.6 --- handler/chat.go | 12 +++++ handler/chat_test.go | 113 +++++++++++++++++++++++++++++++++++++++++++ handler/evaluate.go | 29 +++++++++++ runtime/version.go | 19 ++++++++ 4 files changed, 173 insertions(+) create mode 100644 runtime/version.go diff --git a/handler/chat.go b/handler/chat.go index af86ac2..5f4b2d8 100644 --- a/handler/chat.go +++ b/handler/chat.go @@ -15,6 +15,11 @@ func (h *MuEdHandler) ServeChat(w http.ResponseWriter, r *http.Request) { return } + version, ok := h.checkMuEdVersion(w, r) + if !ok { + return + } + if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -60,6 +65,7 @@ func (h *MuEdHandler) ServeChat(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") + w.Header().Set(muEdVersionHeader, version) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(chatResp) //nolint:errcheck } @@ -70,6 +76,11 @@ func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) { return } + version, ok := h.checkMuEdVersion(w, r) + if !ok { + return + } + if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -93,6 +104,7 @@ func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) { healthResp := runtime.MuEdToChatHealthResponse(resultMap) w.Header().Set("Content-Type", "application/json") + w.Header().Set(muEdVersionHeader, version) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(healthResp) //nolint:errcheck } diff --git a/handler/chat_test.go b/handler/chat_test.go index a983469..a5fe511 100644 --- a/handler/chat_test.go +++ b/handler/chat_test.go @@ -59,6 +59,7 @@ func TestServeChat_Success(t *testing.T) { assert.Equal(t, http.StatusOK, res.StatusCode) assert.Equal(t, "application/json", res.Header.Get("Content-Type")) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) var chatResp map[string]any require.NoError(t, json.Unmarshal(body, &chatResp)) @@ -159,6 +160,7 @@ func TestServeChatHealth_Success(t *testing.T) { assert.Equal(t, http.StatusOK, res.StatusCode) assert.Equal(t, "application/json", res.Header.Get("Content-Type")) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) var result map[string]any require.NoError(t, json.Unmarshal(raw, &result)) @@ -199,3 +201,114 @@ func TestServeChatHealth_RuntimeError(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, w.Result().StatusCode) mockRuntime.AssertExpectations(t) } + +// --- Version header tests (ServeChat) --- + +func TestServeChat_AbsentVersionHeader(t *testing.T) { + mockRuntime := new(MockRuntime) + mockRuntime.On("Handle", mock.Anything, mock.Anything). + Return(chatRuntimeResponse("ASSISTANT", "hi"), nil) + + req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(chatRequestBody(t))) + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeChat(w, req) + + res := w.Result() + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) +} + +func TestServeChat_SupportedVersionHeader(t *testing.T) { + mockRuntime := new(MockRuntime) + mockRuntime.On("Handle", mock.Anything, mock.Anything). + Return(chatRuntimeResponse("ASSISTANT", "hi"), nil) + + req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(chatRequestBody(t))) + req.Header.Set("X-Api-Version", "0.1.0") + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeChat(w, req) + + res := w.Result() + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) +} + +func TestServeChat_UnsupportedVersionHeader(t *testing.T) { + mockRuntime := new(MockRuntime) + + req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(chatRequestBody(t))) + req.Header.Set("X-Api-Version", "99.0.0") + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeChat(w, req) + + res := w.Result() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusNotAcceptable, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) + + var body map[string]any + require.NoError(t, json.Unmarshal(raw, &body)) + assert.Equal(t, "VERSION_NOT_SUPPORTED", body["code"]) + details, ok := body["details"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "99.0.0", details["requestedVersion"]) + + mockRuntime.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) +} + +// --- Version header tests (ServeChatHealth) --- + +func TestServeChatHealth_AbsentVersionHeader(t *testing.T) { + mockRuntime := new(MockRuntime) + mockRuntime.On("Handle", mock.Anything, runtime.EvaluationRequest{ + Command: runtime.CommandChatHealth, + Data: map[string]any{}, + }).Return(runtime.EvaluationResponse{ + "command": "chat/health", + "result": map[string]any{ + "status": "OK", + "capabilities": map[string]any{"chat": true}, + "supportedLanguages": []any{}, + "supportedModels": []any{}, + "supportedAPIVersions": []any{}, + }, + }, nil) + + req := httptest.NewRequest(http.MethodGet, "/chat/health", nil) + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeChatHealth(w, req) + + res := w.Result() + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) + mockRuntime.AssertExpectations(t) +} + +func TestServeChatHealth_UnsupportedVersionHeader(t *testing.T) { + mockRuntime := new(MockRuntime) + + req := httptest.NewRequest(http.MethodGet, "/chat/health", nil) + req.Header.Set("X-Api-Version", "99.0.0") + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeChatHealth(w, req) + + res := w.Result() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusNotAcceptable, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) + + var body map[string]any + require.NoError(t, json.Unmarshal(raw, &body)) + assert.Equal(t, "VERSION_NOT_SUPPORTED", body["code"]) + + mockRuntime.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) +} diff --git a/handler/evaluate.go b/handler/evaluate.go index fd9d4c9..ea668ef 100644 --- a/handler/evaluate.go +++ b/handler/evaluate.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "fmt" "io" "net/http" @@ -13,6 +14,8 @@ import ( "github.com/lambda-feedback/shimmy/runtime" ) +const muEdVersionHeader = "X-Api-Version" + type MuEdHandlerParams struct { fx.In @@ -38,6 +41,32 @@ func NewMuEdHandler(params MuEdHandlerParams) *MuEdHandler { } } +// checkMuEdVersion validates the X-Api-Version request header. +// Returns (resolvedVersion, true) on success, or writes a 406 and returns ("", false). +func (h *MuEdHandler) checkMuEdVersion(w http.ResponseWriter, r *http.Request) (string, bool) { + requested := r.Header.Get(muEdVersionHeader) + if requested != "" && !runtime.MuEdIsVersionSupported(requested) { + body, _ := json.Marshal(map[string]any{ + "title": "API version not supported", + "message": fmt.Sprintf( + "The requested API version '%s' is not supported. Supported versions are: %v.", + requested, runtime.SupportedMuEdVersions, + ), + "code": "VERSION_NOT_SUPPORTED", + "details": map[string]any{ + "requestedVersion": requested, + "supportedVersions": runtime.SupportedMuEdVersions, + }, + }) + w.Header().Set(muEdVersionHeader, runtime.MuEdResolveVersion(requested)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotAcceptable) + w.Write(body) //nolint:errcheck + return "", false + } + return runtime.MuEdResolveVersion(requested), true +} + func (h *MuEdHandler) checkAuth(w http.ResponseWriter, r *http.Request) bool { if h.config.Auth.Key != "" && r.Header.Get("api-key") != h.config.Auth.Key { h.log.Debug("unauthorized request", zap.String("path", r.URL.Path)) diff --git a/runtime/version.go b/runtime/version.go new file mode 100644 index 0000000..1763f37 --- /dev/null +++ b/runtime/version.go @@ -0,0 +1,19 @@ +package runtime + +var SupportedMuEdVersions = []string{"0.1.0"} + +func MuEdIsVersionSupported(version string) bool { + for _, v := range SupportedMuEdVersions { + if v == version { + return true + } + } + return false +} + +func MuEdResolveVersion(requested string) string { + if MuEdIsVersionSupported(requested) { + return requested + } + return SupportedMuEdVersions[len(SupportedMuEdVersions)-1] +} From 68259b417a61501a8e54dbccb8aea6fe9587db88 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 29 May 2026 13:26:48 +0100 Subject: [PATCH 11/18] =?UTF-8?q?Removed=20unused=20=C2=B5Ed=20version=20h?= =?UTF-8?q?andling=20logic=20and=20added=20ChatRequest/ChatResponse=20stru?= =?UTF-8?q?cts.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- runtime/chat.go | 10 ++++++++++ runtime/evaluate.go | 20 -------------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/runtime/chat.go b/runtime/chat.go index ecbfc1c..c402567 100644 --- a/runtime/chat.go +++ b/runtime/chat.go @@ -5,6 +5,16 @@ import ( "fmt" ) +// ChatRequest is the dispatcher-level request for the chat command. +type ChatRequest struct { + Data map[string]any +} + +// ChatResponse is the dispatcher-level response for the chat command. +type ChatResponse struct { + Data map[string]any +} + type MuEdChatRole string const ( diff --git a/runtime/evaluate.go b/runtime/evaluate.go index b88343c..ed349ed 100644 --- a/runtime/evaluate.go +++ b/runtime/evaluate.go @@ -36,26 +36,6 @@ type MuEdEvaluateRequest struct { PreSubmissionFeedback *MuEdPreSubmissionFeedback `json:"preSubmissionFeedback"` } -var SupportedMuEdVersions = []string{"0.1.0"} - -// MuEdIsVersionSupported reports whether version is in SupportedMuEdVersions. -func MuEdIsVersionSupported(version string) bool { - for _, v := range SupportedMuEdVersions { - if v == version { - return true - } - } - return false -} - -// MuEdResolveVersion returns requested if it's supported, else the latest version. -func MuEdResolveVersion(requested string) string { - if MuEdIsVersionSupported(requested) { - return requested - } - return SupportedMuEdVersions[len(SupportedMuEdVersions)-1] -} - // MuEdToHealthResponse converts a legacy runtime health result to muEd format. func MuEdToHealthResponse(result map[string]any) map[string]any { status := "DEGRADED" From daee02a91d794487a300c855f55b168d5b2c887d Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 29 May 2026 16:19:52 +0100 Subject: [PATCH 12/18] Add `Chat` and `ChatHealth` methods to `Runtime` interface and implementations. --- handler/evaluate_test.go | 10 ++++++++++ runtime/handler_test.go | 14 ++++++++++---- runtime/runtime.go | 13 +++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/handler/evaluate_test.go b/handler/evaluate_test.go index 64ae2c8..e4618e8 100644 --- a/handler/evaluate_test.go +++ b/handler/evaluate_test.go @@ -29,6 +29,16 @@ func (m *MockRuntime) Handle(ctx context.Context, req runtime.EvaluationRequest) return args.Get(0).(runtime.EvaluationResponse), args.Error(1) } +func (m *MockRuntime) Chat(ctx context.Context, req runtime.ChatRequest) (runtime.ChatResponse, error) { + args := m.Called(ctx, req) + return args.Get(0).(runtime.ChatResponse), args.Error(1) +} + +func (m *MockRuntime) ChatHealth(ctx context.Context) (runtime.ChatResponse, error) { + args := m.Called(ctx) + return args.Get(0).(runtime.ChatResponse), args.Error(1) +} + func (m *MockRuntime) Start(ctx context.Context) error { return m.Called(ctx).Error(0) } diff --git a/runtime/handler_test.go b/runtime/handler_test.go index d5dab6d..2ec7836 100644 --- a/runtime/handler_test.go +++ b/runtime/handler_test.go @@ -31,14 +31,20 @@ func (m *mockRuntime) Handle(ctx context.Context, request runtime.EvaluationRequ return args.Get(0).(runtime.EvaluationResponse), args.Error(1) } +func (m *mockRuntime) Chat(ctx context.Context, req runtime.ChatRequest) (runtime.ChatResponse, error) { + panic("not required") +} + +func (m *mockRuntime) ChatHealth(ctx context.Context) (runtime.ChatResponse, error) { + panic("not required") +} + func (m *mockRuntime) Start(ctx context.Context) error { - //Not required for tests - panic("Not required") + panic("not required") } func (m *mockRuntime) Shutdown(ctx context.Context) error { - //Not required for tests - panic("Not required") + panic("not required") } func setupLogger(t *testing.T) *zap.Logger { diff --git a/runtime/runtime.go b/runtime/runtime.go index a29ce92..742ee38 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -13,6 +13,9 @@ import ( type Runtime interface { Handle(context.Context, EvaluationRequest) (EvaluationResponse, error) + Chat(context.Context, ChatRequest) (ChatResponse, error) + ChatHealth(context.Context) (ChatResponse, error) + Start(context.Context) error Shutdown(context.Context) error @@ -96,6 +99,16 @@ func (r *EvaluationRuntime) Handle( return r.dispatcher.Send(ctx, string(message.Command), message.Data) } +func (r *EvaluationRuntime) Chat(ctx context.Context, req ChatRequest) (ChatResponse, error) { + data, err := r.dispatcher.Send(ctx, string(CommandChat), req.Data) + return ChatResponse{Data: data}, err +} + +func (r *EvaluationRuntime) ChatHealth(ctx context.Context) (ChatResponse, error) { + data, err := r.dispatcher.Send(ctx, string(CommandChatHealth), map[string]any{}) + return ChatResponse{Data: data}, err +} + func (r *EvaluationRuntime) Shutdown(ctx context.Context) error { return r.dispatcher.Shutdown(ctx) } From 8203a88bec6e120fb1458f2ebbb4cb3f4f1dd881 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 29 May 2026 17:06:21 +0100 Subject: [PATCH 13/18] Refactor chat and chat health handlers to use `Chat` and `ChatHealth` methods directly, removing legacy `Handle` logic. Update tests accordingly. --- handler/chat.go | 14 +++----- handler/chat_test.go | 83 +++++++++++++++++++++----------------------- 2 files changed, 44 insertions(+), 53 deletions(-) diff --git a/handler/chat.go b/handler/chat.go index 5f4b2d8..fe17570 100644 --- a/handler/chat.go +++ b/handler/chat.go @@ -43,16 +43,13 @@ func (h *MuEdHandler) ServeChat(w http.ResponseWriter, r *http.Request) { return } - resp, err := h.runtime.Handle(r.Context(), runtime.EvaluationRequest{ - Command: runtime.CommandChat, - Data: reqData, - }) + resp, err := h.runtime.Chat(r.Context(), runtime.ChatRequest{Data: reqData}) if err != nil { http.Error(w, "chat failed", http.StatusInternalServerError) return } - resultMap, ok := resp["result"].(map[string]any) + resultMap, ok := resp.Data["result"].(map[string]any) if !ok { http.Error(w, "invalid response from chat function", http.StatusInternalServerError) return @@ -86,16 +83,13 @@ func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) { return } - resp, err := h.runtime.Handle(r.Context(), runtime.EvaluationRequest{ - Command: runtime.CommandChatHealth, - Data: map[string]any{}, - }) + resp, err := h.runtime.ChatHealth(r.Context()) if err != nil { http.Error(w, "chat health check failed", http.StatusInternalServerError) return } - resultMap, ok := resp["result"].(map[string]any) + resultMap, ok := resp.Data["result"].(map[string]any) if !ok { http.Error(w, "invalid chat health response", http.StatusInternalServerError) return diff --git a/handler/chat_test.go b/handler/chat_test.go index a5fe511..57213e7 100644 --- a/handler/chat_test.go +++ b/handler/chat_test.go @@ -28,13 +28,15 @@ func chatRequestBody(t *testing.T) []byte { return b } -func chatRuntimeResponse(role, content string) runtime.EvaluationResponse { - return runtime.EvaluationResponse{ - "command": "chat", - "result": map[string]any{ - "output": map[string]any{ - "role": role, - "content": content, +func chatRuntimeResponse(role, content string) runtime.ChatResponse { + return runtime.ChatResponse{ + Data: map[string]any{ + "command": "chat", + "result": map[string]any{ + "output": map[string]any{ + "role": role, + "content": content, + }, }, }, } @@ -44,9 +46,8 @@ func chatRuntimeResponse(role, content string) runtime.EvaluationResponse { func TestServeChat_Success(t *testing.T) { mockRuntime := new(MockRuntime) - mockRuntime.On("Handle", mock.Anything, mock.MatchedBy(func(req runtime.EvaluationRequest) bool { - return req.Command == runtime.CommandChat - })).Return(chatRuntimeResponse("ASSISTANT", "Hello!"), nil) + mockRuntime.On("Chat", mock.Anything, mock.Anything). + Return(chatRuntimeResponse("ASSISTANT", "Hello!"), nil) req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(chatRequestBody(t))) w := httptest.NewRecorder() @@ -99,7 +100,7 @@ func TestServeChat_InvalidJSON(t *testing.T) { newMuEdHandler(nil, mockRuntime, "").ServeChat(w, req) assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) - mockRuntime.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) + mockRuntime.AssertNotCalled(t, "Chat", mock.Anything, mock.Anything) } func TestServeChat_EmptyMessages(t *testing.T) { @@ -112,13 +113,13 @@ func TestServeChat_EmptyMessages(t *testing.T) { newMuEdHandler(nil, mockRuntime, "").ServeChat(w, req) assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) - mockRuntime.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) + mockRuntime.AssertNotCalled(t, "Chat", mock.Anything, mock.Anything) } func TestServeChat_RuntimeError(t *testing.T) { mockRuntime := new(MockRuntime) - mockRuntime.On("Handle", mock.Anything, mock.Anything). - Return(runtime.EvaluationResponse{}, errors.New("chat failed")) + mockRuntime.On("Chat", mock.Anything, mock.Anything). + Return(runtime.ChatResponse{}, errors.New("chat failed")) req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(chatRequestBody(t))) w := httptest.NewRecorder() @@ -133,19 +134,16 @@ func TestServeChat_RuntimeError(t *testing.T) { func TestServeChatHealth_Success(t *testing.T) { mockRuntime := new(MockRuntime) - mockRuntime.On("Handle", mock.Anything, runtime.EvaluationRequest{ - Command: runtime.CommandChatHealth, - Data: map[string]any{}, - }).Return(runtime.EvaluationResponse{ - "command": "chat/health", - "result": map[string]any{ - "status": "OK", - "capabilities": map[string]any{ - "chat": true, + mockRuntime.On("ChatHealth", mock.Anything).Return(runtime.ChatResponse{ + Data: map[string]any{ + "command": "chat/health", + "result": map[string]any{ + "status": "OK", + "capabilities": map[string]any{"chat": true}, + "supportedLanguages": []any{}, + "supportedModels": []any{}, + "supportedAPIVersions": []any{}, }, - "supportedLanguages": []any{}, - "supportedModels": []any{}, - "supportedAPIVersions": []any{}, }, }, nil) @@ -190,8 +188,8 @@ func TestServeChatHealth_MethodNotAllowed(t *testing.T) { func TestServeChatHealth_RuntimeError(t *testing.T) { mockRuntime := new(MockRuntime) - mockRuntime.On("Handle", mock.Anything, mock.Anything). - Return(runtime.EvaluationResponse{}, errors.New("worker unavailable")) + mockRuntime.On("ChatHealth", mock.Anything). + Return(runtime.ChatResponse{}, errors.New("worker unavailable")) req := httptest.NewRequest(http.MethodGet, "/chat/health", nil) w := httptest.NewRecorder() @@ -206,7 +204,7 @@ func TestServeChatHealth_RuntimeError(t *testing.T) { func TestServeChat_AbsentVersionHeader(t *testing.T) { mockRuntime := new(MockRuntime) - mockRuntime.On("Handle", mock.Anything, mock.Anything). + mockRuntime.On("Chat", mock.Anything, mock.Anything). Return(chatRuntimeResponse("ASSISTANT", "hi"), nil) req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(chatRequestBody(t))) @@ -221,7 +219,7 @@ func TestServeChat_AbsentVersionHeader(t *testing.T) { func TestServeChat_SupportedVersionHeader(t *testing.T) { mockRuntime := new(MockRuntime) - mockRuntime.On("Handle", mock.Anything, mock.Anything). + mockRuntime.On("Chat", mock.Anything, mock.Anything). Return(chatRuntimeResponse("ASSISTANT", "hi"), nil) req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(chatRequestBody(t))) @@ -258,24 +256,23 @@ func TestServeChat_UnsupportedVersionHeader(t *testing.T) { require.True(t, ok) assert.Equal(t, "99.0.0", details["requestedVersion"]) - mockRuntime.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) + mockRuntime.AssertNotCalled(t, "Chat", mock.Anything, mock.Anything) } // --- Version header tests (ServeChatHealth) --- func TestServeChatHealth_AbsentVersionHeader(t *testing.T) { mockRuntime := new(MockRuntime) - mockRuntime.On("Handle", mock.Anything, runtime.EvaluationRequest{ - Command: runtime.CommandChatHealth, - Data: map[string]any{}, - }).Return(runtime.EvaluationResponse{ - "command": "chat/health", - "result": map[string]any{ - "status": "OK", - "capabilities": map[string]any{"chat": true}, - "supportedLanguages": []any{}, - "supportedModels": []any{}, - "supportedAPIVersions": []any{}, + mockRuntime.On("ChatHealth", mock.Anything).Return(runtime.ChatResponse{ + Data: map[string]any{ + "command": "chat/health", + "result": map[string]any{ + "status": "OK", + "capabilities": map[string]any{"chat": true}, + "supportedLanguages": []any{}, + "supportedModels": []any{}, + "supportedAPIVersions": []any{}, + }, }, }, nil) @@ -310,5 +307,5 @@ func TestServeChatHealth_UnsupportedVersionHeader(t *testing.T) { require.NoError(t, json.Unmarshal(raw, &body)) assert.Equal(t, "VERSION_NOT_SUPPORTED", body["code"]) - mockRuntime.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) + mockRuntime.AssertNotCalled(t, "ChatHealth", mock.Anything) } From bce16b4675414f7ade2cf695883ccb03bc722386 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 29 May 2026 17:21:13 +0100 Subject: [PATCH 14/18] Replace direct `http.Error` calls with `writeMuEdError` utility in chat and chat health handlers for consistent error handling. --- handler/chat.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/handler/chat.go b/handler/chat.go index fe17570..402c4e0 100644 --- a/handler/chat.go +++ b/handler/chat.go @@ -27,37 +27,37 @@ func (h *MuEdHandler) ServeChat(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { - http.Error(w, "failed to read body", http.StatusBadRequest) + h.writeMuEdError(w, version, http.StatusBadRequest, "VALIDATION_ERROR", "Bad request", "failed to read body", nil) return } var chatReq runtime.MuEdChatRequest if err := json.Unmarshal(body, &chatReq); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) + h.writeMuEdError(w, version, http.StatusBadRequest, "VALIDATION_ERROR", "Bad request", "invalid request body", nil) return } reqData, err := runtime.MuEdBuildChatRequest(chatReq) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + h.writeMuEdError(w, version, http.StatusBadRequest, "VALIDATION_ERROR", "Bad request", err.Error(), nil) return } resp, err := h.runtime.Chat(r.Context(), runtime.ChatRequest{Data: reqData}) if err != nil { - http.Error(w, "chat failed", http.StatusInternalServerError) + h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "chat failed", nil) return } resultMap, ok := resp.Data["result"].(map[string]any) if !ok { - http.Error(w, "invalid response from chat function", http.StatusInternalServerError) + h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "invalid response from chat function", nil) return } chatResp, err := runtime.MuEdToChatResponse(resultMap) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", err.Error(), nil) return } @@ -85,13 +85,13 @@ func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) { resp, err := h.runtime.ChatHealth(r.Context()) if err != nil { - http.Error(w, "chat health check failed", http.StatusInternalServerError) + h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "chat health check failed", nil) return } resultMap, ok := resp.Data["result"].(map[string]any) if !ok { - http.Error(w, "invalid chat health response", http.StatusInternalServerError) + h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "invalid chat health response", nil) return } From 66bd47fa84a91736ef4e516e79a32afb20915074 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 29 May 2026 17:22:08 +0100 Subject: [PATCH 15/18] Return 503 status code when ChatHealth status is "UNAVAILABLE" and add corresponding test. --- handler/chat.go | 7 ++++++- handler/chat_test.go | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/handler/chat.go b/handler/chat.go index 402c4e0..e06cdb4 100644 --- a/handler/chat.go +++ b/handler/chat.go @@ -97,9 +97,14 @@ func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) { healthResp := runtime.MuEdToChatHealthResponse(resultMap) + statusCode := http.StatusOK + if healthResp.Status == runtime.MuEdChatHealthStatusUnavailable { + statusCode = http.StatusServiceUnavailable + } + w.Header().Set("Content-Type", "application/json") w.Header().Set(muEdVersionHeader, version) - w.WriteHeader(http.StatusOK) + w.WriteHeader(statusCode) json.NewEncoder(w).Encode(healthResp) //nolint:errcheck } diff --git a/handler/chat_test.go b/handler/chat_test.go index 57213e7..27a5f8a 100644 --- a/handler/chat_test.go +++ b/handler/chat_test.go @@ -186,6 +186,25 @@ func TestServeChatHealth_MethodNotAllowed(t *testing.T) { assert.Equal(t, http.StatusMethodNotAllowed, w.Result().StatusCode) } +func TestServeChatHealth_Unavailable(t *testing.T) { + mockRuntime := new(MockRuntime) + mockRuntime.On("ChatHealth", mock.Anything).Return(runtime.ChatResponse{ + Data: map[string]any{ + "result": map[string]any{ + "status": "UNAVAILABLE", + }, + }, + }, nil) + + req := httptest.NewRequest(http.MethodGet, "/chat/health", nil) + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeChatHealth(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Result().StatusCode) + mockRuntime.AssertExpectations(t) +} + func TestServeChatHealth_RuntimeError(t *testing.T) { mockRuntime := new(MockRuntime) mockRuntime.On("ChatHealth", mock.Anything). From bc6ea483d9c61fe432c5ec2801665a9278eaad51 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 29 May 2026 17:25:24 +0100 Subject: [PATCH 16/18] Add `chat` and `chat/health` command mappings to `Runtime` --- runtime/models.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runtime/models.go b/runtime/models.go index 66c0f25..b98ef24 100644 --- a/runtime/models.go +++ b/runtime/models.go @@ -33,6 +33,10 @@ func ParseCommand(path string) (Command, bool) { return CommandPreview, true case "healthcheck": return CommandHealth, true + case "chat": + return CommandChat, true + case "chat/health": + return CommandChatHealth, true } return "", false From 9aa8db4f8d17d123a9854f801274a174a5b4c422 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 4 Aug 2026 18:42:02 +0100 Subject: [PATCH 17/18] =?UTF-8?q?Refactor=20chat=20data=20structures=20for?= =?UTF-8?q?=20=C2=B5Ed=20spec=20compliance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor `MuEdChatRequest`, `MuEdChatResponse`, and related health structures for compatibility with µEd spec. Replace typed fields (`user`, `context`, etc.) with freeform maps to ensure full data fidelity. Simplify worker response validation and default handling. Add regression tests to verify unaltered data passthrough. --- handler/chat.go | 2 +- handler/chat_test.go | 4 +- runtime/chat.go | 159 ++++++++++++++++++++----------------------- runtime/chat_test.go | 156 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 213 insertions(+), 108 deletions(-) diff --git a/handler/chat.go b/handler/chat.go index e06cdb4..24e9e26 100644 --- a/handler/chat.go +++ b/handler/chat.go @@ -98,7 +98,7 @@ func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) { healthResp := runtime.MuEdToChatHealthResponse(resultMap) statusCode := http.StatusOK - if healthResp.Status == runtime.MuEdChatHealthStatusUnavailable { + if status, ok := healthResp["status"].(string); ok && status == string(runtime.MuEdChatHealthStatusUnavailable) { statusCode = http.StatusServiceUnavailable } diff --git a/handler/chat_test.go b/handler/chat_test.go index 27a5f8a..d6e717b 100644 --- a/handler/chat_test.go +++ b/handler/chat_test.go @@ -139,7 +139,7 @@ func TestServeChatHealth_Success(t *testing.T) { "command": "chat/health", "result": map[string]any{ "status": "OK", - "capabilities": map[string]any{"chat": true}, + "capabilities": map[string]any{"supportsChat": true}, "supportedLanguages": []any{}, "supportedModels": []any{}, "supportedAPIVersions": []any{}, @@ -287,7 +287,7 @@ func TestServeChatHealth_AbsentVersionHeader(t *testing.T) { "command": "chat/health", "result": map[string]any{ "status": "OK", - "capabilities": map[string]any{"chat": true}, + "capabilities": map[string]any{"supportsChat": true}, "supportedLanguages": []any{}, "supportedModels": []any{}, "supportedAPIVersions": []any{}, diff --git a/runtime/chat.go b/runtime/chat.go index c402567..9c3c93f 100644 --- a/runtime/chat.go +++ b/runtime/chat.go @@ -21,6 +21,7 @@ const ( MuEdChatRoleUser MuEdChatRole = "USER" MuEdChatRoleAssistant MuEdChatRole = "ASSISTANT" MuEdChatRoleSystem MuEdChatRole = "SYSTEM" + MuEdChatRoleTool MuEdChatRole = "TOOL" ) type MuEdChatMessage struct { @@ -28,52 +29,21 @@ type MuEdChatMessage struct { Content string `json:"content"` } -type MuEdChatUserPreferences struct { - Tone string `json:"tone,omitempty"` - Detail string `json:"detail,omitempty"` - Language string `json:"language,omitempty"` -} - -type MuEdChatContext struct { - Course map[string]any `json:"course,omitempty"` - Task map[string]any `json:"task,omitempty"` - Submission map[string]any `json:"submission,omitempty"` -} - -type MuEdChatLLMConfig struct { - Model string `json:"model,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"maxTokens,omitempty"` - Extra map[string]any `json:"extra,omitempty"` -} - -type MuEdChatDataPolicy struct { - RetainData bool `json:"retainData"` - AllowReview bool `json:"allowReview"` -} - -type MuEdChatConfiguration struct { - LLM *MuEdChatLLMConfig `json:"llm,omitempty"` - DataPolicy *MuEdChatDataPolicy `json:"dataPolicy,omitempty"` -} - +// MuEdChatRequest is the request body for the chat endpoint. Only messages +// and conversationId have a fixed shape per the µEd spec — user, context, +// and configuration are all declared additionalProperties/freeform (or, for +// user, nested under a User schema that isn't worth flattening here), and +// are never inspected by shimmy itself; they only flow straight through to +// the worker. Typing them narrowly risks silently dropping fields that don't +// match a hand-picked sub-schema, so they stay as map[string]any, matching +// the convention used for task-specific data in evaluate.go (e.g. +// MuEdSubmission.Content, MuEdTask.ReferenceSolution). type MuEdChatRequest struct { - Messages []MuEdChatMessage `json:"messages"` - ConversationID string `json:"conversationId,omitempty"` - User *MuEdChatUserPreferences `json:"user,omitempty"` - Context *MuEdChatContext `json:"context,omitempty"` - Configuration *MuEdChatConfiguration `json:"configuration,omitempty"` -} - -type MuEdChatResponseMetadata struct { - Tokens map[string]any `json:"tokens,omitempty"` - Model string `json:"model,omitempty"` - Timing map[string]any `json:"timing,omitempty"` -} - -type MuEdChatResponse struct { - Output MuEdChatMessage `json:"output"` - Metadata *MuEdChatResponseMetadata `json:"metadata,omitempty"` + Messages []MuEdChatMessage `json:"messages"` + ConversationID string `json:"conversationId,omitempty"` + User map[string]any `json:"user,omitempty"` + Context map[string]any `json:"context,omitempty"` + Configuration map[string]any `json:"configuration,omitempty"` } type MuEdChatHealthStatus string @@ -84,21 +54,6 @@ const ( MuEdChatHealthStatusUnavailable MuEdChatHealthStatus = "UNAVAILABLE" ) -type MuEdChatCapabilities struct { - Chat bool `json:"chat"` - UserPreferences bool `json:"userPreferences"` - Streaming bool `json:"streaming"` - DataPolicy bool `json:"dataPolicy"` -} - -type MuEdChatHealthResponse struct { - Status MuEdChatHealthStatus `json:"status"` - Capabilities MuEdChatCapabilities `json:"capabilities"` - SupportedLanguages []string `json:"supportedLanguages"` - SupportedModels []string `json:"supportedModels"` - SupportedAPIVersions []string `json:"supportedAPIVersions"` -} - // MuEdBuildChatRequest converts a MuEdChatRequest to the map sent to the worker. func MuEdBuildChatRequest(req MuEdChatRequest) (map[string]any, error) { if len(req.Messages) == 0 { @@ -115,42 +70,72 @@ func MuEdBuildChatRequest(req MuEdChatRequest) (map[string]any, error) { return m, nil } -// MuEdToChatResponse transforms a worker result map into a MuEdChatResponse. -func MuEdToChatResponse(result map[string]any) (*MuEdChatResponse, error) { - b, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal chat result: %w", err) - } - var resp MuEdChatResponse - if err := json.Unmarshal(b, &resp); err != nil { - return nil, fmt.Errorf("failed to unmarshal chat response: %w", err) +// MuEdToChatResponse transforms a worker result map into a µEd chat response map. +func MuEdToChatResponse(result map[string]any) (map[string]any, error) { + output, ok := result["output"].(map[string]any) + if !ok { + return nil, fmt.Errorf("chat response missing output") } - if resp.Output.Role == "" { + + role, _ := output["role"].(string) + if role == "" { return nil, fmt.Errorf("chat response missing output role") } - if resp.Output.Content == "" { + + content, _ := output["content"].(string) + if content == "" { return nil, fmt.Errorf("chat response missing output content") } - return &resp, nil -} -// MuEdToChatHealthResponse transforms a worker result map into a MuEdChatHealthResponse. -// nil slices are normalised to empty slices so they serialise as [] not null. -func MuEdToChatHealthResponse(result map[string]any) MuEdChatHealthResponse { - b, _ := json.Marshal(result) - var resp MuEdChatHealthResponse - json.Unmarshal(b, &resp) //nolint:errcheck - if resp.Status == "" { - resp.Status = MuEdChatHealthStatusOK + resp := map[string]any{ + "output": map[string]any{ + "role": role, + "content": content, + }, + } + if metadata, ok := result["metadata"].(map[string]any); ok { + resp["metadata"] = metadata } - if resp.SupportedLanguages == nil { - resp.SupportedLanguages = []string{} + return resp, nil +} + +// MuEdToChatHealthResponse transforms a worker result map into a µEd chat +// health response map. Unlike evaluate's health capabilities (which shimmy +// hardcodes itself), a chat worker is authoritative on what it supports, so +// this passes the worker's capabilities through largely as-is — it only +// fills in the spec's required keys/defaults and normalises nil slices to +// empty ones so they serialise as [] not null. +func MuEdToChatHealthResponse(result map[string]any) map[string]any { + status, _ := result["status"].(string) + if status == "" { + status = string(MuEdChatHealthStatusOK) + } + + capabilities, ok := result["capabilities"].(map[string]any) + if !ok { + capabilities = map[string]any{} + } + if _, ok := capabilities["supportsChat"]; !ok { + capabilities["supportsChat"] = false + } + if _, ok := capabilities["supportsDataPolicy"]; !ok { + capabilities["supportsDataPolicy"] = "NOT_SUPPORTED" + } + for _, key := range []string{"supportedLanguages", "supportedModels", "supportedAPIVersions"} { + if capabilities[key] == nil { + capabilities[key] = []string{} + } + } + + resp := map[string]any{ + "status": status, + "capabilities": capabilities, } - if resp.SupportedModels == nil { - resp.SupportedModels = []string{} + if msg, ok := result["statusMessage"].(string); ok { + resp["statusMessage"] = msg } - if resp.SupportedAPIVersions == nil { - resp.SupportedAPIVersions = []string{} + if version, ok := result["version"].(string); ok { + resp["version"] = version } return resp } diff --git a/runtime/chat_test.go b/runtime/chat_test.go index 8060068..0e6c79c 100644 --- a/runtime/chat_test.go +++ b/runtime/chat_test.go @@ -68,6 +68,75 @@ func TestMuEdBuildChatRequest_ConversationIDIncluded(t *testing.T) { assert.Equal(t, "abc-123", body["conversationId"]) } +// TestMuEdBuildChatRequest_UserPassedThroughIntact is a regression test: the +// User field used to be typed as a flat {tone, detail, language} struct that +// didn't match the spec's nested User{type, preference{tone,detail,language}, +// taskProgress} shape, silently discarding everything but the mislabeled +// fields. It must now round-trip untouched, since shimmy never inspects it. +func TestMuEdBuildChatRequest_UserPassedThroughIntact(t *testing.T) { + user := map[string]any{ + "type": "LEARNER", + "preference": map[string]any{ + "tone": "FORMAL", + "conversationalStyle": "socratic", + }, + "taskProgress": map[string]any{ + "timeSpentOnQuestion": "30 minutes", + }, + } + req := runtime.MuEdChatRequest{ + Messages: []runtime.MuEdChatMessage{{Role: runtime.MuEdChatRoleUser, Content: "hi"}}, + User: user, + } + body, err := runtime.MuEdBuildChatRequest(req) + require.NoError(t, err) + + gotUser, ok := body["user"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "LEARNER", gotUser["type"]) + preference, ok := gotUser["preference"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "FORMAL", preference["tone"]) + assert.Equal(t, "socratic", preference["conversationalStyle"]) + taskProgress, ok := gotUser["taskProgress"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "30 minutes", taskProgress["timeSpentOnQuestion"]) +} + +// TestMuEdBuildChatRequest_ContextPassedThroughIntact is a regression test: +// the Context field used to be typed as {course, task, submission}, which +// doesn't match the spec's fully freeform "additionalProperties: true" +// context object. A real caller's context shape (e.g. {set, question, +// summary}) must survive the round trip untouched. +func TestMuEdBuildChatRequest_ContextPassedThroughIntact(t *testing.T) { + context := map[string]any{ + "summary": "prior conversation summary", + "set": map[string]any{ + "title": "Fundamentals", + "number": float64(2), + }, + "question": map[string]any{ + "title": "Understanding Polymorphism", + }, + } + req := runtime.MuEdChatRequest{ + Messages: []runtime.MuEdChatMessage{{Role: runtime.MuEdChatRoleUser, Content: "hi"}}, + Context: context, + } + body, err := runtime.MuEdBuildChatRequest(req) + require.NoError(t, err) + + gotContext, ok := body["context"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "prior conversation summary", gotContext["summary"]) + set, ok := gotContext["set"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "Fundamentals", set["title"]) + question, ok := gotContext["question"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "Understanding Polymorphism", question["title"]) +} + // --- MuEdToChatResponse --- func TestMuEdToChatResponse_Valid(t *testing.T) { @@ -79,9 +148,16 @@ func TestMuEdToChatResponse_Valid(t *testing.T) { } resp, err := runtime.MuEdToChatResponse(result) require.NoError(t, err) - assert.Equal(t, runtime.MuEdChatRoleAssistant, resp.Output.Role) - assert.Equal(t, "Hello there!", resp.Output.Content) - assert.Nil(t, resp.Metadata) + output, ok := resp["output"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "ASSISTANT", output["role"]) + assert.Equal(t, "Hello there!", output["content"]) + assert.NotContains(t, resp, "metadata") +} + +func TestMuEdToChatResponse_MissingOutput(t *testing.T) { + _, err := runtime.MuEdToChatResponse(map[string]any{}) + require.Error(t, err) } func TestMuEdToChatResponse_MissingRole(t *testing.T) { @@ -116,8 +192,9 @@ func TestMuEdToChatResponse_MetadataForwarded(t *testing.T) { } resp, err := runtime.MuEdToChatResponse(result) require.NoError(t, err) - require.NotNil(t, resp.Metadata) - assert.Equal(t, "gpt-4", resp.Metadata.Model) + metadata, ok := resp["metadata"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "gpt-4", metadata["model"]) } // --- MuEdToChatHealthResponse --- @@ -126,23 +203,64 @@ func TestMuEdToChatHealthResponse_Valid(t *testing.T) { result := map[string]any{ "status": "DEGRADED", "capabilities": map[string]any{ - "chat": true, + "supportsChat": true, }, - "supportedLanguages": []any{"en"}, - "supportedModels": []any{"gpt-4"}, - "supportedAPIVersions": []any{"1.0"}, + "statusMessage": "partially degraded", + "version": "1.2.3", } resp := runtime.MuEdToChatHealthResponse(result) - assert.Equal(t, runtime.MuEdChatHealthStatusDegraded, resp.Status) - assert.True(t, resp.Capabilities.Chat) - assert.Equal(t, []string{"en"}, resp.SupportedLanguages) - assert.Equal(t, []string{"gpt-4"}, resp.SupportedModels) - assert.Equal(t, []string{"1.0"}, resp.SupportedAPIVersions) + assert.Equal(t, "DEGRADED", resp["status"]) + assert.Equal(t, "partially degraded", resp["statusMessage"]) + assert.Equal(t, "1.2.3", resp["version"]) + + capabilities, ok := resp["capabilities"].(map[string]any) + require.True(t, ok) + assert.Equal(t, true, capabilities["supportsChat"]) + // Defaults filled in for required-but-unset spec keys. + assert.Equal(t, "NOT_SUPPORTED", capabilities["supportsDataPolicy"]) + assert.Equal(t, []string{}, capabilities["supportedLanguages"]) + assert.Equal(t, []string{}, capabilities["supportedModels"]) + assert.Equal(t, []string{}, capabilities["supportedAPIVersions"]) +} + +func TestMuEdToChatHealthResponse_CapabilitiesPassedThroughIntact(t *testing.T) { + // The worker is authoritative on its own capabilities (unlike evaluate, + // which hardcodes them) — arbitrary worker-supplied keys must survive. + result := map[string]any{ + "status": "OK", + "capabilities": map[string]any{ + "supportsChat": true, + "supportsUserPreferences": true, + "supportsStreaming": false, + "supportsDataPolicy": "PARTIAL", + "supportedLanguages": []any{"en", "de"}, + "supportedModels": []any{"gpt-4o"}, + "supportedAPIVersions": []any{"0.1.0"}, + }, + } + resp := runtime.MuEdToChatHealthResponse(result) + capabilities, ok := resp["capabilities"].(map[string]any) + require.True(t, ok) + assert.Equal(t, true, capabilities["supportsChat"]) + assert.Equal(t, true, capabilities["supportsUserPreferences"]) + assert.Equal(t, false, capabilities["supportsStreaming"]) + assert.Equal(t, "PARTIAL", capabilities["supportsDataPolicy"]) + assert.Equal(t, []any{"en", "de"}, capabilities["supportedLanguages"]) + assert.Equal(t, []any{"gpt-4o"}, capabilities["supportedModels"]) + assert.Equal(t, []any{"0.1.0"}, capabilities["supportedAPIVersions"]) } func TestMuEdToChatHealthResponse_DefaultsStatusOK(t *testing.T) { resp := runtime.MuEdToChatHealthResponse(map[string]any{}) - assert.Equal(t, runtime.MuEdChatHealthStatusOK, resp.Status) + assert.Equal(t, "OK", resp["status"]) +} + +func TestMuEdToChatHealthResponse_DefaultsMissingCapabilities(t *testing.T) { + resp := runtime.MuEdToChatHealthResponse(map[string]any{}) + capabilities, ok := resp["capabilities"].(map[string]any) + require.True(t, ok) + assert.Equal(t, false, capabilities["supportsChat"]) + assert.Equal(t, "NOT_SUPPORTED", capabilities["supportsDataPolicy"]) } func TestMuEdToChatHealthResponse_NilSlicesDefaultToEmpty(t *testing.T) { @@ -153,7 +271,9 @@ func TestMuEdToChatHealthResponse_NilSlicesDefaultToEmpty(t *testing.T) { var out map[string]any require.NoError(t, json.Unmarshal(raw, &out)) - assert.Equal(t, []any{}, out["supportedLanguages"]) - assert.Equal(t, []any{}, out["supportedModels"]) - assert.Equal(t, []any{}, out["supportedAPIVersions"]) + capabilities, ok := out["capabilities"].(map[string]any) + require.True(t, ok) + assert.Equal(t, []any{}, capabilities["supportedLanguages"]) + assert.Equal(t, []any{}, capabilities["supportedModels"]) + assert.Equal(t, []any{}, capabilities["supportedAPIVersions"]) } From d1f6cf9d9f48c0fc3b3313a9e2714cc0b4c370fa Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Wed, 5 Aug 2026 13:37:03 +0100 Subject: [PATCH 18/18] Rename `CommandHealth` to `CommandEvaluateHealth` for consistency with function naming and improved clarity --- handler/evaluate.go | 4 +--- handler/evaluate_test.go | 6 +++--- runtime/handler_validate.go | 4 ++-- runtime/models.go | 6 +++--- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/handler/evaluate.go b/handler/evaluate.go index fe8fce4..c462b17 100644 --- a/handler/evaluate.go +++ b/handler/evaluate.go @@ -47,7 +47,6 @@ func writeJSONError(w http.ResponseWriter, msg string, status int) { json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": msg}}) //nolint:errcheck } - // checkMuEdVersion validates the X-Api-Version request header. // Returns (resolvedVersion, true) on success, or writes a 406 and returns ("", false). func (h *MuEdHandler) checkMuEdVersion(w http.ResponseWriter, r *http.Request) (string, bool) { @@ -88,7 +87,6 @@ func (h *MuEdHandler) writeMuEdError(w http.ResponseWriter, version string, stat w.Write(body) //nolint:errcheck } - func (h *MuEdHandler) checkAuth(w http.ResponseWriter, r *http.Request) bool { if h.config.Auth.Key != "" && r.Header.Get("api-key") != h.config.Auth.Key { h.log.Debug("unauthorized request", zap.String("path", r.URL.Path)) @@ -216,7 +214,7 @@ func (h *MuEdHandler) ServeHealth(w http.ResponseWriter, r *http.Request) { } resp, err := h.runtime.Handle(r.Context(), runtime.EvaluationRequest{ - Command: runtime.CommandHealth, + Command: runtime.CommandEvaluateHealth, Data: map[string]any{}, }) if err != nil { diff --git a/handler/evaluate_test.go b/handler/evaluate_test.go index 6ad2d74..5fa03b6 100644 --- a/handler/evaluate_test.go +++ b/handler/evaluate_test.go @@ -290,7 +290,7 @@ func TestMuEdServeHealth_Success(t *testing.T) { healthResult := map[string]any{"tests_passed": true, "successes": []any{}, "failures": []any{}, "errors": []any{}} mockRuntime := new(MockRuntime) mockRuntime.On("Handle", mock.Anything, runtime.EvaluationRequest{ - Command: runtime.CommandHealth, + Command: runtime.CommandEvaluateHealth, Data: map[string]any{}, }).Return(runtime.EvaluationResponse{ "command": "healthcheck", @@ -375,7 +375,7 @@ func TestMuEdServeHealth_DegradedStatus(t *testing.T) { healthResult := map[string]any{"tests_passed": false, "successes": []any{}, "failures": []any{"f1"}, "errors": []any{}} mockRuntime := new(MockRuntime) mockRuntime.On("Handle", mock.Anything, runtime.EvaluationRequest{ - Command: runtime.CommandHealth, + Command: runtime.CommandEvaluateHealth, Data: map[string]any{}, }).Return(runtime.EvaluationResponse{ "command": "healthcheck", @@ -466,7 +466,7 @@ func TestMuEdServeHealth_AbsentVersionHeader(t *testing.T) { healthResult := map[string]any{"tests_passed": true, "successes": []any{}, "failures": []any{}, "errors": []any{}} mockRuntime := new(MockRuntime) mockRuntime.On("Handle", mock.Anything, runtime.EvaluationRequest{ - Command: runtime.CommandHealth, + Command: runtime.CommandEvaluateHealth, Data: map[string]any{}, }).Return(runtime.EvaluationResponse{ "command": "healthcheck", diff --git a/runtime/handler_validate.go b/runtime/handler_validate.go index e89d6c6..fe6cb43 100644 --- a/runtime/handler_validate.go +++ b/runtime/handler_validate.go @@ -57,7 +57,7 @@ func (r *RuntimeHandler) validate(t validationType, command Command, data map[st zap.Stringer("type", t), ) - if t == validationTypeRequest && command == CommandHealth { + if t == validationTypeRequest && command == CommandEvaluateHealth { // Health does not have a request schema, no need to validate return nil } @@ -94,7 +94,7 @@ func getSchemaType(command Command) (schema.SchemaType, error) { return schema.SchemaTypeEval, nil case CommandPreview: return schema.SchemaTypePreview, nil - case CommandHealth: + case CommandEvaluateHealth: return schema.SchemaTypeHealth, nil default: return 0, errInvalidCommand diff --git a/runtime/models.go b/runtime/models.go index b98ef24..3b81967 100644 --- a/runtime/models.go +++ b/runtime/models.go @@ -14,8 +14,8 @@ const ( // CommandEvaluate is the command to evaluate the response. CommandEvaluate Command = "eval" - // CommandHealth is the command for healthcheck - CommandHealth = "healthcheck" + // CommandEvaluateHealth is the command for healthcheck + CommandEvaluateHealth = "healthcheck" // CommandChat is the command for chat. CommandChat Command = "chat" @@ -32,7 +32,7 @@ func ParseCommand(path string) (Command, bool) { case "preview": return CommandPreview, true case "healthcheck": - return CommandHealth, true + return CommandEvaluateHealth, true case "chat": return CommandChat, true case "chat/health":