@@ -19,6 +19,14 @@ const translate = require('./translate');
1919/** Trim a trailing slash so `${base}/chat/completions` is always well-formed. */
2020function baseUrl ( opts ) { return String ( opts . baseURL || '' ) . replace ( / \/ + $ / , '' ) ; }
2121
22+ /**
23+ * [LevelCode] Is this an Anthropic/Claude model reached through an OpenAI-shaped endpoint (OpenRouter
24+ * `anthropic/claude-…`, the LevelCode Cloud gateway, or a bare `claude-…`)? Only these honor an explicit
25+ * cache_control breakpoint; every other provider auto-caches and would ignore or reject the field, so we
26+ * gate the prefix-caching write on this. Cache-hit READ accounting (cached_tokens) is done regardless.
27+ */
28+ function isAnthropicFamily ( model ) { return / (?: ^ | \/ ) c l a u d e | (?: ^ | \/ ) a n t h r o p i c \/ / i. test ( String ( model || '' ) ) ; }
29+
2230/** Auth + content-type + any per-provider headers (e.g. OpenRouter's HTTP-Referer / X-Title). */
2331function authHeaders ( opts ) {
2432 const h = Object . assign ( { 'content-type' : 'application/json' } , opts . headers || { } ) ;
@@ -154,7 +162,10 @@ async function listOpenAIModels(opts) {
154162 */
155163async function streamOpenAIAgentTurn ( opts ) {
156164 const label = opts . label || 'OpenAI-compatible' ;
157- const messages = translate . toOpenAIMessages ( opts . system , opts . messages ) ;
165+ // [LevelCode] Prompt caching. Only Claude-family upstreams honor cache_control (via OpenRouter / the
166+ // LevelCode Cloud gateway); GPT/Gemini/DeepSeek/etc. auto-cache server-side and would ignore or reject
167+ // the field, so we gate it strictly on the model id. cached_tokens is still read for ALL providers below.
168+ const messages = translate . toOpenAIMessages ( opts . system , opts . messages , { cache : isAnthropicFamily ( opts . model ) } ) ;
158169 const tools = translate . toOpenAITools ( opts . tools ) ;
159170 const body = buildChatBody ( { model : opts . model , messages, maxTokens : opts . maxTokens , stream : true } ) ;
160171 if ( tools ) { body . tools = tools ; }
@@ -186,7 +197,14 @@ async function streamOpenAIAgentTurn(opts) {
186197 try { ev = JSON . parse ( data ) ; } catch { return ; }
187198 if ( ev . error ) { const _e = new Error ( ( ev . error && ev . error . message ) ? ev . error . message : ( label + ' stream error' ) ) ; if ( ev . error && ev . error . code ) { _e . code = ev . error . code ; } throw _e ; }
188199 if ( ev . usage ) { // opportunistic — only some providers include usage in the stream
189- if ( ev . usage . prompt_tokens ) { usage . input_tokens = ev . usage . prompt_tokens ; }
200+ // [LevelCode] Surface prompt caching. OpenAI-shaped providers (OpenAI auto-cache, Claude/Gemini
201+ // via OpenRouter, the LevelCode Cloud gateway) report cache hits under prompt_tokens_details.
202+ // cached_tokens, and it is INCLUDED in prompt_tokens — so split it out (fresh = prompt - cached)
203+ // to mirror Anthropic's disjoint fields and keep the context meter's input+cache_read total exact.
204+ const det = ev . usage . prompt_tokens_details || { } ;
205+ const cached = det . cached_tokens || 0 ;
206+ if ( ev . usage . prompt_tokens ) { usage . input_tokens = Math . max ( 0 , ev . usage . prompt_tokens - cached ) ; }
207+ if ( cached ) { usage . cache_read_input_tokens = cached ; }
190208 if ( ev . usage . completion_tokens ) { usage . output_tokens = ev . usage . completion_tokens ; }
191209 }
192210 const c = ev . choices && ev . choices [ 0 ] ;
@@ -203,4 +221,4 @@ async function streamOpenAIAgentTurn(opts) {
203221 return { content, stop_reason : stopReason , usage, malformed } ;
204222}
205223
206- module . exports = { streamOpenAI, completeOpenAI, listOpenAIModels, streamOpenAIAgentTurn, buildChatBody, deltaFromEvent, isReasoningModel } ;
224+ module . exports = { streamOpenAI, completeOpenAI, listOpenAIModels, streamOpenAIAgentTurn, buildChatBody, deltaFromEvent, isReasoningModel, isAnthropicFamily } ;
0 commit comments