Skip to content

feat(openrouter): built-in caching, session affinity and model limits for OpenRouter entries - #7284

Open
ngvoicu wants to merge 3 commits into
router-for-me:devfrom
ngvoicu:feat/openrouter-provider
Open

ngvoicu wants to merge 3 commits into
router-for-me:devfrom
ngvoicu:feat/openrouter-provider

Conversation

@ngvoicu

@ngvoicu ngvoicu commented Nov 5, 2026 •

Copy link
Copy Markdown

Problem

OpenRouter is usually configured in CPA as an OpenAI-compatible entry. Three things go wrong:

  1. Claude never caches. OpenRouter caches Claude (and other explicit-cache models) only when the request marks what to cache. The Claude → OpenAI translation drops a client's cache_control, and Codex/OpenAI-format clients never send it, so every request re-bills the whole prompt.
  2. No session affinity. Requests of one conversation can land on different upstream providers behind OpenRouter, which loses their cache.
  3. No limits. Models are listed by hand without context or output limits, so clients get the Codex template's defaults (500k, no max_tokens).
  4. Wrong reasoning levels and owner. Every OpenAI-compatible model gets the generic levels low/medium/high and owned_by = the entry's name. OpenRouter accepts xhigh and max too, but a request asking for max was sent upstream as high. Aliases that share an id with an OpenRouter model (e.g. one model served by a Claude key, Devin and OpenRouter) list only low/medium/high in the Codex client list, which intersects the members' levels, and report owned_by: "OpenRouter".

Change

