diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index bd1e2af0a..8dd6060a1 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -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. diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 99b541b5f..8267cff6c 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -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"; diff --git a/packages/ai/src/utils/prompt-cache-ttl.ts b/packages/ai/src/utils/prompt-cache-ttl.ts index 0f0662fe7..d695bbac3 100644 --- a/packages/ai/src/utils/prompt-cache-ttl.ts +++ b/packages/ai/src/utils/prompt-cache-ttl.ts @@ -337,48 +337,81 @@ function resolveOpenAIResponsesCacheRetention(cacheRetention?: CacheRetention, e return "short"; } -export function resolvePromptCacheTtlSeconds(model: Model, 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, 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, env?: ProviderEnv): number | undefined { + const lifetime = resolvePromptCacheLifetime(model, env); + return lifetime.kind === "fixed" ? lifetime.ttlSeconds : undefined; +} diff --git a/packages/ai/test/prompt-cache-lifetime.test.ts b/packages/ai/test/prompt-cache-lifetime.test.ts new file mode 100644 index 000000000..baa42e3d1 --- /dev/null +++ b/packages/ai/test/prompt-cache-lifetime.test.ts @@ -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(api: TApi, overrides: Partial> = {}): Model { + 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; +} + +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" }); + }); +}); diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm-renderer.ts b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm-renderer.ts index 09bc722a8..53ac18280 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm-renderer.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm-renderer.ts @@ -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.`; } @@ -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) { diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts index 526b0f3e3..528c53079 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts @@ -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. */ @@ -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) || @@ -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. */ @@ -97,15 +118,18 @@ export function estimateCacheWarmMetrics( lastTurnUsage: Pick | 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 } : {}), }; } @@ -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 = {}; for (const [key, value] of Object.entries(env)) { if (value !== undefined) resolved[key] = value; diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/changes.md b/packages/coding-agent/src/core/extensions/builtin/goal/changes.md index 763151baa..ad426c124 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/goal/changes.md @@ -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. diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts b/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts index 7d23c1243..11e574364 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts @@ -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, @@ -9,6 +10,7 @@ import { type GoalCacheWarmupEntryData, type LiveGoalCacheWarmupEntryData, resolveGoalMonitorContinuationDelayMs, + toProviderEnv, } from "./cache-warm.ts"; import { subscribeGoalChannelState } from "./channel-state-subscriptions.ts"; @@ -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; diff --git a/packages/coding-agent/test/suite/goal-cache-warm-metrics.test.ts b/packages/coding-agent/test/suite/goal-cache-warm-metrics.test.ts index 281a56f88..fd540ab10 100644 --- a/packages/coding-agent/test/suite/goal-cache-warm-metrics.test.ts +++ b/packages/coding-agent/test/suite/goal-cache-warm-metrics.test.ts @@ -1,4 +1,4 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, Model, PromptCacheLifetime } from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; import { estimateCacheWarmMetrics, @@ -20,6 +20,18 @@ function anthropicModel(costOverrides: Partial["cost"]> = {}): Model< } as Model; } +function deepseekModel(cacheRetention?: "none"): Model { + return { + ...anthropicModel(), + id: "deepseek-v4", + name: "DeepSeek V4", + api: "openai-completions", + provider: "deepseek", + baseUrl: "https://api.deepseek.com/v1", + ...(cacheRetention === undefined ? {} : { cacheRetention }), + } as Model; +} + describe("goal monitor continuation delay", () => { it.each([ [undefined, undefined, 240_000], @@ -34,6 +46,19 @@ describe("goal monitor continuation delay", () => { ] as const)("resolves cache-safe wait %s with ceiling %s to %sms", (safeWait, ceiling, expected) => { expect(resolveGoalMonitorContinuationDelayMs(safeWait, ceiling)).toBe(expected); }); + + it.each([ + [undefined, 3_570_000], + [900, 900_000], + ] as const)("uses a liveness backstop rather than a cache TTL for automatic lanes", (ceiling, expected) => { + const lifetime: PromptCacheLifetime = { kind: "automatic" }; + expect(resolveGoalMonitorContinuationDelayMs(270, ceiling, lifetime)).toBe(expected); + }); + + it("ignores a stale cache-safe wait when caching is disabled", () => { + const lifetime: PromptCacheLifetime = { kind: "disabled" }; + expect(resolveGoalMonitorContinuationDelayMs(270, undefined, lifetime)).toBe(240_000); + }); }); describe("goal cache-warm metrics", () => { @@ -63,6 +88,32 @@ describe("goal cache-warm metrics", () => { expect(metrics?.estimatedSavedUsd).toBeUndefined(); }); + it("keeps automatic cache observations free of TTL and savings claims", () => { + expect(estimateCacheWarmMetrics(deepseekModel(), {}, { cacheRead: 100_000, cacheWrite: 20_000 })).toEqual({ + cachedTokens: 120_000, + cacheLifetime: "automatic", + }); + }); + + it("does not report cache metrics when retention is explicitly disabled", () => { + expect( + estimateCacheWarmMetrics(deepseekModel("none"), {}, { cacheRead: 100_000, cacheWrite: 20_000 }), + ).toBeUndefined(); + }); + + it("preserves legacy metrics for unknown cache lanes", () => { + const unknown = { + ...anthropicModel(), + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com", + } as Model; + expect(estimateCacheWarmMetrics(unknown, {}, { cacheRead: 100_000, cacheWrite: 20_000 })).toEqual({ + cachedTokens: 120_000, + estimatedSavedUsd: 0.324, + }); + }); + it("clamps malformed usage and negative cache margins", () => { expect(estimateCacheWarmMetrics(undefined, {}, { cacheRead: -50, cacheWrite: Number.NaN })).toBeUndefined(); const inverted = estimateCacheWarmMetrics( diff --git a/packages/coding-agent/test/suite/goal-cache-warm-renderer.test.ts b/packages/coding-agent/test/suite/goal-cache-warm-renderer.test.ts index 8d51adf72..84d14120e 100644 --- a/packages/coding-agent/test/suite/goal-cache-warm-renderer.test.ts +++ b/packages/coding-agent/test/suite/goal-cache-warm-renderer.test.ts @@ -103,6 +103,21 @@ describe("goal cache-warm entry renderer", () => { expect(text).toContain("ready 2026-07-29 00:04 UTC (4m 30s)"); }); + it("describes automatic caching without inventing a TTL or savings", () => { + const text = renderToText({ + phase: "scheduled", + goalId: "goal-automatic-cache", + delayMs: 3_570_000, + activeMonitorCount: 1, + cache: { cacheLifetime: "automatic", cachedTokens: 120_000 }, + }); + expect(text).toContain("provider caching is automatic"); + expect(text).toContain("~120K tokens cached after the prior turn"); + expect(text).not.toContain("prompt-cache TTL"); + expect(text).not.toContain("kept warm"); + expect(text).not.toContain("saved"); + }); + it("does not claim warmth or savings when the cache TTL may have elapsed", () => { const scheduled = renderToText({ phase: "scheduled", diff --git a/packages/coding-agent/test/suite/goal-cache-warmup.test.ts b/packages/coding-agent/test/suite/goal-cache-warmup.test.ts index 83f9d11d5..c5cf3d332 100644 --- a/packages/coding-agent/test/suite/goal-cache-warmup.test.ts +++ b/packages/coding-agent/test/suite/goal-cache-warmup.test.ts @@ -30,6 +30,21 @@ function cacheModel(): Model { } as Model; } +function automaticCacheModel(cacheRetention?: "none"): Model { + return { + ...cacheModel(), + id: "deepseek-cache", + api: "openai-completions", + provider: "deepseek", + baseUrl: "https://api.deepseek.com/v1", + ...(cacheRetention === undefined ? {} : { cacheRetention }), + } as Model; +} + +function disabledCacheModel(): Model { + return automaticCacheModel("none"); +} + async function setupWarmHarness( threadId: string, ): Promise<{ harness: GoalHarness; notices: string[]; ctx: Awaited> }> { @@ -103,6 +118,70 @@ describe("goal cache-warm continuation story", () => { ); }); + it("uses the automatic-cache liveness backstop without TTL or savings metrics", async () => { + vi.useFakeTimers(); + const notices: string[] = []; + const harness = createGoalHarness(); + const ctx = await makeGoalContext(notices, "thread-cache-automatic", { + pendingMessages: false, + model: automaticCacheModel(), + cacheSafeWaitSeconds: 270, + }); + await runGoalHandlers(harness.handlers, "session_start", { type: "session_start", reason: "reload" }, ctx); + await harness.tools + .get("create_goal") + ?.execute("create", { objective: "Keep watching" }, undefined, undefined, ctx); + harness.events.emit("terminal_monitor_state", { activeCount: 1 }); + await harness.events.flush(); + await runGoalHandlers(harness.handlers, "agent_start", { type: "agent_start" }, ctx); + await runGoalHandlers( + harness.handlers, + "agent_end", + { type: "agent_end", messages: [cleanAssistantStop({ cacheRead: 100_000, cacheWrite: 20_000 })] }, + ctx, + ); + + expect(channelEvents(harness, "goal_continuation_scheduled")).toEqual([ + expect.objectContaining({ + delayMs: 3_570_000, + cache: { cachedTokens: 120_000, cacheLifetime: "automatic" }, + }), + ]); + }); + + it("keeps the goal alive without cache-preservation events when caching is disabled", async () => { + vi.useFakeTimers(); + const notices: string[] = []; + const harness = createGoalHarness(); + const ctx = await makeGoalContext(notices, "thread-cache-disabled", { + pendingMessages: false, + model: disabledCacheModel(), + cacheSafeWaitSeconds: 270, + }); + await runGoalHandlers(harness.handlers, "session_start", { type: "session_start", reason: "reload" }, ctx); + await harness.tools + .get("create_goal") + ?.execute("create", { objective: "Keep watching" }, undefined, undefined, ctx); + harness.events.emit("terminal_monitor_state", { activeCount: 1 }); + await harness.events.flush(); + await runGoalHandlers(harness.handlers, "agent_start", { type: "agent_start" }, ctx); + await runGoalHandlers( + harness.handlers, + "agent_end", + { type: "agent_end", messages: [cleanAssistantStop({ cacheRead: 100_000, cacheWrite: 20_000 })] }, + ctx, + ); + + expect(channelEvents(harness, "goal_continuation_scheduled")).toEqual([]); + expect(warmupEntryData(harness)).toEqual([]); + + const delivered = waitForSentCount(harness, 1); + await vi.advanceTimersByTimeAsync(240_000); + await delivered; + expect(channelEvents(harness, "goal_continuation_resumed")).toEqual([]); + expect(warmupEntryData(harness)).toEqual([]); + }); + it("celebrates the cache-warm wake when the deferred continuation fires", async () => { vi.useFakeTimers(); const { harness, notices } = await setupWarmHarness("thread-cache-warm-resumed"); diff --git a/packages/coding-agent/test/suite/prompt-cache-budget.test.ts b/packages/coding-agent/test/suite/prompt-cache-budget.test.ts index 055c2d549..18c632228 100644 --- a/packages/coding-agent/test/suite/prompt-cache-budget.test.ts +++ b/packages/coding-agent/test/suite/prompt-cache-budget.test.ts @@ -21,6 +21,22 @@ function anthropicModel(overrides: Partial> = {}): M } as Model<"anthropic-messages">; } +function deepseekModel(cacheRetention?: "none"): Model<"openai-completions"> { + return { + id: "deepseek-v4", + name: "DeepSeek V4", + api: "openai-completions", + provider: "deepseek", + baseUrl: "https://api.deepseek.com/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + ...(cacheRetention === undefined ? {} : { cacheRetention }), + }; +} + function googleModel(): Model<"google-generative-ai"> { return { id: "gemini-3-pro", @@ -59,6 +75,11 @@ describe("resolvePromptCacheSafeWaitSeconds", () => { expect(resolvePromptCacheSafeWaitSeconds(googleModel() as Model, undefined, {})).toBeUndefined(); }); + it("does not fabricate a safe wait for automatic or disabled DeepSeek caching", () => { + expect(resolvePromptCacheSafeWaitSeconds(deepseekModel(), undefined, {})).toBeUndefined(); + expect(resolvePromptCacheSafeWaitSeconds(deepseekModel("none"), undefined, {})).toBeUndefined(); + }); + it("returns undefined when no model is active", () => { expect(resolvePromptCacheSafeWaitSeconds(undefined, undefined, {})).toBeUndefined(); }); diff --git a/packages/coding-agent/test/suite/regressions/issue-831-unknown-cache-metrics.test.ts b/packages/coding-agent/test/suite/regressions/issue-831-unknown-cache-metrics.test.ts new file mode 100644 index 000000000..877c5e594 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/issue-831-unknown-cache-metrics.test.ts @@ -0,0 +1,24 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { estimateCacheWarmMetrics } from "../../../src/core/extensions/builtin/goal/cache-warm.ts"; + +function unknownModel(): Model { + return { + id: "unknown-cache-test", + name: "Unknown Cache Test", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com", + reasoning: false, + input: ["text"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200_000, + maxTokens: 8192, + } as Model; +} + +describe("issue #831 unknown cache metrics compatibility", () => { + it("omits cache metrics when the unknown lane has no cached tokens", () => { + expect(estimateCacheWarmMetrics(unknownModel(), {}, { cacheRead: 0, cacheWrite: 0 })).toBeUndefined(); + }); +});