diff --git a/deploy/coolify/Dockerfile b/deploy/coolify/Dockerfile index e95bd70ec..d3cd0fc60 100644 --- a/deploy/coolify/Dockerfile +++ b/deploy/coolify/Dockerfile @@ -19,6 +19,12 @@ RUN printf '{"name":"agentmemory-deploy","version":"1.0.0","private":true,"overr && npm install "@agentmemory/agentmemory@${AGENTMEMORY_VERSION}" --omit=optional --no-fund --no-audit \ && ln -s /opt/agentmemory/node_modules/.bin/agentmemory /usr/local/bin/agentmemory +# Reasoning-model fallback: OpenRouter reasoning models return content:null +# with the answer in message.reasoning when finish_reason:"length". Fall back +# so mem::summarize chunk calls do not hard-fail (empty_provider_response). +COPY openrouter-reasoning-patch.mjs /tmp/openrouter-reasoning-patch.mjs +RUN node /tmp/openrouter-reasoning-patch.mjs && rm /tmp/openrouter-reasoning-patch.mjs + ENV AGENTMEMORY_III_VERSION=${III_VERSION} \ TINI_SUBREAPER=1 diff --git a/deploy/coolify/openrouter-reasoning-patch.mjs b/deploy/coolify/openrouter-reasoning-patch.mjs new file mode 100644 index 000000000..fd56ef194 --- /dev/null +++ b/deploy/coolify/openrouter-reasoning-patch.mjs @@ -0,0 +1,35 @@ +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +// Reasoning models on OpenRouter (DeepSeek V4/R1, etc.) return the final +// answer in message.content but, when reasoning exhausts the token budget +// (finish_reason:"length"), content is null and only reasoning / +// reasoning_details are populated. Fall back so mem::summarize chunk calls +// do not hard-fail with empty_provider_response. The reasoning text is +// degraded (truncated) in the length case, but the chunk+reduce layer +// tolerates partial output better than a hard throw. +const dir = "/opt/agentmemory/node_modules/@agentmemory/agentmemory/dist"; +const old = + "const content = data.choices?.[0]?.message?.content;\n" + + "\t\tif (!content) throw new Error(`${this.name} returned unexpected response"; +const replacement = + "const _msg = data.choices?.[0]?.message;\n" + + "\t\tlet content = _msg?.content;\n" + + "\t\tif (!content) { const _rd = _msg?.reasoning_details; content = (Array.isArray(_rd) ? _rd.find(d => d && d.text)?.text : undefined) ?? _msg?.reasoning; }\n" + + "\t\tif (!content) throw new Error(`${this.name} returned unexpected response"; + +let patched = 0; +for (const f of readdirSync(dir)) { + if (!f.endsWith(".mjs")) continue; + const p = join(dir, f); + const s = readFileSync(p, "utf8"); + if (!s.includes(old)) continue; + writeFileSync(p, s.replace(old, replacement)); + console.log("openrouter-reasoning-patch: patched", p); + patched++; +} +if (patched === 0) { + console.error("openrouter-reasoning-patch: PATTERN NOT FOUND — aborting build"); + process.exit(1); +} +console.log("openrouter-reasoning-patch: " + patched + " file(s) patched"); diff --git a/src/providers/openrouter.ts b/src/providers/openrouter.ts index 5c47bb0a8..91e2e1f57 100644 --- a/src/providers/openrouter.ts +++ b/src/providers/openrouter.ts @@ -59,9 +59,30 @@ export class OpenRouterProvider implements MemoryProvider { const data = (await response.json()) as Record; const choices = data.choices as - | Array<{ message: { content: string } }> + | Array<{ + message: { + content: string | null; + reasoning?: string | null; + reasoning_details?: Array<{ text?: string }> | null; + }; + }> | undefined; - const content = choices?.[0]?.message?.content; + const msg = choices?.[0]?.message; + let content = msg?.content; + // Reasoning models on OpenRouter (DeepSeek V4/R1, etc.) return the final + // answer in `message.content` but, when reasoning exhausts the token + // budget (finish_reason:"length"), `content` is null and only + // `reasoning`/`reasoning_details` are populated. Fall back so summarize + // chunk calls do not hard-fail with empty_provider_response. The + // reasoning text is degraded (truncated) in the length case, but the + // chunk+reduce layer tolerates partial output better than a hard throw. + if (!content) { + const details = msg?.reasoning_details; + content = + (Array.isArray(details) + ? details.find((d) => d.text)?.text + : undefined) ?? msg?.reasoning ?? undefined; + } if (!content) { throw new Error( `${this.name} returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`,