A message uses origin :code_key and cache_boundary: true. Where does the boundary apply?
PR #24063: message templates, code-defined prompts, OpenAI rendering, and cache accounting
PR #24063 makes Jacob's message-style prompt templates executable and uses their structure to place explicit OpenAI prompt-cache boundaries. The core change is that a breakpoint is metadata attached to an ordered prompt section. It is not a marker hidden inside prompt text.
cache_boundary flag then marks the end of that whole resolved message. Top-level code-defined templates still return one plain string, so they do not get explicit section breakpoints.| Component | Responsibility | Key contract |
|---|---|---|
TemplateMessage | Stores role, origin, source key or purpose, and cache-boundary choice. | The boundary may follow system or user content, never assistant content. |
CodeDefinedPrompts | Resolves code_key or code_auto to text. | It returns text. It does not know about provider wire formats. |
LLM.Input | Preserves messages and ordered text sections until provider selection. | Flattening must reproduce the ordinary prompt with no cache metadata. |
PromptCacheBreakpoints | Checks OpenAI GPT-5.6 eligibility. | Unsupported routes stay native and receive flattened message text. |
OpenAIV2 | Formats the final Responses API request. | A marked section becomes an input_text block with prompt_cache_breakpoint. |
Top-level prompt templates supported field, code_key, and code_auto body sources. All three eventually became one string. Jacob's branch added ordered messages with roles and origins, then temporarily rejected new message templates until LLM could consume them. The older caching PR used a separate binary CacheableInput made of tagged segments. It proved the provider path, but no production caller built that input.
field | code_key | code_autoinstructions: textThe new design reuses the message model as the seam. Prompt authors choose where the reusable prefix ends, and LLM carries that choice without parsing text.
| Template shape | Result passed toward LLM | Explicit breakpoint? |
|---|---|---|
Top-level body_source: :code_key or :code_auto | One resolved string | No. It stays on the existing text/instructions path. |
Message with origin: :code_key or :code_auto | One message containing one resolved text section | Yes, when that message has cache_boundary: true. |
| Breakpoint inside the middle of one code-defined file | Not represented by the current template model | No. Split the stable parts into separate code-defined message entries if needed. |
Think of cache_boundary as punctuation outside the text: “the reusable prefix ends after this section.” The text can come from an admin field or a compiled code-defined prompt. Its origin does not change the cache contract.
messages = [
%{role: :system,
origin: :code_key,
code_prompt_key: "aia_chat/default",
cache_boundary: true},
%{role: :user,
origin: :field,
content: "Task: {{task}}",
cache_boundary: false}
]aia_chat/default → prompt textVariable replacement changes section text but leaves the boundary flag alone. Agent conversation messages are appended after the template messages, so the reusable prefix remains at the front and request-specific turns remain in the suffix.
Section{
text: "stable instructions",
cache_boundary: true
}
Section{
text: "dynamic task",
cache_boundary: false
}%{
type: "input_text",
text: "stable instructions",
prompt_cache_breakpoint: %{mode: "explicit"}
}
%{type: "input_text", text: "dynamic task"}"stable instructionsdynamic task". No marker text leaks into the prompt.Keeping cache metadata outside prompt files avoids provider syntax in business-owned text. It also avoids parsing sentinels and makes the fallback exact. The trade-off is granularity: one code-defined message is one section today. A boundary can follow the whole resolved prompt, not an arbitrary line inside it.
api/lib/jump/templates/template_message.ex:12-66 defines message roles, origins, source fields, and cache_boundary. It validates code keys and rejects boundaries after assistant messages.
field :origin, Ecto.Enum, values: [:field, :code_key, :code_auto]
field :cache_boundary, :boolean, default: false
if role == :assistant and cache_boundary do
add_error(changeset, :cache_boundary, ...)
endThe admin switch in api/lib/jump_web/live/admin/template_live.ex:958-967 labels this choice “Cache prefix ends here.” The UI does not ask authors to edit prompt text.
api/lib/jump/prompts/prompt.ex:274-296 walks message templates. Field-origin messages keep their stored content. Code-key and code-auto messages call CodeDefinedPrompts.resolve_body/2, then put the resolved text back into the message.
defp resolve_template_message(%TemplateMessage{origin: :field} = message, _user),
do: message
defp resolve_template_message(%TemplateMessage{} = message, user) do
source = %{
body_source: message.origin,
code_prompt_key: message.code_prompt_key,
purpose: message.purpose
}
%{message | content: CodeDefinedPrompts.resolve_body(source, user)}
endcode_auto still selects feature-flag variants before caching. The selected text becomes the section text, so a variant change changes the cache prefix naturally.api/lib/jump/prompts/prompt.ex:247-269 returns LLM.Input for message templates and text for every other template source. Each current template message becomes one Input.Message with one Input.Section.
defp template_message_to_input(%TemplateMessage{} = message) do
section = Section.new!(message.content,
cache_boundary: message.cache_boundary
)
InputMessage.new!(message.role, [section])
endThis is the exact answer to Jacob's question: code-defined text is not scanned for breakpoints. The enclosing message contributes the boundary metadata.
api/lib/jump/agent/llm/prompt_provider.ex:162-169 maps variable replacement across section text. LLM.Input.map_text/2 changes only Section.text, so role and boundary metadata survive.
case Prompt.get_prompt_input(prompt) do
%Input{} = input ->
Input.map_text(input, &Jump.Prompts.replace_variables(&1, variable_sets))
text ->
Jump.Prompts.replace_variables(text, variable_sets)
endapi/lib/jump/agent/llm/llm_execution.ex:297-305 preserves the old path for strings. Structured input appends the session conversation and adds prompt_cache: :optimize only when a boundary exists.
defp build_llm_input(%Input{} = input, llm_messages) do
input = Input.append_messages(input, llm_messages)
prompt_opts =
if Input.cache_boundaries?(Input.semantic_messages(input)),
do: [prompt_cache: :optimize],
else: []
{input, prompt_opts}
endapi/lib/jump/integrations/llm/prompt_cache_decision.ex:83-121 detects structured boundaries. api/lib/jump/integrations/openai_v2/prompt_cache_breakpoints.ex:20-49 allows explicit rendering only for direct OpenAIV2 requests on the verified GPT-5.6 family.
| Route | Request shape |
|---|---|
| Direct OpenAI GPT-5.6 + boundary | Render explicit content-block markers. |
| Unsupported model or provider | Flatten sections to ordinary message text. |
| No boundary + structured-output schema | Keep the existing schema-tail optimization when eligible. |
| Prompt caching disabled | Use native messages with no explicit marker. |
api/lib/jump/integrations/llm/input.ex:101-129 owns plain and OpenAI section rendering. api/lib/jump/integrations/llm/execution.ex:210-241 chooses the render path after the provider decision. The end-to-end assertion in api/lib/jump/integrations/llm/prompt_cache_execution_test.exs:20-81 checks the exact HTTP body.
api/lib/jump/integrations/llm/usage_normalizer.ex:26-54 separates uncached input, cached reads, and GPT-5.6 cache writes. The follow-up commit also teaches api/lib/jump/open_inference/formatter.ex:645-661 to export nested cache_write_tokens.
uncached = input_tokens - cached_tokens - cache_write_tokens
%Usage{
input_tokens: uncached,
cached_input_tokens: cached_tokens,
cache_creation_tokens: cache_write_tokens
}| Question | Current answer |
|---|---|
| Do code-defined prompts still take text breakpoints? | No. There are no inline sentinels or tagged text segments in prompt files. |
| Can a code-defined prompt be the stable cached prefix? | Yes, when used as a message origin and that message has cache_boundary: true. |
| Can the boundary sit halfway through one code-defined prompt? | Not today. One message origin resolves to one section. |
| What happens on another provider? | The section text is flattened and sent without cache metadata. |
| What happens to top-level code-defined templates? | They keep the existing plain-string behavior and no explicit boundary. |
The changed-area suite passed 365 tests before the telemetry follow-up; 112 focused telemetry and caching tests passed afterward. Formatter diagnostics and diff checks are clean. The current PR uses mocked HTTP for its OpenAI wire assertion, so a live GPT-5.6 cache write/read remains the main unverified integration point.
A message uses origin :code_key and cache_boundary: true. Where does the boundary apply?
What happens to a top-level template whose body_source is :code_auto?
Why does LLM.Input keep cache boundaries outside the prompt text?
An Anthropic or unsupported OpenAI-model request receives structured input with a boundary. What is sent?
What is the current way to place a boundary midway through one large code-defined prompt?