Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -3153,3 +3153,23 @@ Detection has to happen inside the Anthropic SSE loop while the stream is still

### Expected merge conflict zones
- MEDIUM: `api/anthropic-messages.ts` cache-control placement in `buildParams()` and the final checkpoint pass in `convertMessages()`.

## Prompt-cache lifetime semantics (2026-08-31)

### What changed

- `packages/ai/src/utils/prompt-cache-ttl.ts` now resolves the typed `PromptCacheLifetime` union: fixed TTL, provider-managed automatic, explicitly disabled, or unknown. The legacy numeric resolver remains a fixed-lifetime projection.
- Direct DeepSeek detection parses URL authority and accepts only the case-insensitive `api.deepseek.com` hostname, rejecting lookalikes such as `deepseek.com.example.org`; `cacheRetention: "none"` wins before provider detection.
- `packages/ai/src/index.ts` exports the lifetime resolver and type.

### Why

- DeepSeek's automatic cache has no client-visible expiry contract. Treating it as a fixed five-minute cache made downstream scheduling and savings claims false, while substring URL matching accepted unrelated authorities.

### Why an extension could not handle it

- Provider cache semantics and the compatibility URL authority are resolved below all extension hooks in the shared AI provider boundary.

### Expected merge conflict zones

- LOW: `packages/ai/src/utils/prompt-cache-ttl.ts` lifetime switch and root `index.ts` exports.
2 changes: 2 additions & 0 deletions packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,12 @@ export * from "./utils/event-stream.ts";
export * from "./utils/json-parse.ts";
export { extractOpenAiCodexAccountId } from "./utils/openai-codex-auth.ts";
export * from "./utils/overflow.ts";
export type { PromptCacheLifetime } from "./utils/prompt-cache-ttl.ts";
export {
isAnthropicApiBaseUrl,
PROMPT_CACHE_TTL_LONG_SECONDS,
PROMPT_CACHE_TTL_SHORT_SECONDS,
resolvePromptCacheLifetime,
resolvePromptCacheTtlSeconds,
} from "./utils/prompt-cache-ttl.ts";
export * from "./utils/retry.ts";
Expand Down
61 changes: 47 additions & 14 deletions packages/ai/src/utils/prompt-cache-ttl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,48 +337,81 @@ function resolveOpenAIResponsesCacheRetention(cacheRetention?: CacheRetention, e
return "short";
}