OpenAI-compatible entries whose base URL is OpenRouter now get built-in OpenRouter behaviour. There's nothing to configure: detection uses the base URL, as CPA already does for reasoning signatures (isOpenRouterUpstreamURL, now util.IsOpenRouterURL).

  • Cache breakpoints (internal/runtime/executor/helps/openrouter.go):

    • chat-completions requests without cache_control get Anthropic-style breakpoints on the last system message and the latest user or tool message, mirroring CPA's Claude breakpoints;
    • a Claude-format client's ttl: "1h" is kept;
    • requests that already carry markers (on a message part, a message, tools[i] or tools[i].function) are left alone;
    • OpenRouter ignores the markers for models that cache automatically.
  • Session affinity: an X-Session-Id header (OpenRouter's session id). Order: a client-supplied X-Session-Id, then CPA's provider session (execution or derived), then a stable id from the canonical session, which is all there is for clients that name their own session (e.g. pi).

  • Model limits (registry/openrouter_models.go):

    • OpenRouter's public /api/v1/models fills ContextLength and MaxCompletionTokens for configured models;
    • it's fetched in the background on first use, so registration never blocks, refreshed with ModelsRefreshInterval, and backs off for a minute after failures;
    • it follows the shared catalog policy: no fetch under --local-model or CLIProxyAPIHome, and the fetch runs under the service lifetime without a request deadline;
    • when the catalog changes, the OpenRouter entries' models are registered again through the existing refresh callback;
    • a configured max-context-length still wins.
  • Reasoning levels (second commit). The same catalog's reasoning.supported_efforts (e.g. ["max","xhigh","high","medium","low","none"]) becomes the model's thinking support: levels sorted low to high, and "none" means thinking can be turned off (ZeroAllowed). It's applied in two places:

    • the model list (buildOpenAICompatibilityConfigModels);
    • request-time capabilities (attachResolvedAPIKeyModelInfo, via withOpenRouterThinking). These are read from the catalog per request, so they hold as soon as it has loaded, without rebuilding the routing snapshot.

    Configured thinking still wins (now flagged with ExplicitThinking). Models OpenRouter doesn't list keep low/medium/high.

  • Owner: owned_by is the vendor of the OpenRouter id (anthropic/claude-haiku-5.5 → anthropic, ~openai/… → openai), as CPA already does for Vertex publisher/model names.

Other OpenAI-compatible providers are unchanged.

Verification

  • Unit tests: breakpoint placement and no-op cases; executor end to end through an injected transport (OpenRouter URL → two 1h breakpoints and a stable x-session-id; another URL → body and headers unchanged); catalog parsing, background fetch with refresh notification and no refetch while fresh, failure back-off; util.IsOpenRouterURL.

  • Live, real OpenRouter key, no config flags:

    Check Result
    /v1/models?client_version=pi anthropic/claude-haiku-5.5 1,000,000 / 128,000; openai/gpt-6.1-sol 1,050,000 / 128,000
    Claude-format, 2 identical requests (~12.6k tokens) cache_creation_input_tokens: 12620, then cache_read_input_tokens: 12620
    Responses-format, 2 identical requests cached_tokens: 0, then cached_tokens: 12620

    Before this change, the same Claude requests through the entry billed 12,624 input tokens every time.

  • Live, reasoning levels (second commit, anthropic/claude-haiku-5.5 through the entry, request log of the upstream body):

    Before After
    Codex client list levels low, medium, high low, medium, high, xhigh, max
    owned_by OpenRouter anthropic
    Claude-format output_config.effort: "max" → upstream reasoning_effort: "high" reasoning_effort: "max"
    Responses reasoning.effort: "max" → upstream "high" "max"
    Chat reasoning_effort: "max" → upstream "high" "max"

    OpenRouter honours it: the same question used 48–50 reasoning tokens at low and 229–282 at max.

  • Unit tests (third commit, review fixes): session id from a client header, execution, derived and canonical sessions (stable per conversation, distinct across conversations); tool markers leave the request alone; catalog back-off and refresh with a controllable clock and a refresh hook (no sleeps); no fetch under --local-model; executor end to end with a canonical-only session.

  • Unit tests (second commit): efforts parsing and copy-on-lookup; efforts → levels (order, none, unknown efforts, nothing published); model list (levels, owner, configured thinking wins, unlisted model, non-OpenRouter entry unchanged); request-time capabilities (levels, thinking suffix, configured thinking wins, unlisted model, stored capability unchanged, non-OpenRouter entry unchanged).

  • go build -o test-output ./cmd/server; gofmt clean on changed files. Executor, registry, config, translator and SDK tests are green, apart from the pre-existing internal/util TestInPlaceByteWritesAreReviewed failure on dev (internal/kvstore/bolt_store.go).

Supersedes #7283 (same breakpoints behind a config flag).

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-11-06T13:10:47.968917Z
We couldn’t complete this review. Comment @codex review to try again.
8143329 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…s for OpenRouter entries

OpenAI-compatible entries pointing at OpenRouter now behave like a built-in
OpenRouter provider, with nothing to configure (detected by the base URL,
as CPA already does for reasoning signatures):

- Cache breakpoints: chat-completions requests without cache_control get
  Anthropic-style breakpoints on the last system message and the latest
  user/tool message. OpenRouter caches Claude only when asked and ignores
  the markers for models that cache automatically; a Claude client's
  ttl "1h" is kept.
- Session affinity: x-session-id from CPA's session, so a conversation
  stays on one upstream provider and keeps hitting its cache.
- Model limits: OpenRouter's public catalog (/api/v1/models) fills context
  length and completion limit for configured models, fetched in the
  background and refreshed with the other catalogs; configured values win.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 041fb38b54

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// covers the whole conversation prefix. Requests that already carry cache_control are left alone. A Claude-format
// client asking for the 1-hour lifetime keeps it.
func applyOpenAICompatCacheControlDoc(compat *config.OpenAICompatibility, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) {
if doc == nil || compat == nil || !compat.SupportCacheControl {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Infer cache control from the OpenRouter URL

For an existing OpenRouter entry that only has the documented base URL, SupportCacheControl is zero-valued false and this guard returns before adding any breakpoints. The existing isOpenRouterUpstreamURL detector (internal/runtime/executor/openai_responses_signature.go:228-238) is used only for signature sanitation and is not consulted at either new call site, so the advertised no-configuration behavior does nothing unless operators discover and enable the new flag. Pass the base-URL detection into this path or default it for OpenRouter while leaving other providers unchanged.

Useful? React with 👍 / 👎.

if err = e.applyPromptCacheKeyWithCompatDoc(ctx, auth, compat, from, baseModel, req, opts, doc); err != nil {
return resp, err
}
applyOpenAICompatCacheControlDoc(compat, from, req, opts, doc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the promised OpenRouter session header

For an OpenRouter request whose downstream client did not supply x-session-id, both new execution paths proceed to construct httpReq without deriving helps.ProviderSessionUUID or setting that header; a target-tree search of the OpenAI-compatible executor found no outbound forwarding elsewhere. Repeated turns can therefore still land on different OpenRouter backends and miss the cache. Set a stable header for detected OpenRouter requests while preserving a client-supplied value.

Useful? React with 👍 / 👎.

Comment on lines +78 to +82
part, err := ojson.SetBytes([]byte(`{"type":"text","text":""}`), "text", text)
if err != nil {
return
}
part, err = ojson.SetRawBytes(part, "cache_control", []byte(cacheControl))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route temporary JSON writes through ojson.Doc

When converting string content, these calls perform JSON writes against raw []byte values before attaching them to doc. That bypasses the required single-*ojson.Doc mutation pipeline; initialize and mutate the replacement content directly through the destination document so serialization still happens once at the final request exit.

AGENTS.md reference: AGENTS.md:L53-L53

Useful? React with 👍 / 👎.

// Placement follows CPA's own Claude breakpoints: the last system message and the latest user or tool message, which
// covers the whole conversation prefix. Requests that already carry cache_control are left alone. A Claude-format
// client asking for the 1-hour lifetime keeps it.
func applyOpenAICompatCacheControlDoc(compat *config.OpenAICompatibility, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move cache-control helpers into executor/helps

The newly added production file consists entirely of free cache-control support functions, but it is placed directly in internal/runtime/executor, which is reserved for executors and unit tests. Move this support implementation under internal/runtime/executor/helps and expose the small API needed by OpenAICompatExecutor.

AGENTS.md reference: AGENTS.md:L52-L52

Useful? React with 👍 / 👎.

Comment thread config.example.yaml Outdated
# prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials
# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
# support-prompt-cache-key: false # optional: derive prompt_cache_key for requests from all input protocols
# support-cache-control: false # optional: add Anthropic-style cache_control breakpoints to chat-completions requests (e.g. OpenRouter caches Claude only when asked); keeps a Claude client's ttl "1h"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Populate OpenRouter model limits

For a configured OpenRouter model without a manual max-context-length, model registration still goes through buildOpenAICompatibilityConfigModels, whose buildConfiguredModelInfo path (sdk/cliproxy/service_models.go:844-848) only copies a configured context length and never sets MaxCompletionTokens. A target-tree path/content search also finds no registry/openrouter_models.go, catalog fetch, or refresh hook, so clients continue receiving generic fallback limits rather than OpenRouter's values. Add the advertised catalog/updater and merge its limits during registration.

Useful? React with 👍 / 👎.

Comment on lines +49 to +50
func openAIMessagesHaveCacheControl(messages ojson.Result) bool {
for _, msg := range messages.Array() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat tool cache markers as explicit breakpoints

When an opted-in OpenAI-format request already puts cache_control on tools[i] or tools[i].function (a shape this repository explicitly preserves in ConvertOpenAIRequestToClaude), this check scans only messages, so the helper adds two synthetic message markers instead of leaving the request alone. That changes the caller's explicit cache layout and can push a valid request over the provider's breakpoint limit; scan the supported tool locations before injecting.

Useful? React with 👍 / 👎.

…list

OpenAI-compatible entries get the generic thinking levels low/medium/high and
owned_by = the entry's name. For OpenRouter that hid xhigh and max, which
OpenRouter accepts (reasoning.supported_efforts in /api/v1/models), and a
request asking for max was sent upstream as high. Pools sharing a model id
with an OpenRouter member also listed only low/medium/high (the Codex client
list intersects members' levels), and their owned_by read "OpenRouter".

For entries pointing at OpenRouter, and models without configured thinking:
- the model list uses OpenRouter's published efforts as levels ("none" means
  thinking can be turned off), sorted low to high;
- request-time capabilities apply the same levels, read from the catalog per
  request, so they hold as soon as the catalog has loaded;
- owned_by is the vendor of the OpenRouter id (anthropic/... -> anthropic,
  ~openai/... -> openai), as for Vertex publisher/model names.

Configured thinking still wins; models OpenRouter doesn't list keep the
default. The catalog type becomes OpenRouterModel (limits + efforts).
@ngvoicu

ngvoicu commented Nov 6, 2026

Copy link
Copy Markdown
Author

Added a second commit (0d37de3): OpenRouter entries now take their reasoning levels from OpenRouter's model list (reasoning.supported_efforts), in the model list and at request time, and report the model's vendor as owned_by. Before it, max was sent to OpenRouter as high. Details and before/after in the description.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d37de3ad2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

func applyOpenRouterSessionHeader(r *http.Request, baseURL, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) {
if r == nil || !isOpenRouterUpstreamURL(baseURL) || strings.TrimSpace(r.Header.Get("x-session-id")) != "" {
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve explicit sessions in the OpenRouter header

With a normal OpenRouter entry, a downstream x-session-id (or another explicit session header) exists in opts.Headers, not in the freshly constructed httpReq, and ApplyCustomHeadersFromAttrs does not copy arbitrary client headers. session.Enrich also replaces derived-session metadata with canonical-session metadata for such requests, which ProviderSessionUUID does not read, so this guard and the following call leave the outbound header unset instead of preserving it. The fresh evidence beyond the earlier session-header finding is that the completed helper checks only r.Header and derived/execution metadata; consult opts.Headers or canonical metadata here.

Useful? React with 👍 / 👎.

Comment thread internal/registry/openrouter_models.go Outdated
retryAllowed := c.failedAt.IsZero() || now.Sub(c.failedAt) > time.Minute
if stale && !c.fetching && retryAllowed {
c.fetching = true
go c.refresh()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor --local-model before starting the catalog fetch

When --local-model is set and an OpenRouter-compatible entry is registered, the first lookup still reaches this unconditional goroutine and contacts the hard-coded OpenRouter URL; SetLocalModelCatalogs only configures the existing catalog updaters, and this standalone catalog never consults that policy. This defeats the flag's remote-update opt-out in offline or network-restricted deployments, so gate this fetch through the shared catalog policy.

AGENTS.md reference: AGENTS.md:L32-L32

Useful? React with 👍 / 👎.

Comment thread internal/registry/openrouter_models.go Outdated
}

func (c *openRouterCatalog) refresh() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the catalog's post-connect deadline

On a slow proxy or link where /api/v1/models connects but the response body takes more than 30 seconds, this request-scoped deadline remains active through io.ReadAll, cancels the transfer, and leaves every configured model on generic fallback metadata. This catalog lookup is not credential acquisition, so use lifecycle cancellation without a deadline that can terminate established network I/O.

AGENTS.md reference: AGENTS.md:L61-L61

Useful? React with 👍 / 👎.

}}
catalog.lookup("openrouter", "x")
<-calls
time.Sleep(50 * time.Millisecond)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Synchronize the backoff test without wall-clock sleeps

Under scheduler delay, receiving <-calls only proves that the fetch function sent on the channel; the refresh goroutine may not yet have returned and recorded failedAt. This fixed sleep does not establish that state, so the second lookup can be suppressed merely by fetching == true and the test can pass without exercising the one-minute backoff; synchronize refresh completion and use a controllable clock instead.

AGENTS.md reference: AGENTS.md:L62-L62

Useful? React with 👍 / 👎.

…og policy, helpers in helps

- Session affinity: clients that name their own session (explicit headers or
  body ids) have only a canonical session, so no X-Session-Id was sent. Now a
  client X-Session-Id is forwarded, then CPA's provider session, then a stable
  id from the canonical session.
- The OpenRouter catalog follows the shared catalog policy: no fetch under
  --local-model or CLIProxyAPIHome, and the fetch runs under the service
  lifetime without a request deadline.
- Cache breakpoints are not added when the caller already marks a tool
  (tools[i].cache_control or tools[i].function.cache_control).
- String content becomes a text part through *ojson.Doc writes only.
- The cache-control and session helpers move to internal/runtime/executor/helps
  (ApplyOpenRouterCacheControlDoc, ClaudeRequestAsksOneHourCache,
  ApplyOpenRouterSessionHeader).
- Catalog tests use a controllable clock and a refresh hook instead of sleeps.
@ngvoicu

ngvoicu commented Nov 6, 2026

Copy link
Copy Markdown
Author

Pushed 8143329 for the review findings:

Finding Status
Infer cache control from the OpenRouter URL already in cda8deb: detection by base URL, no flag
Add the promised OpenRouter session header already in cda8deb
Populate OpenRouter model limits already in cda8deb (registry/openrouter_models.go)
Preserve explicit sessions in the OpenRouter header fixed: a client X-Session-Id is forwarded; clients that name their own session now get a stable id from the canonical session (before, nothing was sent)
Honor --local-model before starting the catalog fetch fixed: shared catalog policy, also off under CLIProxyAPIHome
Remove the catalog's post-connect deadline fixed: runs under the service lifetime, no deadline
Treat tool cache markers as explicit breakpoints fixed: tools[i].cache_control and tools[i].function.cache_control count
Route temporary JSON writes through ojson.Doc fixed
Move cache-control helpers into executor/helps fixed: helps/openrouter.go
Synchronize the backoff test without wall-clock sleeps fixed: controllable clock and a refresh hook

ngvoicu added a commit to ngvoicu/cache-ttl-probe that referenced this pull request Nov 6, 2026
…ort (P1, P2)

Claude Haiku 5.5 via an openai-compatibility OpenRouter entry on CPA 8.0.53 +
router-for-me/CLIProxyAPI#7284: 5 - 7.5 min by default (was no cache), 30 - 65 min
with ttl 1h. P3 (GPT-6.1 Sol, prompt_cache_options.ttl 12h) shows the 12h request is
dropped in CPA's Responses -> Chat Completions translation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant