128
packages/ai/test/anthropic-long-cache-retention-e2e.test.ts
Normal file
128
packages/ai/test/anthropic-long-cache-retention-e2e.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getEnvApiKey } from "../src/env-api-keys.js";
|
||||
import { getModels, getProviders } from "../src/models.js";
|
||||
import { complete } from "../src/stream.js";
|
||||
import type { Api, KnownProvider, Model, ProviderStreamOptions } from "../src/types.js";
|
||||
import { resolveApiKey } from "./oauth.js";
|
||||
|
||||
const githubCopilotToken = await resolveApiKey("github-copilot");
|
||||
|
||||
interface AnthropicLongCacheRetentionE2ECase {
|
||||
name: string;
|
||||
provider: KnownProvider;
|
||||
model: Model<"anthropic-messages">;
|
||||
apiKey: string | undefined;
|
||||
}
|
||||
|
||||
function getE2EApiKey(provider: KnownProvider): string | undefined {
|
||||
if (provider === "github-copilot") {
|
||||
return githubCopilotToken;
|
||||
}
|
||||
return getEnvApiKey(provider);
|
||||
}
|
||||
|
||||
function getAnthropicMessagesModels(provider: KnownProvider): Model<"anthropic-messages">[] {
|
||||
const models = getModels(provider) as Model<Api>[];
|
||||
return models.filter((model) => model.api === "anthropic-messages") as Model<"anthropic-messages">[];
|
||||
}
|
||||
|
||||
const anthropicMessagesCases: AnthropicLongCacheRetentionE2ECase[] = getProviders().flatMap((provider) =>
|
||||
getAnthropicMessagesModels(provider).map((model) => ({
|
||||
name: `${provider}/${model.id}`,
|
||||
provider,
|
||||
model,
|
||||
apiKey: getE2EApiKey(provider),
|
||||
})),
|
||||
);
|
||||
|
||||
function getProbePriority(model: Model<"anthropic-messages">): number {
|
||||
const modelId = model.id.toLowerCase();
|
||||
const cost = model.cost.input + model.cost.output;
|
||||
let priority = cost;
|
||||
|
||||
if (modelId.includes("haiku") && (modelId.includes("4-5") || modelId.includes("4.5"))) {
|
||||
priority -= 1000;
|
||||
} else if (modelId.includes("sonnet") && (modelId.includes("4-") || modelId.includes("4."))) {
|
||||
priority -= 750;
|
||||
} else if (modelId.includes("claude") && (modelId.includes("4-") || modelId.includes("4."))) {
|
||||
priority -= 500;
|
||||
}
|
||||
|
||||
return priority;
|
||||
}
|
||||
|
||||
function selectOneCasePerProvider(cases: AnthropicLongCacheRetentionE2ECase[]): AnthropicLongCacheRetentionE2ECase[] {
|
||||
const byProvider = new Map<KnownProvider, AnthropicLongCacheRetentionE2ECase[]>();
|
||||
for (const testCase of cases) {
|
||||
const providerCases = byProvider.get(testCase.provider) ?? [];
|
||||
providerCases.push(testCase);
|
||||
byProvider.set(testCase.provider, providerCases);
|
||||
}
|
||||
|
||||
return Array.from(byProvider.values()).map(
|
||||
(providerCases) =>
|
||||
providerCases.sort(
|
||||
(a, b) => getProbePriority(a.model) - getProbePriority(b.model) || a.model.id.localeCompare(b.model.id),
|
||||
)[0],
|
||||
);
|
||||
}
|
||||
|
||||
const probeCases = selectOneCasePerProvider(anthropicMessagesCases);
|
||||
|
||||
function withLongCacheRetention(model: Model<"anthropic-messages">): Model<"anthropic-messages"> {
|
||||
return {
|
||||
...model,
|
||||
compat: {
|
||||
...model.compat,
|
||||
supportsLongCacheRetention: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function expectLongCacheRetentionAccepted(
|
||||
model: Model<"anthropic-messages">,
|
||||
apiKey: string | undefined,
|
||||
): Promise<void> {
|
||||
const options: ProviderStreamOptions = {
|
||||
apiKey,
|
||||
cacheRetention: "long",
|
||||
maxTokens: 128,
|
||||
thinkingEnabled: false,
|
||||
};
|
||||
const response = await complete(
|
||||
model,
|
||||
{
|
||||
systemPrompt: "You are a concise assistant.",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Reply with exactly: long cache retention accepted",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
expect(response.errorMessage, response.errorMessage).toBeFalsy();
|
||||
expect(response.stopReason, response.errorMessage).not.toBe("error");
|
||||
}
|
||||
|
||||
describe("Anthropic Messages long cache retention E2E", () => {
|
||||
it("covers every generated anthropic-messages model", () => {
|
||||
const expectedModels = getProviders().flatMap((provider) =>
|
||||
getAnthropicMessagesModels(provider).map((model) => `${provider}/${model.id}`),
|
||||
);
|
||||
expect(anthropicMessagesCases.map((testCase) => testCase.name).sort()).toEqual(expectedModels.sort());
|
||||
});
|
||||
|
||||
describe("forced long cache retention probe", () => {
|
||||
for (const testCase of probeCases) {
|
||||
const model = withLongCacheRetention(testCase.model);
|
||||
|
||||
it.skipIf(!testCase.apiKey)(`${testCase.name} accepts long cache retention`, { retry: 2 }, async () => {
|
||||
await expectLongCacheRetentionAccepted(model, testCase.apiKey);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { getModel } from "../src/models.js";
|
||||
import { stream } from "../src/stream.js";
|
||||
import type { Context } from "../src/types.js";
|
||||
import type { Context, Model } from "../src/types.js";
|
||||
|
||||
describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
const originalEnv = process.env.PI_CACHE_RETENTION;
|
||||
@@ -70,7 +70,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
|
||||
});
|
||||
|
||||
it("should not add ttl when baseUrl is not api.anthropic.com", async () => {
|
||||
it("should add ttl for non-api.anthropic.com baseUrl by default", async () => {
|
||||
process.env.PI_CACHE_RETENTION = "long";
|
||||
|
||||
// Create a model with a different baseUrl (simulating a proxy)
|
||||
@@ -106,11 +106,39 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
// The payload should have been captured before the error
|
||||
if (capturedPayload) {
|
||||
// System prompt should have cache_control WITHOUT ttl (proxy URL)
|
||||
expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral" });
|
||||
expect(capturedPayload).not.toBeNull();
|
||||
expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
|
||||
});
|
||||
|
||||
it("should omit ttl when supportsLongCacheRetention is false", async () => {
|
||||
const baseModel = getModel("anthropic", "claude-haiku-4-5");
|
||||
const proxyModel = {
|
||||
...baseModel,
|
||||
baseUrl: "https://my-proxy.example.com/v1",
|
||||
compat: { supportsLongCacheRetention: false },
|
||||
};
|
||||
let capturedPayload: any = null;
|
||||
|
||||
const { streamAnthropic } = await import("../src/providers/anthropic.js");
|
||||
|
||||
try {
|
||||
const s = streamAnthropic(proxyModel, context, {
|
||||
apiKey: "fake-key",
|
||||
cacheRetention: "long",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
},
|
||||
});
|
||||
|
||||
for await (const event of s) {
|
||||
if (event.type === "error") break;
|
||||
}
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
expect(capturedPayload).not.toBeNull();
|
||||
expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral" });
|
||||
});
|
||||
|
||||
it("should omit cache_control when cacheRetention is none", async () => {
|
||||
@@ -240,7 +268,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("should not set prompt_cache_retention when baseUrl is not api.openai.com", async () => {
|
||||
it("should set prompt_cache_retention for non-api.openai.com baseUrl by default", async () => {
|
||||
process.env.PI_CACHE_RETENTION = "long";
|
||||
|
||||
// Create a model with a different baseUrl (simulating a proxy)
|
||||
@@ -270,10 +298,38 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
// The payload should have been captured before the error
|
||||
if (capturedPayload) {
|
||||
expect(capturedPayload.prompt_cache_retention).toBeUndefined();
|
||||
expect(capturedPayload).not.toBeNull();
|
||||
expect(capturedPayload.prompt_cache_retention).toBe("24h");
|
||||
});
|
||||
|
||||
it("should omit prompt_cache_retention when supportsLongCacheRetention is false", async () => {
|
||||
const model = {
|
||||
...getModel("openai", "gpt-4o-mini"),
|
||||
compat: { supportsLongCacheRetention: false },
|
||||
};
|
||||
let capturedPayload: any = null;
|
||||
|
||||
const { streamOpenAIResponses } = await import("../src/providers/openai-responses.js");
|
||||
|
||||
try {
|
||||
const s = streamOpenAIResponses(model, context, {
|
||||
apiKey: "fake-key",
|
||||
cacheRetention: "long",
|
||||
sessionId: "session-compat-false",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
},
|
||||
});
|
||||
|
||||
for await (const event of s) {
|
||||
if (event.type === "error") break;
|
||||
}
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
expect(capturedPayload).not.toBeNull();
|
||||
expect(capturedPayload.prompt_cache_retention).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should omit prompt_cache_key when cacheRetention is none", async () => {
|
||||
@@ -332,4 +388,74 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
expect(capturedPayload.prompt_cache_retention).toBe("24h");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAI Completions Provider", () => {
|
||||
function createCompletionsModel(compat?: Model<"openai-completions">["compat"]): Model<"openai-completions"> {
|
||||
return {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
api: "openai-completions",
|
||||
provider: "test-openai-completions",
|
||||
baseUrl: "https://my-proxy.example.com/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
compat,
|
||||
};
|
||||
}
|
||||
|
||||
it("should set prompt_cache_retention for non-api.openai.com baseUrl by default", async () => {
|
||||
let capturedPayload: any = null;
|
||||
const { streamOpenAICompletions } = await import("../src/providers/openai-completions.js");
|
||||
|
||||
try {
|
||||
const s = streamOpenAICompletions(createCompletionsModel(), context, {
|
||||
apiKey: "fake-key",
|
||||
cacheRetention: "long",
|
||||
sessionId: "session-completions",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
},
|
||||
});
|
||||
|
||||
for await (const event of s) {
|
||||
if (event.type === "error") break;
|
||||
}
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
expect(capturedPayload).not.toBeNull();
|
||||
expect(capturedPayload.prompt_cache_key).toBe("session-completions");
|
||||
expect(capturedPayload.prompt_cache_retention).toBe("24h");
|
||||
});
|
||||
|
||||
it("should omit prompt_cache_retention when supportsLongCacheRetention is false", async () => {
|
||||
let capturedPayload: any = null;
|
||||
const { streamOpenAICompletions } = await import("../src/providers/openai-completions.js");
|
||||
|
||||
try {
|
||||
const s = streamOpenAICompletions(createCompletionsModel({ supportsLongCacheRetention: false }), context, {
|
||||
apiKey: "fake-key",
|
||||
cacheRetention: "long",
|
||||
sessionId: "session-completions-false",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
},
|
||||
});
|
||||
|
||||
for await (const event of s) {
|
||||
if (event.type === "error") break;
|
||||
}
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
expect(capturedPayload).not.toBeNull();
|
||||
expect(capturedPayload.prompt_cache_key).toBeUndefined();
|
||||
expect(capturedPayload.prompt_cache_retention).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ const compat = {
|
||||
supportsStrictMode: true,
|
||||
cacheControlFormat: undefined,
|
||||
sendSessionAffinityHeaders: false,
|
||||
supportsLongCacheRetention: true,
|
||||
} satisfies Required<Omit<OpenAICompletionsCompat, "cacheControlFormat">> & {
|
||||
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ const compat: Required<OpenAICompletionsCompat> = {
|
||||
supportsStrictMode: true,
|
||||
cacheControlFormat: "anthropic",
|
||||
sendSessionAffinityHeaders: false,
|
||||
supportsLongCacheRetention: true,
|
||||
};
|
||||
|
||||
function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessage {
|
||||
|
||||
Reference in New Issue
Block a user