export function resolvePromptCacheTtlSeconds(model: Model<Api>, env?: ProviderEnv): number | undefined {
export type PromptCacheLifetime =
| { readonly kind: "fixed"; readonly ttlSeconds: number }
| { readonly kind: "automatic" }
| { readonly kind: "disabled" }
| { readonly kind: "unknown" };

function isDeepSeekOpenAICompletionsModel(model: Model<"openai-completions">): boolean {
if (model.provider === "deepseek") return true;
try {
return new URL(model.baseUrl).hostname.toLowerCase() === "api.deepseek.com";
} catch {
return false;
}
}

/**
* Classifies prompt-cache lifetimes at the provider semantic boundary.
*
* `fixed` is the deterministic client-visible TTL used by cache-aware
* scheduling. `automatic` is provider-managed best-effort caching with no
* client-visible TTL (direct DeepSeek). `disabled` turns caching off, while
* `unknown` has no established cache contract.
*/
export function resolvePromptCacheLifetime(model: Model<Api>, env?: ProviderEnv): PromptCacheLifetime {
switch (model.api) {
case "claude-sdk-oauth":
// The Claude SDK owns prompt caching for this lane and uses Anthropic's default 5m TTL.
return PROMPT_CACHE_TTL_SHORT_SECONDS;
return { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS };
case "anthropic-messages": {
const anthropicModel = model as Model<"anthropic-messages">;
const retention = resolveAnthropicCacheRetention(anthropicModel.cacheRetention, env, "short");
if (retention === "none") return undefined;
if (retention === "none") return { kind: "disabled" };
return retention === "long" &&
isAnthropicApiBaseUrl(anthropicModel.baseUrl) &&
getAnthropicCompat(anthropicModel).supportsLongCacheRetention
? PROMPT_CACHE_TTL_LONG_SECONDS
: PROMPT_CACHE_TTL_SHORT_SECONDS;
? { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_LONG_SECONDS }
: { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS };
}
case "bedrock-converse-stream": {
const bedrockModel = model as Model<"bedrock-converse-stream">;
const retention = resolveBedrockCacheRetention(bedrockModel.cacheRetention, env);
if (retention === "none" || !supportsPromptCaching(bedrockModel, env)) return undefined;
if (retention === "none" || !supportsPromptCaching(bedrockModel, env)) return { kind: "disabled" };
return retention === "long" && supportsOneHourCacheTtl(bedrockModel)
? PROMPT_CACHE_TTL_LONG_SECONDS
: PROMPT_CACHE_TTL_SHORT_SECONDS;
? { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_LONG_SECONDS }
: { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS };
}
case "openai-completions": {
const completionsModel = model as Model<"openai-completions">;
const retention = resolveOpenAICompletionsCacheRetention(completionsModel.cacheRetention, env);
if (retention === "none") return undefined;
if (retention === "none") return { kind: "disabled" };
// DeepSeek's provider-managed cache has no client-visible TTL contract.
if (isDeepSeekOpenAICompletionsModel(completionsModel)) return { kind: "automatic" };
const compat = getOpenAICompletionsCompat(completionsModel);
if (compat.cacheControlFormat === "anthropic") {
return retention === "long" && compat.supportsLongCacheRetention
? PROMPT_CACHE_TTL_LONG_SECONDS
: PROMPT_CACHE_TTL_SHORT_SECONDS;
? { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_LONG_SECONDS }
: { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS };
}
return PROMPT_CACHE_TTL_SHORT_SECONDS;
return { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS };
}
case "openai-responses":
case "openai-codex-responses":
case "azure-openai-responses": {
const retention = resolveOpenAIResponsesCacheRetention(model.cacheRetention, env);
return retention === "none" ? undefined : PROMPT_CACHE_TTL_SHORT_SECONDS;
return retention === "none"
? { kind: "disabled" }
: { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS };
}
default:
return undefined;
return { kind: "unknown" };
}
}

/** Legacy numeric view for fixed-TTL callers. */
export function resolvePromptCacheTtlSeconds(model: Model<Api>, env?: ProviderEnv): number | undefined {
const lifetime = resolvePromptCacheLifetime(model, env);
return lifetime.kind === "fixed" ? lifetime.ttlSeconds : undefined;
}
105 changes: 105 additions & 0 deletions packages/ai/test/prompt-cache-lifetime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import type { Api, Model } from "../src/types.ts";
import {
PROMPT_CACHE_TTL_SHORT_SECONDS,
resolvePromptCacheLifetime,
resolvePromptCacheTtlSeconds,
} from "../src/utils/prompt-cache-ttl.ts";

function createModel<TApi extends Api>(api: TApi, overrides: Partial<Model<TApi>> = {}): Model<TApi> {
return {
id: "test-model",
name: "Test Model",
api,
provider: "test-provider",
baseUrl: "https://example.com/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
...overrides,
} as Model<TApi>;
}

describe("prompt-cache lifetime classification", () => {
it("classifies direct DeepSeek as automatic", () => {
const model = createModel("openai-completions", {
provider: "deepseek",
baseUrl: "https://api.deepseek.com/v1",
});

expect(resolvePromptCacheLifetime(model)).toEqual({ kind: "automatic" });
expect(resolvePromptCacheTtlSeconds(model)).toBeUndefined();
});

it("detects mixed-case canonical DeepSeek hosts without matching spoofed authorities", () => {
const uppercase = createModel("openai-completions", {
provider: "custom-proxy",
baseUrl: "https://API.DEEPSEEK.COM/v1",
});
const spoofed = createModel("openai-completions", {
provider: "custom-proxy",
baseUrl: "https://deepseek.com.example.org/v1",
});
const malformed = createModel("openai-completions", {
provider: "custom-proxy",
baseUrl: "not a URL",
});

expect(resolvePromptCacheLifetime(uppercase)).toEqual({ kind: "automatic" });
for (const model of [spoofed, malformed]) {
expect(resolvePromptCacheLifetime(model)).toEqual({
kind: "fixed",
ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS,
});
}
});

it("lets explicit disabled retention override DeepSeek automatic caching", () => {
const model = createModel("openai-completions", {
provider: "deepseek",
baseUrl: "https://api.deepseek.com/v1",
cacheRetention: "none",
});

expect(resolvePromptCacheLifetime(model)).toEqual({ kind: "disabled" });
expect(resolvePromptCacheTtlSeconds(model)).toBeUndefined();
});

it("keeps long DeepSeek retention automatic without fabricating a TTL", () => {
const model = createModel("openai-completions", {
provider: "deepseek",
baseUrl: "https://api.deepseek.com/v1",
cacheRetention: "long",
});

expect(resolvePromptCacheLifetime(model)).toEqual({ kind: "automatic" });
expect(resolvePromptCacheTtlSeconds(model)).toBeUndefined();
});

it("preserves conservative behavior for unrelated OpenAI-compatible providers", () => {
const model = createModel("openai-completions", {
provider: "custom-proxy",
baseUrl: "https://proxy.example.org/v1",
});

expect(resolvePromptCacheLifetime(model)).toEqual({
kind: "fixed",
ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS,
});
expect(resolvePromptCacheTtlSeconds(model)).toBe(PROMPT_CACHE_TTL_SHORT_SECONDS);
});

it("preserves fixed and unknown lanes", () => {
expect(
resolvePromptCacheLifetime(
createModel("anthropic-messages", {
provider: "anthropic",
baseUrl: "https://api.anthropic.com/v1",
}),
),
).toEqual({ kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS });
expect(resolvePromptCacheLifetime(createModel("google-generative-ai"))).toEqual({ kind: "unknown" });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ function whyLine(data: GoalCacheWarmupEntryData): string {
switch (data.phase) {
case "scheduled": {
const expected = `Continuation expected ${formatExpectedWake(data.dueAtMs, data.delayMs)}`;
if (data.cache?.cacheLifetime === "automatic") {
return `${expected} - provider caching is automatic; the timed wake only keeps the goal alive.`;
}
if (data.cache?.ttlSeconds === undefined) {
return `${expected} - the monitor wakes the goal the moment decisive output lands.`;
}
Expand Down Expand Up @@ -69,6 +72,7 @@ function warmLine(data: GoalCacheWarmupEntryData): string | undefined {
const cache = data.cache;
if (cache === undefined || cache.cachedTokens <= 0) return undefined;
const tokens = `~${formatWarmTokenCount(cache.cachedTokens)} tokens`;
if (cache.cacheLifetime === "automatic") return `${tokens} cached after the prior turn`;
const ttlMayHaveElapsed =
cache.ttlSeconds !== undefined && (data.waitedMs ?? data.delayMs) >= cache.ttlSeconds * 1000;
if (ttlMayHaveElapsed) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Api, Model, ProviderEnv } from "@earendil-works/pi-ai";
import { resolvePromptCacheTtlSeconds } from "@earendil-works/pi-ai";
import type { Api, Model, PromptCacheLifetime, ProviderEnv } from "@earendil-works/pi-ai";
import { resolvePromptCacheLifetime } from "@earendil-works/pi-ai";
import type { TokenUsageSnapshot } from "./types.ts";

/** Custom session-entry type carrying the cache-warm continuation story. */
Expand All @@ -9,10 +9,29 @@ export const GOAL_MONITOR_CONTINUATION_FALLBACK_DELAY_MS = 240_000;
const GOAL_MONITOR_CONTINUATION_MIN_DELAY_MS = 1_000;
const GOAL_MONITOR_CONTINUATION_HARD_CEILING_MS = 3_600_000;

/** Liveness backstop for provider-managed caches that expose no fixed TTL. */
export const GOAL_MONITOR_LIVENESS_BACKSTOP_DEFAULT_SECONDS = 3570;

export function resolveGoalMonitorLivenessBackstopMs(goalBackstopMaxSeconds?: number): number {
const seconds =
typeof goalBackstopMaxSeconds === "number" &&
Number.isFinite(goalBackstopMaxSeconds) &&
goalBackstopMaxSeconds > 0
? goalBackstopMaxSeconds
: GOAL_MONITOR_LIVENESS_BACKSTOP_DEFAULT_SECONDS;
return Math.max(
GOAL_MONITOR_CONTINUATION_MIN_DELAY_MS,
Math.min(seconds * 1000, GOAL_MONITOR_CONTINUATION_HARD_CEILING_MS),
);
}

export function resolveGoalMonitorContinuationDelayMs(
cacheSafeWaitSeconds: number | undefined,
goalBackstopMaxSeconds?: number,
lifetime?: PromptCacheLifetime,
): number {
if (lifetime?.kind === "automatic") return resolveGoalMonitorLivenessBackstopMs(goalBackstopMaxSeconds);
if (lifetime?.kind === "disabled") return GOAL_MONITOR_CONTINUATION_FALLBACK_DELAY_MS;
if (
typeof cacheSafeWaitSeconds !== "number" ||
!Number.isFinite(cacheSafeWaitSeconds) ||
Expand All @@ -33,6 +52,8 @@ export function resolveGoalMonitorContinuationDelayMs(
export interface GoalCacheWarmMetrics {
/** Prompt-cache TTL of the active model in seconds, when known. */
readonly ttlSeconds?: number;
/** Present only for provider-managed caches without a fixed TTL. */
readonly cacheLifetime?: "automatic";
/** Tokens sitting warm in the provider prompt cache after the last turn. */
readonly cachedTokens: number;
/** Estimated USD saved by re-reading those tokens from cache instead of paying a cold input read. */
Expand Down Expand Up @@ -97,15 +118,18 @@ export function estimateCacheWarmMetrics(
lastTurnUsage: Pick<TokenUsageSnapshot, "cacheRead" | "cacheWrite"> | undefined,
): GoalCacheWarmMetrics | undefined {
const cachedTokens = clampTokens(lastTurnUsage?.cacheRead) + clampTokens(lastTurnUsage?.cacheWrite);
const ttlSeconds = model === undefined ? undefined : resolvePromptCacheTtlSeconds(model, toProviderEnv(env));
if (ttlSeconds === undefined && cachedTokens === 0) return undefined;
if (model === undefined) return cachedTokens === 0 ? undefined : { cachedTokens };
const lifetime = resolvePromptCacheLifetime(model, toProviderEnv(env));
if (lifetime.kind === "disabled") return undefined;
if (lifetime.kind === "automatic") return { cachedTokens, cacheLifetime: "automatic" };
if (lifetime.kind === "unknown" && cachedTokens === 0) return undefined;
const estimatedSavedUsd =
model !== undefined && cachedTokens > 0
cachedTokens > 0
? (Math.max(0, model.cost.input - model.cost.cacheRead) * cachedTokens) / TOKENS_PER_PRICE_UNIT
: undefined;
return {
cachedTokens,
...(ttlSeconds !== undefined ? { ttlSeconds } : {}),
...(lifetime.kind === "fixed" ? { ttlSeconds: lifetime.ttlSeconds } : {}),
...(estimatedSavedUsd !== undefined ? { estimatedSavedUsd } : {}),
};
}
Expand Down Expand Up @@ -192,7 +216,7 @@ function clampTokens(value: number | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
}

function toProviderEnv(env: NodeJS.ProcessEnv): ProviderEnv {
export function toProviderEnv(env: NodeJS.ProcessEnv): ProviderEnv {
const resolved: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) resolved[key] = value;
Expand Down
20 changes: 20 additions & 0 deletions packages/coding-agent/src/core/extensions/builtin/goal/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -1336,3 +1336,23 @@ stale-ctx error (`stale-context.ts`) inside `tick()` and retire (clear the
interval, drop the ctx); `GoalWaitTicker.stop()` tolerates a stale ctx on its
final clear render. A later `sync()` with a live ctx re-arms them. Covered by
`test/suite/goal-ticker-stale-context.test.ts`.

## Prompt-cache lifetime aware goal continuations (2026-08-31)

### What changed

- `cache-warm.ts`, `monitor-continuation.ts`, and `cache-warm-renderer.ts` consume `PromptCacheLifetime` rather than inferring cache behavior from an optional number.
- Automatic provider caching uses the explicit liveness backstop, reports no fixed TTL or savings, and renders copy that does not claim a timed cache preservation.
- Explicitly disabled caching still permits the monitor continuation liveness timer, but emits no cache-warm event, durable entry, renderer copy, or metric. Fixed and unknown lanes retain their existing behavior.

### Why

- A provider-managed cache cannot justify a client TTL wake or a cold-read saving estimate. A user explicitly disabling cache retention must also suppress cache-preservation telemetry and transcript claims.

### Why an extension could not handle it

- The Goal extension owns the monitor timer, durable entry/event contract, and cache-warm renderer; no outside extension can remove those entries after scheduling.

### Expected merge conflict zones

- LOW: `cache-warm.ts` lifetime branching, `monitor-continuation.ts` scheduling branch, and `cache-warm-renderer.ts` automatic-copy branch.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import { resolvePromptCacheLifetime } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "../../types.ts";
import {
createGoalCacheWarmScheduleData,
Expand All @@ -9,6 +10,7 @@ import {
type GoalCacheWarmupEntryData,
type LiveGoalCacheWarmupEntryData,
resolveGoalMonitorContinuationDelayMs,
toProviderEnv,
} from "./cache-warm.ts";
import { subscribeGoalChannelState } from "./channel-state-subscriptions.ts";

Expand Down Expand Up @@ -322,15 +324,19 @@ export class MonitorAwareGoalContinuation {

#schedule(goal: Goal, kind: DelayedContinuationKind): void {
if (this.#scheduledContinuationKind !== undefined) return;
const ctx = this.#ctx;
const lifetime =
ctx?.model === undefined ? undefined : resolvePromptCacheLifetime(ctx.model, toProviderEnv(process.env));
const delayMs =
kind === "monitor"
? resolveGoalMonitorContinuationDelayMs(
this.#ctx?.getPromptCacheSafeWaitSeconds?.(),
this.#ctx?.getPromptCacheGoalBackstopMaxSeconds?.(),
ctx?.getPromptCacheSafeWaitSeconds?.(),
ctx?.getPromptCacheGoalBackstopMaxSeconds?.(),
lifetime,
)
: GOAL_USER_GRACE_DELAY_MS;
this.#scheduledDelayMs = delayMs;
if (kind === "monitor") {
if (kind === "monitor" && lifetime?.kind !== "disabled") {
this.#cacheWarmIteration += 1;
this.#scheduledCacheWarmIteration = this.#cacheWarmIteration;
const iteration = this.#scheduledCacheWarmIteration;
Expand Down
Loading