diff --git a/cmd/engram/llm.go b/cmd/engram/llm.go index 473b0c39..99895094 100644 --- a/cmd/engram/llm.go +++ b/cmd/engram/llm.go @@ -69,7 +69,7 @@ func llmBuildPrompt(a, b store.ObservationSnippet) string { func resolveAgentRunner() (store.SemanticRunner, error) { name := os.Getenv("ENGRAM_AGENT_CLI") if name == "" { - return nil, errors.New("ENGRAM_AGENT_CLI is not set; required for --semantic scan (set to 'claude' or 'opencode')") + return nil, errors.New("ENGRAM_AGENT_CLI is not set; required for --semantic scan (set to 'claude', 'opencode', or 'minimax')") } return agentRunnerFactory(name) } diff --git a/cmd/engram/main.go b/cmd/engram/main.go index 730d783f..11795076 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -2721,7 +2721,7 @@ Environment: ENGRAM_TIMEZONE Timezone for timestamp display in TUI and cloud dashboard. Accepts any IANA zone name (e.g. America/New_York, Europe/Berlin). Falls back to system local time when unset or invalid. - ENGRAM_AGENT_CLI LLM runner for conflicts scan --semantic (claude or opencode) + ENGRAM_AGENT_CLI LLM runner for conflicts scan --semantic (claude, opencode, or minimax) ENGRAM_CLOUD_AUTOSYNC Set to 1 to enable background autosync; also requires ENGRAM_CLOUD_TOKEN and ENGRAM_CLOUD_SERVER diff --git a/internal/llm/factory.go b/internal/llm/factory.go index 5ed7d043..00b8caf9 100644 --- a/internal/llm/factory.go +++ b/internal/llm/factory.go @@ -8,7 +8,7 @@ import ( // ─── Sentinel errors ────────────────────────────────────────────────────────── // ErrInvalidRunnerName is returned by NewRunner when the name argument does not -// match a known runner identifier ("claude" | "opencode"). +// match a known runner identifier ("claude" | "opencode" | "minimax"). var ErrInvalidRunnerName = errors.New("invalid runner name") // ─── Factory ────────────────────────────────────────────────────────────────── @@ -17,6 +17,7 @@ var ErrInvalidRunnerName = errors.New("invalid runner name") // Supported values: // - "claude" → *ClaudeRunner (shells out to the claude CLI) // - "opencode" → *OpenCodeRunner (shells out to the opencode CLI) +// - "minimax" → *MiniMaxRunner (calls the MiniMax hosted API over HTTP) // // For any other value, including the empty string, a descriptive error is // returned that names the ENGRAM_AGENT_CLI environment variable and the @@ -33,15 +34,18 @@ func NewRunner(name string) (AgentRunner, error) { case "opencode": return NewOpenCodeRunner(), nil + case "minimax": + return NewMiniMaxRunner(), nil + case "": return nil, fmt.Errorf( - "%w: ENGRAM_AGENT_CLI is not set; supported values are: claude, opencode", + "%w: ENGRAM_AGENT_CLI is not set; supported values are: claude, opencode, minimax", ErrInvalidRunnerName, ) default: return nil, fmt.Errorf( - "%w: %q is not a recognized runner; set ENGRAM_AGENT_CLI to one of: claude, opencode", + "%w: %q is not a recognized runner; set ENGRAM_AGENT_CLI to one of: claude, opencode, minimax", ErrInvalidRunnerName, name, ) diff --git a/internal/llm/factory_test.go b/internal/llm/factory_test.go index 6f134473..422aef84 100644 --- a/internal/llm/factory_test.go +++ b/internal/llm/factory_test.go @@ -29,6 +29,17 @@ func TestNewRunner_OpenCode(t *testing.T) { } } +// TestNewRunner_MiniMax verifies that "minimax" returns a *MiniMaxRunner without error. +func TestNewRunner_MiniMax(t *testing.T) { + runner, err := llm.NewRunner("minimax") + if err != nil { + t.Fatalf("NewRunner(\"minimax\"): unexpected error: %v", err) + } + if _, ok := runner.(*llm.MiniMaxRunner); !ok { + t.Errorf("NewRunner(\"minimax\") returned %T; want *llm.MiniMaxRunner", runner) + } +} + // TestNewRunner_Empty verifies that an empty string returns a descriptive error // naming the ENGRAM_AGENT_CLI env var. func TestNewRunner_Empty(t *testing.T) { diff --git a/internal/llm/minimax.go b/internal/llm/minimax.go new file mode 100644 index 00000000..bf5d2d01 --- /dev/null +++ b/internal/llm/minimax.go @@ -0,0 +1,245 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// ─── MiniMax provider configuration ──────────────────────────────────────────── + +// Text model identifiers accepted by the MiniMax runner. +const ( + // MiniMaxDefaultModel is used when ENGRAM_MINIMAX_MODEL is unset. + MiniMaxDefaultModel = "MiniMax-M3" + + // MiniMaxModelM3 is the flagship text model (1,000,000-token context). + MiniMaxModelM3 = "MiniMax-M3" + + // MiniMaxModelM27 is the compact text model (204,800-token context). + MiniMaxModelM27 = "MiniMax-M2.7" +) + +// MiniMaxModelIDs lists the text models this runner is configured for. +var MiniMaxModelIDs = []string{MiniMaxModelM3, MiniMaxModelM27} + +// Regional API configuration. ENGRAM_MINIMAX_REGION selects a region and +// ENGRAM_MINIMAX_BASE_URL overrides the resolved base URL entirely. +const ( + // MiniMaxRegionGlobal is the default region key. + MiniMaxRegionGlobal = "global_en" + + // MiniMaxRegionChina is the alternate region key. + MiniMaxRegionChina = "cn_zh" + + miniMaxBaseURLGlobal = "https://api.minimax.io/v1" + miniMaxBaseURLChina = "https://api.minimaxi.com/v1" + + // miniMaxChatPath is appended to the resolved base URL to reach the + // chat completions endpoint. + miniMaxChatPath = "/chat/completions" +) + +// miniMaxHTTPClient is the shared client used for outbound requests. It carries +// a conservative backstop timeout; per-call deadlines come from the context. +var miniMaxHTTPClient = &http.Client{Timeout: 60 * time.Second} + +// ─── MiniMaxRunner ────────────────────────────────────────────────────────────── + +// MiniMaxRunner implements AgentRunner by calling the MiniMax hosted chat +// completions API directly over HTTP. It sends the canonical comparison prompt +// as a single user message and parses the returned Verdict JSON. +type MiniMaxRunner struct { + // baseURL is the API base (without the chat completions path). + baseURL string + + // apiKey authenticates the request via a bearer token. + apiKey string + + // model is the text model identifier sent with each request. + model string + + // doRequest is the HTTP round-trip seam. Defaults to miniMaxHTTPClient.Do. + // Tests inject a fake implementation to avoid real network calls. + doRequest func(*http.Request) (*http.Response, error) +} + +// NewMiniMaxRunner constructs a MiniMaxRunner from the environment: +// +// MINIMAX_API_KEY bearer token (required at Compare time) +// ENGRAM_MINIMAX_MODEL text model id; defaults to MiniMaxDefaultModel +// ENGRAM_MINIMAX_REGION "global_en" (default) or "cn_zh" +// ENGRAM_MINIMAX_BASE_URL optional explicit base URL override +// +// Missing credentials are reported as an error from Compare rather than at +// construction, mirroring the other runners' lazy-failure behavior. +func NewMiniMaxRunner() *MiniMaxRunner { + return &MiniMaxRunner{ + baseURL: miniMaxResolveBaseURL(), + apiKey: strings.TrimSpace(os.Getenv("MINIMAX_API_KEY")), + model: miniMaxResolveModel(), + doRequest: miniMaxHTTPClient.Do, + } +} + +// miniMaxResolveBaseURL selects the base URL from the environment. +func miniMaxResolveBaseURL() string { + if v := strings.TrimSpace(os.Getenv("ENGRAM_MINIMAX_BASE_URL")); v != "" { + return strings.TrimRight(v, "/") + } + switch strings.TrimSpace(os.Getenv("ENGRAM_MINIMAX_REGION")) { + case MiniMaxRegionChina: + return miniMaxBaseURLChina + default: + return miniMaxBaseURLGlobal + } +} + +// miniMaxResolveModel selects the model id from the environment. +func miniMaxResolveModel() string { + if v := strings.TrimSpace(os.Getenv("ENGRAM_MINIMAX_MODEL")); v != "" { + return v + } + return MiniMaxDefaultModel +} + +// Compare sends prompt to the MiniMax chat completions endpoint and returns a +// structured Verdict. +// +// The request body carries the model and a single user message; the response's +// first choice message content is parsed as the Verdict JSON (markdown code +// fences are stripped before parsing). +func (r *MiniMaxRunner) Compare(ctx context.Context, prompt string) (Verdict, error) { + if r.apiKey == "" { + return Verdict{}, fmt.Errorf("%w: MINIMAX_API_KEY is not set", ErrCLIAuthMissing) + } + + payload, err := json.Marshal(miniMaxChatRequest{ + Model: r.model, + Stream: false, + Messages: []miniMaxMessage{ + {Role: "user", Content: prompt}, + }, + }) + if err != nil { + return Verdict{}, fmt.Errorf("minimax: encode request: %v", err) + } + + start := time.Now() + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, r.baseURL+miniMaxChatPath, bytes.NewReader(payload)) + if err != nil { + return Verdict{}, fmt.Errorf("minimax: build request: %v", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+r.apiKey) + + do := r.doRequest + if do == nil { + do = miniMaxHTTPClient.Do + } + resp, err := do(httpReq) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return Verdict{}, fmt.Errorf("%w: minimax request", ErrTimeout) + } + return Verdict{}, fmt.Errorf("minimax: request failed: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return Verdict{}, fmt.Errorf("minimax: read response: %v", err) + } + if resp.StatusCode != http.StatusOK { + return Verdict{}, fmt.Errorf("minimax: API returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + + return parseMiniMaxResponse(raw, time.Since(start).Milliseconds()) +} + +// ─── Compile-time interface satisfaction ─────────────────────────────────────── + +var _ AgentRunner = (*MiniMaxRunner)(nil) + +// ─── Request / response shapes ────────────────────────────────────────────────── + +// miniMaxChatRequest is the chat completions request body. +type miniMaxChatRequest struct { + Model string `json:"model"` + Stream bool `json:"stream"` + Messages []miniMaxMessage `json:"messages"` +} + +// miniMaxMessage is a single chat message. +type miniMaxMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// miniMaxChatResponse is the subset of the chat completions response we read. +type miniMaxChatResponse struct { + Model string `json:"model"` + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + BaseResp struct { + StatusCode int `json:"status_code"` + StatusMsg string `json:"status_msg"` + } `json:"base_resp"` +} + +// parseMiniMaxResponse decodes the chat completions response and returns a Verdict. +func parseMiniMaxResponse(raw []byte, durationMS int64) (Verdict, error) { + var env miniMaxChatResponse + if err := json.Unmarshal(raw, &env); err != nil { + return Verdict{}, fmt.Errorf("%w: response envelope: %v", ErrInvalidJSON, err) + } + + // A non-zero status_code signals an application-level failure even when the + // HTTP status is 200. + if env.BaseResp.StatusCode != 0 { + return Verdict{}, fmt.Errorf("minimax: API error %d: %s", env.BaseResp.StatusCode, env.BaseResp.StatusMsg) + } + + if len(env.Choices) == 0 { + return Verdict{}, fmt.Errorf("%w: response contained no choices", ErrInvalidJSON) + } + + // Strip markdown fences from the message content before JSON parsing. + content := strings.TrimSpace(env.Choices[0].Message.Content) + if m := fenceRE.FindStringSubmatch(content); len(m) == 2 { + content = strings.TrimSpace(m[1]) + } + + var iv innerVerdict + if err := json.Unmarshal([]byte(content), &iv); err != nil { + return Verdict{}, fmt.Errorf("%w: inner verdict: %v", ErrInvalidJSON, err) + } + + if !validRelations[iv.Relation] { + return Verdict{}, fmt.Errorf("%w: %q", ErrUnknownRelation, iv.Relation) + } + + // Prefer the model reported by the API, falling back to the inner field. + model := env.Model + if model == "" { + model = iv.Model + } + + return Verdict{ + Relation: iv.Relation, + Confidence: iv.Confidence, + Reasoning: iv.Reasoning, + Model: model, + DurationMS: durationMS, + }, nil +} diff --git a/internal/llm/minimax_test.go b/internal/llm/minimax_test.go new file mode 100644 index 00000000..afee27e0 --- /dev/null +++ b/internal/llm/minimax_test.go @@ -0,0 +1,248 @@ +package llm + +// This test file lives in package llm (not llm_test) so it can inject the +// unexported doRequest seam and exercise the request/response internals +// without performing real network calls. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" +) + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// fakeHTTPResponse builds an *http.Response with the given status and body. +func fakeHTTPResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} + +// fakeDo returns a doRequest func that always returns the given response/error. +func fakeDo(resp *http.Response, err error) func(*http.Request) (*http.Response, error) { + return func(*http.Request) (*http.Response, error) { + return resp, err + } +} + +// chatEnvelope renders a chat completions response body wrapping innerContent. +func chatEnvelope(model, innerContent string) string { + return fmt.Sprintf( + `{"model":%q,"choices":[{"message":{"role":"assistant","content":%q}}],"base_resp":{"status_code":0,"status_msg":"success"}}`, + model, innerContent, + ) +} + +func newTestRunner(do func(*http.Request) (*http.Response, error)) *MiniMaxRunner { + return &MiniMaxRunner{ + baseURL: miniMaxBaseURLGlobal, + apiKey: "test-key", + model: MiniMaxDefaultModel, + doRequest: do, + } +} + +// ─── MiniMaxRunner tests ──────────────────────────────────────────────────────── + +// TestMiniMaxRunner_CompileTimeCheck verifies MiniMaxRunner satisfies AgentRunner. +var _ AgentRunner = (*MiniMaxRunner)(nil) + +func TestMiniMaxRunner_GoldenResponse(t *testing.T) { + inner := `{"Relation":"supersedes","Confidence":0.91,"Reasoning":"A replaces B"}` + body := chatEnvelope("MiniMax-M3", inner) + + r := newTestRunner(fakeDo(fakeHTTPResponse(http.StatusOK, body), nil)) + v, err := r.Compare(context.Background(), "compare these two") + if err != nil { + t.Fatalf("Compare: unexpected error: %v", err) + } + if v.Relation != "supersedes" { + t.Errorf("Relation = %q; want %q", v.Relation, "supersedes") + } + if v.Confidence != 0.91 { + t.Errorf("Confidence = %v; want 0.91", v.Confidence) + } + if v.Reasoning != "A replaces B" { + t.Errorf("Reasoning = %q; want %q", v.Reasoning, "A replaces B") + } + if v.Model != "MiniMax-M3" { + t.Errorf("Model = %q; want %q", v.Model, "MiniMax-M3") + } +} + +func TestMiniMaxRunner_FenceStripping(t *testing.T) { + inner := "```json\n{\"Relation\":\"compatible\",\"Confidence\":0.8,\"Reasoning\":\"They agree\"}\n```" + body := chatEnvelope("MiniMax-M2.7", inner) + + r := newTestRunner(fakeDo(fakeHTTPResponse(http.StatusOK, body), nil)) + v, err := r.Compare(context.Background(), "compare") + if err != nil { + t.Fatalf("Compare with fence: unexpected error: %v", err) + } + if v.Relation != "compatible" { + t.Errorf("Relation = %q; want %q", v.Relation, "compatible") + } + if v.Model != "MiniMax-M2.7" { + t.Errorf("Model = %q; want %q", v.Model, "MiniMax-M2.7") + } +} + +func TestMiniMaxRunner_RequestShape(t *testing.T) { + var captured *http.Request + var capturedBody []byte + do := func(req *http.Request) (*http.Response, error) { + captured = req + capturedBody, _ = io.ReadAll(req.Body) + inner := `{"Relation":"related","Confidence":0.6,"Reasoning":"shared topic"}` + return fakeHTTPResponse(http.StatusOK, chatEnvelope("MiniMax-M3", inner)), nil + } + + r := newTestRunner(do) + if _, err := r.Compare(context.Background(), "the prompt body"); err != nil { + t.Fatalf("Compare: unexpected error: %v", err) + } + + if captured.Method != http.MethodPost { + t.Errorf("method = %q; want POST", captured.Method) + } + if !strings.HasSuffix(captured.URL.String(), "/chat/completions") { + t.Errorf("URL = %q; want suffix /chat/completions", captured.URL.String()) + } + if got := captured.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("Authorization = %q; want %q", got, "Bearer test-key") + } + if got := captured.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q; want application/json", got) + } + + var reqBody miniMaxChatRequest + if err := json.Unmarshal(capturedBody, &reqBody); err != nil { + t.Fatalf("request body not valid JSON: %v", err) + } + if reqBody.Model != MiniMaxDefaultModel { + t.Errorf("request model = %q; want %q", reqBody.Model, MiniMaxDefaultModel) + } + if len(reqBody.Messages) != 1 || reqBody.Messages[0].Content != "the prompt body" { + t.Errorf("request messages = %+v; want single user message with the prompt", reqBody.Messages) + } +} + +func TestMiniMaxRunner_MissingAPIKey(t *testing.T) { + r := &MiniMaxRunner{baseURL: miniMaxBaseURLGlobal, model: MiniMaxDefaultModel, doRequest: fakeDo(nil, nil)} + _, err := r.Compare(context.Background(), "compare") + if !errors.Is(err, ErrCLIAuthMissing) { + t.Errorf("expected ErrCLIAuthMissing; got %v", err) + } +} + +func TestMiniMaxRunner_BaseRespError(t *testing.T) { + body := `{"model":"MiniMax-M3","choices":[],"base_resp":{"status_code":1004,"status_msg":"auth failed"}}` + r := newTestRunner(fakeDo(fakeHTTPResponse(http.StatusOK, body), nil)) + _, err := r.Compare(context.Background(), "compare") + if err == nil || !strings.Contains(err.Error(), "1004") { + t.Errorf("expected API error mentioning status_code 1004; got %v", err) + } +} + +func TestMiniMaxRunner_NoChoices(t *testing.T) { + body := `{"model":"MiniMax-M3","choices":[],"base_resp":{"status_code":0}}` + r := newTestRunner(fakeDo(fakeHTTPResponse(http.StatusOK, body), nil)) + _, err := r.Compare(context.Background(), "compare") + if !errors.Is(err, ErrInvalidJSON) { + t.Errorf("expected ErrInvalidJSON; got %v", err) + } +} + +func TestMiniMaxRunner_InvalidInnerJSON(t *testing.T) { + body := chatEnvelope("MiniMax-M3", "not valid json") + r := newTestRunner(fakeDo(fakeHTTPResponse(http.StatusOK, body), nil)) + _, err := r.Compare(context.Background(), "compare") + if !errors.Is(err, ErrInvalidJSON) { + t.Errorf("expected ErrInvalidJSON; got %v", err) + } +} + +func TestMiniMaxRunner_UnknownRelation(t *testing.T) { + inner := `{"Relation":"maybe_conflict","Confidence":0.5,"Reasoning":"dunno"}` + body := chatEnvelope("MiniMax-M3", inner) + r := newTestRunner(fakeDo(fakeHTTPResponse(http.StatusOK, body), nil)) + _, err := r.Compare(context.Background(), "compare") + if !errors.Is(err, ErrUnknownRelation) { + t.Errorf("expected ErrUnknownRelation; got %v", err) + } +} + +func TestMiniMaxRunner_Non200(t *testing.T) { + r := newTestRunner(fakeDo(fakeHTTPResponse(http.StatusInternalServerError, "boom"), nil)) + _, err := r.Compare(context.Background(), "compare") + if err == nil || !strings.Contains(err.Error(), "500") { + t.Errorf("expected error mentioning status 500; got %v", err) + } +} + +func TestMiniMaxRunner_TransportError(t *testing.T) { + transportErr := errors.New("connection refused") + r := newTestRunner(fakeDo(nil, transportErr)) + _, err := r.Compare(context.Background(), "compare") + if !errors.Is(err, transportErr) { + t.Errorf("expected wrapped transport error; got %v", err) + } +} + +func TestMiniMaxRunner_Timeout(t *testing.T) { + timeoutErr := fmt.Errorf("Post: %w", context.DeadlineExceeded) + r := newTestRunner(fakeDo(nil, timeoutErr)) + _, err := r.Compare(context.Background(), "compare") + if !errors.Is(err, ErrTimeout) { + t.Errorf("expected ErrTimeout; got %v", err) + } +} + +// ─── configuration resolution ────────────────────────────────────────────────── + +func TestMiniMaxResolveBaseURL(t *testing.T) { + t.Run("default is global", func(t *testing.T) { + t.Setenv("ENGRAM_MINIMAX_BASE_URL", "") + t.Setenv("ENGRAM_MINIMAX_REGION", "") + if got := miniMaxResolveBaseURL(); got != miniMaxBaseURLGlobal { + t.Errorf("baseURL = %q; want %q", got, miniMaxBaseURLGlobal) + } + }) + t.Run("china region", func(t *testing.T) { + t.Setenv("ENGRAM_MINIMAX_BASE_URL", "") + t.Setenv("ENGRAM_MINIMAX_REGION", MiniMaxRegionChina) + if got := miniMaxResolveBaseURL(); got != miniMaxBaseURLChina { + t.Errorf("baseURL = %q; want %q", got, miniMaxBaseURLChina) + } + }) + t.Run("explicit override wins", func(t *testing.T) { + t.Setenv("ENGRAM_MINIMAX_REGION", MiniMaxRegionChina) + t.Setenv("ENGRAM_MINIMAX_BASE_URL", "https://example.test/api/") + if got := miniMaxResolveBaseURL(); got != "https://example.test/api" { + t.Errorf("baseURL = %q; want trimmed override", got) + } + }) +} + +func TestMiniMaxResolveModel(t *testing.T) { + t.Run("default", func(t *testing.T) { + t.Setenv("ENGRAM_MINIMAX_MODEL", "") + if got := miniMaxResolveModel(); got != MiniMaxDefaultModel { + t.Errorf("model = %q; want %q", got, MiniMaxDefaultModel) + } + }) + t.Run("override", func(t *testing.T) { + t.Setenv("ENGRAM_MINIMAX_MODEL", MiniMaxModelM27) + if got := miniMaxResolveModel(); got != MiniMaxModelM27 { + t.Errorf("model = %q; want %q", got, MiniMaxModelM27) + } + }) +}