mirror of
https://github.com/rohitg00/agentmemory.git
synced 2026-09-14 20:16:33 +08:00
aa386b7884
Bundles three independent bug fixes that block the v0.9.25 release. fix(providers): fallback chain resolves its OWN model, not the primary's (#778) createFallbackProvider copied config.model from the primary into every fallback. With OpenAI primary + Gemini fallback, Gemini was called with 'gpt-4o-mini' and 404'd on every call, which tripped the circuit breaker and blocked downstream LLM ops entirely. New defaultModelFor() resolves each provider's env-driven default (OPENAI_MODEL, GEMINI_MODEL, ANTHROPIC_MODEL, MINIMAX_MODEL, OPENROUTER_MODEL) so a fallback config now does what it advertises. fix(replay): import-jsonl re-keys on parsed.sessionId, not existing.id (#775) The existing-session branch wrote kv.set(KV.sessions, existing.id, ...) but legacy session rows can be missing the 'id' field. existing.id was then undefined, JSON.stringify dropped the key from the state::set payload, and the engine rejected the call with 'missing field key'. The rejection aborted the whole handler — a single legacy row killed the entire import. Now re-keys on parsed.sessionId (always populated by parseJsonlText's three-level fallback) and backfills missing existing.id so future reads stay clean. fix(summarize): strip markdown fences + retry final parse on failure (#783) parseSummaryXml passed the raw LLM payload to the tag regex. DeepSeek, GPT, and others wrap XML in fences or prepend a conversational sentence, which caused a silent parse_failed and the session was never summarized. Added stripXmlWrappers() that peels markdown fences and a surrounding pre/postamble before the regex runs. Also extended the chunk-level 2-attempt retry pattern to the final summarize merge so a flaky wrap on the first attempt doesn't drop the summary. Tests (10 new total) test/fallback-model-resolution.test.ts (5): primary/fallback model separation across OpenAI/Gemini/Anthropic/Minimax/OpenRouter, env override precedence, duplicate-provider skip. test/replay-import-key.test.ts (2): id-less existing row no longer aborts the import; fresh import path unchanged. test/summarize.test.ts (3): markdown-fenced XML parses cleanly, final retry recovers on first-attempt parse failure, double-fail still surfaces parse_failed. Full suite: 125 files / 1379 tests pass. Closes #778, closes #775, closes #783.
191 lines
5.4 KiB
TypeScript
191 lines
5.4 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
|
|
// #778: fallback providers used to inherit the primary provider's
|
|
// model name and 404 on every call. Each fallback must resolve its
|
|
// own env-driven default model.
|
|
|
|
const captured: Array<{ provider: string; model: string }> = [];
|
|
|
|
vi.mock("../src/providers/openai.js", () => ({
|
|
OpenAIProvider: class {
|
|
name = "openai";
|
|
constructor(_key: string, model: string) {
|
|
captured.push({ provider: "openai", model });
|
|
}
|
|
async compress() {
|
|
return "";
|
|
}
|
|
async summarize() {
|
|
return "";
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock("../src/providers/openrouter.js", () => ({
|
|
OpenRouterProvider: class {
|
|
name = "openrouter";
|
|
constructor(_key: string, model: string, _max: number, url?: string) {
|
|
captured.push({
|
|
provider: url?.includes("googleapis") ? "gemini" : "openrouter",
|
|
model,
|
|
});
|
|
}
|
|
async compress() {
|
|
return "";
|
|
}
|
|
async summarize() {
|
|
return "";
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock("../src/providers/anthropic.js", () => ({
|
|
AnthropicProvider: class {
|
|
name = "anthropic";
|
|
constructor(_key: string, model: string) {
|
|
captured.push({ provider: "anthropic", model });
|
|
}
|
|
async compress() {
|
|
return "";
|
|
}
|
|
async summarize() {
|
|
return "";
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock("../src/providers/minimax.js", () => ({
|
|
MinimaxProvider: class {
|
|
name = "minimax";
|
|
constructor(_key: string, model: string) {
|
|
captured.push({ provider: "minimax", model });
|
|
}
|
|
async compress() {
|
|
return "";
|
|
}
|
|
async summarize() {
|
|
return "";
|
|
}
|
|
},
|
|
}));
|
|
|
|
import { createFallbackProvider } from "../src/providers/index.js";
|
|
import type { ProviderConfig, FallbackConfig } from "../src/types.js";
|
|
|
|
describe("Fallback provider model resolution (#778)", () => {
|
|
const savedEnv: Record<string, string | undefined> = {};
|
|
const envKeys = [
|
|
"OPENAI_API_KEY",
|
|
"OPENAI_MODEL",
|
|
"GEMINI_API_KEY",
|
|
"GEMINI_MODEL",
|
|
"GOOGLE_API_KEY",
|
|
"ANTHROPIC_API_KEY",
|
|
"ANTHROPIC_MODEL",
|
|
"OPENROUTER_API_KEY",
|
|
"OPENROUTER_MODEL",
|
|
"MINIMAX_API_KEY",
|
|
"MINIMAX_MODEL",
|
|
];
|
|
|
|
beforeEach(() => {
|
|
captured.length = 0;
|
|
for (const k of envKeys) {
|
|
savedEnv[k] = process.env[k];
|
|
delete process.env[k];
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const k of envKeys) {
|
|
if (savedEnv[k] === undefined) delete process.env[k];
|
|
else process.env[k] = savedEnv[k];
|
|
}
|
|
});
|
|
|
|
it("primary OpenAI + fallback Gemini: Gemini is built with GEMINI_MODEL, NOT the primary's model", () => {
|
|
process.env.OPENAI_API_KEY = "sk-openai";
|
|
process.env.GEMINI_API_KEY = "gemini-key";
|
|
process.env.GEMINI_MODEL = "gemini-2.5-flash";
|
|
|
|
const primary: ProviderConfig = {
|
|
provider: "openai",
|
|
model: "gpt-4o-mini",
|
|
maxTokens: 4096,
|
|
};
|
|
const fallback: FallbackConfig = { providers: ["gemini"] };
|
|
|
|
createFallbackProvider(primary, fallback);
|
|
|
|
const openaiCall = captured.find((c) => c.provider === "openai");
|
|
const geminiCall = captured.find((c) => c.provider === "gemini");
|
|
expect(openaiCall?.model).toBe("gpt-4o-mini");
|
|
expect(geminiCall?.model).toBe("gemini-2.5-flash");
|
|
expect(geminiCall?.model).not.toBe("gpt-4o-mini");
|
|
});
|
|
|
|
it("Gemini fallback uses the documented default when GEMINI_MODEL is unset", () => {
|
|
process.env.OPENAI_API_KEY = "sk-openai";
|
|
process.env.GEMINI_API_KEY = "gemini-key";
|
|
|
|
createFallbackProvider(
|
|
{ provider: "openai", model: "gpt-4o-mini", maxTokens: 4096 },
|
|
{ providers: ["gemini"] },
|
|
);
|
|
|
|
const geminiCall = captured.find((c) => c.provider === "gemini");
|
|
expect(geminiCall?.model).toBe("gemini-2.5-flash");
|
|
});
|
|
|
|
it("primary Anthropic + fallback OpenAI + Minimax: each fallback uses its own default", () => {
|
|
process.env.ANTHROPIC_API_KEY = "anth-key";
|
|
process.env.OPENAI_API_KEY = "openai-key";
|
|
process.env.OPENAI_MODEL = "gpt-5";
|
|
process.env.MINIMAX_API_KEY = "mini-key";
|
|
|
|
createFallbackProvider(
|
|
{
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4-20250514",
|
|
maxTokens: 4096,
|
|
},
|
|
{ providers: ["openai", "minimax"] },
|
|
);
|
|
|
|
const openai = captured.find((c) => c.provider === "openai");
|
|
const minimax = captured.find((c) => c.provider === "minimax");
|
|
expect(openai?.model).toBe("gpt-5");
|
|
expect(minimax?.model).toBe("MiniMax-M2.7");
|
|
// Neither inherits the Anthropic model name.
|
|
expect(openai?.model).not.toBe("claude-sonnet-4-20250514");
|
|
expect(minimax?.model).not.toBe("claude-sonnet-4-20250514");
|
|
});
|
|
|
|
it("env override on the fallback provider's MODEL var wins over the default", () => {
|
|
process.env.OPENAI_API_KEY = "sk";
|
|
process.env.GEMINI_API_KEY = "gk";
|
|
process.env.GEMINI_MODEL = "gemini-2.5-pro";
|
|
|
|
createFallbackProvider(
|
|
{ provider: "openai", model: "gpt-4o-mini", maxTokens: 4096 },
|
|
{ providers: ["gemini"] },
|
|
);
|
|
|
|
expect(captured.find((c) => c.provider === "gemini")?.model).toBe(
|
|
"gemini-2.5-pro",
|
|
);
|
|
});
|
|
|
|
it("fallback that matches the primary provider is skipped (no duplicate)", () => {
|
|
process.env.OPENAI_API_KEY = "sk";
|
|
|
|
createFallbackProvider(
|
|
{ provider: "openai", model: "gpt-4o-mini", maxTokens: 4096 },
|
|
{ providers: ["openai", "gemini"] },
|
|
);
|
|
|
|
const openaiCalls = captured.filter((c) => c.provider === "openai");
|
|
expect(openaiCalls.length).toBe(1);
|
|
});
|
|
});
|