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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 8 additions & 14 deletions src/marsys/models/adapters/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any, Callable, Dict, List, Optional

from marsys.models.adapters.base import (
CACHE_EXEMPT_KEY,
APIProviderAdapter,
AsyncBaseAPIAdapter,
_CapturedErrorResponse,
Expand Down Expand Up @@ -86,21 +87,14 @@ def _anthropic_model_requires_adaptive_thinking(model_name: str) -> bool:

CACHE_CONTROL_EPHEMERAL = {"type": "ephemeral"}

# A caller marks a message row with this key to say "my content here changes every
# request; do not put the cache breakpoint on me". Neutral and per-item, riding the
# caller's own message dict — the `defer_loading` shape, which is this codebase's
# established way for a caller to signal request structure without a new request
# parameter. Stripped during conversion; it never reaches the wire.
# ``CACHE_EXEMPT_KEY`` — a caller marks a message row with this key to say "my content
# here changes every request; do not put the cache breakpoint on me". Neutral and
# per-item, riding the caller's own message dict — the `defer_loading` shape, which is
# this codebase's established way for a caller to signal request structure without a new
# request parameter. Stripped during conversion; it never reaches the wire.
#
# Why a caller needs this: the breakpoint's value is that the NEXT request can read
# the entry this one writes, which requires the entry's hashed prefix to consist of
# bytes the next request still contains. A row whose text is regenerated per request
# (a clock, a budget figure, anything derived from "now") is by construction absent
# from the next request, so an entry written at or after it is unreadable forever —
# each turn writes a fresh entry and reads none. Measured on Bedrock/Opus 5, single-
# step turns, tools present: marker on the volatile row → turn 2 `read=0`; marker on
# the last durable row → turn 2 `read=8425`.
CACHE_EXEMPT_KEY = "cache_exempt"
# It lives in ``base`` now that both adapter families honour it, and is re-exported here
# because this module is where it was first defined and where callers import it from.


def mark_conversation_tail_for_cache(
Expand Down
24 changes: 17 additions & 7 deletions src/marsys/models/adapters/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,23 @@
Two behaviours of this endpoint that are inherited rather than worked around, recorded
because both are silent until they are not:

* **Prompt caching is implicit and takes no request parameter.** ``prompt_cache_options``
accepts ``implicit`` (the default) and ``explicit``; a ``prompt_cache_breakpoint``
field is rejected as an unknown parameter in every position, and ``explicit`` mode
without one caches nothing. So the correct request is one that says nothing about
caching, and a measured pair of identical large calls reports
``cache_write_tokens: 3395`` then ``cached_tokens: 3395`` with no parameter sent.
The inherited harmonizer reads both figures.
* **Prompt cache breakpoints ride inside a content block, and the inherited builder
places them.** ``prompt_cache_breakpoint`` belongs on an ``input_text``,
``input_image`` or ``input_file`` block — including the content parts of a
``function_call_output`` — never at item level, never at request level, and never on
the top-level ``instructions`` field. An earlier reading of this surface recorded the
field as rejected in every position and concluded that the correct request says
nothing about caching; twenty live requests on ``gpt-5.6-terra`` refuted that, all
200, with the field inside content blocks. The limits that do bind: a request creates
at most four new cache writes (in explicit mode, its latest four breakpoints, and a
breakpoint covers everything before it), reads consider at most the latest fifty
breakpoints, the cacheable prefix is at least 1,024 tokens, breakpoints are served on
Standard pay-as-you-go deployments and silently not on PTU-M, and models before the
GPT-5.6 family answer either field with a 400 — which is why the inherited builder
gates both on the model name. ``explicit`` mode with no breakpoint is the documented
way to turn caching off and measures as exactly that: nothing written, nothing read,
the whole prompt at plain input price. The inherited harmonizer reads back both the
cached and the written figures.
* **Reasoning-capable deployments reject ``temperature``.** The inherited capability
check is a regex over the model name, which the real deployment names
(``gpt-5.6-*``) satisfy. A deployment renamed to something not starting ``gpt-5``+
Expand Down
24 changes: 24 additions & 0 deletions src/marsys/models/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,30 @@
logger = logging.getLogger(__name__)


# A caller's per-request row: content this request renders fresh (a clock, a running
# budget figure, anything derived from "now"), which the NEXT request will not contain
# verbatim. Set it on a message dict and every payload builder that places prompt-cache
# breakpoints will keep its markers off that row.
#
# Why a caller needs to say this and why it is the ONLY thing a caller says about
# caching: a breakpoint is worth something because the next request can read the entry
# this one writes, which requires the entry's hashed prefix to be bytes the next request
# still sends. An entry written at or after a regenerated row is unreadable forever —
# every request writes a fresh entry and reads none. Which rows may carry a marker,
# which block types accept one, and whether a marker may move are provider facts the
# payload builder knows and the caller does not, so placement stays adapter-side and
# this flag stays the whole interface. Measured on Bedrock/Opus 5, single-step turns,
# tools present: marker on the volatile row → turn 2 `read=0`; marker on the last
# durable row → turn 2 `read=8425`. Measured on Azure/gpt-5.6-terra, eight-round turns:
# markers left on every durable row → request 3 reads 12,109 of 13,717; the same single
# marker moved forward each request → reads 0 on every request.
#
# Family-neutral by residence: the Anthropic family reads it to find the last durable
# row, the OpenAI family reads it to skip a row entirely. Re-exported from
# ``marsys.models.adapters.anthropic``, where it was first defined.
CACHE_EXEMPT_KEY = "cache_exempt"


# Hardcoded fallbacks used when no ErrorHandlingConfig is provided. Match the
# pre-Phase-1 behaviour exactly so existing tests and ad-hoc adapter usage
# (without an ExecutionConfig) keep working.
Expand Down
12 changes: 10 additions & 2 deletions src/marsys/models/adapters/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,17 @@ def create_adapter(

# OAuth providers don't use api_key/base_url - they load credentials from CLI
if provider in ("openai-oauth", "anthropic-oauth"):
return adapter_class(model_name, **kwargs)
adapter = adapter_class(model_name, **kwargs)
else:
adapter = adapter_class(model_name, api_key, base_url, **kwargs)

return adapter_class(model_name, api_key, base_url, **kwargs)
# The requested provider, stamped here because this is the only layer that
# knows it: several providers share one adapter class, and the fallback above
# hands every unrecognized provider to the OpenAI class outright, so a class
# name is not evidence of which endpoint a request is bound for. Adapters that
# gate an endpoint-specific request field on provider identity read this.
adapter.provider = provider
return adapter


class LocalAdapterFactory:
Expand Down
Loading
Loading