diff --git a/apps/memos-local-openclaw/src/ingest/providers/openai.ts b/apps/memos-local-openclaw/src/ingest/providers/openai.ts index 825e2131d..60f878e4c 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/openai.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/openai.ts @@ -233,7 +233,14 @@ export async function judgeNewTopicOpenAI( body: JSON.stringify(buildRequestBody(cfg, { model, temperature: 0, - max_tokens: 10, + // NOTE: must stay >= 60. MiniMax's gateway (api.minimaxi.com) rejects + // chat-completion requests with very small max_tokens (e.g. 10) by + // returning an HTML 404 page before the request ever reaches the + // model. 60 matches classifyTopicOpenAI below — already proven to + // work against MiniMax-M2.7-highspeed — and is plenty for a one-word + // NEW/SAME reply plus any reasoning preamble the model may emit. + // See issue #1315. + max_tokens: 60, messages: [ { role: "system", content: TOPIC_JUDGE_PROMPT }, { role: "user", content: userContent }, @@ -336,7 +343,9 @@ export async function arbitrateTopicSplitOpenAI( body: JSON.stringify(buildRequestBody(cfg, { model, temperature: 0, - max_tokens: 10, + // NOTE: must stay >= 60. See note in judgeNewTopicOpenAI above — + // MiniMax's gateway returns HTML 404 for max_tokens: 10. Issue #1315. + max_tokens: 60, messages: [ { role: "system", content: TOPIC_ARBITRATION_PROMPT }, { role: "user", content: userContent }, diff --git a/apps/memos-local-openclaw/tests/topic-judge-minimax-1315.test.ts b/apps/memos-local-openclaw/tests/topic-judge-minimax-1315.test.ts new file mode 100644 index 000000000..afd4ac750 --- /dev/null +++ b/apps/memos-local-openclaw/tests/topic-judge-minimax-1315.test.ts @@ -0,0 +1,132 @@ +/** + * Regression test for issue #1315: + * Topic Judge 100% failure rate against MiniMax (api.minimaxi.com) because + * judgeNewTopicOpenAI / arbitrateTopicSplitOpenAI request max_tokens: 10, + * which MiniMax's gateway rejects with an HTML 404 page. + * + * The fix raises the minimum to 60 (matching classifyTopicOpenAI in the same + * file, which is already proven to work against MiniMax). These tests assert + * the on-the-wire request body uses at least 60 max_tokens for the two + * affected helpers; if anyone lowers them back to 10 the tests fail. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + judgeNewTopicOpenAI, + arbitrateTopicSplitOpenAI, + classifyTopicOpenAI, + filterRelevantOpenAI, + judgeDedupOpenAI, + summarizeOpenAI, +} from "../src/ingest/providers/openai"; +import type { SummarizerConfig, Logger } from "../src/types"; + +const silentLog: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +const minimaxCfg: SummarizerConfig = { + provider: "openai_compatible", + model: "MiniMax-M2.7-highspeed", + endpoint: "https://api.minimaxi.com/v1", + apiKey: "test-key", +}; + +interface CapturedRequest { + url: string; + body: Record; +} + +/** + * Replace global.fetch with a recorder that returns a canned successful + * completion. Returns the captured-requests array so tests can assert on + * url / body / max_tokens. + */ +function installFetchRecorder(replyContent: string): CapturedRequest[] { + const captured: CapturedRequest[] = []; + const fakeFetch = vi.fn(async (url: string | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(init.body as string) : {}; + captured.push({ url: String(url), body }); + return new Response( + JSON.stringify({ + choices: [{ message: { content: replyContent } }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fakeFetch); + return captured; +} + +describe("openai topic-judge max_tokens regression (issue #1315)", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("judgeNewTopicOpenAI sends max_tokens >= 60 (MiniMax rejects max_tokens: 10 with HTML 404)", async () => { + const captured = installFetchRecorder("SAME"); + + await judgeNewTopicOpenAI("current task context", "new user message", minimaxCfg, silentLog); + + expect(captured).toHaveLength(1); + expect(captured[0].url).toBe("https://api.minimaxi.com/v1/chat/completions"); + const maxTokens = captured[0].body.max_tokens as number; + expect(maxTokens).toBeGreaterThanOrEqual(60); + }); + + it("arbitrateTopicSplitOpenAI sends max_tokens >= 60 (same MiniMax 404 gateway behaviour)", async () => { + const captured = installFetchRecorder("NEW"); + + await arbitrateTopicSplitOpenAI("task state", "new message", minimaxCfg, silentLog); + + expect(captured).toHaveLength(1); + expect(captured[0].url).toBe("https://api.minimaxi.com/v1/chat/completions"); + const maxTokens = captured[0].body.max_tokens as number; + expect(maxTokens).toBeGreaterThanOrEqual(60); + }); + + it("judgeNewTopicOpenAI still parses single-word NEW / SAME replies after the bump", async () => { + installFetchRecorder("NEW"); + const isNew = await judgeNewTopicOpenAI("ctx", "msg", minimaxCfg, silentLog); + expect(isNew).toBe(true); + + installFetchRecorder("SAME"); + const isSame = await judgeNewTopicOpenAI("ctx", "msg", minimaxCfg, silentLog); + expect(isSame).toBe(false); + }); + + it("arbitrateTopicSplitOpenAI still normalises replies to NEW or SAME after the bump", async () => { + installFetchRecorder("NEW\n"); + expect(await arbitrateTopicSplitOpenAI("task", "msg", minimaxCfg, silentLog)).toBe("NEW"); + + installFetchRecorder("same"); + expect(await arbitrateTopicSplitOpenAI("task", "msg", minimaxCfg, silentLog)).toBe("SAME"); + }); + + it("other openai helpers keep their existing max_tokens limits (no regression on healthy callers)", async () => { + const a = installFetchRecorder("60"); + await classifyTopicOpenAI("task", "msg", minimaxCfg, silentLog); + // classifyTopic was already 60 — should remain at least 60. + expect(a[0].body.max_tokens as number).toBeGreaterThanOrEqual(60); + + const b = installFetchRecorder('{"relevant":[],"sufficient":false}'); + await filterRelevantOpenAI("q", [{ index: 1, role: "user", content: "c" }], minimaxCfg, silentLog); + expect(b[0].body.max_tokens as number).toBeGreaterThanOrEqual(200); + + const c = installFetchRecorder('{"action":"NEW","reason":""}'); + await judgeDedupOpenAI("new", [{ index: 1, summary: "s", chunkId: "x" }], minimaxCfg, silentLog); + expect(c[0].body.max_tokens as number).toBeGreaterThanOrEqual(300); + + const d = installFetchRecorder("hello world summary"); + await summarizeOpenAI("input text", minimaxCfg, silentLog); + // summarize does not set max_tokens (server default) — assert the field is absent / unset. + expect(d[0].body.max_tokens).toBeUndefined(); + }); +});