From fcc735033fa626777ef028590dcc393dac2efe97 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Thu, 10 Sep 2026 19:49:26 -0500 Subject: [PATCH] feat: add passive prompt-cache controls --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 23 + docs/cli-setup-configuration.md | 123 +++++ docs/features.md | 1 + .../packages/opencode/src/cli/cmd/cache.ts | 129 ++++++ .../packages/opencode/src/config/cache.ts | 232 ++++++++++ .../packages/opencode/src/config/config.ts | 4 + src/mendcode/packages/opencode/src/index.ts | 1 + .../packages/opencode/src/plugin/codex.ts | 46 +- .../opencode/src/provider/cache-policy.ts | 431 ++++++++++++++++++ .../opencode/src/provider/transform.ts | 142 +++--- .../opencode/src/session/cache-lineage.ts | 46 ++ .../packages/opencode/src/session/llm.ts | 35 +- .../opencode/test/config/cache.test.ts | 96 ++++ .../opencode/test/plugin/codex.test.ts | 82 ++++ .../test/provider/cache-policy.test.ts | 145 ++++++ .../opencode/test/provider/transform.test.ts | 26 ++ .../test/session/cache-lineage.test.ts | 75 +++ 18 files changed, 1572 insertions(+), 67 deletions(-) create mode 100644 src/mendcode/packages/opencode/src/cli/cmd/cache.ts create mode 100644 src/mendcode/packages/opencode/src/config/cache.ts create mode 100644 src/mendcode/packages/opencode/src/provider/cache-policy.ts create mode 100644 src/mendcode/packages/opencode/src/session/cache-lineage.ts create mode 100644 src/mendcode/packages/opencode/test/config/cache.test.ts create mode 100644 src/mendcode/packages/opencode/test/provider/cache-policy.test.ts create mode 100644 src/mendcode/packages/opencode/test/session/cache-lineage.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c64edf22..883e6b33 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -159,7 +159,7 @@ jobs: bun run --cwd packages/opencode typecheck cd packages/opencode for test_file in test/installation/*.test.ts test/cli/upgrade-channel.test.ts test/cli/tui/shared-server.test.ts test/cli/tui/thread.test.ts; do - bash -c 'export MENDCODE_DB="${TMPDIR:-/tmp}/opencode-test-data-$$/share/release.db"; exec bun test --timeout 30000 "$@"' bash "$test_file" + bash -c 'export MENDCODE_DB=":memory:"; exec bun test --timeout 30000 "$@"' bash "$test_file" done - name: Upload release artifacts to workflow diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b683db5..277e7289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.1.44-beta.7 - 2026-09-10 + +### Added + +- Add passive prompt-cache controls by project, session, provider, and exact + model through `mendcode cache status|enable|disable` and the `cache` config + block, without enabling keepalive or remote-cache deletion. +- Add conservative smart-mode adapters, cache lineage fingerprints, and + provider cache observations that keep unsupported bindings unknown. + +### Fixed + +- Keep ChatGPT OAuth Responses Lite transport affinity separate from managed + cache keys, rotate affinity when instructions change or disappear, and honor + explicit cache disablement without forwarding MendCode's internal marker. +- Remove generated cache keys and provider annotations when the effective + policy is `off`. + +### Tests + +- Add configuration, adapter, lineage, provider-transform, and ChatGPT OAuth + regressions for cache scope resolution, safe fallback, and disablement. + ## 0.1.44-beta.6 - 2026-09-09 ### Fixed diff --git a/docs/cli-setup-configuration.md b/docs/cli-setup-configuration.md index c362ac2a..578e5c0f 100644 --- a/docs/cli-setup-configuration.md +++ b/docs/cli-setup-configuration.md @@ -159,6 +159,129 @@ Common MendCode config paths: - `.mendcode/tsm/state.json`: optional TSM state. - `.mendcode/worktree/state.json`: managed/adopted worktree registry. +## Prompt Cache Controls + +MendCode exposes passive prompt-cache controls through the `cache` block in the +project or global MendCode config. The controls affect cache keys and provider +cache annotations that MendCode sends with a request; they never delete a +provider's remote cache and they never schedule refresh or keepalive requests. + +The public CLI is: + +```bash +mendcode cache status [--format text|json|--json] +mendcode cache enable [--global] [--provider ] [--model ] [--session ] [--project-path ] +mendcode cache disable [--global] [--provider ] [--model ] [--session ] [--project-path ] +``` + +`on` is an alias for `enable` and `off` is an alias for `disable`. Without +`--global`, mutation commands update the active project's config. With +`--global`, they update the global config. `--project-path` is only valid with +`--global` and writes an exact normalized project-path override. A model target +requires `--provider`; provider, model, session, and project targets cannot be +combined. + +### Configuration + +The `cache` block is optional. If it is absent, MendCode preserves existing +provider behavior (`legacy` effective mode). + +```jsonc +{ + "cache": { + "mode": "smart", + "projects": { + "/Users/me/src/example": { "mode": "smart" } + }, + "sessions": { + "mode": "selected", + "include": ["ses_allowed"], + "exclude": ["ses_blocked"] + }, + "providers": { + "openai": { + "mode": "smart", + "models": ["openai/gpt-6-astra"], + "exclude_models": ["openai/legacy-model"] + }, + "openrouter": { + "models": ["openai/gpt-5.6-mini"] + } + } + } +} +``` + +Supported values and scopes: + +- `mode`: `off` suppresses MendCode cache controls; `smart` enables only the + verified passive-key paths described below. Omit it to keep legacy behavior. +- `projects`: exact normalized project paths mapped to `{ "mode": "off" }` or + `{ "mode": "smart" }`. Use absolute paths for portable global config. +- `sessions.mode`: `all`, `selected`, or `none`. `include` selects session IDs + and `exclude` blocks session IDs. An excluded session wins over inclusion. +- `providers`: provider IDs mapped to an optional `mode`, `models` allowlist, + and `exclude_models` list. Model matching accepts the configured runtime ID, + API model ID, and their final path component. + +Resolution is deterministic. Disabling rules win in this order: global +`mode: off`, project `off`, session `none`/exclusion/not-selected, provider +`off`, then model exclusion or allowlist rejection. Enabling then resolves from +the global mode, project mode, provider selection, or explicitly included +session; otherwise the effective mode is `legacy`. + +### Smart-mode boundaries + +`smart` is deliberately conservative. A managed passive cache key is enabled +only for a binding that has all of the following verified locally: + +- API-key authentication, not ChatGPT subscription OAuth; +- a non-Responses-Lite transport; +- OpenAI's `@ai-sdk/openai` SDK at `api.openai.com`, or the OpenRouter SDK/ + compatible route at `openrouter.ai`; +- the exact provider, model, endpoint, SDK, authentication, and transport match. + +Other providers, custom endpoints, OAuth routes, unknown models, and incomplete +bindings do not receive a newly inferred managed key. Existing provider-specific +legacy annotations remain governed by their existing transform path unless the +policy is explicitly `off`; MendCode does not infer TTL, cache warmth, hit rate, +price savings, or provider support from a model name alone. + +`off` removes recognized cache fields from request options and message/provider +annotations, including `promptCacheKey`, `prompt_cache_key`, `cacheControl`, +`cachePoint`, `cache_control`, `copilot_cache_control`, and gateway +`caching: "auto"`. For ChatGPT OAuth, the internal disable marker is consumed +before the request is forwarded upstream. This is local request shaping, not +remote-cache delete access. + +### Scope examples + +```bash +# Inspect the effective policy for the current project. +mendcode cache status --format json + +# Enable conservative caching for the current project's OpenAI provider. +mendcode cache enable --provider openai + +# Allow one exact model and disable another model on the same provider. +mendcode cache enable --provider openai --model openai/gpt-6-astra +mendcode cache disable --provider openai --model openai/legacy-model + +# Limit caching to one session, or disable it for a session. +mendcode cache enable --session ses_allowed +mendcode cache disable --session ses_blocked + +# Manage an exact project override from the global config. +mendcode cache enable --global --project-path /Users/me/src/example +mendcode cache disable --global --project-path /Users/me/src/example +``` + +`cache status` reports the effective mode, the current project, the configured +block, and `activeKeeper: false`. Cache Keeper, scheduled refresh, automatic +warm-up, and provider canaries are not part of this control surface. Missing +provider cache metrics remain unknown rather than being converted to a zero or +used to claim a cache hit. + ## Prompt Draft Undo and Redo The TUI keeps a temporary in-memory edit history for the active prompt: diff --git a/docs/features.md b/docs/features.md index 88d3e822..9e5fda3a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -20,6 +20,7 @@ The pitch is not “another chat box.” The pitch is a configurable coding term | Loop Workflows | Durable, monitorable loop sessions with `/loop` creation, `/loops` supervision, Agent View roots, contract-aware report-only mode, and a per-project OS background service. | [Loop Workflows](loop-workflows.md) | | Automation runtime | Let another local agent create, continue, inspect, wait for, stream, and cancel real MendCode sessions through the same runtime, with versioned JSON output and shared model selection. | [Automation runtime](automation-runtime.md) | | Usage Insights | Local activity dashboard for tokens, sessions, AI time, words, tools, agents, models, changed files, daily activity, selected-day detail, cache mix, and optional weather. | [Usage Insights](usage-insights.md) | +| Passive prompt-cache controls | Configure legacy/off/smart cache behavior by project, session, provider, and exact model without enabling keepalive or remote-cache deletion. | [CLI, setup, and configuration](cli-setup-configuration.md#prompt-cache-controls) | | Approval-gated memory | Memory can retrieve context without silently turning every session into permanent state. Generated memories become reviewable proposals first. | [CLI, setup, and configuration](cli-setup-configuration.md#permissions-and-memory) | | Memory Center, graph, and Dream | Route-level memory workspace with saved/pending memories, categories, policy controls, Dream logs, and constrained memory side chat. | [Memory Center](memory-center.md) | | Smart permissions | Choose `approval`, `smart`, or `full_access`. Smart mode auto-approves bounded read-only shell work and can route risky decisions through a configured `permissionReviewer` role. | [CLI, setup, and configuration](cli-setup-configuration.md#permissions-and-memory) | diff --git a/src/mendcode/packages/opencode/src/cli/cmd/cache.ts b/src/mendcode/packages/opencode/src/cli/cmd/cache.ts new file mode 100644 index 00000000..3534d877 --- /dev/null +++ b/src/mendcode/packages/opencode/src/cli/cmd/cache.ts @@ -0,0 +1,129 @@ +import type { Argv } from "yargs" +import { Effect } from "effect" +import { Config } from "@/config/config" +import { ConfigCache, type CacheMutation } from "@/config/cache" +import { Instance } from "@/project/instance" +import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" +import { UI } from "../ui" + +type CacheArgs = { + global?: boolean + provider?: string + model?: string + session?: string + projectPath?: string +} + +function cacheOptions(yargs: Argv) { + return yargs + .option("global", { + type: "boolean", + default: false, + describe: "write the global config instead of the current project config", + }) + .option("provider", { + type: "string", + describe: "provider ID to target", + }) + .option("model", { + type: "string", + describe: "exact model ID to target; requires --provider", + }) + .option("session", { + type: "string", + describe: "session ID to include or exclude", + }) + .option("project-path", { + type: "string", + describe: "absolute or relative project path to target in global config", + }) +} + +function mutationFromArgs(args: CacheArgs, action: CacheMutation["action"]): CacheMutation { + if (args.projectPath && !args.global) throw new Error("--project-path requires --global") + return { + action, + providerID: args.provider, + modelID: args.model, + sessionID: args.session, + projectPath: args.projectPath, + } +} + +export const CacheStatusCommand = effectCmd({ + command: "status", + describe: "show prompt-cache policy and configured scopes", + builder: (yargs) => + yargs + .option("format", { + type: "string", + choices: ["text", "json"], + default: "text", + describe: "output format", + }) + .option("json", { + type: "boolean", + default: false, + describe: "output JSON", + }), + handler: Effect.fn("Cli.cache.status")(function* (args: { format?: string; json?: boolean }) { + const config = yield* Config.Service + const current = yield* config.get() + const status = { + project: Instance.directory, + mode: ConfigCache.selectCacheConfig({ + config: current.cache, + projectScope: Instance.directory, + }).mode, + configured: current.cache ?? null, + activeKeeper: false, + note: "MendCode only uses passive provider cache controls; no keepalive requests are scheduled.", + } + if (args.json || args.format === "json") { + UI.println(JSON.stringify(status, null, 2)) + return + } + UI.println(`Prompt cache: ${status.mode}`) + UI.println(`Project: ${status.project}`) + UI.println("Active keeper: disabled") + if (!current.cache) UI.println("Configuration: legacy provider behavior (no cache override)") + else UI.println(`Configuration: ${JSON.stringify(current.cache)}`) + }), +}) + +const CacheMutationCommand = (action: CacheMutation["action"]) => + effectCmd({ + command: action, + aliases: action === "enable" ? ["on"] : ["off"], + describe: `${action === "enable" ? "enable" : "disable"} passive prompt caching for a scope`, + instance: (args: CacheArgs) => !args.global, + builder: cacheOptions, + handler: Effect.fn(`Cli.cache.${action}`)(function* (args: CacheArgs) { + const config = yield* Config.Service + const current = args.global ? yield* config.getGlobal() : yield* config.get() + let next: ConfigCache.Info + try { + next = ConfigCache.updateCacheConfig(current.cache, mutationFromArgs(args, action)) + } catch (error) { + return yield* fail(error instanceof Error ? error.message : String(error)) + } + + if (args.global) yield* config.updateGlobal({ cache: next }) + else yield* config.update({ cache: next }) + + UI.println(`${action === "enable" ? "Enabled" : "Disabled"} prompt cache policy.`) + UI.println(JSON.stringify(next, null, 2)) + }), + }) + +export const CacheEnableCommand = CacheMutationCommand("enable") +export const CacheDisableCommand = CacheMutationCommand("disable") + +export const CacheCommand = cmd({ + command: "cache", + describe: "control passive provider prompt caching", + builder: (yargs) => + yargs.command(CacheStatusCommand).command(CacheEnableCommand).command(CacheDisableCommand).demandCommand(), + async handler() {}, +}) diff --git a/src/mendcode/packages/opencode/src/config/cache.ts b/src/mendcode/packages/opencode/src/config/cache.ts new file mode 100644 index 00000000..dbdfa43a --- /dev/null +++ b/src/mendcode/packages/opencode/src/config/cache.ts @@ -0,0 +1,232 @@ +import path from "path" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" + +export const Mode = Schema.Literals(["off", "smart"]) +export type Mode = Schema.Schema.Type + +const Project = Schema.Struct({ + mode: Schema.optional(Mode), +}) + +const Provider = Schema.Struct({ + mode: Schema.optional(Mode), + models: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + exclude_models: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}) + +const Sessions = Schema.Struct({ + mode: Schema.optional(Schema.Literals(["all", "selected", "none"])), + include: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + exclude: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}) + +export const Info = Schema.Struct({ + mode: Schema.optional(Mode).annotate({ + description: + "Prompt-cache policy. Omit to preserve existing provider behavior, off to suppress cache controls, or smart to allow only verified passive bindings.", + }), + projects: Schema.optional(Schema.Record(Schema.String, Project)).annotate({ + description: "Project-path overrides for the prompt-cache policy", + }), + sessions: Schema.optional(Sessions).annotate({ + description: "Session include/exclude controls for passive prompt caching", + }), + providers: Schema.optional(Schema.Record(Schema.String, Provider)).annotate({ + description: "Provider and exact-model allowlists or exclusions for verified cache bindings", + }), +}) + .annotate({ identifier: "CacheConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + +export type Info = Schema.Schema.Type + +export type EffectiveMode = "legacy" | Mode + +export type CacheSelection = { + mode: EffectiveMode + source: "default" | "project" | "session" | "provider" | "model" + reason: string +} + +function normalizedProjectPath(value: string) { + const resolved = path.resolve(value) + const root = path.parse(resolved).root + return resolved === root ? resolved : resolved.replace(/[\\/]+$/, "") +} + +function projectOverride(config: Info | undefined, projectScope: string | undefined) { + if (!config?.projects || !projectScope) return undefined + const normalized = normalizedProjectPath(projectScope) + for (const [candidate, override] of Object.entries(config.projects)) { + if (normalizedProjectPath(candidate) === normalized) return override + } + return undefined +} + +function modelCandidates(modelID: string | undefined, apiModelID: string | undefined) { + return [ + ...new Set([modelID, apiModelID, modelID?.split("/").pop(), apiModelID?.split("/").pop()].filter(Boolean)), + ] as string[] +} + +function includesModel(models: readonly string[] | undefined, candidates: readonly string[]) { + if (!models) return true + return candidates.some((candidate) => models.includes(candidate)) +} + +export function selectCacheConfig(input: { + config?: Info + projectScope?: string + sessionID?: string + providerID?: string + modelID?: string + apiModelID?: string +}): CacheSelection { + const config = input.config + const project = projectOverride(config, input.projectScope) + const provider = input.providerID ? config?.providers?.[input.providerID] : undefined + const candidates = modelCandidates(input.modelID, input.apiModelID) + const session = config?.sessions + + if (config?.mode === "off") { + return { mode: "off", source: "default", reason: "cache.mode is off" } + } + + if (project?.mode === "off") { + return { mode: "off", source: "project", reason: "the active project is excluded" } + } + + if (session?.mode === "none") { + return { mode: "off", source: "session", reason: "session caching is disabled" } + } + + if (input.sessionID && session?.exclude?.includes(input.sessionID)) { + return { mode: "off", source: "session", reason: "the session is excluded" } + } + + if (input.sessionID && session?.mode === "selected" && !session.include?.includes(input.sessionID)) { + return { mode: "off", source: "session", reason: "the session is not selected" } + } + + if (provider?.mode === "off") { + return { mode: "off", source: "provider", reason: `provider ${input.providerID} is disabled` } + } + + if (provider && input.modelID) { + if (!includesModel(provider.models, candidates)) { + return { mode: "off", source: "model", reason: "the model is not in the provider allowlist" } + } + if (provider.exclude_models?.some((model) => candidates.includes(model))) { + return { mode: "off", source: "model", reason: "the model is excluded" } + } + } + + if (config?.mode === "smart") { + return { mode: "smart", source: "default", reason: "cache.mode is smart" } + } + + if (project?.mode === "smart") { + return { mode: "smart", source: "project", reason: "the active project enables smart caching" } + } + + if ( + provider && + (provider.mode === "smart" || provider.models !== undefined || provider.exclude_models !== undefined) + ) { + return { mode: "smart", source: provider.models ? "model" : "provider", reason: "the provider is selected" } + } + + if (input.sessionID && session?.include?.includes(input.sessionID)) { + return { mode: "smart", source: "session", reason: "the session is explicitly selected" } + } + + return { mode: "legacy", source: "default", reason: "cache policy is not configured" } +} + +export type CacheMutation = { + action: "enable" | "disable" + providerID?: string + modelID?: string + sessionID?: string + projectPath?: string +} + +function unique(values: readonly string[]) { + return [...new Set(values)] +} + +function without(values: readonly string[] | undefined, value: string) { + return (values ?? []).filter((item) => item !== value) +} + +export function updateCacheConfig(current: Info | undefined, mutation: CacheMutation): Info { + const next = current ? { ...current } : {} + const enabled = mutation.action === "enable" + const targetCount = [mutation.providerID, mutation.modelID, mutation.sessionID, mutation.projectPath].filter( + Boolean, + ).length + if (mutation.modelID && !mutation.providerID) throw new Error("A model requires --provider") + if (mutation.projectPath && (mutation.providerID || mutation.modelID || mutation.sessionID)) { + throw new Error("A project target cannot be combined with provider, model, or session") + } + if (mutation.sessionID && (mutation.providerID || mutation.modelID)) { + throw new Error("A session target cannot be combined with provider or model") + } + + if (targetCount === 0) return { ...next, mode: enabled ? "smart" : "off" } + + if (mutation.projectPath) { + const key = normalizedProjectPath(mutation.projectPath) + return { + ...next, + projects: { + ...(next.projects ?? {}), + [key]: { ...(next.projects?.[key] ?? {}), mode: enabled ? "smart" : "off" }, + }, + } + } + + if (mutation.sessionID) { + const sessions = next.sessions ?? {} + const include = enabled + ? unique([...without(sessions.include, mutation.sessionID), mutation.sessionID]) + : without(sessions.include, mutation.sessionID) + const exclude = enabled + ? without(sessions.exclude, mutation.sessionID) + : unique([...without(sessions.exclude, mutation.sessionID), mutation.sessionID]) + return { + ...next, + sessions: { + ...sessions, + ...(enabled ? { mode: "selected" as const } : {}), + include, + exclude, + }, + } + } + + const provider = next.providers?.[mutation.providerID!] + const updatedProvider = { ...(provider ?? {}) } + if (!mutation.modelID) { + updatedProvider.mode = enabled ? "smart" : "off" + } else if (enabled) { + updatedProvider.mode = "smart" + updatedProvider.models = unique([...(updatedProvider.models ?? []), mutation.modelID]) + updatedProvider.exclude_models = without(updatedProvider.exclude_models, mutation.modelID) + } else { + updatedProvider.exclude_models = unique([...(updatedProvider.exclude_models ?? []), mutation.modelID]) + updatedProvider.models = without(updatedProvider.models, mutation.modelID) + } + + return { + ...next, + providers: { + ...(next.providers ?? {}), + [mutation.providerID!]: updatedProvider, + }, + } +} + +export * as ConfigCache from "./cache" diff --git a/src/mendcode/packages/opencode/src/config/config.ts b/src/mendcode/packages/opencode/src/config/config.ts index e10b0fea..6c55984b 100644 --- a/src/mendcode/packages/opencode/src/config/config.ts +++ b/src/mendcode/packages/opencode/src/config/config.ts @@ -25,6 +25,7 @@ import { containsPath } from "../project/instance-context" import { zod } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" +import { ConfigCache } from "./cache" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" import { ConfigLayout } from "./layout" @@ -238,6 +239,9 @@ export const Info = Schema.Struct({ provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({ description: "Custom provider configurations and model overrides", }), + cache: Schema.optional(ConfigCache.Info).annotate({ + description: "Provider prompt-cache controls with project, session, and exact-model scoping", + }), image_generation: Schema.optional( Schema.Struct({ enabled: Schema.optional(Schema.Boolean).annotate({ diff --git a/src/mendcode/packages/opencode/src/index.ts b/src/mendcode/packages/opencode/src/index.ts index 9c53a465..48d2a029 100644 --- a/src/mendcode/packages/opencode/src/index.ts +++ b/src/mendcode/packages/opencode/src/index.ts @@ -41,6 +41,7 @@ const commandLoaders = { debug: () => import("./cli/cmd/debug").then((module) => module.DebugCommand), console: () => import("./cli/cmd/account").then((module) => module.ConsoleCommand), providers: () => import("./cli/cmd/providers").then((module) => module.ProvidersCommand), + cache: () => import("./cli/cmd/cache").then((module) => module.CacheCommand), agent: () => import("./cli/cmd/agent").then((module) => module.AgentCommand), upgrade: () => import("./cli/cmd/upgrade").then((module) => module.UpgradeCommand), uninstall: () => import("./cli/cmd/uninstall").then((module) => module.UninstallCommand), diff --git a/src/mendcode/packages/opencode/src/plugin/codex.ts b/src/mendcode/packages/opencode/src/plugin/codex.ts index 8c2b59f8..eafb0e27 100644 --- a/src/mendcode/packages/opencode/src/plugin/codex.ts +++ b/src/mendcode/packages/opencode/src/plugin/codex.ts @@ -8,6 +8,7 @@ import { setTimeout as sleep } from "node:timers/promises" import { createServer } from "http" import { isRecord } from "@/util/record" import { normalizeAstraRequest } from "@/mend/prompt/model-family" +import { CACHE_MODE_HEADER, isManagedCacheKey } from "@/provider/cache-policy" const log = Log.create({ service: "plugin.codex" }) @@ -129,6 +130,8 @@ function prepareResponsesLiteRequest(input: { headers: Headers sessionIDs: Map sessionPromptFingerprints: Map + managedCacheKey?: string + cacheMode?: "off" }) { const body = normalizeCodexChatGPTRequestBody(input.body) if (typeof body !== "string") return body @@ -148,12 +151,21 @@ function prepareResponsesLiteRequest(input: { } const sourceSessionID = input.headers.get("session-id") ?? input.headers.get("session_id") + let instructionsChanged = false if (sourceSessionID && typeof parsed.instructions === "string") { const fingerprint = Bun.hash(parsed.instructions).toString() - if (input.sessionPromptFingerprints.get(sourceSessionID) !== fingerprint) { + const previousFingerprint = input.sessionPromptFingerprints.get(sourceSessionID) + if (previousFingerprint !== undefined && previousFingerprint !== fingerprint) { + instructionsChanged = true + } + if (previousFingerprint !== fingerprint) { input.sessionIDs.delete(sourceSessionID) input.sessionPromptFingerprints.set(sourceSessionID, fingerprint) } + } else if (sourceSessionID && input.sessionPromptFingerprints.has(sourceSessionID)) { + instructionsChanged = true + input.sessionIDs.delete(sourceSessionID) + input.sessionPromptFingerprints.delete(sourceSessionID) } const sessionID = (sourceSessionID ? input.sessionIDs.get(sourceSessionID) : undefined) ?? Bun.randomUUIDv7() if (sourceSessionID) input.sessionIDs.set(sourceSessionID, sessionID) @@ -174,7 +186,13 @@ function prepareResponsesLiteRequest(input: { delete parsed.instructions parsed.tool_choice = "auto" parsed.parallel_tool_calls = false - parsed.prompt_cache_key = sessionID + if (input.cacheMode === "off") { + delete parsed.prompt_cache_key + delete parsed.promptCacheKey + } else { + parsed.prompt_cache_key = + !instructionsChanged && isManagedCacheKey(input.managedCacheKey) ? input.managedCacheKey : sessionID + } parsed.reasoning = { ...(isRecord(parsed.reasoning) ? parsed.reasoning : {}), context: "all_turns", @@ -195,12 +213,29 @@ export function prepareCodexChatGPTOAuthRequest(input: { headers: Headers sessionIDs?: Map sessionPromptFingerprints?: Map + /** Internal seam for a verified cache lineage; affinity remains session-scoped. */ + managedCacheKey?: string + /** Internal request override used to disable provider cache controls. */ + cacheMode?: "off" responsesLite?: boolean }) { input.headers.set("originator", CODEX_ORIGINATOR) input.headers.set("User-Agent", CODEX_USER_AGENT) input.headers.set("Origin", "https://chatgpt.com") - if (input.responsesLite === false) return normalizeCodexChatGPTRequestBody(input.body) + if (input.responsesLite === false) { + const body = normalizeCodexChatGPTRequestBody(input.body) + if (input.cacheMode !== "off" || typeof body !== "string") return body + let parsed: unknown + try { + parsed = JSON.parse(body) + } catch { + return body + } + if (!isRecord(parsed)) return body + delete parsed.prompt_cache_key + delete parsed.promptCacheKey + return JSON.stringify(parsed) + } const sessionIDs = input.sessionIDs ?? new Map() const sessionPromptFingerprints = input.sessionPromptFingerprints ?? new Map() return prepareResponsesLiteRequest({ @@ -208,6 +243,8 @@ export function prepareCodexChatGPTOAuthRequest(input: { headers: input.headers, sessionIDs, sessionPromptFingerprints, + managedCacheKey: input.managedCacheKey, + cacheMode: input.cacheMode, }) } @@ -672,6 +709,8 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug } const headers = new Headers(request.headers) + const cacheMode = headers.get(CACHE_MODE_HEADER) + headers.delete(CACHE_MODE_HEADER) headers.delete("authorization") headers.set("authorization", `Bearer ${currentAuth.access}`) @@ -695,6 +734,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug headers, sessionIDs: codexSessionIDs, sessionPromptFingerprints: codexSessionPromptFingerprints, + ...(cacheMode === "off" ? { cacheMode: "off" as const } : {}), // The standalone compact endpoint accepts the canonical // Responses input window. Responses Lite's developer-item // rewrite would corrupt that opaque window, so keep the diff --git a/src/mendcode/packages/opencode/src/provider/cache-policy.ts b/src/mendcode/packages/opencode/src/provider/cache-policy.ts new file mode 100644 index 00000000..70ad1aad --- /dev/null +++ b/src/mendcode/packages/opencode/src/provider/cache-policy.ts @@ -0,0 +1,431 @@ +import { createHash } from "node:crypto" +import type { ModelMessage } from "ai" +import { mergeDeep, unique } from "remeda" +import { ConfigCache } from "@/config/cache" +import type * as Provider from "./provider" + +export type CacheAuth = "api" | "oauth" | "unknown" +export type CacheTransport = "responses-http" | "responses-lite" | "other" + +/** Internal marker consumed by the ChatGPT OAuth adapter and never sent upstream. */ +export const CACHE_MODE_HEADER = "x-mendcode-cache-mode" + +export type CacheBinding = { + providerID: string + modelID: string + apiModelID: string + endpoint: string + auth: CacheAuth + transport: CacheTransport + sdk: string + accountScope?: string +} + +export type CacheCapabilities = { + /** The binding already has a supported cache-key representation. */ + key: boolean + /** Explicit cache breakpoints are verified for this exact binding. */ + explicitBreakpoints: boolean + /** A provider-declared retention period, or null when unknown. */ + retentionSeconds: number | null + /** A provider-specific refresh request is verified for this exact binding. */ + refresh: boolean + /** Lineage inheritance is verified for this exact binding. */ + lineage: boolean +} + +export type CacheAdapterScope = "model" | "family" | "provider" | "fallback" + +export type CacheAdapter = { + id: string + matches: (binding: CacheBinding) => boolean + capabilities: (binding: CacheBinding) => CacheCapabilities + /** Optional precedence; model-specific adapters should declare their scope. */ + scope?: CacheAdapterScope + priority?: number +} + +const FALLBACK_CAPABILITIES: CacheCapabilities = { + key: false, + explicitBreakpoints: false, + retentionSeconds: null, + refresh: false, + lineage: false, +} + +const OPENAI_CAPABILITIES: CacheCapabilities = { + key: true, + explicitBreakpoints: false, + retentionSeconds: null, + refresh: false, + lineage: false, +} + +const OPENROUTER_CAPABILITIES: CacheCapabilities = { + key: true, + explicitBreakpoints: false, + retentionSeconds: null, + refresh: false, + lineage: false, +} + +const LEGACY_CONTROL_CAPABILITIES: CacheCapabilities = { + key: false, + explicitBreakpoints: false, + retentionSeconds: null, + refresh: false, + lineage: false, +} + +const FALLBACK_ADAPTER: CacheAdapter = { + id: "legacy-fallback", + scope: "fallback", + matches: () => true, + capabilities: () => ({ ...FALLBACK_CAPABILITIES }), +} + +/** + * Built-in cache knowledge is deliberately conservative. New provider/model + * adapters can be added here after their exact wire contract is verified. + */ +export const defaultCacheAdapters: readonly CacheAdapter[] = [ + { + id: "openai", + scope: "provider", + matches: (binding) => binding.providerID === "openai" && binding.sdk === "@ai-sdk/openai", + capabilities: () => ({ ...OPENAI_CAPABILITIES }), + }, + { + id: "openrouter", + scope: "provider", + matches: (binding) => binding.providerID === "openrouter" || binding.sdk === "@openrouter/ai-sdk-provider", + capabilities: () => ({ ...OPENROUTER_CAPABILITIES }), + }, + { + id: "anthropic-controls", + scope: "provider", + matches: (binding) => binding.sdk === "@ai-sdk/anthropic" || binding.sdk === "@ai-sdk/google-vertex/anthropic", + capabilities: () => ({ ...LEGACY_CONTROL_CAPABILITIES }), + }, + { + id: "bedrock-controls", + scope: "provider", + matches: (binding) => binding.sdk === "@ai-sdk/amazon-bedrock", + capabilities: () => ({ ...LEGACY_CONTROL_CAPABILITIES }), + }, + { + ...FALLBACK_ADAPTER, + }, +] + +const ADAPTER_PRIORITY: Record = { + fallback: 0, + provider: 10, + family: 20, + model: 30, +} + +function adapterPriority(adapter: CacheAdapter) { + return adapter.priority ?? (adapter.scope ? ADAPTER_PRIORITY[adapter.scope] : ADAPTER_PRIORITY.family) +} + +export function resolveCacheAdapter( + binding: CacheBinding, + adapters: readonly CacheAdapter[] = defaultCacheAdapters, +): CacheAdapter { + const matches = adapters.filter((adapter) => adapter.matches(binding)) + if (matches.length === 0) return FALLBACK_ADAPTER + + const priority = Math.max(...matches.map(adapterPriority)) + const best = matches.filter((adapter) => adapterPriority(adapter) === priority) + if (best.length > 1) { + throw new Error( + `Ambiguous cache adapters for ${binding.providerID}/${binding.modelID}: ${best.map((item) => item.id).join(", ")}`, + ) + } + return best[0]! +} + +export type CacheRequestPolicy = { + mode: ConfigCache.EffectiveMode + useCacheKey: boolean + useLegacyAnnotations: boolean + adapterID: string + reason: string +} + +function verifiedPassiveBinding(binding: CacheBinding) { + if (binding.auth !== "api" || binding.transport === "responses-lite") return false + + let hostname: string + try { + hostname = new URL(binding.endpoint).hostname + } catch { + return false + } + + if (binding.providerID === "openai") { + return binding.sdk === "@ai-sdk/openai" && hostname === "api.openai.com" + } + + if (binding.providerID === "openrouter") { + return ( + (binding.sdk === "@openrouter/ai-sdk-provider" || binding.sdk === "@ai-sdk/openai-compatible") && + hostname === "openrouter.ai" + ) + } + + return false +} + +export function resolveCacheRequestPolicy(input: { + config?: ConfigCache.Info + binding: CacheBinding + projectScope?: string + sessionID?: string +}): CacheRequestPolicy { + const selection = ConfigCache.selectCacheConfig({ + config: input.config, + projectScope: input.projectScope, + sessionID: input.sessionID, + providerID: input.binding.providerID, + modelID: input.binding.modelID, + apiModelID: input.binding.apiModelID, + }) + + if (selection.mode === "off") { + return { + mode: "off", + useCacheKey: false, + useLegacyAnnotations: false, + adapterID: "disabled", + reason: selection.reason, + } + } + + const adapter = resolveCacheAdapter(input.binding) + if (selection.mode === "legacy") { + return { + mode: "legacy", + useCacheKey: true, + useLegacyAnnotations: true, + adapterID: adapter.id, + reason: selection.reason, + } + } + + const capabilities = adapter.capabilities(input.binding) + const enabled = capabilities.key && verifiedPassiveBinding(input.binding) + return { + mode: "smart", + useCacheKey: enabled, + useLegacyAnnotations: true, + adapterID: adapter.id, + reason: enabled ? "verified passive cache binding" : "binding is not verified for passive cache keys", + } +} + +export function cacheBindingFromModel( + model: Pick, + input: Partial> = {}, +): CacheBinding { + return { + providerID: model.providerID, + modelID: model.id, + apiModelID: model.api.id, + endpoint: input.endpoint ?? model.api.url, + auth: input.auth ?? "unknown", + transport: input.transport ?? "other", + sdk: model.api.npm, + ...(input.accountScope ? { accountScope: input.accountScope } : {}), + } +} + +function normalizedEndpoint(endpoint: string) { + try { + const parsed = new URL(endpoint) + if ((parsed.protocol !== "https:" && parsed.protocol !== "http:") || parsed.username || parsed.password) return null + parsed.hash = "" + return parsed.toString().replace(/\/$/, "") + } catch { + return null + } +} + +const UNSUPPORTED = Symbol("unsupported-cache-fingerprint-value") + +function canonicalize(value: unknown, seen: Set): unknown { + if (value === null || typeof value === "string" || typeof value === "boolean") return value + if (typeof value === "number") return Number.isFinite(value) ? value : UNSUPPORTED + if (typeof value === "undefined") return { __mendcodeUndefined: true } + if (typeof value !== "object") return UNSUPPORTED + if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : UNSUPPORTED + if (seen.has(value)) return UNSUPPORTED + seen.add(value) + + const result = Array.isArray(value) + ? value.map((item) => canonicalize(item, seen)) + : Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonicalize((value as Record)[key], seen)]), + ) + seen.delete(value) + if (Array.isArray(result) && result.some((item) => item === UNSUPPORTED)) return UNSUPPORTED + if (!Array.isArray(result) && Object.values(result).some((item) => item === UNSUPPORTED)) return UNSUPPORTED + return result +} + +function containsUnstableMedia(value: unknown, seen: Set = new Set()): boolean { + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return true + seen.add(value) + if (Array.isArray(value)) return value.some((item) => containsUnstableMedia(item, seen)) + const record = value as Record + if (["image", "input_image", "input_file", "file", "image_url", "file_url"].includes(String(record.type))) return true + if ("image_url" in record || "file_url" in record) return true + return Object.values(record).some((item) => containsUnstableMedia(item, seen)) +} + +/** + * Hash only a reproducible cache prefix. The prompt itself is never retained. + * Callers must supply the prefix after all relevant transforms and tools have + * been resolved; a hash calculated earlier does not prove wire equality. + */ +export function fingerprintPrefix(input: { + binding: CacheBinding + projectScope: string + prefix: unknown + toolDefinitions: unknown + settings: unknown + serializationRevision: string +}) { + const endpoint = normalizedEndpoint(input.binding.endpoint) + if ( + !endpoint || + !input.projectScope || + !input.binding.accountScope || + input.binding.auth === "unknown" || + input.binding.transport === "other" || + !input.binding.providerID || + !input.binding.modelID || + !input.binding.apiModelID || + !input.binding.sdk || + !input.serializationRevision || + containsUnstableMedia(input.prefix) || + containsUnstableMedia(input.toolDefinitions) || + containsUnstableMedia(input.settings) + ) + return null + + const payload = canonicalize( + { + binding: { ...input.binding, endpoint }, + projectScope: input.projectScope, + prefix: input.prefix, + toolDefinitions: input.toolDefinitions, + settings: input.settings, + serializationRevision: input.serializationRevision, + }, + new Set(), + ) + if (payload === UNSUPPORTED) return null + return createHash("sha256").update(JSON.stringify(payload)).digest("hex") +} + +export function isManagedCacheKey(value: unknown): value is string { + return typeof value === "string" && /^[A-Za-z0-9:_-]{1,256}$/.test(value) +} + +function uniqueMessages(messages: ModelMessage[]) { + return unique(messages) +} + +/** Preserve the existing provider annotations while keeping the decision in one module. */ +export function applyLegacyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { + const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) + const final = msgs.filter((msg) => msg.role !== "system").slice(-2) + + const providerOptions = { + anthropic: { + cacheControl: { type: "ephemeral" }, + }, + openrouter: { + cacheControl: { type: "ephemeral" }, + }, + bedrock: { + cachePoint: { type: "default" }, + }, + openaiCompatible: { + cache_control: { type: "ephemeral" }, + }, + copilot: { + copilot_cache_control: { type: "ephemeral" }, + }, + alibaba: { + cacheControl: { type: "ephemeral" }, + }, + } + + for (const msg of uniqueMessages([...system, ...final])) { + const useMessageLevelOptions = + model.providerID === "anthropic" || + model.providerID.includes("bedrock") || + model.api.npm === "@ai-sdk/amazon-bedrock" + const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 + + if (shouldUseContentOptions) { + const lastContent = msg.content[msg.content.length - 1] + if ( + lastContent && + typeof lastContent === "object" && + lastContent.type !== "tool-approval-request" && + lastContent.type !== "tool-approval-response" + ) { + lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) + continue + } + } + + msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions) + } + + return msgs +} + +function nonNegativeInteger(value: unknown) { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null +} + +export function normalizeCacheObservation(input: { + inputTokens?: unknown + readTokens?: unknown + writeTokens?: unknown + observedAtMs: number +}) { + if (!Number.isSafeInteger(input.observedAtMs) || input.observedAtMs < 0) { + throw new RangeError("Cache observation timestamp must be a non-negative safe integer") + } + + const inputTokens = nonNegativeInteger(input.inputTokens) + const readCandidate = nonNegativeInteger(input.readTokens) + const writeCandidate = nonNegativeInteger(input.writeTokens) + const readTokens = + inputTokens !== null && readCandidate !== null && readCandidate > inputTokens ? null : readCandidate + const writeTokens = + inputTokens !== null && writeCandidate !== null && writeCandidate > inputTokens ? null : writeCandidate + const state = readTokens === null ? "unknown" : readTokens === 0 ? "miss_observed" : "hit_observed" + const hitRatio = inputTokens !== null && inputTokens > 0 && readTokens !== null ? readTokens / inputTokens : null + + return { + inputTokens, + readTokens, + writeTokens, + hitRatio, + observedAtMs: input.observedAtMs, + state, + expiresAtMs: null, + } as const +} + +export * as CachePolicy from "./cache-policy" diff --git a/src/mendcode/packages/opencode/src/provider/transform.ts b/src/mendcode/packages/opencode/src/provider/transform.ts index befeb0b5..30b5a347 100644 --- a/src/mendcode/packages/opencode/src/provider/transform.ts +++ b/src/mendcode/packages/opencode/src/provider/transform.ts @@ -1,11 +1,11 @@ import type { ModelMessage, ToolResultPart } from "ai" -import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" import type { JSONSchema } from "zod/v4/core" import type * as Provider from "./provider" import type * as ModelsDev from "./models" import { iife } from "@/util/iife" import { Flag } from "@mendcode/core/flag/flag" +import { applyLegacyCaching, type CacheRequestPolicy } from "./cache-policy" type Modality = NonNullable["input"][number] @@ -20,6 +20,14 @@ function mimeToModality(mime: string): Modality | undefined { export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const +const CACHE_OPTION_KEYS = new Set([ + "cacheControl", + "cachePoint", + "cache_control", + "copilot_cache_control", + "promptCacheKey", + "prompt_cache_key", +]) export function sanitizeSurrogates(content: string) { return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? msg.role === "system").slice(0, 2) - const final = msgs.filter((msg) => msg.role !== "system").slice(-2) - - const providerOptions = { - anthropic: { - cacheControl: { type: "ephemeral" }, - }, - openrouter: { - cacheControl: { type: "ephemeral" }, - }, - bedrock: { - cachePoint: { type: "default" }, - }, - openaiCompatible: { - cache_control: { type: "ephemeral" }, - }, - copilot: { - copilot_cache_control: { type: "ephemeral" }, - }, - alibaba: { - cacheControl: { type: "ephemeral" }, - }, - } - - for (const msg of unique([...system, ...final])) { - const useMessageLevelOptions = - model.providerID === "anthropic" || - model.providerID.includes("bedrock") || - model.api.npm === "@ai-sdk/amazon-bedrock" - const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 - - if (shouldUseContentOptions) { - const lastContent = msg.content[msg.content.length - 1] - if ( - lastContent && - typeof lastContent === "object" && - lastContent.type !== "tool-approval-request" && - lastContent.type !== "tool-approval-response" - ) { - lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) - continue - } - } - - msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions) - } - - return msgs -} - function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { return msgs.map((msg) => { if (msg.role !== "user" || !Array.isArray(msg.content)) return msg @@ -416,10 +373,67 @@ function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMes }) } -export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { +function stripCacheFields(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripCacheFields) + if (!value || typeof value !== "object") return value + + const result: Record = {} + for (const [key, item] of Object.entries(value)) { + if (CACHE_OPTION_KEYS.has(key)) continue + result[key] = stripCacheFields(item) + } + return result +} + +export function removeCacheOptions(options: Record) { + const result = stripCacheFields(options) as Record + if (result.gateway && typeof result.gateway === "object" && result.gateway.caching === "auto") { + const gateway = { ...result.gateway } + delete gateway.caching + if (Object.keys(gateway).length === 0) delete result.gateway + else result.gateway = gateway + } + return result +} + +export function enforceCacheOptions(options: Record, policy: CacheRequestPolicy) { + if (policy.mode === "off" || (policy.mode === "smart" && !policy.useCacheKey)) return removeCacheOptions(options) + return options +} + +function withoutCacheAnnotations(msgs: ModelMessage[]) { + return msgs.map((msg) => { + const result = { ...msg } as Record + const providerOptions = stripCacheFields(msg.providerOptions) as Record | undefined + if (providerOptions && Object.keys(providerOptions).length > 0) result.providerOptions = providerOptions + else delete result.providerOptions + + if (Array.isArray(msg.content)) { + result.content = msg.content.map((part) => { + if (!part || typeof part !== "object") return part + const next = { ...part } as Record + const partOptions = stripCacheFields(next.providerOptions) as Record | undefined + if (partOptions && Object.keys(partOptions).length > 0) next.providerOptions = partOptions + else delete next.providerOptions + return next + }) + } + return result as ModelMessage + }) +} + +export function message( + msgs: ModelMessage[], + model: Provider.Model, + options: Record, + cachePolicy?: CacheRequestPolicy, +) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) - if ( + if (cachePolicy?.mode === "off") { + msgs = withoutCacheAnnotations(msgs) + } else if ( + cachePolicy?.useLegacyAnnotations !== false && (model.providerID === "anthropic" || model.providerID === "google-vertex-anthropic" || model.api.id.includes("anthropic") || @@ -430,7 +444,7 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re model.api.npm === "@ai-sdk/alibaba") && model.api.npm !== "@ai-sdk/gateway" ) { - msgs = applyCaching(msgs, model) + msgs = applyLegacyCaching(msgs, model) } // Remap providerOptions keys from stored providerID to expected SDK key @@ -1056,8 +1070,11 @@ export function options(input: { model: Provider.Model sessionID: string providerOptions?: Record + cache?: CacheRequestPolicy }): Record { const result: Record = {} + const legacyCaching = !input.cache || input.cache.mode === "legacy" + const managedCaching = input.cache?.mode === "smart" && input.cache.useCacheKey if ( input.model.api.npm === "@ai-sdk/google-vertex/anthropic" || @@ -1075,7 +1092,7 @@ export function options(input: { result["store"] = false } - if (input.model.api.npm === "@ai-sdk/azure") { + if (legacyCaching && input.model.api.npm === "@ai-sdk/azure") { result["store"] = false result["promptCacheKey"] = input.sessionID } @@ -1106,7 +1123,10 @@ export function options(input: { } } - if (input.model.providerID === "openai" || input.providerOptions?.setCacheKey) { + if ( + (legacyCaching && (input.model.providerID === "openai" || input.providerOptions?.setCacheKey)) || + (managedCaching && input.model.providerID === "openai") + ) { result["promptCacheKey"] = input.sessionID } @@ -1178,21 +1198,21 @@ export function options(input: { result["textVerbosity"] = "low" } - if (input.model.providerID.startsWith("opencode")) { + if (legacyCaching && input.model.providerID.startsWith("opencode")) { result["promptCacheKey"] = input.sessionID result["include"] = INCLUDE_ENCRYPTED_REASONING result["reasoningSummary"] = "auto" } } - if (input.model.providerID === "venice") { + if (legacyCaching && input.model.providerID === "venice") { result["promptCacheKey"] = input.sessionID } - if (input.model.providerID === "openrouter") { + if ((legacyCaching || managedCaching) && input.model.providerID === "openrouter") { result["prompt_cache_key"] = input.sessionID } - if (input.model.api.npm === "@ai-sdk/gateway") { + if (legacyCaching && input.model.api.npm === "@ai-sdk/gateway") { result["gateway"] = { caching: "auto", } diff --git a/src/mendcode/packages/opencode/src/session/cache-lineage.ts b/src/mendcode/packages/opencode/src/session/cache-lineage.ts new file mode 100644 index 00000000..0331993d --- /dev/null +++ b/src/mendcode/packages/opencode/src/session/cache-lineage.ts @@ -0,0 +1,46 @@ +export { + fingerprintPrefix, + isManagedCacheKey, + type CacheBinding, + type CacheCapabilities, + type CacheAdapter, + type CacheAdapterScope, +} from "@/provider/cache-policy" + +export type CacheIdentity = { + runtimeSessionID: string + transportAffinity: string + cacheKey: string + lineageID: string + compatible: boolean + reason: string +} + +export function selectCacheIdentity(input: { + sessionID: string + transportAffinity: string + ownLineageID: string + parent?: { lineageID: string; cacheKey: string; fingerprint: string | null } + fingerprint: string | null + legacyKey: string +}): CacheIdentity { + const own = { + runtimeSessionID: input.sessionID, + transportAffinity: input.transportAffinity, + cacheKey: input.legacyKey, + lineageID: input.ownLineageID, + compatible: false, + reason: "cache prefix is not proven compatible", + } + if (!input.parent || !input.fingerprint || input.parent.fingerprint !== input.fingerprint) return own + if (!input.parent.lineageID || !input.parent.cacheKey) return own + return { + ...own, + cacheKey: input.parent.cacheKey, + lineageID: input.parent.lineageID, + compatible: true, + reason: "cache prefix fingerprint matches the parent", + } +} + +export * as CacheLineage from "./cache-lineage" diff --git a/src/mendcode/packages/opencode/src/session/llm.ts b/src/mendcode/packages/opencode/src/session/llm.ts index d0c61627..37376097 100644 --- a/src/mendcode/packages/opencode/src/session/llm.ts +++ b/src/mendcode/packages/opencode/src/session/llm.ts @@ -38,6 +38,7 @@ import { runtimeCapabilityPrompt } from "@/mend/prompt/runtime-capabilities" import { autoReasoningSignal, selectAutoReasoning } from "@/mend/prompt/reasoning-auto" import { isAstraModel, normalizeAstraOptions } from "@/mend/prompt/model-family" import { ReasoningRequested, ReasoningCleared, recordReasoningState, clearReasoningState, requestedReasoningEffort } from "@/mend/prompt/reasoning-state" +import { CACHE_MODE_HEADER, cacheBindingFromModel, resolveCacheRequestPolicy } from "@/provider/cache-policy" import { profileContext, type ContextProfile } from "./context-profile" import { discoveryWireMiddleware } from "./tool-discovery" @@ -220,6 +221,30 @@ const live: Layer.Layer< // TODO: move this to a proper hook const isOpenaiOauth = item.id === "openai" && info?.type === "oauth" const mendProjectRoot = input.root || input.cwd + const authMode = info?.type === "oauth" ? "oauth" : info?.type === "api" ? "api" : "unknown" + const endpoint = + (typeof input.model.options?.baseURL === "string" && input.model.options.baseURL) || + (typeof item.options?.baseURL === "string" && item.options.baseURL) || + input.model.api.url + const cachePolicy = resolveCacheRequestPolicy({ + config: cfg.cache, + projectScope: mendProjectRoot, + sessionID: input.sessionID, + binding: cacheBindingFromModel(input.model, { + auth: authMode, + transport: isOpenaiOauth + ? "responses-lite" + : input.model.api.npm === "@ai-sdk/openai" + ? "responses-http" + : "other", + endpoint, + ...(info?.type === "oauth" + ? { accountScope: info.accountId ? `oauth:${info.accountId}` : "oauth" } + : info?.type === "api" + ? { accountScope: "api" } + : {}), + }), + }) const mendFocus = input.mendPrompt?.focus ?? SystemPrompt.mendFocus(input.model) const mendPromptPolicy = input.mendPrompt?.policy ?? @@ -294,6 +319,7 @@ const live: Layer.Layer< model: input.model, sessionID: input.sessionID, providerOptions: item.options, + cache: cachePolicy, }) const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant) if (isOpenaiOauth) { @@ -354,6 +380,7 @@ const live: Layer.Layer< // retains reasoning parameters without substituting a different model ID. if (input.model.api.npm === "@ai-sdk/openai") params.options.forceReasoning = true } + params.options = ProviderTransform.enforceCacheOptions(params.options, cachePolicy) const { headers } = yield* plugin.trigger( "chat.headers", @@ -368,6 +395,10 @@ const live: Layer.Layer< headers: {}, }, ) + const requestHeaders = { + ...headers, + ...(isOpenaiOauth && cachePolicy.mode === "off" ? { [CACHE_MODE_HEADER]: "off" } : {}), + } const tools = resolveTools(input) @@ -594,7 +625,7 @@ const live: Layer.Layer< "User-Agent": `mendcode/${InstallationVersion}`, }), ...input.model.headers, - ...headers, + ...requestHeaders, }, maxRetries: input.retries ?? 0, includeRawChunks: true, @@ -607,7 +638,7 @@ const live: Layer.Layer< async transformParams(args) { if (args.type === "stream") { // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) + args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options, cachePolicy) contextProfile = profileContext({ prompt: args.params.prompt, tools: args.params.tools, diff --git a/src/mendcode/packages/opencode/test/config/cache.test.ts b/src/mendcode/packages/opencode/test/config/cache.test.ts new file mode 100644 index 00000000..e63160c9 --- /dev/null +++ b/src/mendcode/packages/opencode/test/config/cache.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { ConfigCache } from "@/config/cache" + +describe("cache configuration", () => { + test("keeps legacy behavior when no cache policy is configured", () => { + expect(ConfigCache.selectCacheConfig({ config: undefined, projectScope: "/repo", sessionID: "ses_1" })).toEqual({ + mode: "legacy", + source: "default", + reason: "cache policy is not configured", + }) + }) + + test("applies session, project, provider, and model restrictions", () => { + const config: ConfigCache.Info = { + mode: "smart", + projects: { + "/repo": { mode: "smart" }, + }, + sessions: { + mode: "selected", + include: ["ses_allowed"], + }, + providers: { + openai: { + models: ["gpt-6-astra"], + }, + }, + } + + expect( + ConfigCache.selectCacheConfig({ + config, + projectScope: "/repo/", + sessionID: "ses_allowed", + providerID: "openai", + modelID: "openai/gpt-6-astra", + apiModelID: "gpt-6-astra", + }).mode, + ).toBe("smart") + expect( + ConfigCache.selectCacheConfig({ + config, + projectScope: "/repo", + sessionID: "ses_other", + providerID: "openai", + modelID: "openai/gpt-6-astra", + apiModelID: "gpt-6-astra", + }), + ).toMatchObject({ mode: "off", source: "session" }) + expect( + ConfigCache.selectCacheConfig({ + config, + projectScope: "/repo", + sessionID: "ses_allowed", + providerID: "openai", + modelID: "openai/gpt-5.5", + apiModelID: "gpt-5.5", + }), + ).toMatchObject({ mode: "off", source: "model" }) + }) + + test("updates exact provider models and session selections without mutating input", () => { + const current: ConfigCache.Info = { + providers: { openai: { mode: "smart", models: ["gpt-5.5"] } }, + sessions: { mode: "selected", include: ["ses_old"] }, + } + const next = ConfigCache.updateCacheConfig(current, { + action: "enable", + providerID: "openai", + modelID: "gpt-6-astra", + }) + const withSession = ConfigCache.updateCacheConfig(next, { action: "disable", sessionID: "ses_old" }) + + expect(next.providers?.openai?.models).toEqual(["gpt-5.5", "gpt-6-astra"]) + expect(withSession.sessions?.include).toEqual([]) + expect(withSession.sessions?.exclude).toEqual(["ses_old"]) + expect(current.providers?.openai?.models).toEqual(["gpt-5.5"]) + expect(current.sessions?.exclude).toBeUndefined() + }) + + test("accepts the public JSON configuration shape", () => { + expect( + ConfigCache.Info.zod.parse({ + mode: "smart", + projects: { "/repo": { mode: "smart" } }, + sessions: { mode: "selected", include: ["ses_1"], exclude: [] }, + providers: { openai: { mode: "smart", models: ["gpt-6-astra"], exclude_models: [] } }, + }), + ).toEqual({ + mode: "smart", + projects: { "/repo": { mode: "smart" } }, + sessions: { mode: "selected", include: ["ses_1"], exclude: [] }, + providers: { openai: { mode: "smart", models: ["gpt-6-astra"], exclude_models: [] } }, + }) + }) +}) diff --git a/src/mendcode/packages/opencode/test/plugin/codex.test.ts b/src/mendcode/packages/opencode/test/plugin/codex.test.ts index cf975dea..ab26a7e5 100644 --- a/src/mendcode/packages/opencode/test/plugin/codex.test.ts +++ b/src/mendcode/packages/opencode/test/plugin/codex.test.ts @@ -311,6 +311,88 @@ describe("plugin.codex", () => { ]) }) + test("rotates Responses Lite affinity when instructions are removed", () => { + const sessionIDs = new Map() + const sessionPromptFingerprints = new Map() + + const request = (instructions?: string) => { + const headers = new Headers({ "session-id": "ses_prompt_context_removed" }) + const body = prepareCodexChatGPTOAuthRequest({ + headers, + sessionIDs, + sessionPromptFingerprints, + body: JSON.stringify({ + model: "gpt-5.6-luna", + input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }], + ...(instructions === undefined ? {} : { instructions }), + }), + }) + return { headers, body: JSON.parse(body as string) as Record } + } + + const first = request("stable instructions") + const removed = request() + + expect(removed.headers.get("session-id")).not.toBe(first.headers.get("session-id")) + expect(removed.body.prompt_cache_key).toBe(removed.headers.get("session-id")) + }) + + test("uses a validated managed cache key without changing transport affinity", () => { + const headers = new Headers({ "session-id": "ses_managed_cache" }) + const body = prepareCodexChatGPTOAuthRequest({ + headers, + managedCacheKey: "mendcode:project:root-1", + body: JSON.stringify({ + model: "gpt-5.6-luna", + input: [], + instructions: "stable instructions", + }), + }) + const parsed = JSON.parse(body as string) as Record + + expect(parsed.prompt_cache_key).toBe("mendcode:project:root-1") + expect(headers.get("session-id")).not.toBe("mendcode:project:root-1") + expect(headers.get("x-session-affinity")).toBe(headers.get("session-id")) + }) + + test("omits the Responses Lite cache key when cache mode is off", () => { + const headers = new Headers({ "session-id": "ses_cache_off" }) + const body = prepareCodexChatGPTOAuthRequest({ + headers, + cacheMode: "off", + body: JSON.stringify({ model: "gpt-5.6-luna", input: [], instructions: "stable" }), + }) + const parsed = JSON.parse(body as string) as Record + + expect(parsed.prompt_cache_key).toBeUndefined() + expect(headers.get("x-session-affinity")).toBe(headers.get("session-id")) + }) + + test("consumes the cache-off marker before forwarding an OAuth request", async () => { + let forwarded: { headers: Headers; body: Record } | undefined + using server = Bun.serve({ + port: 0, + async fetch(request) { + forwarded = { headers: new Headers(request.headers), body: await readRequestBody(request) } + return Response.json({}) + }, + }) + const providerFetch = await loadCodexFetch(`http://127.0.0.1:${server.port}/codex/responses`) + + await providerFetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "session-id": "ses_cache_off_forward", + "x-mendcode-cache-mode": "off", + }, + body: JSON.stringify({ model: "gpt-5.6-luna", input: [], instructions: "stable" }), + }) + + expect(forwarded?.headers.get("x-mendcode-cache-mode")).toBeNull() + expect(forwarded?.body.prompt_cache_key).toBeUndefined() + }) + test("keeps the API-key transport independent from the ChatGPT OAuth adapter", async () => { const plugin = await CodexAuthPlugin({} as never) const loaded = await plugin.auth!.loader!(async () => ({ type: "api", key: "test" }) as never, {} as never) diff --git a/src/mendcode/packages/opencode/test/provider/cache-policy.test.ts b/src/mendcode/packages/opencode/test/provider/cache-policy.test.ts new file mode 100644 index 00000000..e666251a --- /dev/null +++ b/src/mendcode/packages/opencode/test/provider/cache-policy.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test" +import type { ModelMessage } from "ai" +import { + applyLegacyCaching, + defaultCacheAdapters, + fingerprintPrefix, + normalizeCacheObservation, + resolveCacheAdapter, + resolveCacheRequestPolicy, + type CacheAdapter, + type CacheBinding, +} from "@/provider/cache-policy" +import type * as Provider from "@/provider/provider" + +function binding(overrides: Partial = {}): CacheBinding { + return { + providerID: "openai", + modelID: "openai/gpt-6-astra", + apiModelID: "gpt-6-astra", + endpoint: "https://api.openai.com/v1/responses", + auth: "api", + transport: "responses-http", + sdk: "@ai-sdk/openai", + accountScope: "account-1", + ...overrides, + } +} + +const model = { + providerID: "anthropic", + api: { id: "claude-3-5-sonnet", npm: "@ai-sdk/anthropic", url: "https://api.anthropic.com" }, +} as unknown as Provider.Model + +describe("cache policy", () => { + test("resolves a model adapter before a provider adapter and falls back safely", () => { + const modelAdapter: CacheAdapter = { + id: "astra-model", + scope: "model", + matches: (input) => input.apiModelID === "gpt-6-astra", + capabilities: () => ({ + key: true, + explicitBreakpoints: false, + retentionSeconds: null, + refresh: false, + lineage: false, + }), + } + + expect(resolveCacheAdapter(binding(), [modelAdapter, ...defaultCacheAdapters]).id).toBe("astra-model") + expect(resolveCacheAdapter(binding({ providerID: "unknown", sdk: "unknown" })).id).toBe("legacy-fallback") + expect(() => resolveCacheAdapter(binding(), [{ ...modelAdapter, id: "second-model" }, modelAdapter])).toThrow( + "Ambiguous cache adapters", + ) + }) + + test("fingerprints only complete, reproducible prefixes", () => { + const input = { + binding: binding(), + projectScope: "project-1", + prefix: { system: "stable", order: { b: 2, a: 1 } }, + toolDefinitions: [ + { name: "read", schema: { type: "object" } }, + { name: "write", schema: { type: "object" } }, + ], + settings: { effort: "high" }, + serializationRevision: "serializer-1", + } + const same = fingerprintPrefix({ + ...input, + prefix: { order: { a: 1, b: 2 }, system: "stable" }, + }) + const first = fingerprintPrefix(input) + const reorderedTools = fingerprintPrefix({ ...input, toolDefinitions: [...input.toolDefinitions].reverse() }) + + expect(first).toMatch(/^[a-f0-9]{64}$/) + expect(same).toBe(first) + expect(reorderedTools).not.toBe(first) + expect(fingerprintPrefix({ ...input, binding: binding({ accountScope: undefined }) })).toBeNull() + expect(fingerprintPrefix({ ...input, prefix: { type: "image", data: "opaque" } })).toBeNull() + expect(fingerprintPrefix({ ...input, binding: binding({ endpoint: "not-a-url" }) })).toBeNull() + }) + + test("keeps existing provider annotations in one reusable function", () => { + const messages: ModelMessage[] = [ + { role: "system", content: "stable" }, + { role: "user", content: "request" }, + ] + const result = applyLegacyCaching(messages, model) + + expect(result[0]?.providerOptions).toMatchObject({ anthropic: { cacheControl: { type: "ephemeral" } } }) + expect(result[1]?.providerOptions).toMatchObject({ anthropic: { cacheControl: { type: "ephemeral" } } }) + }) + + test("distinguishes observed zero from unavailable cache metrics", () => { + expect( + normalizeCacheObservation({ inputTokens: 10_000, readTokens: 8_000, writeTokens: 0, observedAtMs: 100 }), + ).toEqual({ + inputTokens: 10_000, + readTokens: 8_000, + writeTokens: 0, + hitRatio: 0.8, + observedAtMs: 100, + state: "hit_observed", + expiresAtMs: null, + }) + expect(normalizeCacheObservation({ inputTokens: 10_000, readTokens: 0, observedAtMs: 100 })).toMatchObject({ + state: "miss_observed", + hitRatio: 0, + }) + expect(normalizeCacheObservation({ inputTokens: 10_000, observedAtMs: 100 })).toMatchObject({ + state: "unknown", + readTokens: null, + hitRatio: null, + }) + expect( + normalizeCacheObservation({ inputTokens: 10, readTokens: 11, writeTokens: 12, observedAtMs: 100 }), + ).toMatchObject({ + state: "unknown", + readTokens: null, + writeTokens: null, + }) + expect(() => normalizeCacheObservation({ observedAtMs: -1 })).toThrow(RangeError) + }) + + test("allows only verified passive API bindings in smart mode", () => { + expect( + resolveCacheRequestPolicy({ + config: { mode: "smart" }, + binding: binding(), + }), + ).toMatchObject({ mode: "smart", useCacheKey: true, adapterID: "openai" }) + expect( + resolveCacheRequestPolicy({ + config: { mode: "smart" }, + binding: binding({ auth: "oauth", transport: "responses-lite" }), + }), + ).toMatchObject({ mode: "smart", useCacheKey: false }) + expect( + resolveCacheRequestPolicy({ + config: { mode: "off" }, + binding: binding(), + }), + ).toMatchObject({ mode: "off", useCacheKey: false, useLegacyAnnotations: false }) + }) +}) diff --git a/src/mendcode/packages/opencode/test/provider/transform.test.ts b/src/mendcode/packages/opencode/test/provider/transform.test.ts index 04251f6b..6f9ff36d 100644 --- a/src/mendcode/packages/opencode/test/provider/transform.test.ts +++ b/src/mendcode/packages/opencode/test/provider/transform.test.ts @@ -118,6 +118,32 @@ describe("ProviderTransform.options - setCacheKey", () => { }) expect(result.store).toBe(false) }) + + test("disables generated options and legacy annotations when cache mode is off", () => { + const policy = { + mode: "off" as const, + useCacheKey: false, + useLegacyAnnotations: false, + adapterID: "disabled", + reason: "test", + } + const options = ProviderTransform.options({ model: mockModel, sessionID, providerOptions: {}, cache: policy }) + const messages = ProviderTransform.message( + [{ role: "system", content: "stable" }], + mockModel, + options, + policy, + ) + + expect(options.promptCacheKey).toBeUndefined() + expect(messages[0]?.providerOptions).toBeUndefined() + expect( + ProviderTransform.enforceCacheOptions( + { promptCacheKey: sessionID, prompt_cache_key: sessionID, gateway: { caching: "auto" } }, + policy, + ), + ).toEqual({}) + }) }) describe("ProviderTransform.options - zai/zhipuai thinking", () => { diff --git a/src/mendcode/packages/opencode/test/session/cache-lineage.test.ts b/src/mendcode/packages/opencode/test/session/cache-lineage.test.ts new file mode 100644 index 00000000..1375d5b0 --- /dev/null +++ b/src/mendcode/packages/opencode/test/session/cache-lineage.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { fingerprintPrefix, selectCacheIdentity } from "@/session/cache-lineage" + +describe("cache lineage", () => { + test("inherits only an identical fingerprint while keeping runtime identity separate", () => { + const inherited = selectCacheIdentity({ + sessionID: "child-session", + transportAffinity: "child-affinity", + ownLineageID: "child-lineage", + parent: { lineageID: "root-lineage", cacheKey: "root-cache", fingerprint: "same" }, + fingerprint: "same", + legacyKey: "child-cache", + }) + const forked = selectCacheIdentity({ + sessionID: "child-session", + transportAffinity: "child-affinity", + ownLineageID: "child-lineage", + parent: { lineageID: "root-lineage", cacheKey: "root-cache", fingerprint: "different" }, + fingerprint: "same", + legacyKey: "child-cache", + }) + + expect(inherited).toEqual({ + runtimeSessionID: "child-session", + transportAffinity: "child-affinity", + cacheKey: "root-cache", + lineageID: "root-lineage", + compatible: true, + reason: "cache prefix fingerprint matches the parent", + }) + expect(forked.cacheKey).toBe("child-cache") + expect(forked.lineageID).toBe("child-lineage") + expect(forked.compatible).toBe(false) + }) + + test("does not create a fingerprint without account/project scope or with unknown transport", () => { + const common = { + projectScope: "project-1", + prefix: "stable", + toolDefinitions: [], + settings: {}, + serializationRevision: "serializer-1", + } + expect( + fingerprintPrefix({ + ...common, + binding: { + providerID: "openai", + modelID: "openai/gpt-6-astra", + apiModelID: "gpt-6-astra", + endpoint: "https://api.openai.com/v1/responses", + auth: "api", + transport: "responses-http", + sdk: "@ai-sdk/openai", + accountScope: "account-1", + }, + }), + ).toMatch(/^[a-f0-9]{64}$/) + expect( + fingerprintPrefix({ + ...common, + binding: { + providerID: "openai", + modelID: "openai/gpt-6-astra", + apiModelID: "gpt-6-astra", + endpoint: "https://api.openai.com/v1/responses", + auth: "unknown", + transport: "responses-http", + sdk: "@ai-sdk/openai", + accountScope: "account-1", + }, + }), + ).toBeNull() + }) +})