@@ -51,14 +51,14 @@ function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCacheControl(
|
function getCacheControl(
|
||||||
baseUrl: string,
|
model: Model<"anthropic-messages">,
|
||||||
cacheRetention?: CacheRetention,
|
cacheRetention?: CacheRetention,
|
||||||
): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } {
|
): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } {
|
||||||
const retention = resolveCacheRetention(cacheRetention);
|
const retention = resolveCacheRetention(cacheRetention);
|
||||||
if (retention === "none") {
|
if (retention === "none") {
|
||||||
return { retention };
|
return { retention };
|
||||||
}
|
}
|
||||||
const ttl = retention === "long" && baseUrl.includes("api.anthropic.com") ? "1h" : undefined;
|
const ttl = retention === "long" && getAnthropicCompat(model).supportsLongCacheRetention ? "1h" : undefined;
|
||||||
return {
|
return {
|
||||||
retention,
|
retention,
|
||||||
cacheControl: { type: "ephemeral", ...(ttl && { ttl }) },
|
cacheControl: { type: "ephemeral", ...(ttl && { ttl }) },
|
||||||
@@ -166,6 +166,7 @@ const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
|
|||||||
function getAnthropicCompat(model: Model<"anthropic-messages">): Required<AnthropicMessagesCompat> {
|
function getAnthropicCompat(model: Model<"anthropic-messages">): Required<AnthropicMessagesCompat> {
|
||||||
return {
|
return {
|
||||||
supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true,
|
supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true,
|
||||||
|
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,7 +834,7 @@ function buildParams(
|
|||||||
isOAuthToken: boolean,
|
isOAuthToken: boolean,
|
||||||
options?: AnthropicOptions,
|
options?: AnthropicOptions,
|
||||||
): MessageCreateParamsStreaming {
|
): MessageCreateParamsStreaming {
|
||||||
const { cacheControl } = getCacheControl(model.baseUrl, options?.cacheRetention);
|
const { cacheControl } = getCacheControl(model, options?.cacheRetention);
|
||||||
const params: MessageCreateParamsStreaming = {
|
const params: MessageCreateParamsStreaming = {
|
||||||
model: model.id,
|
model: model.id,
|
||||||
messages: convertMessages(context.messages, model, isOAuthToken, cacheControl),
|
messages: convertMessages(context.messages, model, isOAuthToken, cacheControl),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
CacheRetention,
|
CacheRetention,
|
||||||
Context,
|
Context,
|
||||||
Model,
|
Model,
|
||||||
|
OpenAIResponsesCompat,
|
||||||
SimpleStreamOptions,
|
SimpleStreamOptions,
|
||||||
StreamFunction,
|
StreamFunction,
|
||||||
StreamOptions,
|
StreamOptions,
|
||||||
@@ -35,18 +36,18 @@ function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention
|
|||||||
return "short";
|
return "short";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCompat> {
|
||||||
* Get prompt cache retention based on cacheRetention and base URL.
|
return {
|
||||||
* Only applies to direct OpenAI API calls (api.openai.com).
|
sendSessionIdHeader: model.compat?.sendSessionIdHeader ?? true,
|
||||||
*/
|
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
|
||||||
function getPromptCacheRetention(baseUrl: string, cacheRetention: CacheRetention): "24h" | undefined {
|
};
|
||||||
if (cacheRetention !== "long") {
|
}
|
||||||
return undefined;
|
|
||||||
}
|
function getPromptCacheRetention(
|
||||||
if (baseUrl.includes("api.openai.com")) {
|
compat: Required<OpenAIResponsesCompat>,
|
||||||
return "24h";
|
cacheRetention: CacheRetention,
|
||||||
}
|
): "24h" | undefined {
|
||||||
return undefined;
|
return cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI Responses-specific options
|
// OpenAI Responses-specific options
|
||||||
@@ -169,6 +170,7 @@ function createClient(
|
|||||||
apiKey = process.env.OPENAI_API_KEY;
|
apiKey = process.env.OPENAI_API_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const compat = getCompat(model);
|
||||||
const headers = { ...model.headers };
|
const headers = { ...model.headers };
|
||||||
if (model.provider === "github-copilot") {
|
if (model.provider === "github-copilot") {
|
||||||
const hasImages = hasCopilotVisionInput(context.messages);
|
const hasImages = hasCopilotVisionInput(context.messages);
|
||||||
@@ -180,7 +182,7 @@ function createClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
if (model.compat?.sendSessionIdHeader !== false) {
|
if (compat.sendSessionIdHeader) {
|
||||||
headers.session_id = sessionId;
|
headers.session_id = sessionId;
|
||||||
}
|
}
|
||||||
headers["x-client-request-id"] = sessionId;
|
headers["x-client-request-id"] = sessionId;
|
||||||
@@ -203,12 +205,13 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
|
|||||||
const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS);
|
const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS);
|
||||||
|
|
||||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
|
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
|
||||||
|
const compat = getCompat(model);
|
||||||
const params: ResponseCreateParamsStreaming = {
|
const params: ResponseCreateParamsStreaming = {
|
||||||
model: model.id,
|
model: model.id,
|
||||||
input: messages,
|
input: messages,
|
||||||
stream: true,
|
stream: true,
|
||||||
prompt_cache_key: cacheRetention === "none" ? undefined : options?.sessionId,
|
prompt_cache_key: cacheRetention === "none" ? undefined : options?.sessionId,
|
||||||
prompt_cache_retention: getPromptCacheRetention(model.baseUrl, cacheRetention),
|
prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention),
|
||||||
store: false,
|
store: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -296,12 +296,16 @@ export interface OpenAICompletionsCompat {
|
|||||||
cacheControlFormat?: "anthropic";
|
cacheControlFormat?: "anthropic";
|
||||||
/** Whether to send known session-affinity headers (`session_id`, `x-client-request-id`, `x-session-affinity`) from `options.sessionId` when caching is enabled. Default: false. */
|
/** Whether to send known session-affinity headers (`session_id`, `x-client-request-id`, `x-session-affinity`) from `options.sessionId` when caching is enabled. Default: false. */
|
||||||
sendSessionAffinityHeaders?: boolean;
|
sendSessionAffinityHeaders?: boolean;
|
||||||
|
/** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */
|
||||||
|
supportsLongCacheRetention?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compatibility settings for OpenAI Responses APIs. */
|
/** Compatibility settings for OpenAI Responses APIs. */
|
||||||
export interface OpenAIResponsesCompat {
|
export interface OpenAIResponsesCompat {
|
||||||
/** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */
|
/** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */
|
||||||
sendSessionIdHeader?: boolean;
|
sendSessionIdHeader?: boolean;
|
||||||
|
/** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */
|
||||||
|
supportsLongCacheRetention?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compatibility settings for Anthropic Messages-compatible APIs. */
|
/** Compatibility settings for Anthropic Messages-compatible APIs. */
|
||||||
@@ -314,6 +318,8 @@ export interface AnthropicMessagesCompat {
|
|||||||
* Default: true.
|
* Default: true.
|
||||||
*/
|
*/
|
||||||
supportsEagerToolInputStreaming?: boolean;
|
supportsEagerToolInputStreaming?: boolean;
|
||||||
|
/** Whether the provider supports Anthropic long cache retention (`cache_control.ttl: "1h"`). Default: true. */
|
||||||
|
supportsLongCacheRetention?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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 { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { getModel } from "../src/models.js";
|
import { getModel } from "../src/models.js";
|
||||||
import { stream } from "../src/stream.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)", () => {
|
describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||||
const originalEnv = process.env.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" });
|
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";
|
process.env.PI_CACHE_RETENTION = "long";
|
||||||
|
|
||||||
// Create a model with a different baseUrl (simulating a proxy)
|
// Create a model with a different baseUrl (simulating a proxy)
|
||||||
@@ -106,11 +106,39 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
|||||||
// Expected to fail
|
// Expected to fail
|
||||||
}
|
}
|
||||||
|
|
||||||
// The payload should have been captured before the error
|
expect(capturedPayload).not.toBeNull();
|
||||||
if (capturedPayload) {
|
expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
|
||||||
// System prompt should have cache_control WITHOUT ttl (proxy URL)
|
});
|
||||||
expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral" });
|
|
||||||
|
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 () => {
|
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";
|
process.env.PI_CACHE_RETENTION = "long";
|
||||||
|
|
||||||
// Create a model with a different baseUrl (simulating a proxy)
|
// Create a model with a different baseUrl (simulating a proxy)
|
||||||
@@ -270,10 +298,38 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
|||||||
// Expected to fail
|
// Expected to fail
|
||||||
}
|
}
|
||||||
|
|
||||||
// The payload should have been captured before the error
|
expect(capturedPayload).not.toBeNull();
|
||||||
if (capturedPayload) {
|
expect(capturedPayload.prompt_cache_retention).toBe("24h");
|
||||||
expect(capturedPayload.prompt_cache_retention).toBeUndefined();
|
});
|
||||||
|
|
||||||
|
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 () => {
|
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");
|
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,
|
supportsStrictMode: true,
|
||||||
cacheControlFormat: undefined,
|
cacheControlFormat: undefined,
|
||||||
sendSessionAffinityHeaders: false,
|
sendSessionAffinityHeaders: false,
|
||||||
|
supportsLongCacheRetention: true,
|
||||||
} satisfies Required<Omit<OpenAICompletionsCompat, "cacheControlFormat">> & {
|
} satisfies Required<Omit<OpenAICompletionsCompat, "cacheControlFormat">> & {
|
||||||
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
|
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const compat: Required<OpenAICompletionsCompat> = {
|
|||||||
supportsStrictMode: true,
|
supportsStrictMode: true,
|
||||||
cacheControlFormat: "anthropic",
|
cacheControlFormat: "anthropic",
|
||||||
sendSessionAffinityHeaders: false,
|
sendSessionAffinityHeaders: false,
|
||||||
|
supportsLongCacheRetention: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessage {
|
function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessage {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `models.json` provider compatibility to accept `compat.supportsLongCacheRetention`, allowing proxies to opt out of long-retention cache fields when needed while long retention is enabled by default when requested ([#3543](https://github.com/badlogic/pi-mono/issues/3543))
|
||||||
- Fixed `--thinking xhigh` for `openai-codex` `gpt-5.5` so it is no longer downgraded to `high`.
|
- Fixed `--thinking xhigh` for `openai-codex` `gpt-5.5` so it is no longer downgraded to `high`.
|
||||||
- Fixed git package installs with custom `npmCommand` values such as `pnpm` by avoiding npm-specific production flags in that compatibility path ([#3604](https://github.com/badlogic/pi-mono/issues/3604))
|
- Fixed git package installs with custom `npmCommand` values such as `pnpm` by avoiding npm-specific production flags in that compatibility path ([#3604](https://github.com/badlogic/pi-mono/issues/3604))
|
||||||
- Fixed first user messages rendering without spacing after existing notices such as compaction summaries or status messages ([#3613](https://github.com/badlogic/pi-mono/issues/3613))
|
- Fixed first user messages rendering without spacing after existing notices such as compaction summaries or status messages ([#3613](https://github.com/badlogic/pi-mono/issues/3613))
|
||||||
|
|||||||
@@ -284,7 +284,8 @@ By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthro
|
|||||||
"api": "anthropic-messages",
|
"api": "anthropic-messages",
|
||||||
"apiKey": "ANTHROPIC_PROXY_KEY",
|
"apiKey": "ANTHROPIC_PROXY_KEY",
|
||||||
"compat": {
|
"compat": {
|
||||||
"supportsEagerToolInputStreaming": false
|
"supportsEagerToolInputStreaming": false,
|
||||||
|
"supportsLongCacheRetention": true
|
||||||
},
|
},
|
||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
@@ -301,6 +302,7 @@ By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthro
|
|||||||
| Field | Description |
|
| Field | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `supportsEagerToolInputStreaming` | Whether the provider accepts per-tool `eager_input_streaming`. Default: `true`. Set to `false` to omit that field and use the legacy fine-grained tool streaming beta header on tool-enabled requests. |
|
| `supportsEagerToolInputStreaming` | Whether the provider accepts per-tool `eager_input_streaming`. Default: `true`. Set to `false` to omit that field and use the legacy fine-grained tool streaming beta header on tool-enabled requests. |
|
||||||
|
| `supportsLongCacheRetention` | Whether the provider accepts Anthropic long cache retention (`cache_control.ttl: "1h"`) when cache retention is `long`. Default: `true`. |
|
||||||
|
|
||||||
## OpenAI Compatibility
|
## OpenAI Compatibility
|
||||||
|
|
||||||
@@ -339,6 +341,7 @@ For providers with partial OpenAI compatibility, use the `compat` field.
|
|||||||
| `thinkingFormat` | Use `reasoning_effort`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters |
|
| `thinkingFormat` | Use `reasoning_effort`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters |
|
||||||
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. |
|
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. |
|
||||||
| `supportsStrictMode` | Include the `strict` field in tool definitions |
|
| `supportsStrictMode` | Include the `strict` field in tool definitions |
|
||||||
|
| `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: "24h"` for OpenAI prompt caching, or `cache_control.ttl: "1h"` when `cacheControlFormat` is `anthropic`. Default: `true`. |
|
||||||
| `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). |
|
| `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). |
|
||||||
| `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) |
|
| `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) |
|
||||||
|
|
||||||
|
|||||||
@@ -111,14 +111,17 @@ const OpenAICompletionsCompatSchema = Type.Object({
|
|||||||
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
|
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
|
||||||
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
|
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
|
||||||
supportsStrictMode: Type.Optional(Type.Boolean()),
|
supportsStrictMode: Type.Optional(Type.Boolean()),
|
||||||
|
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const OpenAIResponsesCompatSchema = Type.Object({
|
const OpenAIResponsesCompatSchema = Type.Object({
|
||||||
// Reserved for future use
|
sendSessionIdHeader: Type.Optional(Type.Boolean()),
|
||||||
|
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const AnthropicMessagesCompatSchema = Type.Object({
|
const AnthropicMessagesCompatSchema = Type.Object({
|
||||||
supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()),
|
supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()),
|
||||||
|
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ProviderCompatSchema = Type.Union([
|
const ProviderCompatSchema = Type.Union([
|
||||||
|
|||||||
@@ -461,6 +461,35 @@ describe("ModelRegistry", () => {
|
|||||||
expect(compat?.supportsEagerToolInputStreaming).toBe(false);
|
expect(compat?.supportsEagerToolInputStreaming).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("compat schema accepts long cache retention flag", () => {
|
||||||
|
writeRawModelsJson({
|
||||||
|
demo: {
|
||||||
|
baseUrl: "https://example.com",
|
||||||
|
apiKey: "DEMO_KEY",
|
||||||
|
api: "anthropic-messages",
|
||||||
|
compat: {
|
||||||
|
supportsLongCacheRetention: false,
|
||||||
|
},
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "demo-model",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 1000,
|
||||||
|
maxTokens: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
|
const compat = registry.find("demo", "demo-model")?.compat as AnthropicMessagesCompat | undefined;
|
||||||
|
|
||||||
|
expect(registry.getError()).toBeUndefined();
|
||||||
|
expect(compat?.supportsLongCacheRetention).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("model-level baseUrl overrides provider-level baseUrl for custom models", () => {
|
test("model-level baseUrl overrides provider-level baseUrl for custom models", () => {
|
||||||
writeRawModelsJson({
|
writeRawModelsJson({
|
||||||
"opencode-go": {
|
"opencode-go": {
|
||||||
|
|||||||
Reference in New Issue
Block a user